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.
}
- @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() }}
}