From ef7af9988e999247ac85cdd26b0af27a005d7484 Mon Sep 17 00:00:00 2001 From: chenmingxuan Date: Fri, 16 Oct 2026 10:20:11 +0800 Subject: [PATCH] feat(plugins): add source-aware evidence and recording timelines --- .env.example | 3 + apps/admin_console/routers/media.py | 68 +++ .../services/evidence_service.py | 151 ++++++ .../services/model_settings_service.py | 10 + apps/admin_console/services/report_export.py | 71 ++- apps/admin_console/services/task_import.py | 9 +- .../services/task_queue_service.py | 16 + .../agent-stream/agent-stream.component.html | 33 +- .../agent-stream/agent-stream.component.scss | 13 + .../agent-stream/agent-stream.component.ts | 26 +- .../floating-video-player.component.html | 18 + .../floating-video-player.component.scss | 39 ++ .../floating-video-player.component.spec.ts | 136 ++++- .../floating-video-player.component.ts | 139 +++++- .../capability-settings.component.ts | 26 +- .../src/app/core/models/stream.model.ts | 2 + .../src/app/services/agent.service.spec.ts | 94 ++++ .../src/app/services/agent.service.ts | 49 +- .../app/utils/action-formatter.util.spec.ts | 96 ++++ .../src/app/utils/action-formatter.util.ts | 65 ++- .../app/utils/plugin-tool-view.util.spec.ts | 71 +++ .../src/app/utils/plugin-tool-view.util.ts | 58 +++ .../src/app/utils/recording-timeline.util.ts | 13 + .../src/app/utils/video-step-markers.util.ts | 8 +- artemis/agents/flash/runner.py | 24 +- artemis/agents/validator/execution_loop.py | 13 +- artemis/config/agent.py | 24 + artemis/config/settings.py | 3 + artemis/controllers/unified_controller.py | 80 ++- artemis/data_engine/engine.py | 2 + artemis/data_engine/storage.py | 5 + artemis/plugins/evidence.py | 146 ++++++ artemis/plugins/models.py | 62 ++- artemis/plugins/recording.py | 464 ++++++++++++++++++ artemis/plugins/recording_process.py | 86 ++++ artemis/plugins/runtime.py | 181 ++++++- artemis/resources/config/artemis.jsonc | 2 + artemis/sdk/agent.py | 1 - artemis/utils/video.py | 121 ++++- config/artemis.jsonc | 2 + docs/ai-automation.md | 6 + docs/plans/2.0.0-plugin-evidence.md | 32 +- docs/plugin-development.md | 89 ++++ docs/plugins.md | 6 + examples/plugins/evidence-demo/SKILL.md | 10 + examples/plugins/evidence-demo/plugin.json | 24 + examples/plugins/evidence-demo/server.py | 64 +++ mcp_server/tools/automation.py | 1 + mcp_server/tools/task_runner.py | 10 +- scripts/check_plugin.py | 47 ++ skills/artemis-automation/SKILL.md | 4 + tests/unit/admin_console/test_automation.py | 3 +- tests/unit/admin_console/test_plugin_media.py | 82 ++++ tests/unit/core/test_platform.py | 6 +- tests/unit/test_plugin_evidence.py | 396 +++++++++++++++ tests/unit/test_plugin_recording.py | 391 +++++++++++++++ tests/unit/test_plugins.py | 1 + tests/unit/test_recording_config.py | 79 +++ tests/unit/test_recording_process.py | 42 ++ tests/unit/test_unified_controller_video.py | 26 + tests/unit/utils/test_video_validation.py | 206 ++++++++ 61 files changed, 3847 insertions(+), 108 deletions(-) create mode 100644 apps/admin_console/services/evidence_service.py create mode 100644 apps/showcase_ui/src/app/utils/plugin-tool-view.util.spec.ts create mode 100644 apps/showcase_ui/src/app/utils/plugin-tool-view.util.ts create mode 100644 artemis/plugins/evidence.py create mode 100644 artemis/plugins/recording.py create mode 100644 artemis/plugins/recording_process.py create mode 100644 docs/plugin-development.md create mode 100644 examples/plugins/evidence-demo/SKILL.md create mode 100644 examples/plugins/evidence-demo/plugin.json create mode 100644 examples/plugins/evidence-demo/server.py create mode 100644 scripts/check_plugin.py create mode 100644 tests/unit/admin_console/test_plugin_media.py create mode 100644 tests/unit/test_plugin_evidence.py create mode 100644 tests/unit/test_plugin_recording.py create mode 100644 tests/unit/test_recording_config.py create mode 100644 tests/unit/test_recording_process.py create mode 100644 tests/unit/utils/test_video_validation.py diff --git a/.env.example b/.env.example index c097bc7..afc7d00 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,9 @@ OPENAI_REASONING_EFFORT=high # 功能开关:保存后对新任务生效 ARTEMIS_VIDEO_ENABLED=false ARTEMIS_RECORDING_ENABLED=false +# 留档编码与质量;自动模式只有客户端确认 HEVC 可播放时才优先 H.265,否则使用 H.264。 +# JSON 配置 agent.recording 和任务 recording 可覆盖。截图分辨率不受此配置影响。 +ARTEMIS_RECORDING_CONFIG={"codec":"auto","max_long_edge":1280,"fps":15,"h265_kbps":1000,"h264_kbps":1500,"computer_scope":"screen","minimum_disk_mb":256,"hevc_playback":false} ARTEMIS_OUTPUTTER_ENABLED=true ARTEMIS_STEP_SUMMARIZER_ENABLED=true # 默认将任务截图长边压至 2048;false 保留设备原始截图。长边值可在 config/artemis.jsonc 的 agent.screenshot 中覆盖。 diff --git a/apps/admin_console/routers/media.py b/apps/admin_console/routers/media.py index ec9bd0c..a5565de 100644 --- a/apps/admin_console/routers/media.py +++ b/apps/admin_console/routers/media.py @@ -115,6 +115,74 @@ async def get_session_video(session_id: str): def _get_session_video_sync(session_id: str): + from apps.admin_console.services.evidence_service import plugin_sources + from apps.admin_console.services.report_export import session_videos + + legacy = _get_legacy_session_video_sync(session_id) + sources = [] + if legacy.get("video_segments") or legacy.get("video_url"): + segments = legacy.get("video_segments") or [] + # Pauses create sibling resume directories. Only collect files already + # attached to this task; unrelated recordings never enter its timeline. + if segments and all("offset_ms" in segment for segment in segments): + combined = {} + for path, _ in session_videos({"id": session_id}): + for segment in media_service.resolve_video_segments( + media_service.path_to_video_url(path) + ): + if "offset_ms" in segment: + combined[segment["url"]] = segment + if combined: + segments = sorted(combined.values(), key=lambda segment: segment["offset_ms"]) + sources.append( + { + "id": "android", + "kind": "android", + "label": "安卓", + "target_ids": ["android"], + "status": "ready", + "message": "", + "segments": [ + {**s, "start": float(s.get("offset_ms", s["start"] * 1000)) / 1000} + for s in segments + ], + "url": legacy.get("video_url"), + } + ) + sources += plugin_sources(session_id) + legacy["sources"] = sources + legacy["session_timeline"] = any(source["segments"] for source in sources) + row = dict(session_repo.get_session_by_id(session_id) or {}) + legacy["session_duration"] = max( + max( + 0.0, + float(row.get("end_time") or row.get("start_time") or 0) + - float(row.get("start_time") or 0), + ), + max( + (s["start"] + s["duration"] for source in sources for s in source["segments"]), + default=0, + ), + ) + ready = next((s for s in sources if s.get("segments") or s.get("url")), None) + if ready: + legacy.update( + status="ready", + has_video=True, + video_url=ready.get("url") or ready["segments"][0]["url"], + video_segments=ready["segments"], + ) + elif any(s.get("status") == "recording" for s in sources): + legacy.update(status="processing", retry_after_ms=1000) + elif sources: + legacy.update( + status="failed", + message=";".join(str(s.get("message") or "录像不可用") for s in sources), + ) + return legacy + + +def _get_legacy_session_video_sync(session_id: str): video_rec_map = session_repo.get_video_recordings_map() video_idx = media_service.build_video_index() row = session_repo.get_session_by_id(session_id) diff --git a/apps/admin_console/services/evidence_service.py b/apps/admin_console/services/evidence_service.py new file mode 100644 index 0000000..f432924 --- /dev/null +++ b/apps/admin_console/services/evidence_service.py @@ -0,0 +1,151 @@ +"""Read task-owned plugin evidence without executing tools or converting media.""" + +import json +import math +from pathlib import Path +import re + +from artemis.config import TRACES_PATH + + +def task_root(session_id: str) -> Path: + if not re.fullmatch(r"[A-Za-z0-9_-]{1,100}", session_id): + raise ValueError("无效任务编号") + root = TRACES_PATH.resolve() / session_id + if root.resolve() != root: + raise ValueError("任务证据目录不能是符号链接") + return root + + +def evidence_file(root: Path, relative: str) -> Path | None: + path = root / relative + try: + if ( + path.is_file() + and path.resolve() == path.absolute() + and path.resolve().is_relative_to(root.resolve()) + ): + return path + except OSError: + return None + return None + + +def plugin_sources(session_id: str) -> list[dict]: + root = task_root(session_id) + path = evidence_file(root, "evidence/recordings/recordings.json") + if not path or path.stat().st_size > 4 * 1024 * 1024: + return [] + try: + data = json.loads(path.read_text(encoding="utf-8")) + if data.get("version") != 1 or not isinstance(data.get("sources"), list): + return [] + from apps.admin_console.services.media_service import MediaService + + sources = [] + for item in data["sources"]: + if not isinstance(item, dict) or not isinstance(item.get("id"), str): + continue + source = {**item, "segments": []} + for segment in item.get("segments", []): + if not isinstance(segment, dict): + continue + video = evidence_file(root, str(segment.get("file") or "")) + if video is None or video.suffix.lower() != ".mp4": + continue + duration = float(segment.get("duration", 0)) + start = float(segment.get("offset_ms", 0)) / 1000 + if ( + not math.isfinite(duration) + or duration <= 0 + or not math.isfinite(start) + or start < 0 + ): + continue + source["segments"].append( + { + **segment, + "url": MediaService.path_to_video_url(video), + "start": start, + "duration": duration, + } + ) + sources.append(source) + return sources + except (OSError, ValueError, TypeError, KeyError): + return [] + + +def plugin_files(session_id: str) -> list[tuple[Path, str]]: + root = task_root(session_id) + files = {} + for source in plugin_sources(session_id): + for segment in source["segments"]: + path = evidence_file(root, segment["file"]) + if path: + files[path] = "任务录屏/电脑/" + path.name + for directory in (root / "artifacts" / "plugins", root / "evidence"): + if not directory.is_dir(): + continue + for path in directory.rglob("*"): + if path.suffix.lower() == ".tmp" or ( + directory.name == "evidence" and path.suffix.lower() in {".mp4", ".mkv"} + ): + continue + relative = path.relative_to(root).as_posix() + checked = evidence_file(root, relative) + if checked: + files[checked] = ( + "任务文件/插件/" + path.relative_to(root / "artifacts" / "plugins").as_posix() + if directory.name == "plugins" + else "证据索引/" + path.relative_to(root / "evidence").as_posix() + ) + return list(files.items()) + + +def export_index(session_id: str, report: dict, android_videos: list) -> dict: + from apps.admin_console.services.report_export import session_artifacts, session_screenshots + + extras = plugin_files(session_id) + recordings = plugin_sources(session_id) + root = task_root(session_id) + paths = {path.resolve(): name for path, name in extras} + for source in recordings: + for segment in source["segments"]: + segment["file"] = paths.get((root / segment["file"]).resolve()) + segment.pop("url", None) + images = session_screenshots(report) + artifacts = session_artifacts(report) + files = [(path, "步骤截图/" + name) for path, name in images] + files += [(path, "任务录屏/" + name) for path, name in android_videos] + files += [(path, "任务文件/安卓/" + name) for path, name in artifacts] + files += extras + calls = [] + ledger = evidence_file(root, "evidence/calls.jsonl") + if ledger: + with ledger.open(encoding="utf-8") as handle: + for line in handle: + try: + call = json.loads(line) + if not isinstance(call, dict): + continue + for attachment in call.get("attachments", []): + relative = str(attachment.get("path") or "") + attachment["file"] = paths.get((root / relative).resolve()) + calls.append(call) + except (ValueError, TypeError, AttributeError): + continue # A partial final line after a crash is not a valid receipt. + return { + "version": 1, + "session_id": session_id, + "name": report["name"], + "status": report["status"], + "verdict": report["verdict"], + "steps": report.get("steps", []), + "calls": calls, + "recordings": recordings, + "android_videos": ["任务录屏/" + name for _, name in android_videos], + "image_references": {path.stem: "步骤截图/" + name for path, name in images}, + "files": [{"path": name, "bytes": path.stat().st_size} for path, name in files], + "note": "工具执行完成不等于用例通过。录像保留真实时间与缺口;各来源截图不能相互替代。", + } diff --git a/apps/admin_console/services/model_settings_service.py b/apps/admin_console/services/model_settings_service.py index 87369e4..4ecdbeb 100644 --- a/apps/admin_console/services/model_settings_service.py +++ b/apps/admin_console/services/model_settings_service.py @@ -175,6 +175,7 @@ class CapabilitySettings(BaseModel): ) video_enabled: bool = False recording_enabled: bool = False + recording: dict = Field(default_factory=dict) outputter_enabled: bool = True step_summarizer_enabled: bool = True screenshot_limit_enabled: bool = True @@ -204,6 +205,7 @@ def get_capability_settings() -> CapabilitySettings: return CapabilitySettings( video_enabled=settings.ARTEMIS_VIDEO_ENABLED, recording_enabled=settings.ARTEMIS_RECORDING_ENABLED, + recording=load_agent_config().recording.model_dump(), outputter_enabled=settings.ARTEMIS_OUTPUTTER_ENABLED, step_summarizer_enabled=settings.ARTEMIS_STEP_SUMMARIZER_ENABLED, screenshot_limit_enabled=screenshot.enabled, @@ -212,6 +214,12 @@ def get_capability_settings() -> CapabilitySettings: def save_capability_settings(update: CapabilitySettings) -> CapabilitySettings: + from artemis.config.agent import RecordingConfig + import json + + recording = RecordingConfig.model_validate( + {**load_agent_config().recording.model_dump(), **update.recording} + ).model_dump() values = { "ARTEMIS_ADB_AUTO_CONNECT": update.adb_auto_connect, "ARTEMIS_ADB_DISCOVERY_INTERVAL_SECONDS": update.adb_discovery_interval_seconds, @@ -229,6 +237,8 @@ def save_capability_settings(update: CapabilitySettings) -> CapabilitySettings: "ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS": update.worker_cleanup_timeout_seconds, } persist_environment({key: str(value).lower() for key, value in values.items()}) + persist_environment({"ARTEMIS_RECORDING_CONFIG": json.dumps(recording)}) + settings.ARTEMIS_RECORDING_CONFIG = recording for key, value in values.items(): setattr(settings, key, value) return get_capability_settings() diff --git a/apps/admin_console/services/report_export.py b/apps/admin_console/services/report_export.py index 7c8bd13..768f60a 100644 --- a/apps/admin_console/services/report_export.py +++ b/apps/admin_console/services/report_export.py @@ -475,6 +475,24 @@ def plugin_operations(steps: list[dict]) -> list[dict]: for block in result.get("content", []) if isinstance(block, dict) and block.get("type") == "text" ) + source = result.get("source") or payload.get("source") or {} + evidence = [] + if result.get("call_id"): + evidence.append("调用:" + str(result["call_id"])) + if source: + evidence.append( + f"来源:{source.get('kind', '')} · {source.get('label') or source.get('target_id', '')}" + ) + if result.get("execution"): + evidence.append( + f"执行:{result['execution']};副作用:{result.get('side_effect', 'unknown')};错误码:{result.get('error_code') or '无'}" + ) + for attachment in result.get("attachments") or []: + evidence.append( + f"文件:{attachment.get('name')};SHA-256:{attachment.get('sha256')}" + ) + if evidence: + content = "\n".join([content or result.get("message") or "", *evidence]) operations.append( { "step": step.get("step_number"), @@ -809,6 +827,8 @@ def session_artifacts(report: dict) -> list[tuple[Path, str]]: def export_session_media(session_id: str, kind: str) -> Path: + from apps.admin_console.services.evidence_service import export_index, plugin_files + if kind not in {"screenshots", "video", "all"}: raise ValueError("不支持的下载类型") with EXPORT_LOCK: @@ -818,7 +838,10 @@ def export_session_media(session_id: str, kind: str) -> Path: images = session_screenshots(report) if kind in {"screenshots", "all"} else [] videos = session_videos(report) if kind in {"video", "all"} else [] artifacts = session_artifacts(report) if kind == "all" else [] - if kind == "video" and not videos: + extras = plugin_files(session_id) if kind in {"video", "all"} else [] + if kind == "video": + extras = [(path, name) for path, name in extras if path.suffix.lower() == ".mp4"] + if kind == "video" and not videos and not extras: raise ValueError("此任务没有已保存的录屏") if kind == "screenshots" and not images: raise ValueError("此任务没有可下载的步骤截图") @@ -830,7 +853,8 @@ def export_session_media(session_id: str, kind: str) -> Path: "status": report["status"], "verdict": report["verdict"], "screenshots": [name for _, name in images], - "videos": [name for _, name in videos], + "videos": ["任务录屏/" + name for _, name in videos] + + [name for path, name in extras if path.suffix.lower() == ".mp4"], "artifacts": [name for _, name in artifacts], "note": "录屏按时间顺序分段;暂停期间未占用设备,也未录制其他任务。", } @@ -842,9 +866,27 @@ def export_session_media(session_id: str, kind: str) -> Path: for source, name in images: archive.write(source, f"{prefix}步骤截图/{name}") for source, name in videos: - archive.write(source, f"{prefix}任务录屏/{name}") + archive.write(source, f"{prefix}任务录屏/{name}", compress_type=zipfile.ZIP_STORED) for source, name in artifacts: archive.write(source, f"{prefix}任务文件/安卓/{name}") + for source, name in extras: + archive.write( + source, + prefix + name, + compress_type=zipfile.ZIP_STORED + if source.suffix.lower() == ".mp4" + else zipfile.ZIP_DEFLATED, + ) + if kind == "all": + archive.writestr( + prefix + "证据索引.json", + json.dumps( + export_index(session_id, report, videos), + ensure_ascii=False, + indent=2, + default=str, + ), + ) archive.writestr( prefix + "文件清单.json", json.dumps(manifest, ensure_ascii=False, indent=2) ) @@ -853,6 +895,8 @@ def export_session_media(session_id: str, kind: str) -> Path: def export_batch(batch_id: str, progress: Callable[[int, int, str], None] | None = None) -> Path: + from apps.admin_console.services.evidence_service import export_index, plugin_files + with EXPORT_LOCK: batch = store.get("batch", identifier(batch_id)) if not batch: @@ -901,9 +945,28 @@ def export_batch(batch_id: str, progress: Callable[[int, int, str], None] | None for source, name in session_screenshots(report): archive.write(source, f"{prefix}/步骤截图/{name}") for source, name in session_videos(report): - archive.write(source, f"{prefix}/任务录屏/{name}") + archive.write( + source, f"{prefix}/任务录屏/{name}", compress_type=zipfile.ZIP_STORED + ) for source, name in session_artifacts(report): archive.write(source, f"{prefix}/任务文件/安卓/{name}") + for source, name in plugin_files(report["id"]): + archive.write( + source, + f"{prefix}/{name}", + compress_type=zipfile.ZIP_STORED + if source.suffix.lower() == ".mp4" + else zipfile.ZIP_DEFLATED, + ) + archive.writestr( + f"{prefix}/证据索引.json", + json.dumps( + export_index(report["id"], report, session_videos(report)), + ensure_ascii=False, + indent=2, + default=str, + ), + ) if progress: progress(number, len(records), f"已打包任务 {number}/{len(records)}") if progress: diff --git a/apps/admin_console/services/task_import.py b/apps/admin_console/services/task_import.py index ab24b36..43c24fe 100644 --- a/apps/admin_console/services/task_import.py +++ b/apps/admin_console/services/task_import.py @@ -8,8 +8,9 @@ import zipfile from openpyxl import Workbook, load_workbook from openpyxl.styles import Alignment, Font, PatternFill from openpyxl.utils import get_column_letter -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_serializer from artemis.plugins.models import ExecutionTarget, PluginSelection +from artemis.config.agent import RecordingConfig HEADERS = ( "用例编号", @@ -38,6 +39,7 @@ class TaskOptions(BaseModel): explorer_mode: str | None = Field(default=None, pattern="^(flash|pro|ultra)$") locked_app_package: str | None = Field(default=None, min_length=1, max_length=200) record_video: bool | None = None + recording: RecordingConfig | None = None analyze_video: bool | None = None request_timeout_seconds: float | None = Field(default=None, gt=0, le=3600) max_retries: int | None = Field(default=None, ge=0, le=10) @@ -51,6 +53,11 @@ class TaskOptions(BaseModel): plugins: list[PluginSelection] | None = None execution_target: ExecutionTarget | None = None + @field_serializer("recording", when_used="unless-none") + def serialize_recording(self, value: RecordingConfig) -> dict: + # A per-task partial override must not replace other saved recording defaults. + return value.model_dump(exclude_unset=True) + class TaskCase(BaseModel): model_config = ConfigDict(extra="forbid") diff --git a/apps/admin_console/services/task_queue_service.py b/apps/admin_console/services/task_queue_service.py index bafcd43..4d2c958 100644 --- a/apps/admin_console/services/task_queue_service.py +++ b/apps/admin_console/services/task_queue_service.py @@ -586,6 +586,10 @@ class TaskQueueService: env["ARTEMIS_TASK_INGRESS"] = str(task_item.get("ingress", "frontend")) env["ARTEMIS_TASK_WORKER"] = "1" overrides = task_item.get("runtime_overrides") or {} + if overrides.get("recording") is not None: + env["ARTEMIS_TASK_RECORDING_CONFIG"] = json.dumps(overrides["recording"]) + else: + env.pop("ARTEMIS_TASK_RECORDING_CONFIG", None) for option, variable in { "model": "OPENAI_MODEL", "reasoning_effort": "OPENAI_REASONING_EFFORT", @@ -869,6 +873,10 @@ class TaskQueueService: registered at recording start; remuxing that file is all that is needed to publish the full video. """ + from apps.admin_console.services.evidence_service import task_root + from artemis.plugins.recording import recover_interrupted_recordings + + await recover_interrupted_recordings(task_root(str(sess_id))) rec_info = session_repo.get_video_recording_for_session(sess_id) rec_status = (rec_info or {}).get("status") if rec_status == "ready": @@ -1890,6 +1898,14 @@ class TaskQueueService: recordings published. """ recovered = 0 + from artemis.config import TRACES_PATH + from artemis.plugins.recording import recover_interrupted_recordings + + # This hook runs in the startup worker thread, outside the server event loop. + for manifest in TRACES_PATH.glob("*/evidence/recordings/recordings.json"): + root = manifest.parent.parent.parent + if asyncio.run(recover_interrupted_recordings(root)): + recovered += 1 try: pending = session_repo.get_unfinalized_video_recordings() except Exception as exc: 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 b8204e9..d0b393f 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 @@ -809,6 +809,7 @@ limitations under the License.
{{ getActionIcon(item.data) }} {{ getActionTitle(item.data) }} + @if (isActionFailed(item.data, block.data)) { error @@ -1050,7 +1051,7 @@ limitations under the License.
} - @if (shouldShowTool(item.data, block.data) && !isDeviceActionTool(item.data)) { + @if (shouldShowTool(item.data, block.data) && !isEvidenceTool(item.data)) {
} - - @if (shouldShowTool(item.data, block.data) && isDeviceActionTool(item.data)) { + + @if (shouldShowTool(item.data, block.data) && isEvidenceTool(item.data)) {
{{ getToolIcon(item.data) }} {{ getToolTitle(item.data) }} + + @if (getPluginToolView(item.data); as plugin) { + {{ plugin.kind }} · {{ plugin.status }} + } @if (isToolFailed(item.data)) { error @@ -1114,7 +1121,7 @@ limitations under the License.
- @if (!isActionExpanded('tool-' + block.id + '-' + itemIdx) && (isAdbCommandTool(item.data) ? getAdbCommandLine(item.data) : (getToolTargetText(item.data) || getToolInputText(item.data) || getToolCoords(item.data) || getToolAnalysisText(item.data) || getToolGenericDetails(item.data)))) { + @if (!getPluginToolView(item.data) && !isActionExpanded('tool-' + block.id + '-' + itemIdx) && (isAdbCommandTool(item.data) ? getAdbCommandLine(item.data) : (getToolTargetText(item.data) || getToolInputText(item.data) || getToolCoords(item.data) || getToolAnalysisText(item.data) || getToolGenericDetails(item.data)))) {
@if (isAdbCommandTool(item.data)) { @@ -1196,7 +1203,21 @@ limitations under the License.
} } - @if (!isAdbCommandTool(item.data)) { + @if (getPluginToolView(item.data); as plugin) { + @for (param of plugin.args; track param.key) { +
+ {{ param.key }} + {{ param.value }} +
+ } + @if (plugin.result) { +
+ 实际结果 + {{ plugin.result }} +
+ } + } + @if (!isAdbCommandTool(item.data) && !getPluginToolView(item.data)) { @if (getToolTargetText(item.data)) {
目标 @@ -1251,7 +1272,7 @@ limitations under the License. } @if (getStepPostImageUrl(block.data, item.data)) {
- 操作后 + {{ getPluginToolView(item.data)?.auxiliary ? '观察截图' : '操作后' }}
操作后截图 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 57795d9..9cb9f4b 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 @@ -4053,3 +4053,16 @@ $stream-row-height: 25px; th { background: #dbeafe; } .md-para-gap { height: 8px; } } + +.evidence-source { max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 2px 7px; border: 1px solid #cbd5e1; border-radius: 6px; background: #f1f5f9; color: #475569; font-size: 11px; cursor: pointer; } + +.plugin-tool-card { + .action-card-header { flex-wrap: wrap; } + .plugin-call-kind { font-size: 10px; color: #64748b; } + .plugin-result { white-space: pre-wrap; overflow-wrap: anywhere; } + &.is-auxiliary:not(.is-expanded):not(.is-failed):not(.is-running) { + background: #f8fafc; + border-color: #e2e8f0; + box-shadow: none; + } +} 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 ef1bf9c..752f75e 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 @@ -162,6 +162,7 @@ import { extractCheckerResult, renderMarkdownToHtml } from '../../utils/markdown-parser.util'; +import { getPluginToolView } from '../../utils/plugin-tool-view.util'; import { getActionObject, @@ -182,7 +183,8 @@ import { getStepPostImageUrl, isReportStatusAction, getReportStatusValue, - getReportStatusExplanation + getReportStatusExplanation, + actionEvidenceSource } from '../../utils/action-formatter.util'; import { @@ -1612,6 +1614,17 @@ export class AgentStreamComponent implements AfterViewInit { } } + public evidenceSourceLabel(item: any): string { return actionEvidenceSource(item).label; } + + public openEvidenceVideo(item: any, step: any, event: Event): void { + event.stopPropagation(); + const started = this.agentService.currentSession()?.start_time; + const value = item?.timestamp || item?.start_time || step?.timestamp; + const timestamp = typeof value === 'number' ? value : Date.parse(value) / 1000; + if (!started || !Number.isFinite(timestamp)) return; + this.agentService.openVideoPlayer(undefined, undefined, undefined, Math.max(0, timestamp - started), undefined, actionEvidenceSource(item).id); + } + public getScreenRecordingButtonTitle(): string { if (this.agentService.isCurrentSessionRunning()) { return this.agentService.currentSession()?.recording_status === 'recording' ? "任务正在运行(录屏中)" : "任务正在运行"; @@ -1637,6 +1650,12 @@ export class AgentStreamComponent implements AfterViewInit { return isDeviceActionTool(tool); } + public getPluginToolView = getPluginToolView; + + public isEvidenceTool(tool: any): boolean { + return isDeviceActionTool(tool) || getPluginToolView(tool) !== null; + } + public isFailureAnalyzerActionTool(tool: any): boolean { return isDeviceActionTool(tool); } @@ -2033,11 +2052,12 @@ export class AgentStreamComponent implements AfterViewInit { } public getToolIcon(tool: any): string { - return getToolIcon(tool); + const plugin = getPluginToolView(tool); + return plugin ? (plugin.auxiliary ? 'visibility' : 'extension') : getToolIcon(tool); } public getToolTitle(tool: any): string { - return getToolTitle(tool); + return getPluginToolView(tool)?.title || getToolTitle(tool); } public getToolTargetText(tool: any): string { 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 8e08b2a..24919be 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 @@ -95,6 +95,22 @@ limitations under the License.
+ @if (sources().length && !agentService.isVideoMinimized()) { +
+ + +
+ @if (agentService.recordingPlaybackMessage()) { +
{{ agentService.recordingPlaybackMessage() }}
+ } + @if (!agentService.activeVideoUrl()) { +
此来源暂无可播放录像,请查看步骤截图或日志。
+ } + } @if (!agentService.isVideoMinimized()) {
@@ -332,8 +348,10 @@ limitations under the License. @if (agentService.playerMode() === 'video' && agentService.recordingPlaybackStatus() === 'ready' && !agentService.isVideoLoading() && agentService.activeVideoUrl(); as videoUrl) { @if (!videoLoadError()) {
+ @if (gapMessage()) {
{{ gapMessage() }}
}