Compare commits

..
Author SHA1 Message Date
chenmingxuan 8bf59897bb fix(runtime): release 2.7.1 with consistent ADB and failure diagnostics 2026-11-05 11:40:49 +08:00
chenmingxuan b2316ca249 chore(release): prepare 2.7.0 2026-11-04 18:06:18 +08:00
chenmingxuan 04022f2eaf feat(config): default to WebSocket and migrate saved HTTP settings 2026-11-04 17:45:52 +08:00
chenmingxuan 2a3f43fb60 feat(llm): support incremental Responses WebSocket transport 2026-11-04 17:15:32 +08:00
chenmingxuan 73adcf25a7 chore(release): prepare 2.6.0 with bundled Android helper 2026-11-03 12:08:11 +08:00
chenmingxuan dc3a48f6b2 fix(android): expose stable native selectors with exact pixel bounds 2026-11-03 00:02:35 +08:00
chenmingxuan 4ea7939d9c feat(android): add state conditions and measured gesture feedback
Add optional ensure_state, gesture region and final value feedback shared by Flash and Pro. Preserve completed inputs when verification fails and stop repeated ineffective strategies. Regenerate pinned tool schemas for the new optional arguments.
2026-11-02 23:20:34 +08:00
chenmingxuan 6a69a595ef fix(ui): localize native Android action displays 2026-11-02 22:33:40 +08:00
chenmingxuan 4b12ae7728 feat(android): add native controls and continuous multi-touch 2026-11-02 21:33:45 +08:00
chenmingxuan 3dd96078db feat: add cache routing alignment and usage visibility for 2.5.0 2026-11-02 17:15:59 +08:00
chenmingxuan 7c0c2b59ca fix: bound model stream aggregation and close failed responses 2026-11-02 16:48:51 +08:00
chenmingxuan 4879adc380 fix: prepare transient controls inside native action sequences 2026-11-02 15:33:13 +08:00
chenmingxuan 00e62a663a fix: guard transient controls and verify timeline endpoints 2026-11-02 13:54:50 +08:00
chenmingxuan 479a185192 feat: add gesture sequences and calibrated pixel drags 2026-11-02 11:53:27 +08:00
chenmingxuan 2a3bbaa3fb feat(plugins): add managed runtime SDK and release 2.4.5 2026-10-30 14:39:21 +08:00
chenmingxuan 506e3bf1c5 fix(cli): localize existing-server prompts for Chinese launcher 2026-10-29 16:48:29 +08:00
chenmingxuan c6d2050ff0 fix: release 2.4.4 with collapsible task outcome details 2026-10-29 16:32:28 +08:00
chenmingxuan 0c3ca69c1d chore(release): prepare ARTEMIS 2.4.3 2026-10-29 14:21:59 +08:00
chenmingxuan 486e6d550d fix(llm): isolate invalid output failures and trace argument streams 2026-10-29 12:47:18 +08:00
chenmingxuan b53ca18595 feat: release 2.4.2 with batch human review badges 2026-10-28 20:40:56 +08:00
chenmingxuan ac3d8cf54a fix: release 2.4.1 with consistent npm locks and Node version checks 2026-10-28 18:56:01 +08:00
chenmingxuan e90da727d4 fix: preserve legacy plugin catalogs and protect helper token initialization 2026-10-28 17:53:43 +08:00
chenmingxuan 4dc2057d43 feat: release 2.4.0 with original cases and automatic human review 2026-10-28 17:25:08 +08:00
chenmingxuan baf069854b fix(runtime): recover awake ownership and batch model failures 2026-10-28 11:50:05 +08:00
chenmingxuan 6b2c0f199d fix(deps): upgrade OpenAI stack and resolve dependency vulnerabilities 2026-10-28 11:22:04 +08:00
chenmingxuan bb873ec0e0 feat: release 2.3.0 with workspace plugin extensions 2026-10-27 21:38:43 +08:00
chenmingxuan 8380057548 feat: add gpt-6.1-terra and default exploration to high reasoning 2026-10-27 12:25:54 +08:00
chenmingxuan be6e8c2abd feat: release 2.2.0 with PC-only tests and execution reliability fixes
Unify task scheduling and queue state, fix Android input and observation recovery, preserve screenshot paths, and match final review criteria accurately. Add collapsible copyable results and publish the complete 2.1.0 upgrade notes. Validation: 3189 backend tests, 326 frontend tests, 24 client tests, production build and quality gates.
2026-10-27 01:13:41 +08:00
chenmingxuan fac4ccb760 feat: prepare 2.2.0 with UI and execution fixes
Add dark theme, richer Markdown and readable plugin results.
Improve Android input evidence, response integrity and diagnostics.
Unify lifecycle, guidance receipts and evidence sources across clients.
Keep direct action dispatch without an added AI quota approval layer.
Align version metadata at 2.2.0 and bundle Helper 1.2.2.

Validation: 3063 backend tests before the version bump; 44 after.
Frontend: 317 tests and production build passed.
2026-10-26 00:44:13 +08:00
chenmingxuan 83bcfd54d5 feat: release 2.1.0 with optional Flash final AI review
Default Flash final review to off and persist the setting across the UI,
MCP, SDK and queued tasks. Keep execution separate from test verdicts.

Bundle UI fonts and licenses to remove Google Fonts from startup builds.
Align backend/frontend versions and document installation and upgrades.
2026-10-23 12:36:56 +08:00
chenmingxuan b584dae80a fix: restore Python 3.12 startup and release 2.0.1 2026-10-22 19:22:13 +08:00
chenmingxuan bf6247703d docs: refresh Artemis development and design guides 2026-10-19 11:48:53 +08:00
chenmingxuan 973ccae679 fix: preserve exploration demonstration source and recording scope 2026-10-18 23:20:50 +08:00
chenmingxuan f4cb55c030 release: merge Artemis 2.0.0 2026-10-18 21:08:38 +08:00
474 changed files with 44221 additions and 5557 deletions

No files matched your search

+14 -1
View File
@@ -6,6 +6,13 @@ OPENAI_BASE_URL=https://api-n-cd.playones.com/v1
OPENAI_MODEL=gpt-6-luna OPENAI_MODEL=gpt-6-luna
# 单次模型 HTTP 请求超时(秒) # 单次模型 HTTP 请求超时(秒)
OPENAI_TIMEOUT_SECONDS=300 OPENAI_TIMEOUT_SECONDS=300
# 模型请求和模型凭据检查默认直连;设为 true 才读取 Windows/HTTP(S)_PROXY/ALL_PROXY/NO_PROXY。
# 仅环境配置,不显示在网页中;修改后重启服务生效。TLS 校验及自定义 CA 不受影响。
OPENAI_USE_SYSTEM_PROXY=false
# 默认 websocket,要求网关支持 Responses WebSocket;不支持时可在网页切回 http。
OPENAI_TRANSPORT=websocket
# 内部迁移标记。升级时一次性将旧 HTTP 配置切换为 WebSocket;保留此标记以记住后续手动选择。
ARTEMIS_TRANSPORT_DEFAULT_VERSION=1
# 可选的后台步骤截图摘要单次超时,独立于上面的主模型请求超时。 # 可选的后台步骤截图摘要单次超时,独立于上面的主模型请求超时。
ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS=120 ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS=120
# 失败后最多重试 5 次,由 Artemis 统一管理,SDK 不叠加重试。 # 失败后最多重试 5 次,由 Artemis 统一管理,SDK 不叠加重试。
@@ -19,6 +26,10 @@ ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS=3
# default 表示不发送思考强度参数;接口不支持所选值时明确报错。 # default 表示不发送思考强度参数;接口不支持所选值时明确报错。
OPENAI_REASONING_EFFORT=high OPENAI_REASONING_EFFORT=high
# 探索独立默认值;网页已保存的探索配置优先,已创建会话保留快照。
ARTEMIS_EXPLORATION_MODEL=gpt-6.1-terra
ARTEMIS_EXPLORATION_REASONING_EFFORT=high
# 1 跳过本次网页启动的 Codex MCP 与官方 artemis-use Skill 同步;默认自动安装/更新。 # 1 跳过本次网页启动的 Codex MCP 与官方 artemis-use Skill 同步;默认自动安装/更新。
ARTEMIS_SKIP_CODEX_SYNC=0 ARTEMIS_SKIP_CODEX_SYNC=0
@@ -29,6 +40,8 @@ ARTEMIS_RECORDING_ENABLED=false
# JSON 配置 agent.recording 和任务 recording 可覆盖。截图分辨率不受此配置影响。 # 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_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_OUTPUTTER_ENABLED=true
# Flash 执行结束后的独立 AI 分析与判定,默认关闭;不影响 Pro 或执行过程。
ARTEMIS_FLASH_FINAL_CHECK_ENABLED=false
ARTEMIS_STEP_SUMMARIZER_ENABLED=true ARTEMIS_STEP_SUMMARIZER_ENABLED=true
# 默认将任务截图长边压至 2048;false 保留设备原始截图。长边值可在 config/artemis.jsonc 的 agent.screenshot 中覆盖。 # 默认将任务截图长边压至 2048;false 保留设备原始截图。长边值可在 config/artemis.jsonc 的 agent.screenshot 中覆盖。
ARTEMIS_SCREENSHOT_LIMIT_ENABLED=true ARTEMIS_SCREENSHOT_LIMIT_ENABLED=true
@@ -83,7 +96,7 @@ ARTEMIS_BATCH_MAX_EXECUTIONS=10000
ARTEMIS_IMPORT_MAX_BYTES=10485760 ARTEMIS_IMPORT_MAX_BYTES=10485760
# 插件连接与安装预算(修改后重启服务) # 插件连接与安装预算(修改后重启服务)
ARTEMIS_PLUGIN_MARKETPLACE_URL=https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/catalog.json ARTEMIS_PLUGIN_MARKETPLACE_URL=https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/catalog-v3.json
ARTEMIS_PLUGIN_START_TIMEOUT_SECONDS=30 ARTEMIS_PLUGIN_START_TIMEOUT_SECONDS=30
ARTEMIS_PLUGIN_TOOL_TIMEOUT_SECONDS=60 ARTEMIS_PLUGIN_TOOL_TIMEOUT_SECONDS=60
ARTEMIS_PLUGIN_SHUTDOWN_TIMEOUT_SECONDS=15 ARTEMIS_PLUGIN_SHUTDOWN_TIMEOUT_SECONDS=15
+41 -24
View File
@@ -2,15 +2,27 @@
## Project Overview ## Project Overview
本仓库是基于 ARTEMIS 扩展的 Android AI 自动化测试平台,提供简体中文网页、OpenAI Responses 模型调用、批量任务、历史检索及 Word/Excel 报告。后端使用 Python 3.12+、FastAPI、LangGraph,前端使用 Angular,AI 客户端通过 MCP 接入。 本仓库是基于 ARTEMIS 扩展的自动化测试平台,提供简体中文网页、Flash/Pro 执行、交互探索、插件跨端操作、精选任务、批量任务及 Word/Excel 报告。后端使用 Python 3.12+、FastAPI、LangGraph,前端使用 Angular;模型统一使用 OpenAI Responses,AI 客户端通过轻量 MCP 桥接用户启动的主程序。
## Scope ## Scope
- 本文件适用于整个仓库;子目录的 AGENTS.md 在对应目录内优先。 - 本文件适用于整个仓库;子目录的 AGENTS.md 在对应目录内优先。
- 当前任务的用户指令优先于仓库指南。保留用户已有修改,只处理本次任务范围。 - 当前任务的用户指令优先于仓库指南。保留用户已有修改,只处理本次任务范围。
- 本文件指导开发;使用软件执行 Android 测试时,阅读 [自动化 Skill](skills/artemis-use/SKILL.md)。 - 主程序与 `Artemis-Plugins` 是独立仓库。跨仓库任务分别检查分支、未提交改动和远端状态;发布文档或修复时,不夹带已有未发布提交。必要时使用隔离工作树,只移植本次提交。
- 本文件指导开发;使用软件执行设备或跨端任务、排查任务时,阅读 [自动化 Skill](skills/artemis-use/SKILL.md)。
- 以下是项目原生命令;宿主要求 RTK 等包装器时按宿主规则调用,不把本机路径或工具写成项目安装前提。 - 以下是项目原生命令;宿主要求 RTK 等包装器时按宿主规则调用,不把本机路径或工具写成项目安装前提。
## Read When Needed
| 修改范围 | 先读 |
| --- | --- |
| 执行、验收、探索、插件或证据的职责边界 | [DESIGN.md 的架构与行为约定](DESIGN.md#system-architecture) |
| 网页布局、样式或交互 | [视觉与交互设计](DESIGN.md#colors)及[前端指南](apps/showcase_ui/AGENTS.md) |
| 探索、示范、用例提炼与复跑 | [探索说明](docs/exploration-mode.md) |
| 插件协议、适配或市场协同 | [插件开发规范](docs/plugin-development.md)及插件仓库自身的 AGENTS.md、DESIGN.md |
| 远程 Python SDK | [SDK 设计](packages/artemis-client/DESIGN.md),保持标准库薄客户端边界 |
| 继续历史开发、确认版本或发布 | [README 当前发行入口](README.md)、对应发行说明及实时 Git 状态;[开发快照](docs/development-handoff.md)与 `docs/plans/` 仅作为历史线索,不替代当前源码与发布包核验 |
## Quick Commands ## Quick Commands
在仓库根目录执行,前端命令见局部指南: 在仓库根目录执行,前端命令见局部指南:
@@ -29,45 +41,49 @@
Windows 首次安装使用 `start.bat`,国内镜像使用 `start-cn.bat`。启动只提示源码更新;升级使用 `update.bat` / `update-cn.bat`,会终止本项目运行进程。开发中可传入 `-SkipUpdate` 跳过更新提示。 Windows 首次安装使用 `start.bat`,国内镜像使用 `start-cn.bat`。启动只提示源码更新;升级使用 `update.bat` / `update-cn.bat`,会终止本项目运行进程。开发中可传入 `-SkipUpdate` 跳过更新提示。
## Key Paths
- `artemis/agents/`、`artemis/runtime/`:Flash/Pro 执行、设备与任务运行时。
- `artemis/llm/`、`artemis/services/llm.py`:模型请求、超时、重试与恢复。
- `artemis/config/`、`.env.example`:运行配置、环境变量及默认值。
- `config/artemis.jsonc`、`artemis/resources/config/artemis.jsonc`:开发与打包使用的配置,默认值变更须同步。
- `apps/admin_console/`:HTTP API、统一队列、批次、历史及报告服务。
- `apps/showcase_ui/`:Angular 网页;遵循 [前端指南](apps/showcase_ui/AGENTS.md)。
- `mcp_server/`:MCP 工具与客户端使用规则。
- `packages/artemis-client/`:远程 Python SDK;`skills/artemis-use/`:随源码分发的 AI 使用技能。
- `scripts/`:安装、镜像、升级、检查及打包脚本。
- `tests/unit/`、`tests/tools/`、`packages/artemis-client/tests/`:默认测试范围。
## Architecture Boundaries ## Architecture Boundaries
- 模型传输只使用 OpenAI Responses,不恢复 Gemini 专用调用或 Chat Completions 分支。API 模型名与 Flash/Pro 执行模式分开处理。 - 模型传输只使用 OpenAI Responses,不恢复 Gemini 专用调用或 Chat Completions 分支。API 模型名与 Flash/Pro 执行模式分开处理。
- 网页和工作区 MCP 工具复用后台配置、队列、历史和报告服务;不要另建调度器或第二套状态存储。 - 网页、MCP 与 SDK 复用后台配置、队列、历史和报告服务;不另建调度器或第二套状态存储。MCP 不自行启动完整后台,主程序离线时仍可握手并明确提示连接状态。
- 批次创建与执行分开;保持 request_id 幂等性。同设备串行,失败项不自动重跑;模型重试不能变成业务用例重提。 - 批次创建与执行分开;保持 `request_id` 幂等性。同设备及共享桌面资源按现有锁串行,任务固定插件版本;失败项不自动重跑,模型重试不能变成业务用例重提,结果不明的写操作不盲目重发。
- 区分“执行完成”与“验证通过”。报告结论必须有实际证据,保留未验证、失败、跳过和中断状态。 - 最终验收使用独立总预算,协调单次模型超时与重试;不能套用中间检查点的 180 秒时限。重试保留已取得的工具证据,用户停止或探索新指导仍可中断。
- 区分执行完成、工具成功和验证通过。正式结论必须经过完整解析与校验,取消或截断的流式内容不能显示为通过;报告、网页、MCP 与人工复验后的有效结论保持一致。
- 示范的实际来源贯穿分析、澄清、规划、执行、验收与用例提炼。补充网址不等于更换平台;用户明确要求改端或跨端时再按要求执行,手机证据不能替代电脑证据。
- 人工示范遵循窗口/屏幕录制配置。窗口模式跟随前台窗口,无有效窗口时保留缺口,不扩大到多屏桌面;普通录屏在只读最终验收前停止,恢复操作时再追加片段。
- 探索主动执行预算耗尽后保留进度并暂停,不直接判失败;探索成功、用例提炼成功和固定版本复跑通过分别记录。收藏保存独立模板与配置,不依赖原任务一直存在。
- 历史压缩、视觉摘要与工具上下文调整须保留关键文件回执和来源,维护稳定的多轮前缀;缓存率使用实际用量加权统计,不把缓存输入当作消失的 token,也不承诺固定收益。
- 设备动作回放已移除;只读历史步骤、截图和上下文回忆仍保留,不因 replay 命名误删这些能力。 - 设备动作回放已移除;只读历史步骤、截图和上下文回忆仍保留,不因 replay 命名误删这些能力。
- 密钥仅通过网页或环境配置写入,读取配置不得回显;MCP 不接受密钥。全局配置修改影响后续任务。 - 密钥仅通过网页或环境配置写入,读取配置不得回显;MCP 不接受密钥。全局配置修改影响后续任务,已保存配置优先于新安装默认值。
- 默认 high、请求超时 300 秒、失败后重试 5 次、间隔 10 秒,视频分析与录屏关闭。接口不支持思考强度时明确报错,不静默降级。 - 新安装普通任务默认 `gpt-6-luna / high`,探索默认 `gpt-6.1-terra / high`;请求超时 300 秒、失败后重试 5 次、间隔 10 秒,视频分析与普通录屏关闭。接口不支持思考强度时明确报错,不静默降级。
- 业务环境恢复由任务提示词提出,不新增默认业务回滚流程;连接、录像、子进程和资源锁的运行时收尾仍然必需。
## Conventions ## Conventions
- Python 使用绝对导入、4 空格缩进和双引号,由 Ruff 检查;不要放宽质量基线来掩盖新增问题。 - Python 使用绝对导入、4 空格缩进和双引号,由 Ruff 检查;不要放宽质量基线来掩盖新增问题。
- 新增可调参数集中进入配置层,并更新环境示例、网页/MCP 入口和必要文档,避免散落硬编码。 - 新增可调参数集中进入配置层,按作用域更新环境示例、网页/MCP 入口和文档;明确仅供环境配置的参数不增加前端入口。JSON 默认值同步 `config/artemis.jsonc` 与 `artemis/resources/config/artemis.jsonc`。
- MCP 工具增删时同步工具注册、CLI 安装白名单、测试、Skill 和能力文档。 - MCP 工具增删时同步工具注册、CLI 安装白名单、测试、Skill 和能力文档。
- 同步 Codex MCP/Skill 时保留已有工具白名单、禁用状态和审批策略;异常时保留原配置并告警,不生成非法 TOML 或阻断网页启动。
- Windows PowerShell 启动脚本保持兼容 Windows PowerShell 5.1,避免编码导致解析失败。升级只做 Git 快进,保护已有配置和本地修改。 - Windows PowerShell 启动脚本保持兼容 Windows PowerShell 5.1,避免编码导致解析失败。升级只做 Git 快进,保护已有配置和本地修改。
- `update*.bat` 保持精简的 ASCII 入口;升级程序及其辅助脚本先复制到项目外执行。更新后通过新版 `start.ps1 -PrepareOnly -SkipUpdate` 同步环境,保留此内部调用约定,避免继续运行旧版安装函数。 - `update*.bat` 保持精简的 ASCII 入口;升级程序及其辅助脚本先复制到项目外执行。更新后通过新版 `start.ps1 -PrepareOnly -SkipUpdate` 同步环境,保留此内部调用约定,避免继续运行旧版安装函数。
- 前端遵循现有简体中文文案与视觉样式;不直接编辑构建产物。 - 前端遵循 DESIGN.md 与局部指南,复用现有组件和简体中文文案;不直接编辑构建产物。页面仍显示旧效果时,先核对实际服务的源码目录及构建来源。
## Definition of Done ## Definition of Done
- Python 行为改动运行默认测试、Ruff 两项检查、质量基线和核心类型检查;前端改动遵循局部指南的测试与构建要求。 - Python 行为改动运行默认测试、Ruff 两项检查、质量基线和核心类型检查;前端改动遵循局部指南的测试与构建要求。
- 文档改动核对命令、相对链接和功能说明;仅文档变更无需启动设备或调用付费模型。 - 文档改动核对命令、相对链接和功能说明;历史测试数量与未测范围见对应版本记录,不能冒充本次验证。仅文档变更无需启动设备或调用付费模型。
- 默认测试不依赖设备、密钥或私有服务;外部测试按现有 android、integration、e2e、cloud、manual 标记分层,不默认执行全量外部测试。 - 默认测试不依赖设备、密钥或私有服务;外部测试按现有 android、integration、e2e、cloud、manual 标记分层,不默认执行全量外部测试。
- 设备验证沿用用户指定设备;多设备且未指定时先确认。编写应用交互测试前,先通过 ARTEMIS 探索实际页面和操作路径。 - 设备验证沿用用户指定设备;多设备且未指定时先确认。编写应用交互测试前,先通过 ARTEMIS 探索实际页面和操作路径。
- 提交前检查 `git diff --check` 和暂存范围;报告实际执行的验证及未完成项,不将未运行的检查写成通过。 - 提交前检查 `git diff --check` 和暂存范围,只暂存本次具体路径;报告实际执行的验证及未完成项,不将未运行的检查写成通过。
## Release Gates
- 主程序、SDK、插件和第三方依赖独立版本化。主程序升版只修改 `pyproject.toml` 的项目版本、`uv.lock` 的 artemis 条目及前端应用的根版本字段,发行文档另行更新。禁止对仓库或整个锁文件全局替换版本字符串。
- 单独审查锁文件差异。第三方条目的版本、下载地址、完整性校验和依赖声明必须相互匹配;主程序升版不应改变这些条目。真正升级依赖时由包管理器生成并核对上游声明;发生 EINTEGRITY 先核验来源,禁止关闭校验或只照错误提示改哈希。
- 发布前必须在隔离目录解压最终 ZIP,使用独立空 npm 缓存完成 `npm ci` 和生产构建,覆盖本次发布的官方/国内安装路径。包内安装或构建输入变化后必须重跑;不得清空用户全局缓存或改动用户运行目录来制造干净环境。
- 记录实际 Node/npm 版本、镜像、源码提交、ZIP SHA256 和验证范围。已有 node_modules、构建指纹、缓存命中或大量单元测试通过,均不能证明全新安装成功;某个 Node 版本通过也不能冒充其他版本实测。测试和构建命令见前端局部指南。
- 发布顺序:固定提交并打包 → 验证最终包 → 推送标签 → 创建草稿并上传包及校验文件 → 回读校验 → 正式发布 → 无认证下载核验。标签必须指向打包提交;推送标签后检查 Release 状态,本仓库曾出现 Gitea 将已有草稿自动发布的情况。失败时修复并重新验证,不提前宣布完成。
- 候选包与正式包目录明确区分。候选包失效后移出可分发路径并标明替代版本,不能留下同名旧 ZIP 供用户误取;对外只给已核验的正式发布链接或准确文件路径。正式版本不覆盖原标签或附件,后续修复发补丁版。
- 安装或市场版本不符时,先核对用户实际 ZIP 的 SHA256、运行源码提交及生效市场地址;不凭界面版本号判断,也不把未经验证的缓存假设当成原因。主程序与插件版本、索引兼容性分别验证。
## Boundaries ## Boundaries
@@ -79,5 +95,6 @@ Windows 首次安装使用 `start.bat`,国内镜像使用 `start-cn.bat`。启
## References ## References
- [用户入口](README.md):安装、配置与日常使用。 - [用户入口](README.md):安装、配置与日常使用。
- [设计约定](DESIGN.md):当前架构、行为与网页视觉规则。
- [AI 自动化](docs/ai-automation.md):MCP、Skill 及升级行为。 - [AI 自动化](docs/ai-automation.md):MCP、Skill 及升级行为。
- [工作区说明](docs/automation-workspace.md):报告、批量导入和历史检索。 - [工作区说明](docs/automation-workspace.md):报告、批量导入和历史检索。
+214
View File
@@ -0,0 +1,214 @@
---
name: ARTEMIS 控制台
colors:
canvas: "#f8f9fa"
surface: "#ffffff"
text: "#1e293b"
text-strong: "#0f172a"
text-secondary: "#475569"
text-muted: "#64748b"
text-control: "#334155"
border: "#e2e8f0"
border-control: "#dbe3ec"
primary: "#1a73e8"
primary-hover: "#1557b0"
primary-soft: "#eff6ff"
exploration-action: "#4f46e5"
exploration-soft: "#eef2ff"
focus: "#a5b4fc"
success: "#047857"
success-soft: "#ecfdf5"
warning: "#92400e"
warning-soft: "#fffbeb"
error: "#b91c1c"
error-soft: "#fef2f2"
rounded:
compact: 8px
control: 10px
card: 16px
composer: 22px
spacing:
tight: 4px
icon: 6px
small: 8px
group: 12px
section: 16px
workspace: 24px
components:
model-selector:
backgroundColor: "{colors.surface}"
textColor: "{colors.text-control}"
rounded: "{rounded.control}"
demonstration-control:
height: 36px
exploration-composer:
backgroundColor: "{colors.surface}"
rounded: "{rounded.composer}"
---
# ARTEMIS 设计与交互约定
## Overview
ARTEMIS 面向使用自然语言执行、探索和复跑测试的人。当前产品包含 Flash、Pro、探索三种入口,按需调用安卓设备和电脑插件,提供批次、精选任务、历史、报告及证据。用户应能看懂当前在做什么、何时需要回答、哪些结果已有证据。
本文记录主程序的职责边界及 `apps/showcase_ui` 的视觉与交互规则,包括浅色和暗色外观。远程 SDK 的协议边界见[局部设计](packages/artemis-client/DESIGN.md);独立插件仓库维护适配器设计和构建规则。阶段性进度见[开发快照](docs/development-handoff.md),当前工作区版本与发布状态见[2.2.0 说明](docs/releases/2.2.0.md)。
## System architecture
| 层 | 职责与源码入口 |
| --- | --- |
| 网页、MCP、远程 SDK | 请求建模、交互与结果读取。`apps/showcase_ui`、`mcp_server`、`packages/artemis-client` 共同使用后台服务;MCP 和薄 SDK 不另启完整后台 |
| HTTP 与业务服务 | `apps/admin_console` 管理配置、统一队列、批次、收藏、历史、人工复验和导出;2.2.0 的 PC、Android、跨端任务按提交顺序串行执行,`TaskQueueService` 与跨进程共享执行锁共同准入,旧并行参数不能绕过;暂停释放执行资源,恢复时重新排队 |
| 任务与模型 | `artemis/agents`、`artemis/runtime` 执行 Flash/Pro;`artemis/exploration` 管理交流、预算、示范与用例版本;`artemis/llm`、`artemis/services/llm.py` 统一 Responses 请求 |
| 插件与证据 | `artemis/plugins` 管理安装、会话、版本固定、来源、截图、录像和附件;插件执行工具动作,宿主管理任务生命周期和最终验收 |
| 配置与交付 | `artemis/config`、两份 `artemis.jsonc` 和环境配置提供默认值;`scripts` 负责启动、镜像、独立升级和源码打包 |
插件运行辅助接口见 [SDK 与市场材料](docs/plugin-sdk.md):已批准文件用不可变快照绑定实际消费字节,受管外部工具在返回前回收后代。宿主负责 MCP 总预算,插件负责内部超时和取消。开发检查保持轻量;市场材料使用独立内容指纹与验收记录校验,不代替源码、权利来源或真实业务审查。
请求重试、业务执行与最终验收分别处理。提交使用稳定标识;创建批次或探索草稿不自动执行,查询不产生设备动作。执行准入和恢复遵守现有资源锁,任务固定所用插件版本,不能通过另建队列绕开互斥。
设备保活是辅助服务。保活所属锁或旧保活清理目录不可访问时记录警告,不使设备连接、恢复或监控退出;未取得保活所属锁时不发送保活动作,也不创建替代锁绕过所属权。执行资源锁、主服务注册锁和队列写锁仍须成功取得。
运行时准备阶段失败记录 `initialization_failed`,批次保留详细原因并暂停未开始项,修复环境后由用户继续,已失败项不自动重跑。模型请求导致的批次暂停单独标记;模型暂停结束后恢复既定调度及失败即停止策略。用户暂停、停止全部任务和服务重启后的恢复暂停均需用户继续,不被模型恢复覆盖。Flash 与 Pro/探索 Operator 的生成失败复用统一备用模型与重试预算;不完整输出不会成为动作,已有动作不因模型切换而重放。
最终验收有独立总预算,按请求超时、重试和剩余任务时间协调,保留取证结果;完整有效的结构化结论才更新正式裁定。`completed`、工具成功、探索通过、用例提炼和复跑通过各自表达自己的状态。
Responses 消息交给工具、下游解析器或正式裁定前,必须具有服务端 `completed` 状态;工具参数的原始字符串必须是完整 JSON 对象,不能使用库自动补全的半截参数。流结束或库补出的 `last` 标记不能替代完成状态。缺少终止事件、明确未完成或失败的流丢弃预览,进入现有无效输出恢复策略,最多重新请求一次,配置为零次重试时不重试;恢复保留已有工具证据,不重新提交业务任务。非流式、结构化输出和串接解析器遵守同一检查。
普通应用启动及重试只负责启动或恢复前台,不隐式停止或重启应用;停止是独立的显式动作。焦点窗口为空时,使用已恢复活动所在任务的归属补充判断;只有可见候选任务、没有已恢复活动时继续等待。超时返回焦点与任务诊断,不升级为强制停止。
测试执行遵循用户任务指令和中途指导;明确的次数及禁止要求交给执行模型结合观察和历史遵守。执行层不额外编译自然语言约束、维护动作配额、比对提交内容指纹或请求 AI 二次审批。Flash、Pro 和插件动作经既有参数、目标及插件授权校验后调用工具;资源锁、取消与暂停、真实执行回执、输入状态观察和结果不明时不自动重发的行为继续保留。历史任务中的旧约束日志仅作为原始诊断记录,不再参与执行判定。
取消来源由拥有中断动作的运行时明确赋值,并穿过任务控制及模型等待包装层传递。用户停止、指导替换、暂停、任务或检查替换、已知外层时限,与单次请求超时、传输超时及最终验收总预算分开记录。没有来源的取消保留未知,HTTP 499 不能单独证明用户取消或代理故障;取消不进入模型重试,也不保存取消消息中的指导正文。
模型诊断复用现有 trace:自动重试保留本地请求编号,用尝试编号和上次尝试编号关联,重试及终止事件带对应尝试编号。可用的出站、服务端请求、Responses 响应及服务端链路编号随记录导出,仅采集白名单字段;历史缺失的来源及编号不补推。
短时安卓控件复用 Flash/Pro 统一动作层。`action_sequence` 是通用安卓短动作组合器,在单次工具调用内顺序执行 ensure_visible、click、wait、drag,成员之间不插入模型请求或安全网;默认不增加观测,显式可见性守卫读取本机最新层级,安卓不等待整屏截图稳定。ensure_visible 使用显式声明的面板内 target 与入口 opener 资源 ID:目标已显示时跳过打开,否则只点击入口一次,并有界等目标出现(默认 1500ms,上限 3000ms);找不到、不唯一、层级不可用或超时就停止,不自动猜入口或重试业务输入。它只用于界面准备;每次短时面板操作都应将准备和操作放在同一次调用中。普通原生元素索引点击也确认资源 ID 仍覆盖记录位置,拒绝过期坐标。序列 click 可按真实资源 ID 定位并连续点击 1–10 次,每次重新校验目标,部分完成保留 completed_taps。整组参数在首个输入前校验,沿用 `pro.execution.max_burst_actions` 的短序列上限;两种模式的运行时工具声明都暴露实际上限,等待和准备均占一项。旧 click 的 `unless_visible` 在指定面板元素可见时跳过打开按钮,不能加到实际操作按钮上;drag 的 `require_visible` 在元素消失或层级不可用时拒绝输入。所有守卫只接受真实观察到的 resource_id,精简元素列表保留这些标识,不硬编码业务应用。暂停或新指导在成员边界生效,失败后停止,保留每项开始/结束时间及 completed/skipped/unknown/not_started 回执;不自动重发已完成或结果不明的输入。后续目标须来自同布局的真实观察,每次反向调整也须连续准备并操作;短序列不能代替业务流程的观察与端点、刻度验收。
`drag` 支持明确的归一化或原图像素端点,默认 300ms,不自动过冲。可根据当前缩放下的两个真实数值刻度校准 thumb 位置,或将 pan 刻度移至固定指针;返回真实派发端点、比例及端点取整误差。校准假定本次局部刻度线性,换缩放须重新读取锚点;计算回执保留 pending_observation,实际选中值及业务目标仍须通过动作后的观察验证。像素分辨率不足时先放大,不宣称任意时间精度。
状态动作使用显式 `control_action.ensure_state`:仅以真实原生 checked/selected、文本/描述或数值判断,已满足则跳过,否则写入一次并有界只读核验。缺失状态不当作 false;核验失败保留完成的输入,不自动再切换。drag/pinch/touch_gesture 的可选 region 在首个输入前约束所有触点路径。drag 或整个 action_sequence 的可选 feedback 绑定唯一持久数值读数,在输入前确认可读,整组结束后有界等待稳定并报告实际值、目标、残差和允许误差;连续两次同策略未减小误差则拒绝第三次输入,要求重新观察、缩放或改变有效移动距离。记录仅存于当前设备执行实例,缺失读数不记作零,不共享到新任务。计算精度、实际数值条件和最终业务验收分开;无原生读数的自绘控件仍依靠视觉证据,不能宣称任意精度。详见[原生动作说明](docs/android-native-actions.md)。
任务耗时复用现有队列、工作进程控制文件与验收 trace,网页、MCP 和报告使用同一只读投影。分别列出后台排队、等待设备/资源、暂停与示范、执行与整理、最终验收及报告导出;总历时包含这些等待,不代表模型速度。缺少时间记录显示未知,不将旧任务缺失的分项补成零。运行结束后不继续累计执行耗时,报告导出单独计时。
指导接收不保证赶在终态前消费。保留接收时点、执行器消费回执及后续动作记录的区别;`consumed` 仅证明执行器已读取,不能宣称模型已应用或验收已通过。终态仍未领取的指导投影为 `unapplied`,领取后未留下消费回执的为 `unconfirmed`,不改写原始文件。网页详细查询复用原指导入口,旧数组响应保持兼容。
执行平台与证据来源分别记录。报告和证据索引从执行记录汇总来源及实际 step_id/call_id/source_id;界面、接口、文件和终端各自只能支持其观察范围,业务后台名称或网址不能推断平台。最终复核明确事实、矛盾和未验证边界;来源清单不生成新结论,不覆盖 AI 原判、有效结论或人工复验。
原生安卓动作在派发时记录所选设备来源。安卓截图来源也可从已保存的成功截图回执读取设备、采集时间、文件路径和校验值,并引用实际 trace_id;兼容 Flash 与 Pro 的回执封装,不从模型参数补造来源。空步骤、笔记和结束声明不计入缺失动作来源;窗口枚举等无单一目标的操作保留未关联状态,该计数不等于截图丢失。未生成独立输出正文时,报告展示当前有效判定及依据,保留人工复验优先级,不把正文缺失写成验收结论缺失。
规划器的笔记工具与执行器的设备、插件工具分别绑定。规划器依据任务的实际授权插件清单制定计划,由执行器加载并验证能力;规划阶段没有绑定动作工具,不表示任务缺少这些能力,也不构成重新申请授权的依据。
MCP 与单任务 HTTP 入参拒绝未知字段,校验失败不调度任务。MCP JSON 结果保留兼容文本并提供 structuredContent;任务摘要分页明确总数与 next_offset,引用实际存储编号。跨端阶段先完整初始化,再按既有阶段 id 更新状态与依据;补丁保留其他阶段、范围、资源和顺序,合并后统一校验并保存,失败不更新内存状态。
Flash 的最终 AI 判定与分析独立配置,默认关闭;首页功能配置和 MCP 共用保存的全局设置。任务开始时固定设置,尚未开始的任务使用最新值;批量、精选及提炼用例的 Flash 复跑同样遵循。关闭时跳过最终 Checker,保留正常执行、步骤、截图、报告和人工复验;结果为“执行完成 · 未做 AI 判定”,不标记“AI确认”或测试通过。Pro 与主动探索的验收规则保持不变。
示范来源保存在元数据中,贯穿分析、提问、规划、执行、验收和用例生成。补充网址不会隐式把电脑示范改到安卓;用户明确改端或提出跨端步骤时按其要求执行。证据必须对应真实来源、窗口、采集时间与操作。
录像使用用户选择的范围。人工示范的窗口模式跟随前台窗口,仅保留一路,无有效窗口时保留缺口;普通录屏在只读验收前停止。恢复操作再追加片段,时间映射以实际录像为准,不借用另一来源或时间的画面。
收藏保存独立模板与执行配置,用例复跑固定版本;删除原任务不破坏已保存模板。历史与导出的有效结论合并人工复验,不能用新裁定改写旧证据。
普通任务新安装默认 `gpt-6-luna / high`,探索单独使用 `gpt-6.1-terra / high`,保留已有配置。上下文参照上限 300k、默认历史压缩预算 80k,均不表示整条任务累计 token 限额。优化稳定前缀时保留关键文件回执、路径、校验和来源;费用收益依实际网关用量及计价判断。
普通 Pro 的计划全部完成后,执行循环返回既有收尾和最终验收流程,不要求额外设备动作;有新指导或验收反馈时先处理反馈,探索模式仍须交付完整报告。完成计划不等于测试通过。Operator 写入计划时拒绝新增或增加同名顶层里程碑,撤销该次写入并返回工具错误;局部替换不得夹带目标范围外的里程碑,已有重复项可继续整理,确需重复的阶段使用不同名称。
## Colors
浅色使用浅灰画布、白色内容面和深色正文;暗色使用深蓝灰画布、分层内容面和浅色正文。首页主要操作使用蓝色,探索发送及相关主操作保留靛色;绿色、琥珀色、红色用于结果与需要处理的状态。新控件复用所在区域的语义色。
| 用途 | 值与来源 |
| --- | --- |
| 画布 / 正文 | `#f8f9fa` / `#1e293b`,来自[全局样式](apps/showcase_ui/src/styles.scss) |
| 首页操作 / 悬停 | `#1a73e8` / `#1557b0`,来自[首页样式](apps/showcase_ui/src/app/pages/home/home.component.scss) |
| 探索操作 / 浅色状态面 | `#4f46e5` / `#eef2ff`,来自[探索面板](apps/showcase_ui/src/app/components/exploration-panel/exploration-panel.component.scss) |
| 正文层级 / 边框 | 强调 `#0f172a`、次级 `#475569`、辅助 `#64748b`;常规边框 `#e2e8f0` |
| 成功、等待、错误 | 浅色外观用深色状态文字配对应浅色面,暗色采用下方语义映射;同时给出文字、图标或说明,结论不只依靠颜色 |
全局导航使用单个太阳/月亮按钮,点击一次直接切换浅色与暗色,不使用下拉菜单。首次使用跟随系统,点击后记住当前浏览器的明确选择;首次绘制前应用,页面切换和刷新保持一致。按钮图标和可访问名称提示点击后将切换的外观。
颜色通过 `--app-*` 语义变量引用,浅色保留组件原有回退值,暗色集中定义在[主题样式](apps/showcase_ui/src/_theme.scss)。主要映射如下:
| 语义变量 | 暗色值 |
| --- | --- |
| `--app-canvas` / `--app-surface` | `#0b1220` / `#111c2e` |
| `--app-surface-soft` / `--app-surface-raised` | `#172337` / `#1e2c42` |
| `--app-text-strong` / `--app-text-control` | `#f1f5f9` / `#dbe4f0` |
| `--app-text-secondary` / `--app-text-muted` | `#bdcadd` / `#98aac1` |
| `--app-border` / `--app-border-control` | `#2b3b52` / `#3d506a` |
| `--app-primary-text` / `--app-exploration-text` | `#8ebcff` / `#b7b3ff` |
| `--app-success-text` / `--app-warning-text` / `--app-error-text` | `#6ee7b7` / `#fcd089` / `#ff9ca7` |
状态文字、状态背景、边框和高光分别引用对应变量;保留实体主按钮的色彩与白色按钮文字。截图、录像和用户文件使用原始颜色,不对页面整体施加反色滤镜。
加载、空状态、断开、未验证与失败使用明确文案。执行完成可以有完成标识,测试裁定仍需独立展示;不能让绿色完成态替代测试通过结论。
## Typography
沿用全局字体栈:`Google Sans`、`Google Sans Text`、`Inter`、系统无衬线字体;中文使用系统可用字形。代码、路径及请求诊断使用现有等宽样式,保留换行与复制能力。
`Inter`、`Outfit`、`Roboto` 及 `Material Symbols Outlined` 使用随源码分发的[本地字体](apps/showcase_ui/public/fonts/README.md),构建和显示不依赖 Google Fonts。图标保留完整字形及 `FILL`、`GRAD`、`opsz`、`wght` 可变轴,不用文字代替缺失图标。
模型控件正文为 13px,探索输入正文为 14px,用例标题为 18px;辅助信息依现有组件使用 11–12px。保留字号、字重、留白形成的层次,不通过大段加粗堆叠状态。Markdown、表格、长链接和中文长标题应换行,不撑破容器。
计划、笔记和报告共用 Markdown 渲染,支持标题、表格与列对齐、嵌套列表、任务清单、引用、分隔线、链接、图片、删除线及围栏代码块。宽表格与代码在自身区域横向滚动,保留代码空白和字面内容。计划中的 `[/]` 进行中、`verify` / `assert@end` 和 `finding` 标识继续可辨认。原始 HTML 按文字展示,链接与图片限制安全协议,并保留 Angular 的 HTML 清理。
图标沿用 Material Symbols Outlined,常规全局尺寸 20px;组件局部按原有尺寸调整。图标与相邻文字垂直居中,独立图标按钮提供可访问名称。
## Layout
- 首页负责创建任务与访问精选模板;工作区主区域展示当前任务的交流、执行步骤和结果,底部输入框始终服务当前任务。
- 编辑、模型设置和复跑参数按需展开,默认保持任务时间线可读。精选卡片在收起状态下等高,长标题、不同状态和展开内容不会遮挡操作入口。
- 通常以 4、6、8、12、16、24px 组织内部间距;沿用组件现有网格和断点。子容器允许收缩,长内容使用 `min-width: 0` 及合适换行。
- 探索面板在 600px 以下收紧外边距并允许操作换行;模型选择控件宽度跟随容器。窄屏仍可辨认状态、选择来源和发送指导。
- 列表后台刷新保留滚动、选择和折叠状态;首次加载与静默更新分开,避免周期性加载提示挤动内容。实时连接复用现有页面可见性管理。
- 结束任务的错误与阻塞说明默认只占一行,点击展开安全 Markdown 正文与已有依据,长正文限高滚动。摘要优先采用已保存的有效结论原因,否则取执行错误首行;不新增 AI 复盘请求,不改写原始记录。展开说明不触发任务切换,刷新保留展开状态。
## Elevation
卡片使用细边框和克制的阴影区分区域。首页现有玻璃效果、渐变和按钮悬停保留在原有区域,正文、报告、表单和选项保持清晰背景及文字对比。
探索输入区沿用 `0 6px 24px #0f172a08` 的阴影;焦点反馈沿用所在控件的轮廓和浅色描边。全局 CDK overlay 层级为 1200,新增弹层先使用既有 overlay 机制,不任意叠加更大的层级。
## Shapes
模型输入与选择框圆角 10px;紧凑控件常用 8px,内容卡片按组件使用 12–16px,探索输入区为 22px。保留现有徽标、标签与胶囊形控件的局部形状;这些值不要求把全部历史控件改成同一圆角。
## Components
| 组件或状态 | 必须维持的交互与尺寸 | 实现依据 |
| --- | --- | --- |
| 模型选择 | 预设选择加自定义输入;主模型、兜底和精选任务复用同一组件。最小高度 40px、内边距 10px 12px、圆角 10px,保留焦点与禁用态 | [model-selector](apps/showcase_ui/src/app/components/model-selector/model-selector.component.ts) |
| 人工示范控件 | 来源标签与控件水平对齐,选择框及开始按钮均为 36px 高、垂直居中;窄屏可换行。准备、录制、分析分别显示 | [exploration-panel](apps/showcase_ui/src/app/components/exploration-panel/exploration-panel.component.scss) |
| 探索建议 | 推荐内容为用户口吻的可编辑回复;点击只填草稿,用户发送后才继续任务 | [探索组件](apps/showcase_ui/src/app/components/exploration-panel/exploration-panel.component.ts) |
| 历史标题与星标 | 星标只占标题,第二行状态与编号正常左对齐;状态徽标沿用 20px 高及统一内边距 | [task-history](apps/showcase_ui/src/app/components/task-history/task-history.component.ts) |
| 验收状态 | 展示准备证据、核对、生成与校验等阶段及可用的耗时、预算、重试诊断;未完成的流式文字不变为正式通过标签 | [agent-stream](apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html) |
| 插件工具回执 | 先显示窗口、控件、截图等已有关键字段与短预览;长文本、控件树及完整原始 payload 可展开查看。只调整网页呈现,保留原始证据和模型上下文 | [plugin-tool-result](apps/showcase_ui/src/app/components/plugin-tool-result/plugin-tool-result.component.ts) |
| 截图、录像与文件 | 显示真实来源和缺口;按时间定位并保留跳转前的播放/暂停状态,文件成功以真实保存回执为准 | [工作区说明](docs/automation-workspace.md) |
| 保存和错误反馈 | 配置加载、保存、空值、失败与密钥已配置状态有明确提示;空密钥保留已有值,连接测试与保存分开 | [模型配置](apps/showcase_ui/src/app/components/model-settings/model-settings.component.ts) |
| Flash 判定开关 | 切换即保存,保存中禁用,失败恢复已保存值;不顺带提交其他配置草稿。说明尚未开始的任务生效、无需重启,运行中任务保持原配置 | [功能配置](apps/showcase_ui/src/app/components/model-settings/capability-settings.component.ts) |
| 仅 PC 勾选项 | 位于首页输入区,与扩展插件控件共用配色和尺寸;默认关闭。开启后切换电脑文案与插件目标,就绪条件保留运行时和模型,排除 Android 设备、锁屏、ADB 及手机录屏工具;单任务提交使用 execution_target=computer | [首页](apps/showcase_ui/src/app/pages/home/home.component.ts) |
| 外观选择 | 所有页面共用,键盘可操作;窄屏可收起导航文字,但保留链接的可访问名称 | [导航](apps/showcase_ui/src/app/components/nav-switcher/nav-switcher.component.ts) |
这些规则对应已接受的现有行为。改动后结合实际层叠样式、渲染尺寸和相关状态检查,不能只核对一处声明。
## Do's and Don'ts
- 复用现有组件、API 类型、中文文案和状态语义。新增状态先说明它与执行、验收或人工操作的关系。
- 保持实际来源与证据一致,缺失时说明原因;不使用手机画面替代电脑操作,也不把工具成功当作业务通过。
- 保留可见焦点、禁用反馈和减少动画偏好;录制红点表示实际录制,等待或执行中使用各自文案。
- 源码已更新而页面仍旧时,先核对服务目录、构建与缓存;不通过反复增加样式覆盖掩盖旧构建问题。
- 视觉变化验证代表性宽度、长标题、加载、等待回答、错误与展开状态;文档格式检查不等于渲染验证。开发检查按[前端指南](apps/showcase_ui/AGENTS.md)执行。
## Workspace plugin extensions
工作区扩展使用声明式清单版本 3,宿主提供回放面板、人工表单、任务存储和报告接口;不运行插件页面脚本,不另建队列或结果库。纯工作区插件不加入模型执行选择。接口定义见 [工作区插件](docs/workspace-plugins.md)。
回放两侧面板默认收起、独立展开,普通浮窗和影院模式共用;插件声明期望/最小/最大宽度,宿主按视口收缩,窄屏一次展开一侧,始终保持面板位于回放的左侧或右侧。任务需求只展示本次运行最初的提示词,复用安全 Markdown 渲染。人工表单字段均可留空,不调用模型、不覆盖已有执行结论或人工复验。
评审按单次任务和插件 ID 保存版本历史;运行中只读,终态可编辑或清空。停用/卸载隐藏面板与新导出字段但保留记录,任务删除时同步清理。报告只采用已安装且启用的贡献,空值保持空白;修改记录或插件状态使后续报告缓存失效。
执行详情中的 Payload 默认折叠;其他超长字段也默认折叠,展开后保留完整可复制内容并限制显示高度。原始用例面板仅由启用的工作区插件声明,额外导入列不影响未启用插件的用户。评审支持所有执行模式和目标,用户修改评审后自动保存:结果选择立即保存,备注短暂防抖;切换任务前保存,失败时保留输入和当前任务。仅用户编辑触发写入,AI 不推断人工结论。
模型网络代理策略仅由环境项 `OPENAI_USE_SYSTEM_PROXY` 控制,默认 false。同步、异步模型客户端和模型凭据检查统一遵循;不出现在前端配置中,不改变 TLS 验证与自定义 CA 设置。
+14 -6
View File
@@ -2,13 +2,13 @@
基于 [ARTEMIS](https://github.com/google/artemis) 扩展的 Android AI 自动化测试平台。用自然语言描述任务,即可通过简体中文网页或 MCP 驱动真机、模拟器执行操作并收集结果。模型统一使用 **OpenAI Responses API**。 基于 [ARTEMIS](https://github.com/google/artemis) 扩展的 Android AI 自动化测试平台。用自然语言描述任务,即可通过简体中文网页或 MCP 驱动真机、模拟器执行操作并收集结果。模型统一使用 **OpenAI Responses API**。
**2.0.0 已发布**:新增交互探索、插件扩展和精选任务,增强跨端证据与 AI 接入。完整变化及升级步骤见 [2.0.0 发行说明](docs/releases/2.0.0.md)。 **2.7.1**:统一任务操作与录屏使用的 ADB,修复模型空结果引起的应用查找误报,并在任务资料 ZIP 中保留运行环境诊断和批次原暂停原因。见 [2.7.1 发行说明](docs/releases/2.7.1.md)。继续包含 [2.7.0 的 Responses WebSocket 增量传输](docs/releases/2.7.0.md)、[2.6.0 的 Android 原生控件与多指手势](docs/releases/2.6.0.md)、[2.5.0 的动作序列与缓存统计](docs/releases/2.5.0.md)及 [2.4.5 的插件开发 SDK](docs/releases/2.4.5.md);SDK 与插件继续独立版本化,升级主程序不会自动更新或授权插件。
## 主要功能 ## 主要功能
- **自然语言任务**:Flash 适合明确的操作流程,Pro 适合需要计划、分支探索和验证的任务。 - **自然语言任务**:Flash 适合明确的操作流程,Pro 适合需要计划、分支探索和验证的任务。
- **交互探索**:边执行边交流,也可录制人工示范;探索成功后生成一个可编辑、复跑的用例。默认使用独立的 `gpt-6.1-sol / high` 配置,超时暂停并保留进度。见 [探索模式](docs/exploration-mode.md)。 - **交互探索**:边执行边交流,也可录制人工示范;探索成功后生成一个可编辑、复跑的用例。默认使用独立的 `gpt-6.1-terra / high` 配置,超时暂停并保留进度。见 [探索模式](docs/exploration-mode.md)。
- **插件扩展**:在插件市场安装并启用电脑控制等能力,AI 在安卓任务中按需使用;手机、电脑的步骤与证据统一留档。见 [插件与跨端任务](docs/plugins.md)。 - **插件扩展**:安装并启用电脑控制等执行能力,或使用工作区插件为回放和报告增加人工评审。手机、电脑的步骤与证据统一留档。见 [插件与跨端任务](docs/plugins.md)和[工作区插件接口](docs/workspace-plugins.md)。
- **可保存的模型配置**:支持自定义接口、模型、思考强度和超时重试,以及视频分析与录屏开关。 - **可保存的模型配置**:支持自定义接口、模型、思考强度和超时重试,以及视频分析与录屏开关。
- **批量测试**:按新版 Excel 模板导入提示词,每项可独立指定模型、模式和参数;任务可暂停、补充指导并继续。 - **批量测试**:按新版 Excel 模板导入提示词,每项可独立指定模型、模式和参数;任务可暂停、补充指导并继续。
- **精选任务**:收藏单任务、批次或探索用例,在首页一键复跑,默认沿用已保存的配置。 - **精选任务**:收藏单任务、批次或探索用例,在首页一键复跑,默认沿用已保存的配置。
@@ -17,7 +17,11 @@
## 安装与启动 ## 安装与启动
准备一台开启 USB 调试并完成授权的 Android 手机,或已启动的 Android 模拟器。首次安装需要联网。可使用 Git 克隆或下载 1.1.1 及更新版本的发布源码包;新版 ZIP 解压后也保留 Git 更新能力。 Android 或跨端测试需准备一台开启 USB 调试并完成授权的 Android 手机,或已启动的 Android 模拟器;仅 PC 测试无需手机在线,但需启用并授权电脑观察插件。首次安装需要联网。可使用 Git 克隆或下载 1.1.1 及更新版本的发布源码包;新版 ZIP 解压后也保留 Git 更新能力。
网页字体和 Material Symbols 图标已随源码提供,网页构建及显示无需访问 Google Fonts。
顶部导航的太阳/月亮按钮可一键切换亮暗主题,自动记住当前浏览器的选择;首次使用跟随系统。计划、笔记和报告支持 Markdown 表格、嵌套列表、任务清单、引用、链接及代码块,宽表格可在内容区横向滚动。
### Windows ### Windows
@@ -50,15 +54,19 @@ cd Artemis-Eapil
1. 在首页“系统设置与运行准备”中选择设备,打开“模型配置”。 1. 在首页“系统设置与运行准备”中选择设备,打开“模型配置”。
2. 填写支持 Responses 的接口地址、API Key 和模型名称,测试连接后保存。接口地址通常是以 `/v1` 结尾的 API 根地址。 2. 填写支持 Responses 的接口地址、API Key 和模型名称,测试连接后保存。接口地址通常是以 `/v1` 结尾的 API 根地址。
3. 输入任务目标,选择 Flash、Pro 或探索后执行;在工作区查看步骤、截图和结果。流程尚不明确时,选择探索并随时交流。 3. 输入任务目标,选择 Flash、Pro 或探索后执行;仅测试电脑时勾选输入区的“仅 PC”,无需手机或模拟器在线,但需已启用并授权的电脑观察插件。在工作区查看步骤、截图和结果。流程尚不明确时,选择探索并随时交流。
4. 通过“报告与批量”导出报告或导入 Excel 用例;任务队列支持历史搜索。 4. 通过“报告与批量”导出报告或导入 Excel 用例;任务队列支持历史搜索。
预设模型为 `gpt-6-luna`、`gpt-6.1-sol`、`gpt-6-astra`、`mimo-v2.6-pro`,主模型与兜底模型均可选择预设或填写自定义名称。新安装默认模型是 `gpt-6-luna`,思考强度默认 `high`(最高可选 `max`),单次请求超时 **300 秒**,失败后最多重试 **5 次**,间隔 **10 秒**。遇到 429 请求过多时改为每次等待 **60 秒**。连续 3 次没有有效工具调用时,本步骤改用 `gpt-6-luna`(high),兜底模型最多再尝试 3 次;下一步恢复首选模型。视频分析与录屏默认关闭。模型及思考参数是否可用取决于所配置网关;不支持时会明确报错。 预设模型为 `gpt-6-luna`、`gpt-6.1-terra`、`gpt-6.1-sol`、`gpt-6-astra`、`mimo-v2.6-pro`,主模型与兜底模型均可选择预设或填写自定义名称。新安装默认模型是 `gpt-6-luna`,思考强度默认 `high`(最高可选 `max`),单次请求超时 **300 秒**,失败后最多重试 **5 次**,间隔 **10 秒**。遇到 429 请求过多时改为每次等待 **60 秒**。连续 3 次没有有效工具调用时,本步骤改用 `gpt-6-luna`(high),兜底模型最多再尝试 3 次;下一步恢复首选模型。视频分析与录屏默认关闭。模型及思考参数是否可用取决于所配置网关;不支持时会明确报错。
也可在项目 `.env` 中设置 `OPENAI_API_KEY`、`OPENAI_BASE_URL` 和 `OPENAI_MODEL`,其他参数见 [.env.example](.env.example)。新安装默认接口地址为 `https://api-n-cd.playones.com/v1`;已保存的地址不变。直接编辑环境文件后需重启相应服务。不要提交自己的密钥。 也可在项目 `.env` 中设置 `OPENAI_API_KEY`、`OPENAI_BASE_URL` 和 `OPENAI_MODEL`,其他参数见 [.env.example](.env.example)。新安装默认接口地址为 `https://api-n-cd.playones.com/v1`;已保存的地址不变。直接编辑环境文件后需重启相应服务。不要提交自己的密钥。
模型传输默认 **WebSocket(增量传输)**。升级后首次启动会将旧的全局 HTTP 配置一并迁移为 WebSocket;之后仍可在“模型配置 → 传输协议”手动切回 HTTP,后续启动保留手动选择。Flash、Pro、探索及辅助模型的异步请求复用 Responses 连接,历史一致时只上传新增输入;网关需支持 `/v1/responses` 的 WebSocket 升级与响应链。详细行为与流量诊断见[传输说明](docs/ai-automation.md#responses-websocket-增量传输)。
“功能开关”默认将任务截图长边限制为 2048 像素;关闭后保留设备原始分辨率。可在 [config/artemis.jsonc](config/artemis.jsonc) 的 `agent.screenshot` 中覆盖 `enabled` 和 `max_long_edge`,重启后对新任务生效。 “功能开关”默认将任务截图长边限制为 2048 像素;关闭后保留设备原始分辨率。可在 [config/artemis.jsonc](config/artemis.jsonc) 的 `agent.screenshot` 中覆盖 `enabled` 和 `max_long_edge`,重启后对新任务生效。
首页“功能配置”提供“Flash 最终 AI 判定与分析”,默认关闭。切换后自动保存并提示结果,失败恢复原值;无需重启服务。开启后,Flash 在执行结束时根据用例需求和执行步骤进行独立复核;关闭后显示“执行完成 · 未做 AI 判定”,保留执行步骤、截图和报告,不自动记为测试通过。此设置适用于所有 Flash 任务,包括批量、精选、MCP/SDK 和探索提炼用例的 Flash 复跑;保存后对尚未开始的任务生效,运行中的任务保持原设置。Pro 和主动探索不受影响。其余功能配置仍需点击底部保存按钮。
报告会区分“执行完成”和“验证通过”;没有验证证据的结果标为“未验证”。任务步骤与截图可查看,设备动作回放不提供。 报告会区分“执行完成”和“验证通过”;没有验证证据的结果标为“未验证”。任务步骤与截图可查看,设备动作回放不提供。
## 让 AI 使用 ## 让 AI 使用
@@ -15,6 +15,7 @@
import json import json
import logging import logging
import os import os
import re
import sqlite3 import sqlite3
import time import time
from typing import Any from typing import Any
@@ -235,6 +236,47 @@ class SessionRepository:
rows = cursor.fetchall() rows = cursor.fetchall()
return [r[0] for r in rows if r and r[0]] return [r[0] for r in rows if r and r[0]]
def get_recorded_model_info_map(self, session_ids: list[str]) -> dict[str, dict[str, str]]:
"""Read each session's own requested model from its earliest diagnostics.
Prefer operator/Flash requests over auxiliary roles. Older sessions may
have no model diagnostics; do not replace their missing data with the
current global configuration.
"""
result: dict[str, dict[str, str]] = {}
safe_payload = "CASE WHEN json_valid(t.payload) THEN t.payload ELSE '{}' END"
valid_model = f"json_type({safe_payload}, '$.model') = 'text'"
role = f"lower(json_extract({safe_payload}, '$.role'))"
with db_session(self.db_path) as conn:
for offset in range(0, len(session_ids), 500):
batch = session_ids[offset : offset + 500]
placeholders = ",".join("?" for _ in batch)
rows = conn.execute(
"SELECT s.session_id, COALESCE("
" (SELECT t.payload FROM traces t WHERE t.session_id = s.session_id"
f" AND t.type = 'llm_diagnostic' AND {valid_model}"
f" AND {role} IN ('operator', 'flashrunner')"
" ORDER BY t.timestamp ASC LIMIT 1),"
" (SELECT t.payload FROM traces t WHERE t.session_id = s.session_id"
f" AND t.type = 'llm_diagnostic' AND {valid_model}"
" ORDER BY t.timestamp ASC LIMIT 1)"
") AS model_payload FROM sessions s"
f" WHERE s.session_id IN ({placeholders})",
batch,
).fetchall()
for row in rows:
if not row[1]:
continue
payload = json.loads(row[1])
model = payload.get("model")
if isinstance(model, str) and model.strip():
provider = payload.get("provider")
result[str(row[0])] = {
"id": model,
"provider": provider if isinstance(provider, str) else "",
}
return result
def get_llm_traces_for_profiles_map( def get_llm_traces_for_profiles_map(
self, session_ids: list[str] | None = None, limit: int = 3 self, session_ids: list[str] | None = None, limit: int = 3
) -> dict[str, list[str]]: ) -> dict[str, list[str]]:
@@ -349,7 +391,22 @@ class SessionRepository:
total += t total += t
cached += _int(payload.get("cached_tokens")) cached += _int(payload.get("cached_tokens"))
node = str(payload.get("node") or "").strip().lower() node = str(payload.get("node") or "").strip().lower()
model_name = str(payload.get("model") or "unknown") model_name = payload.get("model")
if not model_name:
# Older gateway traces carry the model in their own endpoint key.
source = str(payload.get("source") or "")
source_parts = source.split(":", 2)
if len(source_parts) == 3 and source_parts[0] == "openai":
# Model aliases can contain colons. The HTTP(S) base URL
# marks the boundary; an ambiguous key stays unknown.
match = re.match(r"^openai:(.+?):https?://", source)
model_name = match[1] if match else None
elif len(source_parts) == 3 and source_parts[:2] in (
["lens", "visual_transition"],
["lens", "step_capsule"],
):
model_name = source_parts[2] if source_parts[2] != "fallback" else None
model_name = str(model_name or "unknown")
group = by_node_model.setdefault( group = by_node_model.setdefault(
(node, model_name), (node, model_name),
{ {
@@ -220,6 +220,41 @@ class StepRepository:
# recovering, waiting, completed, and terminal outcomes. Other tools # recovering, waiting, completed, and terminal outcomes. Other tools
# retain the historical compact payload to avoid sending large blobs. # retain the historical compact payload to avoid sending large blobs.
res = payload_obj.get("result") res = payload_obj.get("result")
if trace_type == "tool" and trace_name == "android_file":
# Preserve host-written file receipts, not file contents. A file's
# checksum is not a screenshot-store image ID.
if isinstance(res, str):
try:
res = json.loads(res)
except ValueError:
res = None
if isinstance(res, dict) and "outcome" in res:
res = res["outcome"]
if isinstance(res, str):
try:
res = json.loads(res)
except ValueError:
res = None
if isinstance(res, dict):
result_payload["result"] = {
key: self._clean_value(res[key])
for key in (
"ok",
"action",
"path",
"trace_path",
"device_path",
"bytes",
"sha256",
"verified",
"width",
"height",
"captured_at",
"device_serial",
)
if key in res
}
return result_payload
is_video_analysis = str(trace_name or "").lower() in { is_video_analysis = str(trace_name or "").lower() in {
"video_analysis", "video_analysis",
"video_analyzer", "video_analyzer",
+3 -1
View File
@@ -12,7 +12,7 @@ from apps.admin_console.services.exploration_listing import EXPLORATION_RUNS_CTE
from apps.admin_console.services.automation_store import identifier, store from apps.admin_console.services.automation_store import identifier, store
from apps.admin_console.services import automation_service as service from apps.admin_console.services import automation_service as service
from apps.admin_console.services import batch_archive as archive_service from apps.admin_console.services import batch_archive as archive_service
from apps.admin_console.services import report_export, task_import from apps.admin_console.services import report_export, task_extensions, task_import
from artemis.config.settings import settings from artemis.config.settings import settings
from apps.admin_console.services.case_review import read_reviewed_result from apps.admin_console.services.case_review import read_reviewed_result
@@ -131,7 +131,9 @@ def batch_by_request(request_id: str):
@router.get("/batches/{batch_id}") @router.get("/batches/{batch_id}")
def batch_detail(batch_id: str): def batch_detail(batch_id: str):
jobs = store.jobs(batch_id) jobs = store.jobs(batch_id)
reviewed = task_extensions.reviewed_batch_sessions(batch_id, store)
for job in jobs: for job in jobs:
job["human_reviewed"] = job["session_id"] in reviewed
job["test_result"] = read_reviewed_result( job["test_result"] = read_reviewed_result(
job["session_id"], job["status"], job.get("error") or "", storage=store job["session_id"], job["status"], job.get("error") or "", storage=store
) )
+3
View File
@@ -214,6 +214,9 @@ async def diagnose_plugin(plugin_id: str, request: DiagnoseRequest):
try: try:
store = get_store() store = get_store()
manifest = PluginManifest.model_validate(store.get(plugin_id)["manifest"]) manifest = PluginManifest.model_validate(store.get(plugin_id)["manifest"])
if manifest.workspace and manifest.server is None and manifest.skill is None:
store.resolve(PluginSelection(id=plugin_id, profile=request.profile))
return {"ok": True, "tools": {}, "message": "工作区扩展已就绪"}
runtime = PluginRuntime( runtime = PluginRuntime(
[PluginSelection(id=plugin_id, profile=request.profile)], [PluginSelection(id=plugin_id, profile=request.profile)],
store=store, store=store,
+10
View File
@@ -84,6 +84,9 @@ def _list_sessions_sync():
with store.connection() as connection: with store.connection() as connection:
exploration_runs = exploration_metadata(connection) exploration_runs = exploration_metadata(connection)
jobs_by_id = store.jobs_for_sessions([str(row.get("session_id")) for row in rows]) jobs_by_id = store.jobs_for_sessions([str(row.get("session_id")) for row in rows])
recorded_models = session_repo.get_recorded_model_info_map(
[str(row.get("session_id")) for row in rows]
)
video_rec_map = session_repo.get_video_recordings_map() video_rec_map = session_repo.get_video_recordings_map()
latest_recordings = session_repo.get_latest_video_recordings_map() latest_recordings = session_repo.get_latest_video_recordings_map()
agent_names_by_session = session_repo.get_agent_trace_names_map() agent_names_by_session = session_repo.get_agent_trace_names_map()
@@ -217,6 +220,13 @@ def _list_sessions_sync():
print(f"[list_sessions] Failed to update orphaned sessions: {e}") print(f"[list_sessions] Failed to update orphaned sessions: {e}")
for row in result: for row in result:
# Session history must not inherit today's global model configuration.
row["model_info"] = {
**row["model_info"],
"id": "",
"provider": "",
**recorded_models.get(str(row.get("session_id")), {}),
}
if row.get("exploration_kind") == "validation" and row.get("exploration_case_name"): if row.get("exploration_kind") == "validation" and row.get("exploration_case_name"):
row["display_name"] = row["exploration_case_name"] row["display_name"] = row["exploration_case_name"]
model = row.pop("exploration_model", None) model = row.pop("exploration_model", None)
@@ -0,0 +1,67 @@
"""Workspace UI extension API. No model or device actions are dispatched."""
from uuid import UUID
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, ConfigDict, Field
from apps.admin_console.services import batch_archive, report_export, task_extensions
from apps.admin_console.services.automation_store import store
router = APIRouter(prefix="/api/task-extensions", tags=["task-extensions"])
class ExtensionUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
values: dict[str, str] = Field(max_length=20)
expected_revision: int = Field(ge=0)
request_id: UUID
@router.get("/{session_id}")
def read_extensions(session_id: str):
try:
return {
**task_extensions.task_context(session_id, store),
"plugins": task_extensions.contributions(session_id, store),
}
except ValueError as exc:
raise HTTPException(404, str(exc)) from exc
@router.put("/{session_id}/{plugin_id}")
def update_extension(session_id: str, plugin_id: str, value: ExtensionUpdate):
job = store.job(session_id) or {}
batch_id = job.get("batch_id")
if (
batch_id
and batch_archive.status(batch_id, store.get("batch", batch_id) or {})["status"]
== "preparing"
):
raise HTTPException(409, "报告正在打包,请稍后保存评审")
with (
report_export.export_lock(
*(["batch:" + batch_id] if batch_id else []), "session:" + session_id
),
task_extensions.plugin_store().mutation_lock,
):
context = read_extensions(session_id)
if not context["editable"]:
raise HTTPException(409, "请在任务结束后填写评审")
manifest = next((m for m in task_extensions.active_manifests() if m.id == plugin_id), None)
if manifest is None:
raise HTTPException(409, "插件未安装或未启用,请刷新页面")
try:
task_extensions.save_values(
store,
session_id,
manifest,
value.values,
value.expected_revision,
str(value.request_id),
)
except ValueError as exc:
raise HTTPException(409, str(exc)) from exc
if batch_id:
batch_archive.forget(batch_id)
return read_extensions(session_id)
+53 -4
View File
@@ -70,7 +70,7 @@ def pause_running_task(session_id: str):
@router.post("/api/tasks/{session_id}/guide") @router.post("/api/tasks/{session_id}/guide")
def guide_running_task(session_id: str, value: GuidanceRequest): def guide_running_task(session_id: str, value: GuidanceRequest):
_live_task(session_id) live = _live_task(session_id)
from apps.admin_console.services.exploration_service import exploration_service from apps.admin_console.services.exploration_service import exploration_service
from artemis.exploration.store import ExplorationConflict from artemis.exploration.store import ExplorationConflict
@@ -93,18 +93,33 @@ def guide_running_task(session_id: str, value: GuidanceRequest):
or (control_dir(session_id) / "pause.request").exists() or (control_dir(session_id) / "pause.request").exists()
): ):
request_resume(session_id) request_resume(session_id)
return {"session_id": session_id, "status": "guidance_submitted", "guidance": guidance} from apps.admin_console.services.task_lifecycle import guidance_details
details = guidance_details(session_id)
guidance = next(
(item for item in details["instructions"] if item["id"] == guidance.get("id")), guidance
)
return {
"session_id": session_id,
"status": "guidance_submitted",
"guidance": guidance,
"worker_active": live["process"].returncode is None,
"message": "指导已接收;接收不保证任务结束前消费,请以消费记录为准。",
}
@router.get("/api/tasks/{session_id}/guidance") @router.get("/api/tasks/{session_id}/guidance")
def task_guidance_history(session_id: str): def task_guidance_history(session_id: str, details: bool = False):
from apps.admin_console.services.automation_store import identifier from apps.admin_console.services.automation_store import identifier
try: try:
identifier(session_id) identifier(session_id)
except ValueError as exc: except ValueError as exc:
raise HTTPException(400, str(exc)) from exc raise HTTPException(400, str(exc)) from exc
return list_instructions(session_id) from apps.admin_console.services.task_lifecycle import guidance_details
result = guidance_details(session_id)
return result if details else result["instructions"]
@router.post("/api/tasks/{session_id}/continue") @router.post("/api/tasks/{session_id}/continue")
@@ -426,6 +441,24 @@ async def get_status():
) )
model_info = model_service.get_active_model_info(active_profile) model_info = model_service.get_active_model_info(active_profile)
model_sid = running_sid if is_running else latest_session_id
if model_info and model_sid:
recorded_model = session_repo.get_recorded_model_info_map([str(model_sid)]).get(
str(model_sid)
)
if not isinstance(recorded_model, dict):
matching_task = next(
(
item
for item in state.queue_items
if isinstance(item, dict) and str(item.get("session_id")) == str(model_sid)
),
None,
)
override = (matching_task or {}).get("runtime_overrides") or {}
model = override.get("model")
recorded_model = {"id": model, "provider": "openai"} if model else {}
model_info = {**model_info, "id": "", "provider": "", **recorded_model}
# Unified Global Queue: merge web tasks and external SDK/CLI device queue tickets # Unified Global Queue: merge web tasks and external SDK/CLI device queue tickets
global_queued = DeviceExecutionLock.get_queued_tasks() global_queued = DeviceExecutionLock.get_queued_tasks()
@@ -451,6 +484,16 @@ async def get_status():
with store.connection() as connection: with store.connection() as connection:
exploration_runs = exploration_metadata(connection) exploration_runs = exploration_metadata(connection)
for item in [*queue_data, *active_tasks]: for item in [*queue_data, *active_tasks]:
control_status = (
read_state(str(item["session_id"])).get("status") if item.get("session_id") else None
)
item["phase"] = {
"queued": "waiting_resources",
"resuming": "waiting_resources",
"task_paused": "paused",
"demonstrating": "demonstrating",
"analyzing_demo": "analyzing_demo",
}.get(control_status)
item.update(exploration_runs.get(str(item.get("session_id")), {})) item.update(exploration_runs.get(str(item.get("session_id")), {}))
if job := batch_jobs.get(str(item.get("session_id"))): if job := batch_jobs.get(str(item.get("session_id"))):
item["batch_id"] = job.get("batch_id") item["batch_id"] = job.get("batch_id")
@@ -483,6 +526,12 @@ async def get_status():
is_paused = state.is_paused is_paused = state.is_paused
conn_profile = conn_info.get("profile") or active_profile conn_profile = conn_info.get("profile") or active_profile
conn_model_info = model_service.get_active_model_info(conn_profile) conn_model_info = model_service.get_active_model_info(conn_profile)
if conn_model_info and model_info:
conn_model_info = {
**conn_model_info,
"id": model_info["id"],
"provider": model_info["provider"],
}
return { return {
"status": "paused" if is_paused else "running", "status": "paused" if is_paused else "running",
"paused_error": state.paused_error if is_paused else None, "paused_error": state.paused_error if is_paused else None,
+3 -1
View File
@@ -12,11 +12,13 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
from pydantic import BaseModel from pydantic import BaseModel, ConfigDict
from apps.admin_console.services.task_import import TaskOptions from apps.admin_console.services.task_import import TaskOptions
class RunRequest(BaseModel): class RunRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
goal: str | None = None goal: str | None = None
goals: list[str] | None = None goals: list[str] | None = None
profile: str | None = "flash" profile: str | None = "flash"
+3
View File
@@ -284,6 +284,9 @@ app.include_router(favorites_router)
from apps.admin_console.routers.plugins import router as plugins_router from apps.admin_console.routers.plugins import router as plugins_router
app.include_router(plugins_router) app.include_router(plugins_router)
from apps.admin_console.routers.task_extensions import router as task_extensions_router
app.include_router(task_extensions_router)
from apps.admin_console.routers.exploration import router as exploration_router from apps.admin_console.routers.exploration import router as exploration_router
@@ -15,6 +15,7 @@ from artemis.runtime.task_limits import task_timeout
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
TERMINAL = {"completed", "failed", "cancelled", "skipped", "interrupted"} TERMINAL = {"completed", "failed", "cancelled", "skipped", "interrupted"}
MODEL_PAUSE_REASON = "模型请求已暂停,请先恢复模型连接再继续批次"
background_tasks: set[asyncio.Task] = set() background_tasks: set[asyncio.Task] = set()
@@ -122,8 +123,19 @@ def recover_batches():
if batch["status"] in {"running", "pausing", "paused"}: if batch["status"] in {"running", "pausing", "paused"}:
if any(str(job["session_id"]) in live_sessions for job in store.jobs(batch["id"])): if any(str(job["session_id"]) in live_sessions for job in store.jobs(batch["id"])):
continue continue
recovery_reason = "服务已重启;请检查中断任务,点击继续执行未开始任务"
batch.setdefault("recovery_events", []).append(
{
"message": recovery_reason,
"recovered_at": time.time(),
"previous_reason": batch.get("reason"),
"previous_pause_source": batch.get("pause_source"),
}
)
batch.update( batch.update(
status="paused", reason="服务已重启;请检查中断任务,点击继续执行未开始任务" status="paused",
reason=batch.get("reason") or recovery_reason,
pause_source="recovery",
) )
for job in store.jobs(batch["id"]): for job in store.jobs(batch["id"]):
if job["status"] in {"running", "queued", "finalizing", "task_paused", "resuming"}: if job["status"] in {"running", "queued", "finalizing", "task_paused", "resuming"}:
@@ -164,9 +176,29 @@ async def on_terminal(item: dict, status: str):
} }
): ):
job["status"] = "failed" job["status"] = "failed"
job["error"] = job.get("error") or "任务执行结束,但业务检查未通过" job["error"] = (
job.get("error")
or report.get("error")
or (report.get("test_result") or {}).get("reason")
or "任务未完成或验证未通过,请查看报告中的具体原因"
)
elif job["status"] == "failed" and report.get("error") and not job.get("error"): elif job["status"] == "failed" and report.get("error") and not job.get("error"):
job["error"] = report["error"] job["error"] = report["error"]
if job["status"] == "failed" and report["outcome"].get("code") == "initialization_failed":
job["failure_code"] = "initialization_failed"
batch = store.get("batch", job.get("batch_id")) if job.get("batch_id") else None
if (
batch
and (batch["status"] == "running" or batch.get("pause_source") == "model")
and any(j["status"] == "pending" for j in store.jobs(batch["id"]))
):
batch.update(
status="paused",
pause_source="initialization",
reason="任务初始化失败;请处理环境问题后继续剩余用例:"
+ (job.get("error") or "请查看任务报告"),
)
store.put("batch", batch["id"], batch)
# Persist terminal classification before export, but the coordinator waits for finalization. # Persist terminal classification before export, but the coordinator waits for finalization.
store.save_job({**job, "status": "finalizing", "terminal_status": job["status"]}) store.save_job({**job, "status": "finalizing", "terminal_status": job["status"]})
await asyncio.to_thread(export_session, sid, job["formats"]) await asyncio.to_thread(export_session, sid, job["formats"])
@@ -175,6 +207,7 @@ async def on_terminal(item: dict, status: str):
logger.exception("Report finalization failed for %s", sid) logger.exception("Report finalization failed for %s", sid)
job["report_error"] = str(exc)[:500] job["report_error"] = str(exc)[:500]
job.pop("terminal_status", None) job.pop("terminal_status", None)
job["finalized_at"] = time.time()
store.save_job(job) store.save_job(job)
@@ -216,10 +249,18 @@ async def tick_batches():
if batch["status"] not in {"running", "paused"}: if batch["status"] not in {"running", "paused"}:
continue continue
jobs = store.jobs(batch["id"]) jobs = store.jobs(batch["id"])
if (
batch["status"] == "paused"
and batch.get("pause_source") == "model"
and not state.is_paused
):
batch.update(status="running", reason="")
batch.pop("pause_source", None)
store.put("batch", batch["id"], batch)
active = [j for j in jobs if j["status"] in {"running", "queued", "finalizing"}] active = [j for j in jobs if j["status"] in {"running", "queued", "finalizing"}]
if active: if active:
if state.is_paused: if state.is_paused and batch["status"] == "running":
batch.update(status="paused", reason="模型请求已暂停,请先恢复模型连接再继续批次") batch.update(status="paused", reason=MODEL_PAUSE_REASON, pause_source="model")
store.put("batch", batch["id"], batch) store.put("batch", batch["id"], batch)
for job in active: for job in active:
# The queue records started_at at submission, then replaces it # The queue records started_at at submission, then replaces it
@@ -283,7 +324,7 @@ async def tick_batches():
store.put("batch", batch["id"], batch) store.put("batch", batch["id"], batch)
continue continue
if state.is_paused: if state.is_paused:
batch.update(status="paused", reason="模型请求已暂停,请先恢复模型连接再继续批次") batch.update(status="paused", reason=MODEL_PAUSE_REASON, pause_source="model")
store.put("batch", batch["id"], batch) store.put("batch", batch["id"], batch)
continue continue
job = pending[0] job = pending[0]
@@ -298,7 +339,7 @@ async def tick_batches():
else "无法确认所选设备在线且已授权,请恢复设备连接后继续批次" else "无法确认所选设备在线且已授权,请恢复设备连接后继续批次"
) )
if rejection: if rejection:
batch.update(status="paused", reason=str(rejection)) batch.update(status="paused", reason=str(rejection), pause_source="device")
store.put("batch", batch["id"], batch) store.put("batch", batch["id"], batch)
continue continue
if (store.get("batch", batch["id"]) or {}).get("status") != "running": if (store.get("batch", batch["id"]) or {}).get("status") != "running":
@@ -437,11 +478,14 @@ def batch_action(batch_id: str, action: str) -> dict:
if action == "pause": if action == "pause":
if batch["status"] not in {"running", "paused"}: if batch["status"] not in {"running", "paused"}:
raise ValueError("只能暂停正在执行的批次") raise ValueError("只能暂停正在执行的批次")
batch.update(status="paused", reason="用户暂停;当前任务继续,后续任务等待") batch.update(
status="paused", reason="用户暂停;当前任务继续,后续任务等待", pause_source="user"
)
elif action == "resume": elif action == "resume":
if batch["status"] not in {"draft", "paused"}: if batch["status"] not in {"draft", "paused"}:
raise ValueError("只能开始草稿或继续已暂停批次") raise ValueError("只能开始草稿或继续已暂停批次")
batch.update(status="running", reason="") batch.update(status="running", reason="")
batch.pop("pause_source", None)
elif action == "retry_failed": elif action == "retry_failed":
if batch["status"] == "running": if batch["status"] == "running":
raise ValueError("请先暂停批次") raise ValueError("请先暂停批次")
@@ -294,7 +294,7 @@ class AutomationStore:
if batch.get("status") != "deleting": if batch.get("status") != "deleting":
raise ValueError("批次尚未准备删除") raise ValueError("批次尚未准备删除")
conn.execute( conn.execute(
"DELETE FROM automation_records WHERE kind='case_review' AND id IN " "DELETE FROM automation_records WHERE kind IN ('case_review','task_extensions') AND id IN "
"(SELECT session_id FROM automation_jobs WHERE batch_id=?)", "(SELECT session_id FROM automation_jobs WHERE batch_id=?)",
(batch_id,), (batch_id,),
) )
@@ -312,13 +312,15 @@ class AutomationStore:
if session_id: if session_id:
conn.execute("DELETE FROM automation_jobs WHERE session_id=?", (session_id,)) conn.execute("DELETE FROM automation_jobs WHERE session_id=?", (session_id,))
conn.execute( conn.execute(
"DELETE FROM automation_records WHERE kind='case_review' AND id=?", "DELETE FROM automation_records WHERE kind IN ('case_review','task_extensions') AND id=?",
(session_id,), (session_id,),
) )
else: else:
conn.execute("DELETE FROM automation_jobs") conn.execute("DELETE FROM automation_jobs")
conn.execute("DELETE FROM automation_records WHERE kind='batch'") conn.execute("DELETE FROM automation_records WHERE kind='batch'")
conn.execute("DELETE FROM automation_records WHERE kind='case_review'") conn.execute(
"DELETE FROM automation_records WHERE kind IN ('case_review','task_extensions')"
)
conn.commit() conn.commit()
+17 -4
View File
@@ -15,7 +15,7 @@ def archive_path(batch_id: str):
def cached(batch_id: str, batch: dict) -> bool: def cached(batch_id: str, batch: dict) -> bool:
return bool(batch.get("report_ready")) and report_export.reusable( return bool(batch.get("report_ready")) and report_export.reusable(
archive_path(batch_id), report_export.BATCH_ARCHIVE_SIGNATURE, "completed" archive_path(batch_id), report_export.batch_archive_signature(batch_id), "completed"
) )
@@ -41,7 +41,13 @@ def _snapshot(state: dict) -> dict:
def status(batch_id: str, batch: dict) -> dict: def status(batch_id: str, batch: dict) -> dict:
with _lock: with _lock:
state = _states.get(batch_id) state = _states.get(batch_id)
if state: if state and state["status"] != "ready":
return _snapshot(state)
if (
state
and state.get("signature") == report_export.batch_archive_signature(batch_id)
and archive_path(batch_id).is_file()
):
return _snapshot(state) return _snapshot(state)
if cached(batch_id, batch): if cached(batch_id, batch):
return { return {
@@ -65,11 +71,12 @@ def status(batch_id: str, batch: dict) -> dict:
def prepare(batch_id: str, batch: dict) -> dict: def prepare(batch_id: str, batch: dict) -> dict:
cache_ready = cached(batch_id, batch)
with _lock: with _lock:
current = _states.get(batch_id) current = _states.get(batch_id)
if current and current["status"] == "preparing": if current and current["status"] == "preparing":
return _snapshot(current) return _snapshot(current)
if cached(batch_id, batch): if cache_ready:
_states.pop(batch_id, None) _states.pop(batch_id, None)
return status_from_cache() return status_from_cache()
state = { state = {
@@ -88,11 +95,17 @@ def prepare(batch_id: str, batch: dict) -> dict:
def run() -> None: def run() -> None:
try: try:
with report_export.export_lock("batch:" + batch_id): with report_export.export_lock("batch:" + batch_id):
signature = report_export.batch_archive_signature(batch_id)
latest = report_export.store.get("batch", batch_id) latest = report_export.store.get("batch", batch_id)
if not (latest and cached(batch_id, latest)): if not (latest and cached(batch_id, latest)):
report_export.export_batch(batch_id, progress=update) report_export.export_batch(batch_id, progress=update)
with _lock: with _lock:
state.update(status="ready", stage="报告包已就绪", finished_at=time.monotonic()) state.update(
status="ready",
stage="报告包已就绪",
finished_at=time.monotonic(),
signature=signature,
)
except Exception as exc: except Exception as exc:
with _lock: with _lock:
state.update( state.update(
+5 -1
View File
@@ -9,7 +9,11 @@ from artemis.runtime.case_result import read_case_result
def reviewed_result( def reviewed_result(
session_id: str, ai_result: dict | None, storage: AutomationStore session_id: str, ai_result: dict | None, storage: AutomationStore
) -> dict | None: ) -> dict | None:
original = {**ai_result, "source": "ai"} if ai_result else None original = (
{**ai_result, "source": "system" if ai_result.get("verification_skipped") else "ai"}
if ai_result
else None
)
record = storage.get("case_review", session_id) record = storage.get("case_review", session_id)
reviews = (record or {}).get("reviews") or [] reviews = (record or {}).get("reviews") or []
if not reviews: if not reviews:
@@ -151,6 +151,7 @@ def export_index(
"status": report["status"], "status": report["status"],
"verdict": report["verdict"], "verdict": report["verdict"],
"steps": report.get("steps", []), "steps": report.get("steps", []),
"evidence_sources": report.get("evidence_sources"),
"calls": calls, "calls": calls,
"recordings": recordings, "recordings": recordings,
"android_videos": ["任务录屏/安卓/" + name for _, name in android_videos], "android_videos": ["任务录屏/安卓/" + name for _, name in android_videos],
@@ -35,6 +35,9 @@ def execution_config(item: dict) -> dict:
options["recording"] = {**options["recording"], **(overrides.get("recording") or {})} options["recording"] = {**options["recording"], **(overrides.get("recording") or {})}
options.update({k: v for k, v in overrides.items() if k != "recording" and v is not None}) options.update({k: v for k, v in overrides.items() if k != "recording" and v is not None})
return { return {
"flash_final_check_enabled": agent.flash.final_check_enabled
if profile == "flash"
else None,
"runtime_overrides": TaskOptions.model_validate(options).model_dump(exclude_none=True), "runtime_overrides": TaskOptions.model_validate(options).model_dump(exclude_none=True),
"expected_output": item.get("expected_output"), "expected_output": item.get("expected_output"),
"enable_outputter": item.get("enable_outputter") "enable_outputter": item.get("enable_outputter")
+2
View File
@@ -1,5 +1,7 @@
"""Independent favorite snapshots dispatched through the existing task services.""" """Independent favorite snapshots dispatched through the existing task services."""
from __future__ import annotations
from copy import deepcopy from copy import deepcopy
import asyncio import asyncio
import hashlib import hashlib
+1 -1
View File
@@ -149,7 +149,7 @@ class MediaService:
return p return p
LIVE_RECORDING_GRACE_SECONDS = 10.0 LIVE_RECORDING_GRACE_SECONDS = 10.0
TRACE_STATUS_SUFFIXES = ("_PASS_", "_FAIL_", "_TESTFAIL_") TRACE_STATUS_SUFFIXES = ("_PASS_", "_FAIL_", "_TESTFAIL_", "_BLOCKED_", "_UNVERIFIED_")
@classmethod @classmethod
def is_live_recording(cls, p: Path) -> bool: def is_live_recording(cls, p: Path) -> bool:
@@ -1,20 +1,18 @@
"""Persist the single Responses endpoint without returning credentials to the browser.""" """Persist the single Responses endpoint without returning credentials to the browser."""
import os
import tempfile
import threading
from pathlib import Path
from typing import Literal from typing import Literal
from urllib.parse import urlsplit from urllib.parse import urlsplit
from pydantic import BaseModel, Field, SecretStr, field_validator from pydantic import BaseModel, Field, SecretStr, field_validator
from artemis.config.agent import load_agent_config from artemis.config.agent import load_agent_config
from artemis.config.environment import (
persist_environment as write_environment,
TRANSPORT_DEFAULT_VERSION_KEY,
)
from artemis.config.paths import get_env_file from artemis.config.paths import get_env_file
from artemis.config.settings import settings from artemis.config.settings import settings
_write_lock = threading.Lock()
class ModelSettingsUpdate(BaseModel): class ModelSettingsUpdate(BaseModel):
base_url: str = "https://api-n-cd.playones.com/v1" base_url: str = "https://api-n-cd.playones.com/v1"
@@ -22,6 +20,7 @@ class ModelSettingsUpdate(BaseModel):
api_key: SecretStr | None = None # Omitted/blank keeps the existing key. api_key: SecretStr | None = None # Omitted/blank keeps the existing key.
timeout_seconds: float = Field(default=300, gt=0, le=3600) timeout_seconds: float = Field(default=300, gt=0, le=3600)
max_retries: int = Field(default=5, ge=0, le=10) max_retries: int = Field(default=5, ge=0, le=10)
transport: Literal["http", "websocket"] | None = None
retry_interval_seconds: float = Field(default=10, ge=0, le=300) retry_interval_seconds: float = Field(default=10, ge=0, le=300)
rate_limit_retry_seconds: float | None = Field(default=None, ge=0, le=3600) rate_limit_retry_seconds: float | None = Field(default=None, ge=0, le=3600)
tool_fallback_model: str | None = Field(default=None, min_length=1, max_length=200) tool_fallback_model: str | None = Field(default=None, min_length=1, max_length=200)
@@ -72,6 +71,7 @@ def get_model_settings() -> dict:
"api_key_configured": bool(settings.OPENAI_API_KEY), "api_key_configured": bool(settings.OPENAI_API_KEY),
"timeout_seconds": settings.OPENAI_TIMEOUT_SECONDS, "timeout_seconds": settings.OPENAI_TIMEOUT_SECONDS,
"max_retries": settings.OPENAI_MAX_RETRIES, "max_retries": settings.OPENAI_MAX_RETRIES,
"transport": settings.OPENAI_TRANSPORT,
"retry_interval_seconds": settings.OPENAI_RETRY_INTERVAL_SECONDS, "retry_interval_seconds": settings.OPENAI_RETRY_INTERVAL_SECONDS,
"rate_limit_retry_seconds": settings.OPENAI_RATE_LIMIT_RETRY_SECONDS, "rate_limit_retry_seconds": settings.OPENAI_RATE_LIMIT_RETRY_SECONDS,
"tool_fallback_model": settings.OPENAI_TOOL_FALLBACK_MODEL, "tool_fallback_model": settings.OPENAI_TOOL_FALLBACK_MODEL,
@@ -81,6 +81,7 @@ def get_model_settings() -> dict:
"reasoning_effort": settings.OPENAI_REASONING_EFFORT, "reasoning_effort": settings.OPENAI_REASONING_EFFORT,
"model_presets": [ "model_presets": [
"gpt-6-luna", "gpt-6-luna",
"gpt-6.1-terra",
"gpt-6.1-sol", "gpt-6.1-sol",
"gpt-6-astra", "gpt-6-astra",
"mimo-v2.6-pro", "mimo-v2.6-pro",
@@ -101,6 +102,7 @@ def save_model_settings(update: ModelSettingsUpdate) -> dict:
"OPENAI_REASONING_EFFORT": update.reasoning_effort, "OPENAI_REASONING_EFFORT": update.reasoning_effort,
} }
optional_settings = { optional_settings = {
"transport": ("OPENAI_TRANSPORT", str),
"rate_limit_retry_seconds": ("OPENAI_RATE_LIMIT_RETRY_SECONDS", float), "rate_limit_retry_seconds": ("OPENAI_RATE_LIMIT_RETRY_SECONDS", float),
"tool_fallback_model": ("OPENAI_TOOL_FALLBACK_MODEL", str), "tool_fallback_model": ("OPENAI_TOOL_FALLBACK_MODEL", str),
"tool_fallback_reasoning_effort": ("OPENAI_TOOL_FALLBACK_REASONING_EFFORT", str), "tool_fallback_reasoning_effort": ("OPENAI_TOOL_FALLBACK_REASONING_EFFORT", str),
@@ -114,6 +116,8 @@ def save_model_settings(update: ModelSettingsUpdate) -> dict:
value = getattr(update, field_name) value = getattr(update, field_name)
if value is not None: if value is not None:
values[env_name] = str(value) values[env_name] = str(value)
if update.transport is not None:
values[TRANSPORT_DEFAULT_VERSION_KEY] = "1"
if update.api_key and update.api_key.get_secret_value().strip(): if update.api_key and update.api_key.get_secret_value().strip():
values["OPENAI_API_KEY"] = update.api_key.get_secret_value().strip() values["OPENAI_API_KEY"] = update.api_key.get_secret_value().strip()
persist_environment(values) persist_environment(values)
@@ -138,31 +142,7 @@ def save_model_settings(update: ModelSettingsUpdate) -> dict:
def persist_environment(values: dict[str, str]) -> None: def persist_environment(values: dict[str, str]) -> None:
with _write_lock: write_environment(get_env_file(), values)
env_path = get_env_file()
env_path.parent.mkdir(parents=True, exist_ok=True)
original = env_path.read_text(encoding="utf-8") if env_path.exists() else ""
# Preserve comments and unrelated variables; quote values to avoid dotenv interpolation.
kept = [
line
for line in original.splitlines()
if line.removeprefix("export ").split("=", 1)[0].strip() not in values
]
for key, value in values.items():
escaped = value.replace("\\", "\\\\").replace("'", "\\'")
kept.append(f"{key}='{escaped}'")
content = "\n".join(kept) + "\n"
fd, temp_name = tempfile.mkstemp(prefix=".env-", dir=env_path.parent)
try:
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as stream:
stream.write(content)
stream.flush()
os.fsync(stream.fileno())
os.replace(temp_name, env_path)
finally:
Path(temp_name).unlink(missing_ok=True)
for key, value in values.items():
os.environ[key] = value
class CapabilitySettings(BaseModel): class CapabilitySettings(BaseModel):
@@ -177,6 +157,9 @@ class CapabilitySettings(BaseModel):
recording_enabled: bool = False recording_enabled: bool = False
recording: dict = Field(default_factory=dict) recording: dict = Field(default_factory=dict)
outputter_enabled: bool = True outputter_enabled: bool = True
flash_final_check_enabled: bool = Field(
default_factory=lambda: load_agent_config().flash.final_check_enabled
)
step_summarizer_enabled: bool = True step_summarizer_enabled: bool = True
screenshot_limit_enabled: bool = True screenshot_limit_enabled: bool = True
screenshot_max_long_edge: int = 2048 screenshot_max_long_edge: int = 2048
@@ -207,6 +190,7 @@ def get_capability_settings() -> CapabilitySettings:
recording_enabled=settings.ARTEMIS_RECORDING_ENABLED, recording_enabled=settings.ARTEMIS_RECORDING_ENABLED,
recording=load_agent_config().recording.model_dump(), recording=load_agent_config().recording.model_dump(),
outputter_enabled=settings.ARTEMIS_OUTPUTTER_ENABLED, outputter_enabled=settings.ARTEMIS_OUTPUTTER_ENABLED,
flash_final_check_enabled=load_agent_config().flash.final_check_enabled,
step_summarizer_enabled=settings.ARTEMIS_STEP_SUMMARIZER_ENABLED, step_summarizer_enabled=settings.ARTEMIS_STEP_SUMMARIZER_ENABLED,
screenshot_limit_enabled=screenshot.enabled, screenshot_limit_enabled=screenshot.enabled,
screenshot_max_long_edge=screenshot.max_long_edge, screenshot_max_long_edge=screenshot.max_long_edge,
@@ -227,6 +211,7 @@ def save_capability_settings(update: CapabilitySettings) -> CapabilitySettings:
"ARTEMIS_VIDEO_ENABLED": update.video_enabled, "ARTEMIS_VIDEO_ENABLED": update.video_enabled,
"ARTEMIS_RECORDING_ENABLED": update.recording_enabled, "ARTEMIS_RECORDING_ENABLED": update.recording_enabled,
"ARTEMIS_OUTPUTTER_ENABLED": update.outputter_enabled, "ARTEMIS_OUTPUTTER_ENABLED": update.outputter_enabled,
"ARTEMIS_FLASH_FINAL_CHECK_ENABLED": update.flash_final_check_enabled,
"ARTEMIS_STEP_SUMMARIZER_ENABLED": update.step_summarizer_enabled, "ARTEMIS_STEP_SUMMARIZER_ENABLED": update.step_summarizer_enabled,
"ARTEMIS_SCREENSHOT_LIMIT_ENABLED": update.screenshot_limit_enabled, "ARTEMIS_SCREENSHOT_LIMIT_ENABLED": update.screenshot_limit_enabled,
"ARTEMIS_TASK_TIMEOUT_SECONDS": update.task_timeout_seconds, "ARTEMIS_TASK_TIMEOUT_SECONDS": update.task_timeout_seconds,
@@ -255,10 +240,19 @@ async def test_model_settings(update: ModelSettingsUpdate) -> dict:
api_base=update.base_url, api_base=update.base_url,
timeout_seconds=update.timeout_seconds, timeout_seconds=update.timeout_seconds,
max_retries=0, max_retries=0,
transport=update.transport or settings.OPENAI_TRANSPORT,
reasoning_effort=None if update.reasoning_effort == "default" else update.reasoning_effort, reasoning_effort=None if update.reasoning_effort == "default" else update.reasoning_effort,
) )
model = ModelFactory.create_model(endpoint) model = ModelFactory.create_model(endpoint)
try:
result = await model.ainvoke([HumanMessage(content="Reply with OK only.")]) result = await model.ainvoke([HumanMessage(content="Reply with OK only.")])
finally:
await model.http_async_client.aclose()
model.http_client.close()
if not result.content: if not result.content:
raise ValueError("Responses 接口返回空内容,请检查模型配置") raise ValueError("Responses 接口返回空内容,请检查模型配置")
return {"success": True, "message": "Responses 连接测试成功", "model": update.model} return {
"success": True,
"message": f"Responses {endpoint.transport.upper()} 连接测试成功",
"model": update.model,
}
+213 -11
View File
@@ -14,6 +14,8 @@ from apps.admin_console.services.export_cache import (
reusable, reusable,
) )
from apps.admin_console.services.request_diagnostics import diagnostics_json from apps.admin_console.services.request_diagnostics import diagnostics_json
from apps.admin_console.services.task_lifecycle import guidance_details
from apps.admin_console.services import task_extensions
from docx import Document from docx import Document
from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT
@@ -34,7 +36,32 @@ from artemis.runtime.case_result import RESULT_LABELS, resolve_case_result
from apps.admin_console.services.case_review import reviewed_result from apps.admin_console.services.case_review import reviewed_result
EXPORT_ROOT = WORKSPACE_ROOT / "artifacts" / "reports" EXPORT_ROOT = WORKSPACE_ROOT / "artifacts" / "reports"
BATCH_ARCHIVE_SIGNATURE = "artemis-batch-layout-2" BATCH_ARCHIVE_SIGNATURE = "artemis-batch-layout-4"
def batch_archive_signature(batch_id: str | None = None) -> str:
signature = task_extensions.installation_signature()
if batch_id:
with store.connection() as conn:
changes = conn.execute(
"SELECT r.id,r.updated_at FROM automation_records r "
"JOIN automation_jobs j ON j.session_id=r.id "
"WHERE r.kind='task_extensions' AND j.batch_id=? ORDER BY r.id",
(batch_id,),
).fetchall()
if changes:
signature += ":" + fingerprint({"reviews": [tuple(row) for row in changes]})
return BATCH_ARCHIVE_SIGNATURE + (":" + signature if signature else "")
def extension_rows(report: dict) -> list[tuple[str, str, str]]:
return [
(section["name"], field["label"], field["value"])
for section in report.get("task_extensions", [])
for field in section["fields"]
]
STATUS = { STATUS = {
"completed": "执行完成", "completed": "执行完成",
"success": "执行完成", "success": "执行完成",
@@ -73,6 +100,8 @@ def text(value) -> str:
def verdict(outcome: dict | None) -> str: def verdict(outcome: dict | None) -> str:
result = (outcome or {}).get("test_result") result = (outcome or {}).get("test_result")
if isinstance(result, dict) and result.get("status") in RESULT_LABELS: if isinstance(result, dict) and result.get("status") in RESULT_LABELS:
if result["status"] == "inconclusive" and result.get("verification_skipped"):
return "未做 AI 判定"
label = RESULT_LABELS[result["status"]] label = RESULT_LABELS[result["status"]]
if result.get("source"): if result.get("source"):
label += "(人工确认)" if result["source"] == "human" else "(AI确认)" label += "(人工确认)" if result["source"] == "human" else "(AI确认)"
@@ -164,9 +193,11 @@ def failure_reason(directory: Path, status: str, recorded: str | None) -> str:
def session_report(session_id: str, db_path=None) -> dict: def session_report(session_id: str, db_path=None) -> dict:
from apps.admin_console.services.exploration_evidence import exploration_metadata from apps.admin_console.services.exploration_evidence import exploration_metadata
from artemis.data_engine.evidence_sources import recorded_sources
identifier(session_id) identifier(session_id)
meta = store.job(session_id) or {} meta = store.job(session_id) or {}
batch = store.get("batch", meta["batch_id"]) if meta.get("batch_id") else None
repo = SessionRepository(db_path) if db_path else SessionRepository() repo = SessionRepository(db_path) if db_path else SessionRepository()
session = repo.get_session_by_id(session_id) or {} session = repo.get_session_by_id(session_id) or {}
if not session and not meta: if not session and not meta:
@@ -204,13 +235,17 @@ def session_report(session_id: str, db_path=None) -> dict:
if status in {"failed", "interrupted"} and verification.startswith("未验证"): if status in {"failed", "interrupted"} and verification.startswith("未验证"):
verification = "未验证(任务异常终止)" verification = "未验证(任务异常终止)"
if not content: if not content:
content = ( lines = ["本任务未保存独立输出正文。", f"当前有效判定:{verification}"]
"本任务未生成最终结论。请查看异常原因和已记录的执行步骤。" if test_result:
if status in {"failed", "interrupted"} if test_result.get("reason"):
else "本任务没有保存最终结论。以下内容仅来自已记录的执行状态和证据。" lines.append(f"判定依据:{test_result['reason']}")
) lines.extend(f"- {item}" for item in test_result.get("evidence") or [])
elif error:
lines.append(f"异常原因:{error}")
content = "\n\n".join(lines)
return { return {
"id": session_id, "id": session_id,
**guidance_details(session_id, db_path=repo.db_path),
"exploration": exploration_metadata(session_id), "exploration": exploration_metadata(session_id),
"name": execution_title(meta) or session.get("initial_goal", session_id)[:120], "name": execution_title(meta) or session.get("initial_goal", session_id)[:120],
"iteration": meta.get("iteration", 1), "iteration": meta.get("iteration", 1),
@@ -223,6 +258,7 @@ def session_report(session_id: str, db_path=None) -> dict:
"outcome": outcome, "outcome": outcome,
"content": content, "content": content,
"steps": steps, "steps": steps,
"evidence_sources": recorded_sources(steps),
"start": session.get("start_time") or meta.get("started_at"), "start": session.get("start_time") or meta.get("started_at"),
"end": session.get("end_time") or meta.get("ended_at"), "end": session.get("end_time") or meta.get("ended_at"),
"error": error, "error": error,
@@ -232,7 +268,18 @@ def session_report(session_id: str, db_path=None) -> dict:
"category": case.get("category", ""), "category": case.get("category", ""),
"subcategory": case.get("subcategory", ""), "subcategory": case.get("subcategory", ""),
"preconditions": case.get("preconditions", ""), "preconditions": case.get("preconditions", ""),
"original_case": case.get("original_case"),
"task_config": case.get("task_config") or {}, "task_config": case.get("task_config") or {},
"task_extensions": task_extensions.report_sections(session_id, store),
"runtime_diagnostics": read_json(directory / "runtime_diagnostics.json"),
"batch_diagnostics": (
{
key: batch.get(key)
for key in ("id", "status", "reason", "pause_source", "recovery_events")
}
if batch
else None
),
} }
@@ -244,6 +291,81 @@ def time_text(value) -> str:
) )
def lifecycle_text(value: dict) -> str:
labels = {
"total": "总历时",
"queued": "后台排队",
"resource_wait": "等待设备/资源",
"paused": "暂停与示范",
"execution": "执行与整理",
"final_review": "最终验收",
"report_export": "报告导出",
}
durations = value.get("durations") or {}
return (
"\n".join(
f"{label}:{round(durations[key], 1)} 秒"
if durations.get(key) is not None
else f"{label}:未记录"
for key, label in labels.items()
)
+ "\n执行含初始化与报告整理,不代表模型速度。"
)
def guidance_text(items: list[dict]) -> str:
labels = {
"pending": "已接收,等待读取",
"reading": "执行器正在读取",
"consumed": "执行器已读取",
"unapplied": "任务结束,未记录消费",
"unconfirmed": "任务结束,读取未确认",
}
return (
"\n".join(
f"{item['id']} · {labels.get(item['status'], '状态未知')} · 接收 {time_text(item.get('accepted_at') or item.get('timestamp'))}"
+ (f" · 读取 {time_text(item['consumed_at'])}" if item.get("consumed_at") else "")
for item in items
)
or "无指导记录"
)
def source_text(value: dict) -> str:
labels = {
"android": "安卓界面",
"computer": "电脑界面",
"api": "接口数据",
"terminal": "终端",
"file": "文件",
"other": "其他",
}
lines = []
for item in value.get("sources", []):
references = ", ".join(
" / ".join(
f"{key}={ref[key]}"
for key in ("step_id", "call_id", "source_id", "trace_id")
if ref.get(key)
)
or "编号未记录"
for ref in item.get("references", [])
)
variation = "(来源标签随步骤变化,详见原始步骤)" if item.get("label_varies") else ""
lines.append(
f"{labels.get(item['kind'], '其他')} · {item['label'] or item['target_id']}"
f"{variation} · {references}"
)
if value.get("sources_omitted"):
lines.append(f"另 {value['sources_omitted']} 个来源见原始步骤记录。")
lines.append(
f"未关联到单一来源的步骤:{value.get('unattributed_step_count', '未知')}。"
"窗口枚举等操作可能没有单一目标;此计数不表示截图丢失。"
)
lines.append(value.get("note") or "来源清单不代表断言已验证。")
return "\n".join(lines)
def metadata(report: dict) -> list[tuple]: def metadata(report: dict) -> list[tuple]:
return [ return [
("任务编号", report["id"]), ("任务编号", report["id"]),
@@ -266,14 +388,30 @@ def metadata(report: dict) -> list[tuple]:
("检测项目", report.get("category") or "—"), ("检测项目", report.get("category") or "—"),
("测试子项", report.get("subcategory") or "—"), ("测试子项", report.get("subcategory") or "—"),
("前置约束提取", report.get("preconditions") or "—"), ("前置约束提取", report.get("preconditions") or "—"),
*(
[
("原始用例 · 预置条件", report["original_case"].get("preconditions") or "未填写"),
("原始用例 · 操作步骤", report["original_case"].get("steps") or "未填写"),
("原始用例 · 预期结果", report["original_case"].get("expected") or "未填写"),
]
if report.get("original_case") is not None
else []
),
("任务配置", config_text(report.get("task_config"))), ("任务配置", config_text(report.get("task_config"))),
("任务目标", report["goal"]), ("任务目标", report["goal"]),
("预期结果", report["expected"] or "未指定"), ("预期结果", report["expected"] or "未指定"),
("执行状态", STATUS.get(report["status"], report["status"])), ("执行状态", STATUS.get(report["status"], report["status"])),
("耗时分项", lifecycle_text(report.get("lifecycle") or {})),
("指导消费", guidance_text(report.get("instructions") or [])),
("证据来源与边界", source_text(report.get("evidence_sources") or {})),
("验证结论", report["verdict"]), ("验证结论", report["verdict"]),
("人工复验时间", time_text((report.get("test_result") or {}).get("reviewed_at"))), ("人工复验时间", time_text((report.get("test_result") or {}).get("reviewed_at"))),
( (
"原 AI 结论", "原始结果"
if ((report.get("test_result") or {}).get("ai_result") or {}).get(
"verification_skipped"
)
else "原 AI 结论",
verdict({"test_result": (report.get("test_result") or {}).get("ai_result")}) verdict({"test_result": (report.get("test_result") or {}).get("ai_result")})
if (report.get("test_result") or {}).get("source") == "human" if (report.get("test_result") or {}).get("source") == "human"
else "—", else "—",
@@ -552,7 +690,19 @@ def word_report(report: dict, path: Path):
details = [ details = [
(label, value) (label, value)
for label, value in metadata(report) for label, value in metadata(report)
if label in {"任务编号", "用例编号", "检测项目", "测试子项", "开始时间", "结束时间", "设备"} if label
in {
"任务编号",
"用例编号",
"检测项目",
"测试子项",
"开始时间",
"结束时间",
"设备",
"耗时分项",
"指导消费",
"证据来源与边界",
}
] ]
word_table(doc, details, (3.2, 13.3)) word_table(doc, details, (3.2, 13.3))
doc.add_heading("任务目标与预期" if not report["expected"] else "任务目标", 2) doc.add_heading("任务目标与预期" if not report["expected"] else "任务目标", 2)
@@ -566,6 +716,11 @@ def word_report(report: dict, path: Path):
doc.add_paragraph("任务配置:" + config_text(report["task_config"])) doc.add_paragraph("任务配置:" + config_text(report["task_config"]))
doc.add_heading("结论与发现", 1) doc.add_heading("结论与发现", 1)
add_markdown_content(doc, report["content"]) add_markdown_content(doc, report["content"])
if review_rows := extension_rows(report):
doc.add_heading("人工评审(插件)", 1)
word_table(
doc, [(f"{name} · {label}", value) for name, label, value in review_rows], (5.5, 11)
)
doc.add_heading("执行步骤与证据", 1) doc.add_heading("执行步骤与证据", 1)
for step in report["steps"]: for step in report["steps"]:
paragraph = doc.add_paragraph() paragraph = doc.add_paragraph()
@@ -699,6 +854,10 @@ def excel_report(report: dict, path: Path):
wb.active.title = "任务结果" wb.active.title = "任务结果"
sheet_rows(wb.active, [("项目", "内容"), *metadata(report)], (22, 82)) sheet_rows(wb.active, [("项目", "内容"), *metadata(report)], (22, 82))
sheet_rows(wb.create_sheet("结论"), conclusion_rows(report["content"]), (15, 96)) sheet_rows(wb.create_sheet("结论"), conclusion_rows(report["content"]), (15, 96))
if review_rows := extension_rows(report):
sheet_rows(
wb.create_sheet("人工评审"), [("插件", "项目", "内容"), *review_rows], (24, 30, 82)
)
table_number = 0 table_number = 0
for kind, rows in markdown_blocks(report["content"]): for kind, rows in markdown_blocks(report["content"]):
if kind == "table": if kind == "table":
@@ -820,8 +979,7 @@ def session_videos(report: dict) -> list[tuple[Path, str]]:
return [] return []
source = Path(str(candidate)).resolve() source = Path(str(candidate)).resolve()
trace_root = TRACES_PATH.resolve() trace_root = TRACES_PATH.resolve()
# Workers save recordings in their finalized web_<time>_<id>_PASS/FAIL # The recording row already ties the finalized trace folder to this session.
# folder. The recording row already ties that path to this session.
if not source.is_relative_to(trace_root): if not source.is_relative_to(trace_root):
return [] return []
# A resumed recording lives under resume_NNNN; the initial recording is # A resumed recording lives under resume_NNNN; the initial recording is
@@ -933,6 +1091,17 @@ def export_session_media(session_id: str, kind: str, *, report: dict | None = No
) )
if kind == "all": if kind == "all":
archive.writestr(prefix + "模型请求诊断.json", diagnostics or "{}") archive.writestr(prefix + "模型请求诊断.json", diagnostics or "{}")
archive.writestr(
prefix + "运行环境诊断.json",
json.dumps(
{
"runtime": report.get("runtime_diagnostics"),
"batch": report.get("batch_diagnostics"),
},
ensure_ascii=False,
indent=2,
),
)
archive.writestr( archive.writestr(
prefix + "证据索引.json", prefix + "证据索引.json",
json.dumps( json.dumps(
@@ -971,6 +1140,8 @@ def export_batch(batch_id: str, progress: Callable[[int, int, str], None] | None
directory = EXPORT_ROOT / "batches" / batch_id directory = EXPORT_ROOT / "batches" / batch_id
directory.mkdir(parents=True, exist_ok=True) directory.mkdir(parents=True, exist_ok=True)
formats = batch["formats"] formats = batch["formats"]
with task_extensions.plugin_store().mutation_lock:
signature = batch_archive_signature(batch_id)
records = [session_report(job["session_id"]) for job in store.jobs(batch_id)] records = [session_report(job["session_id"]) for job in store.jobs(batch_id)]
if progress: if progress:
progress(0, len(records), "正在生成批次汇总") progress(0, len(records), "正在生成批次汇总")
@@ -985,6 +1156,17 @@ def export_batch(batch_id: str, progress: Callable[[int, int, str], None] | None
assert wb.active is not None assert wb.active is not None
wb.active.title = "批次结果" wb.active.title = "批次结果"
sheet_rows(wb.active, rows, (40, 48, 16, 28, 70)) sheet_rows(wb.active, rows, (40, 48, 16, 28, 70))
review_rows = [
(record["id"], record["name"], *row)
for record in records
for row in extension_rows(record)
]
if review_rows:
sheet_rows(
wb.create_sheet("人工评审"),
[("任务编号", "任务名称", "插件", "项目", "内容"), *review_rows],
(40, 40, 24, 30, 70),
)
path = directory / "批次结果.xlsx" path = directory / "批次结果.xlsx"
wb.save(path) wb.save(path)
outputs.append(path) outputs.append(path)
@@ -995,6 +1177,15 @@ def export_batch(batch_id: str, progress: Callable[[int, int, str], None] | None
f"批次编号:{batch_id};共 {len(records)} 个任务。验证通过与执行完成分别统计。" f"批次编号:{batch_id};共 {len(records)} 个任务。验证通过与执行完成分别统计。"
) )
word_table(doc, rows, (3.5, 5.4, 2.5, 3.5, 7.3), header=True) word_table(doc, rows, (3.5, 5.4, 2.5, 3.5, 7.3), header=True)
for record in records:
if review_rows := extension_rows(record):
doc.add_heading("人工评审 · " + record["name"], 1)
doc.add_paragraph("任务编号:" + record["id"])
word_table(
doc,
[(f"{name} · {label}", value) for name, label, value in review_rows],
(7, 15.2),
)
path = directory / "批次汇总.docx" path = directory / "批次汇总.docx"
doc.save(str(path)) doc.save(str(path))
outputs.append(path) outputs.append(path)
@@ -1032,6 +1223,17 @@ def export_batch(batch_id: str, progress: Callable[[int, int, str], None] | None
compress_type=compression(source), compress_type=compression(source),
) )
archive.writestr(f"{prefix}/模型请求诊断.json", diagnostics_json(report["id"])) archive.writestr(f"{prefix}/模型请求诊断.json", diagnostics_json(report["id"]))
archive.writestr(
f"{prefix}/运行环境诊断.json",
json.dumps(
{
"runtime": report.get("runtime_diagnostics"),
"batch": report.get("batch_diagnostics"),
},
ensure_ascii=False,
indent=2,
),
)
archive.writestr( archive.writestr(
f"{prefix}/证据索引.json", f"{prefix}/证据索引.json",
json.dumps( json.dumps(
@@ -1053,5 +1255,5 @@ def export_batch(batch_id: str, progress: Callable[[int, int, str], None] | None
if progress: if progress:
progress(len(records), len(records), "正在完成压缩包") progress(len(records), len(records), "正在完成压缩包")
temporary.replace(target) temporary.replace(target)
remember(target, BATCH_ARCHIVE_SIGNATURE) remember(target, signature)
return target return target
@@ -62,9 +62,13 @@ def request_diagnostics(
field: datetime.fromtimestamp(value[field], UTC).isoformat() field: datetime.fromtimestamp(value[field], UTC).isoformat()
for field in ( for field in (
"started_at", "started_at",
"request_sent_at",
"headers_at", "headers_at",
"first_output_at", "first_output_at",
"last_output_at", "last_output_at",
"first_tool_arguments_at",
"last_tool_arguments_at",
"cancel_observed_at",
"ended_at", "ended_at",
"retry_at", "retry_at",
) )
@@ -0,0 +1,205 @@
"""Human-authored task metadata owned by the host, scoped to installed plugins."""
import hashlib
import json
import time
from functools import lru_cache
from apps.admin_console.services.automation_store import AutomationStore, identifier
from apps.admin_console.database.repositories.session_repository import SessionRepository
from artemis.plugins.models import PluginManifest
from artemis.plugins.store import PluginStore
from apps.admin_console.services.task_import import case_title
TERMINAL = {"completed", "success", "failed", "cancelled", "interrupted", "skipped"}
@lru_cache(maxsize=1)
def plugin_store() -> PluginStore:
return PluginStore()
def active_manifests() -> list[PluginManifest]:
return [
PluginManifest.model_validate(plugin["manifest"])
for plugin in plugin_store().list()
if plugin["enabled"] and plugin["manifest"].get("workspace")
]
def installation_signature() -> str:
manifests = [manifest.model_dump(mode="json") for manifest in active_manifests()]
return (
hashlib.sha256(json.dumps(manifests, sort_keys=True).encode()).hexdigest()
if manifests
else ""
)
def reviewed_batch_sessions(batch_id: str, store: AutomationStore) -> set[str]:
"""Read current human review values once per batch, without loading history."""
manifest = next((item for item in active_manifests() if item.id == "task-review"), None)
if manifest is None or manifest.workspace is None:
return set()
with store.connection() as conn:
rows = conn.execute(
"""SELECT j.session_id, json_extract(r.payload, '$."task-review".values') AS review_values
FROM automation_jobs j JOIN automation_records r
ON r.kind='task_extensions' AND r.id=j.session_id
WHERE j.batch_id=?""",
(batch_id,),
).fetchall()
reviewed = set()
for row in rows:
values = json.loads(row["review_values"] or "{}")
if any(
isinstance(value := values.get(field.id), str)
and value.strip()
and (field.kind == "text" or value in field.options)
for field in manifest.workspace.fields
):
reviewed.add(row["session_id"])
return reviewed
def task_context(session_id: str, store: AutomationStore) -> dict:
identifier(session_id)
session = SessionRepository(store.path).get_session_by_id(session_id) or {}
job = store.job(session_id) or {}
if not session and not job:
raise ValueError("任务记录不存在")
case = job.get("case") or {}
navigation = None
if batch_id := job.get("batch_id"):
jobs = [item for item in store.jobs(batch_id) if item.get("status") in TERMINAL]
ids = [item["session_id"] for item in jobs]
index = ids.index(session_id) if session_id in ids else -1
navigation = {
"scope": "batch",
"position": index + 1,
"total": len(ids),
"previous": ids[index - 1] if index > 0 else None,
"next": ids[index + 1] if 0 <= index < len(ids) - 1 else None,
}
return {
"session_id": session_id,
"initial_prompt": session.get("initial_goal") or (job.get("case") or {}).get("goal", ""),
"editable": (job.get("status") or session.get("status")) in TERMINAL,
"case_title": case_title(case),
"original_case": case.get("original_case"),
"navigation": navigation,
}
def contributions(session_id: str, store: AutomationStore) -> list[dict]:
record = store.get("task_extensions", session_id) or {}
result = []
for manifest in active_manifests():
assert manifest.workspace is not None
saved = record.get(manifest.id) or {}
values = saved.get("values", {})
result.append(
{
"plugin_id": manifest.id,
"name": manifest.name,
"version": manifest.version,
"workspace": manifest.workspace.model_dump(),
"revision": saved.get("revision", 0),
"values": {
field.id: values.get(field.id, "") for field in manifest.workspace.fields
},
"history": saved.get("history", []),
}
)
return result
def report_sections(session_id: str, store: AutomationStore) -> list[dict]:
return [
{
"plugin_id": item["plugin_id"],
"name": item["name"],
"version": item["version"],
"revision": item["revision"],
"fields": [
{"id": field["id"], "label": field["label"], "value": item["values"][field["id"]]}
for field in item["workspace"]["fields"]
if field["report"]
],
}
for item in contributions(session_id, store)
if any(field["report"] for field in item["workspace"]["fields"])
]
def save_values(
store: AutomationStore,
session_id: str,
manifest: PluginManifest,
values: dict[str, str],
expected_revision: int,
request_id: str,
) -> None:
"""CAS + request receipts prevent lost edits and repeated history entries."""
identifier(session_id)
assert manifest.workspace is not None
fields = {field.id: field for field in manifest.workspace.fields}
if values.keys() - fields.keys():
raise ValueError("包含未声明的扩展字段")
for key, value in values.items():
field = fields[key]
if len(value) > field.max_length or (
field.kind == "choice" and value and value not in field.options
):
raise ValueError(f"字段 {field.label} 的内容不符合插件声明")
now = time.time()
with store.connection() as conn:
conn.execute("BEGIN IMMEDIATE")
row = conn.execute(
"SELECT payload FROM automation_records WHERE kind='task_extensions' AND id=?",
(session_id,),
).fetchone()
record = json.loads(row[0]) if row else {}
saved = record.get(manifest.id, {"revision": 0, "values": {}, "history": []})
for entry in saved["history"]:
if entry["request_id"] == request_id:
if entry["patch"] != values:
raise ValueError("同一保存请求不能提交不同内容")
return
if saved["revision"] != expected_revision:
raise ValueError("评审已在其他页面修改,请重新加载后保存")
updated = {**saved["values"], **values}
revision = saved["revision"] + 1
saved["history"].append(
{
"revision": revision,
"saved_at": now,
"request_id": request_id,
"patch": values,
"values": updated,
"plugin_version": manifest.version,
"fields": [field.model_dump() for field in manifest.workspace.fields],
}
)
record[manifest.id] = {**saved, "revision": revision, "values": updated}
conn.execute(
"INSERT INTO automation_records VALUES ('task_extensions',?,?,?) "
"ON CONFLICT(kind,id) DO UPDATE SET payload=excluded.payload,updated_at=excluded.updated_at",
(session_id, json.dumps(record, ensure_ascii=False), now),
)
job = conn.execute(
"SELECT batch_id FROM automation_jobs WHERE session_id=?", (session_id,)
).fetchone()
if job and job[0]:
batch = conn.execute(
"SELECT payload FROM automation_records WHERE kind='batch' AND id=?", (job[0],)
).fetchone()
if not batch or json.loads(batch[0]).get("status") == "deleting":
raise ValueError("批次正在删除,不能保存评审")
payload = json.loads(batch[0])
payload["report_ready"] = False
conn.execute(
"UPDATE automation_records SET payload=?,updated_at=? WHERE kind='batch' AND id=?",
(json.dumps(payload, ensure_ascii=False), now, job[0]),
)
conn.commit()
+77 -7
View File
@@ -21,6 +21,7 @@ HEADERS = (
"Artemis提示词", "Artemis提示词",
"任务配置(JSON)", "任务配置(JSON)",
) )
CASE_HEADERS = ("预置条件", "操作步骤", "预期结果")
MAX_ROWS = int(os.getenv("ARTEMIS_IMPORT_MAX_ROWS", "1000")) MAX_ROWS = int(os.getenv("ARTEMIS_IMPORT_MAX_ROWS", "1000"))
MAX_BYTES = int(os.getenv("ARTEMIS_IMPORT_MAX_BYTES", "10485760")) MAX_BYTES = int(os.getenv("ARTEMIS_IMPORT_MAX_BYTES", "10485760"))
@@ -51,7 +52,9 @@ class TaskOptions(BaseModel):
action_failure_attempts: int | None = Field(default=None, ge=1, le=10) action_failure_attempts: int | None = Field(default=None, ge=1, le=10)
worker_cleanup_timeout_seconds: float | None = Field(default=None, gt=0, le=300) worker_cleanup_timeout_seconds: float | None = Field(default=None, gt=0, le=300)
plugins: list[PluginSelection] | None = None plugins: list[PluginSelection] | None = None
execution_target: ExecutionTarget | None = None execution_target: ExecutionTarget | None = Field(
default=None, description="android (default), computer (PC only, no Android), or mixed"
)
@field_serializer("recording", when_used="unless-none") @field_serializer("recording", when_used="unless-none")
def serialize_recording(self, value: RecordingConfig) -> dict: def serialize_recording(self, value: RecordingConfig) -> dict:
@@ -59,6 +62,15 @@ class TaskOptions(BaseModel):
return value.model_dump(exclude_unset=True) return value.model_dump(exclude_unset=True)
class OriginalCase(BaseModel):
"""Imported review material, never appended to the execution prompt."""
model_config = ConfigDict(extra="forbid")
preconditions: str = Field(default="", max_length=10000)
steps: str = Field(default="", max_length=10000)
expected: str = Field(default="", max_length=10000)
class TaskCase(BaseModel): class TaskCase(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
case_id: str = Field(default="", max_length=200) case_id: str = Field(default="", max_length=200)
@@ -68,16 +80,29 @@ class TaskCase(BaseModel):
preconditions: str = Field(default="", max_length=10000) preconditions: str = Field(default="", max_length=10000)
goal: str = Field(min_length=1, max_length=10000) goal: str = Field(min_length=1, max_length=10000)
task_config: TaskOptions = Field(default_factory=TaskOptions) task_config: TaskOptions = Field(default_factory=TaskOptions)
original_case: OriginalCase | None = None
def original_case_enabled() -> bool:
from apps.admin_console.services.task_extensions import active_manifests
return any(
panel.kind == "original_case"
for manifest in active_manifests()
if manifest.workspace is not None
for panel in manifest.workspace.panels
)
def template() -> bytes: def template() -> bytes:
headers = (*HEADERS, *CASE_HEADERS) if original_case_enabled() else HEADERS
wb = Workbook() wb = Workbook()
ws = wb.active ws = wb.active
assert ws is not None assert ws is not None
ws.title = "任务" ws.title = "任务"
ws.append(HEADERS) ws.append(headers)
ws.freeze_panes = "A2" ws.freeze_panes = "A2"
ws.auto_filter.ref = "A1:G1" ws.auto_filter.ref = f"A1:{get_column_letter(len(headers))}1"
for cell in ws[1]: for cell in ws[1]:
cell.font = Font(bold=True, color="FFFFFF") cell.font = Font(bold=True, color="FFFFFF")
cell.fill = PatternFill("solid", fgColor="245C78") cell.fill = PatternFill("solid", fgColor="245C78")
@@ -91,7 +116,7 @@ def template() -> bytes:
help_sheet.append(["前置约束提取及其他描述列仅作查看、搜索和报告元数据,不追加到提示词。"]) help_sheet.append(["前置约束提取及其他描述列仅作查看、搜索和报告元数据,不追加到提示词。"])
help_sheet.append(["任务配置(JSON)可留空,空字段继承批次或全局配置;禁止密钥与未知字段。"]) help_sheet.append(["任务配置(JSON)可留空,空字段继承批次或全局配置;禁止密钥与未知字段。"])
help_sheet.append(["示例仅供参考,不会导入执行。请在“任务”页填写自己的用例。"]) help_sheet.append(["示例仅供参考,不会导入执行。请在“任务”页填写自己的用例。"])
help_sheet.append(HEADERS) help_sheet.append(headers)
help_sheet.append( help_sheet.append(
[ [
"示例-001", "示例-001",
@@ -117,7 +142,29 @@ def template() -> bytes:
), ),
] ]
) )
for column in range(1, 8): help_sheet.append(
[
"示例-PC-001",
"电脑桌面",
"浏览器检查",
"仅 PC 测试",
"已启用并授权提供画面观察的电脑插件,无需手机或模拟器在线",
"打开电脑浏览器,访问指定网页并核对实际结果。",
json.dumps({"profile": "flash", "execution_target": "computer"}, ensure_ascii=False),
]
)
if len(headers) > len(HEADERS):
help_sheet.append(
[
"人工评审插件附加列:预置条件、操作步骤、预期结果。仅保存原始用例,均不追加到 AI 提示词。"
]
)
for row_number in (6, 7):
for column, value in enumerate(
("填写原始用例的预置条件", "填写原始用例的操作步骤", "填写原始用例的预期结果"), 8
):
help_sheet.cell(row_number, column, value)
for column in range(1, len(headers) + 1):
help_sheet.column_dimensions[get_column_letter(column)].width = 70 if column > 5 else 26 help_sheet.column_dimensions[get_column_letter(column)].width = 70 if column > 5 else 26
for row in help_sheet: for row in help_sheet:
for cell in row: for cell in row:
@@ -151,7 +198,10 @@ def parse_workbook(data: bytes) -> dict:
raise ValueError("缺少“任务”工作表,请使用下载的模板") raise ValueError("缺少“任务”工作表,请使用下载的模板")
rows = iter(wb["任务"].iter_rows()) rows = iter(wb["任务"].iter_rows())
headers = [str(cell.value or "").strip() for cell in next(rows, [])] headers = [str(cell.value or "").strip() for cell in next(rows, [])]
if headers != list(HEADERS): supported_headers = [list(HEADERS)]
if original_case_enabled():
supported_headers.append([*HEADERS, *CASE_HEADERS])
if headers not in supported_headers:
raise ValueError("列名或顺序与新版模板不一致,请重新下载模板") raise ValueError("列名或顺序与新版模板不一致,请重新下载模板")
tasks, errors = [], [] tasks, errors = [], []
for number, row in enumerate(rows, 2): for number, row in enumerate(rows, 2):
@@ -177,9 +227,29 @@ def parse_workbook(data: bytes) -> dict:
"preconditions": values[4], "preconditions": values[4],
"goal": values[5], "goal": values[5],
"task_config": config, "task_config": config,
"original_case": (
dict(
zip(
("preconditions", "steps", "expected"),
(
str(cell.value if cell.value is not None else "").strip()
for cell in row[7:10]
),
)
)
if len(headers) == 10
else None
),
}
)
tasks.append(
{
"row": number,
**case.model_dump(
exclude={"original_case"} if case.original_case is None else None
),
} }
) )
tasks.append({"row": number, **case.model_dump()})
except (ValueError, TypeError) as exc: except (ValueError, TypeError) as exc:
errors.append({"row": number, "message": str(exc)[:500]}) errors.append({"row": number, "message": str(exc)[:500]})
if not tasks and not errors: if not tasks and not errors:
@@ -0,0 +1,82 @@
"""One read-only lifecycle view for web, MCP and exported reports."""
import json
from pathlib import Path
import sqlite3
from artemis.config import DB_PATH
from artemis.runtime.task_control import list_instructions, read_state
from artemis.runtime.task_progress import progress_snapshot
def _records(session_id: str, path: Path):
if not path.is_file():
return None, None, []
with sqlite3.connect(path.resolve().as_uri() + "?mode=ro", uri=True) as conn:
conn.row_factory = sqlite3.Row
tables = {
row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
}
session = (
conn.execute("SELECT * FROM sessions WHERE session_id=?", (session_id,)).fetchone()
if "sessions" in tables
else None
)
record = (
conn.execute(
"SELECT payload FROM automation_jobs WHERE session_id=?", (session_id,)
).fetchone()
if "automation_jobs" in tables
else None
)
rows = (
conn.execute(
"SELECT payload FROM traces WHERE session_id=? AND type='final_review_diagnostic'",
(session_id,),
).fetchall()
if "traces" in tables
else []
)
return session, record, rows
def task_lifecycle(session_id: str, *, db_path=None, now=None) -> dict:
session, record, rows = _records(session_id, Path(db_path) if db_path else DB_PATH)
try:
job = json.loads(record[0]) if record else {}
except (ValueError, TypeError):
job = {}
reviews = []
for row in rows:
try:
value = json.loads(row[0])
except (ValueError, TypeError):
continue
if isinstance(value, dict):
reviews.append(value)
session = dict(session) if session else {}
control = read_state(session_id)
if session.get("pid") and control.get("pid") != session["pid"]:
control = {}
value = progress_snapshot(
job=job if isinstance(job, dict) else {},
session=session,
control=control,
reviews=reviews,
now=now,
)
instructions = list_instructions(session_id, task_status=value["status"])
value["guidance_counts"] = {
status: sum(item["status"] == status for item in instructions)
for status in ("pending", "reading", "consumed", "unapplied", "unconfirmed")
}
return value
def guidance_details(session_id: str, *, db_path=None) -> dict:
lifecycle = task_lifecycle(session_id, db_path=db_path)
return {
"session_id": session_id,
"lifecycle": lifecycle,
"instructions": list_instructions(session_id, task_status=lifecycle["status"]),
}
@@ -47,6 +47,7 @@ from artemis.runtime import (
request_cancel, request_cancel,
trace_store, trace_store,
) )
from artemis.runtime.cancellation import CancellationReason
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -136,6 +137,7 @@ class TaskQueueService:
pid=pid_int, pid=pid_int,
process_created_at=created_at, process_created_at=created_at,
reason=reason, reason=reason,
cancel_reason=CancellationReason.USER_STOP,
) )
if not written: if not written:
return cls._hard_kill(pid_int, created_at), False return cls._hard_kill(pid_int, created_at), False
@@ -344,7 +346,7 @@ class TaskQueueService:
@classmethod @classmethod
def _concurrency_limit(cls) -> int: def _concurrency_limit(cls) -> int:
"""Return 0 for per-device concurrency, or the global task limit.""" """Return the shared global single-task limit, including computer targets."""
return DeviceExecutionLock.resolve_env_concurrency() return DeviceExecutionLock.resolve_env_concurrency()
@classmethod @classmethod
@@ -352,10 +354,8 @@ class TaskQueueService:
"""Persistent dispatcher scheduling pending tasks onto devices. """Persistent dispatcher scheduling pending tasks onto devices.
Scans the queue and launches each eligible task as an independent Scans the queue and launches each eligible task as an independent
coroutine. Admission is governed by _concurrency_limit(): 0 admits one coroutine. Admission allows one executing task across all targets.
task per device so distinct devices execute concurrently, N>=1 admits Global FIFO ordering and cross-process mutual exclusion are enforced by DeviceExecutionLock
at most N tasks across all devices. Per-device FIFO ordering and
cross-process mutual exclusion remain enforced by DeviceExecutionLock
inside each worker process. inside each worker process.
""" """
print( print(
@@ -434,6 +434,8 @@ class TaskQueueService:
target = cls._task_target(item) target = cls._task_target(item)
resources = set(item.get("plugin_resources", [])) resources = set(item.get("plugin_resources", []))
if resources & busy_resources: if resources & busy_resources:
if limit == 1:
return
continue continue
if limit == 0: if limit == 0:
# A task without a resolved device may bind to any serial, so it # A task without a resolved device may bind to any serial, so it
@@ -449,6 +451,8 @@ class TaskQueueService:
continue continue
elif limit >= 1 and device is not None and target.lock_key in busy_devices: elif limit >= 1 and device is not None and target.lock_key in busy_devices:
# A second worker for this device would wait on its lock. # A second worker for this device would wait on its lock.
if limit == 1:
return
continue continue
item["status"] = "running" item["status"] = "running"
@@ -518,7 +522,11 @@ class TaskQueueService:
job = store.job(str(sess_id)) if sess_id else None job = store.job(str(sess_id)) if sess_id else None
if job: if job:
job.update(status="running", started_at=time.time()) job.update(
status="running",
started_at=task_item["start_time"],
worker_started_at=task_item["start_time"],
)
store.save_job(job) store.save_job(job)
# A fresh launch clears a stale stop request left over for this run # A fresh launch clears a stale stop request left over for this run
@@ -579,6 +587,13 @@ class TaskQueueService:
env["ARTEMIS_SESSION_ID"] = str(sess_id) env["ARTEMIS_SESSION_ID"] = str(sess_id)
env["ARTEMIS_TASK_INGRESS"] = str(task_item.get("ingress", "frontend")) env["ARTEMIS_TASK_INGRESS"] = str(task_item.get("ingress", "frontend"))
env["ARTEMIS_TASK_WORKER"] = "1" env["ARTEMIS_TASK_WORKER"] = "1"
# Capture the global Flash choice at launch, including saved favorites.
# It is metadata in the snapshot, never a favorite's runtime override.
flash_final_check = (task_item.get("execution_config") or {}).get(
"flash_final_check_enabled"
)
if profile == "flash" and flash_final_check is not None:
env["ARTEMIS_FLASH_FINAL_CHECK_ENABLED"] = str(flash_final_check).lower()
from artemis.exploration.store import explorations from artemis.exploration.store import explorations
exploration_run = explorations.run(str(sess_id)) if sess_id else None exploration_run = explorations.run(str(sess_id)) if sess_id else None
@@ -1507,8 +1522,10 @@ class TaskQueueService:
from artemis.plugins.store import PluginStore from artemis.plugins.store import PluginStore
for batch in store.records("batch"): for batch in store.records("batch"):
if batch["status"] == "running": if batch["status"] in {"running", "paused"}:
batch.update(status="paused", reason="已停止全部任务,等待手动继续") batch.update(
status="paused", reason="已停止全部任务,等待手动继续", pause_source="user"
)
store.put("batch", batch["id"], batch) store.put("batch", batch["id"], batch)
# 1. Cancel local queue reservations # 1. Cancel local queue reservations
for item in state.queue_items: for item in state.queue_items:
+11 -1
View File
@@ -2,12 +2,14 @@
## Local Purpose ## Local Purpose
本目录维护简体中文 Angular 网页,包括设备与任务操作、模型配置、报告、批量导入和历史检索。 本目录维护简体中文 Angular 网页,包括 Flash/Pro/探索、人工示范、插件、精选任务、模型配置、批次、历史、报告与跨端证据。
## Inherits ## Inherits
继承仓库根目录 AGENTS.md;本文件仅补充前端规则,当前用户指令优先。 继承仓库根目录 AGENTS.md;本文件仅补充前端规则,当前用户指令优先。
修改 UI 前阅读根目录 [DESIGN.md](../../DESIGN.md) 的视觉与交互约定;本应用没有独立的局部 DESIGN.md。SDK 的同名文件描述远程协议,不是网页设计来源。
## Local Commands ## Local Commands
在本目录执行,包管理器使用 npm 和现有 package-lock.json: 在本目录执行,包管理器使用 npm 和现有 package-lock.json:
@@ -28,12 +30,20 @@ npm run build
## Local Conventions ## Local Conventions
- 发版遵守根指南的 Release Gates。前端应用版本只改 package.json 顶层 `version` 和 package-lock.json 顶层及 `packages[""].version`;禁止把相同数字的第三方依赖一并替换。
- Node 支持范围以锁定的 Angular CLI/构建依赖实际 `engines` 为准,核对启动脚本的主、次、补丁版本边界和提示;不要只判断“Node 22/24/26”。
- 安装或依赖变更需在隔离目录、独立空 npm 缓存下运行 `npm ci`,再运行上述测试和构建;国内入口同时核验镜像安装。不得以关闭完整性校验、强制安装或删除锁文件掩盖不一致,也不得用已装依赖的构建结果宣称全新安装通过。
- 页面组件在 src/app/components,服务在 src/app/services;先复用已有组件、样式及 API 服务。 - 页面组件在 src/app/components,服务在 src/app/services;先复用已有组件、样式及 API 服务。
- 保持现有简体中文文案、颜色、间距和控件样式,菜单、弹窗和表单必须清晰可读。 - 保持现有简体中文文案、颜色、间距和控件样式,菜单、弹窗和表单必须清晰可读。
- 修改任务或配置交互时覆盖加载、保存、失败和空状态;不要将执行中误标成录屏中或把完成误标成验证通过。 - 修改任务或配置交互时覆盖加载、保存、失败和空状态;不要将执行中误标成录屏中或把完成误标成验证通过。
- 使用相邻的 .spec.ts 测试交互和请求数据;不要只测试实现细节。 - 使用相邻的 .spec.ts 测试交互和请求数据;不要只测试实现细节。
- 后端字段调整需同时核对类型、请求和响应;设备序列号、报告格式、批次编号等不能在前端猜测。 - 后端字段调整需同时核对类型、请求和响应;设备序列号、报告格式、批次编号等不能在前端猜测。
- UI 有明显变化时在浏览器验证实际布局和关键操作,构建通过不能代替可读性检查。 - UI 有明显变化时在浏览器验证实际布局和关键操作,构建通过不能代替可读性检查。
- 工作区底部输入框服务当前任务;探索建议只填入草稿,用户发送后再提交。用例编辑和配置按需展开,复跑固定保存版本。
- 主模型、兜底模型及精选任务复用现有模型选择组件;收藏使用已保存的独立配置,设备离线时给出可操作的选择提示。
- 星标仅占标题区域,后续状态行正常左对齐;人工示范来源与按钮保持等高、居中,窄屏可换行。执行、等待回答、示范准备/录制/分析、最终验收及未验证各有明确文字。
- 电脑步骤使用电脑证据,录像缺口明确展示。播放器按真实来源和时间定位,跳转保留原播放/暂停状态。
- 列表后台刷新保留选中项、滚动及折叠状态,不用反复插入的加载行挤动布局;实时连接沿用现有共享机制和页面可见性管理。
## Local Risks ## Local Risks
+1871 -2313
View File
File diff suppressed because it is too large. Load diff
+18 -12
View File
@@ -1,6 +1,6 @@
{ {
"name": "artemis-showcase-ui", "name": "artemis-showcase-ui",
"version": "2.0.0", "version": "2.7.1",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"start": "ng serve --proxy-config proxy.conf.json", "start": "ng serve --proxy-config proxy.conf.json",
@@ -9,23 +9,29 @@
"test": "ng test" "test": "ng test"
}, },
"private": true, "private": true,
"overrides": {
"@angular/build": {
"vite": "8.3.5"
}
},
"dependencies": { "dependencies": {
"@angular/cdk": "^22.1.5", "@angular/cdk": "^22.2.4",
"@angular/common": "^22.1.4", "@angular/common": "^22.2.3",
"@angular/compiler": "^22.1.4", "@angular/compiler": "^22.2.3",
"@angular/core": "^22.1.4", "@angular/core": "^22.2.3",
"@angular/forms": "^22.1.4", "@angular/forms": "^22.2.3",
"@angular/platform-browser": "^22.1.4", "@angular/platform-browser": "^22.2.3",
"@angular/platform-browser-dynamic": "^22.1.4", "@angular/platform-browser-dynamic": "^22.2.3",
"@angular/router": "^22.1.4", "@angular/router": "^22.2.3",
"marked": "18.1.1",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0", "tslib": "^2.3.0",
"zone.js": "~0.15.0" "zone.js": "~0.15.0"
}, },
"devDependencies": { "devDependencies": {
"@angular/build": "^22.1.6", "@angular/build": "^22.2.4",
"@angular/cli": "^22.1.6", "@angular/cli": "^22.2.4",
"@angular/compiler-cli": "^22.1.4", "@angular/compiler-cli": "^22.2.3",
"@types/jasmine": "~5.1.0", "@types/jasmine": "~5.1.0",
"istanbul-lib-instrument": "^6.0.3", "istanbul-lib-instrument": "^6.0.3",
"jasmine-core": "~5.6.0", "jasmine-core": "~5.6.0",
+23
View File
@@ -0,0 +1,23 @@
# 本地网页字体
这些字体作为静态资源随源码和网页构建产物分发,由 `src/styles.scss` 的 `@font-face` 引用。构建和页面显示不需要连接 Google Fonts;不要恢复外部字体样式表或预连接。
正文提供 Latin 字形,中文继续使用系统字体。Material Symbols 保留上游完整图标集和 `FILL`、`GRAD`、`opsz`、`wght` 四个可变轴,兼容现有连字名称及组件样式。
## 来源与许可证
| 文件 | 固定来源 | 许可证 |
| --- | --- | --- |
| `inter-latin-wght-normal.woff2` | [@fontsource-variable/inter 5.3.0](https://www.npmjs.com/package/@fontsource-variable/inter/v/5.3.0),`files/inter-latin-wght-normal.woff2` | [SIL OFL 1.1](licenses/inter-OFL.txt) |
| `outfit-latin-wght-normal.woff2` | [@fontsource-variable/outfit 5.3.0](https://www.npmjs.com/package/@fontsource-variable/outfit/v/5.3.0),`files/outfit-latin-wght-normal.woff2` | [SIL OFL 1.1](licenses/outfit-OFL.txt) |
| `roboto-latin-wght-normal.woff2` | [@fontsource-variable/roboto 5.3.0](https://www.npmjs.com/package/@fontsource-variable/roboto/v/5.3.0),`files/roboto-latin-wght-normal.woff2` | [SIL OFL 1.1](licenses/roboto-OFL.txt) |
| `material-symbols-outlined.woff2` | Google [material-design-icons 的固定提交](https://github.com/google/material-design-icons/tree/10ecc14790612ca8fd164e3f4ffe9c16fc50d5b7/variablefont),`MaterialSymbolsOutlined[FILL,GRAD,opsz,wght].woff2` | [Apache 2.0](licenses/material-symbols-outlined-Apache-2.0.txt) |
字体文件未经修改。更新时从固定版本获取,保留上游版权与许可证,并同步以下 SHA256;不要在安装或构建时下载字体。
| 文件 | SHA256 |
| --- | --- |
| `inter-latin-wght-normal.woff2` | `3100e775e8616cd2611beecfa23a4263d7037586789b43f035236a2e6fbd4c62` |
| `outfit-latin-wght-normal.woff2` | `6c18d579fd87c3776be068b762cbc83fde3acb543d49eabd3ade842eb987e887` |
| `roboto-latin-wght-normal.woff2` | `1404ca348bd75ef836f4dd8b6f2cc719458642d1237c368296b2fc652dca47dc` |
| `material-symbols-outlined.woff2` | `3ae08513a06396b0258cf1d1a1091c2d336977518fb755ea5b4a13aacdbdc6ef` |
@@ -0,0 +1,93 @@
Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) Inter-Italic[opsz,wght].ttf: Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@@ -0,0 +1,93 @@
Copyright 2021 The Outfit Project Authors (https://github.com/Outfitio/Outfit-Fonts)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,93 @@
Copyright 2011 The Roboto Project Authors (https://github.com/googlefonts/roboto-classic) Roboto-Italic[wdth,wght].ttf: Copyright 2011 The Roboto Project Authors (https://github.com/googlefonts/roboto-classic)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+41
View File
@@ -0,0 +1,41 @@
// Shared markup appears in component innerHTML and in body-mounted overlays.
:is(.report-prose, .stream-text, .markdown-content) {
min-width: 0;
overflow-wrap: anywhere;
a { color: var(--app-primary-text, #2563eb); text-decoration: underline; }
img { max-width: 100%; height: auto; border-radius: 8px; }
.md-table-scroll { max-width: 100%; overflow-x: auto; margin: 10px 0; }
table { width: 100%; border-collapse: collapse; font-size: 12px; }
th, td {
border: 1px solid var(--app-border-control, #cbd5e1);
padding: 7px 9px;
text-align: left;
vertical-align: top;
}
th { background: var(--app-primary-soft, #eff6ff); font-weight: 600; }
.md-table-scroll .md-align-left { text-align: left; }
.md-table-scroll .md-align-center { text-align: center; }
.md-table-scroll .md-align-right { text-align: right; }
pre { max-width: 100%; overflow-x: auto; white-space: pre; }
ul, ol { padding-left: 24px; }
li > ul, li > ol { margin: 4px 0; }
.md-task-item { list-style: none; }
.md-task-marker { display: inline-block; margin-left: -18px; margin-right: 4px; }
.md-task-marker.checked { color: var(--app-success-text, #047857); }
.md-task-marker.progress { color: var(--app-primary-text, #2563eb); }
.verify-badge, .finding-badge {
display: inline-block;
margin-right: 6px;
padding: 1px 5px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
background: var(--app-primary-soft, #eff6ff);
color: var(--app-primary-text, #1d4ed8);
}
.verify-badge.assert, .finding-badge {
background: var(--app-warning-soft, #fffbeb);
color: var(--app-warning-text, #92400e);
}
}
+59
View File
@@ -0,0 +1,59 @@
@use 'sass:color';
// Light values stay beside each existing declaration as fallbacks. These shared
// semantic overrides also reach CDK overlays mounted outside routed components.
:root[data-theme='dark'] {
color-scheme: dark;
@each $role, $value in (
canvas: #0b1220,
surface: #111c2e,
surface-soft: #172337,
surface-raised: #1e2c42,
text-strong: #f1f5f9,
text-control: #dbe4f0,
text-secondary: #bdcadd,
text-muted: #98aac1,
text-faint: #8da1bb,
border: #2b3b52,
border-control: #3d506a,
highlight: #24344d,
primary-text: #8ebcff,
primary-soft: #152e50,
primary-border: #375b83,
exploration-text: #b7b3ff,
exploration-soft: #29264b,
exploration-border: #514a83,
success-text: #6ee7b7,
success-soft: #103b32,
success-border: #24634d,
warning-text: #fcd089,
warning-soft: #3b2b16,
warning-border: #775728,
error-text: #ff9ca7,
error-soft: #421f2b,
error-border: #7d3d4e,
info-text: #83d8f5,
info-soft: #153747,
info-border: #316479
) {
--app-#{$role}: #{$value};
--app-#{$role}-rgb: #{color.channel($value, 'red', $space: rgb)} #{color.channel($value, 'green', $space: rgb)} #{color.channel($value, 'blue', $space: rgb)};
}
}
:root {
color-scheme: light;
background: var(--app-canvas, #f8f9fa);
}
body {
background: var(--app-canvas, #f8f9fa);
}
input, textarea, select {
color: inherit;
}
::selection {
background: var(--app-primary-border, #bfdbfe);
}
@@ -0,0 +1,127 @@
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { computed, signal } from '@angular/core';
import { provideRouter } from '@angular/router';
import { OverlayContainer } from '@angular/cdk/overlay';
import { Subject } from 'rxjs';
import { AgentService } from '../../services/agent.service';
import { SessionUsage } from '../../core/models/session.model';
import { AgentStreamComponent } from './agent-stream.component';
describe('execution mode cache usage', () => {
let fixture: ComponentFixture<AgentStreamComponent>;
let component: AgentStreamComponent;
let service: AgentService;
let overlay: OverlayContainer;
let requests: Subject<SessionUsage>[];
const running = signal(false);
const usage = (session_id = 'cache-session', cached_tokens = 90000): SessionUsage => ({
session_id, llm_calls: 4, prompt_tokens: 305000, completion_tokens: 2100,
total_tokens: 307100, cached_tokens, operator_context_tokens: 150000,
operator_context_window_tokens: 300000
});
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting(), provideRouter([])]
});
service = TestBed.inject(AgentService);
running.set(false);
service.currentSessionId.set('cache-session');
spyOn(service, 'currentSession').and.callFake(computed(() => ({
session_id: service.currentSessionId() || '', initial_goal: '缓存统计测试',
start_time: 1000, end_time: 1090, status: running() ? 'running' : 'completed'
})));
service.activeModel.set({ name: 'Flash', id: 'test-model', provider: 'openai' });
requests = [];
spyOn(service, 'getSessionUsage').and.callFake(() => {
const response = new Subject<SessionUsage>();
requests.push(response);
return response.asObservable();
});
fixture = TestBed.createComponent(AgentStreamComponent);
component = fixture.componentInstance;
overlay = TestBed.inject(OverlayContainer);
fixture.detectChanges();
const trigger = fixture.nativeElement.querySelector('.model-info-chip') as HTMLButtonElement;
trigger.click();
fixture.detectChanges();
});
afterEach(() => overlay.ngOnDestroy());
const text = () => TestBed.inject(OverlayContainer).getContainerElement().textContent || '';
it('renders cumulative cache input and the token-weighted rate without adding cached input to totals', () => {
expect(text()).toContain('正在读取');
requests[0].next(usage());
fixture.detectChanges();
expect(text()).toContain('缓存命中率');
expect(text()).toContain('29.5%');
expect(text()).toContain('90k 缓存 Token');
expect(text()).toContain('307.1k');
expect(text()).not.toContain('397.1k');
requests[0].next(usage('cache-session', 0));
fixture.detectChanges();
expect(text()).toContain('0%');
expect(text()).toContain('0 缓存 Token');
});
it('shows unavailable usage and fetch errors without inventing a hit rate', () => {
requests[0].next({ ...usage(), prompt_tokens: 0, cached_tokens: 0 });
fixture.detectChanges();
const row = overlay.getContainerElement().querySelector('[title^="本任务累计缓存输入"]');
expect(row?.textContent).toContain('—');
requests[0].error(new Error('offline'));
fixture.detectChanges();
expect(text()).toContain('读取失败');
expect(text()).toContain('刷新失败');
});
it('keeps cache statistics available in Pro and exploration mode', () => {
requests[0].next(usage());
for (const name of ['Pro', '探索']) {
service.activeModel.set({ name, id: 'test-model', provider: 'openai' });
fixture.detectChanges();
expect(text()).toContain('29.5%');
expect(text()).toContain('90k 缓存 Token');
}
});
it('refreshes running usage while allowing slow reads to finish and stops polling on close', fakeAsync(() => {
running.set(true);
fixture.detectChanges();
expect(requests.length).toBe(2);
tick(6000);
expect(requests.length).toBe(2);
requests[1].next(usage());
requests[1].complete();
tick(3000);
expect(requests.length).toBe(3);
requests[2].next(usage('cache-session', 180000));
fixture.detectChanges();
expect(text()).toContain('59%');
component.isRunInfoOpen.set(false);
fixture.detectChanges();
expect(requests[2].observed).toBeFalse();
tick(3000);
expect(requests.length).toBe(3);
}));
it('cancels old reads and clears the previous task usage when switching tasks or closing', () => {
requests[0].next(usage());
service.currentSessionId.set('next-session');
fixture.detectChanges();
expect(requests[0].observed).toBeFalse();
expect(component.runUsage()).toBeNull();
expect(text()).not.toContain('90k 缓存 Token');
requests[1].error(new Error('offline'));
fixture.detectChanges();
expect(text()).toContain('读取失败');
component.isRunInfoOpen.set(false);
fixture.detectChanges();
expect(overlay.getContainerElement().querySelector('.run-info-dropdown')).toBeNull();
});
});
@@ -0,0 +1,58 @@
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { AgentStreamComponent } from './agent-stream.component';
describe('action card text selection', () => {
let component: AgentStreamComponent;
let header: HTMLDivElement;
let outside: HTMLDivElement;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting(), provideRouter([])]
});
component = TestBed.createComponent(AgentStreamComponent).componentInstance;
header = document.createElement('div');
header.textContent = '点击元素 Projects';
header.addEventListener('click', event => component.toggleActionExpanded('action-1', event));
outside = document.createElement('div');
outside.textContent = '其他步骤';
document.body.append(header, outside);
window.getSelection()?.removeAllRanges();
});
afterEach(() => {
window.getSelection()?.removeAllRanges();
header.remove();
outside.remove();
});
function selectText(element: HTMLElement): void {
const range = document.createRange();
range.selectNodeContents(element);
window.getSelection()?.addRange(range);
}
it('keeps selected header text and the expanded card in place for copying', () => {
component.toggleActionExpanded('action-1');
selectText(header);
header.click();
expect(component.isActionExpanded('action-1')).toBeTrue();
expect(window.getSelection()?.toString()).toBe('点击元素 Projects');
});
it('expands and collapses normally without selected text', () => {
header.click();
expect(component.isActionExpanded('action-1')).toBeTrue();
header.click();
expect(component.isActionExpanded('action-1')).toBeFalse();
});
it('allows another card to open while unrelated text is selected', () => {
selectText(outside);
header.click();
expect(component.isActionExpanded('action-1')).toBeTrue();
});
});
@@ -44,7 +44,7 @@ limitations under the License.
</button> </button>
<button #automationOrigin="cdkOverlayOrigin" cdkOverlayOrigin class="vscode-tab-btn" [class.active]="isAutomationOpen()" (click)="isAutomationOpen.set(!isAutomationOpen())" title="报告与批量" aria-label="报告与批量"><span class="material-symbols-outlined tab-icon">assignment</span><span>报告与批量</span></button> <button #automationOrigin="cdkOverlayOrigin" cdkOverlayOrigin class="vscode-tab-btn" [class.active]="isAutomationOpen()" (click)="isAutomationOpen.set(!isAutomationOpen())" title="报告与批量" aria-label="报告与批量"><span class="material-symbols-outlined tab-icon">assignment</span><span>报告与批量</span></button>
</div> </div>
<ng-template cdkConnectedOverlay [cdkConnectedOverlayOrigin]="automationOrigin" [cdkConnectedOverlayOpen]="isAutomationOpen()" [cdkConnectedOverlayHasBackdrop]="true" cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop" (backdropClick)="isAutomationOpen.set(false)" [cdkConnectedOverlayPositions]="dropdownPositions"><div class="vscode-dropdown-menu" style="width:min(560px,calc(100vw - 24px));max-height:85vh;overflow:auto;padding:20px"><div class="popover-title-header" style="justify-content:space-between;margin-bottom:6px"><span>报告与自动化</span><button type="button" (click)="isAutomationOpen.set(false)" aria-label="关闭报告与自动化" style="border:0;background:transparent;color:#64748b;cursor:pointer;padding:4px"><span class="material-symbols-outlined">close</span></button></div><app-automation-panel (queueRequested)="openBatchQueue()" /></div></ng-template> <ng-template cdkConnectedOverlay [cdkConnectedOverlayOrigin]="automationOrigin" [cdkConnectedOverlayOpen]="isAutomationOpen()" [cdkConnectedOverlayHasBackdrop]="true" cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop" (backdropClick)="isAutomationOpen.set(false)" [cdkConnectedOverlayPositions]="dropdownPositions"><div class="vscode-dropdown-menu" style="width:min(560px,calc(100vw - 24px));max-height:85vh;overflow:auto;padding:20px"><div class="popover-title-header" style="justify-content:space-between;margin-bottom:6px"><span>报告与自动化</span><button type="button" (click)="isAutomationOpen.set(false)" aria-label="关闭报告与自动化" style="border:0;background:transparent;color:var(--app-text-muted, #64748b);cursor:pointer;padding:4px"><span class="material-symbols-outlined">close</span></button></div><app-automation-panel (queueRequested)="openBatchQueue()" /></div></ng-template>
<!-- Dropdown Popover Panel for Task Queue --> <!-- Dropdown Popover Panel for Task Queue -->
<ng-template <ng-template
@@ -87,7 +87,7 @@ limitations under the License.
@if (getTaskStatus(session) === 'running') { @if (getTaskStatus(session) === 'running') {
<span class="status-dot"></span> <span class="status-dot"></span>
} }
{{ getTaskStatus(session) | uiLabel }} {{ taskPhaseLabel(session) || (getTaskStatus(session) | uiLabel) }}
</span> </span>
<app-test-result-badge [result]="session.test_result" [executionStatus]="getTaskStatus(session)" [compact]="true" /> <app-test-result-badge [result]="session.test_result" [executionStatus]="getTaskStatus(session)" [compact]="true" />
</div> </div>
@@ -129,9 +129,7 @@ limitations under the License.
<div class="item-goal" [title]="session.initial_goal || ''"> <div class="item-goal" [title]="session.initial_goal || ''">
{{ session.display_name || session.initial_goal || '未命名任务' }} {{ session.display_name || session.initial_goal || '未命名任务' }}
</div> </div>
@if (session.error && getTaskStatus(session) === 'failed') { <app-task-outcome [error]="session.error" [result]="session.test_result" [status]="getTaskStatus(session)" />
<div class="item-error" [title]="session.error">{{session.error}}</div>
}
<div class="item-footer"> <div class="item-footer">
<app-task-id-copy [taskId]="session.session_id" /> <app-task-id-copy [taskId]="session.session_id" />
<span class="item-date">{{ session.start_time * 1000 | date:'MM/dd/yy' }} · 已用 {{ formatTaskDuration(session.start_time, session.end_time) }}</span> <span class="item-date">{{ session.start_time * 1000 | date:'MM/dd/yy' }} · 已用 {{ formatTaskDuration(session.start_time, session.end_time) }}</span>
@@ -183,160 +181,7 @@ limitations under the License.
<!-- Rich Notes Content Viewer --> <!-- Rich Notes Content Viewer -->
@if (noteKeys().length > 0) { @if (noteKeys().length > 0) {
<div class="dropdown-notes-content"> <div class="dropdown-notes-content">
@if (agentService.selectedNoteKey() !== 'task_plan.md') {
<app-report-markdown [content]="currentNoteContent()" /> <app-report-markdown [content]="currentNoteContent()" />
} @else {
@if (parsedNote().title) {
<h3 class="note-title">{{ parsedNote().title }}</h3>
}
<!-- Milestones list -->
@if (parsedNote().milestones.length > 0) {
<div class="milestones-container">
@for (milestone of parsedNote().milestones; track trackMilestone($index, milestone)) {
<div
class="milestone-block"
[ngClass]="milestone.type"
>
<div class="milestone-title-row">
@switch (milestone.type) {
@case ('checked') {
<span class="material-symbols-outlined milestone-status-icon checked">check_box</span>
}
@case ('progress') {
<span class="material-symbols-outlined milestone-status-icon progress">indeterminate_check_box</span>
}
@case ('unchecked') {
<span class="material-symbols-outlined milestone-status-icon unchecked">check_box_outline_blank</span>
}
}
<div class="milestone-text">
@for (seg of milestone.segments; track seg) {
<span
[class.bold-segment]="seg.bold"
[class.code-segment]="seg.code"
>{{ seg.text }}</span>
}
</div>
</div>
<!-- Sub-steps list -->
@if (milestone.subSteps.length > 0) {
<div class="milestone-substeps">
@for (step of milestone.subSteps; track trackMarkdownLine($index, step)) {
<div
class="substep-row"
[ngClass]="step.type"
[style.padding-left.px]="(step.indent > 2 ? (step.indent - 2) * 12 : 0)"
>
@switch (step.type) {
@case ('checked') {
<span class="material-symbols-outlined check-icon checked">check_box</span>
}
@case ('progress') {
<span class="material-symbols-outlined check-icon progress">indeterminate_check_box</span>
}
@case ('unchecked') {
<span class="material-symbols-outlined check-icon unchecked">check_box_outline_blank</span>
}
@case ('list-item') {
<span class="bullet">•</span>
}
}
@if (step.type !== 'empty') {
<span class="line-text">
@for (seg of step.segments; track seg) {
<span
[class.bold-segment]="seg.bold"
[class.code-segment]="seg.code"
>{{ seg.text }}</span>
}
</span>
}
</div>
}
</div>
}
<!-- Verification result -->
@if (milestone.checks.length > 0) {
<div class="milestone-checks-container">
@for (check of milestone.checks; track trackMarkdownLine($index, check)) {
<div
class="verification-card"
[ngClass]="check.type"
[style.margin-left.px]="(check.indent > 2 ? (check.indent - 2) * 12 : 0)"
>
<div class="card-header">
<span class="card-tag" [class.assert]="check.checkKind === 'assert'">{{ check.checkKind || 'verify' }}{{ check.atEnd ? ' @end' : '' }}</span>
</div>
<div class="card-body">
@for (seg of check.segments; track seg) {
<span
[class.bold-segment]="seg.bold"
[class.code-segment]="seg.code"
>{{ seg.text }}</span>
}
</div>
</div>
}
</div>
}
</div>
}
</div>
}
<!-- Other Markdown Lines Container -->
@if (parsedNote().otherLines.length > 0) {
<div class="other-lines-container">
@for (line of parsedNote().otherLines; track trackMarkdownLine($index, line)) {
@if (line.type === 'verify' || line.type === 'finding') {
<div class="verification-card standalone" [ngClass]="line.type" [style.margin-left.px]="line.indent * 12">
<div class="card-header">
<span class="card-tag" [class.assert]="line.checkKind === 'assert'">{{ line.checkKind || 'verify' }}{{ line.atEnd ? ' @end' : '' }}</span>
</div>
<div class="card-body">
@for (seg of line.segments; track seg) {
<span
[class.bold-segment]="seg.bold"
[class.code-segment]="seg.code"
>{{ seg.text }}</span>
}
</div>
</div>
} @else {
<div
class="md-line"
[ngClass]="line.type"
[style.margin-left.px]="line.indent * 12"
>
@switch (line.type) {
@case ('checked') {
<span class="material-symbols-outlined check-icon checked">check_box</span>
}
@case ('progress') {
<span class="material-symbols-outlined check-icon progress">indeterminate_check_box</span>
}
@case ('unchecked') {
<span class="material-symbols-outlined check-icon unchecked">check_box_outline_blank</span>
}
@case ('list-item') {
<span class="bullet">•</span>
}
}
@if (line.type !== 'empty') {
<span class="line-text">
@for (seg of line.segments; track seg) {
<span
[class.bold-segment]="seg.bold"
[class.code-segment]="seg.code"
>{{ seg.text }}</span>
}
</span>
}
</div>
}
}
</div>
}
}
</div> </div>
} }
</div> </div>
@@ -400,6 +245,21 @@ limitations under the License.
} }
</div> </div>
<div class="run-info-row" title="本任务累计缓存输入 ÷ 累计输入,包含所有模型调用;缓存 Token 已计入输入总量。未上报的缓存输入按 0 统计。">
<span class="run-info-label">缓存命中率</span>
@if (runInfoCachePercent() !== null) {
<span class="run-info-value">
{{ runInfoCachePercent() }}%
<span class="run-info-sub">{{ formatRunTokens(runUsage()?.cached_tokens) }} 缓存 Token</span>
</span>
} @else {
<span class="run-info-value is-muted">{{ runUsageLoading() ? '正在读取…' : runUsageError() ? '读取失败' : '—' }}</span>
}
</div>
@if (runUsageError() && runUsage()) {
<div class="run-info-note" role="status">用量刷新失败,显示上次统计。</div>
}
<div class="run-info-row is-stacked"> <div class="run-info-row is-stacked">
<div class="run-info-line"> <div class="run-info-line">
<span class="run-info-label">上下文</span> <span class="run-info-label">上下文</span>
@@ -514,6 +374,7 @@ limitations under the License.
<!-- Event Logs Section --> <!-- Event Logs Section -->
<main class="stream-logs-content"> <main class="stream-logs-content">
<ng-content select="[task-summary]"></ng-content> <ng-content select="[task-summary]"></ng-content>
<app-task-lifecycle [value]="agentService.currentLifecycle()" />
<app-llm-request-status [sessionId]="agentService.currentSessionId()" [running]="agentService.isCurrentSessionRunning()" (activeChange)="hasModelRequest.set($event)" /> <app-llm-request-status [sessionId]="agentService.currentSessionId()" [running]="agentService.isCurrentSessionRunning()" (activeChange)="hasModelRequest.set($event)" />
@if (agentService.isSessionContentLoading()) { @if (agentService.isSessionContentLoading()) {
<div class="session-loading-state" aria-live="polite"> <div class="session-loading-state" aria-live="polite">
@@ -522,16 +383,16 @@ limitations under the License.
</div> </div>
} }
@if (!agentService.isSessionContentLoading() && phases().length === 0 && !showPlanningLoader() && !hasModelRequest() && startupWorkItems().length === 0) { @if (!agentService.isSessionContentLoading() && phases().length === 0 && !showPlanningLoader() && !hasModelRequest() && startupWorkItems().length === 0 && awaitingGuidance().length === 0) {
<div class="empty-state"> <div class="empty-state">
<div class="empty-icon"><span class="material-symbols-outlined">sensors</span></div> <div class="empty-icon"><span class="material-symbols-outlined">sensors</span></div>
<h3>尚未选择执行记录</h3> <h3>{{ agentService.currentSessionId() ? '暂无步骤记录' : '尚未选择执行记录' }}</h3>
<p>请从右侧任务列表选择会话,查看实时执行记录。</p> <p>{{ agentService.currentSessionId() ? '此任务尚未留下执行步骤。' : '请从右侧任务列表选择会话,查看实时执行记录。' }}</p>
</div> </div>
} }
<!-- The phase-based session logs list --> <!-- The phase-based session logs list -->
@if (!agentService.isSessionContentLoading() && (phases().length > 0 || showPlanningLoader() || startupWorkItems().length > 0)) { @if (!agentService.isSessionContentLoading() && (phases().length > 0 || showPlanningLoader() || startupWorkItems().length > 0 || awaitingGuidance().length > 0)) {
<div class="logs-list-container"> <div class="logs-list-container">
@if (startupWorkItems().length > 0) { @if (startupWorkItems().length > 0) {
<div class="phase-container startup-phase" aria-live="polite"> <div class="phase-container startup-phase" aria-live="polite">
@@ -571,7 +432,7 @@ limitations under the License.
<article class="exploration-message" [class.user]="message.role === 'user'" [class.assistant]="message.role === 'assistant'" [class.question]="pendingQuestion() === message.id" [class.system]="message.role === 'system'"> <article class="exploration-message" [class.user]="message.role === 'user'" [class.assistant]="message.role === 'assistant'" [class.question]="pendingQuestion() === message.id" [class.system]="message.role === 'system'">
<small><span class="material-symbols-outlined">{{message.role === 'user' ? 'person' : message.role === 'assistant' ? 'auto_awesome' : 'info'}}</span>{{message.role === 'user' ? '你的指导' : message.role === 'assistant' ? '探索助手' : '探索记录'}} · {{message.created_at * 1000 | date:'HH:mm:ss'}} @if(pendingQuestion() === message.id){<b>等待你回答</b>}</small> <small><span class="material-symbols-outlined">{{message.role === 'user' ? 'person' : message.role === 'assistant' ? 'auto_awesome' : 'info'}}</span>{{message.role === 'user' ? '你的指导' : message.role === 'assistant' ? '探索助手' : '探索记录'}} · {{message.created_at * 1000 | date:'HH:mm:ss'}} @if(pendingQuestion() === message.id){<b>等待你回答</b>}</small>
<div class="markdown-content" [innerHTML]="renderMarkdown(message.text, phase.id)"></div> <div class="markdown-content" [innerHTML]="renderMarkdown(message.text, phase.id)"></div>
@if(message.recommendation){<div class="recommendation">建议:{{message.recommendation}} @if(pendingQuestion() === message.id){<button (click)="useRecommendation.emit(message.recommendation)">采用建议</button>}</div>} @if(message.recommendation){<div class="recommendation">建议回复:{{message.recommendation}} @if(pendingQuestion() === message.id){<button (click)="useRecommendation.emit(message.recommendation)">填入回复</button>}</div>}
</article> </article>
} @else { } @else {
<div class="phase-container"> <div class="phase-container">
@@ -847,13 +708,13 @@ limitations under the License.
@if (getActionTargetText(item.data)) { @if (getActionTargetText(item.data)) {
<div class="action-detail-row"> <div class="action-detail-row">
<span class="detail-label">目标:</span> <span class="detail-label">目标:</span>
<span class="detail-value">"{{ getActionTargetText(item.data) }}"</span> <app-expandable-value class="detail-value" label="目标" [value]="getActionTargetText(item.data)" />
</div> </div>
} }
@if (getActionInputText(item.data)) { @if (getActionInputText(item.data)) {
<div class="action-detail-row"> <div class="action-detail-row">
<span class="detail-label">{{ getActionInputLabel(item.data) }}:</span> <span class="detail-label">{{ getActionInputLabel(item.data) }}:</span>
<span class="detail-value">"{{ getActionInputText(item.data) }}"</span> <app-expandable-value class="detail-value" label="输入" [value]="getActionInputText(item.data)" />
</div> </div>
} }
@if (getActionCoords(item.data)) { @if (getActionCoords(item.data)) {
@@ -871,13 +732,13 @@ limitations under the License.
@if (getActionTargetText(item.data)) { @if (getActionTargetText(item.data)) {
<div class="grid-item"> <div class="grid-item">
<span class="grid-label">目标</span> <span class="grid-label">目标</span>
<span class="grid-value">"{{ getActionTargetText(item.data) }}"</span> <app-expandable-value label="目标" [value]="getActionTargetText(item.data)" />
</div> </div>
} }
@if (getActionInputText(item.data)) { @if (getActionInputText(item.data)) {
<div class="grid-item"> <div class="grid-item">
<span class="grid-label">{{ getActionInputLabel(item.data) }}</span> <span class="grid-label">{{ getActionInputLabel(item.data) }}</span>
<span class="grid-value">"{{ getActionInputText(item.data) }}"</span> <app-expandable-value label="输入" [value]="getActionInputText(item.data)" />
</div> </div>
} }
@if (getActionCoords(item.data)) { @if (getActionCoords(item.data)) {
@@ -899,9 +760,9 @@ limitations under the License.
</div> </div>
} }
@for (param of getActionExtraParams(item.data); track trackParam($index, param)) { @for (param of getActionExtraParams(item.data); track trackParam($index, param)) {
<div class="grid-item"> <div class="grid-item" [class.full-width]="param.key === 'Result'">
<span class="grid-label">{{ param.key }}</span> <span class="grid-label">{{ param.key }}</span>
<span class="grid-value code-pill">{{ param.value }}</span> <app-expandable-value [label]="param.key" [value]="param.value" />
</div> </div>
} }
<!-- Fallback item if all detail fields are empty --> <!-- Fallback item if all detail fields are empty -->
@@ -1148,7 +1009,7 @@ limitations under the License.
@if (getAdbCommandLine(item.data)) { @if (getAdbCommandLine(item.data)) {
<div class="action-detail-row"> <div class="action-detail-row">
<span class="detail-label">命令:</span> <span class="detail-label">命令:</span>
<span class="detail-value command-value"><code>{{ getAdbCommandLine(item.data) }}</code></span> <app-expandable-value class="detail-value" label="命令" [value]="getAdbCommandLine(item.data)" />
</div> </div>
} }
@if (getAdbCwd(item.data)) { @if (getAdbCwd(item.data)) {
@@ -1169,13 +1030,13 @@ limitations under the License.
@if (getToolTargetText(item.data)) { @if (getToolTargetText(item.data)) {
<div class="action-detail-row"> <div class="action-detail-row">
<span class="detail-label">目标:</span> <span class="detail-label">目标:</span>
<span class="detail-value">"{{ getToolTargetText(item.data) }}"</span> <app-expandable-value class="detail-value" label="目标" [value]="getToolTargetText(item.data)" />
</div> </div>
} }
@if (getToolInputText(item.data)) { @if (getToolInputText(item.data)) {
<div class="action-detail-row"> <div class="action-detail-row">
<span class="detail-label">{{ getToolInputLabel(item.data) }}:</span> <span class="detail-label">{{ getToolInputLabel(item.data) }}:</span>
<span class="detail-value">"{{ getToolInputText(item.data) }}"</span> <app-expandable-value class="detail-value" label="输入" [value]="getToolInputText(item.data)" />
</div> </div>
} }
@if (getToolCoords(item.data)) { @if (getToolCoords(item.data)) {
@@ -1187,13 +1048,13 @@ limitations under the License.
@if (getToolAnalysisText(item.data)) { @if (getToolAnalysisText(item.data)) {
<div class="action-detail-row"> <div class="action-detail-row">
<span class="detail-label">分析:</span> <span class="detail-label">分析:</span>
<span class="detail-value">"{{ getToolAnalysisText(item.data) }}"</span> <app-expandable-value class="detail-value" label="分析" [value]="getToolAnalysisText(item.data)" />
</div> </div>
} }
@if (getToolGenericDetails(item.data)) { @if (getToolGenericDetails(item.data)) {
<div class="action-detail-row"> <div class="action-detail-row">
<span class="detail-label">详情:</span> <span class="detail-label">详情:</span>
<span class="detail-value">"{{ getToolGenericDetails(item.data) }}"</span> <app-expandable-value class="detail-value" label="详情" [value]="getToolGenericDetails(item.data)" />
</div> </div>
} }
} }
@@ -1207,7 +1068,7 @@ limitations under the License.
@if (getAdbCommandLine(item.data)) { @if (getAdbCommandLine(item.data)) {
<div class="grid-item full-width"> <div class="grid-item full-width">
<span class="grid-label">命令</span> <span class="grid-label">命令</span>
<span class="grid-value command-value"><code>{{ getAdbCommandLine(item.data) }}</code></span> <app-expandable-value label="命令" [value]="getAdbCommandLine(item.data)" />
</div> </div>
} }
@if (getAdbCwd(item.data)) { @if (getAdbCwd(item.data)) {
@@ -1227,13 +1088,13 @@ limitations under the License.
@for (param of plugin.args; track param.key) { @for (param of plugin.args; track param.key) {
<div class="grid-item"> <div class="grid-item">
<span class="grid-label">{{ param.key }}</span> <span class="grid-label">{{ param.key }}</span>
<span class="grid-value code-pill">{{ param.value }}</span> <app-expandable-value [label]="param.key" [value]="param.value" />
</div> </div>
} }
@if (plugin.result) { @if (plugin.result || plugin.rawResult || plugin.facts.length) {
<div class="grid-item full-width"> <div class="grid-item full-width">
<span class="grid-label">实际结果</span> <span class="grid-label">实际结果</span>
<span class="grid-value plugin-result">{{ plugin.result }}</span> <app-plugin-tool-result [view]="plugin" />
</div> </div>
} }
} }
@@ -1241,13 +1102,13 @@ limitations under the License.
@if (getToolTargetText(item.data)) { @if (getToolTargetText(item.data)) {
<div class="grid-item"> <div class="grid-item">
<span class="grid-label">目标</span> <span class="grid-label">目标</span>
<span class="grid-value">"{{ getToolTargetText(item.data) }}"</span> <app-expandable-value label="目标" [value]="getToolTargetText(item.data)" />
</div> </div>
} }
@if (getToolInputText(item.data)) { @if (getToolInputText(item.data)) {
<div class="grid-item"> <div class="grid-item">
<span class="grid-label">{{ getToolInputLabel(item.data) }}</span> <span class="grid-label">{{ getToolInputLabel(item.data) }}</span>
<span class="grid-value">"{{ getToolInputText(item.data) }}"</span> <app-expandable-value label="输入" [value]="getToolInputText(item.data)" />
</div> </div>
} }
@if (getToolCoords(item.data)) { @if (getToolCoords(item.data)) {
@@ -1259,19 +1120,19 @@ limitations under the License.
@if (getToolAnalysisText(item.data)) { @if (getToolAnalysisText(item.data)) {
<div class="grid-item"> <div class="grid-item">
<span class="grid-label">分析</span> <span class="grid-label">分析</span>
<span class="grid-value">"{{ getToolAnalysisText(item.data) }}"</span> <app-expandable-value label="分析" [value]="getToolAnalysisText(item.data)" />
</div> </div>
} }
@if (getToolGenericDetails(item.data)) { @if (getToolGenericDetails(item.data)) {
<div class="grid-item"> <div class="grid-item">
<span class="grid-label">详情</span> <span class="grid-label">详情</span>
<span class="grid-value">"{{ getToolGenericDetails(item.data) }}"</span> <app-expandable-value label="详情" [value]="getToolGenericDetails(item.data)" />
</div> </div>
} }
@for (param of getToolExtraParams(item.data); track trackParam($index, param)) { @for (param of getToolExtraParams(item.data); track trackParam($index, param)) {
<div class="grid-item"> <div class="grid-item">
<span class="grid-label">{{ param.key }}</span> <span class="grid-label">{{ param.key }}</span>
<span class="grid-value code-pill">{{ param.value }}</span> <app-expandable-value [label]="param.key" [value]="param.value" />
</div> </div>
} }
} }
@@ -1346,10 +1207,11 @@ limitations under the License.
} }
} }
@if (!conversationMessages().length && awaitingGuidance().length) { @if (!conversationMessages().length && awaitingGuidance().length) {
<div class="guidance-queue" aria-label="待处理用户指导"> <div class="guidance-queue" aria-label="指导接收与消费记录">
@for (item of awaitingGuidance(); track item.id) { @for (item of awaitingGuidance(); track item.id) {
<div class="guidance-queue-item"> <div class="guidance-queue-item">
<strong>用户指导 · {{ guidanceStatusLabel(item) }}</strong> <strong>用户指导 · {{ guidanceStatusLabel(item) }}</strong>
<small>接收 {{ (item.accepted_at || item.timestamp) * 1000 | date:'HH:mm:ss' }}@if (item.consumed_at) { · 执行器读取 {{ item.consumed_at * 1000 | date:'HH:mm:ss' }} }</small>
<p>{{ item.instruction }}</p> <p>{{ item.instruction }}</p>
</div> </div>
} }
File diff suppressed because it is too large. Load diff
@@ -1,9 +1,13 @@
import { TaskOutcomeComponent } from '../task-outcome/task-outcome.component';
import { ExplorationMessage, Demonstration } from '../../services/exploration.service'; import { ExplorationMessage, Demonstration } from '../../services/exploration.service';
import { mergeConversation } from '../../utils/exploration-timeline.util'; import { mergeConversation } from '../../utils/exploration-timeline.util';
import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component'; import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component';
import { TaskHistoryComponent } from '../task-history/task-history.component'; import { TaskHistoryComponent } from '../task-history/task-history.component';
import { LlmRequestStatusComponent } from '../llm-request-status/llm-request-status.component'; import { LlmRequestStatusComponent } from '../llm-request-status/llm-request-status.component';
import { TaskLifecycleComponent } from '../task-lifecycle/task-lifecycle.component';
import { ReportMarkdownComponent } from '../report-markdown/report-markdown.component'; import { ReportMarkdownComponent } from '../report-markdown/report-markdown.component';
import { PluginToolResultComponent } from '../plugin-tool-result/plugin-tool-result.component';
import { ExpandableValueComponent } from '../expandable-value/expandable-value.component';
import { AutomationPanelComponent } from '../automation-panel/automation-panel.component'; import { AutomationPanelComponent } from '../automation-panel/automation-panel.component';
import { UiLabelPipe } from '../../utils/ui-label.pipe'; import { UiLabelPipe } from '../../utils/ui-label.pipe';
/** /**
@@ -27,6 +31,7 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Subscription } from 'rxjs';
import { AgentService, GuidanceEvent, StartupProgressEvent } from '../../services/agent.service'; import { AgentService, GuidanceEvent, StartupProgressEvent } from '../../services/agent.service';
import { Session, ModelInfo, SessionUsage } from '../../core/models/session.model'; import { Session, ModelInfo, SessionUsage } from '../../core/models/session.model';
import { TestResultBadgeComponent, TEST_RESULT_LABELS } from '../test-result-badge/test-result-badge.component'; import { TestResultBadgeComponent, TEST_RESULT_LABELS } from '../test-result-badge/test-result-badge.component';
@@ -37,6 +42,7 @@ import { OverlayModule, ConnectedPosition } from '@angular/cdk/overlay';
import { StepBlock, PhaseBlock, StepEvent, ActionParam, CheckerResult, StreamResetNotice, DEFAULT_STREAM_RESET_MESSAGE } from '../../core/models/stream.model'; import { StepBlock, PhaseBlock, StepEvent, ActionParam, CheckerResult, StreamResetNotice, DEFAULT_STREAM_RESET_MESSAGE } from '../../core/models/stream.model';
import { import {
contextPercent, contextPercent,
cachePercent,
contextWindow, contextWindow,
runMode, runMode,
formatCompactTokens, formatCompactTokens,
@@ -248,7 +254,7 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote, StreamRe
@Component({ @Component({
selector: 'app-agent-stream', selector: 'app-agent-stream',
standalone: true, standalone: true,
imports: [ReportMarkdownComponent, LlmRequestStatusComponent, TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, OverlayModule, TaskHistoryComponent, AutomationPanelComponent, TestResultBadgeComponent, TestResultCardComponent, TaskBatchFilterComponent], imports: [TaskOutcomeComponent,ExpandableValueComponent, PluginToolResultComponent, ReportMarkdownComponent, LlmRequestStatusComponent, TaskLifecycleComponent, TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, OverlayModule, TaskHistoryComponent, AutomationPanelComponent, TestResultBadgeComponent, TestResultCardComponent, TaskBatchFilterComponent],
templateUrl: './agent-stream.component.html', templateUrl: './agent-stream.component.html',
styleUrl: './agent-stream.component.scss', styleUrl: './agent-stream.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
@@ -300,6 +306,9 @@ export class AgentStreamComponent implements AfterViewInit {
// usage, executor context share and the Pro tuning the run started with. // usage, executor context share and the Pro tuning the run started with.
public isRunInfoOpen = signal<boolean>(false); public isRunInfoOpen = signal<boolean>(false);
public runUsage = signal<SessionUsage | null>(null); public runUsage = signal<SessionUsage | null>(null);
public runUsageLoading = signal(false);
public runUsageError = signal(false);
public runInfoCachePercent = computed(() => cachePercent(this.runUsage()?.cached_tokens, this.runUsage()?.prompt_tokens));
private runInfoClock = signal<number>(Date.now()); private runInfoClock = signal<number>(Date.now());
public runInfoMode = computed(() => runMode(this.agentService.currentSession(), this.agentService.viewedModel())); public runInfoMode = computed(() => runMode(this.agentService.currentSession(), this.agentService.viewedModel()));
@@ -504,12 +513,13 @@ export class AgentStreamComponent implements AfterViewInit {
}); });
public guidanceStatusLabel(event: GuidanceEvent): string { public guidanceStatusLabel(event: GuidanceEvent): string {
const terminal = ['completed', 'failed', 'cancelled', 'interrupted'] const terminal = ['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped']
.includes(this.agentService.currentSession()?.status || ''); .includes(this.agentService.currentSession()?.status || '');
if (event.status === 'pending') { if (event.status === 'unapplied' || (event.status === 'pending' && terminal)) return '任务结束,未记录消费';
return terminal ? '任务结束前未进入模型' : '已接收,正在打断当前模型步骤'; if (event.status === 'unconfirmed' || (event.status === 'reading' && terminal)) return '任务结束,读取未确认';
} if (event.status === 'reading') return '执行器正在读取';
return terminal ? '模型已读取,未留下后续动作记录' : '模型已读取,等待动作记录'; if (event.status === 'pending') return '已接收,等待安全时点读取';
return terminal ? '执行器已读取,未留下后续动作记录' : '执行器已读取,等待动作记录';
} }
public phases = computed(() => { public phases = computed(() => {
@@ -606,10 +616,17 @@ export class AgentStreamComponent implements AfterViewInit {
if (untracked(() => this.runUsage())?.session_id !== sessionId) { if (untracked(() => this.runUsage())?.session_id !== sessionId) {
this.runUsage.set(null); this.runUsage.set(null);
} }
this.loadRunUsage(sessionId); let request: Subscription | null = null;
if (!running) return; const refresh = () => {
const usageInterval = setInterval(() => this.loadRunUsage(sessionId), 3000); if (request && !request.closed) return;
onCleanup(() => clearInterval(usageInterval)); request = this.loadRunUsage(sessionId);
};
refresh();
const usageInterval = running ? setInterval(refresh, 3000) : null;
onCleanup(() => {
if (usageInterval !== null) clearInterval(usageInterval);
request?.unsubscribe();
});
}); });
effect((onCleanup) => { effect((onCleanup) => {
if (!this.isRunInfoOpen() || !this.agentService.isCurrentSessionRunning()) return; if (!this.isRunInfoOpen() || !this.agentService.isCurrentSessionRunning()) return;
@@ -918,13 +935,20 @@ export class AgentStreamComponent implements AfterViewInit {
} }
} }
private loadRunUsage(sessionId: string): void { private loadRunUsage(sessionId: string): Subscription {
this.agentService.getSessionUsage(sessionId).subscribe({ this.runUsageLoading.set(true);
this.runUsageError.set(false);
return this.agentService.getSessionUsage(sessionId).subscribe({
next: (usage) => { next: (usage) => {
if (this.agentService.currentSessionId() !== sessionId) return; if (this.agentService.currentSessionId() !== sessionId) return;
this.runUsageLoading.set(false);
this.runUsage.set(usage); this.runUsage.set(usage);
}, },
error: (err) => console.error('Failed to load session usage:', err) error: () => {
if (this.agentService.currentSessionId() !== sessionId) return;
this.runUsageLoading.set(false);
this.runUsageError.set(true);
}
}); });
} }
@@ -959,6 +983,11 @@ export class AgentStreamComponent implements AfterViewInit {
return 'completed'; return 'completed';
} }
public taskPhaseLabel(session: Session): string | null {
if (['completed', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(this.getTaskStatus(session))) return null;
return ({ waiting_resources: '等待资源', paused: '已暂停', demonstrating: '人工示范中', analyzing_demo: '分析示范中' } as Record<string, string>)[session.phase || ''] || null;
}
/** /**
* Determine the device serial number for the session * Determine the device serial number for the session
*/ */
@@ -1446,6 +1475,11 @@ export class AgentStreamComponent implements AfterViewInit {
public toggleActionExpanded(cardId: string, event?: Event, defaultExpanded: boolean = false): void { public toggleActionExpanded(cardId: string, event?: Event, defaultExpanded: boolean = false): void {
if (event) { if (event) {
event.stopPropagation(); event.stopPropagation();
const selection = window.getSelection();
if (selection && !selection.isCollapsed && event.currentTarget instanceof Node
&& selection.containsNode(event.currentTarget, true)) {
return;
}
} }
const isCurrentlyExpanded = this.isActionExpanded(cardId, defaultExpanded); const isCurrentlyExpanded = this.isActionExpanded(cardId, defaultExpanded);
if (isCurrentlyExpanded) { if (isCurrentlyExpanded) {
@@ -17,9 +17,10 @@
<div class="report-heading"><app-favorite-button [sourceId]="r.id" /><strong class="report-title">{{r.name}}</strong></div> <div class="report-heading"><app-favorite-button [sourceId]="r.id" /><strong class="report-title">{{r.name}}</strong></div>
<div class="report-badges"><span [class.failed]="r.status === 'failed'">{{label(r.status)}}</span><app-test-result-badge [result]="r.test_result" [executionStatus]="r.status" /></div> <div class="report-badges"><span [class.failed]="r.status === 'failed'">{{label(r.status)}}</span><app-test-result-badge [result]="r.test_result" [executionStatus]="r.status" /></div>
@if(r.test_result; as result){<app-test-result-card [result]="result" [sessionId]="r.id" [executionStatus]="r.status" (reviewSaved)="reviewSaved($event)" />} @if(r.test_result; as result){<app-test-result-card [result]="result" [sessionId]="r.id" [executionStatus]="r.status" (reviewSaved)="reviewSaved($event)" />}
@if (r.error) { <div class="report-error" role="status"><strong>异常原因</strong><p>{{r.error}}</p></div> } @if (r.error) { <app-task-outcome [error]="r.error" [status]="r.status" /> }
</div> </div>
<div class="report-meta"><div><strong>任务目标与预期</strong><p>{{r.goal || '未记录'}}</p></div>@if(r.expected){<div><strong>预期结果</strong><p>{{r.expected}}</p></div>}</div> <div class="report-meta"><div><strong>任务目标与预期</strong><p>{{r.goal || '未记录'}}</p></div>@if(r.expected){<div><strong>预期结果</strong><p>{{r.expected}}</p></div>}</div>
<app-evidence-sources [value]="r.evidence_sources || null" />
<div class="actions"><a [href]="'/api/automation/reports/'+r.id+'/docx'">下载 Word</a><a [href]="'/api/automation/reports/'+r.id+'/xlsx'">下载 Excel</a> <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+'/screenshots'">步骤截图 ZIP</a>
<a [href]="'/api/automation/reports/'+r.id+'/video'">分段录屏 ZIP</a> <a [href]="'/api/automation/reports/'+r.id+'/video'">分段录屏 ZIP</a>
@@ -107,6 +108,19 @@
</div> </div>
} }
@if(b.report_error){<p class="error">报告生成失败:{{b.report_error}}</p>} @if(b.report_error){<p class="error">报告生成失败:{{b.report_error}}</p>}
@for(j of b.jobs; track j.session_id){<div class="job" [class.selected]="agent.currentSessionId() === j.session_id"><app-favorite-button [sourceId]="j.session_id" /><button (click)="openBatchTask(j.session_id)" [title]="j.case.goal" [attr.aria-pressed]="agent.currentSessionId() === j.session_id">{{j.case.case_id ? j.case.case_id + ' ' : ''}}{{j.case.name}}@if(j.repeat_count > 1){ · 第 {{j.iteration}} / {{j.repeat_count}} 轮}</button><span>{{label(j.status)}}</span><app-test-result-badge [result]="j.test_result" [executionStatus]="j.status" [compact]="true" />@if(j.error){<small>{{j.error}}</small>}</div>} @for(j of b.jobs; track j.session_id){
<div class="job" [class.selected]="agent.currentSessionId() === j.session_id">
<div class="job-heading">
<app-favorite-button [sourceId]="j.session_id" />
<button (click)="openBatchTask(j.session_id)" [title]="j.case.goal" [attr.aria-pressed]="agent.currentSessionId() === j.session_id">{{j.case.case_id ? j.case.case_id + ' ' : ''}}{{j.case.name}}@if(j.repeat_count > 1){ · 第 {{j.iteration}} / {{j.repeat_count}} 轮}</button>
</div>
<div class="job-statuses">
<span>{{label(j.status)}}</span>
<app-test-result-badge [result]="j.test_result" [executionStatus]="j.status" [compact]="true" />
@if(j.human_reviewed){<span class="human-review-badge" title="人工评审已有保存内容,不代表测试通过">人工已审</span>}
</div>
<app-task-outcome [error]="j.error" [result]="j.test_result" [status]="j.status" />
</div>
}
</section>} </section>}
} }
@@ -1,60 +1,69 @@
:host { display:block;min-width:0;color:#1e293b;font-size:13px;line-height:1.65; } :host { display:block;min-width:0;color:var(--app-text-control, #1e293b);font-size:13px;line-height:1.65; }
.back-to-batch{margin-bottom:12px;max-width:100%;text-align:left;overflow-wrap:anywhere} .back-to-batch{margin-bottom:12px;max-width:100%;text-align:left;overflow-wrap:anywhere}
.job.selected{background:#eff6ff;border-radius:8px;padding-left:8px;padding-right:8px}.job.selected button{color:#1d4ed8;border-color:#93c5fd;background:#fff} .job.selected{background:var(--app-primary-soft, #eff6ff);border-radius:8px;padding-left:8px;padding-right:8px}.job.selected button{color:var(--app-primary-text, #1d4ed8);border-color:var(--app-primary-border, #93c5fd);background:var(--app-surface, #fff)}
.tabs {display:flex;gap:4px;padding:4px;margin-bottom:18px;background:#f1f5f9;border:1px solid #e2e8f0;border-radius:20px; .tabs {display:flex;gap:4px;padding:4px;margin-bottom:18px;background:var(--app-surface-soft, #f1f5f9);border:1px solid var(--app-border, #e2e8f0);border-radius:20px;
button {flex:1;padding:7px 8px;border:0;border-radius:16px;color:#475569;background:transparent;white-space:nowrap; button {flex:1;padding:7px 8px;border:0;border-radius:16px;color:var(--app-text-secondary, #475569);background:transparent;white-space:nowrap;
&.selected {color:#2563eb;background:#fff;box-shadow:0 1px 4px #0f172a12;} } &.selected {color:var(--app-primary-text, #2563eb);background:var(--app-surface, #fff);box-shadow:0 1px 4px #0f172a12;} }
} }
section {padding:16px;margin-bottom:14px;border:1px solid #e2e8f0;border-radius:12px;background:#fff;&:last-child{margin-bottom:0;}} section {padding:16px;margin-bottom:14px;border:1px solid var(--app-border, #e2e8f0);border-radius:12px;background:var(--app-surface, #fff);&:last-child{margin-bottom:0;}}
h3 {color:#0f172a;font-size:14px;font-weight:600;margin:0 0 8px;} h3 {color:var(--app-text-strong, #0f172a);font-size:14px;font-weight:600;margin:0 0 8px;}
p {margin:8px 0 12px;color:#475569;overflow-wrap:anywhere;}strong{font-weight:600;}.muted,small{color:#64748b;font-size:12px;} p {margin:8px 0 12px;color:var(--app-text-secondary, #475569);overflow-wrap:anywhere;}strong{font-weight:600;}.muted,small{color:var(--app-text-muted, #64748b);font-size:12px;}
.error,.notice{padding:10px 12px;border-radius:8px;margin:0 0 14px;}.error{color:#b91c1c;background:#fff1f2;border:1px solid #fecdd3;}.notice{color:#047857;background:#ecfdf5;border:1px solid #a7f3d0;} .error,.notice{padding:10px 12px;border-radius:8px;margin:0 0 14px;}.error{color:var(--app-error-text, #b91c1c);background:var(--app-error-soft, #fff1f2);border:1px solid var(--app-error-border, #fecdd3);}.notice{color:var(--app-success-text, #047857);background:var(--app-success-soft, #ecfdf5);border:1px solid var(--app-success-border, #a7f3d0);}
button,a,.upload {display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:34px;border:1px solid #cbd5e1;border-radius:8px;padding:7px 12px;background:#fff;color:#334155;text-decoration:none;cursor:pointer;font:inherit;font-weight:500;line-height:1.5;transition:background .15s,border-color .15s; button,a,.upload {display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:34px;border:1px solid var(--app-border-control, #cbd5e1);border-radius:8px;padding:7px 12px;background:var(--app-surface, #fff);color:var(--app-text-control, #334155);text-decoration:none;cursor:pointer;font:inherit;font-weight:500;line-height:1.5;transition:background .15s,border-color .15s;
&:hover{background:#f8fafc;border-color:#94a3b8;}&:focus-visible{outline:2px solid #3b82f6;outline-offset:2px;}&:disabled{color:#94a3b8;background:#f8fafc;border-color:#e2e8f0;cursor:not-allowed;}} &:hover{background:var(--app-surface-soft, #f8fafc);border-color:var(--app-border-control, #94a3b8);}&:focus-visible{outline:2px solid #3b82f6;outline-offset:2px;}&:disabled{color:var(--app-text-faint, #94a3b8);background:var(--app-surface-soft, #f8fafc);border-color:var(--app-border, #e2e8f0);cursor:not-allowed;}}
a{color:#2563eb;border-color:#bfdbfe;background:#eff6ff;}button.primary{background:#2563eb;color:white;border-color:#2563eb;width:100%;margin-top:10px;}button.primary:hover{background:#1d4ed8;}button.primary:disabled{background:#e2e8f0;color:#64748b;border-color:#e2e8f0;} a{color:var(--app-primary-text, #2563eb);border-color:var(--app-primary-border, #bfdbfe);background:var(--app-primary-soft, #eff6ff);}button.primary{background:#2563eb;color:white;border-color:#2563eb;width:100%;margin-top:10px;}button.primary:hover{background:#1d4ed8;}button.primary:disabled{background:var(--app-surface-raised, #e2e8f0);color:var(--app-text-muted, #64748b);border-color:var(--app-border, #e2e8f0);}
.actions,.fields,.format-options{display:flex;gap:8px;flex-wrap:wrap;align-items:center;}.actions{margin:12px 0 4px;}.fields>*{flex:1;min-width:130px;}.format-options{margin:12px 0;}.format-options label{flex:1;min-width:120px;margin:0;padding:10px 12px;border:1px solid #e2e8f0;border-radius:8px;background:#f8fafc;} .actions,.fields,.format-options{display:flex;gap:8px;flex-wrap:wrap;align-items:center;}.actions{margin:12px 0 4px;}.fields>*{flex:1;min-width:130px;}.format-options{margin:12px 0;}.format-options label{flex:1;min-width:120px;margin:0;padding:10px 12px;border:1px solid var(--app-border, #e2e8f0);border-radius:8px;background:var(--app-surface-soft, #f8fafc);}
label{display:block;margin:12px 0;color:#334155;font-weight:500;} label{display:block;margin:12px 0;color:var(--app-text-control, #334155);font-weight:500;}
input:not([type=checkbox]):not([type=file]),select,textarea{box-sizing:border-box;display:block;width:100%;margin-top:6px;border:1px solid #cbd5e1;border-radius:8px;padding:9px 10px;background:#fff;color:#1e293b;font:inherit;font-weight:400;&:focus{outline:2px solid #bfdbfe;border-color:#3b82f6;}} input:not([type=checkbox]):not([type=file]),select,textarea{box-sizing:border-box;display:block;width:100%;margin-top:6px;border:1px solid var(--app-border-control, #cbd5e1);border-radius:8px;padding:9px 10px;background:var(--app-surface, #fff);color:var(--app-text-control, #1e293b);font:inherit;font-weight:400;&:focus{outline:2px solid var(--app-primary-border, #bfdbfe);border-color:#3b82f6;}}
input[type=checkbox]{width:15px;height:15px;accent-color:#2563eb;vertical-align:-3px;margin-right:6px;} input[type=checkbox]{width:15px;height:15px;accent-color:var(--app-primary-text, #2563eb);vertical-align:-3px;margin-right:6px;}
.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;} .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;} .device{background:var(--app-surface-soft, #f8fafc);padding:12px 16px;}.device label{margin:0;}
.selection-toolbar{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:12px 0;color:#475569;} .selection-toolbar{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:12px 0;color:var(--app-text-secondary, #475569);}
.selection-column{flex:0 0 18px;text-align:center;}.selection-column input{margin:0;} .selection-column{flex:0 0 18px;text-align:center;}.selection-column input{margin:0;}
.case-preview-list{max-height:320px;overflow:auto;border:1px solid #e2e8f0;border-radius:8px;margin-bottom:14px;} .case-preview-list{max-height:320px;overflow:auto;border:1px solid var(--app-border, #e2e8f0);border-radius:8px;margin-bottom:14px;}
.case-preview{display:flex;align-items:flex-start;gap:10px;padding:12px;border-bottom:1px solid #e2e8f0;&:last-child{border-bottom:0;}&.unselected{background:#f8fafc;color:#64748b;}} .case-preview{display:flex;align-items:flex-start;gap:10px;padding:12px;border-bottom:1px solid var(--app-border, #e2e8f0);&:last-child{border-bottom:0;}&.unselected{background:var(--app-surface-soft, #f8fafc);color:var(--app-text-muted, #64748b);}}
.case-preview-content{min-width:0;flex:1;strong{display:block;overflow-wrap:anywhere;}small{display:block;}details{margin:5px 0 0;}summary{padding:0;font-size:12px;color:#2563eb;}pre{max-height:160px;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;background:#f8fafc;border-radius:6px;padding:8px;font-size:11px;}} .case-preview-content{min-width:0;flex:1;strong{display:block;overflow-wrap:anywhere;}small{display:block;}details{margin:5px 0 0;}summary{padding:0;font-size:12px;color:var(--app-primary-text, #2563eb);}pre{max-height:160px;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;background:var(--app-surface-soft, #f8fafc);border-radius:6px;padding:8px;font-size:11px;}}
.case-goal{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;white-space:pre-wrap;margin:5px 0;font-size:12px;} .case-goal{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;white-space:pre-wrap;margin:5px 0;font-size:12px;}
.execution-summary{padding:9px 12px;border-radius:8px;background:#eff6ff;color:#1d4ed8;font-weight:500;} .execution-summary{padding:9px 12px;border-radius:8px;background:var(--app-primary-soft, #eff6ff);color:var(--app-primary-text, #1d4ed8);font-weight:500;}
.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;} .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:var(--app-text-muted, #64748b);font-size:12px;font-weight:400;}.record.selected{border-color:var(--app-primary-border, #93c5fd);background:var(--app-primary-soft, #eff6ff);}.record.selected strong{color:var(--app-primary-text, #1d4ed8);}
.batch-record{position:relative;display:flex;gap:8px;align-items:stretch;margin-top:8px;}.batch-record>app-favorite-button{position:absolute;left:9px;top:9px;z-index:1}.batch-record .record{padding-left:44px;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;} .batch-record{position:relative;display:flex;gap:8px;align-items:stretch;margin-top:8px;}.batch-record>app-favorite-button{position:absolute;left:9px;top:9px;z-index:1}.batch-record .record{padding-left:44px;flex:1;min-width:0;width:auto;margin:0;}.batch-record .delete-batch{flex:0 0 40px;min-width:40px;padding:0;color:var(--app-error-text, #b91c1c);}.batch-record .delete-batch:hover:not(:disabled){background:var(--app-error-soft, #fff1f2);border-color:var(--app-error-border, #fda4af);}.batch-record .delete-batch .material-symbols-outlined{font-size:19px;}
.archive-progress{margin:12px 0;padding:11px 13px;border:1px solid #bfdbfe;border-radius:10px;background:#f8fbff;color:#1e40af;} .archive-progress{margin:12px 0;padding:11px 13px;border:1px solid var(--app-primary-border, #bfdbfe);border-radius:10px;background:var(--app-primary-soft, #f8fbff);color:var(--app-primary-text, #1e40af);}
.archive-progress-heading{display:flex;align-items:center;gap:8px;} .archive-progress-heading{display:flex;align-items:center;gap:8px;}
.archive-progress p{margin:7px 0 0;font-size:12px;color:#475569;} .archive-progress p{margin:7px 0 0;font-size:12px;color:var(--app-text-secondary, #475569);}
.archive-progress.archive-failed{border-color:#fecdd3;background:#fff1f2;color:#be123c;} .archive-progress.archive-failed{border-color:var(--app-error-border, #fecdd3);background:var(--app-error-soft, #fff1f2);color:var(--app-error-text, #be123c);}
.archive-track{height:7px;margin-top:10px;border-radius:999px;background:#dbeafe;overflow:hidden;} .archive-track{height:7px;margin-top:10px;border-radius:999px;background:var(--app-primary-soft, #dbeafe);overflow:hidden;}
.archive-track span{display:block;height:100%;border-radius:inherit;background:#3b82f6;transition:width .3s ease;} .archive-track span{display:block;height:100%;border-radius:inherit;background:#3b82f6;transition:width .3s ease;}
.archive-spinner{width:13px;height:13px;border:2px solid #bfdbfe;border-top-color:#2563eb;border-radius:50%;animation:archive-spin .8s linear infinite;} .archive-spinner{width:13px;height:13px;border:2px solid var(--app-primary-border, #bfdbfe);border-top-color:#2563eb;border-radius:50%;animation:archive-spin .8s linear infinite;}
@keyframes archive-spin{to{transform:rotate(360deg);}} @keyframes archive-spin{to{transform:rotate(360deg);}}
.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;} .code{font-family:Consolas,monospace;font-size:12px;line-height:1.6;resize:vertical;background:var(--app-surface-soft, #f8fafc);}.table-wrap{max-height:260px;overflow:auto;border:1px solid var(--app-border, #e2e8f0);border-radius:8px;margin-bottom:14px;}table{width:100%;border-collapse:collapse;}th,td{padding:9px 10px;border-bottom:1px solid var(--app-border, #e2e8f0);text-align:left;overflow-wrap:anywhere;}th{background:var(--app-surface-soft, #f8fafc);color:var(--app-text-secondary, #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;} .preview{white-space:pre-wrap;max-height:400px;overflow:auto;font:inherit;color:var(--app-text-control, #334155);background:var(--app-surface-soft, #f8fafc);border-radius:8px;padding:12px;border:1px solid var(--app-border, #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;} .job{display:flex;flex-direction:column;gap:8px;padding:12px 0;border-bottom:1px solid var(--app-border, #f1f5f9);}
.task-guidance{margin-top:16px;padding:14px;border:1px solid #bfdbfe;border-radius:10px;background:#f8fbff;} .job:last-child{border-bottom:0;}
.job-heading{position:relative;min-width:0;}
.job-heading>app-favorite-button{position:absolute;left:6px;top:4px;z-index:1;}
.job-heading>button{box-sizing:border-box;width:100%;min-width:0;padding-left:40px;justify-content:flex-start;text-align:left;overflow-wrap:anywhere;}
.job-statuses{display:flex;flex-wrap:wrap;align-items:center;gap:8px;}
.job-statuses>span{display:inline-flex;align-items:center;box-sizing:border-box;height:20px;padding:0 6px;border:1px solid transparent;border-radius:5px;font-size:11px;line-height:14px;background:var(--app-surface-soft, #f1f5f9);color:var(--app-text-secondary, #475569);white-space:nowrap;}
.job small{overflow-wrap:anywhere;}
details{margin:14px 0;}summary{color:var(--app-text-secondary, #475569);cursor:pointer;padding:8px 0;}
.task-guidance{margin-top:16px;padding:14px;border:1px solid var(--app-primary-border, #bfdbfe);border-radius:10px;background:var(--app-primary-soft, #f8fbff);}
.task-guidance label{margin:8px 0 4px;} .task-guidance label{margin:8px 0 4px;}
.report-overview{padding:14px;border:1px solid #dbeafe;border-radius:10px;background:#f8fbff;} .report-overview{padding:14px;border:1px solid var(--app-primary-border, #dbeafe);border-radius:10px;background:var(--app-primary-soft, #f8fbff);}
.report-heading{display:flex;align-items:flex-start;gap:8px;}.report-heading .report-title{flex:1;min-width:0;} .report-heading{display:flex;align-items:flex-start;gap:8px;}.report-heading .report-title{flex:1;min-width:0;}
.report-title{display:block;font-size:14px;line-height:1.5;color:#0f172a;overflow-wrap:anywhere;} .report-title{display:block;font-size:14px;line-height:1.5;color:var(--app-text-strong, #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-badges{display:flex;gap:7px;flex-wrap:wrap;margin-top:9px;span{padding:3px 8px;border:1px solid var(--app-primary-border, #bfdbfe);border-radius:20px;background:var(--app-primary-soft, #eff6ff);color:var(--app-primary-text, #1e40af);font-size:12px;&.failed{background:var(--app-error-soft, #fff1f2);border-color:var(--app-error-border, #fecdd3);color:var(--app-error-text, #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 var(--app-border, #e2e8f0);border-radius:8px;background:var(--app-surface, #fff);}strong{font-size:12px;color:var(--app-text-secondary, #475569);}p{margin:5px 0 0;white-space:pre-wrap;color:var(--app-text-control, #1e293b);}}
.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 var(--app-border, #e2e8f0);border-radius:10px;background:var(--app-surface, #fff);h3{margin-bottom:12px;}}
.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:var(--app-text-control, #1e293b);line-height:1.75;}
.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 { :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;} h1,h2,h3,h4{margin:18px 0 8px;color:var(--app-text-strong, #0f172a);line-height:1.4;}h1{font-size:18px;}h2{font-size:16px;}h3,h4{font-size:14px;}
> div{margin:3px 0;} > div{margin:3px 0;}
ul,ol{margin:8px 0;padding-left:24px;}li{margin:4px 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;} blockquote{margin:10px 0;padding:8px 12px;border-left:3px solid var(--app-primary-border, #93c5fd);background:var(--app-surface-soft, #f8fafc);}
pre{max-width:100%;overflow:auto;padding:10px 12px;background:#f1f5f9;border-radius:6px;white-space:pre-wrap;} pre{max-width:100%;overflow:auto;padding:10px 12px;background:var(--app-surface-soft, #f1f5f9);border-radius:6px;white-space:pre-wrap;}
code{font-family:Consolas,monospace;background:#f1f5f9;padding:1px 3px;border-radius:3px;} code{font-family:Consolas,monospace;background:var(--app-surface-soft, #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;} table{width:100%;border-collapse:collapse;font-size:12px;}th,td{padding:7px 9px;border:1px solid var(--app-border, #e2e8f0);vertical-align:top;}th{background:var(--app-primary-soft, #eff6ff);color:var(--app-primary-text, #1e3a8a);}
.md-para-gap{height:8px;} .md-para-gap{height:8px;}
} }
.job .human-review-badge{white-space:nowrap;border:1px solid var(--app-primary-border, #bfdbfe);background:var(--app-primary-soft, #eff6ff);color:var(--app-primary-text, #1d4ed8);font-weight:500;}
@@ -90,6 +90,30 @@ describe('AutomationPanelComponent batch deletion', () => {
expect(http.post).not.toHaveBeenCalled(); expect(http.post).not.toHaveBeenCalled();
}); });
it('shows only saved human review badges and removes them on a batch refresh', async () => {
const fixture = TestBed.createComponent(AutomationPanelComponent);
const component = fixture.componentInstance;
await new Promise<void>(resolve => setTimeout(resolve, 0));
component.tab.set('batches');
const batch = { id: 'review-batch', name: '评审批次', status: 'completed', jobs: [
{ session_id: 'reviewed', status: 'completed', human_reviewed: true, case: { name: '已评用例' } },
{ session_id: 'empty', status: 'completed', human_reviewed: false, case: { name: '空白用例' } },
{ session_id: 'legacy', status: 'completed', case: { name: '旧版任务' } }
] };
component.batch.set(batch);
fixture.detectChanges();
const rows = fixture.nativeElement.querySelectorAll('.job');
expect(rows[0].querySelector('.human-review-badge').textContent).toBe('人工已审');
expect(rows[1].querySelector('.human-review-badge')).toBeNull();
expect(rows[2].querySelector('.human-review-badge')).toBeNull();
expect(fixture.nativeElement.textContent).not.toContain('人工未审');
(http.get.and as any).callFake((url: string) => of(url.endsWith('/review-batch')
? { ...batch, jobs: batch.jobs.map(job => ({ ...job, human_reviewed: false })) } : []));
await component.refresh();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.human-review-badge')).toBeNull();
});
it('confirms deletion and removes the batch from the visible list', async () => { it('confirms deletion and removes the batch from the visible list', async () => {
const fixture = TestBed.createComponent(AutomationPanelComponent); const fixture = TestBed.createComponent(AutomationPanelComponent);
fixture.componentInstance.tab.set('batches'); fixture.componentInstance.tab.set('batches');
@@ -129,7 +153,7 @@ describe('AutomationPanelComponent batch deletion', () => {
{session_id:'task-b',status:'completed',case:{name:'用例 B',goal:'观察主页'}} {session_id:'task-b',status:'completed',case:{name:'用例 B',goal:'观察主页'}}
]}); ]});
fixture.detectChanges(); fixture.detectChanges();
const jobs: HTMLButtonElement[] = [...fixture.nativeElement.querySelectorAll('.job > button')]; const jobs: HTMLButtonElement[] = [...fixture.nativeElement.querySelectorAll('.job-heading > button')];
jobs[0].click(); jobs[1].click(); jobs[0].click(); jobs[1].click();
expect(agent.selectSession.calls.allArgs()).toEqual([['task-a',true],['task-b',true]]); expect(agent.selectSession.calls.allArgs()).toEqual([['task-a',true],['task-b',true]]);
expect(component.tab()).toBe('batches'); expect(component.tab()).toBe('batches');
@@ -152,7 +176,12 @@ describe('AutomationPanelComponent batch deletion', () => {
}); });
fixture.detectChanges(); fixture.detectChanges();
const root = fixture.nativeElement as HTMLElement; const root = fixture.nativeElement as HTMLElement;
expect(root.querySelector('.report-error')?.textContent).toContain('连续 3 次'); const explanation = root.querySelector<HTMLDetailsElement>('app-task-outcome details')!;
expect(explanation.open).toBeFalse();
expect(explanation.querySelector('summary')?.textContent).toContain('连续 3 次');
explanation.querySelector('summary')!.click();
expect(explanation.open).toBeTrue();
expect(explanation.querySelector('.body')?.textContent).toContain('连续 3 次');
expect(root.querySelector('.report-body h2')?.textContent).toBe('结果'); 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')?.textContent).toContain('第二行'); expect(root.querySelector('.report-body')?.textContent).toContain('第二行');
@@ -1,3 +1,4 @@
import { TaskOutcomeComponent } from '../task-outcome/task-outcome.component';
import { Component, DestroyRef, computed, effect, inject, output, signal } from '@angular/core'; import { Component, DestroyRef, computed, effect, inject, output, signal } from '@angular/core';
import { FavoriteButtonComponent } from '../favorite-button/favorite-button.component'; import { FavoriteButtonComponent } from '../favorite-button/favorite-button.component';
import { ReportMarkdownComponent } from '../report-markdown/report-markdown.component'; import { ReportMarkdownComponent } from '../report-markdown/report-markdown.component';
@@ -11,6 +12,7 @@ import { TestResultBadgeComponent } from '../test-result-badge/test-result-badge
import { TestResultCardComponent } from '../test-result-badge/test-result-card.component'; import { TestResultCardComponent } from '../test-result-badge/test-result-card.component';
import { CaseReviewResponse } from '../../core/models/session.model'; import { CaseReviewResponse } from '../../core/models/session.model';
import { TaskPluginsComponent, TaskPluginOptions } from '../task-plugins/task-plugins.component'; import { TaskPluginsComponent, TaskPluginOptions } from '../task-plugins/task-plugins.component';
import { EvidenceSourcesComponent } from '../evidence-sources/evidence-sources.component';
interface ArchiveProgress { interface ArchiveProgress {
status: 'idle' | 'preparing' | 'ready' | 'failed'; status: 'idle' | 'preparing' | 'ready' | 'failed';
@@ -23,7 +25,7 @@ interface ArchiveProgress {
} }
@Component({ @Component({
selector: 'app-automation-panel', standalone: true, imports: [FavoriteButtonComponent, ReportMarkdownComponent, CommonModule, FormsModule, TestResultBadgeComponent, TestResultCardComponent, TaskPluginsComponent], selector: 'app-automation-panel', standalone: true, imports: [TaskOutcomeComponent,FavoriteButtonComponent, ReportMarkdownComponent, CommonModule, FormsModule, TestResultBadgeComponent, TestResultCardComponent, TaskPluginsComponent, EvidenceSourcesComponent],
templateUrl: './automation-panel.component.html', styleUrl: './automation-panel.component.scss' templateUrl: './automation-panel.component.html', styleUrl: './automation-panel.component.scss'
}) })
export class AutomationPanelComponent { export class AutomationPanelComponent {
@@ -15,9 +15,9 @@
*/ */
$border-color: rgba(0, 0, 0, 0.08); $border-color: rgba(0, 0, 0, 0.08);
$text-primary: #1f1f1f; $text-primary: var(--app-text-strong, #1f1f1f);
$text-secondary: #71717a; $text-secondary: var(--app-text-muted, #71717a);
$text-muted: #a1a1aa; $text-muted: var(--app-text-faint, #a1a1aa);
:host { :host {
display: flex; display: flex;
@@ -32,7 +32,7 @@ $text-muted: #a1a1aa;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
background: #ffffff; background: var(--app-surface, #ffffff);
box-sizing: border-box; box-sizing: border-box;
.chat-header { .chat-header {
@@ -43,23 +43,23 @@ $text-muted: #a1a1aa;
height: 60px; height: 60px;
padding: 0 20px; padding: 0 20px;
border-bottom: 1px solid $border-color; border-bottom: 1px solid $border-color;
background: #ffffff; background: var(--app-surface, #ffffff);
box-sizing: border-box; box-sizing: border-box;
.tab-selector-bar { .tab-selector-bar {
display: flex; display: flex;
height: 36px; height: 36px;
box-sizing: border-box; box-sizing: border-box;
background: rgba(241, 245, 249, 0.72); background: rgb(var(--app-surface-soft-rgb, 241 245 249) / 0.72);
backdrop-filter: blur(16px) saturate(180%); backdrop-filter: blur(16px) saturate(180%);
-webkit-backdrop-filter: blur(16px) saturate(180%); -webkit-backdrop-filter: blur(16px) saturate(180%);
border: 1px solid rgba(226, 232, 240, 0.85); border: 1px solid rgb(var(--app-border-rgb, 226 232 240) / 0.85);
border-radius: 18px; border-radius: 18px;
padding: 3px; padding: 3px;
gap: 3px; gap: 3px;
align-items: center; align-items: center;
flex-shrink: 0; flex-shrink: 0;
box-shadow: 0 2px 8px -1px rgba(15, 23, 42, 0.04), inset 0 1px 1px rgba(255, 255, 255, 0.8); box-shadow: 0 2px 8px -1px rgba(15, 23, 42, 0.04), inset 0 1px 1px rgb(var(--app-highlight-rgb, 255 255 255) / 0.8);
.tab-selector-btn { .tab-selector-btn {
display: inline-flex; display: inline-flex;
@@ -74,7 +74,7 @@ $text-muted: #a1a1aa;
border-radius: 15px; border-radius: 15px;
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: #64748b; color: var(--app-text-muted, #64748b);
cursor: pointer; cursor: pointer;
line-height: 1; line-height: 1;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
@@ -90,13 +90,13 @@ $text-muted: #a1a1aa;
} }
&:hover:not(.active) { &:hover:not(.active) {
color: #0f172a; color: var(--app-text-strong, #0f172a);
background: rgba(255, 255, 255, 0.7); background: rgb(var(--app-surface-rgb, 255 255 255) / 0.7);
} }
&.active { &.active {
background: #ffffff; background: var(--app-surface, #ffffff);
color: #2563eb; color: var(--app-primary-text, #2563eb);
font-weight: 700; font-weight: 700;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.02); box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.02);
} }
@@ -116,7 +116,7 @@ $text-muted: #a1a1aa;
background: linear-gradient(135deg, rgba(37, 99, 235, 0.92), rgba(26, 115, 232, 0.85)); background: linear-gradient(135deg, rgba(37, 99, 235, 0.92), rgba(26, 115, 232, 0.85));
backdrop-filter: blur(14px) saturate(180%); backdrop-filter: blur(14px) saturate(180%);
-webkit-backdrop-filter: blur(14px) saturate(180%); -webkit-backdrop-filter: blur(14px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.35); border: 1px solid rgb(var(--app-border-rgb, 255 255 255) / 0.35);
border-radius: 16px; border-radius: 16px;
font-size: 13.5px; font-size: 13.5px;
font-weight: 600; font-weight: 600;
@@ -127,7 +127,7 @@ $text-muted: #a1a1aa;
gap: 5px; gap: 5px;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
margin-right: 8px; margin-right: 8px;
box-shadow: 0 4px 14px rgba(37, 99, 235, 0.3), inset 0 1px 1px rgba(255, 255, 255, 0.45); box-shadow: 0 4px 14px rgba(37, 99, 235, 0.3), inset 0 1px 1px rgb(var(--app-highlight-rgb, 255 255 255) / 0.45);
.material-symbols-outlined { .material-symbols-outlined {
font-size: 16px; font-size: 16px;
@@ -136,8 +136,8 @@ $text-muted: #a1a1aa;
&:hover { &:hover {
background: linear-gradient(135deg, rgba(37, 99, 235, 1), rgba(26, 115, 232, 0.95)); background: linear-gradient(135deg, rgba(37, 99, 235, 1), rgba(26, 115, 232, 0.95));
border-color: rgba(255, 255, 255, 0.6); border-color: rgb(var(--app-border-rgb, 255 255 255) / 0.6);
box-shadow: 0 6px 18px rgba(37, 99, 235, 0.42), inset 0 1px 1px rgba(255, 255, 255, 0.6); box-shadow: 0 6px 18px rgba(37, 99, 235, 0.42), inset 0 1px 1px rgb(var(--app-highlight-rgb, 255 255 255) / 0.6);
transform: translateY(-0.5px); transform: translateY(-0.5px);
} }
@@ -151,17 +151,17 @@ $text-muted: #a1a1aa;
height: 32px; height: 32px;
padding: 0; padding: 0;
box-sizing: border-box; box-sizing: border-box;
background: rgba(255, 255, 255, 0.72); background: rgb(var(--app-surface-rgb, 255 255 255) / 0.72);
backdrop-filter: blur(16px) saturate(180%); backdrop-filter: blur(16px) saturate(180%);
-webkit-backdrop-filter: blur(16px) saturate(180%); -webkit-backdrop-filter: blur(16px) saturate(180%);
border: 1px solid rgba(203, 213, 225, 0.85); border: 1px solid rgb(var(--app-border-control-rgb, 203 213 225) / 0.85);
border-radius: 50%; border-radius: 50%;
color: #64748b; color: var(--app-text-muted, #64748b);
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
box-shadow: 0 2px 6px -1px rgba(15, 23, 42, 0.04), inset 0 1px 1px rgba(255, 255, 255, 0.9); box-shadow: 0 2px 6px -1px rgba(15, 23, 42, 0.04), inset 0 1px 1px rgb(var(--app-highlight-rgb, 255 255 255) / 0.9);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
.material-symbols-outlined { .material-symbols-outlined {
@@ -170,9 +170,9 @@ $text-muted: #a1a1aa;
} }
&:hover:not(:disabled) { &:hover:not(:disabled) {
background: #fef2f2; background: var(--app-error-soft, #fef2f2);
color: #ef4444; color: var(--app-error-text, #ef4444);
border-color: #fca5a5; border-color: var(--app-error-border, #fca5a5);
box-shadow: 0 3px 10px rgba(239, 68, 68, 0.15); box-shadow: 0 3px 10px rgba(239, 68, 68, 0.15);
transform: scale(1.05); transform: scale(1.05);
} }
@@ -192,7 +192,7 @@ $text-muted: #a1a1aa;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
background: #ffffff; background: var(--app-surface, #ffffff);
&::-webkit-scrollbar { &::-webkit-scrollbar {
width: 5px; width: 5px;
@@ -205,7 +205,7 @@ $text-muted: #a1a1aa;
.queue-section-header { .queue-section-header {
font-size: 11.5px; font-size: 11.5px;
font-weight: 700; font-weight: 700;
color: #475569; color: var(--app-text-secondary, #475569);
margin: 6px 0 2px; margin: 6px 0 2px;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -221,18 +221,18 @@ $text-muted: #a1a1aa;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: #f1f5f9; background: var(--app-surface-soft, #f1f5f9);
border: 1px dashed #cbd5e1; border: 1px dashed var(--app-border-control, #cbd5e1);
border-radius: 8px; border-radius: 8px;
padding: 12px; padding: 12px;
color: #64748b; color: var(--app-text-muted, #64748b);
font-size: 12px; font-size: 12px;
text-align: center; text-align: center;
} }
.history-section { .history-section {
margin-top: 12px; margin-top: 12px;
border-top: 1px solid rgba(226, 232, 240, 0.8); border-top: 1px solid rgb(var(--app-border-rgb, 226 232 240) / 0.8);
padding-top: 12px; padding-top: 12px;
&[open] .history-summary { &[open] .history-summary {
@@ -242,7 +242,7 @@ $text-muted: #a1a1aa;
.history-summary { .history-summary {
font-size: 11.5px; font-size: 11.5px;
font-weight: 700; font-weight: 700;
color: #475569; color: var(--app-text-secondary, #475569);
cursor: pointer; cursor: pointer;
outline: none; outline: none;
user-select: none; user-select: none;
@@ -265,8 +265,8 @@ $text-muted: #a1a1aa;
} }
&:hover { &:hover {
background: #f1f5f9; background: var(--app-surface-soft, #f1f5f9);
color: #1e293b; color: var(--app-text-control, #1e293b);
} }
} }
@@ -278,8 +278,8 @@ $text-muted: #a1a1aa;
} }
.task-card { .task-card {
background: #ffffff; background: var(--app-surface, #ffffff);
border: 1px solid rgba(226, 232, 240, 0.8); border: 1px solid rgb(var(--app-border-rgb, 226 232 240) / 0.8);
border-radius: 10px; border-radius: 10px;
padding: 10px 12px; padding: 10px 12px;
display: flex; display: flex;
@@ -292,7 +292,7 @@ $text-muted: #a1a1aa;
&:hover { &:hover {
transform: translateY(-1px); transform: translateY(-1px);
border-color: rgba(203, 213, 225, 1); border-color: rgb(var(--app-border-control-rgb, 203 213 225) / 1);
box-shadow: 0 3px 8px rgba(148, 163, 184, 0.08); box-shadow: 0 3px 8px rgba(148, 163, 184, 0.08);
.task-card-header .task-card-header-actions { .task-card-header .task-card-header-actions {
@@ -314,7 +314,7 @@ $text-muted: #a1a1aa;
&.selected { &.selected {
border-color: #3b82f6; border-color: #3b82f6;
box-shadow: 0 0 0 1px #3b82f6, 0 4px 12px rgba(59, 130, 246, 0.08); box-shadow: 0 0 0 1px #3b82f6, 0 4px 12px rgba(59, 130, 246, 0.08);
background: #ffffff; background: var(--app-surface, #ffffff);
} }
&.running { &.running {
@@ -336,7 +336,7 @@ $text-muted: #a1a1aa;
min-width: 0; min-width: 0;
.task-status-group { display: inline-flex; align-items: center; gap: 6px; flex-wrap: wrap; min-width: 0; } .task-status-group { display: inline-flex; align-items: center; gap: 6px; flex-wrap: wrap; min-width: 0; }
.task-origin-badge { display: inline-flex; align-items: center; box-sizing: border-box; height: 20px; padding: 0 6px; border: 1px solid #e2e8f0; border-radius: 5px; background: #f8fafc; color: #64748b; font-size: 11px; line-height: 14px; white-space: nowrap; } .task-origin-badge { display: inline-flex; align-items: center; box-sizing: border-box; height: 20px; padding: 0 6px; border: 1px solid var(--app-border, #e2e8f0); border-radius: 5px; background: var(--app-surface-soft, #f8fafc); color: var(--app-text-muted, #64748b); font-size: 11px; line-height: 14px; white-space: nowrap; }
.task-badge { .task-badge {
flex-shrink: 0; flex-shrink: 0;
@@ -350,9 +350,9 @@ $text-muted: #a1a1aa;
padding: 0 5px; padding: 0 5px;
border-radius: 4px; border-radius: 4px;
letter-spacing: 0.05em; letter-spacing: 0.05em;
border: 1px solid #e2e8f0; border: 1px solid var(--app-border, #e2e8f0);
background: #fafafa; background: var(--app-surface-soft, #fafafa);
color: #64748b; color: var(--app-text-muted, #64748b);
&::before { &::before {
content: ''; content: '';
@@ -364,48 +364,48 @@ $text-muted: #a1a1aa;
} }
&.running { &.running {
background: #fafafa; background: var(--app-surface-soft, #fafafa);
color: #1f1f1f; color: var(--app-text-strong, #1f1f1f);
border-color: #1f1f1f; border-color: var(--app-border-control, #1f1f1f);
&::before { &::before {
background-color: #1f1f1f; background-color: #1f1f1f;
animation: pulse-black-dot-small 1.5s infinite ease-in-out; animation: pulse-black-dot-small 1.5s infinite ease-in-out;
} }
} }
&.paused { &.paused {
background: #fff7ed; background: var(--app-warning-soft, #fff7ed);
color: #c2410c; color: var(--app-warning-text, #c2410c);
border-color: #fdba74; border-color: var(--app-warning-border, #fdba74);
&::before { background-color: #f97316; } &::before { background-color: #f97316; }
} }
&.completed { &.completed {
background: #fafafa; background: var(--app-surface-soft, #fafafa);
color: #1f1f1f; color: var(--app-text-strong, #1f1f1f);
border-color: #e2e8f0; border-color: var(--app-border, #e2e8f0);
&::before { &::before {
background-color: #1f1f1f; background-color: #1f1f1f;
} }
} }
&.pending { &.pending {
background: #fafafa; background: var(--app-surface-soft, #fafafa);
color: #64748b; color: var(--app-text-muted, #64748b);
border-color: #e2e8f0; border-color: var(--app-border, #e2e8f0);
&::before { &::before {
background-color: #94a3b8; background-color: #94a3b8;
} }
} }
&.failed { &.failed {
background: #fafafa; background: var(--app-surface-soft, #fafafa);
color: #ef4444; color: var(--app-error-text, #ef4444);
border-color: #fee2e2; border-color: var(--app-error-border, #fee2e2);
&::before { &::before {
background-color: #ef4444; background-color: #ef4444;
} }
} }
&.cancelled { &.cancelled {
background: #fafafa; background: var(--app-surface-soft, #fafafa);
color: #d97706; color: var(--app-warning-text, #d97706);
border-color: #fef3c7; border-color: var(--app-warning-border, #fef3c7);
&::before { &::before {
background-color: #f59e0b; background-color: #f59e0b;
} }
@@ -433,7 +433,7 @@ $text-muted: #a1a1aa;
min-width: 0; min-width: 0;
flex-shrink: 1; flex-shrink: 1;
font-size: 11px; font-size: 11px;
color: #94a3b8; color: var(--app-text-faint, #94a3b8);
line-height: 1; line-height: 1;
cursor: default; cursor: default;
transition: color 0.15s ease, opacity 0.15s ease; transition: color 0.15s ease, opacity 0.15s ease;
@@ -443,7 +443,7 @@ $text-muted: #a1a1aa;
width: 12.5px; width: 12.5px;
height: 12.5px; height: 12.5px;
line-height: 12.5px; line-height: 12.5px;
color: #94a3b8; color: var(--app-text-faint, #94a3b8);
opacity: 0.75; opacity: 0.75;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -458,9 +458,9 @@ $text-muted: #a1a1aa;
} }
&:hover { &:hover {
color: #64748b; color: var(--app-text-muted, #64748b);
.device-icon { .device-icon {
color: #64748b; color: var(--app-text-muted, #64748b);
opacity: 1; opacity: 1;
} }
} }
@@ -477,7 +477,7 @@ $text-muted: #a1a1aa;
.task-time { .task-time {
font-size: 11.5px; font-size: 11.5px;
color: #94a3b8; color: var(--app-text-faint, #94a3b8);
white-space: nowrap; white-space: nowrap;
flex-shrink: 0; flex-shrink: 0;
line-height: 1; line-height: 1;
@@ -495,7 +495,7 @@ $text-muted: #a1a1aa;
border: none; border: none;
background: transparent; background: transparent;
border-radius: 4px; border-radius: 4px;
color: #94a3b8; color: var(--app-text-faint, #94a3b8);
cursor: pointer; cursor: pointer;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -510,8 +510,8 @@ $text-muted: #a1a1aa;
} }
&:hover:not(:disabled) { &:hover:not(:disabled) {
color: #ef4444; color: var(--app-error-text, #ef4444);
background: #fee2e2; background: var(--app-error-soft, #fee2e2);
} }
&:disabled { &:disabled {
@@ -531,7 +531,7 @@ $text-muted: #a1a1aa;
border: none; border: none;
background: transparent; background: transparent;
border-radius: 4px; border-radius: 4px;
color: #ef4444; color: var(--app-error-text, #ef4444);
cursor: pointer; cursor: pointer;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -546,8 +546,8 @@ $text-muted: #a1a1aa;
} }
&:hover:not(:disabled) { &:hover:not(:disabled) {
color: #dc2626; color: var(--app-error-text, #dc2626);
background: #fee2e2; background: var(--app-error-soft, #fee2e2);
transform: translateY(-50%) scale(1.1); transform: translateY(-50%) scale(1.1);
} }
@@ -572,7 +572,7 @@ $text-muted: #a1a1aa;
.task-goal { .task-goal {
font-size: 12.5px; font-size: 12.5px;
font-weight: 500; font-weight: 500;
color: #1e293b; color: var(--app-text-control, #1e293b);
line-height: 1.4; line-height: 1.4;
word-break: break-word; word-break: break-word;
display: -webkit-box; display: -webkit-box;
@@ -587,15 +587,15 @@ $text-muted: #a1a1aa;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
border-top: 1px solid rgba(241, 245, 249, 0.8); border-top: 1px solid rgb(var(--app-border-rgb, 241 245 249) / 0.8);
padding-top: 6px; padding-top: 6px;
font-size: 11.5px; font-size: 11.5px;
color: #94a3b8; color: var(--app-text-faint, #94a3b8);
.task-id { .task-id {
font-family: 'Fira Code', 'Roboto Mono', Consolas, monospace; font-family: 'Fira Code', 'Roboto Mono', Consolas, monospace;
font-size: 11px; font-size: 11px;
color: #94a3b8; color: var(--app-text-faint, #94a3b8);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -604,14 +604,14 @@ $text-muted: #a1a1aa;
transition: color 0.15s ease; transition: color 0.15s ease;
&:hover { &:hover {
color: #475569; color: var(--app-text-secondary, #475569);
} }
} }
.task-date { .task-date {
flex-shrink: 0; flex-shrink: 0;
font-size: 11px; font-size: 11px;
color: #94a3b8; color: var(--app-text-faint, #94a3b8);
} }
} }
} }
@@ -635,7 +635,7 @@ $text-muted: #a1a1aa;
.selector-title { .selector-title {
font-size: 11px; font-size: 11px;
font-weight: 700; font-weight: 700;
color: #94a3b8; color: var(--app-text-faint, #94a3b8);
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
@@ -649,12 +649,12 @@ $text-muted: #a1a1aa;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
padding: 6px 12px; padding: 6px 12px;
border: 1px solid #e2e8f0; border: 1px solid var(--app-border, #e2e8f0);
background: #ffffff; background: var(--app-surface, #ffffff);
border-radius: 20px; border-radius: 20px;
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;
color: #475569; color: var(--app-text-secondary, #475569);
cursor: pointer; cursor: pointer;
transition: all 0.15s ease; transition: all 0.15s ease;
@@ -663,15 +663,15 @@ $text-muted: #a1a1aa;
} }
&:hover { &:hover {
background: #f8fafc; background: var(--app-surface-soft, #f8fafc);
border-color: #cbd5e1; border-color: var(--app-border-control, #cbd5e1);
color: #1e293b; color: var(--app-text-control, #1e293b);
} }
&.active { &.active {
background: #eff6ff; background: var(--app-primary-soft, #eff6ff);
border-color: #bfdbfe; border-color: var(--app-primary-border, #bfdbfe);
color: #1d4ed8; color: var(--app-primary-text, #1d4ed8);
font-weight: 600; font-weight: 600;
} }
} }
@@ -694,8 +694,8 @@ $text-muted: #a1a1aa;
width: 52px; width: 52px;
height: 52px; height: 52px;
border-radius: 50%; border-radius: 50%;
background: #f1f5f9; background: var(--app-surface-soft, #f1f5f9);
border: 1px solid #e2e8f0; border: 1px solid var(--app-border, #e2e8f0);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -704,7 +704,7 @@ $text-muted: #a1a1aa;
.material-symbols-outlined { .material-symbols-outlined {
font-size: 26px; font-size: 26px;
color: #94a3b8; color: var(--app-text-faint, #94a3b8);
} }
} }
@@ -712,14 +712,14 @@ $text-muted: #a1a1aa;
font-family: 'Google Sans', 'Google Sans Text', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-family: 'Google Sans', 'Google Sans Text', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
color: #1e293b; color: var(--app-text-control, #1e293b);
margin: 0 0 6px 0; margin: 0 0 6px 0;
} }
.empty-notes-subtitle { .empty-notes-subtitle {
font-family: 'Google Sans', 'Google Sans Text', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-family: 'Google Sans', 'Google Sans Text', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 12.5px; font-size: 12.5px;
color: #64748b; color: var(--app-text-muted, #64748b);
line-height: 1.5; line-height: 1.5;
max-width: 240px; max-width: 240px;
margin: 0; margin: 0;
@@ -736,7 +736,7 @@ $text-muted: #a1a1aa;
.note-title { .note-title {
font-size: 14px; font-size: 14px;
font-weight: 700; font-weight: 700;
color: #1f1f1f; color: var(--app-text-strong, #1f1f1f);
margin-top: 0; margin-top: 0;
margin-bottom: 20px; margin-bottom: 20px;
font-family: 'Google Sans', 'Google Sans Text', 'Inter', sans-serif; font-family: 'Google Sans', 'Google Sans Text', 'Inter', sans-serif;
@@ -744,8 +744,8 @@ $text-muted: #a1a1aa;
.code-segment { .code-segment {
font-family: 'Fira Code', 'Roboto Mono', Consolas, monospace; font-family: 'Fira Code', 'Roboto Mono', Consolas, monospace;
background-color: #f1f3f4; background-color: var(--app-info-soft, #f1f3f4);
color: #1967d2; color: var(--app-primary-text, #1967d2);
padding: 2px 6px; padding: 2px 6px;
border-radius: 4px; border-radius: 4px;
font-size: 14px; font-size: 14px;
@@ -756,8 +756,8 @@ $text-muted: #a1a1aa;
.checked { .checked {
.code-segment { .code-segment {
color: #a1a1aa; color: var(--app-text-faint, #a1a1aa);
background-color: #f8f9fa; background-color: var(--app-surface-soft, #f8f9fa);
} }
} }
@@ -778,7 +778,7 @@ $text-muted: #a1a1aa;
opacity: 0.7; opacity: 0.7;
} }
.milestone-text { .milestone-text {
color: #a1a1aa; color: var(--app-text-faint, #a1a1aa);
} }
} }
} }
@@ -795,11 +795,11 @@ $text-muted: #a1a1aa;
font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24; font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24;
&.checked { &.checked {
color: #1a73e8; color: var(--app-primary-text, #1a73e8);
} }
&.progress { &.progress {
color: #1a73e8; color: var(--app-primary-text, #1a73e8);
} }
&.unchecked { &.unchecked {
@@ -809,7 +809,7 @@ $text-muted: #a1a1aa;
.milestone-text { .milestone-text {
font-family: 'Google Sans', 'Google Sans Text', 'Inter', sans-serif; font-family: 'Google Sans', 'Google Sans Text', 'Inter', sans-serif;
color: #1f1f1f; color: var(--app-text-strong, #1f1f1f);
font-size: 14px; font-size: 14px;
line-height: 1.8; line-height: 1.8;
font-weight: 400; font-weight: 400;
@@ -839,7 +839,7 @@ $text-muted: #a1a1aa;
align-items: flex-start; align-items: flex-start;
gap: 10px; gap: 10px;
font-family: 'Google Sans', 'Google Sans Text', 'Inter', sans-serif; font-family: 'Google Sans', 'Google Sans Text', 'Inter', sans-serif;
color: #1f1f1f; color: var(--app-text-strong, #1f1f1f);
font-size: 14px; font-size: 14px;
line-height: 1.8; line-height: 1.8;
@@ -848,7 +848,7 @@ $text-muted: #a1a1aa;
} }
&.checked { &.checked {
color: #a1a1aa; color: var(--app-text-faint, #a1a1aa);
.check-icon.checked { .check-icon.checked {
opacity: 0.7; opacity: 0.7;
@@ -862,11 +862,11 @@ $text-muted: #a1a1aa;
font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24; font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24;
&.checked { &.checked {
color: #1a73e8; color: var(--app-primary-text, #1a73e8);
} }
&.progress { &.progress {
color: #1a73e8; color: var(--app-primary-text, #1a73e8);
} }
&.unchecked { &.unchecked {
@@ -875,7 +875,7 @@ $text-muted: #a1a1aa;
} }
.bullet { .bullet {
color: #9ca3af; color: var(--app-text-faint, #9ca3af);
font-weight: bold; font-weight: bold;
user-select: none; user-select: none;
padding-left: 2px; padding-left: 2px;
@@ -907,8 +907,8 @@ $text-muted: #a1a1aa;
} }
.verification-card { .verification-card {
background: #f8fafc; background: var(--app-surface-soft, #f8fafc);
border: 1px solid #e2e8f0; border: 1px solid var(--app-border, #e2e8f0);
border-radius: 6px; border-radius: 6px;
padding: 8px 12px; padding: 8px 12px;
display: flex; display: flex;
@@ -931,10 +931,10 @@ $text-muted: #a1a1aa;
line-height: 1; line-height: 1;
letter-spacing: 0.6px; letter-spacing: 0.6px;
text-transform: uppercase; text-transform: uppercase;
color: #64748b; color: var(--app-text-muted, #64748b);
&.assert { &.assert {
color: #0284c7; color: var(--app-info-text, #0284c7);
} }
} }
} }
@@ -943,35 +943,35 @@ $text-muted: #a1a1aa;
font-family: inherit; font-family: inherit;
font-size: 13.5px; font-size: 13.5px;
line-height: 1.6; line-height: 1.6;
color: #334155; color: var(--app-text-control, #334155);
word-break: break-word; word-break: break-word;
.bold-segment { .bold-segment {
font-weight: 600; font-weight: 600;
color: #1e293b; color: var(--app-text-control, #1e293b);
} }
.code-segment { .code-segment {
font-family: 'Fira Code', 'Roboto Mono', Consolas, monospace; font-family: 'Fira Code', 'Roboto Mono', Consolas, monospace;
font-size: 12px; font-size: 12px;
background: #f1f5f9; background: var(--app-surface-soft, #f1f5f9);
color: #0f172a; color: var(--app-text-strong, #0f172a);
padding: 1.5px 6px; padding: 1.5px 6px;
border-radius: 4px; border-radius: 4px;
border: 1px solid #e2e8f0; border: 1px solid var(--app-border, #e2e8f0);
} }
} }
&.finding { &.finding {
background: #fffbeb; background: var(--app-warning-soft, #fffbeb);
border-color: #fef3c7; border-color: var(--app-warning-border, #fef3c7);
.card-header .card-tag { .card-header .card-tag {
color: #b45309; color: var(--app-warning-text, #b45309);
} }
.card-body { .card-body {
color: #92400e; color: var(--app-warning-text, #92400e);
} }
} }
} }
@@ -988,7 +988,7 @@ $text-muted: #a1a1aa;
margin-bottom: 14px; margin-bottom: 14px;
min-height: 22px; min-height: 22px;
font-family: 'Google Sans', 'Google Sans Text', 'Inter', sans-serif; font-family: 'Google Sans', 'Google Sans Text', 'Inter', sans-serif;
color: #1f1f1f; color: var(--app-text-strong, #1f1f1f);
font-size: 14px; font-size: 14px;
line-height: 1.6; line-height: 1.6;
@@ -1001,7 +1001,7 @@ $text-muted: #a1a1aa;
font-weight: 700; font-weight: 700;
margin-top: 24px; margin-top: 24px;
margin-bottom: 12px; margin-bottom: 12px;
border-bottom: 1px solid #e5e7eb; border-bottom: 1px solid var(--app-border, #e5e7eb);
padding-bottom: 6px; padding-bottom: 6px;
} }
@@ -1020,17 +1020,17 @@ $text-muted: #a1a1aa;
} }
&.checked { &.checked {
color: #a1a1aa; color: var(--app-text-faint, #a1a1aa);
.check-icon { .check-icon {
color: #1a73e8; color: var(--app-primary-text, #1a73e8);
opacity: 0.7; opacity: 0.7;
} }
} }
&.progress { &.progress {
.check-icon { .check-icon {
color: #1a73e8; color: var(--app-primary-text, #1a73e8);
} }
} }
@@ -1042,7 +1042,7 @@ $text-muted: #a1a1aa;
&.list-item { &.list-item {
.bullet { .bullet {
color: #9ca3af; color: var(--app-text-faint, #9ca3af);
font-weight: bold; font-weight: bold;
user-select: none; user-select: none;
} }
@@ -0,0 +1,27 @@
import { TestBed } from '@angular/core/testing';
import { EvidenceSourcesComponent } from './evidence-sources.component';
describe('EvidenceSourcesComponent', () => {
it('keeps supporting API data separate from the platform and retains actual record identifiers', () => {
const fixture = TestBed.createComponent(EvidenceSourcesComponent);
fixture.componentRef.setInput('value', {total_sources:1, sources_omitted:0, unattributed_step_count:2,
note:'界面结果不能单独证明后端持久状态。', sources:[{
id:'src', category:'auxiliary', kind:'api', label:'安卓业务后台', target_id:'service', plugin_id:'data',
reference_count:1, references:[{step_number:7, step_id:'actual-step', call_id:'actual-call'}]
}]});
fixture.detectChanges();
const text = fixture.nativeElement.textContent;
expect(text).toContain('辅助来源 · 接口数据');
expect(text).toContain('actual-step');
expect(text).toContain('actual-call');
expect(text).toContain('来源未完整记录的步骤:2');
expect(text).not.toContain('界面来源 · 安卓界面');
expect(fixture.nativeElement.querySelector('app-test-result-badge')).toBeNull();
});
it('does not fabricate source metadata for an older backend', () => {
const fixture = TestBed.createComponent(EvidenceSourcesComponent);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('details')).toBeNull();
});
});
@@ -0,0 +1,52 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
interface SourceReference { step_id?: string; step_number?: number; call_id?: string; source_id?: string; }
interface RecordedSource {
id: string; kind: string; label: string; target_id: string; plugin_id: string;
category: 'interface' | 'auxiliary'; references: SourceReference[]; reference_count: number;
}
interface SourceSummary {
sources: RecordedSource[]; total_sources: number; sources_omitted: number;
unattributed_step_count: number; note: string;
}
@Component({
selector: 'app-evidence-sources', standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `@if (value(); as summary) {
<details class="sources" aria-label="证据来源与验收边界">
<summary>证据来源 · {{ summary.total_sources ? summary.total_sources + ' 个' : '未记录' }}</summary>
<p>{{ summary.note }}</p>
@for (source of summary.sources; track source.id) {
<article>
<strong>{{ source.category === 'interface' ? '界面来源' : '辅助来源' }} · {{ kind(source.kind) }}</strong>
<div>{{ source.label || source.target_id }}</div>
<small>目标 {{ source.target_id }}@if(source.plugin_id) { · 插件 {{ source.plugin_id }} }</small>
<ul>
@for (ref of source.references; track $index) {
<li>步骤 {{ ref.step_number ?? '未记录' }}@if(ref.step_id) { · <code>{{ ref.step_id }}</code> }@if(ref.call_id) { · 调用 <code>{{ ref.call_id }}</code> }</li>
}
</ul>
@if(source.reference_count > source.references.length) { <small>另 {{ source.reference_count - source.references.length }} 条引用见步骤记录。</small> }
</article>
}
@if(summary.sources_omitted) { <p>另 {{ summary.sources_omitted }} 个来源见原始步骤记录。</p> }
@if(summary.unattributed_step_count) { <p>来源未完整记录的步骤:{{ summary.unattributed_step_count }};不据此推断执行平台。</p> }
</details>
}`,
styles: [`
:host { display:block; margin:10px 0; }
.sources { border:1px solid var(--app-border-control,#cbd5e1); border-radius:8px; padding:10px 12px; background:var(--app-surface,#fff); color:var(--app-text-control,#1e293b); font-size:12px; line-height:1.7; overflow-wrap:anywhere; }
summary { cursor:pointer; font-weight:600; }
article { border-top:1px solid var(--app-border-control,#cbd5e1); margin-top:8px; padding-top:8px; }
p,small { color:var(--app-text-secondary,#475569); }
ul { padding-left:20px; margin:4px 0; }
code { font-size:11px; }
`]
})
export class EvidenceSourcesComponent {
value = input<SourceSummary | null>(null);
kind(kind: string): string {
return ({android:'安卓界面', computer:'电脑界面', api:'接口数据', terminal:'终端', file:'文件', other:'其他'} as Record<string,string>)[kind] || '来源类型未记录';
}
}
@@ -0,0 +1,26 @@
import { TestBed } from '@angular/core/testing';
import { ExpandableValueComponent } from './expandable-value.component';
describe('执行字段折叠', () => {
for (const [label, value] of [['Payload', '{"x":1}'], ['输入', '完整内容'.repeat(100)], ['输出', '行\n'.repeat(6)]]) {
it(`默认折叠 ${label} 并保留完整内容`, () => {
const fixture = TestBed.createComponent(ExpandableValueComponent);
fixture.componentRef.setInput('label', label);
fixture.componentRef.setInput('value', value);
fixture.detectChanges();
const details: HTMLDetailsElement = fixture.nativeElement.querySelector('details');
expect(details.open).toBeFalse();
details.querySelector('summary')!.click();
expect(details.open).toBeTrue();
expect(details.querySelector('pre')!.textContent).toBe(value);
fixture.detectChanges();
expect(details.open).toBeTrue();
});
}
it('直接显示普通短字段', () => {
const fixture = TestBed.createComponent(ExpandableValueComponent);
fixture.componentRef.setInput('value', '点击设置'); fixture.detectChanges();
expect(fixture.nativeElement.querySelector('details')).toBeNull();
expect(fixture.nativeElement.textContent).toContain('点击设置');
});
});
@@ -0,0 +1,31 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
/** Visual folding only: the full receipt remains selectable and unchanged. */
@Component({
selector: 'app-expandable-value', standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (foldable()) {
<details>
<summary><span>{{ label() }} · {{ value().length }} 字符</span><span class="hint">展开查看</span></summary>
<pre tabindex="0" [attr.aria-label]="label() + '完整内容,可选择复制'">{{ value() }}</pre>
</details>
} @else { <span class="short-value">{{ value() }}</span> }
`,
styles: [`
:host { display:block; min-width:0; max-width:100%; }
details { border:1px solid var(--app-border, #e2e8f0); border-radius:8px; background:var(--app-surface-soft, #f8fafc); }
summary { cursor:pointer; padding:7px 10px; font-size:12px; color:var(--app-text-secondary, #475569); overflow-wrap:anywhere; }
.hint { margin-left:8px; color:var(--app-primary-text, #1a73e8); }
details[open] .hint { display:none; }
pre { margin:0; padding:10px; max-height:24rem; overflow:auto; white-space:pre-wrap; overflow-wrap:anywhere; font-size:12px; user-select:text; }
.short-value { white-space:pre-wrap; overflow-wrap:anywhere; }
summary:focus-visible, pre:focus-visible { outline:2px solid var(--app-focus, #a5b4fc); outline-offset:2px; }
`]
})
export class ExpandableValueComponent {
value = input('');
label = input('详情');
foldable = computed(() => /payload|原始|^result$/i.test(this.label())
|| this.value().length > 240 || this.value().split('\n').length > 4);
}
@@ -1,5 +1,5 @@
@if(session(); as s) { @if(session(); as s) {
<app-agent-stream [conversationMessages]="messages()" [demonstrations]="s.demonstrations" [pendingQuestion]="s.pending_question || ''" (useRecommendation)="text = $event; send()"> <app-agent-stream [conversationMessages]="messages()" [demonstrations]="s.demonstrations" [pendingQuestion]="s.pending_question || ''" (useRecommendation)="fillRecommendation($event)">
<section task-summary class="session-summary"> <section task-summary class="session-summary">
<div class="row"><strong><span class="material-symbols-outlined">explore</span> {{activeValidation() ? '用例复跑中' : label(s.status)}}</strong><small>{{s.config.model}} / {{s.config.reasoning_effort}} · 续接 {{s.continuation_count}} 次</small></div> <div class="row"><strong><span class="material-symbols-outlined">explore</span> {{activeValidation() ? '用例复跑中' : label(s.status)}}</strong><small>{{s.config.model}} / {{s.config.reasoning_effort}} · 续接 {{s.continuation_count}} 次</small></div>
<div class="exploration-goal"><app-favorite-button sourceKind="exploration" [sourceId]="s.id" label="收藏为重新探索" /><p>{{s.goal}}</p></div> <div class="exploration-goal"><app-favorite-button sourceKind="exploration" [sourceId]="s.id" label="收藏为重新探索" /><p>{{s.goal}}</p></div>
@@ -28,7 +28,7 @@
@if(error()){<p role="alert" class="feedback error">{{error()}} <button (click)="refresh()">重试读取</button></p>} @if(error()){<p role="alert" class="feedback error">{{error()}} <button (click)="refresh()">重试读取</button></p>}
@if(notice()){<p role="status" class="feedback">{{notice()}}</p>} @if(notice()){<p role="status" class="feedback">{{notice()}}</p>}
@if(s.pending_question) { @if(s.pending_question) {
<div class="question-banner" role="status" aria-live="polite"><span class="material-symbols-outlined">chat_bubble</span><div><strong>探索已暂停,等待你回答</strong><p>{{pendingQuestion()?.text || '探索助手有一个问题需要你确认,请查看上方对话。'}}</p>@if(pendingQuestion()?.recommendation; as suggestion){<button (click)="text = suggestion; send()" [disabled]="busy()">采用建议并继续</button>}</div></div> <div class="question-banner" role="status" aria-live="polite"><span class="material-symbols-outlined">chat_bubble</span><div><strong>探索已暂停,等待你回答</strong><p>{{pendingQuestion()?.text || '探索助手有一个问题需要你确认,请查看上方对话。'}}</p>@if(pendingQuestion()?.recommendation; as suggestion){<button (click)="fillRecommendation(suggestion)" [disabled]="busy()">填入建议回复</button>}</div></div>
} @else if(finalizing()) { } @else if(finalizing()) {
<div class="feedback" role="status">设备操作已结束,正在根据已验证的步骤提炼用例。完成后会在上方展示。</div> <div class="feedback" role="status">设备操作已结束,正在根据已验证的步骤提炼用例。完成后会在上方展示。</div>
} }
@@ -44,7 +44,7 @@
@if(activeValidation()){<p class="feedback">用例正在独立复跑,请进入复跑任务查看或指导。<button (click)="agent.selectSession(s.active_run_id!,true)">查看当前复跑</button></p>} @if(activeValidation()){<p class="feedback">用例正在独立复跑,请进入复跑任务查看或指导。<button (click)="agent.selectSession(s.active_run_id!,true)">查看当前复跑</button></p>}
<div class="composer-heading"><label for="exploration-message">{{s.pending_question ? '回答探索助手' : '与探索助手交流'}}</label><span class="composer-status" [class.waiting]="!!s.pending_question">{{label(s.status)}}</span></div> <div class="composer-heading"><label for="exploration-message">{{s.pending_question ? '回答探索助手' : '与探索助手交流'}}</label><span class="composer-status" [class.waiting]="!!s.pending_question">{{label(s.status)}}</span></div>
<textarea id="exploration-message" [(ngModel)]="text" (keydown)="onKeyDown($event)" rows="2" maxlength="10000" [placeholder]="s.pending_question ? '在这里回答,发送后继续探索…' : '告诉我下一步怎么做,或补充你的想法…'" [disabled]="busy() || activeValidation() || finalizing()"></textarea> <textarea #messageInput id="exploration-message" [(ngModel)]="text" (keydown)="onKeyDown($event)" rows="2" maxlength="10000" [placeholder]="s.pending_question ? '在这里回答,发送后继续探索…' : '告诉我下一步怎么做,或补充你的想法…'" [disabled]="busy() || activeValidation() || finalizing()"></textarea>
<div class="actions composer-actions"> <div class="actions composer-actions">
@if(!activeDemo() && s.active_run_id && !activeValidation() && ['running','waiting_user'].includes(s.status)){<button class="tool-button" (click)="demoOptionsOpen.set(!demoOptionsOpen())" [attr.aria-expanded]="demoOptionsOpen()"><span class="material-symbols-outlined">touch_app</span>我来操作</button>} @if(!activeDemo() && s.active_run_id && !activeValidation() && ['running','waiting_user'].includes(s.status)){<button class="tool-button" (click)="demoOptionsOpen.set(!demoOptionsOpen())" [attr.aria-expanded]="demoOptionsOpen()"><span class="material-symbols-outlined">touch_app</span>我来操作</button>}
@if(!activeValidation() && !s.pending_question && (!s.active_run_id || s.status === 'paused')){<button (click)="action(s.status === 'draft' ? 'start' : 'continue')" [disabled]="busy()">{{s.status === 'draft' ? '开始探索' : '继续探索'}}</button>} @if(!activeValidation() && !s.pending_question && (!s.active_run_id || s.status === 'paused')){<button (click)="action(s.status === 'draft' ? 'start' : 'continue')" [disabled]="busy()">{{s.status === 'draft' ? '开始探索' : '继续探索'}}</button>}
@@ -1,77 +1,78 @@
:host { display:flex; flex-direction:column; min-height:0; height:100%; overflow:hidden; color:#334155; font-size:13px; } :host { display:flex; flex-direction:column; min-height:0; height:100%; overflow:hidden; color:var(--app-text-control, #334155); font-size:13px; }
.exploration-panel { padding:18px; display:flex; flex-direction:column; gap:16px; } .exploration-panel { padding:18px; display:flex; flex-direction:column; gap:16px; }
header,.row,.actions,.manual-control { display:flex; align-items:center; gap:8px; flex-wrap:wrap; } header,.row,.actions { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
header,.row { justify-content:space-between; } header,.row { justify-content:space-between; }
header strong { font-size:15px; color:#0f172a; } header strong { font-size:15px; color:var(--app-text-strong, #0f172a); }
p { margin:8px 0; line-height:1.65; white-space:pre-wrap; overflow-wrap:anywhere; } p { margin:8px 0; line-height:1.65; white-space:pre-wrap; overflow-wrap:anywhere; }
header p,.muted,small { color:#64748b; font-size:12px; } header p,.muted,small { color:var(--app-text-muted, #64748b); font-size:12px; }
button,input,select,textarea { font:inherit; box-sizing:border-box; } button,input,select,textarea { font:inherit; box-sizing:border-box; }
button { display:inline-flex; align-items:center; justify-content:center; gap:4px; border:1px solid #dbe3ec; border-radius:8px; background:white; color:#334155; padding:6px 10px; min-height:32px; cursor:pointer; } button { display:inline-flex; align-items:center; justify-content:center; gap:4px; border:1px solid var(--app-border, #dbe3ec); border-radius:8px; background:var(--app-surface, white); color:var(--app-text-control, #334155); padding:6px 10px; min-height:32px; cursor:pointer; }
button:hover:enabled { background:#f1f5f9; border-color:#94a3b8; } button:hover:enabled { background:var(--app-surface-soft, #f1f5f9); border-color:var(--app-border-control, #94a3b8); }
button:disabled { opacity:.5; cursor:default; } button:disabled { opacity:.5; cursor:default; }
button.primary { background:#0f172a; color:white; border-color:#0f172a; } button.primary { background:#0f172a; color:white; border-color:var(--app-border-control, #0f172a); }
.material-symbols-outlined { font-size:18px; } .material-symbols-outlined { font-size:18px; }
label { display:flex; flex-direction:column; gap:6px; color:#475569; margin:8px 0; } label { display:flex; flex-direction:column; gap:6px; color:var(--app-text-secondary, #475569); margin:8px 0; }
input,select,textarea { width:100%; min-width:0; border:1px solid #cbd5e1; border-radius:8px; padding:8px 10px; background:white; color:#0f172a; } input,select,textarea { width:100%; min-width:0; border:1px solid var(--app-border-control, #cbd5e1); border-radius:8px; padding:8px 10px; background:var(--app-surface, white); color:var(--app-text-strong, #0f172a); }
textarea { resize:vertical; line-height:1.6; } textarea { resize:vertical; line-height:1.6; }
input:focus,select:focus,textarea:focus { outline:2px solid #94a3b8; outline-offset:1px; } input:focus,select:focus,textarea:focus { outline:2px solid var(--app-border-control, #94a3b8); outline-offset:1px; }
details { border:1px solid #e2e8f0; border-radius:10px; padding:10px 12px; } details { border:1px solid var(--app-border, #e2e8f0); border-radius:10px; padding:10px 12px; }
summary { cursor:pointer; font-size:12px; color:#475569; } summary { cursor:pointer; font-size:12px; color:var(--app-text-secondary, #475569); }
.session-summary,.case-editor { padding:12px; border:1px solid #e2e8f0; border-radius:12px; background:#f8fafc; } .session-summary,.case-editor { padding:12px; border:1px solid var(--app-border, #e2e8f0); border-radius:12px; background:var(--app-surface-soft, #f8fafc); }
.session-summary .actions { margin-top:10px; } .session-summary .actions { margin-top:10px; }
.conversation { display:flex; flex-direction:column; gap:12px; } .conversation { display:flex; flex-direction:column; gap:12px; }
.message { padding:10px 12px; border-radius:12px; background:#f1f5f9; border:1px solid transparent; } .message { padding:10px 12px; border-radius:12px; background:var(--app-surface-soft, #f1f5f9); border:1px solid transparent; }
.message.user { margin-left:20px; background:#ecfdf5; border-color:#d1fae5; } .message.user { margin-left:20px; background:var(--app-success-soft, #ecfdf5); border-color:var(--app-success-border, #d1fae5); }
.message.system { padding:4px 8px; background:transparent; color:#64748b; font-size:12px; } .message.system { padding:4px 8px; background:transparent; color:var(--app-text-muted, #64748b); font-size:12px; }
.recommendation { border-left:2px solid #94a3b8; padding-left:10px; line-height:1.6; } .recommendation { border-left:2px solid var(--app-border-control, #94a3b8); padding-left:10px; line-height:1.6; }
.recommendation button { margin:6px 0 0 8px; } .recommendation button { margin:6px 0 0 8px; }
.composer { border-top:1px solid #e2e8f0; padding-top:4px; } .composer { border-top:1px solid var(--app-border, #e2e8f0); padding-top:4px; }
.composer > button { margin-top:8px; } .composer > button { margin-top:8px; }
.manual-control { padding:10px 12px; border:1px solid #dbe3ec; border-radius:10px; background:#fafcfd; } .demo { border:1px solid var(--app-success-border, #bbf7d0); border-radius:12px; padding:12px; background:var(--app-success-soft, #f0fdf4); }
.manual-control label { flex:1; margin:0; } .demo.warning { background:var(--app-warning-soft, #fffbeb); border-color:#fcd34d; }
.manual-control button { align-self:flex-end; }
.demo { border:1px solid #bbf7d0; border-radius:12px; padding:12px; background:#f0fdf4; }
.demo.warning { background:#fffbeb; border-color:#fcd34d; }
.record-dot { display:inline-block; width:7px; height:7px; border-radius:50%; background:#94a3b8; margin-right:6px; } .record-dot { display:inline-block; width:7px; height:7px; border-radius:50%; background:#94a3b8; margin-right:6px; }
.record-dot.recording { background:#dc2626; animation:pulse 1.8s infinite; } .record-dot.recording { background:#dc2626; animation:pulse 1.8s infinite; }
@keyframes pulse { 50% { opacity:.4; } } @keyframes pulse { 50% { opacity:.4; } }
@media(prefers-reduced-motion:reduce) { .record-dot.recording { animation:none; } } @media(prefers-reduced-motion:reduce) { .record-dot.recording { animation:none; } }
.feedback { padding:9px 12px; background:#f0fdf4; border-radius:8px; font-size:12px; } .feedback { padding:9px 12px; background:var(--app-success-soft, #f0fdf4); border-radius:8px; font-size:12px; }
.error { color:#b91c1c; } .error { color:var(--app-error-text, #b91c1c); }
.feedback.error { background:#fef2f2; } .feedback.error { background:var(--app-error-soft, #fef2f2); }
.run,.demo-history { padding:10px 0; border-bottom:1px solid #e2e8f0; } .run,.demo-history { padding:10px 0; border-bottom:1px solid var(--app-border, #e2e8f0); }
.run-link { border:none; padding-left:0; background:transparent; text-align:left; } .run-link { border:none; padding-left:0; background:transparent; text-align:left; }
video { width:100%; max-height:260px; margin-top:8px; background:#0f172a; } video { width:100%; max-height:260px; margin-top:8px; background:#0f172a; }
a { color:#0f766e; font-size:12px; } a { color:var(--app-info-text, #0f766e); font-size:12px; }
.empty { color:#64748b; text-align:center; padding:25px 10px; } .empty { color:var(--app-text-muted, #64748b); text-align:center; padding:25px 10px; }
.empty .material-symbols-outlined { font-size:32px; } .empty .material-symbols-outlined { font-size:32px; }
.settings-fields,.case-editor { display:flex; flex-direction:column; gap:3px; } .settings-fields,.case-editor { display:flex; flex-direction:column; gap:3px; }
app-agent-stream {flex:1;min-height:0;display:block} app-agent-stream {flex:1;min-height:0;display:block}
.composer {flex:none;margin:0 20px 16px;padding:12px 16px;background:#fff;border:1px solid #dbe3ec;border-radius:16px;box-shadow:0 4px 18px #0f172a06;max-height:45%;overflow:auto} .composer {flex:none;margin:0 20px 16px;padding:12px 16px;background:var(--app-surface, #fff);border:1px solid var(--app-border, #dbe3ec);border-radius:16px;box-shadow:0 4px 18px #0f172a06;max-height:45%;overflow:auto}
.composer label {margin:0 0 6px}.composer textarea {resize:none}.composer-actions {margin-top:8px}.composer-actions small {flex:1} .composer label {margin:0 0 6px}.composer textarea {resize:none}.composer-actions {margin-top:8px}.composer-actions small {flex:1}
.session-summary {margin-bottom:18px;background:#fff}.session-summary p {margin:6px 0}.session-summary .row strong {display:flex;align-items:center;gap:6px} .session-summary {margin-bottom:18px;background:var(--app-surface, #fff)}.session-summary p {margin:6px 0}.session-summary .row strong {display:flex;align-items:center;gap:6px}
.execution-history {margin-top:8px;border:0;padding:4px 0}.case-editor {margin:16px 0;background:#fff}.case-compact {display:flex;flex-wrap:wrap;align-items:center;gap:8px}.case-compact .actions {margin-left:auto} .execution-history {margin-top:8px;border:0;padding:4px 0}.case-editor {margin:16px 0;background:var(--app-surface, #fff)}.case-compact {display:flex;flex-wrap:wrap;align-items:center;gap:8px}.case-compact .actions {margin-left:auto}
.manual-control {padding:0;margin-bottom:8px;border:0;background:transparent;justify-content:flex-end}.manual-control label {flex:none;flex-direction:row;align-items:center}.manual-control select {width:auto}.demo {padding:8px 10px;margin-bottom:8px}.demo p {margin:4px 0} .demo {padding:8px 10px;margin-bottom:8px}.demo p {margin:4px 0}
.composer {margin:0 24px 18px;padding:14px 18px;border-radius:22px;box-shadow:0 6px 24px #0f172a08;max-height:50%;box-sizing:border-box;min-width:0} .composer {margin:0 24px 18px;padding:14px 18px;border-radius:22px;box-shadow:0 6px 24px #0f172a08;max-height:50%;box-sizing:border-box;min-width:0}
.composer-heading {display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:4px} .composer-heading {display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:4px}
.composer-heading label {margin:0;font-size:12px;color:#64748b} .composer-heading label {margin:0;font-size:12px;color:var(--app-text-muted, #64748b)}
.composer-status {font-size:11px;border-radius:20px;padding:3px 8px;background:#eef2ff;color:#4f46e5} .composer .manual-control {display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap;margin:0 0 10px}
.composer-status.waiting {color:#92400e;background:#fef3c7} .composer .manual-control label {display:flex;flex-direction:row;align-items:center;gap:8px;margin:0;min-width:0}
.composer .manual-control select {width:auto;min-width:110px;height:36px;padding:6px 10px}
.composer .manual-control button {align-self:center;height:36px;white-space:nowrap}
.composer-status {font-size:11px;border-radius:20px;padding:3px 8px;background:var(--app-primary-soft, #eef2ff);color:var(--app-exploration-text, #4f46e5)}
.composer-status.waiting {color:var(--app-warning-text, #92400e);background:var(--app-warning-soft, #fef3c7)}
.composer textarea {border:0;border-radius:0;padding:10px 0;background:transparent;min-height:66px;max-height:140px;font-size:14px;box-shadow:none} .composer textarea {border:0;border-radius:0;padding:10px 0;background:transparent;min-height:66px;max-height:140px;font-size:14px;box-shadow:none}
.composer textarea:focus {outline:none} .composer textarea:focus {outline:none}
.composer:focus-within {border-color:#a5b4fc;box-shadow:0 0 0 3px #eef2ff} .composer:focus-within {border-color:var(--app-primary-border, #a5b4fc);box-shadow:0 0 0 3px #eef2ff}
.composer-actions {gap:6px;flex-wrap:wrap;border-top:1px solid #f1f5f9;padding-top:10px} .composer-actions {gap:6px;flex-wrap:wrap;border-top:1px solid var(--app-border, #f1f5f9);padding-top:10px}
.composer-actions .tool-button {border:0;background:#f8fafc;color:#64748b;border-radius:10px;padding:6px 9px} .composer-actions .tool-button {border:0;background:var(--app-surface-soft, #f8fafc);color:var(--app-text-muted, #64748b);border-radius:10px;padding:6px 9px}
.composer-actions .send-hint {flex:1;text-align:right;font-size:10px;color:#94a3b8;min-width:100px} .composer-actions .send-hint {flex:1;text-align:right;font-size:10px;color:var(--app-text-faint, #94a3b8);min-width:100px}
.send-button {min-height:36px;border-radius:12px;padding:7px 12px;background:#4f46e5!important;border-color:#4f46e5!important;gap:6px} .send-button {min-height:36px;border-radius:12px;padding:7px 12px;background:#4f46e5!important;border-color:#4f46e5!important;gap:6px}
.composer-footnote {font-size:11px;color:#94a3b8;margin:8px 0 0} .composer-footnote {font-size:11px;color:var(--app-text-faint, #94a3b8);margin:8px 0 0}
.composer.needs-answer {border-color:#f4c97a} .composer.needs-answer {border-color:var(--app-warning-border, #f4c97a)}
.question-banner {display:flex;gap:10px;background:#fffbeb;border:1px solid #fde68a;border-radius:12px;padding:12px;margin-bottom:12px;color:#92400e} .question-banner {display:flex;gap:10px;background:var(--app-warning-soft, #fffbeb);border:1px solid var(--app-warning-border, #fde68a);border-radius:12px;padding:12px;margin-bottom:12px;color:var(--app-warning-text, #92400e)}
.question-banner > .material-symbols-outlined {font-size:22px;margin-top:2px}.question-banner p {margin:4px 0;font-size:13px}.question-banner strong {font-size:13px}.question-banner button {margin-top:5px;background:#fff7d6;border-color:#f4c97a;color:#92400e} .question-banner > .material-symbols-outlined {font-size:22px;margin-top:2px}.question-banner p {margin:4px 0;font-size:13px}.question-banner strong {font-size:13px}.question-banner button {margin-top:5px;background:var(--app-warning-soft, #fff7d6);border-color:var(--app-warning-border, #f4c97a);color:var(--app-warning-text, #92400e)}
.case-editor {border-color:#c7d2fe;background:linear-gradient(135deg,#f5f7ff,#fff);padding:20px;border-radius:18px} .case-editor {border-color:var(--app-primary-border, #c7d2fe);background:linear-gradient(135deg,var(--app-primary-soft, #f5f7ff),var(--app-surface, #fff));padding:20px;border-radius:18px}
.case-title {display:flex;align-items:center;gap:8px;color:#4f46e5;font-size:14px}.case-title .material-symbols-outlined {font-size:22px} .case-title {display:flex;align-items:center;gap:8px;color:var(--app-exploration-text, #4f46e5);font-size:14px}.case-title .material-symbols-outlined {font-size:22px}
.case-compact {display:block}.case-compact h3 {font-size:18px;line-height:1.5;color:#1e293b;margin:14px 0 6px}.case-compact small {font-size:11px;font-weight:400;background:#e0e7ff;color:#4338ca;padding:3px 6px;border-radius:5px;white-space:nowrap}.case-compact .actions {margin:16px 0 0}.case-compact .primary {background:#4f46e5;border-color:#4f46e5} .case-compact {display:block}.case-compact h3 {font-size:18px;line-height:1.5;color:var(--app-text-control, #1e293b);margin:14px 0 6px}.case-compact small {font-size:11px;font-weight:400;background:var(--app-primary-soft, #e0e7ff);color:var(--app-exploration-text, #4338ca);padding:3px 6px;border-radius:5px;white-space:nowrap}.case-compact .actions {margin:16px 0 0}.case-compact .primary {background:#4f46e5;border-color:#4f46e5}
.session-summary {padding:12px 16px;border-radius:14px}.exploration-goal{display:flex;align-items:flex-start;gap:6px}.session-summary .exploration-goal p{flex:1;min-width:0;margin:4px 0}.case-compact h3{display:flex;align-items:flex-start;gap:6px}.case-compact h3>span{min-width:0;overflow-wrap:anywhere}.session-summary p {color:#64748b;font-size:12px} .session-summary {padding:12px 16px;border-radius:14px}.exploration-goal{display:flex;align-items:flex-start;gap:6px}.session-summary .exploration-goal p{flex:1;min-width:0;margin:4px 0}.case-compact h3{display:flex;align-items:flex-start;gap:6px}.case-compact h3>span{min-width:0;overflow-wrap:anywhere}.session-summary p {color:var(--app-text-muted, #64748b);font-size:12px}
@media(max-width:600px) {.composer {margin:0 12px 12px;padding:12px}.send-hint {display:none}.send-button {margin-left:auto}.question-banner {max-height:160px;overflow:auto}.case-editor {padding:14px}} @media(max-width:600px) {.composer {margin:0 12px 12px;padding:12px}.send-hint {display:none}.send-button {margin-left:auto}.question-banner {max-height:160px;overflow:auto}.case-editor {padding:14px}}
@@ -1,5 +1,6 @@
import { Component, input, output, signal } from '@angular/core'; import { Component, input, output, signal } from '@angular/core';
import { TestBed, fakeAsync, flushMicrotasks } from '@angular/core/testing'; import { TestBed, fakeAsync, flushMicrotasks } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { ExplorationPanelComponent } from './exploration-panel.component'; import { ExplorationPanelComponent } from './exploration-panel.component';
import { ExplorationService, ExplorationSession } from '../../services/exploration.service'; import { ExplorationService, ExplorationSession } from '../../services/exploration.service';
import { AgentService } from '../../services/agent.service'; import { AgentService } from '../../services/agent.service';
@@ -8,7 +9,7 @@ import { AgentStreamComponent } from '../agent-stream/agent-stream.component';
@Component({selector:'app-agent-stream',standalone:true,template:'<ng-content></ng-content>'}) class StreamStub {conversationMessages=input<any[]>([]);demonstrations=input<any[]>([]);pendingQuestion=input('');useRecommendation=output<string>();} @Component({selector:'app-agent-stream',standalone:true,template:'<ng-content></ng-content>'}) class StreamStub {conversationMessages=input<any[]>([]);demonstrations=input<any[]>([]);pendingQuestion=input('');useRecommendation=output<string>();}
describe('Exploration conversation', () => { describe('Exploration conversation', () => {
const config = {model: 'gpt-6.1-sol', reasoning_effort: 'high', timeout_seconds: 1800, demonstration_timeout_seconds: 900, demonstration_warning_seconds: 60}; const config = {model: 'gpt-6.1-terra', reasoning_effort: 'high', timeout_seconds: 1800, demonstration_timeout_seconds: 900, demonstration_warning_seconds: 60};
let current: ExplorationSession; let current: ExplorationSession;
let api: any; let api: any;
beforeEach(() => { beforeEach(() => {
@@ -45,6 +46,27 @@ describe('Exploration conversation', () => {
expect(api.act).not.toHaveBeenCalled();expect(fixture.componentInstance.text).toBe('返回主页'); expect(api.act).not.toHaveBeenCalled();expect(fixture.componentInstance.text).toBe('返回主页');
expect(fixture.nativeElement.querySelector('textarea').disabled).toBeTrue();fixture.destroy(); expect(fixture.nativeElement.querySelector('textarea').disabled).toBeTrue();fixture.destroy();
})); }));
it('fills a suggested reply for review from either entry point and only sends after submission', fakeAsync(() => {
current.status='waiting_user'; current.pending_question='q';
current.messages=[{id:'q',sequence:1,role:'assistant',kind:'question',text:'是否按示范流程继续?',recommendation:'确认,按示范流程继续探索。',created_at:1}];
const fixture=render();
fixture.nativeElement.querySelector('.question-banner button').click();
fixture.detectChanges(); flushMicrotasks();
const input: HTMLTextAreaElement = fixture.nativeElement.querySelector('#exploration-message');
expect(input.value).toBe('确认,按示范流程继续探索。');
expect(document.activeElement).toBe(input);
expect(api.act).not.toHaveBeenCalled();
const stream = fixture.debugElement.query(By.directive(StreamStub)).componentInstance as StreamStub;
stream.useRecommendation.emit(current.messages[0].recommendation!);
expect(api.act).not.toHaveBeenCalled();
fixture.componentInstance.text='确认,按示范流程继续,最后返回顶部。';
fixture.detectChanges();
fixture.nativeElement.querySelector('.send-button').click(); flushMicrotasks();
expect(api.act).toHaveBeenCalledTimes(1);
expect(api.act.calls.mostRecent().args.slice(0,3)).toEqual(['s','messages',{text:'确认,按示范流程继续,最后返回顶部。'}]);
expect(fixture.componentInstance.text).toBe('');
fixture.destroy();
}));
it('only invites manual input when the recorder is ready, and warns before its deadline', fakeAsync(() => { it('only invites manual input when the recorder is ready, and warns before its deadline', fakeAsync(() => {
current.active_demonstration_id='demo'; current.active_demonstration_id='demo';
current.demonstrations=[{id:'demo',run_id:'run',source:'android',status:'requested',warning_seconds:60,media:[]}]; current.demonstrations=[{id:'demo',run_id:'run',source:'android',status:'requested',warning_seconds:60,media:[]}];
@@ -1,4 +1,4 @@
import { Component, ChangeDetectionStrategy, DestroyRef, effect, inject, signal, untracked, input } from '@angular/core'; import { Component, ChangeDetectionStrategy, DestroyRef, ElementRef, effect, inject, signal, untracked, input, viewChild } from '@angular/core';
import { FavoriteButtonComponent } from '../favorite-button/favorite-button.component'; import { FavoriteButtonComponent } from '../favorite-button/favorite-button.component';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { AgentStreamComponent } from '../agent-stream/agent-stream.component'; import { AgentStreamComponent } from '../agent-stream/agent-stream.component';
@@ -27,6 +27,7 @@ export class ExplorationPanelComponent {
editorOpen = signal(false); editorOpen = signal(false);
rerunOpen = signal(false); rerunOpen = signal(false);
demoOptionsOpen = signal(false); demoOptionsOpen = signal(false);
private messageInput = viewChild<ElementRef<HTMLTextAreaElement>>('messageInput');
get text() { return this.api.drafts.get(this.sessionId()) || ''; } get text() { return this.api.drafts.get(this.sessionId()) || ''; }
set text(value: string) { this.api.drafts.set(this.sessionId(), value); } set text(value: string) { this.api.drafts.set(this.sessionId(), value); }
source = 'android'; source = 'android';
@@ -43,7 +44,7 @@ export class ExplorationPanelComponent {
private followsLatest = true; private followsLatest = true;
private requests = this.api.requests; private requests = this.api.requests;
private displayedId: string | null = null; private displayedId: string | null = null;
readonly models = ['gpt-6.1-sol', 'gpt-6-astra', 'gpt-6-luna', 'mimo-v2.6-pro', 'mimo-v2.6-flash']; readonly models = ['gpt-6.1-terra', 'gpt-6.1-sol', 'gpt-6-astra', 'gpt-6-luna', 'mimo-v2.6-pro', 'mimo-v2.6-flash'];
readonly efforts = ['default', 'none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']; readonly efforts = ['default', 'none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
constructor() { constructor() {
@@ -132,6 +133,11 @@ export class ExplorationPanelComponent {
const text = this.text.trim(), id = this.sessionId(); if (!text || this.activeValidation() || this.finalizing()) return; const text = this.text.trim(), id = this.sessionId(); if (!text || this.activeValidation() || this.finalizing()) return;
if (await this.action('messages', {text}) && this.api.drafts.get(id)?.trim() === text) this.api.drafts.delete(id); if (await this.action('messages', {text}) && this.api.drafts.get(id)?.trim() === text) this.api.drafts.delete(id);
} }
fillRecommendation(reply: string) {
if (this.busy() || this.activeValidation() || this.finalizing() || this.pendingQuestion()?.recommendation !== reply) return;
this.text = reply;
this.messageInput()?.nativeElement.focus();
}
demoAction(action: string) { demoAction(action: string) {
const demo = this.activeDemo(); const demo = this.activeDemo();
if (demo) void this.action(`demonstrations/${demo.id}/${action}`, action === 'extend' ? {seconds: this.session()?.config.demonstration_timeout_seconds || 900} : {}); if (demo) void this.action(`demonstrations/${demo.id}/${action}`, action === 'extend' ? {seconds: this.session()?.config.demonstration_timeout_seconds || 900} : {});
@@ -8,7 +8,7 @@ import { FavoriteKind, FavoritesService } from '../../services/favorites.service
(click)="toggle($event)"><span class="material-symbols-outlined" aria-hidden="true">{{favorite() ? 'star' : 'star_outline'}}</span></button> (click)="toggle($event)"><span class="material-symbols-outlined" aria-hidden="true">{{favorite() ? 'star' : 'star_outline'}}</span></button>
@if(error()){<span class="feedback" role="alert">{{error()}}</span>} @if(error()){<span class="feedback" role="alert">{{error()}}</span>}
@if(api.error() && !api.loaded()){<button type="button" class="retry" (click)="$event.stopPropagation(); api.load(true)" title="重试加载收藏">重试</button>}`, @if(api.error() && !api.loaded()){<button type="button" class="retry" (click)="$event.stopPropagation(); api.load(true)" title="重试加载收藏">重试</button>}`,
styles: [`:host{display:inline-flex;align-items:center;position:relative;flex-shrink:0}button{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:0;border-radius:7px;background:transparent;color:#64748b;cursor:pointer}button:hover{background:#fef3c7;color:#b45309}button.saved{color:#c47b0b}button.saved span{font-variation-settings:'FILL' 1}button:focus-visible{outline:2px solid #818cf8;outline-offset:2px}button:disabled{opacity:.45;cursor:default}.material-symbols-outlined{font-size:19px}.feedback{position:absolute;left:0;top:30px;min-width:170px;max-width:260px;padding:8px 10px;z-index:20;border:1px solid #fecaca;border-radius:8px;background:#fff7f7;color:#b91c1c;font-size:12px;line-height:1.5;box-shadow:0 4px 16px #0f172a12}.retry{font-size:10px;width:auto}`] styles: [`:host{display:inline-flex;align-items:center;position:relative;flex-shrink:0}button{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:0;border-radius:7px;background:transparent;color:var(--app-text-muted, #64748b);cursor:pointer}button:hover{background:var(--app-warning-soft, #fef3c7);color:var(--app-warning-text, #b45309)}button.saved{color:var(--app-warning-text, #c47b0b)}button.saved span{font-variation-settings:'FILL' 1}button:focus-visible{outline:2px solid var(--app-exploration-border, #818cf8);outline-offset:2px}button:disabled{opacity:.45;cursor:default}.material-symbols-outlined{font-size:19px}.feedback{position:absolute;left:0;top:30px;min-width:170px;max-width:260px;padding:8px 10px;z-index:20;border:1px solid var(--app-error-border, #fecaca);border-radius:8px;background:var(--app-error-soft, #fff7f7);color:var(--app-error-text, #b91c1c);font-size:12px;line-height:1.5;box-shadow:0 4px 16px #0f172a12}.retry{font-size:10px;width:auto}`]
}) })
export class FavoriteButtonComponent { export class FavoriteButtonComponent {
api = inject(FavoritesService); api = inject(FavoritesService);
@@ -1,58 +1,58 @@
:host { display: block; width: 100%; color: #334155; } :host { display: block; width: 100%; color: var(--app-text-control, #334155); }
.featured { margin-top: 26px; } .featured { margin-top: 26px; }
.heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; } .heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
h2 { display: flex; align-items: center; gap: 7px; margin: 0; font-size: 16px; font-weight: 650; color: #1e293b; } h2 { display: flex; align-items: center; gap: 7px; margin: 0; font-size: 16px; font-weight: 650; color: var(--app-text-control, #1e293b); }
h2 span { font-size: 20px; color: #d29a32; } h2 span { font-size: 20px; color: var(--app-warning-text, #d29a32); }
.heading p { font-size: 12px; color: #64748b; margin: 6px 0 0; } .heading p { font-size: 12px; color: var(--app-text-muted, #64748b); margin: 6px 0 0; }
.heading > input { max-width: 210px; width: 40%; border-radius: 20px; } .heading > input { max-width: 210px; width: 40%; border-radius: 20px; }
.filters { display: flex; gap: 5px; flex-wrap: wrap; margin: 17px 0; } .filters { display: flex; gap: 5px; flex-wrap: wrap; margin: 17px 0; }
.filters button { border: 0; padding: 6px 13px; border-radius: 18px; font-size: 12px; background: transparent; color: #64748b; } .filters button { border: 0; padding: 6px 13px; border-radius: 18px; font-size: 12px; background: transparent; color: var(--app-text-muted, #64748b); }
.filters button.selected { background: #e0e7ff; color: #4338ca; font-weight: 600; } .filters button.selected { background: var(--app-primary-soft, #e0e7ff); color: var(--app-exploration-text, #4338ca); font-weight: 600; }
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr)); gap: 14px; align-items: start; } .cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr)); gap: 14px; align-items: start; }
.card { padding: 16px; border: 1px solid #e2e8f0; border-radius: 16px; background: #fff; min-width: 0; transition: border-color .15s, box-shadow .15s; } .card { padding: 16px; border: 1px solid var(--app-border, #e2e8f0); border-radius: 16px; background: var(--app-surface, #fff); min-width: 0; transition: border-color .15s, box-shadow .15s; }
.card:hover { border-color: #c7d2fe; box-shadow: 0 4px 16px #33415508; } .card:hover { border-color: var(--app-primary-border, #c7d2fe); box-shadow: 0 4px 16px #33415508; }
.card.expanded { border-color: #a5b4fc; } .card.expanded { border-color: var(--app-primary-border, #a5b4fc); }
.card-overview { display: flex; flex-direction: column; min-height: 178px; } .card-overview { display: flex; flex-direction: column; min-height: 178px; }
.card-heading { display: flex; align-items: center; justify-content: space-between; gap: 6px; min-height: 22px; } .card-heading { display: flex; align-items: center; justify-content: space-between; gap: 6px; min-height: 22px; }
.mode { padding: 3px 8px; background: #f1f5f9; color: #475569; border-radius: 6px; font-size: 11px; line-height: 16px; } .mode { padding: 3px 8px; background: var(--app-surface-soft, #f1f5f9); color: var(--app-text-secondary, #475569); border-radius: 6px; font-size: 11px; line-height: 16px; }
.mode.pro { background: #ede9fe; color: #6d28d9; } .mode.pro { background: var(--app-exploration-soft, #ede9fe); color: var(--app-exploration-text, #6d28d9); }
.mode.explore { background: #e0f2fe; color: #0369a1; } .mode.explore { background: var(--app-info-soft, #e0f2fe); color: var(--app-info-text, #0369a1); }
.case-version { font-size: 11px; color: #94a3b8; white-space: nowrap; } .case-version { font-size: 11px; color: var(--app-text-faint, #94a3b8); white-space: nowrap; }
.card-title { display: flex; align-items: flex-start; gap: 6px; margin: 13px 0 7px; min-height: 42px; } .card-title { display: flex; align-items: flex-start; gap: 6px; margin: 13px 0 7px; min-height: 42px; }
.card-title h3 { flex: 1; min-width: 0; margin: 0; font-size: 14px; line-height: 21px; overflow-wrap: anywhere; color: #1e293b; .card-title h3 { flex: 1; min-width: 0; margin: 0; font-size: 14px; line-height: 21px; overflow-wrap: anywhere; color: var(--app-text-control, #1e293b);
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; } display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; }
.star { display: flex; flex-shrink: 0; width: 24px; height: 24px; align-items: center; justify-content: center; padding: 0; border: 0; background: transparent; color: #c58b23; } .star { display: flex; flex-shrink: 0; width: 24px; height: 24px; align-items: center; justify-content: center; padding: 0; border: 0; background: transparent; color: var(--app-warning-text, #c58b23); }
.star span { font-size: 19px; font-variation-settings: 'FILL' 1; } .star span { font-size: 19px; font-variation-settings: 'FILL' 1; }
.model-summary { font-size: 12px; line-height: 18px; color: #64748b; margin: 0 0 12px; min-height: 36px; overflow-wrap: anywhere; .model-summary { font-size: 12px; line-height: 18px; color: var(--app-text-muted, #64748b); margin: 0 0 12px; min-height: 36px; overflow-wrap: anywhere;
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; } display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; }
.card-actions { display: flex; align-items: center; justify-content: space-between; margin-top: auto; gap: 10px; } .card-actions { display: flex; align-items: center; justify-content: space-between; margin-top: auto; gap: 10px; }
.card-actions button { display: flex; align-items: center; gap: 4px; min-height: 34px; padding: 7px 10px; border-radius: 8px; font-size: 12px; white-space: nowrap; } .card-actions button { display: flex; align-items: center; gap: 4px; min-height: 34px; padding: 7px 10px; border-radius: 8px; font-size: 12px; white-space: nowrap; }
.card-actions span { font-size: 16px; } .card-actions span { font-size: 16px; }
.configure { color: #64748b; border: 0; background: transparent; } .configure { color: var(--app-text-muted, #64748b); border: 0; background: transparent; }
.primary { border: 1px solid #6366f1; background: #6366f1; color: white; } .primary { border: 1px solid #6366f1; background: #6366f1; color: white; }
.primary:hover { background: #4f46e5; } .primary:hover { background: #4f46e5; }
.configuration { border-top: 1px solid #e2e8f0; margin-top: 14px; padding-top: 14px; display: grid; gap: 12px; } .configuration { border-top: 1px solid var(--app-border, #e2e8f0); margin-top: 14px; padding-top: 14px; display: grid; gap: 12px; }
.configuration label { display: grid; gap: 6px; font-size: 12px; color: #475569; } .configuration label { display: grid; gap: 6px; font-size: 12px; color: var(--app-text-secondary, #475569); }
.configuration .checkbox { display: flex; align-items: center; } .configuration .checkbox { display: flex; align-items: center; }
.checkbox input { width: auto; min-height: auto; } .checkbox input { width: auto; min-height: auto; }
input, select { box-sizing: border-box; min-width: 0; width: 100%; min-height: 40px; padding: 10px 12px; border: 1px solid #dbe3ec; border-radius: 10px; color: #334155; background: white; font: inherit; font-size: 13px; } input, select { box-sizing: border-box; min-width: 0; width: 100%; min-height: 40px; padding: 10px 12px; border: 1px solid var(--app-border, #dbe3ec); border-radius: 10px; color: var(--app-text-control, #334155); background: var(--app-surface, white); font: inherit; font-size: 13px; }
option { color: #334155; background: #fff; } option { color: var(--app-text-control, #334155); background: var(--app-surface, #fff); }
.hint { font-size: 11px; color: #64748b; line-height: 1.5; margin: 7px 0 0; } .hint { font-size: 11px; color: var(--app-text-muted, #64748b); line-height: 1.5; margin: 7px 0 0; }
.save { justify-self: start; padding: 7px 10px; border: 1px solid #cbd5e1; background: #fff; color: #475569; border-radius: 8px; font-size: 12px; } .save { justify-self: start; padding: 7px 10px; border: 1px solid var(--app-border-control, #cbd5e1); background: var(--app-surface, #fff); color: var(--app-text-secondary, #475569); border-radius: 8px; font-size: 12px; }
details { border: 1px solid #e2e8f0; padding: 10px; border-radius: 9px; } details { border: 1px solid var(--app-border, #e2e8f0); padding: 10px; border-radius: 9px; }
details summary { font-size: 12px; cursor: pointer; overflow-wrap: anywhere; } details summary { font-size: 12px; cursor: pointer; overflow-wrap: anywhere; }
details[open] > label, details[open] > app-setting-slider { margin-top: 12px; display: block; } details[open] > label, details[open] > app-setting-slider { margin-top: 12px; display: block; }
details[open] > app-model-selector { margin-top: 12px; } details[open] > app-model-selector { margin-top: 12px; }
button { font-family: inherit; cursor: pointer; } button { font-family: inherit; cursor: pointer; }
button:disabled { opacity: .5; cursor: default; } button:disabled { opacity: .5; cursor: default; }
button:focus-visible, input:focus-visible, select:focus-visible, summary:focus-visible { outline: 2px solid #818cf8; outline-offset: 2px; } button:focus-visible, input:focus-visible, select:focus-visible, summary:focus-visible { outline: 2px solid var(--app-exploration-border, #818cf8); outline-offset: 2px; }
.empty { display: flex; flex-direction: column; align-items: center; text-align: center; padding: 28px 18px; border: 1px dashed #cbd5e1; border-radius: 16px; color: #64748b; font-size: 12px; line-height: 1.7; } .empty { display: flex; flex-direction: column; align-items: center; text-align: center; padding: 28px 18px; border: 1px dashed var(--app-border-control, #cbd5e1); border-radius: 16px; color: var(--app-text-muted, #64748b); font-size: 12px; line-height: 1.7; }
.empty > span { font-size: 28px; color: #c5a365; margin-bottom: 9px; } .empty > span { font-size: 28px; color: var(--app-warning-text, #c5a365); margin-bottom: 9px; }
.empty strong { font-size: 14px; color: #475569; } .empty strong { font-size: 14px; color: var(--app-text-secondary, #475569); }
.empty p { max-width: 440px; margin: 8px 0 0; } .empty p { max-width: 440px; margin: 8px 0 0; }
.error, .notice { padding: 10px 12px; border-radius: 8px; font-size: 12px; line-height: 1.6; } .error, .notice { padding: 10px 12px; border-radius: 8px; font-size: 12px; line-height: 1.6; }
.error { background: #fff1f2; color: #b91c1c; } .error { background: var(--app-error-soft, #fff1f2); color: var(--app-error-text, #b91c1c); }
.notice { background: #f0fdf4; color: #166534; } .notice { background: var(--app-success-soft, #f0fdf4); color: var(--app-success-text, #166534); }
@media (max-width: 560px) { @media (max-width: 560px) {
.heading { align-items: flex-start; flex-direction: column; } .heading { align-items: flex-start; flex-direction: column; }
.heading > input { width: 100%; max-width: none; } .heading > input { width: 100%; max-width: none; }
@@ -35,7 +35,7 @@ describe('Featured tasks',()=>{
expect(fixture.nativeElement.textContent).not.toContain('旧任务部分配置缺失'); expect(fixture.nativeElement.textContent).not.toContain('旧任务部分配置缺失');
fixture.nativeElement.querySelector('.configure').click();fixture.detectChanges();flushMicrotasks(); fixture.nativeElement.querySelector('.configure').click();fixture.detectChanges();flushMicrotasks();
const select:HTMLSelectElement=fixture.nativeElement.querySelector('app-model-selector select'); const select:HTMLSelectElement=fixture.nativeElement.querySelector('app-model-selector select');
expect(Array.from(select.options,option=>option.value)).toEqual(['gpt-6-luna','gpt-6.1-sol','gpt-6-astra','mimo-v2.6-pro','__custom__']); expect(Array.from(select.options,option=>option.value)).toEqual(['gpt-6-luna','gpt-6.1-terra', 'gpt-6.1-sol','gpt-6-astra','mimo-v2.6-pro','__custom__']);
expect(select.value).toBe('__custom__'); expect(select.value).toBe('__custom__');
expect(fixture.nativeElement.querySelector('input[aria-label="复跑模型(自定义)"]').value).toBe('original-model'); expect(fixture.nativeElement.querySelector('input[aria-label="复跑模型(自定义)"]').value).toBe('original-model');
select.value='gpt-6-luna';select.dispatchEvent(new Event('change'));fixture.detectChanges(); select.value='gpt-6-luna';select.dispatchEvent(new Event('change'));fixture.detectChanges();
@@ -27,7 +27,9 @@ limitations under the License.
class="floating-video-wrapper" class="floating-video-wrapper"
[class.minimized]="agentService.isVideoMinimized()" [class.minimized]="agentService.isVideoMinimized()"
[class.theater]="isTheaterMode()" [class.theater]="isTheaterMode()"
[style.left.px]="!isTheaterMode() ? posX() : null" [style.--extension-width]="extensionWidth() + 'px'"
[style.left.px]="!isTheaterMode() ? windowLeft() : null"
[style.width.px]="isTheaterMode() || agentService.isVideoMinimized() ? null : 400 + extensionWidth()"
[style.top.px]="!isTheaterMode() ? posY() : null" [style.top.px]="!isTheaterMode() ? posY() : null"
[style.--player-top]="posY() + 'px'" [style.--player-top]="posY() + 'px'"
> >
@@ -42,6 +44,9 @@ limitations under the License.
</span> </span>
</div> </div>
<div class="header-actions" (mousedown)="$event.stopPropagation()"> <div class="header-actions" (mousedown)="$event.stopPropagation()">
@if (extensionPanels(); as panels) {
<ng-container [ngTemplateOutlet]="panels.headerControls() || null" />
}
<!-- Mode Toggle Group: Video vs. Step Replay --> <!-- Mode Toggle Group: Video vs. Step Replay -->
@if (agentService.recordingPlaybackStatus() === 'ready' && agentService.hasCurrentSessionStepFrames() && !agentService.isVideoMinimized()) { @if (agentService.recordingPlaybackStatus() === 'ready' && agentService.hasCurrentSessionStepFrames() && !agentService.isVideoMinimized()) {
<div class="mode-toggle-group"> <div class="mode-toggle-group">
@@ -117,6 +122,9 @@ limitations under the License.
} }
<!-- Video Main Content Area --> <!-- Video Main Content Area -->
@if (!agentService.isVideoMinimized()) { @if (!agentService.isVideoMinimized()) {
<app-task-extension-panels [sessionId]="agentService.currentSessionId()" [enabled]="true"
[sessionIds]="reviewSessionIds()" (navigate)="agentService.selectSession($event, true)"
[taskStatus]="agentService.currentSession()?.status || ''" (expandedWidth)="extensionWidth.set($event)">
<div class="video-body"> <div class="video-body">
<!-- 1. LIVE SCREEN STREAM (AUTOMATICALLY ACTIVE DURING RUNNING TEST) --> <!-- 1. LIVE SCREEN STREAM (AUTOMATICALLY ACTIVE DURING RUNNING TEST) -->
@if (agentService.recordingPlaybackStatus() === 'live') { @if (agentService.recordingPlaybackStatus() === 'live') {
@@ -488,6 +496,7 @@ limitations under the License.
} }
} }
</div> </div>
</app-task-extension-panels>
} }
</div> </div>
} }
@@ -28,16 +28,17 @@
} }
.floating-video-wrapper { .floating-video-wrapper {
container-type: inline-size;
position: fixed; position: fixed;
width: 400px; width: 400px;
max-width: calc(100vw - 32px); max-width: calc(100vw - 32px);
max-height: calc(100dvh - var(--player-top, 60px) - 12px); max-height: calc(100dvh - var(--player-top, 60px) - 12px);
background: rgba(255, 255, 255, 0.84); background: rgb(var(--app-surface-rgb, 255 255 255) / 0.84);
backdrop-filter: blur(24px) saturate(180%); backdrop-filter: blur(24px) saturate(180%);
-webkit-backdrop-filter: blur(24px) saturate(180%); -webkit-backdrop-filter: blur(24px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.85); border: 1px solid rgb(var(--app-border-rgb, 255 255 255) / 0.85);
border-radius: 16px; border-radius: 16px;
box-shadow: 0 20px 50px -10px rgba(15, 23, 42, 0.22), 0 0 0 1px rgba(226, 232, 240, 0.6), inset 0 1px 1px rgba(255, 255, 255, 0.9); box-shadow: 0 20px 50px -10px rgba(15, 23, 42, 0.22), 0 0 0 1px rgba(226, 232, 240, 0.6), inset 0 1px 1px rgb(var(--app-highlight-rgb, 255 255 255) / 0.9);
z-index: 1050; z-index: 1050;
overflow: hidden; overflow: hidden;
display: flex; display: flex;
@@ -60,8 +61,8 @@
left: 50% !important; left: 50% !important;
top: 50% !important; top: 50% !important;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
width: 85vw; width: min(calc(85vw + var(--extension-width, 0px)), calc(100vw - 32px));
max-width: 960px; max-width: calc(960px + var(--extension-width, 0px));
height: auto; height: auto;
max-height: 88vh; max-height: 88vh;
box-shadow: 0 24px 60px -12px rgba(15, 23, 42, 0.35); box-shadow: 0 24px 60px -12px rgba(15, 23, 42, 0.35);
@@ -80,9 +81,9 @@
/* --- Window Header (Draggable Handle) --- */ /* --- Window Header (Draggable Handle) --- */
.window-header { .window-header {
height: 44px; height: 44px;
background: rgba(255, 255, 255, 0.75); background: rgb(var(--app-surface-rgb, 255 255 255) / 0.75);
backdrop-filter: blur(16px); backdrop-filter: blur(16px);
border-bottom: 1px solid rgba(226, 232, 240, 0.75); border-bottom: 1px solid rgb(var(--app-border-rgb, 226 232 240) / 0.75);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
@@ -104,14 +105,14 @@
.header-icon { .header-icon {
font-size: 18px; font-size: 18px;
color: #2563eb; color: var(--app-primary-text, #2563eb);
flex-shrink: 0; flex-shrink: 0;
} }
.header-title { .header-title {
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: #0f172a; color: var(--app-text-strong, #0f172a);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -121,9 +122,9 @@
.mode-switcher-group { .mode-switcher-group {
display: flex; display: flex;
align-items: center; align-items: center;
background: rgba(241, 245, 249, 0.65); background: rgb(var(--app-surface-soft-rgb, 241 245 249) / 0.65);
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
border: 1px solid rgba(226, 232, 240, 0.8); border: 1px solid rgb(var(--app-border-rgb, 226 232 240) / 0.8);
border-radius: 8px; border-radius: 8px;
padding: 2px; padding: 2px;
margin-left: 4px; margin-left: 4px;
@@ -132,7 +133,7 @@
.mode-btn { .mode-btn {
border: none; border: none;
background: transparent; background: transparent;
color: #475569; color: var(--app-text-secondary, #475569);
font-size: 10px; font-size: 10px;
font-weight: 700; font-weight: 700;
padding: 2px 7px; padding: 2px 7px;
@@ -152,8 +153,8 @@
} }
&:hover:not(:disabled) { &:hover:not(:disabled) {
color: #0f172a; color: var(--app-text-strong, #0f172a);
background: rgba(255, 255, 255, 0.6); background: rgb(var(--app-surface-rgb, 255 255 255) / 0.6);
} }
&.active { &.active {
@@ -180,9 +181,9 @@
.mode-toggle-group { .mode-toggle-group {
display: flex; display: flex;
align-items: center; align-items: center;
background: rgba(241, 245, 249, 0.7); background: rgb(var(--app-surface-soft-rgb, 241 245 249) / 0.7);
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
border: 1px solid rgba(226, 232, 240, 0.85); border: 1px solid rgb(var(--app-border-rgb, 226 232 240) / 0.85);
border-radius: 8px; border-radius: 8px;
padding: 2px; padding: 2px;
margin-right: 4px; margin-right: 4px;
@@ -194,7 +195,7 @@
gap: 3px; gap: 3px;
border: none; border: none;
background: transparent; background: transparent;
color: #475569; color: var(--app-text-secondary, #475569);
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
padding: 2px 7px; padding: 2px 7px;
@@ -207,8 +208,8 @@
} }
&:hover:not(.active) { &:hover:not(.active) {
color: #0f172a; color: var(--app-text-strong, #0f172a);
background: rgba(255, 255, 255, 0.8); background: rgb(var(--app-surface-rgb, 255 255 255) / 0.8);
} }
&.active { &.active {
@@ -222,9 +223,9 @@
.speed-pill-group { .speed-pill-group {
display: flex; display: flex;
align-items: center; align-items: center;
background: rgba(241, 245, 249, 0.65); background: rgb(var(--app-surface-soft-rgb, 241 245 249) / 0.65);
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
border: 1px solid rgba(226, 232, 240, 0.8); border: 1px solid rgb(var(--app-border-rgb, 226 232 240) / 0.8);
border-radius: 8px; border-radius: 8px;
padding: 2px; padding: 2px;
margin-right: 4px; margin-right: 4px;
@@ -232,7 +233,7 @@
.speed-btn { .speed-btn {
border: none; border: none;
background: transparent; background: transparent;
color: #334155; color: var(--app-text-control, #334155);
font-size: 11px; font-size: 11px;
font-weight: 700; font-weight: 700;
padding: 2px 6px; padding: 2px 6px;
@@ -241,8 +242,8 @@
transition: all 0.15s ease; transition: all 0.15s ease;
&:hover { &:hover {
color: #0f172a; color: var(--app-text-strong, #0f172a);
background: #cbd5e1; background: var(--app-surface-raised, #cbd5e1);
} }
&.active { &.active {
@@ -255,11 +256,11 @@
} }
.header-btn { .header-btn {
background: rgba(241, 245, 249, 0.75); background: rgb(var(--app-surface-soft-rgb, 241 245 249) / 0.75);
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(226, 232, 240, 0.85); border: 1px solid rgb(var(--app-border-rgb, 226 232 240) / 0.85);
color: #1e293b; color: var(--app-text-control, #1e293b);
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -267,21 +268,21 @@
width: 28px; width: 28px;
height: 28px; height: 28px;
border-radius: 7px; border-radius: 7px;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04), inset 0 1px 1px rgba(255, 255, 255, 0.8); box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04), inset 0 1px 1px rgb(var(--app-highlight-rgb, 255 255 255) / 0.8);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
&:hover { &:hover {
background: rgba(255, 255, 255, 0.95); background: rgb(var(--app-surface-rgb, 255 255 255) / 0.95);
color: #0f172a; color: var(--app-text-strong, #0f172a);
border-color: rgba(203, 213, 225, 0.95); border-color: rgb(var(--app-border-control-rgb, 203 213 225) / 0.95);
transform: translateY(-0.5px); transform: translateY(-0.5px);
box-shadow: 0 2px 6px rgba(15, 23, 42, 0.08); box-shadow: 0 2px 6px rgba(15, 23, 42, 0.08);
} }
&.close-btn:hover { &.close-btn:hover {
background: rgba(254, 226, 226, 0.95); background: rgb(var(--app-error-soft-rgb, 254 226 226) / 0.95);
color: #dc2626; color: var(--app-error-text, #dc2626);
border-color: rgba(252, 165, 165, 0.9); border-color: rgb(var(--app-error-border-rgb, 252 165 165) / 0.9);
box-shadow: 0 2px 6px rgba(220, 38, 38, 0.15); box-shadow: 0 2px 6px rgba(220, 38, 38, 0.15);
} }
@@ -301,7 +302,7 @@
.video-body { .video-body {
min-height: 0; min-height: 0;
position: relative; position: relative;
background: #ffffff; background: var(--app-surface, #ffffff);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
@@ -314,16 +315,16 @@
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
background: #ffffff; background: var(--app-surface, #ffffff);
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
color: #475569; color: var(--app-text-secondary, #475569);
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;
.spinning-icon { .spinning-icon {
font-size: 28px; font-size: 28px;
color: #2563eb; color: var(--app-primary-text, #2563eb);
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
} }
} }
@@ -336,7 +337,7 @@
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: #ffffff; background: var(--app-surface, #ffffff);
min-height: 260px; min-height: 260px;
overflow: hidden; overflow: hidden;
@@ -346,7 +347,7 @@
display: block; display: block;
object-fit: contain; object-fit: contain;
user-select: none; user-select: none;
background: #ffffff; background: var(--app-surface, #ffffff);
} }
.live-status-overlay { .live-status-overlay {
@@ -363,9 +364,9 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
background: #fef2f2; background: var(--app-error-soft, #fef2f2);
border: 1px solid #fca5a5; border: 1px solid var(--app-error-border, #fca5a5);
color: #dc2626; color: var(--app-error-text, #dc2626);
font-size: 10.5px; font-size: 10.5px;
font-weight: 700; font-weight: 700;
padding: 3px 9px; padding: 3px 9px;
@@ -384,10 +385,10 @@
.live-refresh-btn { .live-refresh-btn {
pointer-events: auto; pointer-events: auto;
background: rgba(255, 255, 255, 0.95); background: rgb(var(--app-surface-rgb, 255 255 255) / 0.95);
backdrop-filter: blur(6px); backdrop-filter: blur(6px);
border: 1px solid #cbd5e1; border: 1px solid var(--app-border-control, #cbd5e1);
color: #1e293b; color: var(--app-text-control, #1e293b);
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -399,9 +400,9 @@
transition: all 0.15s ease; transition: all 0.15s ease;
&:hover { &:hover {
background: #f1f5f9; background: var(--app-surface-soft, #f1f5f9);
color: #0f172a; color: var(--app-text-strong, #0f172a);
border-color: #94a3b8; border-color: var(--app-border-control, #94a3b8);
transform: rotate(90deg); transform: rotate(90deg);
} }
@@ -415,7 +416,7 @@
.step-replay-container { .step-replay-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: #ffffff; background: var(--app-surface, #ffffff);
position: relative; position: relative;
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;
@@ -424,16 +425,16 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
background: #fffbeb; background: var(--app-warning-soft, #fffbeb);
color: #b45309; color: var(--app-warning-text, #b45309);
padding: 6px 12px; padding: 6px 12px;
font-size: 11.5px; font-size: 11.5px;
font-weight: 500; font-weight: 500;
border-bottom: 1px solid #fde68a; border-bottom: 1px solid var(--app-warning-border, #fde68a);
.notice-icon { .notice-icon {
font-size: 14px; font-size: 14px;
color: #d97706; color: var(--app-warning-text, #d97706);
flex-shrink: 0; flex-shrink: 0;
} }
} }
@@ -443,7 +444,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
position: relative; position: relative;
background: #f8fafc; background: var(--app-surface-soft, #f8fafc);
min-height: 280px; min-height: 280px;
max-height: 520px; max-height: 520px;
overflow: hidden; overflow: hidden;
@@ -458,21 +459,21 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 8px; gap: 8px;
background: #f8fafc; background: var(--app-surface-soft, #f8fafc);
color: #64748b; color: var(--app-text-muted, #64748b);
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
z-index: 5; z-index: 5;
.spinning-icon { .spinning-icon {
font-size: 24px; font-size: 24px;
color: #0284c7; color: var(--app-info-text, #0284c7);
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
} }
.broken-icon { .broken-icon {
font-size: 28px; font-size: 28px;
color: #94a3b8; color: var(--app-text-faint, #94a3b8);
} }
} }
@@ -495,7 +496,7 @@
cursor: pointer; cursor: pointer;
border-radius: 8px; border-radius: 8px;
box-shadow: 0 4px 20px -2px rgba(15, 23, 42, 0.14), 0 0 0 1px rgba(226, 232, 240, 0.9); box-shadow: 0 4px 20px -2px rgba(15, 23, 42, 0.14), 0 0 0 1px rgba(226, 232, 240, 0.9);
background: #ffffff; background: var(--app-surface, #ffffff);
transition: opacity 0.15s ease; transition: opacity 0.15s ease;
&:hover { &:hover {
@@ -532,7 +533,7 @@
background: rgba(15, 23, 42, 0.88); background: rgba(15, 23, 42, 0.88);
backdrop-filter: blur(14px) saturate(180%); backdrop-filter: blur(14px) saturate(180%);
-webkit-backdrop-filter: blur(14px) saturate(180%); -webkit-backdrop-filter: blur(14px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.2); border: 1px solid rgb(var(--app-border-rgb, 255 255 255) / 0.2);
border-radius: 10px; border-radius: 10px;
padding: 6px 10px; padding: 6px 10px;
color: #ffffff; color: #ffffff;
@@ -580,7 +581,7 @@
.action-type-icon { .action-type-icon {
font-size: 15px; font-size: 15px;
color: #38bdf8; color: var(--app-info-text, #38bdf8);
flex-shrink: 0; flex-shrink: 0;
} }
} }
@@ -595,8 +596,8 @@
.pre-post-toggle { .pre-post-toggle {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
background: rgba(255, 255, 255, 0.12); background: rgb(var(--app-surface-rgb, 255 255 255) / 0.12);
border: 1px solid rgba(255, 255, 255, 0.15); border: 1px solid rgb(var(--app-border-rgb, 255 255 255) / 0.15);
border-radius: 6px; border-radius: 6px;
padding: 2px; padding: 2px;
gap: 2px; gap: 2px;
@@ -614,7 +615,7 @@
&:hover { &:hover {
color: #ffffff; color: #ffffff;
background: rgba(255, 255, 255, 0.2); background: rgb(var(--app-surface-rgb, 255 255 255) / 0.2);
} }
&.active { &.active {
@@ -639,11 +640,11 @@
&:hover { &:hover {
color: #ffffff; color: #ffffff;
background: rgba(255, 255, 255, 0.15); background: rgb(var(--app-surface-rgb, 255 255 255) / 0.15);
} }
&.pinned { &.pinned {
color: #38bdf8; color: var(--app-info-text, #38bdf8);
background: rgba(56, 189, 248, 0.2); background: rgba(56, 189, 248, 0.2);
} }
@@ -664,7 +665,7 @@
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: #ffffff; background: var(--app-surface, #ffffff);
video { video {
min-height: 0; min-height: 0;
@@ -674,21 +675,21 @@
display: block; display: block;
object-fit: contain; object-fit: contain;
cursor: pointer; cursor: pointer;
background: #ffffff; background: var(--app-surface, #ffffff);
} }
} }
.video-controls-footer { .video-controls-footer {
background: rgba(255, 255, 255, 0.84); background: rgb(var(--app-surface-rgb, 255 255 255) / 0.84);
backdrop-filter: blur(20px) saturate(180%); backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%); -webkit-backdrop-filter: blur(20px) saturate(180%);
border-top: 1px solid rgba(226, 232, 240, 0.75); border-top: 1px solid rgb(var(--app-border-rgb, 226 232 240) / 0.75);
padding: 12px 14px; padding: 12px 14px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
flex-shrink: 0; flex-shrink: 0;
box-shadow: 0 -4px 16px rgba(15, 23, 42, 0.04), inset 0 1px 1px rgba(255, 255, 255, 0.9); box-shadow: 0 -4px 16px rgba(15, 23, 42, 0.04), inset 0 1px 1px rgb(var(--app-highlight-rgb, 255 255 255) / 0.9);
.scrubber-bar-container { .scrubber-bar-container {
position: relative; position: relative;
@@ -714,7 +715,7 @@
left: 0; left: 0;
right: 0; right: 0;
height: 5px; height: 5px;
background: rgba(226, 232, 240, 0.85); background: rgb(var(--app-surface-raised-rgb, 226 232 240) / 0.85);
border-radius: 3px; border-radius: 3px;
} }
@@ -740,7 +741,7 @@
width: 12px; width: 12px;
height: 12px; height: 12px;
padding: 0; padding: 0;
border: 2px solid #fff; border: 2px solid var(--app-border, #fff);
border-radius: 50%; border-radius: 50%;
background: #64748b; background: #64748b;
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.25); box-shadow: 0 1px 4px rgba(15, 23, 42, 0.25);
@@ -775,8 +776,8 @@
width: 7px; width: 7px;
height: 7px; height: 7px;
border-radius: 50%; border-radius: 50%;
background: rgba(255, 255, 255, 0.9); background: rgb(var(--app-surface-rgb, 255 255 255) / 0.9);
border: 1.5px solid #94a3b8; border: 1.5px solid var(--app-border-control, #94a3b8);
transform: translate(-50%, 0); transform: translate(-50%, 0);
pointer-events: auto; pointer-events: auto;
cursor: pointer; cursor: pointer;
@@ -785,17 +786,17 @@
&:hover { &:hover {
transform: translate(-50%, 0) scale(1.4); transform: translate(-50%, 0) scale(1.4);
border-color: #0284c7; border-color: #0284c7;
background: #ffffff; background: var(--app-surface, #ffffff);
} }
&.passed { &.passed {
background: #0284c7; background: #0284c7;
border-color: #ffffff; border-color: var(--app-border, #ffffff);
} }
&.active { &.active {
background: #38bdf8; background: #38bdf8;
border-color: #ffffff; border-color: var(--app-border, #ffffff);
transform: translate(-50%, 0) scale(1.3); transform: translate(-50%, 0) scale(1.3);
box-shadow: 0 0 6px rgba(56, 189, 248, 0.8); box-shadow: 0 0 6px rgba(56, 189, 248, 0.8);
} }
@@ -805,22 +806,27 @@
.controls-row { .controls-row {
display: flex; display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
.controls-left, .controls-right { .controls-left, .controls-right {
display: flex; display: flex;
flex-wrap: wrap;
white-space: nowrap;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
} }
.ctrl-btn { .ctrl-btn {
background: rgba(241, 245, 249, 0.75); flex-shrink: 0;
background: rgb(var(--app-surface-soft-rgb, 241 245 249) / 0.75);
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(226, 232, 240, 0.85); border: 1px solid rgb(var(--app-border-rgb, 226 232 240) / 0.85);
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04), inset 0 1px 1px rgba(255, 255, 255, 0.85); box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04), inset 0 1px 1px rgb(var(--app-highlight-rgb, 255 255 255) / 0.85);
color: #0f172a; color: var(--app-text-strong, #0f172a);
width: 32px; width: 32px;
height: 32px; height: 32px;
border-radius: 8px; border-radius: 8px;
@@ -831,9 +837,9 @@
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
&:hover { &:hover {
background: rgba(255, 255, 255, 0.95); background: rgb(var(--app-surface-rgb, 255 255 255) / 0.95);
border-color: rgba(191, 219, 254, 0.9); border-color: rgb(var(--app-primary-border-rgb, 191 219 254) / 0.9);
color: #0284c7; color: var(--app-info-text, #0284c7);
transform: translateY(-0.5px); transform: translateY(-0.5px);
box-shadow: 0 3px 8px rgba(15, 23, 42, 0.08); box-shadow: 0 3px 8px rgba(15, 23, 42, 0.08);
} }
@@ -843,8 +849,8 @@
} }
&.active { &.active {
color: #0284c7; color: var(--app-info-text, #0284c7);
background: rgba(224, 242, 254, 0.85); background: rgb(var(--app-info-soft-rgb, 224 242 254) / 0.85);
border-color: rgba(56, 189, 248, 0.8); border-color: rgba(56, 189, 248, 0.8);
box-shadow: 0 2px 6px rgba(2, 132, 199, 0.15); box-shadow: 0 2px 6px rgba(2, 132, 199, 0.15);
} }
@@ -853,16 +859,16 @@
background: linear-gradient(135deg, rgba(2, 132, 199, 0.92), rgba(37, 99, 235, 0.88)); background: linear-gradient(135deg, rgba(2, 132, 199, 0.92), rgba(37, 99, 235, 0.88));
backdrop-filter: blur(14px) saturate(180%); backdrop-filter: blur(14px) saturate(180%);
-webkit-backdrop-filter: blur(14px) saturate(180%); -webkit-backdrop-filter: blur(14px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.35); border: 1px solid rgb(var(--app-border-rgb, 255 255 255) / 0.35);
color: #ffffff; color: #ffffff;
width: 36px; width: 36px;
height: 36px; height: 36px;
border-radius: 50%; border-radius: 50%;
box-shadow: 0 4px 14px rgba(2, 132, 199, 0.35), inset 0 1px 1px rgba(255, 255, 255, 0.5); box-shadow: 0 4px 14px rgba(2, 132, 199, 0.35), inset 0 1px 1px rgb(var(--app-highlight-rgb, 255 255 255) / 0.5);
&:hover { &:hover {
background: linear-gradient(135deg, rgba(2, 132, 199, 1), rgba(37, 99, 235, 0.95)); background: linear-gradient(135deg, rgba(2, 132, 199, 1), rgba(37, 99, 235, 0.95));
box-shadow: 0 6px 18px rgba(2, 132, 199, 0.45), inset 0 1px 1px rgba(255, 255, 255, 0.6); box-shadow: 0 6px 18px rgba(2, 132, 199, 0.45), inset 0 1px 1px rgb(var(--app-highlight-rgb, 255 255 255) / 0.6);
transform: translateY(-1px) scale(1.04); transform: translateY(-1px) scale(1.04);
} }
@@ -883,18 +889,18 @@
font-size: 11px; font-size: 11px;
font-weight: 700; font-weight: 700;
font-family: 'JetBrains Mono', 'Roboto Mono', monospace; font-family: 'JetBrains Mono', 'Roboto Mono', monospace;
color: #334155; color: var(--app-text-control, #334155);
letter-spacing: -0.2px; letter-spacing: -0.2px;
&:hover { &:hover {
color: #0284c7; color: var(--app-info-text, #0284c7);
border-color: rgba(191, 219, 254, 0.9); border-color: rgb(var(--app-primary-border-rgb, 191 219 254) / 0.9);
} }
} }
.material-symbols-outlined { .material-symbols-outlined {
font-size: 18px; font-size: 18px;
color: #1e293b; color: var(--app-text-control, #1e293b);
} }
} }
@@ -902,7 +908,7 @@
font-size: 12px; font-size: 12px;
font-weight: 700; font-weight: 700;
font-family: 'JetBrains Mono', 'Roboto Mono', monospace; font-family: 'JetBrains Mono', 'Roboto Mono', monospace;
color: #1e293b; color: var(--app-text-control, #1e293b);
margin-left: 4px; margin-left: 4px;
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
@@ -911,7 +917,7 @@
/* --- Clean, Minimal High-Contrast Empty State --- */ /* --- Clean, Minimal High-Contrast Empty State --- */
.empty-video-fallback { .empty-video-fallback {
background: #ffffff; background: var(--app-surface, #ffffff);
padding: 36px 20px; padding: 36px 20px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -924,8 +930,8 @@
width: 48px; width: 48px;
height: 48px; height: 48px;
border-radius: 50%; border-radius: 50%;
background: #fef2f2; background: var(--app-error-soft, #fef2f2);
border: 1px solid #fee2e2; border: 1px solid var(--app-error-border, #fee2e2);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -933,7 +939,7 @@
.fallback-icon { .fallback-icon {
font-size: 24px; font-size: 24px;
color: #ef4444; color: var(--app-error-text, #ef4444);
} }
} }
@@ -941,13 +947,13 @@
margin: 0 0 6px 0; margin: 0 0 6px 0;
font-size: 13.5px; font-size: 13.5px;
font-weight: 600; font-weight: 600;
color: #0f172a; color: var(--app-text-strong, #0f172a);
} }
.fallback-desc { .fallback-desc {
margin: 0 0 12px 0; margin: 0 0 12px 0;
font-size: 12px; font-size: 12px;
color: #64748b; color: var(--app-text-muted, #64748b);
line-height: 1.4; line-height: 1.4;
max-width: 240px; max-width: 240px;
} }
@@ -1026,25 +1032,25 @@
gap: 8px; gap: 8px;
align-items: center; align-items: center;
padding: 8px 12px; padding: 8px 12px;
background: #f8fafc; background: var(--app-surface-soft, #f8fafc);
border-bottom: 1px solid #e2e8f0; border-bottom: 1px solid var(--app-border, #e2e8f0);
font-size: 12px; font-size: 12px;
color: #475569; color: var(--app-text-secondary, #475569);
select { select {
min-width: 0; min-width: 0;
flex: 1; flex: 1;
border: 1px solid #cbd5e1; border: 1px solid var(--app-border-control, #cbd5e1);
border-radius: 6px; border-radius: 6px;
padding: 4px; padding: 4px;
background: white; background: var(--app-surface, white);
} }
} }
.recording-notice { .recording-notice {
padding: 8px 12px; padding: 8px 12px;
background: #fffbeb; background: var(--app-warning-soft, #fffbeb);
color: #92400e; color: var(--app-warning-text, #92400e);
font-size: 12px; font-size: 12px;
} }
@@ -1055,8 +1061,8 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 24px; padding: 24px;
color: #475569; color: var(--app-text-secondary, #475569);
background: #f1f5f9; background: var(--app-surface-soft, #f1f5f9);
text-align: center; text-align: center;
z-index: 2; z-index: 2;
} }
@@ -1066,13 +1072,13 @@
justify-content: space-between; justify-content: space-between;
flex-wrap: wrap; flex-wrap: wrap;
gap: 4px 12px; gap: 4px 12px;
color: #64748b; color: var(--app-text-muted, #64748b);
font-size: 11px; font-size: 11px;
line-height: 16px; line-height: 16px;
} }
.recording-skip-notice { .recording-skip-notice {
color: #64748b; color: var(--app-text-muted, #64748b);
font-size: 11px; font-size: 11px;
line-height: 16px; line-height: 16px;
} }
@@ -1,3 +1,5 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { signal } from '@angular/core'; import { signal } from '@angular/core';
import { AgentService, RecordingSource, VideoSegment } from '../../services/agent.service'; import { AgentService, RecordingSource, VideoSegment } from '../../services/agent.service';
@@ -39,6 +41,8 @@ describe('FloatingVideoPlayerComponent video markers', () => {
currentSessionStepFrames: signal([frame]), currentSessionStepFrames: signal([frame]),
currentSession: signal({ session_id: 'test', start_time: start }), currentSession: signal({ session_id: 'test', start_time: start }),
currentSessionId: signal('test'), currentSessionId: signal('test'),
sessions: signal([{ session_id: 'test', status: 'completed' }]),
selectSession: jasmine.createSpy('selectSession'),
videoSeekRequest: signal<{ seconds: number; requestId: number; sourceId?: string } | null>(null), videoSeekRequest: signal<{ seconds: number; requestId: number; sourceId?: string } | null>(null),
stepSeekRequest: signal(null), stepSeekRequest: signal(null),
consumeVideoAutoplay: jasmine.createSpy('consumeVideoAutoplay').and.returnValue(false) consumeVideoAutoplay: jasmine.createSpy('consumeVideoAutoplay').and.returnValue(false)
@@ -59,11 +63,14 @@ describe('FloatingVideoPlayerComponent video markers', () => {
agentService = createAgentService(); agentService = createAgentService();
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [FloatingVideoPlayerComponent], imports: [FloatingVideoPlayerComponent],
providers: [{ provide: AgentService, useValue: agentService }] providers: [provideHttpClient(), provideHttpClientTesting(), { provide: AgentService, useValue: agentService }]
}); });
fixture = TestBed.createComponent(FloatingVideoPlayerComponent); fixture = TestBed.createComponent(FloatingVideoPlayerComponent);
fixture.detectChanges(); fixture.detectChanges();
TestBed.inject(HttpTestingController).expectOne('/api/task-extensions/test').flush({
session_id: 'test', initial_prompt: '', editable: true, plugins: []
});
component = fixture.componentInstance; component = fixture.componentInstance;
video = component.videoRef!.nativeElement; video = component.videoRef!.nativeElement;
paused = true; paused = true;
@@ -98,6 +105,23 @@ describe('FloatingVideoPlayerComponent video markers', () => {
fixture.detectChanges(); fixture.detectChanges();
} }
for (const mode of ['video', 'steps']) {
it(`shows review controls in ${mode} mode even without a recording`, () => {
agentService.playerMode.set(mode);
agentService.recordingPlaybackStatus.set('unavailable');
component.extensionPanels()!.refresh(); fixture.detectChanges();
TestBed.inject(HttpTestingController).expectOne('/api/task-extensions/test').flush({
session_id: 'test', initial_prompt: '', editable: true, plugins: [{
plugin_id: 'review', name: '原始用例', version: '1.1.0', revision: 0, values: {}, history: [],
workspace: { fields: [], panels: [{ id: 'case', title: '原始用例', side: 'left', kind: 'original_case', width: {minimum: 240, preferred: 340, maximum: 480} }] }
}]
}); fixture.detectChanges();
const button: HTMLButtonElement = fixture.nativeElement.querySelector('button[aria-label="展开原始用例"]');
expect(button).not.toBeNull(); button.click(); fixture.detectChanges();
expect(fixture.nativeElement.querySelector('aside').textContent).toContain('未记录原始用例');
});
}
it('removes the live image while hidden and reconnects it on return', () => { it('removes the live image while hidden and reconnects it on return', () => {
const hidden = spyOnProperty(document, 'hidden', 'get').and.returnValue(false); const hidden = spyOnProperty(document, 'hidden', 'get').and.returnValue(false);
agentService.recordingPlaybackStatus.set('live'); agentService.recordingPlaybackStatus.set('live');
@@ -26,10 +26,13 @@ import {
HostListener, HostListener,
effect, effect,
untracked, untracked,
OnDestroy OnDestroy,
viewChild
} from '@angular/core'; } from '@angular/core';
import { TaskExtensionPanelsComponent } from '../task-extension-panels/task-extension-panels.component';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { NgTemplateOutlet } from '@angular/common';
import { AgentService } from '../../services/agent.service'; import { AgentService } from '../../services/agent.service';
import { StepReplayFrame } from '../../core/models/stream.model'; import { StepReplayFrame } from '../../core/models/stream.model';
import { drawActionCoordinatesOnOverlay } from '../../utils/image-overlay.util'; import { drawActionCoordinatesOnOverlay } from '../../utils/image-overlay.util';
@@ -40,13 +43,22 @@ import { buildVideoStepMarkers, VideoStepMarker } from '../../utils/video-step-m
@Component({ @Component({
selector: 'app-floating-video-player', selector: 'app-floating-video-player',
standalone: true, standalone: true,
imports: [FormsModule], imports: [FormsModule, NgTemplateOutlet, TaskExtensionPanelsComponent],
templateUrl: './floating-video-player.component.html', templateUrl: './floating-video-player.component.html',
styleUrl: './floating-video-player.component.scss', styleUrl: './floating-video-player.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class FloatingVideoPlayerComponent implements OnDestroy { export class FloatingVideoPlayerComponent implements OnDestroy {
public agentService = inject(AgentService); public agentService = inject(AgentService);
public extensionWidth = signal(0);
public reviewSessionIds = computed(() => this.agentService.sessions()
.filter(session => ['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(session.status || ''))
.map(session => session.session_id));
public extensionPanels = viewChild(TaskExtensionPanelsComponent);
public windowLeft(): number {
const width = this.agentService.isVideoMinimized() ? 280 : 400 + this.extensionWidth();
return Math.max(16, Math.min(this.posX(), window.innerWidth - Math.min(width, window.innerWidth - 32) - 16));
}
private zone = inject(NgZone); private zone = inject(NgZone);
@ViewChild('videoRef') videoRef?: ElementRef<HTMLVideoElement>; @ViewChild('videoRef') videoRef?: ElementRef<HTMLVideoElement>;
@@ -327,7 +339,7 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
this.isDragging = true; this.isDragging = true;
this.dragStartX = event.clientX; this.dragStartX = event.clientX;
this.dragStartY = event.clientY; this.dragStartY = event.clientY;
this.initialPosX = this.posX(); this.initialPosX = this.windowLeft();
this.initialPosY = this.posY(); this.initialPosY = this.posY();
event.preventDefault(); event.preventDefault();
@@ -15,10 +15,10 @@
*/ */
$border-color: rgba(0, 0, 0, 0.08); $border-color: rgba(0, 0, 0, 0.08);
$text-primary: #1f2937; $text-primary: var(--app-text-strong, #1f2937);
$text-secondary: #4b5563; $text-secondary: var(--app-text-secondary, #4b5563);
$text-muted: #9ca3af; $text-muted: var(--app-text-faint, #9ca3af);
$bg-main: #f9fafb; $bg-main: var(--app-surface-soft, #f9fafb);
:host { :host {
display: flex; display: flex;
@@ -32,7 +32,7 @@ $bg-main: #f9fafb;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
background: rgba(255, 255, 255, 0.4); background: rgb(var(--app-surface-rgb, 255 255 255) / 0.4);
backdrop-filter: blur(16px); backdrop-filter: blur(16px);
box-sizing: border-box; box-sizing: border-box;
@@ -42,7 +42,7 @@ $bg-main: #f9fafb;
align-items: center; align-items: center;
padding: 20px 24px; padding: 20px 24px;
border-bottom: 1px solid $border-color; border-bottom: 1px solid $border-color;
background: rgba(255, 255, 255, 0.6); background: rgb(var(--app-surface-rgb, 255 255 255) / 0.6);
flex-wrap: wrap; flex-wrap: wrap;
gap: 16px; gap: 16px;
@@ -74,10 +74,10 @@ $bg-main: #f9fafb;
.session-dropdown { .session-dropdown {
padding: 8px 12px; padding: 8px 12px;
border: 1px solid rgba(0, 0, 0, 0.12); border: 1px solid rgb(var(--app-border-control-rgb, 0 0 0) / 0.12);
border-radius: 8px; border-radius: 8px;
font-size: 13.5px; font-size: 13.5px;
background-color: #ffffff; background-color: var(--app-surface, #ffffff);
outline: none; outline: none;
color: $text-primary; color: $text-primary;
min-width: 250px; min-width: 250px;
@@ -92,8 +92,8 @@ $bg-main: #f9fafb;
.btn-refresh { .btn-refresh {
padding: 8px 14px; padding: 8px 14px;
background: #ffffff; background: var(--app-surface, #ffffff);
border: 1px solid rgba(0, 0, 0, 0.12); border: 1px solid rgb(var(--app-border-control-rgb, 0 0 0) / 0.12);
border-radius: 8px; border-radius: 8px;
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
@@ -105,7 +105,7 @@ $bg-main: #f9fafb;
transition: background 0.2s, transform 0.1s; transition: background 0.2s, transform 0.1s;
&:hover { &:hover {
background: #f3f4f6; background: var(--app-surface-raised, #f3f4f6);
} }
&:active { &:active {
@@ -119,7 +119,7 @@ $bg-main: #f9fafb;
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 24px; padding: 24px;
background: rgba(249, 250, 251, 0.3); background: rgb(var(--app-surface-soft-rgb, 249 250 251) / 0.3);
&::-webkit-scrollbar { &::-webkit-scrollbar {
width: 6px; width: 6px;
@@ -172,8 +172,8 @@ $bg-main: #f9fafb;
gap: 16px; gap: 16px;
.log-card { .log-card {
background: #ffffff; background: var(--app-surface, #ffffff);
border: 1px solid rgba(0, 0, 0, 0.06); border: 1px solid rgb(var(--app-border-control-rgb, 0 0 0) / 0.06);
border-radius: 12px; border-radius: 12px;
padding: 16px 20px; padding: 16px 20px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.01), 0 2px 4px -1px rgba(0, 0, 0, 0.006); box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.01), 0 2px 4px -1px rgba(0, 0, 0, 0.006);
@@ -214,24 +214,24 @@ $bg-main: #f9fafb;
font-family: monospace; font-family: monospace;
&.llm_stream { &.llm_stream {
background: #e0f2fe; background: var(--app-info-soft, #e0f2fe);
color: #0369a1; color: var(--app-info-text, #0369a1);
} }
&.trace_recorded { &.trace_recorded {
background: #f3e8ff; background: var(--app-exploration-soft, #f3e8ff);
color: #6b21a8; color: var(--app-exploration-text, #6b21a8);
} }
&.step_recorded { &.step_recorded {
background: #ecfdf5; background: var(--app-success-soft, #ecfdf5);
color: #047857; color: var(--app-success-text, #047857);
} }
&.step_updated { &.step_updated {
background: #fef3c7; background: var(--app-warning-soft, #fef3c7);
color: #b45309; color: var(--app-warning-text, #b45309);
} }
&.background_tasks_updated { &.background_tasks_updated {
background: #fff1f2; background: var(--app-error-soft, #fff1f2);
color: #be123c; color: var(--app-error-text, #be123c);
} }
} }
} }
@@ -260,28 +260,28 @@ $bg-main: #f9fafb;
} }
.summary-bubble { .summary-bubble {
background: #f1f5f9; background: var(--app-surface-soft, #f1f5f9);
border: 1px solid rgba(0, 0, 0, 0.05); border: 1px solid rgb(var(--app-border-control-rgb, 0 0 0) / 0.05);
border-left: 4px solid #475569; border-left: 4px solid var(--app-border-control, #475569);
border-radius: 8px; border-radius: 8px;
padding: 12px 16px; padding: 12px 16px;
font-size: 13.5px; font-size: 13.5px;
line-height: 1.5; line-height: 1.5;
color: #1e293b; color: var(--app-text-control, #1e293b);
} }
} }
details { details {
border: 1px solid rgba(0, 0, 0, 0.06); border: 1px solid rgb(var(--app-border-control-rgb, 0 0 0) / 0.06);
border-radius: 8px; border-radius: 8px;
background: #fafafa; background: var(--app-surface-soft, #fafafa);
overflow: hidden; overflow: hidden;
transition: border-color 0.2s; transition: border-color 0.2s;
&[open] { &[open] {
border-color: rgba(0, 0, 0, 0.12); border-color: rgb(var(--app-border-control-rgb, 0 0 0) / 0.12);
.details-summary { .details-summary {
border-bottom: 1px solid rgba(0, 0, 0, 0.06); border-bottom: 1px solid rgb(var(--app-border-control-rgb, 0 0 0) / 0.06);
background: rgba(0, 0, 0, 0.02); background: rgba(0, 0, 0, 0.02);
} }
} }
@@ -294,7 +294,7 @@ $bg-main: #f9fafb;
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
outline: none; outline: none;
background: #ffffff; background: var(--app-surface, #ffffff);
transition: background 0.2s; transition: background 0.2s;
&:hover { &:hover {
@@ -306,18 +306,18 @@ $bg-main: #f9fafb;
padding: 14px; padding: 14px;
font-size: 13px; font-size: 13px;
line-height: 1.6; line-height: 1.6;
color: #334155; color: var(--app-text-control, #334155);
&.thinking-text { &.thinking-text {
background: #f0fdf4; background: var(--app-success-soft, #f0fdf4);
color: #14532d; color: var(--app-success-text, #14532d);
white-space: pre-wrap; white-space: pre-wrap;
font-family: inherit; font-family: inherit;
} }
&.planning-text { &.planning-text {
background: #f8fafc; background: var(--app-surface-soft, #f8fafc);
color: #334155; color: var(--app-text-control, #334155);
white-space: pre-wrap; white-space: pre-wrap;
font-family: inherit; font-family: inherit;
} }
@@ -326,28 +326,28 @@ $bg-main: #f9fafb;
&.thinking-details { &.thinking-details {
border-left: 4px solid #16a34a; border-left: 4px solid #16a34a;
.details-summary { .details-summary {
color: #16a34a; color: var(--app-success-text, #16a34a);
} }
} }
&.planning-details { &.planning-details {
border-left: 4px solid #4f46e5; border-left: 4px solid #4f46e5;
.details-summary { .details-summary {
color: #4f46e5; color: var(--app-exploration-text, #4f46e5);
} }
} }
&.tool-details { &.tool-details {
border-left: 4px solid #2563eb; border-left: 4px solid #2563eb;
.details-summary { .details-summary {
color: #2563eb; color: var(--app-primary-text, #2563eb);
} }
} }
&.result-details { &.result-details {
border-left: 4px solid #0891b2; border-left: 4px solid #0891b2;
.details-summary { .details-summary {
color: #0891b2; color: var(--app-info-text, #0891b2);
} }
} }
} }
@@ -355,7 +355,7 @@ $bg-main: #f9fafb;
&.raw-body { &.raw-body {
details { details {
border: 1px solid rgba(0, 0, 0, 0.06); border: 1px solid rgb(var(--app-border-control-rgb, 0 0 0) / 0.06);
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
@@ -366,7 +366,7 @@ $bg-main: #f9fafb;
color: $text-secondary; color: $text-secondary;
cursor: pointer; cursor: pointer;
outline: none; outline: none;
background: #ffffff; background: var(--app-surface, #ffffff);
} }
.details-content { .details-content {
@@ -392,11 +392,11 @@ $bg-main: #f9fafb;
height: 4px; height: 4px;
} }
&::-webkit-scrollbar-thumb { &::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2); background: rgb(var(--app-surface-rgb, 255 255 255) / 0.2);
border-radius: 4px; border-radius: 4px;
} }
&::-webkit-scrollbar-thumb:hover { &::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3); background: rgb(var(--app-surface-rgb, 255 255 255) / 0.3);
} }
} }
} }
@@ -47,7 +47,7 @@
@if (review.last_failure_category) { <dt>最近错误类别</dt><dd>{{ review.last_failure_category }}</dd> } @if (review.last_failure_category) { <dt>最近错误类别</dt><dd>{{ review.last_failure_category }}</dd> }
@if (review.status === 'budget_exhausted') { <dt>结束原因</dt><dd class="error">取证、模型请求与重试已耗尽验收总预算;未确认通过。</dd> } @if (review.status === 'budget_exhausted') { <dt>结束原因</dt><dd class="error">取证、模型请求与重试已耗尽验收总预算;未确认通过。</dd> }
@if (review.status === 'request_retries_exhausted') { <dt>结束原因</dt><dd class="error">验收模型请求重试已用尽;未确认通过,没有重跑设备操作。</dd> } @if (review.status === 'request_retries_exhausted') { <dt>结束原因</dt><dd class="error">验收模型请求重试已用尽;未确认通过,没有重跑设备操作。</dd> }
@if (review.status === 'cancelled') { <dt>结束原因</dt><dd>验收被停止或指导中断,不作为请求超时重试。</dd> } @if (review.status === 'cancelled') { <dt>结束原因</dt><dd>{{ cancellationReason(review.cancel_reason) }}</dd> }
</dl> </dl>
</details> </details>
} }
@@ -57,7 +57,12 @@
<dl> <dl>
<dt>本地请求编号</dt><dd>{{ request.request_id }}</dd> <dt>本地请求编号</dt><dd>{{ request.request_id }}</dd>
<dt>本次尝试编号</dt><dd>{{ request.attempt_id }}</dd> <dt>本次尝试编号</dt><dd>{{ request.attempt_id }}</dd>
@if (request.previous_attempt_id) { <dt>上次尝试编号</dt><dd>{{ request.previous_attempt_id }}</dd> }
<dt>出站请求编号</dt><dd>{{ request.client_request_id || '未记录' }}</dd>
<dt>服务端请求编号</dt><dd>{{ request.server_request_id || '服务未提供' }}</dd> <dt>服务端请求编号</dt><dd>{{ request.server_request_id || '服务未提供' }}</dd>
<dt>响应编号</dt><dd>{{ request.response_id || '服务未提供' }}</dd>
@if (request.server_trace_id) { <dt>服务端链路编号</dt><dd>{{ request.server_trace_id }}</dd> }
@if (request.traceparent) { <dt>链路上下文</dt><dd>{{ request.traceparent }}</dd> }
<dt>模型配置</dt><dd>{{ request.model || '未记录' }} / {{ request.reasoning_effort || '默认' }} · 第 {{ request.attempt }} 次请求,最多重试 {{ request.max_retries }} 次</dd> <dt>模型配置</dt><dd>{{ request.model || '未记录' }} / {{ request.reasoning_effort || '默认' }} · 第 {{ request.attempt }} 次请求,最多重试 {{ request.max_retries }} 次</dd>
@if (request.final_review; as review) { <dt>所属最终验收</dt><dd>{{ review.review_id }} · {{ reviewStage(review) }} · 请求记录时剩余 {{ review.remaining_seconds }} 秒</dd> } @if (request.final_review; as review) { <dt>所属最终验收</dt><dd>{{ review.review_id }} · {{ reviewStage(review) }} · 请求记录时剩余 {{ review.remaining_seconds }} 秒</dd> }
<dt>输出 Token 上限</dt><dd>{{ request.max_output_tokens || '未设置(由模型服务决定)' }}</dd> <dt>输出 Token 上限</dt><dd>{{ request.max_output_tokens || '未设置(由模型服务决定)' }}</dd>
@@ -68,6 +73,8 @@
<dt>首个有效输出</dt><dd>{{ request.first_output_at ? (request.first_output_at * 1000 | date:'HH:mm:ss.SSS') : '未收到/未记录' }}</dd> <dt>首个有效输出</dt><dd>{{ request.first_output_at ? (request.first_output_at * 1000 | date:'HH:mm:ss.SSS') : '未收到/未记录' }}</dd>
<dt>最后有效输出</dt><dd>{{ request.last_output_at ? (request.last_output_at * 1000 | date:'HH:mm:ss.SSS') : '未收到/未记录' }}</dd> <dt>最后有效输出</dt><dd>{{ request.last_output_at ? (request.last_output_at * 1000 | date:'HH:mm:ss.SSS') : '未收到/未记录' }}</dd>
<dt>结束时间</dt><dd>{{ request.ended_at ? (request.ended_at * 1000 | date:'HH:mm:ss.SSS') : '尚未记录' }}</dd> <dt>结束时间</dt><dd>{{ request.ended_at ? (request.ended_at * 1000 | date:'HH:mm:ss.SSS') : '尚未记录' }}</dd>
@if (termination(request); as reason) { <dt>结束原因</dt><dd>{{ reason }}</dd> }
@if (request.cancel_phase) { <dt>中断阶段</dt><dd>{{ phase(request.cancel_phase) }}</dd> }
<dt>输入图片</dt><dd>{{ request.images.length }} 张 @for (image of request.images; track $index) { <span class="image-size">{{ image.width && image.height ? image.width + ' × ' + image.height : '尺寸未知' }}</span> }</dd> <dt>输入图片</dt><dd>{{ request.images.length }} 张 @for (image of request.images; track $index) { <span class="image-size">{{ image.width && image.height ? image.width + ' × ' + image.height : '尺寸未知' }}</span> }</dd>
<dt>Token 用量</dt><dd>{{ request.usage ? '输入 ' + request.usage.prompt_tokens + ' / 输出 ' + request.usage.completion_tokens + ' / 合计 ' + request.usage.total_tokens : '未知(服务未报告)' }}</dd> <dt>Token 用量</dt><dd>{{ request.usage ? '输入 ' + request.usage.prompt_tokens + ' / 输出 ' + request.usage.completion_tokens + ' / 合计 ' + request.usage.total_tokens : '未知(服务未报告)' }}</dd>
@if (request.error) { <dt>失败原因</dt><dd class="error">{{ request.error_type }} · {{ request.error }} · 阶段 {{ phase(request.failure_phase || '') }} @if (request.category) { · {{ request.category }} }</dd> } @if (request.error) { <dt>失败原因</dt><dd class="error">{{ request.error_type }} · {{ request.error }} · 阶段 {{ phase(request.failure_phase || '') }} @if (request.category) { · {{ request.category }} }</dd> }
@@ -1,26 +1,26 @@
:host { display: block; } :host { display: block; }
.model-requests { margin: 10px 0 16px; color: #475569; font-size: 12px; } .model-requests { margin: 10px 0 16px; color: var(--app-text-secondary, #475569); font-size: 12px; }
.request-live { padding: 12px 14px; border: 1px solid #dbeafe; border-radius: 10px; background: #f8fbff; margin-bottom: 8px; } .request-live { padding: 12px 14px; border: 1px solid var(--app-primary-border, #dbeafe); border-radius: 10px; background: var(--app-primary-soft, #f8fbff); margin-bottom: 8px; }
.request-heading { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; } .request-heading { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
strong { color: #334155; font-size: 13px; } strong { color: var(--app-text-control, #334155); font-size: 13px; }
.model { color: #64748b; overflow-wrap: anywhere; } .model { color: var(--app-text-muted, #64748b); overflow-wrap: anywhere; }
.elapsed { margin-left: auto; color: #1d4ed8; font-variant-numeric: tabular-nums; } .elapsed { margin-left: auto; color: var(--app-primary-text, #1d4ed8); font-variant-numeric: tabular-nums; }
.status-dot { height: 7px; width: 7px; border-radius: 50%; background: #64748b; flex: 0 0 auto; } .status-dot { height: 7px; width: 7px; border-radius: 50%; background: #64748b; flex: 0 0 auto; }
.waiting { background: #3b82f6; animation: pulse 1.5s ease-in-out infinite; } .waiting { background: #3b82f6; animation: pulse 1.5s ease-in-out infinite; }
.retrying { border-color: #fde68a; background: #fffbeb; .elapsed { color: #92400e; } .waiting { background: #d97706; } } .retrying { border-color: var(--app-warning-border, #fde68a); background: var(--app-warning-soft, #fffbeb); .elapsed { color: var(--app-warning-text, #92400e); } .waiting { background: #d97706; } }
.stale { border-color: #cbd5e1; background: #f8fafc; } .stale { border-color: var(--app-border-control, #cbd5e1); background: var(--app-surface-soft, #f8fafc); }
p { margin: 6px 0 0; line-height: 1.7; } p { margin: 6px 0 0; line-height: 1.7; }
.error { color: #b45309; overflow-wrap: anywhere; } .error { color: var(--app-warning-text, #b45309); overflow-wrap: anywhere; }
summary { cursor: pointer; padding: 5px 0; line-height: 1.7; } summary:hover { color: #2563eb; } summary { cursor: pointer; padding: 5px 0; line-height: 1.7; } summary:hover { color: var(--app-primary-text, #2563eb); }
summary:focus-visible, a:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; } summary:focus-visible, a:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; }
.diagnostics-header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; margin: 8px 0; color: #64748b; } .diagnostics-header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; margin: 8px 0; color: var(--app-text-muted, #64748b); }
a { color: #2563eb; text-decoration: none; border: 1px solid #dbeafe; border-radius: 6px; padding: 4px 9px; } a { color: var(--app-primary-text, #2563eb); text-decoration: none; border: 1px solid var(--app-primary-border, #dbeafe); border-radius: 6px; padding: 4px 9px; }
.request-history { max-height: 360px; overflow-y: auto; } .request-history { max-height: 360px; overflow-y: auto; }
.request-detail { border-top: 1px solid #e2e8f0; padding: 4px 0; } .request-detail { border-top: 1px solid var(--app-border, #e2e8f0); padding: 4px 0; }
dl { display: grid; grid-template-columns: 110px minmax(0, 1fr); gap: 7px 10px; margin: 8px 0 12px; line-height: 1.6; } dl { display: grid; grid-template-columns: 110px minmax(0, 1fr); gap: 7px 10px; margin: 8px 0 12px; line-height: 1.6; }
dt { color: #64748b; } dd { margin: 0; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; } dt { color: var(--app-text-muted, #64748b); } dd { margin: 0; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
.image-size, .fallback { display: inline-block; padding: 0 5px; margin: 2px 3px; background: #f1f5f9; border-radius: 4px; } .image-size, .fallback { display: inline-block; padding: 0 5px; margin: 2px 3px; background: var(--app-surface-soft, #f1f5f9); border-radius: 4px; }
.notice { color: #64748b; } .notice { color: var(--app-text-muted, #64748b); }
@keyframes pulse { 50% { opacity: .35; } } @keyframes pulse { 50% { opacity: .35; } }
@media (prefers-reduced-motion: reduce) { .waiting { animation: none; } } @media (prefers-reduced-motion: reduce) { .waiting { animation: none; } }
@media (max-width: 480px) { dl { grid-template-columns: 90px minmax(0, 1fr); } .elapsed { margin-left: 15px; } } @media (max-width: 480px) { dl { grid-template-columns: 90px minmax(0, 1fr); } .elapsed { margin-left: 15px; } }
@@ -1,7 +1,7 @@
import { fakeAsync, TestBed, tick } from '@angular/core/testing'; import { fakeAsync, TestBed, tick } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http'; import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { FinalReview, LlmRequestStatusComponent, ModelRequest, requestHint, reviewStage } from './llm-request-status.component'; import { cancellationReason, FinalReview, LlmRequestStatusComponent, ModelRequest, requestHint, requestTermination, reviewStage } from './llm-request-status.component';
const waiting: ModelRequest = { const waiting: ModelRequest = {
request_id: 'call-1', attempt_id: 'attempt-1', model: 'gpt-6-luna', role: 'FlashRunner', request_id: 'call-1', attempt_id: 'attempt-1', model: 'gpt-6-luna', role: 'FlashRunner',
@@ -118,4 +118,42 @@ describe('模型请求诊断', () => {
expect(reviewStage({ ...review, status: 'cancelled' })).toBe('验收已中断'); expect(reviewStage({ ...review, status: 'cancelled' })).toBe('验收已中断');
expect(reviewStage({ ...review, status: 'completed' })).toBe('验收完成'); expect(reviewStage({ ...review, status: 'completed' })).toBe('验收完成');
}); });
it('取消原因有来源才显示,旧记录与未知字段保留未知', () => {
expect(cancellationReason('user_stop')).toContain('用户停止');
expect(cancellationReason('guidance_replaced')).toContain('新指导替换');
expect(cancellationReason('task_paused')).toContain('任务暂停');
expect(cancellationReason('task_replaced')).toContain('新任务替换');
expect(cancellationReason('superseded')).toContain('新的检查或计划');
expect(cancellationReason()).toContain('取消来源未记录');
expect(cancellationReason('untrusted new instruction')).toContain('取消来源未记录');
expect(requestTermination({ ...waiting, phase: 'cancelled' })).toContain('取消来源未记录');
});
it('区分三层超时、未完成输出与未确认来源的499', () => {
expect(requestTermination({ ...waiting, phase: 'failed', timeout_scope: 'request' })).toContain('单次模型请求');
expect(requestTermination({ ...waiting, phase: 'failed', timeout_scope: 'transport' })).toContain('连接或读写超时');
expect(requestTermination({ ...waiting, phase: 'failed', timeout_scope: 'final_review' })).toContain('最终验收总预算');
expect(requestTermination({ ...waiting, phase: 'cancelled', cancel_reason: 'outer_timeout', timeout_scope: 'outer' })).toContain('外层等待时限');
expect(requestTermination({ ...waiting, phase: 'failed', http_status: 499 })).toContain('来源尚未确认');
expect(requestTermination({ ...waiting, phase: 'failed', category: 'connection' })).toContain('来源需结合服务端记录');
expect(requestTermination({ ...waiting, phase: 'failed', response_guard: { reason: 'missing_completion' } })).toContain('本次输出未被采用');
expect(requestTermination({ ...waiting, phase: 'completed' })).toBe('');
});
it('将本次尝试、上次重试、链路编号与结束原因一起展示', () => {
const fixture = TestBed.createComponent(LlmRequestStatusComponent);
fixture.componentRef.setInput('sessionId', 'cancelled'); fixture.detectChanges();
TestBed.inject(HttpTestingController).expectOne('/api/sessions/cancelled/request-diagnostics').flush({ requests: [{
...waiting, active: false, phase: 'cancelled', cancel_reason: 'guidance_replaced',
cancel_phase: 'retry_wait', previous_attempt_id: 'attempt-before', client_request_id: 'client-wire-id',
server_request_id: 'server-wire-id', response_id: 'resp-from-server', server_trace_id: 'gateway-trace',
traceparent: '00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01', ended_at: Date.now() / 1000
}] });
fixture.detectChanges();
const text = fixture.nativeElement.textContent;
for (const value of ['attempt-before', 'client-wire-id', 'server-wire-id', 'resp-from-server', 'gateway-trace', '新指导替换了旧请求', '中断阶段', '等待重试']) expect(text).toContain(value);
expect(fixture.nativeElement.querySelector('.request-live')).toBeNull();
fixture.destroy();
});
}); });
@@ -9,6 +9,10 @@ export interface ModelRequest {
attempt: number; max_retries: number; timeout_seconds: number; elapsed_seconds: number; attempt: number; max_retries: number; timeout_seconds: number; elapsed_seconds: number;
started_at: number; headers_at?: number; first_output_at?: number; last_output_at?: number; started_at: number; headers_at?: number; first_output_at?: number; last_output_at?: number;
ended_at?: number; retry_at?: number; http_status?: number; server_request_id?: string; ended_at?: number; retry_at?: number; http_status?: number; server_request_id?: string;
client_request_id?: string | null; previous_attempt_id?: string | null; response_id?: string;
server_trace_id?: string | null; traceparent?: string;
cancel_reason?: string; cancel_phase?: string; timeout_scope?: string;
response_guard?: { reason: string; status?: string };
error?: string; error_type?: string; failure_phase?: string; error?: string; error_type?: string; failure_phase?: string;
output_chunks?: number; tool_argument_chars?: number; max_output_tokens?: number | null; output_chunks?: number; tool_argument_chars?: number; max_output_tokens?: number | null;
tools?: { name: string | null; argument_chars: number }[]; tools?: { name: string | null; argument_chars: number }[];
@@ -24,6 +28,29 @@ export interface FinalReview {
total_seconds: number; elapsed_seconds: number; remaining_seconds: number; total_seconds: number; elapsed_seconds: number; remaining_seconds: number;
request_count: number; retry_count: number; request_attempt?: number; max_retries: number; request_count: number; retry_count: number; request_attempt?: number; max_retries: number;
request_phase?: string; last_failure_category?: string; request_phase?: string; last_failure_category?: string;
cancel_reason?: string;
}
export function cancellationReason(reason?: string): string {
return ({
user_stop: '用户停止了任务。',
guidance_replaced: '新指导替换了旧请求,任务可按新指导继续。',
task_paused: '任务暂停,已中断当前请求。',
task_replaced: '新任务替换了当前任务。',
superseded: '新的检查或计划替换了当前请求。',
outer_timeout: '外层等待时限已到,取消了当前请求。'
} as Record<string, string>)[reason || ''] || '取消来源未记录,无法确定是否由用户停止、指导或外层时限触发。';
}
export function requestTermination(request: ModelRequest): string {
if (request.phase === 'cancelled') return cancellationReason(request.cancel_reason);
if (request.timeout_scope === 'final_review') return '最终验收总预算已用尽,本次请求已中断。';
if (request.timeout_scope === 'request') return '单次模型请求时限已到。';
if (request.timeout_scope === 'transport') return '模型连接或读写超时。';
if (request.http_status === 499) return '服务返回 HTTP 499;来源尚未确认,需对照本地记录和服务端日志。';
if (request.response_guard) return '模型响应未完整结束或工具参数无效,本次输出未被采用。';
if (request.category === 'connection') return '模型连接中断或不可用,来源需结合服务端记录核对。';
return '';
} }
export function reviewStage(review: FinalReview): string { export function reviewStage(review: FinalReview): string {
@@ -73,6 +100,8 @@ export class LlmRequestStatusComponent {
phase = requestPhase; phase = requestPhase;
hint = requestHint; hint = requestHint;
reviewStage = reviewStage; reviewStage = reviewStage;
termination = requestTermination;
cancellationReason = cancellationReason;
constructor() { constructor() {
effect(() => this.activeChange.emit(this.active().length > 0 || this.activeReviews().length > 0)); effect(() => this.activeChange.emit(this.active().length > 0 || this.activeReviews().length > 0));
@@ -24,19 +24,19 @@ let nextId = 0;
} }
`, `,
styles: [` styles: [`
:host { display: grid; gap: 7px; min-width: 0; color: #334155; font-size: 13px; } :host { display: grid; gap: 7px; min-width: 0; color: var(--app-text-control, #334155); font-size: 13px; }
label { font-weight: 600; line-height: 1.5; } label { font-weight: 600; line-height: 1.5; }
.selector { position: relative; min-width: 0; } .selector { position: relative; min-width: 0; }
select, input { box-sizing: border-box; width: 100%; min-width: 0; min-height: 40px; select, input { box-sizing: border-box; width: 100%; min-width: 0; min-height: 40px;
padding: 10px 12px; border: 1px solid #dbe3ec; border-radius: 10px; background: #fff; padding: 10px 12px; border: 1px solid var(--app-border, #dbe3ec); border-radius: 10px; background: var(--app-surface, #fff);
color: inherit; font: inherit; } color: inherit; font: inherit; }
select { appearance: none; padding-right: 36px; cursor: pointer; text-overflow: ellipsis; } select { appearance: none; padding-right: 36px; cursor: pointer; text-overflow: ellipsis; }
.selector > span { position: absolute; right: 11px; top: 50%; transform: translateY(-50%); .selector > span { position: absolute; right: 11px; top: 50%; transform: translateY(-50%);
pointer-events: none; font-size: 19px; color: #64748b; } pointer-events: none; font-size: 19px; color: var(--app-text-muted, #64748b); }
select:hover:not(:disabled), input:hover:not(:disabled) { border-color: #a5b4fc; } select:hover:not(:disabled), input:hover:not(:disabled) { border-color: var(--app-primary-border, #a5b4fc); }
select:focus-visible, input:focus-visible { outline: 2px solid #a5b4fc; outline-offset: 2px; } select:focus-visible, input:focus-visible { outline: 2px solid var(--app-primary-border, #a5b4fc); outline-offset: 2px; }
select:disabled, input:disabled { opacity: .5; cursor: default; } select:disabled, input:disabled { opacity: .5; cursor: default; }
option { background: #fff; color: #334155; } option { background: var(--app-surface, #fff); color: var(--app-text-control, #334155); }
`] `]
}) })
export class ModelSelectorComponent { export class ModelSelectorComponent {
@@ -12,6 +12,7 @@ interface Capabilities {
video_enabled: boolean; video_enabled: boolean;
recording_enabled: boolean; recording_enabled: boolean;
outputter_enabled: boolean; outputter_enabled: boolean;
flash_final_check_enabled: boolean;
step_summarizer_enabled: boolean; step_summarizer_enabled: boolean;
screenshot_limit_enabled: boolean; screenshot_limit_enabled: boolean;
screenshot_max_long_edge: number; screenshot_max_long_edge: number;
@@ -28,7 +29,7 @@ interface Capabilities {
template: ` template: `
<section aria-label="功能开关"> <section aria-label="功能开关">
<h3>功能开关</h3> <h3>功能开关</h3>
<p>按需启用,保存后对新任务生效。</p> <p>Flash 最终 AI 判定切换后自动保存;其余设置点击底部按钮保存。保存后对尚未开始的任务生效,不影响运行中的任务。</p>
<label><input type="checkbox" role="switch" [(ngModel)]="config.adb_auto_connect" [disabled]="busy() || loading() || !loaded()" /> <label><input type="checkbox" role="switch" [(ngModel)]="config.adb_auto_connect" [disabled]="busy() || loading() || !loaded()" />
<span>自动连接设备<small>刷新设备时发现本机常见模拟器,并重连手动连接过的 TCP 设备;重启 ADB 后也会重连。不扫描局域网,不自动选择任务设备。保存后立即生效。</small></span></label> <span>自动连接设备<small>刷新设备时发现本机常见模拟器,并重连手动连接过的 TCP 设备;重启 ADB 后也会重连。不扫描局域网,不自动选择任务设备。保存后立即生效。</small></span></label>
<label><input type="checkbox" role="switch" [(ngModel)]="config.video_enabled" [disabled]="busy() || loading() || !loaded()" /> <label><input type="checkbox" role="switch" [(ngModel)]="config.video_enabled" [disabled]="busy() || loading() || !loaded()" />
@@ -51,6 +52,8 @@ interface Capabilities {
} }
<label><input type="checkbox" role="switch" [(ngModel)]="config.outputter_enabled" [disabled]="busy() || loading() || !loaded()" /> <label><input type="checkbox" role="switch" [(ngModel)]="config.outputter_enabled" [disabled]="busy() || loading() || !loaded()" />
<span>最终报告生成<small>根据执行记录生成任务结果报告。</small></span></label> <span>最终报告生成<small>根据执行记录生成任务结果报告。</small></span></label>
<label><input type="checkbox" role="switch" aria-label="Flash 最终 AI 判定与分析" [ngModel]="config.flash_final_check_enabled" (ngModelChange)="saveFlashFinalCheck($event)" [disabled]="busy() || loading() || !loaded()" />
<span>Flash 最终 AI 判定与分析<small>默认关闭。开启后,在 Flash 执行结束时根据用例需求和执行步骤复核结果,会增加耗时。关闭时显示“执行完成 · 未做 AI 判定”,仍保留步骤和报告。不影响 Pro。</small></span></label>
<label><input type="checkbox" role="switch" [(ngModel)]="config.step_summarizer_enabled" [disabled]="busy() || loading() || !loaded()" /> <label><input type="checkbox" role="switch" [(ngModel)]="config.step_summarizer_enabled" [disabled]="busy() || loading() || !loaded()" />
<span>步骤摘要压缩<small>Flash 模式下在后台生成摘要,减少长任务的上下文占用。</small></span></label> <span>步骤摘要压缩<small>Flash 模式下在后台生成摘要,减少长任务的上下文占用。</small></span></label>
<label><input type="checkbox" role="switch" [(ngModel)]="config.screenshot_limit_enabled" [disabled]="busy() || loading() || !loaded()" /> <label><input type="checkbox" role="switch" [(ngModel)]="config.screenshot_limit_enabled" [disabled]="busy() || loading() || !loaded()" />
@@ -74,33 +77,55 @@ interface Capabilities {
@if (error()) { <p role="alert" class="error">{{ error() }}</p> } @if (error()) { <p role="alert" class="error">{{ error() }}</p> }
</section> </section>
`, `,
styles: [`section { margin-top: 24px; padding-top: 20px; border-top: 1px solid #64748b55; } styles: [`section { margin-top: 24px; padding-top: 20px; border-top: 1px solid rgb(var(--app-border-control-rgb, 100 116 139) / 0.3333); }
h3 { margin: 0 0 8px; } p, small { color: #64748b; line-height: 1.6; } h3 { margin: 0 0 8px; } p, small { color: var(--app-text-muted, #64748b); line-height: 1.6; }
label { display: flex; gap: 12px; align-items: flex-start; padding: 14px 0; cursor: pointer; } label { display: flex; gap: 12px; align-items: flex-start; padding: 14px 0; cursor: pointer; }
input { width: 20px; height: 20px; accent-color: #0284c7; flex-shrink: 0; } input { width: 20px; height: 20px; accent-color: var(--app-info-text, #0284c7); flex-shrink: 0; }
small { display: block; margin-top: 4px; } button { padding: 10px 18px; border-radius: 10px; border: 1px solid #38bdf877; background: #0284c7; color: white; font: inherit; cursor: pointer; } small { display: block; margin-top: 4px; } button { padding: 10px 18px; border-radius: 10px; border: 1px solid #38bdf877; background: #0284c7; color: white; font: inherit; cursor: pointer; }
button:disabled { opacity: .5; cursor: default; } .error { color: #be123c; } button:disabled { opacity: .5; cursor: default; } .error { color: var(--app-error-text, #be123c); }
.execution-settings { margin: 12px 0 20px; padding: 12px 14px; border: 1px solid #cbd5e1; border-radius: 10px; background: #f8fafc; } .execution-settings { margin: 12px 0 20px; padding: 12px 14px; border: 1px solid var(--app-border-control, #cbd5e1); border-radius: 10px; background: var(--app-surface-soft, #f8fafc); }
summary { cursor: pointer; font-weight: 600; color: #334155; } summary { cursor: pointer; font-weight: 600; color: var(--app-text-control, #334155); }
.number-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 16px; } .number-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 16px; }
.number-fields label { display: block; padding: 0; color: #334155; } .number-fields label { display: block; padding: 0; color: var(--app-text-control, #334155); }
.number-fields input, .number-fields select { box-sizing: border-box; display: block; width: 100%; height: auto; margin-top: 8px; padding: 9px; border: 1px solid #cbd5e1; border-radius: 8px; font: inherit; } .number-fields input, .number-fields select { box-sizing: border-box; display: block; width: 100%; height: auto; margin-top: 8px; padding: 9px; border: 1px solid var(--app-border-control, #cbd5e1); border-radius: 8px; font: inherit; }
section {font-size:13px;border-color:#e2e8f0}h3 {font-size:15px;color:#334155} section {font-size:13px;border-color:var(--app-border, #e2e8f0)}h3 {font-size:15px;color:var(--app-text-control, #334155)}
section > label {align-items:center;flex-direction:row-reverse;justify-content:space-between;border-bottom:1px solid #f1f5f9;gap:20px}section > label > span {flex:1}small {font-size:12px} section > label {align-items:center;flex-direction:row-reverse;justify-content:space-between;border-bottom:1px solid var(--app-border, #f1f5f9);gap:20px}section > label > span {flex:1}small {font-size:12px}
input[role=switch] {appearance:none;position:relative;width:36px;height:22px;border:1px solid #cbd5e1;border-radius:20px;background:#e2e8f0;cursor:pointer;transition:background .15s} input[role=switch] {appearance:none;position:relative;width:36px;height:22px;border:1px solid var(--app-border-control, #cbd5e1);border-radius:20px;background:var(--app-surface-raised, #e2e8f0);cursor:pointer;transition:background .15s}
input[role=switch]::after {content:'';position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:#fff;box-shadow:0 1px 3px #0f172a25;transition:transform .15s} input[role=switch]::after {content:'';position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:var(--app-surface, #fff);box-shadow:0 1px 3px #0f172a25;transition:transform .15s}
input[role=switch]:checked {background:#6366f1;border-color:#6366f1}input[role=switch]:checked::after {transform:translateX(14px)}input[role=switch]:focus-visible {outline:2px solid #a5b4fc;outline-offset:3px}input:disabled {opacity:.5;cursor:default} input[role=switch]:checked {background:#6366f1;border-color:#6366f1}input[role=switch]:checked::after {transform:translateX(14px)}input[role=switch]:focus-visible {outline:2px solid var(--app-primary-border, #a5b4fc);outline-offset:3px}input:disabled {opacity:.5;cursor:default}
button {background:#4f46e5;border-color:#4f46e5;border-radius:12px;font-weight:600}.execution-settings {border-color:#e2e8f0;border-radius:14px;padding:14px 16px}.number-fields input,.number-fields select {background:#fff;border-color:#dbe3ec} button {background:#4f46e5;border-color:#4f46e5;border-radius:12px;font-weight:600}.execution-settings {border-color:var(--app-border, #e2e8f0);border-radius:14px;padding:14px 16px}.number-fields input,.number-fields select {background:var(--app-surface, #fff);border-color:var(--app-border, #dbe3ec)}
@media(max-width: 480px) { .number-fields { grid-template-columns: 1fr; } }`] @media(max-width: 480px) { .number-fields { grid-template-columns: 1fr; } }`]
}) })
export class CapabilitySettingsComponent implements OnInit { export class CapabilitySettingsComponent implements OnInit {
changed = output<Capabilities>(); changed = output<Capabilities>();
private http = inject(HttpClient); private http = inject(HttpClient);
config: Capabilities = { adb_auto_connect: true, adb_discovery_interval_seconds: 10, adb_discovery_timeout_seconds: 5, video_enabled: false, recording_enabled: false, outputter_enabled: true, step_summarizer_enabled: true, screenshot_limit_enabled: true, screenshot_max_long_edge: 2048, task_timeout_seconds: 1800, observation_timeout_seconds: 60, observation_max_attempts: 3, device_command_timeout_seconds: 10, action_failure_attempts: 3, worker_cleanup_timeout_seconds: 30 }; private savedConfig: Capabilities | null = null;
config: Capabilities = { adb_auto_connect: true, adb_discovery_interval_seconds: 10, adb_discovery_timeout_seconds: 5, video_enabled: false, recording_enabled: false, outputter_enabled: true, flash_final_check_enabled: false, step_summarizer_enabled: true, screenshot_limit_enabled: true, screenshot_max_long_edge: 2048, task_timeout_seconds: 1800, observation_timeout_seconds: 60, observation_max_attempts: 3, device_command_timeout_seconds: 10, action_failure_attempts: 3, worker_cleanup_timeout_seconds: 30 };
busy = signal(false); loading = signal(true); loaded = signal(false); message = signal(''); error = signal(''); busy = signal(false); loading = signal(true); loaded = signal(false); message = signal(''); error = signal('');
ngOnInit(): void { ngOnInit(): void {
this.http.get<Capabilities>('/api/system/capability-settings').pipe(timeout(UI_READ_TIMEOUT_MS), finalize(() => this.loading.set(false))).subscribe({ this.http.get<Capabilities>('/api/system/capability-settings').pipe(timeout(UI_READ_TIMEOUT_MS), finalize(() => this.loading.set(false))).subscribe({
next: config => { this.config = config; this.loaded.set(true); this.changed.emit(config); }, error: () => this.error.set('功能配置加载失败,请刷新页面重试。') next: config => { this.savedConfig = structuredClone(config); this.config = config; this.loaded.set(true); this.changed.emit(config); }, error: () => this.error.set('功能配置加载失败,请刷新页面重试。')
});
}
saveFlashFinalCheck(enabled: boolean): void {
if (this.busy() || !this.loaded() || !this.savedConfig) return;
const previous = this.savedConfig.flash_final_check_enabled;
this.config.flash_final_check_enabled = enabled;
this.busy.set(true); this.message.set('正在保存 Flash 判定设置…'); this.error.set('');
// Preserve other settings that are still being edited, including invalid drafts.
const update = { ...this.savedConfig, flash_final_check_enabled: enabled };
this.http.post<Capabilities>('/api/system/capability-settings', update).pipe(finalize(() => this.busy.set(false))).subscribe({
next: config => {
this.savedConfig = structuredClone(config);
this.config.flash_final_check_enabled = config.flash_final_check_enabled;
this.changed.emit(config);
this.message.set(`Flash 最终 AI 判定已${config.flash_final_check_enabled ? '开启' : '关闭'},尚未开始的任务将使用此设置,无需重启服务。`);
},
error: () => {
this.config.flash_final_check_enabled = previous;
this.message.set('');
this.error.set('Flash 判定设置保存失败,开关已恢复。请检查连接后重新切换。');
}
}); });
} }
save(): void { save(): void {
@@ -110,7 +135,7 @@ export class CapabilitySettingsComponent implements OnInit {
} }
this.busy.set(true); this.message.set(''); this.error.set(''); this.busy.set(true); this.message.set(''); this.error.set('');
this.http.post<Capabilities>('/api/system/capability-settings', this.config).pipe(finalize(() => this.busy.set(false))).subscribe({ this.http.post<Capabilities>('/api/system/capability-settings', this.config).pipe(finalize(() => this.busy.set(false))).subscribe({
next: config => { this.config = config; this.changed.emit(config); this.message.set('功能设置已保存,新任务将使用此配置。'); }, next: config => { this.savedConfig = structuredClone(config); this.config = config; this.changed.emit(config); this.message.set('功能设置已保存,新任务将使用此配置。'); },
error: () => this.error.set('保存失败,请检查服务器连接和 .env 文件权限。') error: () => this.error.set('保存失败,请检查服务器连接和 .env 文件权限。')
}); });
} }
@@ -11,7 +11,7 @@ describe('持久化模型与功能设置', () => {
api_key_configured: true, timeout_seconds: 300, max_retries: 5, retry_interval_seconds: 10, reasoning_effort: 'high', api_key_configured: true, timeout_seconds: 300, max_retries: 5, retry_interval_seconds: 10, reasoning_effort: 'high',
rate_limit_retry_seconds: 60, tool_fallback_model: 'gpt-6-luna', tool_fallback_reasoning_effort: 'high', rate_limit_retry_seconds: 60, tool_fallback_model: 'gpt-6-luna', tool_fallback_reasoning_effort: 'high',
tool_fallback_max_retries: 3, tool_fallback_no_progress_attempts: 3, tool_fallback_max_retries: 3, tool_fallback_no_progress_attempts: 3,
model_presets: ['gpt-6-luna', 'gpt-6.1-sol', 'gpt-6-astra', 'mimo-v2.6-pro'], env_path: '/app/.env' }; model_presets: ['gpt-6-luna', 'gpt-6.1-terra', 'gpt-6.1-sol', 'gpt-6-astra', 'mimo-v2.6-pro'], env_path: '/app/.env' };
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] }); TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
http = TestBed.inject(HttpTestingController); http = TestBed.inject(HttpTestingController);
@@ -30,6 +30,31 @@ describe('持久化模型与功能设置', () => {
http.expectNone('/api/system/model-settings'); http.expectNone('/api/system/model-settings');
})); }));
it('缺少协议的配置默认 WebSocket,允许手动切回 HTTP', fakeAsync(() => {
const fixture = TestBed.createComponent(ModelSettingsComponent);
fixture.detectChanges();
http.expectOne('/api/system/model-settings').flush(config);
tick();
fixture.detectChanges();
const component = fixture.componentInstance;
expect(component.transport).toBe('websocket');
const selector: HTMLSelectElement = fixture.nativeElement.querySelector('#model-transport');
selector.value = 'http';
selector.dispatchEvent(new Event('change'));
tick();
fixture.detectChanges();
component.test();
const check = http.expectOne('/api/system/model-settings/test');
expect(check.request.body.transport).toBe('http');
check.flush({message: 'Responses HTTP 连接测试成功'});
component.save();
const save = http.expectOne('/api/system/model-settings');
expect(save.request.body.transport).toBe('http');
expect(save.request.body.api_key).toBeUndefined();
save.flush({...config, transport: 'http'});
expect(component.transport).toBe('http');
}));
it('加载配置后保留密钥,保存新模型且不发送空密钥', () => { it('加载配置后保留密钥,保存新模型且不发送空密钥', () => {
const fixture = TestBed.createComponent(ModelSettingsComponent); const fixture = TestBed.createComponent(ModelSettingsComponent);
fixture.detectChanges(); fixture.detectChanges();
@@ -220,4 +245,60 @@ describe('持久化模型与功能设置', () => {
fixture.componentInstance.save(); fixture.componentInstance.save();
http.expectNone('/api/system/capability-settings'); http.expectNone('/api/system/capability-settings');
}); });
it('Flash 判定切换即保存,失败恢复原值,重新切换后持久生效', async () => {
const fixture = TestBed.createComponent(CapabilitySettingsComponent);
const capabilities = { ...fixture.componentInstance.config };
fixture.detectChanges();
const root: HTMLElement = fixture.nativeElement;
const toggle = root.querySelector<HTMLInputElement>('[aria-label="Flash 最终 AI 判定与分析"]')!;
await Promise.resolve();
expect(toggle.disabled).toBeTrue();
http.expectOne('/api/system/capability-settings').flush(capabilities);
fixture.detectChanges(); await fixture.whenStable();
expect(toggle.checked).toBeFalse();
expect(root.textContent).toContain('不影响 Pro');
toggle.click(); fixture.detectChanges(); await fixture.whenStable();
let request = http.expectOne('/api/system/capability-settings');
expect(request.request.body.flash_final_check_enabled).toBeTrue();
expect(fixture.componentInstance.busy()).toBeTrue();
request.flush({}, {status:500,statusText:'Error'});
fixture.detectChanges();
await fixture.whenStable();
expect(fixture.componentInstance.config.flash_final_check_enabled).toBeFalse();
expect(toggle.checked).toBeFalse();
expect(fixture.componentInstance.error()).toContain('保存失败');
toggle.click(); fixture.detectChanges(); await fixture.whenStable();
request = http.expectOne('/api/system/capability-settings');
const saved = request.request.body;
request.flush(saved);
expect(fixture.componentInstance.message()).toContain('无需重启');
fixture.destroy();
const reopened = TestBed.createComponent(CapabilitySettingsComponent);
reopened.detectChanges();
http.expectOne('/api/system/capability-settings').flush(saved);
reopened.detectChanges(); await reopened.whenStable();
expect(reopened.nativeElement.querySelector('[aria-label="Flash 最终 AI 判定与分析"]').checked).toBeTrue();
reopened.destroy();
});
it('自动保存 Flash 判定不提交其他未保存或无效的草稿', async () => {
const fixture = TestBed.createComponent(CapabilitySettingsComponent);
const capabilities = { ...fixture.componentInstance.config };
fixture.detectChanges();
http.expectOne('/api/system/capability-settings').flush(capabilities);
fixture.detectChanges(); await fixture.whenStable();
fixture.componentInstance.config.task_timeout_seconds = 0;
fixture.componentInstance.config.recording_enabled = true;
const toggle: HTMLInputElement = fixture.nativeElement.querySelector('[aria-label="Flash 最终 AI 判定与分析"]');
toggle.click(); fixture.detectChanges(); await fixture.whenStable();
const request = http.expectOne('/api/system/capability-settings');
expect(request.request.body.flash_final_check_enabled).toBeTrue();
expect(request.request.body.task_timeout_seconds).toBe(1800);
expect(request.request.body.recording_enabled).toBeFalse();
request.flush(request.request.body);
expect(fixture.componentInstance.config.task_timeout_seconds).toBe(0);
expect(fixture.componentInstance.config.recording_enabled).toBeTrue();
fixture.destroy();
});
}); });
@@ -8,6 +8,7 @@ import { ModelSelectorComponent } from '../model-selector/model-selector.compone
import { MODEL_PRESETS } from '../../core/model-presets'; import { MODEL_PRESETS } from '../../core/model-presets';
interface ModelSettings { interface ModelSettings {
transport?: 'http' | 'websocket';
base_url: string; base_url: string;
model: string; model: string;
api_key_configured: boolean; api_key_configured: boolean;
@@ -36,6 +37,12 @@ interface ModelSettings {
<input id="model-base-url" name="baseUrl" type="url" [(ngModel)]="baseUrl" required <input id="model-base-url" name="baseUrl" type="url" [(ngModel)]="baseUrl" required
placeholder="https://api-n-cd.playones.com/v1" [disabled]="busy() || loading()" /> placeholder="https://api-n-cd.playones.com/v1" [disabled]="busy() || loading()" />
<small>填写接口根地址,通常包含 /v1,无需添加 /responses。</small> <small>填写接口根地址,通常包含 /v1,无需添加 /responses。</small>
<label for="model-transport">传输协议</label>
<select id="model-transport" name="transport" [(ngModel)]="transport" [disabled]="busy() || loading()">
<option value="http">HTTP(兼容模式)</option>
<option value="websocket">WebSocket(增量传输)</option>
</select>
<small>WebSocket 可减少重复历史与截图的上传,需要接口支持。保存后对新任务生效。</small>
<app-model-selector label="模型名称" [(value)]="model" [presets]="modelPresets" [disabled]="!!busy() || loading()" /> <app-model-selector label="模型名称" [(value)]="model" [presets]="modelPresets" [disabled]="!!busy() || loading()" />
<small>选择预设模型,或选择“自定义模型”填写名称。</small> <small>选择预设模型,或选择“自定义模型”填写名称。</small>
<app-setting-slider label="思考强度" [(value)]="reasoning" [values]="reasoningValues" [labels]="reasoningLabels" [disabled]="!!busy() || loading()" hint="更高的强度适合复杂推理;需接口支持,也可选择模型默认。" /> <app-setting-slider label="思考强度" [(value)]="reasoning" [values]="reasoningValues" [labels]="reasoningLabels" [disabled]="!!busy() || loading()" hint="更高的强度适合复杂推理;需接口支持,也可选择模型默认。" />
@@ -78,30 +85,31 @@ interface ModelSettings {
</form> </form>
`, `,
styles: [` styles: [`
.model-settings { display: grid; gap: 10px; color: #1e293b; } .model-settings { display: grid; gap: 10px; color: var(--app-text-control, #1e293b); }
p, small { margin: 0; line-height: 1.6; color: #64748b; } p, small { margin: 0; line-height: 1.6; color: var(--app-text-muted, #64748b); }
label { font-weight: 600; } label span { font-weight: 400; font-size: 12px; } label { font-weight: 600; } label span { font-weight: 400; font-size: 12px; }
input, select { box-sizing: border-box; width: 100%; padding: 12px; border-radius: 10px; border: 1px solid #64748b66; input, select { box-sizing: border-box; width: 100%; padding: 12px; border-radius: 10px; border: 1px solid rgb(var(--app-border-control-rgb, 100 116 139) / 0.4000);
background: #f8fafc; color: inherit; font: inherit; } background: var(--app-surface-soft, #f8fafc); color: inherit; font: inherit; }
input:focus, select:focus { outline: 2px solid #38bdf8; } input:focus, select:focus { outline: 2px solid #38bdf8; }
.number-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 8px; } .number-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 8px; }
.number-fields input { margin-top: 8px; } .number-fields input { margin-top: 8px; }
.recovery-settings { border: 1px solid #cbd5e1; border-radius: 10px; background: #f8fafc; padding: 10px 14px; } .recovery-settings { border: 1px solid var(--app-border-control, #cbd5e1); border-radius: 10px; background: var(--app-surface-soft, #f8fafc); padding: 10px 14px; }
.recovery-settings summary { cursor: pointer; font-weight: 600; color: #334155; } .recovery-settings summary { cursor: pointer; font-weight: 600; color: var(--app-text-control, #334155); }
.recovery-content { display: grid; gap: 10px; padding-top: 12px; } .recovery-content { display: grid; gap: 10px; padding-top: 12px; }
.recovery-content label input, .recovery-content label select { margin-top: 8px; } .recovery-content label input, .recovery-content label select { margin-top: 8px; }
.actions { display: flex; gap: 12px; margin-top: 8px; } .actions { display: flex; gap: 12px; margin-top: 8px; }
button { padding: 11px 20px; border-radius: 10px; border: 1px solid #38bdf877; background: #0284c7; color: white; cursor: pointer; font: inherit; } button { padding: 11px 20px; border-radius: 10px; border: 1px solid #38bdf877; background: #0284c7; color: white; cursor: pointer; font: inherit; }
button:disabled { opacity: .5; cursor: default; } .success { color: #047857; } .error { color: #be123c; } button:disabled { opacity: .5; cursor: default; } .success { color: var(--app-success-text, #047857); } .error { color: var(--app-error-text, #be123c); }
button:focus-visible { outline: 2px solid #0284c7; outline-offset: 2px; } button:focus-visible { outline: 2px solid #0284c7; outline-offset: 2px; }
small { overflow-wrap: anywhere; } @media(max-width: 480px) { .number-fields { grid-template-columns: 1fr; } } small { overflow-wrap: anywhere; } @media(max-width: 480px) { .number-fields { grid-template-columns: 1fr; } }
.model-settings {gap:12px;font-size:13px} label {margin-top:6px}input,select {background:#fff;border-color:#dbe3ec;padding:11px 12px} .model-settings {gap:12px;font-size:13px} label {margin-top:6px}input,select {background:var(--app-surface, #fff);border-color:var(--app-border, #dbe3ec);padding:11px 12px}
input:focus,select:focus {outline:2px solid #a5b4fc;outline-offset:2px} input:focus,select:focus {outline:2px solid var(--app-primary-border, #a5b4fc);outline-offset:2px}
.actions {border-top:1px solid #e2e8f0;padding-top:16px;align-items:center}.actions button {min-height:40px;background:#4f46e5;border-color:#4f46e5;font-weight:600;border-radius:12px}.actions button[type=button] {background:#fff;border-color:#dbe3ec;color:#475569} .actions {border-top:1px solid var(--app-border, #e2e8f0);padding-top:16px;align-items:center}.actions button {min-height:40px;background:#4f46e5;border-color:#4f46e5;font-weight:600;border-radius:12px}.actions button[type=button] {background:var(--app-surface, #fff);border-color:var(--app-border, #dbe3ec);color:var(--app-text-secondary, #475569)}
.recovery-settings {border-color:#e2e8f0;border-radius:14px;padding:14px 16px}.recovery-settings summary {font-size:13px} .recovery-settings {border-color:var(--app-border, #e2e8f0);border-radius:14px;padding:14px 16px}.recovery-settings summary {font-size:13px}
`] `]
}) })
export class ModelSettingsComponent implements OnInit { export class ModelSettingsComponent implements OnInit {
transport: 'http' | 'websocket' = 'websocket';
readonly reasoningValues = REASONING_VALUES; readonly reasoningValues = REASONING_VALUES;
readonly reasoningLabels = REASONING_LABELS; readonly reasoningLabels = REASONING_LABELS;
private http = inject(HttpClient); private http = inject(HttpClient);
@@ -128,7 +136,7 @@ export class ModelSettingsComponent implements OnInit {
&& Number.isInteger(this.toolFallbackNoProgress) && this.toolFallbackNoProgress >= 1 && this.toolFallbackNoProgress <= 10; && Number.isInteger(this.toolFallbackNoProgress) && this.toolFallbackNoProgress >= 1 && this.toolFallbackNoProgress <= 10;
} }
private payload() { private payload() {
return { base_url: this.baseUrl.trim(), model: this.model.trim(), api_key: this.apiKey.trim() || undefined, return { base_url: this.baseUrl.trim(), transport: this.transport, model: this.model.trim(), api_key: this.apiKey.trim() || undefined,
timeout_seconds: this.timeout, max_retries: this.retries, retry_interval_seconds: this.retryInterval, reasoning_effort: this.reasoning, timeout_seconds: this.timeout, max_retries: this.retries, retry_interval_seconds: this.retryInterval, reasoning_effort: this.reasoning,
rate_limit_retry_seconds: this.rateLimitRetry, tool_fallback_model: this.toolFallbackModel.trim(), rate_limit_retry_seconds: this.rateLimitRetry, tool_fallback_model: this.toolFallbackModel.trim(),
tool_fallback_reasoning_effort: this.toolFallbackReasoning, tool_fallback_max_retries: this.toolFallbackRetries, tool_fallback_reasoning_effort: this.toolFallbackReasoning, tool_fallback_max_retries: this.toolFallbackRetries,
@@ -136,6 +144,7 @@ export class ModelSettingsComponent implements OnInit {
} }
private apply(config: ModelSettings): void { private apply(config: ModelSettings): void {
this.baseUrl = config.base_url; this.model = config.model; this.timeout = config.timeout_seconds; this.baseUrl = config.base_url; this.model = config.model; this.timeout = config.timeout_seconds;
this.transport = config.transport ?? 'websocket';
this.modelPresets = config.model_presets; this.modelPresets = config.model_presets;
this.retries = config.max_retries; this.retryInterval = config.retry_interval_seconds ?? 10; this.reasoning = config.reasoning_effort ?? 'high'; this.keyConfigured.set(config.api_key_configured); this.envPath.set(config.env_path); this.retries = config.max_retries; this.retryInterval = config.retry_interval_seconds ?? 10; this.reasoning = config.reasoning_effort ?? 'high'; this.keyConfigured.set(config.api_key_configured); this.envPath.set(config.env_path);
this.rateLimitRetry = config.rate_limit_retry_seconds ?? 60; this.rateLimitRetry = config.rate_limit_retry_seconds ?? 60;
@@ -23,14 +23,14 @@
box-sizing: border-box; box-sizing: border-box;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
background: rgba(255, 255, 255, 0.72); background: rgb(var(--app-surface-rgb, 255 255 255) / 0.72);
backdrop-filter: blur(16px) saturate(180%); backdrop-filter: blur(16px) saturate(180%);
-webkit-backdrop-filter: blur(16px) saturate(180%); -webkit-backdrop-filter: blur(16px) saturate(180%);
border: 1px solid rgba(226, 232, 240, 0.85); border: 1px solid rgb(var(--app-border-rgb, 226 232 240) / 0.85);
border-radius: 18px; border-radius: 18px;
padding: 3px; padding: 3px;
gap: 3px; gap: 3px;
box-shadow: 0 2px 8px -1px rgba(15, 23, 42, 0.06), 0 1px 2px rgba(0, 0, 0, 0.02), inset 0 1px 1px rgba(255, 255, 255, 0.9); box-shadow: 0 2px 8px -1px rgba(15, 23, 42, 0.06), 0 1px 2px rgba(0, 0, 0, 0.02), inset 0 1px 1px rgb(var(--app-highlight-rgb, 255 255 255) / 0.9);
user-select: none; user-select: none;
font-family: 'Google Sans', 'Google Sans Text', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-family: 'Google Sans', 'Google Sans Text', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
transition: background 0.25s cubic-bezier(0.4, 0, 0.2, 1), transition: background 0.25s cubic-bezier(0.4, 0, 0.2, 1),
@@ -38,15 +38,15 @@
border-color 0.25s cubic-bezier(0.4, 0, 0.2, 1); border-color 0.25s cubic-bezier(0.4, 0, 0.2, 1);
&:hover { &:hover {
background: rgba(255, 255, 255, 0.85); background: rgb(var(--app-surface-rgb, 255 255 255) / 0.85);
box-shadow: 0 4px 14px -2px rgba(15, 23, 42, 0.1), 0 2px 4px rgba(0, 0, 0, 0.03), inset 0 1px 1px rgba(255, 255, 255, 1); box-shadow: 0 4px 14px -2px rgba(15, 23, 42, 0.1), 0 2px 4px rgba(0, 0, 0, 0.03), inset 0 1px 1px rgb(var(--app-highlight-rgb, 255 255 255) / 1);
border-color: rgba(203, 213, 225, 0.9); border-color: rgb(var(--app-border-control-rgb, 203 213 225) / 0.9);
} }
.app-version { .app-version {
padding: 0 10px 0 8px; padding: 0 10px 0 8px;
border-left: 1px solid #e2e8f0; border-left: 1px solid var(--app-border, #e2e8f0);
color: #64748b; color: var(--app-text-muted, #64748b);
font-size: 11px; font-size: 11px;
font-weight: 500; font-weight: 500;
line-height: 18px; line-height: 18px;
@@ -65,7 +65,7 @@
border-radius: 15px; border-radius: 15px;
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: #475569; color: var(--app-text-secondary, #475569);
text-decoration: none; text-decoration: none;
cursor: pointer; cursor: pointer;
line-height: 1; line-height: 1;
@@ -83,24 +83,24 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: #64748b; color: var(--app-text-muted, #64748b);
transition: color 0.15s ease, transform 0.15s ease; transition: color 0.15s ease, transform 0.15s ease;
} }
.tab-label { .tab-label {
color: #475569; color: var(--app-text-secondary, #475569);
transition: color 0.15s ease; transition: color 0.15s ease;
} }
&:hover:not(.active) { &:hover:not(.active) {
background: rgba(255, 255, 255, 0.7); background: rgb(var(--app-surface-rgb, 255 255 255) / 0.7);
color: #0f172a; color: var(--app-text-strong, #0f172a);
.tab-icon { .tab-icon {
color: #1a73e8; color: var(--app-primary-text, #1a73e8);
} }
.tab-label { .tab-label {
color: #0f172a; color: var(--app-text-strong, #0f172a);
} }
} }
@@ -110,7 +110,7 @@
-webkit-backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
color: #ffffff !important; color: #ffffff !important;
font-weight: 700; font-weight: 700;
box-shadow: 0 2px 8px rgba(26, 115, 232, 0.35), inset 0 1px 1px rgba(255, 255, 255, 0.35); box-shadow: 0 2px 8px rgba(26, 115, 232, 0.35), inset 0 1px 1px rgb(var(--app-highlight-rgb, 255 255 255) / 0.35);
.tab-icon, .tab-icon,
.tab-label, .tab-label,
@@ -124,3 +124,40 @@
} }
} }
} }
.theme-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 30px;
width: 30px;
height: 30px;
padding: 0;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--app-text-secondary, #475569);
cursor: pointer;
.material-symbols-outlined { font-size: 16px; }
&:hover {
background: var(--app-surface-raised, #eef2f7);
color: var(--app-primary-text, #1a73e8);
}
}
.floating-nav-switcher .nav-tab-btn:focus-visible,
.theme-toggle:focus-visible {
outline: 2px solid var(--app-primary-text, #1a73e8);
outline-offset: 2px;
}
@media (max-width: 600px) {
.floating-nav-switcher {
left: 12px;
max-width: calc(100vw - 24px);
.nav-tab-btn { padding: 0 10px; }
.nav-tab-btn .tab-label { display: none; }
.app-version { padding: 0 6px; }
}
}
@@ -14,10 +14,11 @@
* limitations under the License. * limitations under the License.
*/ */
import { Component, ChangeDetectionStrategy } from '@angular/core'; import { Component, ChangeDetectionStrategy, inject } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router'; import { RouterLink, RouterLinkActive } from '@angular/router';
import { version } from '../../../../package.json'; import { version } from '../../../../package.json';
import { ThemeService } from '../../services/theme.service';
@Component({ @Component({
selector: 'app-nav-switcher', selector: 'app-nav-switcher',
@@ -30,6 +31,7 @@ import { version } from '../../../../package.json';
routerLinkActive="active" routerLinkActive="active"
[routerLinkActiveOptions]="{exact: true}" [routerLinkActiveOptions]="{exact: true}"
class="nav-tab-btn" class="nav-tab-btn"
aria-label="新任务 / 首页"
title="返回首页开始新任务" title="返回首页开始新任务"
> >
<span class="material-symbols-outlined tab-icon">add_task</span> <span class="material-symbols-outlined tab-icon">add_task</span>
@@ -39,15 +41,22 @@ import { version } from '../../../../package.json';
routerLink="/workspace" routerLink="/workspace"
routerLinkActive="active" routerLinkActive="active"
class="nav-tab-btn" class="nav-tab-btn"
aria-label="工作区"
title="打开工作区" title="打开工作区"
> >
<span class="material-symbols-outlined tab-icon">space_dashboard</span> <span class="material-symbols-outlined tab-icon">space_dashboard</span>
<span class="tab-label">工作区</span> <span class="tab-label">工作区</span>
</a> </a>
<a routerLink="/plugins" routerLinkActive="active" class="nav-tab-btn" title="管理扩展插件"> <a routerLink="/plugins" routerLinkActive="active" class="nav-tab-btn" aria-label="插件" title="管理扩展插件">
<span class="material-symbols-outlined tab-icon">extension</span><span class="tab-label">插件</span> <span class="material-symbols-outlined tab-icon">extension</span><span class="tab-label">插件</span>
</a> </a>
<span class="app-version" [title]="'ARTEMIS 版本 ' + version" [attr.aria-label]="'ARTEMIS 版本 ' + version">v{{version}}</span> <span class="app-version" [title]="'ARTEMIS 版本 ' + version" [attr.aria-label]="'ARTEMIS 版本 ' + version">v{{version}}</span>
<button type="button" class="theme-toggle"
[attr.aria-label]="theme.isDark() ? '切换为浅色模式' : '切换为暗色模式'"
[title]="theme.isDark() ? '切换为浅色模式' : '切换为暗色模式'"
(click)="theme.toggle()">
<span class="material-symbols-outlined" aria-hidden="true">{{ theme.isDark() ? 'light_mode' : 'dark_mode' }}</span>
</button>
</nav> </nav>
`, `,
changeDetection: ChangeDetectionStrategy.Eager, changeDetection: ChangeDetectionStrategy.Eager,
@@ -55,4 +64,5 @@ import { version } from '../../../../package.json';
}) })
export class NavSwitcherComponent { export class NavSwitcherComponent {
readonly version = version; readonly version = version;
readonly theme = inject(ThemeService);
} }
@@ -0,0 +1,68 @@
import { TestBed } from '@angular/core/testing';
import { PluginToolResultComponent } from './plugin-tool-result.component';
import { PluginToolView } from '../../utils/plugin-tool-view.util';
describe('readable plugin receipts', () => {
const makeView = (result: string): PluginToolView => ({
title: '观察电脑窗口', kind: '辅助观察', auxiliary: true, status: '已执行', args: [],
result, rawResult: JSON.stringify({ result, call_id: 'original-call' }, null, 2),
facts: [{ key: '窗口', value: 'Demo' }, { key: '截图', value: '1280 × 720' }]
});
const mount = async (view: PluginToolView) => {
await TestBed.configureTestingModule({ imports: [PluginToolResultComponent] }).compileComponents();
const fixture = TestBed.createComponent(PluginToolResultComponent);
fixture.componentRef.setInput('view', view);
fixture.detectChanges();
return fixture;
};
it('keeps a long control tree and the original payload folded and fully accessible', async () => {
const fullText = Array.from({ length: 200 }, (_, i) => `- [${i}] Button "Item ${i}"`).join('\n');
const view = makeView(fullText);
const fixture = await mount(view);
const root: HTMLElement = fixture.nativeElement;
expect(root.querySelector('dl')!.textContent).toContain('1280 × 720');
expect(root.querySelector('.receipt-preview')!.textContent!.length).toBeLessThan(1100);
const textDetails = root.querySelector<HTMLDetailsElement>('.receipt-text')!;
const rawDetails = root.querySelector<HTMLDetailsElement>('.receipt-raw')!;
expect(textDetails.open).toBeFalse();
expect(rawDetails.open).toBeFalse();
textDetails.querySelector('summary')!.click();
expect(textDetails.open).toBeTrue();
expect(textDetails.querySelector('pre')!.textContent).toBe(fullText);
expect(rawDetails.querySelector('pre')!.textContent).toBe(view.rawResult);
});
it('renders untrusted tool text as text and keeps short results readable', async () => {
const text = '<img src=x onerror="alert(1)">\n已观察,未验证业务结果';
const fixture = await mount(makeView(text));
const root: HTMLElement = fixture.nativeElement;
expect(root.querySelector('.receipt-preview')!.textContent).toBe(text);
expect(root.querySelector('img')).toBeNull();
expect(root.querySelector('.receipt-text')).toBeNull();
});
it('reflects a new receipt without resetting an open raw payload', async () => {
const fixture = await mount(makeView('执行中'));
const raw = fixture.nativeElement.querySelector('.receipt-raw') as HTMLDetailsElement;
raw.querySelector('summary')!.click();
fixture.componentRef.setInput('view', makeView('已返回新的窗口观察'));
fixture.detectChanges();
expect(raw.open).toBeTrue();
expect(fixture.nativeElement.querySelector('.receipt-preview').textContent).toBe('已返回新的窗口观察');
expect(raw.querySelector('pre')!.textContent).toContain('已返回新的窗口观察');
});
it('keeps long window labels and unbroken receipt text inside a narrow container', async () => {
const view = makeView('long-unbroken-result'.repeat(200));
view.facts[0].value = 'VeryLongWindowTitle'.repeat(30);
const fixture = await mount(view);
const root = fixture.nativeElement as HTMLElement;
root.style.width = '240px';
fixture.detectChanges();
expect(root.clientWidth).toBe(240);
expect(root.scrollWidth).toBeLessThanOrEqual(root.clientWidth);
const label = root.querySelector('dd')!;
expect(label.scrollWidth).toBeLessThanOrEqual(label.clientWidth);
});
});
@@ -0,0 +1,58 @@
import { Component, computed, input } from '@angular/core';
import { PluginToolView } from '../../utils/plugin-tool-view.util';
import { ExpandableValueComponent } from '../expandable-value/expandable-value.component';
/** Readable presentation only: the full receipt and model context stay intact. */
@Component({
selector: 'app-plugin-tool-result',
standalone: true,
imports: [ExpandableValueComponent],
template: `
@if (view().facts.length) {
<dl class="receipt-facts">
@for (fact of view().facts; track fact.key) {
<div><dt>{{ fact.key }}</dt><dd><app-expandable-value [label]="fact.key" [value]="fact.value" /></dd></div>
}
</dl>
}
@if (view().result) {
<pre class="receipt-preview">{{ preview() }}</pre>
@if (isLong()) {
<details class="receipt-detail receipt-text">
<summary>完整文本与控件树 <span>{{ view().result.length }} 字符</span></summary>
<pre>{{ view().result }}</pre>
</details>
}
}
@if (view().rawResult) {
<details class="receipt-detail receipt-raw">
<summary>原始返回 payload <span>完整保留</span></summary>
<pre>{{ view().rawResult }}</pre>
</details>
}
`,
styles: [`
:host { display: block; min-width: 0; width: 100%; user-select: text; cursor: auto; }
.receipt-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(140px, 100%), 1fr)); gap: 8px 16px; margin: 0 0 12px; }
.receipt-facts div { min-width: 0; }
dt { font-size: 11px; color: var(--app-text-muted, #64748b); }
dd { margin: 3px 0 0; font-size: 12px; color: var(--app-text-control, #334155); overflow-wrap: anywhere; }
pre { margin: 0; padding: 10px 12px; border-radius: 6px; font: 12px/1.6 Consolas, monospace; color: var(--app-text-control, #334155); background: var(--app-surface-soft, #f8fafc); white-space: pre-wrap; overflow-wrap: anywhere; }
.receipt-preview { max-height: 180px; overflow: auto; }
.receipt-detail { margin-top: 10px; border: 1px solid var(--app-border, #e2e8f0); border-radius: 6px; min-width: 0; }
summary { padding: 9px 10px; font-size: 12px; color: var(--app-text-secondary, #475569); cursor: pointer; overflow-wrap: anywhere; }
summary span { margin-left: 8px; font-size: 11px; color: var(--app-text-muted, #64748b); }
summary:focus-visible { outline: 2px solid var(--app-primary-text, #1a73e8); outline-offset: 2px; }
.receipt-detail pre { max-height: 400px; overflow: auto; border-top: 1px solid var(--app-border, #e2e8f0); border-radius: 0 0 6px 6px; }
`]
})
export class PluginToolResultComponent {
view = input.required<PluginToolView>();
private readonly previewLimit = 1000;
preview = computed(() => {
const text = this.view().result;
const excerpt = text.split('\n').slice(0, 7).join('\n').slice(0, this.previewLimit);
return excerpt.length < text.length ? `${excerpt}\n…` : text;
});
isLong = computed(() => this.preview() !== this.view().result);
}
@@ -4,7 +4,7 @@ import { ReportMarkdownComponent } from './report-markdown.component';
describe('报告 Markdown 展示', () => { describe('报告 Markdown 展示', () => {
it('renders report headings, emphasis, lists, tables and code in both preview and full view', () => { it('renders report headings, emphasis, lists, tables and code in both preview and full view', () => {
const fixture = TestBed.createComponent(ReportMarkdownComponent); const fixture = TestBed.createComponent(ReportMarkdownComponent);
fixture.componentRef.setInput('content', '### 双端布局报告\n- **手机**:`/zh`\n\n| 区域 | 结论 |\n| --- | --- |\n| 页底 | 已确认 |\n\n```text\n<原始证据>\n```'); fixture.componentRef.setInput('content', '### 双端布局报告\n- **手机**:`/zh`\n- [x] 已检查\n- [/] 进行中\n\n| 区域 | 结论 |\n| --- | ---: |\n| 页底 | 已确认 |\n\n```text\n<原始证据>\n```');
fixture.componentRef.setInput('compact', true); fixture.componentRef.setInput('compact', true);
fixture.detectChanges(); fixture.detectChanges();
const view: HTMLElement = fixture.nativeElement; const view: HTMLElement = fixture.nativeElement;
@@ -12,6 +12,9 @@ describe('报告 Markdown 展示', () => {
expect(view.querySelector('li strong')?.textContent).toBe('手机'); expect(view.querySelector('li strong')?.textContent).toBe('手机');
expect(view.querySelector('li code')?.textContent).toBe('/zh'); expect(view.querySelector('li code')?.textContent).toBe('/zh');
expect(view.querySelector('td')?.textContent).toBe('页底'); expect(view.querySelector('td')?.textContent).toBe('页底');
expect(view.querySelector('td.md-align-right')?.textContent).toBe('已确认');
expect(view.querySelector('[aria-label="已完成"]')).not.toBeNull();
expect(view.querySelector('[aria-label="进行中"]')).not.toBeNull();
expect(view.querySelector('pre code')?.textContent).toBe('<原始证据>'); expect(view.querySelector('pre code')?.textContent).toBe('<原始证据>');
expect(view.querySelector('.compact')).not.toBeNull(); expect(view.querySelector('.compact')).not.toBeNull();
fixture.componentRef.setInput('compact', false); fixture.componentRef.setInput('compact', false);
@@ -1,7 +1,7 @@
import { Component, computed, input } from '@angular/core'; import { Component, computed, input } from '@angular/core';
import { renderMarkdownToHtml } from '../../utils/markdown-parser.util'; import { renderMarkdownToHtml } from '../../utils/markdown-parser.util';
/** Shared, sanitized presentation for report prose (not the editable task plan). */ /** Shared presentation for notes, plans and reports; Angular sanitizes the HTML. */
@Component({ @Component({
selector: 'app-report-markdown', selector: 'app-report-markdown',
standalone: true, standalone: true,
@@ -14,21 +14,21 @@ import { renderMarkdownToHtml } from '../../utils/markdown-parser.util';
> div, p { margin: 4px 0; } > div, p { margin: 4px 0; }
> :first-child { margin-top: 0; } > :first-child { margin-top: 0; }
> :last-child { margin-bottom: 0; } > :last-child { margin-bottom: 0; }
h1,h2,h3,h4,h5,h6 { margin: 16px 0 6px; font-weight: 600; line-height: 1.5; color: #0f172a; } h1,h2,h3,h4,h5,h6 { margin: 16px 0 6px; font-weight: 600; line-height: 1.5; color: var(--app-text-strong, #0f172a); }
h1 { font-size: 18px; } h2 { font-size: 16px; } h3,h4,h5,h6 { font-size: 14px; } h1 { font-size: 18px; } h2 { font-size: 16px; } h3,h4,h5,h6 { font-size: 14px; }
strong { font-weight: 600; } strong { font-weight: 600; }
ul,ol { margin: 6px 0; padding-left: 24px; } ul,ol { margin: 6px 0; padding-left: 24px; }
li { margin: 4px 0; } li { margin: 4px 0; }
blockquote { margin: 10px 0; padding: 6px 12px; border-left: 3px solid #93c5fd; background: #f8fafc; } blockquote { margin: 10px 0; padding: 6px 12px; border-left: 3px solid var(--app-primary-border, #93c5fd); background: var(--app-surface-soft, #f8fafc); }
code { font-family: Consolas, monospace; font-size: .92em; border-radius: 4px; padding: 1px 4px; background: #f1f5f9; } code { font-family: Consolas, monospace; font-size: .92em; border-radius: 4px; padding: 1px 4px; background: var(--app-surface-soft, #f1f5f9); }
pre { max-width: 100%; overflow: auto; padding: 10px 12px; border-radius: 6px; background: #f1f5f9; white-space: pre; } pre { box-sizing: border-box; max-width: 100%; overflow: auto; padding: 10px 12px; border-radius: 6px; background: var(--app-surface-soft, #f1f5f9); white-space: pre; }
pre code { padding: 0; background: none; } pre code { padding: 0; background: none; }
.md-table-scroll { max-width: 100%; overflow-x: auto; } .md-table-scroll { max-width: 100%; overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 12px; } table { width: 100%; border-collapse: collapse; font-size: 12px; }
th,td { border: 1px solid #cbd5e1; padding: 7px 9px; text-align: left; vertical-align: top; } th,td { border: 1px solid var(--app-border-control, #cbd5e1); padding: 7px 9px; text-align: left; vertical-align: top; }
th { background: #eff6ff; font-weight: 600; } th { background: var(--app-primary-soft, #eff6ff); font-weight: 600; }
.md-para-gap { height: 4px; margin: 0; } .md-para-gap { height: 4px; margin: 0; }
hr { border: 0; border-top: 1px solid #e2e8f0; margin: 12px 0; } hr { border: 0; border-top: 1px solid var(--app-border, #e2e8f0); margin: 12px 0; }
} }
`] `]
}) })
@@ -12,13 +12,13 @@ import { Component, computed, input, output } from '@angular/core';
@if(hint()){<p>{{hint()}}</p>} @if(hint()){<p>{{hint()}}</p>}
`, `,
styles: [` styles: [`
:host{display:block;min-width:0;padding:14px 16px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:14px;font-size:13px;color:#334155} :host{display:block;min-width:0;padding:14px 16px;background:var(--app-surface-soft, #f8fafc);border:1px solid var(--app-border, #e2e8f0);border-radius:14px;font-size:13px;color:var(--app-text-control, #334155)}
.slider-heading,.slider-ends{display:flex;align-items:center;justify-content:space-between;gap:10px} .slider-heading,.slider-ends{display:flex;align-items:center;justify-content:space-between;gap:10px}
.slider-heading{font-weight:600}output{color:#4f46e5;font-size:12px;white-space:nowrap}.slider-ends{font-size:11px;color:#64748b} .slider-heading{font-weight:600}output{color:var(--app-exploration-text, #4f46e5);font-size:12px;white-space:nowrap}.slider-ends{font-size:11px;color:var(--app-text-muted, #64748b)}
input{appearance:none;display:block;box-sizing:border-box;width:100%;height:6px;border:0;border-radius:10px;margin:20px 0 12px;background:linear-gradient(to right,#6366f1 var(--fill),#dbe3ee var(--fill));cursor:pointer} input{appearance:none;display:block;box-sizing:border-box;width:100%;height:6px;border:0;border-radius:10px;margin:20px 0 12px;background:linear-gradient(to right,#6366f1 var(--fill),var(--app-primary-soft, #dbe3ee) var(--fill));cursor:pointer}
input::-webkit-slider-thumb{appearance:none;width:18px;height:18px;border-radius:50%;background:#fff;border:2px solid #6366f1;box-shadow:0 2px 6px #33415526} input::-webkit-slider-thumb{appearance:none;width:18px;height:18px;border-radius:50%;background:var(--app-surface, #fff);border:2px solid #6366f1;box-shadow:0 2px 6px #33415526}
input::-moz-range-thumb{width:14px;height:14px;border-radius:50%;background:#fff;border:2px solid #6366f1} input::-moz-range-thumb{width:14px;height:14px;border-radius:50%;background:var(--app-surface, #fff);border:2px solid #6366f1}
input:focus-visible{outline:2px solid #a5b4fc;outline-offset:5px}input:disabled{opacity:.5;cursor:default}p{margin:10px 0 0;font-size:12px;line-height:1.6;color:#64748b} input:focus-visible{outline:2px solid var(--app-primary-border, #a5b4fc);outline-offset:5px}input:disabled{opacity:.5;cursor:default}p{margin:10px 0 0;font-size:12px;line-height:1.6;color:var(--app-text-muted, #64748b)}
`] `]
}) })
export class SettingSliderComponent { export class SettingSliderComponent {
@@ -12,7 +12,7 @@ import { AgentService } from '../../services/agent.service';
@if(agent.batchFilter() && !['__batch__','__single__','__exploration__'].includes(agent.batchFilter()) && !hasSelectedBatch()){<option [value]="agent.batchFilter()">当前选定批次</option>} @if(agent.batchFilter() && !['__batch__','__single__','__exploration__'].includes(agent.batchFilter()) && !hasSelectedBatch()){<option [value]="agent.batchFilter()">当前选定批次</option>}
</select></label>@if(agent.batchFilter()){<button type="button" (click)="agent.batchFilter.set('')">清除筛选</button>}</div> </select></label>@if(agent.batchFilter()){<button type="button" (click)="agent.batchFilter.set('')">清除筛选</button>}</div>
@if(error()){<div class="filter-error" role="alert">{{error()}} <button type="button" (click)="load()">重试</button></div>}`, @if(error()){<div class="filter-error" role="alert">{{error()}} <button type="button" (click)="load()">重试</button></div>}`,
styles: [`:host{display:block;margin:0 0 12px}.batch-filter{display:flex;align-items:flex-end;gap:8px;color:#475569;font-size:12px}label{flex:1;min-width:0}select{display:block;box-sizing:border-box;width:100%;height:34px;margin-top:5px;border:1px solid #cbd5e1;border-radius:7px;padding:0 9px;background:#fff;color:#1e293b;font:inherit}option{background:#fff;color:#1e293b}button{height:34px;white-space:nowrap;border:1px solid #cbd5e1;border-radius:7px;padding:0 9px;background:#fff;color:#475569;font:inherit;cursor:pointer}select:focus-visible,button:focus-visible{outline:2px solid #3b82f6;outline-offset:2px}.filter-error{font-size:12px;color:#b91c1c;margin-top:5px}.filter-error button{height:auto;padding:2px 6px}`] styles: [`:host{display:block;margin:0 0 12px}.batch-filter{display:flex;align-items:flex-end;gap:8px;color:var(--app-text-secondary, #475569);font-size:12px}label{flex:1;min-width:0}select{display:block;box-sizing:border-box;width:100%;height:34px;margin-top:5px;border:1px solid var(--app-border-control, #cbd5e1);border-radius:7px;padding:0 9px;background:var(--app-surface, #fff);color:var(--app-text-control, #1e293b);font:inherit}option{background:var(--app-surface, #fff);color:var(--app-text-control, #1e293b)}button{height:34px;white-space:nowrap;border:1px solid var(--app-border-control, #cbd5e1);border-radius:7px;padding:0 9px;background:var(--app-surface, #fff);color:var(--app-text-secondary, #475569);font:inherit;cursor:pointer}select:focus-visible,button:focus-visible{outline:2px solid #3b82f6;outline-offset:2px}.filter-error{font-size:12px;color:var(--app-error-text, #b91c1c);margin-top:5px}.filter-error button{height:auto;padding:2px 6px}`]
}) })
export class TaskBatchFilterComponent { export class TaskBatchFilterComponent {
agent = inject(AgentService); private http = inject(HttpClient); agent = inject(AgentService); private http = inject(HttpClient);
@@ -0,0 +1,115 @@
@if (enabled() && error()) {
<div class="extension-notice" role="status">{{ error() }} <button type="button" (click)="refresh()">重试</button></div>
}
<ng-template #headerControls>
@if (data()?.plugins?.length) {
<nav class="panel-toggles" aria-label="任务扩展面板">
@for (side of ['left', 'right']; track side) {
@for (plugin of data()!.plugins; track plugin.plugin_id) {
@for (panel of plugin.workspace.panels; track panel.id) {
@if (panel.side === side) {
<button type="button" class="panel-toggle" [class.active]="opened()[key(plugin, panel)]" [attr.aria-expanded]="!!opened()[key(plugin, panel)]"
[attr.aria-label]="(opened()[key(plugin, panel)] ? '收起' : '展开') + panel.title"
[title]="(opened()[key(plugin, panel)] ? '收起' : '展开') + panel.title"
[attr.aria-controls]="'extension-' + key(plugin, panel)" (click)="toggle(plugin, panel)">
<svg viewBox="0 0 20 20" aria-hidden="true"><rect x="2" y="3" width="16" height="14" rx="3" /><path [attr.d]="side === 'left' ? 'M7 3v14' : 'M13 3v14'" /></svg>
<span class="panel-toggle-label">{{ panel.title }}</span>
<span class="material-symbols-outlined" aria-hidden="true">{{ (side === 'left') !== !!opened()[key(plugin, panel)] ? 'chevron_left' : 'chevron_right' }}</span>
</button>
}
}
}
}
</nav>
}
</ng-template>
<div class="extension-layout" [class.has-left]="panels('left').length" [class.has-right]="panels('right').length"
[style.--left-width]="width('left') + 'px'" [style.--right-width]="width('right') + 'px'">
@if (panels('left').length) {
<aside class="panel-column left-column" aria-label="任务左侧扩展">
@for (item of panels('left'); track key(item.plugin, item.panel)) {
<ng-container *ngTemplateOutlet="panelContent; context: { plugin: item.plugin, panel: item.panel }" />
}
</aside>
}
<div class="player-content"><ng-content /></div>
@if (panels('right').length) {
<aside class="panel-column right-column" aria-label="任务右侧扩展">
@for (item of panels('right'); track key(item.plugin, item.panel)) {
<ng-container *ngTemplateOutlet="panelContent; context: { plugin: item.plugin, panel: item.panel }" />
}
</aside>
}
</div>
<ng-template #panelContent let-plugin="plugin" let-panel="panel">
<section [id]="'extension-' + key(plugin, panel)" [attr.aria-label]="panel.title">
<header><h3>{{ panel.kind === 'original_case' ? (data()?.case_title || panel.title) : panel.title }}</h3><span class="panel-kind">{{ panel.kind === 'original_case' ? '原始用例' : panel.kind === 'initial_prompt' ? '原始提示词' : '人工填写 · 选填' }}</span></header>
@if (panel.kind === 'original_case') {
@if (!data()?.original_case) {
<p class="muted">此任务未记录原始用例内容;旧任务不会从 AI 提示词推测补写。</p>
}
<div class="case-section"><h4>预置条件</h4><app-report-markdown [content]="data()?.original_case?.preconditions || '未填写'" /></div>
<div class="case-section"><h4>操作步骤</h4><app-report-markdown [content]="data()?.original_case?.steps || '未填写'" /></div>
<div class="case-section"><h4>预期结果</h4><app-report-markdown [content]="data()?.original_case?.expected || '未填写'" /></div>
} @else if (panel.kind === 'initial_prompt') {
@if (data()?.initial_prompt) {
<app-report-markdown [content]="data()!.initial_prompt" />
} @else { <p class="muted">该任务未记录初始需求提示词。</p> }
} @else {
@if (!data()?.editable) { <p class="muted">任务结束后可填写评审。</p> }
<fieldset [disabled]="!data()?.editable">
@for (field of plugin.workspace.fields; track field.id) {
<div class="field" [class.choice-field]="field.kind === 'choice'" [class.note-field]="field.kind === 'text'">
<span class="field-label">{{ field.label }}</span>
@if (field.description) { <span class="muted">{{ field.description }}</span> }
@if (field.kind === 'choice') {
<div class="choice-group" role="group" [attr.aria-label]="field.label">
@for (option of field.options; track option) {
<button type="button" class="choice-button" [class.selected]="drafts[plugin.plugin_id].values[field.id] === option"
[class.pass]="option === 'Pass' || option === '通过'" [class.failed]="option === 'Failed' || option === '失败'"
[attr.aria-pressed]="drafts[plugin.plugin_id].values[field.id] === option" title="再次点击已选项可清除"
(click)="choose(plugin.plugin_id, field.id, option)">
@if (option === 'Pass' || option === 'Failed' || option === '通过' || option === '失败') {
<svg class="choice-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path [attr.d]="option === 'Pass' || option === '通过' ? 'M5 12l4 4L19 6' : 'M6 6l12 12M18 6L6 18'" />
</svg>
}
<span class="choice-label">{{ option }}</span>
</button>
}
</div>
} @else {
<textarea rows="1" [attr.aria-label]="field.label" [maxLength]="field.max_length" placeholder="添加备注(选填)"
[ngModel]="drafts[plugin.plugin_id].values[field.id] || ''" (ngModelChange)="edit(plugin.plugin_id, field.id, $event)"></textarea>
}
</div>
}
<div class="form-actions">
<button type="button" class="save" [disabled]="!!saving()" (click)="save(plugin)">{{ saving() === plugin.plugin_id ? '正在保存…' : '保存评审' }}</button>
<button type="button" class="reload" [disabled]="!!saving()" (click)="reload(plugin)" title="放弃未保存内容,读取最新记录">重新加载</button>
<span class="muted unsaved">{{ saving() === plugin.plugin_id ? '正在保存…' : drafts[plugin.plugin_id].dirty ? '等待自动保存…' : '修改后自动保存' }}</span>
</div>
</fieldset>
<nav class="review-navigation" aria-label="切换评审任务">
<div class="navigation-buttons">
<button type="button" [disabled]="!navigation().previous || !!saving() || moving()" (click)="move('previous')"><span aria-hidden="true">‹</span>上一条</button>
<span class="review-position">{{ navigation().position }} / {{ navigation().total }}</span>
<button type="button" [disabled]="!navigation().next || !!saving() || moving()" (click)="move('next')">下一条<span aria-hidden="true">›</span></button>
</div>
<span class="muted">{{ navigation().scope === 'batch' ? '同批次已结束任务 · 按执行顺序' : '当前历史列表' }}</span>
@if (drafts[plugin.plugin_id].dirty) { <span class="muted">有待保存的修改,切换前会先保存。</span> }
</nav>
@if (notice()) { <p class="notice" role="status">{{ notice() }}</p> }
@if (plugin.history.length) {
<details class="history"><summary>修改记录({{ plugin.history.length }})</summary>
@for (entry of plugin.history; track entry.revision) {
<div class="history-entry"><strong>第 {{ entry.revision }} 次 · {{ entry.saved_at * 1000 | date:'yyyy-MM-dd HH:mm:ss' }}</strong>
@for (field of entry.fields; track field.id) { <p>{{ field.label }}:{{ entry.values[field.id] || '未填写' }}</p> }
</div>
}
</details>
}
}
</section>
</ng-template>
@@ -0,0 +1,60 @@
:host { display: block; width: 100%; min-width: 0; min-height: 0; overflow: auto; container-type: inline-size; color: var(--app-text-control, #1e293b); }
.panel-toggles { display: flex; gap: 4px; padding-right: 8px; margin-right: 4px; border-right: 1px solid var(--app-border, #e2e8f0); }
button { box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; gap: 5px; padding: 6px 9px; border: 1px solid var(--app-border-control, #dbe3ec); border-radius: 8px; color: inherit; background: var(--app-surface, #fff); font: inherit; font-size: 12px; line-height: 20px; cursor: pointer; }
.panel-toggle { height: 28px; padding: 4px 7px; border-color: transparent; background: transparent; color: var(--app-text-secondary, #64748b); gap: 4px; }
.panel-toggle:hover { background: var(--app-surface-soft, #f1f5f9); }
.panel-toggle.active { background: var(--app-primary-soft, #eff6ff); color: var(--app-primary-text, #1a73e8); }
.panel-toggle svg { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-width: 1.4; }
.panel-toggle .material-symbols-outlined { font-size: 15px; }
button.save { background: #1a73e8; border-color: #1a73e8; color: white; padding: 8px 16px; font-weight: 500; }
button:disabled { opacity: .6; cursor: default; }
button:focus-visible, textarea:focus-visible { outline: 2px solid var(--app-primary-text, #1a73e8); outline-offset: 2px; }
.material-symbols-outlined { font-size: 18px; }
.extension-layout { display: grid; grid-template-columns: minmax(0, 1fr); min-height: 0; }
.has-left { grid-template-columns: minmax(0, min(var(--left-width), 55%)) minmax(0, 1fr); }
.has-right { grid-template-columns: minmax(0, 1fr) minmax(0, min(var(--right-width), 55%)); }
.has-left.has-right { grid-template-columns: minmax(0, min(var(--left-width), 32%)) minmax(0, 1fr) minmax(0, min(var(--right-width), 32%)); }
.player-content { min-width: 0; overflow: hidden; }
.panel-column { min-width: 0; max-height: min(70dvh, 680px); overflow: auto; padding: 20px; user-select: text; background: var(--app-surface, #fff); scrollbar-width: thin; scrollbar-color: var(--app-border-control, #cbd5e1) transparent; }
.left-column { border-right: 1px solid var(--app-border, #e2e8f0); }
.right-column { border-left: 1px solid var(--app-border, #e2e8f0); }
section + section { border-top: 1px solid var(--app-border, #e2e8f0); padding-top: 12px; margin-top: 12px; }
header { display: flex; justify-content: space-between; align-items: flex-start; gap: 8px; margin-bottom: 14px; overflow-wrap: anywhere; }
h3 { margin: 0; font-size: 14px; color: var(--app-text-strong, #0f172a); }
.panel-kind { flex-shrink: 0; font-size: 11px; color: var(--app-text-muted, #64748b); }
.case-section { margin: 18px 0; }
.case-section h4 { margin: 0 0 8px; font-size: 13px; color: var(--app-text-strong, #0f172a); }
.review-navigation { margin: 12px 0 0; padding: 12px 0 0; background: var(--app-surface, #fff); border-top: 1px solid var(--app-border, #e2e8f0); }
.navigation-buttons { display: flex; align-items: center; justify-content: space-between; gap: 4px; margin-bottom: 4px; }
.navigation-buttons button { white-space: nowrap; }
.review-position { font-size: 11px; color: var(--app-text-muted, #64748b); white-space: nowrap; }
.muted { display: block; font-size: 12px; color: var(--app-text-secondary, #64748b); line-height: 1.6; }
fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
.field { display: grid; gap: 8px; margin: 0 0 14px; }
.choice-field:not(:first-child) { border-top: 1px solid var(--app-border, #e2e8f0); padding-top: 14px; margin-top: 14px; }
.field-label { font-size: 13px; font-weight: 600; }
.note-field .field-label { font-size: 11px; font-weight: 400; color: var(--app-text-muted, #64748b); }
.choice-group { display: flex; flex-wrap: wrap; gap: 4px; padding: 4px; margin-top: 4px; border: 1px solid var(--app-border, #e2e8f0); border-radius: 10px; background: var(--app-surface-soft, #f8fafc); }
.choice-button { flex: 1; min-height: 34px; border-color: transparent; background: transparent; color: var(--app-text-secondary, #64748b); font-size: 13px; transition: background .15s, color .15s; }
.choice-icon { display: block; flex: 0 0 16px; width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
.choice-label { display: block; line-height: 20px; }
.choice-button:hover { background: var(--app-surface-raised, #e2e8f0); }
.choice-button.selected { border-color: var(--app-primary-border, #93c5fd); background: var(--app-primary-soft, #eff6ff); color: var(--app-primary-text, #1a73e8); font-weight: 600; }
.choice-button.pass.selected { border-color: var(--app-success-border, #a7f3d0); background: var(--app-success-soft, #ecfdf5); color: var(--app-success-text, #047857); }
.choice-button.failed.selected { border-color: var(--app-error-border, #fecdd3); background: var(--app-error-soft, #fff1f2); color: var(--app-error-text, #be123c); }
textarea { box-sizing: border-box; width: 100%; min-width: 0; padding: 6px 10px; font: inherit; font-size: 12px; line-height: 1.6; color: inherit; border: 1px solid var(--app-border, #e2e8f0); border-radius: 8px; background: var(--app-surface-soft, #f8fafc); resize: vertical; min-height: 34px; max-height: 200px; }
textarea::placeholder { color: var(--app-text-muted, #94a3b8); }
.form-actions { display: grid; grid-template-columns: auto auto; justify-content: start; align-items: center; gap: 8px; margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--app-border, #e2e8f0); }
.form-actions button { height: 36px; padding: 0 16px; white-space: nowrap; }
.unsaved { grid-column: 1 / -1; }
.reload { border-color: transparent; background: transparent; color: var(--app-text-muted, #64748b); }
.notice, .extension-notice { font-size: 12px; padding: 8px; overflow-wrap: anywhere; line-height: 1.6; }
.history { margin-top: 18px; font-size: 12px; color: var(--app-text-secondary, #64748b); }
summary { cursor: pointer; }
.history-entry { border-top: 1px solid var(--app-border, #e2e8f0); padding: 10px 0; overflow-wrap: anywhere; white-space: pre-wrap; }
@container (max-width: 640px) {
.panel-toggle-label { display: none; }
.panel-toggles { padding-right: 4px; margin-right: 0; gap: 2px; }
.panel-toggle { padding: 4px; }
.panel-column { padding: 16px; }
}
@@ -0,0 +1,221 @@
import { TestBed, ComponentFixture, fakeAsync, flushMicrotasks, tick } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TaskExtensionPanelsComponent } from './task-extension-panels.component';
import { TaskExtensions } from '../../services/task-extensions.service';
function snapshot(sessionId = 'task-one'): TaskExtensions {
return {
session_id: sessionId, initial_prompt: '# 原始需求\n\n| 项目 | 要求 |\n| --- | --- |\n| 页面 | 可见 |', editable: true,
plugins: [{
plugin_id: 'review', name: '人工评审', version: '1.0.0', revision: 0, values: { accuracy: '', note: '', correctness: '' }, history: [],
workspace: {
panels: [
{ id: 'prompt', title: '任务需求', side: 'left', kind: 'initial_prompt', width: { minimum: 240, preferred: 360, maximum: 480 } },
{ id: 'review', title: '人工评审', side: 'right', kind: 'form', width: { minimum: 260, preferred: 380, maximum: 520 } }
],
fields: [
{ id: 'accuracy', label: 'AI执行准确性', description: '人工核对步骤', kind: 'choice', options: ['Pass', 'Failed'], max_length: 100, report: true },
{ id: 'note', label: '执行备注', description: '', kind: 'text', options: [], max_length: 2000, report: true },
{ id: 'correctness', label: '测试结果正确性', description: '', kind: 'choice', options: ['Pass', 'Failed'], max_length: 100, report: true }
]
}
}]
};
}
describe('步骤回放工作区扩展', () => {
let fixture: ComponentFixture<TaskExtensionPanelsComponent>;
let component: TaskExtensionPanelsComponent;
let http: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
fixture = TestBed.createComponent(TaskExtensionPanelsComponent);
component = fixture.componentInstance;
http = TestBed.inject(HttpTestingController);
});
afterEach(() => { http.verify(); fixture.destroy(); });
function open(data = snapshot()) {
fixture.componentRef.setInput('sessionId', data.session_id);
fixture.componentRef.setInput('enabled', true);
fixture.detectChanges();
http.expectOne(`/api/task-extensions/${data.session_id}`).flush(data);
fixture.detectChanges();
}
function toggle(side: 'left' | 'right') {
const plugin = component.data()!.plugins[0];
component.toggle(plugin, plugin.workspace.panels.find(panel => panel.side === side)!);
fixture.detectChanges();
}
it('does not request disabled extensions and shows no controls without a plugin', () => {
fixture.componentRef.setInput('sessionId', 'task-one');
fixture.detectChanges();
http.expectNone('/api/task-extensions/task-one');
open({ ...snapshot(), plugins: [] });
expect(fixture.nativeElement.querySelector('nav')).toBeNull();
});
it('starts collapsed, renders the full initial Markdown prompt and honors declared widths', () => {
open();
expect(fixture.nativeElement.querySelector('aside')).toBeNull();
toggle('left');
expect(fixture.nativeElement.querySelector('h1').textContent).toBe('原始需求');
expect(fixture.nativeElement.querySelector('table td').textContent).toBe('页面');
expect(component.width('left')).toBe(360);
expect(fixture.nativeElement.querySelector('.report-prose.compact')).toBeNull();
});
it('keeps either single panel beside playback below the former stacking breakpoint', () => {
open();
for (const width of [740, 600]) {
fixture.nativeElement.style.width = `${width}px`;
for (const side of ['left', 'right'] as const) {
toggle(side);
const panel = fixture.nativeElement.querySelector('aside').getBoundingClientRect();
const player = fixture.nativeElement.querySelector('.player-content').getBoundingClientRect();
const layout = fixture.nativeElement.querySelector('.extension-layout').getBoundingClientRect();
expect(panel.top).toBe(player.top);
expect(panel.width).toBeGreaterThan(0);
expect(player.width).toBeGreaterThan(0);
expect(side === 'left' ? panel.right : player.right).toBeCloseTo(side === 'left' ? player.left : panel.left, 0);
expect(Math.max(panel.right, player.right)).toBeLessThanOrEqual(layout.right + 1);
toggle(side);
}
}
});
it('automatically saves choices and clears while keeping unfilled outcomes blank', fakeAsync(() => {
open(); toggle('right');
const pass: HTMLButtonElement = fixture.nativeElement.querySelector('.choice-button.pass');
pass.click(); fixture.detectChanges();
expect(saved().values).toEqual({ accuracy: 'Pass', note: '', correctness: '' });
pass.click(); fixture.detectChanges(); expect(saved(2).values.accuracy).toBe('');
expect(fixture.nativeElement.textContent).toContain('人工评审已自动保存');
}));
function saved(revision = 1, id = 'task-one') {
const request = http.expectOne(`/api/task-extensions/${id}/review`);
expect(request.request.method).toBe('PUT');
const body = request.request.body, data = snapshot(id);
data.plugins[0].revision = revision; data.plugins[0].values = body.values;
request.flush(data); flushMicrotasks(); fixture.detectChanges(); return body;
}
it('debounces notes and never posts just from opening a task', fakeAsync(() => {
open(); toggle('right'); tick(700); http.expectNone('/api/task-extensions/task-one/review');
component.edit('review', 'note', '第一段'); tick(400);
component.edit('review', 'note', '完整备注'); tick(599);
http.expectNone('/api/task-extensions/task-one/review'); tick(1);
expect(saved().values.note).toBe('完整备注');
}));
it('does not overwrite an automatic save with an older read response', fakeAsync(() => {
open(); component.edit('review', 'note', '已保存的新内容'); tick(600); saved(2);
component.refresh(); fixture.detectChanges();
http.expectOne('/api/task-extensions/task-one').flush(snapshot()); fixture.detectChanges();
expect(component.drafts['review'].values['note']).toBe('已保存的新内容');
expect(component.drafts['review'].revision).toBe(2);
}));
it('flushes a pending note when the review panel is closed', fakeAsync(() => {
open(); component.edit('review', 'note', '关闭前保存'); fixture.destroy();
const request = http.expectOne('/api/task-extensions/task-one/review');
expect(request.request.body.values.note).toBe('关闭前保存');
const response = snapshot(); response.plugins[0].revision = 1; response.plugins[0].values = request.request.body.values;
request.flush(response); flushMicrotasks();
}));
it('preserves draft and request receipt on failure, and retries only when requested', fakeAsync(() => {
open(); toggle('right'); const plugin = component.data()!.plugins[0];
component.edit('review', 'accuracy', 'Failed'); void component.save(plugin);
const first = http.expectOne('/api/task-extensions/task-one/review'), requestId = first.request.body.request_id;
first.flush({}, { status: 503, statusText: 'Unavailable' }); flushMicrotasks(); tick(700);
http.expectNone('/api/task-extensions/task-one/review'); void component.save(plugin);
const retry = http.expectOne('/api/task-extensions/task-one/review'); expect(retry.request.body.request_id).toBe(requestId);
retry.flush({}, { status: 409, statusText: 'Conflict' }); flushMicrotasks();
expect(component.drafts['review'].values['accuracy']).toBe('Failed');
component.edit('review', 'accuracy', ''); void component.save(plugin);
const clear = saved(); expect(clear.values.accuracy).toBe(''); expect(clear.request_id).not.toBe(requestId);
}));
it('serializes rapid edits and saves the latest choice using the returned revision', fakeAsync(() => {
const data = snapshot(); data.plugins[0].workspace.fields[0].options = ['通过', '失败']; open(data); toggle('right');
const pass: HTMLButtonElement = fixture.nativeElement.querySelector('.choice-button.pass');
const failed: HTMLButtonElement = fixture.nativeElement.querySelector('.choice-button.failed');
pass.click(); failed.click(); fixture.detectChanges();
expect(component.drafts['review'].values['accuracy']).toBe('失败'); expect(saved().values.accuracy).toBe('通过');
const second = saved(2); expect(second.values.accuracy).toBe('失败'); expect(second.expected_revision).toBe(1);
expect(component.drafts['review'].dirty).toBeFalse();
}));
it('does not apply a delayed save response to the task now displayed', fakeAsync(() => {
open(); toggle('right'); component.edit('review', 'note', '只属于第一个任务'); void component.save(component.data()!.plugins[0]);
const oldSave = http.expectOne('/api/task-extensions/task-one/review');
fixture.componentRef.setInput('sessionId', 'task-two'); fixture.detectChanges();
http.expectOne('/api/task-extensions/task-two').flush(snapshot('task-two')); fixture.detectChanges();
oldSave.flush(snapshot('task-one')); flushMicrotasks(); fixture.detectChanges();
expect(component.data()!.session_id).toBe('task-two'); expect(component.drafts['review'].values['note']).toBe('');
}));
it('flushes a pending note on direct task switches and hides disabled plugins', fakeAsync(() => {
open(); toggle('right'); component.edit('review', 'note', '自动保存草稿');
fixture.componentRef.setInput('sessionId', 'task-two'); fixture.detectChanges();
http.expectOne('/api/task-extensions/task-two').flush(snapshot('task-two'));
const body = saved(1, 'task-one'); expect(body.values.note).toBe('自动保存草稿');
fixture.componentRef.setInput('sessionId', 'task-one'); fixture.detectChanges();
const first = snapshot(); first.plugins[0].revision = 1; first.plugins[0].values = body.values;
http.expectOne('/api/task-extensions/task-one').flush(first); fixture.detectChanges();
expect(component.drafts['review'].values['note']).toBe('自动保存草稿');
window.dispatchEvent(new Event('artemis-plugin-change')); fixture.detectChanges();
http.expectOne('/api/task-extensions/task-one').flush({ ...first, plugins: [] }); fixture.detectChanges();
expect(fixture.nativeElement.querySelector('aside')).toBeNull();
}));
it('keeps running tasks read-only then enables editing when the task finishes', () => {
open({ ...snapshot(), editable: false }); toggle('right');
expect(fixture.nativeElement.querySelector('fieldset').disabled).toBeTrue();
component.save(component.data()!.plugins[0]); http.expectNone('/api/task-extensions/task-one/review');
fixture.componentRef.setInput('taskStatus', 'completed'); fixture.detectChanges();
http.expectOne('/api/task-extensions/task-one').flush(snapshot()); fixture.detectChanges();
expect(fixture.nativeElement.querySelector('fieldset').disabled).toBeFalse();
});
it('collapses the opposite panel on narrow screens', () => {
const original = Object.getOwnPropertyDescriptor(window, 'innerWidth')!;
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 600 });
try {
open(); toggle('left'); toggle('right');
expect(component.panels('left').length).toBe(0);
expect(component.panels('right').length).toBe(1);
} finally { Object.defineProperty(window, 'innerWidth', original); }
});
it('shows original case sections in order without substituting the AI prompt', () => {
const data = snapshot();
data.plugins[0].workspace.panels[0].kind = 'original_case';
data.case_title = 'CASE-001 检查设置';
data.original_case = { preconditions: '已登录', steps: '1. 打开设置\n2. 核对版本', expected: '版本可见' };
open(data); toggle('left');
const text = fixture.nativeElement.textContent;
expect(text).toContain('CASE-001 检查设置');
expect(text).toMatch(/预置条件[\s\S]*已登录[\s\S]*操作步骤[\s\S]*核对版本[\s\S]*预期结果[\s\S]*版本可见/);
expect(text).not.toContain('原始需求');
});
it('saves before navigation and keeps the current task on save failure', fakeAsync(() => {
const first = snapshot(); first.navigation = { scope: 'batch', position: 1, total: 2, previous: null, next: 'task-two' };
open(first); toggle('right'); const emitted = jasmine.createSpy('navigate'); component.navigate.subscribe(emitted);
const saveButton = fixture.nativeElement.querySelector('button.save'), navigation = fixture.nativeElement.querySelector('.review-navigation');
expect(saveButton.compareDocumentPosition(navigation) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
component.edit('review', 'note', '切换前保存'); void component.move('next'); expect(emitted).not.toHaveBeenCalled();
http.expectOne('/api/task-extensions/task-one/review').flush({}, {status: 409, statusText: 'Conflict'});
flushMicrotasks(); expect(emitted).not.toHaveBeenCalled(); expect(component.drafts['review'].dirty).toBeTrue();
void component.move('next'); saved(); flushMicrotasks(); expect(emitted).toHaveBeenCalledOnceWith('task-two');
fixture.componentRef.setInput('sessionId', 'task-two'); fixture.detectChanges();
http.expectOne('/api/task-extensions/task-two').flush(snapshot('task-two')); fixture.detectChanges();
expect(component.panels('right').length).toBe(1);
}));
});
@@ -0,0 +1,201 @@
import { Component, ChangeDetectionStrategy, HostListener, TemplateRef, DestroyRef, computed, effect, inject, input, output, signal, viewChild } from '@angular/core';
import { DatePipe, NgTemplateOutlet } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { firstValueFrom } from 'rxjs';
import { ReportMarkdownComponent } from '../report-markdown/report-markdown.component';
import { ExtensionDraft, ExtensionPanel, TaskExtension, TaskExtensions, TaskExtensionsService } from '../../services/task-extensions.service';
@Component({
selector: 'app-task-extension-panels', standalone: true,
imports: [FormsModule, NgTemplateOutlet, DatePipe, ReportMarkdownComponent],
templateUrl: './task-extension-panels.component.html',
styleUrl: './task-extension-panels.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TaskExtensionPanelsComponent {
private service = inject(TaskExtensionsService);
sessionId = input<string | null>(null);
enabled = input(false);
taskStatus = input('');
sessionIds = input<string[]>([]);
navigate = output<string>();
navigation = computed(() => {
if (this.data()?.navigation) return this.data()!.navigation!;
const ids = this.sessionIds(), index = ids.indexOf(this.sessionId() || '');
return { scope: 'history', position: index + 1, total: ids.length,
previous: index > 0 ? ids[index - 1] : null,
next: index >= 0 && index < ids.length - 1 ? ids[index + 1] : null };
});
expandedWidth = output<number>();
headerControls = viewChild<TemplateRef<unknown>>('headerControls');
data = signal<TaskExtensions | null>(null);
error = signal('');
private destroyRef = inject(DestroyRef);
private savingKeys = signal<string[]>([]);
saving = computed(() => this.savingKeys().find(key => key.startsWith(`${this.sessionId()}/`))?.split('/')[1] || null);
moving = signal(false);
private scheduled = new Map<string, { timer: ReturnType<typeof setTimeout>; run: () => Promise<boolean> }>();
private inFlight = new Map<string, Promise<boolean>>();
notice = signal('');
opened = signal<Record<string, boolean>>({});
private refreshToken = signal(0);
private identity = '';
private generation = 0;
drafts: Record<string, ExtensionDraft> = {};
constructor() {
this.destroyRef.onDestroy(() => this.flushScheduled());
effect(onCleanup => {
const id = this.sessionId();
const enabled = this.enabled();
this.refreshToken();
this.taskStatus();
this.generation++;
const identity = enabled ? id || '' : '';
if (identity !== this.identity) {
this.flushScheduled();
this.identity = identity;
this.data.set(null);
if (!identity) this.opened.set({});
this.expandedWidth.emit(0);
}
this.error.set('');
this.notice.set('');
if (!id || !enabled) return;
const subscription = this.service.read(id).subscribe({
next: data => {
this.drafts = Object.fromEntries(data.plugins.map(plugin => [plugin.plugin_id, this.service.draft(id, plugin)]));
this.data.set(data);
this.expandedWidth.emit(this.width('left') + this.width('right'));
},
error: () => this.error.set('暂时无法加载任务扩展,请重试')
});
onCleanup(() => subscription.unsubscribe());
});
}
@HostListener('window:artemis-plugin-change')
refresh(): void { this.refreshToken.update(value => value + 1); }
async move(direction: 'previous' | 'next'): Promise<void> {
const destination = this.navigation()[direction], id = this.sessionId();
if (!destination || !id || !this.data() || this.moving()) return;
this.moving.set(true);
try {
for (const plugin of this.data()!.plugins) {
if (this.drafts[plugin.plugin_id]?.dirty && !await this.save(plugin)) return;
}
if (id === this.sessionId() && !this.destroyRef.destroyed) this.navigate.emit(destination);
} finally { this.moving.set(false); }
}
private cancelScheduled(key: string): void {
const pending = this.scheduled.get(key);
if (pending) clearTimeout(pending.timer);
this.scheduled.delete(key);
}
private flushScheduled(): void {
for (const pending of [...this.scheduled.values()]) void pending.run();
}
key(plugin: TaskExtension, panel: ExtensionPanel): string { return `${plugin.plugin_id}/${panel.id}`; }
panels(side: 'left' | 'right') {
return (this.data()?.plugins || []).flatMap(plugin => plugin.workspace.panels
.filter(panel => panel.side === side && this.opened()[this.key(plugin, panel)])
.map(panel => ({ plugin, panel })));
}
width(side: 'left' | 'right'): number {
return Math.max(0, ...this.panels(side).map(({ panel }) =>
Math.max(panel.width.minimum, Math.min(panel.width.preferred, panel.width.maximum))));
}
toggle(plugin: TaskExtension, panel: ExtensionPanel): void {
const key = this.key(plugin, panel);
const next = { ...this.opened(), [key]: !this.opened()[key] };
if (next[key] && window.innerWidth < 1000) {
for (const other of this.data()?.plugins || []) {
for (const sibling of other.workspace.panels) {
if (sibling.side !== panel.side) next[this.key(other, sibling)] = false;
}
}
}
this.opened.set(next);
this.expandedWidth.emit(this.width('left') + this.width('right'));
}
@HostListener('window:resize')
resize(): void {
if (window.innerWidth < 1000 && this.panels('left').length && this.panels('right').length) {
const next = { ...this.opened() };
for (const { plugin, panel } of this.panels('left')) next[this.key(plugin, panel)] = false;
this.opened.set(next);
this.expandedWidth.emit(this.width('left') + this.width('right'));
}
}
edit(pluginId: string, field: string, value: string): void {
const id = this.sessionId(), draft = this.drafts[pluginId];
if (!id || !draft || !this.data()?.editable || draft.values[field] === value) return;
draft.values[field] = value;
draft.dirty = true;
draft.requestId = undefined;
this.notice.set('');
const key = `${id}/${pluginId}`;
this.cancelScheduled(key);
const run = () => this.saveFor(id, pluginId, draft);
this.scheduled.set(key, { run, timer: setTimeout(() => void run(), 600) });
}
choose(pluginId: string, field: string, value: string): void {
this.edit(pluginId, field, this.drafts[pluginId].values[field] === value ? '' : value);
const plugin = this.data()?.plugins.find(item => item.plugin_id === pluginId);
if (plugin) void this.save(plugin);
}
reload(plugin: TaskExtension): void {
const id = this.sessionId();
if (!id) return;
this.cancelScheduled(`${id}/${plugin.plugin_id}`);
this.service.discard(id, plugin.plugin_id);
this.refresh();
}
save(plugin: TaskExtension): Promise<boolean> {
const id = this.sessionId();
if (!id || !this.data()?.editable) return Promise.resolve(false);
return this.saveFor(id, plugin.plugin_id, this.drafts[plugin.plugin_id]);
}
private async saveFor(id: string, pluginId: string, draft: ExtensionDraft): Promise<boolean> {
const key = `${id}/${pluginId}`;
this.cancelScheduled(key);
const pending = this.inFlight.get(key);
if (pending) {
if (!await pending) return false;
return draft.dirty ? this.saveFor(id, pluginId, draft) : true;
}
if (!draft.dirty) return true;
const submitted = { ...draft.values }, generation = this.generation;
const visible = () => !this.destroyRef.destroyed && id === this.sessionId() && generation === this.generation;
this.savingKeys.update(keys => [...keys, key]);
const request = firstValueFrom(this.service.save(id, pluginId, draft)).then(data => {
const saved = data.plugins.find(plugin => plugin.plugin_id === pluginId);
if (!saved) throw new Error('未收到保存回执');
draft.revision = saved.revision;
draft.dirty = JSON.stringify(draft.values) !== JSON.stringify(submitted);
draft.requestId = undefined;
if (!draft.dirty) draft.values = { ...saved.values };
if (visible()) {
this.data.set(data);
this.notice.set(draft.dirty ? '正在保存后续修改…' : '人工评审已自动保存');
}
return true;
}).catch(error => {
this.cancelScheduled(key);
if (visible()) this.notice.set(typeof error.error?.detail === 'string'
? `保存失败:${error.error.detail};输入已保留`
: '保存失败,输入已保留,请点击保存评审重试');
return false;
}).finally(() => {
this.inFlight.delete(key);
this.savingKeys.update(keys => keys.filter(item => item !== key));
});
this.inFlight.set(key, request);
const succeeded = await request;
return succeeded && draft.dirty ? this.saveFor(id, pluginId, draft) : succeeded;
}
}
@@ -18,6 +18,28 @@ describe('历史任务检索', () => {
}); });
afterEach(() => http.verify()); afterEach(() => http.verify());
it('历史错误独立折叠,展开不选择任务且后台刷新保留展开状态', () => {
const fixture = TestBed.createComponent(TaskHistoryComponent);
const row = {session_id:'blocked', name:'前置条件缺失', status:'failed', start_time:100,
error:'[web-blocked] FlashRunner failed: **目标未绑定**\n\n- 未执行删除'};
http.expectOne(r => r.url === '/api/automation/history').flush({total:1,items:[row]});
fixture.detectChanges();
const host = fixture.nativeElement as HTMLElement;
const details = host.querySelector<HTMLDetailsElement>('app-task-outcome details')!;
expect(details.closest('button')).toBeNull();
expect(details.open).toBeFalse();
details.querySelector('summary')!.click();
expect(details.open).toBeTrue();
expect(TestBed.inject(AgentService).selectSession).not.toHaveBeenCalled();
fixture.componentInstance.load(true);
http.expectOne(r => r.url === '/api/automation/history').flush({total:1,items:[{...row}]});
fixture.detectChanges();
expect(host.querySelector('app-task-outcome details')).toBe(details);
expect(details.open).toBeTrue();
expect(details.querySelector('strong')!.textContent).toBe('目标未绑定');
fixture.destroy();
});
it('已终止探索可删除,确认范围后刷新,取消不发请求', () => { it('已终止探索可删除,确认范围后刷新,取消不发请求', () => {
const fixture = TestBed.createComponent(TaskHistoryComponent); const fixture = TestBed.createComponent(TaskHistoryComponent);
const row = {session_id:'stopped', status:'cancelled', exploration_kind:'exploration', exploration_status:'paused', start_time:100}; const row = {session_id:'stopped', status:'cancelled', exploration_kind:'exploration', exploration_status:'paused', start_time:100};
@@ -268,7 +290,7 @@ describe('历史任务检索', () => {
expect(row.getAttribute('title')).toBe('请打开设置首页并核对两个真实分类'); expect(row.getAttribute('title')).toBe('请打开设置首页并核对两个真实分类');
expect(row.textContent).toContain('1.1 打开并核对设置'); expect(row.textContent).toContain('1.1 打开并核对设置');
expect(row.textContent).toContain('总耗时 1分5秒'); expect(row.textContent).toContain('总耗时 1分5秒');
expect(row.textContent).toContain('任务执行超时'); expect(fixture.nativeElement.querySelector('app-task-outcome summary').textContent).toContain('任务执行超时');
expect(fixture.nativeElement.querySelector('.status-failed .history-status').textContent).toContain('失败'); expect(fixture.nativeElement.querySelector('.status-failed .history-status').textContent).toContain('失败');
fixture.destroy(); fixture.destroy();
}); });
@@ -1,3 +1,4 @@
import { TaskOutcomeComponent } from '../task-outcome/task-outcome.component';
import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component'; import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component';
import { FavoriteButtonComponent } from '../favorite-button/favorite-button.component'; import { FavoriteButtonComponent } from '../favorite-button/favorite-button.component';
import { TaskModeBadgeComponent } from '../task-mode-badge/task-mode-badge.component'; import { TaskModeBadgeComponent } from '../task-mode-badge/task-mode-badge.component';
@@ -9,18 +10,55 @@ import { HttpClient, HttpParams } from '@angular/common/http';
import { AgentService } from '../../services/agent.service'; import { AgentService } from '../../services/agent.service';
@Component({ @Component({
selector:'app-task-history', standalone:true, imports:[CommonModule,FormsModule,TaskIdCopyComponent,TestResultBadgeComponent,TaskModeBadgeComponent,FavoriteButtonComponent], selector:'app-task-history', standalone:true, imports:[TaskOutcomeComponent,CommonModule,FormsModule,TaskIdCopyComponent,TestResultBadgeComponent,TaskModeBadgeComponent,FavoriteButtonComponent],
template:`<section aria-label="历史任务"><h3>历史任务 <small>{{total()}} 条</small><small class="query-status" role="status">{{loading() ? '正在查询…' : ''}}</small></h3> template:`<section aria-label="历史任务"><h3>历史任务 <small>{{total()}} 条</small><small class="query-status" role="status">{{loading() ? '正在查询…' : ''}}</small></h3>
<input aria-label="搜索历史任务" placeholder="搜索名称、目标、任务编号或标签" [(ngModel)]="query" (ngModelChange)="search()"> <input aria-label="搜索历史任务" placeholder="搜索名称、目标、任务编号或标签" [(ngModel)]="query" (ngModelChange)="search()">
<div class="filters"><label>起始日期<input type="date" [(ngModel)]="start" (ngModelChange)="search()"></label><label>结束日期<input type="date" [(ngModel)]="end" (ngModelChange)="search()"></label><label>状态<select [(ngModel)]="status" (ngModelChange)="search()"><option value="">全部状态</option><option value="completed">执行完成</option><option value="failed">失败</option><option value="cancelled">已取消</option><option value="interrupted">中断</option><option value="skipped">跳过</option></select></label></div> <div class="filters"><label>起始日期<input type="date" [(ngModel)]="start" (ngModelChange)="search()"></label><label>结束日期<input type="date" [(ngModel)]="end" (ngModelChange)="search()"></label><label>状态<select [(ngModel)]="status" (ngModelChange)="search()"><option value="">全部状态</option><option value="completed">执行完成</option><option value="failed">失败</option><option value="cancelled">已取消</option><option value="interrupted">中断</option><option value="skipped">跳过</option></select></label></div>
@if(error()){<p role="alert">{{error()}}</p>} @if(error()){<p role="alert">{{error()}}</p>}
@for(group of groups(); track group.key){<details [open]="!collapsed.has(group.key)" (toggle)="toggle(group.key,$event)"><summary>{{group.title}} <small>本页 {{group.rows.length}} 条</small></summary> @for(group of groups(); track group.key){<details [open]="!collapsed.has(group.key)" (toggle)="toggle(group.key,$event)"><summary>{{group.title}} <small>本页 {{group.rows.length}} 条</small></summary>
@for(row of group.rows; track row.session_id){<div class="history-item" [class]="'history-item status-'+row.status"><div class="history-heading"><app-favorite-button [sourceId]="row.session_id" /><button class="row" [class.selected]="agent.currentSessionId() === row.session_id" (click)="agent.selectSession(row.session_id,true)" [title]="row.initial_goal || ''"><strong>{{row.name || row.initial_goal}}</strong><span class="history-meta"><app-task-mode-badge [session]="row" />@if(row.exploration_kind === "validation"){<span>用例复跑 · v{{row.case_version}}</span>}<b class="history-status">{{label(row.status)}}</b><app-test-result-badge [result]="row.test_result" [executionStatus]="row.status" [compact]="true" /> <span>{{row.end_time ? '完成于 '+(row.end_time*1000 | date:'MM-dd HH:mm:ss') : '开始于 '+(row.start_time*1000 | date:'MM-dd HH:mm:ss')}}</span><span>总耗时 {{duration(row.start_time,row.end_time)}}</span></span>@if(row.error){<span class="history-error" [title]="row.error">{{row.error}}</span>}</button></div><app-task-id-copy [taskId]="row.session_id" />@if(agent.canDeleteSession(row)){<button class="delete-history" (click)="remove(row.session_id, row.exploration_kind)" aria-label="删除此历史任务"><span class="material-symbols-outlined">delete</span></button>}</div>} @for(row of group.rows; track row.session_id){<div class="history-item" [class]="'history-item status-'+row.status"><div class="history-heading"><app-favorite-button [sourceId]="row.session_id" /><button class="row" [class.selected]="agent.currentSessionId() === row.session_id" (click)="agent.selectSession(row.session_id,true)" [title]="row.initial_goal || ''"><strong>{{row.name || row.initial_goal}}</strong><span class="history-meta"><app-task-mode-badge [session]="row" />@if(row.exploration_kind === "validation"){<span>用例复跑 · v{{row.case_version}}</span>}<b class="history-status">{{label(row.status)}}</b><app-test-result-badge [result]="row.test_result" [executionStatus]="row.status" [compact]="true" /> <span>{{row.end_time ? '完成于 '+(row.end_time*1000 | date:'MM-dd HH:mm:ss') : '开始于 '+(row.start_time*1000 | date:'MM-dd HH:mm:ss')}}</span><span>总耗时 {{duration(row.start_time,row.end_time)}}</span></span></button></div><app-task-outcome [error]="row.error" [result]="row.test_result" [status]="row.status" /><app-task-id-copy [taskId]="row.session_id" />@if(agent.canDeleteSession(row)){<button class="delete-history" (click)="remove(row.session_id, row.exploration_kind)" aria-label="删除此历史任务"><span class="material-symbols-outlined">delete</span></button>}</div>}
</details>} </details>}
@if(!loading() && !total()){<p>没有符合条件的历史任务。</p>} @if(!loading() && !total()){<p>没有符合条件的历史任务。</p>}
<div class="pager"><button [disabled]="page === 1 || loading()" (click)="page=page-1;load()">上一页</button><span>{{page}} / {{pages()}}</span><button [disabled]="page >= pages() || loading()" (click)="page=page+1;load()">下一页</button></div> <div class="pager"><button [disabled]="page === 1 || loading()" (click)="page=page-1;load()">上一页</button><span>{{page}} / {{pages()}}</span><button [disabled]="page >= pages() || loading()" (click)="page=page+1;load()">下一页</button></div>
</section>`, </section>`,
styles:[`:host{display:block;font-size:12px;color:inherit}h3{display:flex;align-items:center;gap:6px;font-size:14px;margin:14px 0 8px}.query-status{margin-left:auto;font-size:11px;font-weight:400;white-space:nowrap}small{opacity:.6}input,select,button{font:inherit;color:inherit;border:1px solid #64748b55;border-radius:6px;padding:8px;background:transparent;box-sizing:border-box}input{width:100%}select option{background:white;color:#1e293b}.filters{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0}.filters label{flex:1;min-width:110px}.filters input,.filters select{display:block;width:100%}summary{padding:10px 0;cursor:pointer}.row{display:flex;flex-direction:column;gap:6px;width:100%;text-align:left;cursor:pointer;margin:5px 0;overflow-wrap:anywhere}.row strong{font-weight:600}.row.selected{border-color:#60a5fa;background:#3b82f615}.history-item{position:relative;border:1px solid #cbd5e1;border-left:3px solid #94a3b8;border-radius:8px;margin:6px 0;padding:8px;background:#fff}.history-item.status-completed,.history-item.status-success{border-left-color:#16a34a}.history-item.status-failed{border-left-color:#dc2626;background:#fffafa}.history-item.status-cancelled,.history-item.status-interrupted{border-left-color:#d97706;background:#fffdf8}.history-item.status-skipped{border-left-color:#64748b}.history-item:has(.row.selected){border-color:#60a5fa;background:#eff6ff}.history-item .row{border:0;padding:0;margin:0 0 4px}.history-item .row.selected{background:transparent}.history-item .row{padding-right:35px}.history-heading{display:flex;align-items:flex-start;gap:5px;min-width:0}.history-heading>app-favorite-button{margin-top:-5px}.history-heading>.row{flex:1;min-width:0;width:auto}.history-meta{display:flex;gap:5px;align-items:center;flex-wrap:wrap;color:#475569;font-size:11px}.history-status{display:inline-flex;align-items:center;box-sizing:border-box;height:20px;line-height:14px;border-radius:5px;padding:0 6px;background:#f1f5f9;color:#334155}.status-completed .history-status,.status-success .history-status{background:#dcfce7;color:#166534}.status-failed .history-status{background:#fee2e2;color:#991b1b}.status-cancelled .history-status,.status-interrupted .history-status{background:#fef3c7;color:#92400e}.history-error{font-size:11px;color:#b91c1c;line-height:1.4}.delete-history{position:absolute;right:5px;top:8px;border:0;padding:3px;color:#64748b;cursor:pointer;font-size:12px}.delete-history .material-symbols-outlined{font-size:16px}.delete-history:hover{color:#b91c1c}.pager{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:12px 0}button:disabled{opacity:.4}p{opacity:.7}`] styles:[`
:host{display:block;font-size:12px;color:inherit}
h3{display:flex;align-items:center;gap:6px;font-size:14px;margin:14px 0 8px}
.query-status{margin-left:auto;font-size:11px;font-weight:400;white-space:nowrap}
small{opacity:.6}
input,select,button{font:inherit;color:inherit;border:1px solid rgb(var(--app-border-control-rgb, 100 116 139) / 0.3333);border-radius:6px;padding:8px;background:transparent;box-sizing:border-box}
input{width:100%}
select option{background:var(--app-surface, white);color:var(--app-text-control, #1e293b)}
.filters{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0}
.filters label{flex:1;min-width:110px}
.filters input,.filters select{display:block;width:100%}
summary{padding:10px 0;cursor:pointer}
.row{display:flex;flex-direction:column;gap:6px;width:100%;text-align:left;cursor:pointer;margin:5px 0;overflow-wrap:anywhere}
.row strong{font-weight:600;display:flex;align-items:center;min-height:28px;padding-left:33px}
.row.selected{border-color:#60a5fa;background:#3b82f615}
.history-item{position:relative;border:1px solid var(--app-border-control, #cbd5e1);border-left:3px solid var(--app-border-control, #94a3b8);border-radius:8px;margin:6px 0;padding:8px;background:var(--app-surface, #fff)}
.history-item.status-completed,.history-item.status-success{border-left-color:#16a34a}
.history-item.status-failed{border-left-color:#dc2626;background:var(--app-error-soft, #fffafa)}
.history-item.status-cancelled,.history-item.status-interrupted{border-left-color:#d97706;background:var(--app-warning-soft, #fffdf8)}
.history-item.status-skipped{border-left-color:var(--app-border-control, #64748b)}
.history-item:has(.row.selected){border-color:#60a5fa;background:var(--app-primary-soft, #eff6ff)}
.history-item .row{border:0;padding:0 35px 0 0;margin:0 0 4px}
.history-item .row.selected{background:transparent}
.history-heading{position:relative;min-width:0}
.history-heading>app-favorite-button{position:absolute;left:0;top:0;z-index:1}
.history-heading>.row{min-width:0;width:100%}
.history-meta{display:flex;gap:5px;align-items:center;flex-wrap:wrap;color:var(--app-text-secondary, #475569);font-size:11px}
.history-status{display:inline-flex;align-items:center;box-sizing:border-box;height:20px;line-height:14px;border-radius:5px;padding:0 6px;background:var(--app-surface-soft, #f1f5f9);color:var(--app-text-control, #334155)}
.status-completed .history-status,.status-success .history-status{background:var(--app-success-soft, #dcfce7);color:var(--app-success-text, #166534)}
.status-failed .history-status{background:var(--app-error-soft, #fee2e2);color:var(--app-error-text, #991b1b)}
.status-cancelled .history-status,.status-interrupted .history-status{background:var(--app-warning-soft, #fef3c7);color:var(--app-warning-text, #92400e)}
.delete-history{position:absolute;right:5px;top:8px;border:0;padding:3px;color:var(--app-text-muted, #64748b);cursor:pointer;font-size:12px}
.delete-history .material-symbols-outlined{font-size:16px}
.delete-history:hover{color:var(--app-error-text, #b91c1c)}
.pager{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:12px 0}
button:disabled{opacity:.4}
p{opacity:.7}
`]
}) })
export class TaskHistoryComponent { export class TaskHistoryComponent {
agent=inject(AgentService); private http=inject(HttpClient); query='';start='';end='';status='';page=1; agent=inject(AgentService); private http=inject(HttpClient); query='';start='';end='';status='';page=1;
@@ -19,7 +19,7 @@ import { Component, DestroyRef, Input, inject, signal } from '@angular/core';
padding: 3px 0; cursor: pointer; user-select: text; border-radius: 3px; } padding: 3px 0; cursor: pointer; user-select: text; border-radius: 3px; }
.value { min-width: 0; overflow-wrap: anywhere; user-select: text; } .value { min-width: 0; overflow-wrap: anywhere; user-select: text; }
.material-symbols-outlined { flex-shrink: 0; font-size: 14px; user-select: none; } .material-symbols-outlined { flex-shrink: 0; font-size: 14px; user-select: none; }
.copy-target:hover { color: #2563eb; } .copy-target:hover { color: var(--app-primary-text, #2563eb); }
.copy-target:focus-visible { outline: 2px solid #60a5fa; outline-offset: 2px; } .copy-target:focus-visible { outline: 2px solid #60a5fa; outline-offset: 2px; }
.feedback { display: block; line-height: 1.6; user-select: text; } .feedback { display: block; line-height: 1.6; user-select: text; }
.feedback:empty { display: none; } .feedback:empty { display: none; }
@@ -0,0 +1,33 @@
import { TestBed } from '@angular/core/testing';
import { TaskLifecycleComponent, durationText } from './task-lifecycle.component';
describe('TaskLifecycleComponent', () => {
it('distinguishes missing time, zero and longer durations', () => {
expect(durationText(null)).toBe('未记录');
expect(durationText(0)).toBe('0 秒');
expect(durationText(125.7)).toBe('2 分 5 秒');
});
it('shows final review separately and never turns execution completion into a passed verdict', () => {
const fixture = TestBed.createComponent(TaskLifecycleComponent);
fixture.componentRef.setInput('value', {
schema_version:1, status:'completed', phase:'finished', observed_at:100,
durations:{queued:60, resource_wait:5, execution:100, final_review:20, paused:null}
});
fixture.detectChanges();
const text = fixture.nativeElement.textContent;
expect(text).toContain('执行已结束');
expect(text).toContain('1 分 0 秒');
expect(text).toContain('最终验收');
expect(text).toContain('20 秒');
expect(text).toContain('未记录');
expect(text).toContain('执行完成不等于验证通过');
expect(fixture.nativeElement.querySelector('app-test-result-badge')).toBeNull();
});
it('does not invent lifecycle data for an older backend', () => {
const fixture = TestBed.createComponent(TaskLifecycleComponent);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('details')).toBeNull();
});
});
@@ -0,0 +1,50 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { DatePipe } from '@angular/common';
import { TaskLifecycle } from '../../core/models/session.model';
export function durationText(value: number | null | undefined): string {
if (value == null || !Number.isFinite(value)) return '未记录';
const seconds = Math.max(0, Math.floor(value));
return seconds < 60 ? `${seconds} 秒` : `${Math.floor(seconds / 60)} 分 ${seconds % 60} 秒`;
}
@Component({
selector: 'app-task-lifecycle', standalone: true, imports: [DatePipe],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `@if (value(); as progress) {
<details class="lifecycle">
<summary>任务进度 · {{ phase(progress.phase) }}</summary>
<dl>
@for (item of items; track item.key) {
<div><dt>{{ item.label }}</dt><dd>{{ duration(progress.durations[item.key]) }}</dd></div>
}
</dl>
<p>{{ progress.note || '分项使用实际记录;缺失为未知。执行完成不等于验证通过。' }}</p>
@if (progress.observed_at != null) { <small>记录更新于 {{ progress.observed_at * 1000 | date:'HH:mm:ss' }}</small> }
</details>
}`,
styles: [`
:host { display: block; }
.lifecycle { margin: 8px 0; padding: 10px 12px; border: 1px solid var(--app-border-control, #cbd5e1); border-radius: 8px; color: var(--app-text-control, #1e293b); background: var(--app-surface, #fff); font-size: 12px; }
summary { cursor: pointer; font-weight: 600; }
dl { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 10px; margin: 12px 0; }
dt, p, small { color: var(--app-text-muted, #64748b); }
dd { margin: 4px 0 0; font-variant-numeric: tabular-nums; }
p { margin: 8px 0; line-height: 1.6; overflow-wrap: anywhere; }
`]
})
export class TaskLifecycleComponent {
value = input<TaskLifecycle | null>(null);
duration = durationText;
items = [
{key:'total', label:'总历时'}, {key:'queued', label:'后台排队'},
{key:'resource_wait', label:'等待设备/资源'}, {key:'paused', label:'暂停与示范'},
{key:'execution', label:'执行与整理'}, {key:'final_review', label:'最终验收'},
{key:'report_export', label:'报告导出'}
];
phase(value: string): string {
return ({queued:'后台排队', waiting_resources:'等待设备/资源', running:'执行中', paused:'已暂停',
demonstrating:'人工示范中', analyzing_demo:'正在分析示范', final_review:'正在最终验收',
report_export:'正在导出报告', finished:'执行已结束', unknown:'状态未记录'} as Record<string,string>)[value] || '状态待确认';
}
}
@@ -7,11 +7,11 @@ import { Session } from '../../core/models/session.model';
template: `<span class="mode-badge" [class]="'mode-badge ' + mode()" [title]="'执行模式:' + label()"><span class="material-symbols-outlined" aria-hidden="true">{{icon()}}</span>{{label()}}</span>`, template: `<span class="mode-badge" [class]="'mode-badge ' + mode()" [title]="'执行模式:' + label()"><span class="material-symbols-outlined" aria-hidden="true">{{icon()}}</span>{{label()}}</span>`,
styles: [` styles: [`
:host{display:inline-flex;vertical-align:middle} :host{display:inline-flex;vertical-align:middle}
.mode-badge{display:inline-flex;align-items:center;box-sizing:border-box;height:20px;gap:3px;padding:0 6px;border:1px solid #e2e8f0;border-radius:5px;background:#f8fafc;color:#475569;font-size:11px;font-weight:600;line-height:14px;white-space:nowrap} .mode-badge{display:inline-flex;align-items:center;box-sizing:border-box;height:20px;gap:3px;padding:0 6px;border:1px solid var(--app-border, #e2e8f0);border-radius:5px;background:var(--app-surface-soft, #f8fafc);color:var(--app-text-secondary, #475569);font-size:11px;font-weight:600;line-height:14px;white-space:nowrap}
.material-symbols-outlined{font-size:13px;line-height:1} .material-symbols-outlined{font-size:13px;line-height:1}
.explore{background:#eef2ff;border-color:#c7d2fe;color:#4338ca} .explore{background:var(--app-primary-soft, #eef2ff);border-color:var(--app-primary-border, #c7d2fe);color:var(--app-exploration-text, #4338ca)}
.flash{background:#fff7ed;border-color:#fed7aa;color:#9a3412} .flash{background:var(--app-warning-soft, #fff7ed);border-color:var(--app-warning-border, #fed7aa);color:var(--app-warning-text, #9a3412)}
.pro{background:#eff6ff;border-color:#bfdbfe;color:#1d4ed8} .pro{background:var(--app-primary-soft, #eff6ff);border-color:var(--app-primary-border, #bfdbfe);color:var(--app-primary-text, #1d4ed8)}
`] `]
}) })
export class TaskModeBadgeComponent { export class TaskModeBadgeComponent {
@@ -0,0 +1,71 @@
import { TestBed } from '@angular/core/testing';
import { TaskOutcomeComponent } from './task-outcome.component';
describe('任务结束说明', () => {
function create(status = 'failed', error = '[web-example] FlashRunner failed: **前置受阻**\n\n- 未找到目标设备\n- 未执行删除') {
const fixture = TestBed.createComponent(TaskOutcomeComponent);
fixture.componentRef.setInput('status', status);
fixture.componentRef.setInput('error', error);
fixture.detectChanges();
return fixture;
}
it('默认只显示一行摘要,展开后渲染完整 Markdown,不选择外层任务', () => {
const fixture = create();
const host = fixture.nativeElement as HTMLElement;
host.style.width = '280px';
const details = host.querySelector('details')!;
expect(details.open).toBeFalse();
expect(host.querySelector('.summary-text')!.textContent).toBe('前置受阻');
expect(host.textContent).not.toContain('FlashRunner failed');
expect(host.querySelector('button details')).toBeNull();
const select = jasmine.createSpy('外层任务选择');
host.addEventListener('click', select);
host.querySelector('summary')!.click();
expect(details.open).toBeTrue();
expect(select).not.toHaveBeenCalled();
expect(host.querySelector('.body strong')!.textContent).toBe('前置受阻');
expect(host.querySelectorAll('.body li').length).toBe(2);
fixture.componentRef.setInput('error', '[web-example] FlashRunner failed: **更新说明**\n\n- 已有记录');
fixture.detectChanges();
expect(host.querySelector('details')).toBe(details);
expect(details.open).toBeTrue();
host.querySelector('summary')!.click();
expect(details.open).toBeFalse();
});
it('优先采用已有结论,同时保留不同的执行错误和依据', () => {
const fixture = create();
fixture.componentRef.setInput('result', {status:'blocked', reason:'目标设备未绑定', evidence:['截图中仅有 **添加设备**']});
fixture.detectChanges();
const host = fixture.nativeElement as HTMLElement;
expect(host.querySelector('.summary-text')!.textContent).toBe('目标设备未绑定');
expect(host.textContent).toContain('未执行删除');
expect(host.textContent).toContain('已记录依据');
expect(host.textContent).toContain('添加设备');
});
it('完成但结论阻塞也展示说明,运行中和无内容时不展示', () => {
const fixture = create('running');
expect(fixture.nativeElement.querySelector('details')).toBeNull();
fixture.componentRef.setInput('status', 'completed');
fixture.componentRef.setInput('error', '');
fixture.componentRef.setInput('result', {status:'blocked',reason:'缺少预置数据',evidence:[]});
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.summary-text').textContent).toBe('缺少预置数据');
fixture.componentRef.setInput('result', null);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('details')).toBeNull();
});
it('不重复相同说明,不执行错误正文中的 HTML 或危险链接', () => {
const error = '**失败**\n<script>alert(1)</script>\n[打开](javascript:alert(1))';
const fixture = create('failed', error);
fixture.componentRef.setInput('result', {status:'blocked',reason:error,evidence:[]});
fixture.detectChanges();
const host = fixture.nativeElement as HTMLElement;
expect(host.querySelectorAll('app-report-markdown').length).toBe(1);
expect(host.querySelector('script')).toBeNull();
expect(host.querySelector('a[href^="javascript:"]')).toBeNull();
});
});
@@ -0,0 +1,62 @@
import { Component, computed, input } from '@angular/core';
import { CaseTestResult } from '../../core/models/session.model';
import { ReportMarkdownComponent } from '../report-markdown/report-markdown.component';
/** Read-only presentation of persisted results; opening it never requests a model. */
@Component({
selector: 'app-task-outcome',
standalone: true,
imports: [ReportMarkdownComponent],
template: `@if (visible()) {
<details (click)="$event.stopPropagation()" (keydown)="$event.stopPropagation()">
<summary aria-label="任务说明,展开或收起">
<span class="chevron" aria-hidden="true">›</span>
<span class="summary-text">{{summary()}}</span>
<span class="hint">详情</span>
</summary>
<div class="body">
@if (reason()) { <app-report-markdown [content]="reason()" /> }
@if (message() && message() !== reason()) {
@if (reason()) { <p class="label">执行记录</p> }
<app-report-markdown [content]="message()" />
}
@if (result()?.evidence?.length) {
<p class="label">已记录依据</p>
<ul>@for (item of result()!.evidence; track $index) {<li><app-report-markdown [content]="item" /></li>}</ul>
}
</div>
</details>
}`,
styles: [`
:host { display: block; min-width: 0; max-width: 100%; }
details { margin: 4px 0; font-size: 12px; color: var(--app-text-secondary, #475569); }
summary { display: flex; align-items: center; gap: 6px; min-width: 0; min-height: 26px; cursor: pointer; list-style: none; border-radius: 4px; }
summary::-webkit-details-marker { display: none; }
summary:focus-visible { outline: 2px solid var(--app-primary-text, #1a73e8); outline-offset: 2px; }
.summary-text { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chevron { flex: none; font-size: 18px; line-height: 20px; }
details[open] .chevron { transform: rotate(90deg); }
.hint { flex: none; font-size: 11px; color: var(--app-text-muted, #64748b); }
.body { max-height: 280px; overflow: auto; padding: 8px 10px; margin-top: 4px; border: 1px solid var(--app-border, #e2e8f0); border-radius: 6px; background: var(--app-surface, #fff); cursor: text; }
.label { font-size: 11px; color: var(--app-text-muted, #64748b); margin: 10px 0 4px; }
ul { margin: 4px 0; padding-left: 20px; }
`]
})
export class TaskOutcomeComponent {
error = input<string | null>();
result = input<CaseTestResult | null>();
status = input('');
// Strip only the known SDK envelope, preserving the actual explanation and diagnostics.
private explanation(value: string | null | undefined): string {
return (value || '').replace(/^\[[^\]\r\n]+\]\s*FlashRunner failed:\s*/, '').trim();
}
message = computed(() => this.explanation(this.error()));
reason = computed(() => this.explanation(this.result()?.reason));
visible = computed(() => ['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(this.status())
&& !!(this.message() || (this.result()?.status !== 'passed' && this.reason())));
summary = computed(() => {
const line = (this.reason() || this.message()).split(/\r?\n/).find(part => part.trim()) || '';
return line.replace(/^\s{0,3}(?:#{1,6}\s+|[-*+]\s+|>\s*)/, '')
.replace(/!?\[([^\]]+)\]\([^)]*\)/g, '$1').replace(/(\*\*|__|~~|`)/g, '');
});
}
@@ -5,7 +5,7 @@
</button> </button>
@if (open()) { @if (open()) {
<section class="plugin-options" aria-label="扩展插件设置"> <section class="plugin-options" aria-label="扩展插件设置">
<p>照常描述安卓测试任务。需要操作电脑、浏览器等外部步骤时,AI 会按需调用已启用并授权的插件。</p> <p>{{ target() === 'computer' ? '仅在电脑端执行。请启用并授权提供电脑画面观察的插件,例如 CUA。' : '照常描述安卓测试任务。需要操作电脑、浏览器等外部步骤时,AI 会按需调用已启用并授权的插件。' }}</p>
<label class="auto-option"><input type="checkbox" [(ngModel)]="automatic" (ngModelChange)="emit()">自动使用可用插件</label> <label class="auto-option"><input type="checkbox" [(ngModel)]="automatic" (ngModelChange)="emit()">自动使用可用插件</label>
@if (loading()) { <p role="status">正在读取插件…</p> } @if (loading()) { <p role="status">正在读取插件…</p> }
@else if (error()) { <p class="error" role="alert">{{ error() }} <button type="button" class="text-button" (click)="load()">重试</button></p> } @else if (error()) { <p class="error" role="alert">{{ error() }} <button type="button" class="text-button" (click)="load()">重试</button></p> }
@@ -21,9 +21,9 @@
} }
</div> </div>
} @empty { <p>还没有可用插件,请先安装、授权并启用。</p> } } @empty { <p>还没有可用插件,请先安装、授权并启用。</p> }
<p class="hint">不勾选任何插件时,本次任务只使用手机能力。</p> <p class="hint">{{ target() === 'computer' ? '仅 PC 任务需要选择至少一个提供电脑画面观察的插件。' : '不勾选任何插件时,本次任务只使用手机能力。' }}</p>
} @else { } @else {
<p class="hint">{{ available().length ? 'AI 会按需使用可用插件,无需逐次选择。关闭自动使用后,可指定本次允许使用的插件。' : '还没有可用插件,手机测试不受影响。' }}</p> <p class="hint">{{ available().length ? 'AI 会按需使用可用插件,无需逐次选择。关闭自动使用后,可指定本次允许使用的插件。' : target() === 'computer' ? '还没有可用电脑插件,请先安装、授权并启用。' : '还没有可用插件,手机测试不受影响。' }}</p>
} }
<a routerLink="/plugins" class="manage-link">管理插件 <span class="material-symbols-outlined" aria-hidden="true">arrow_forward</span></a> <a routerLink="/plugins" class="manage-link">管理插件 <span class="material-symbols-outlined" aria-hidden="true">arrow_forward</span></a>
</section> </section>
@@ -1,20 +1,20 @@
:host { display: block; width: 100%; color: #475569; font-size: 12px; } :host { display: block; width: 100%; color: var(--app-text-secondary, #475569); font-size: 12px; }
.plugin-toggle { display: inline-flex; align-items: center; gap: 6px; border: 1px solid #e2e8f0; border-radius: 20px; padding: 5px 10px; background: #f8fafc; color: #64748b; font: inherit; cursor: pointer; transition: background .15s, border-color .15s; } .plugin-toggle { display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--app-border, #e2e8f0); border-radius: 20px; padding: 5px 10px; background: var(--app-surface-soft, #f8fafc); color: var(--app-text-muted, #64748b); font: inherit; cursor: pointer; transition: background .15s, border-color .15s; }
.plugin-toggle:hover, .plugin-toggle[aria-expanded=true] { background: #eff6ff; border-color: #bfdbfe; color: #2563eb; } .plugin-toggle:hover, .plugin-toggle[aria-expanded=true] { background: var(--app-primary-soft, #eff6ff); border-color: var(--app-primary-border, #bfdbfe); color: var(--app-primary-text, #2563eb); }
.material-symbols-outlined { font-size: 16px; } .material-symbols-outlined { font-size: 16px; }
.mode { color: #2563eb; padding-left: 6px; border-left: 1px solid #dce5ef; } .mode { color: var(--app-primary-text, #2563eb); padding-left: 6px; border-left: 1px solid var(--app-primary-border, #dce5ef); }
.chevron { font-size: 15px; } .chevron { font-size: 15px; }
.plugin-options { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 14px; padding: 14px 16px; margin-top: 8px; display: grid; gap: 12px; } .plugin-options { background: var(--app-surface-soft, #f8fafc); border: 1px solid var(--app-border, #e2e8f0); border-radius: 14px; padding: 14px 16px; margin-top: 8px; display: grid; gap: 12px; }
p { margin: 0; line-height: 1.65; } p { margin: 0; line-height: 1.65; }
label { display: flex; align-items: center; gap: 8px; } label { display: flex; align-items: center; gap: 8px; }
.auto-option { font-weight: 500; color: #334155; } .auto-option { font-weight: 500; color: var(--app-text-control, #334155); }
input[type=checkbox] { accent-color: #2563eb; margin: 0; width: 14px; height: 14px; flex-shrink: 0; } input[type=checkbox] { accent-color: var(--app-primary-text, #2563eb); margin: 0; width: 14px; height: 14px; flex-shrink: 0; }
.choice { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; } .choice { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
select { font: inherit; color: #334155; background: #fff; border: 1px solid #dce3eb; border-radius: 8px; padding: 6px 10px; max-width: 100%; } select { font: inherit; color: var(--app-text-control, #334155); background: var(--app-surface, #fff); border: 1px solid var(--app-primary-border, #dce3eb); border-radius: 8px; padding: 6px 10px; max-width: 100%; }
select:disabled { opacity: .5; } select:disabled { opacity: .5; }
.hint { color: #64748b; font-size: 11px; } .hint { color: var(--app-text-muted, #64748b); font-size: 11px; }
.error { color: #b91c1c; } .error { color: var(--app-error-text, #b91c1c); }
.text-button, .manage-link { color: #2563eb; font: inherit; text-decoration: none; } .text-button, .manage-link { color: var(--app-primary-text, #2563eb); font: inherit; text-decoration: none; }
.text-button { background: none; border: 0; padding: 0 6px; cursor: pointer; } .text-button { background: none; border: 0; padding: 0 6px; cursor: pointer; }
.manage-link { display: inline-flex; align-items: center; gap: 4px; justify-self: start; } .manage-link { display: inline-flex; align-items: center; gap: 4px; justify-self: start; }
:focus-visible { outline: 2px solid #3b82f6; outline-offset: 3px; } :focus-visible { outline: 2px solid #3b82f6; outline-offset: 3px; }
@@ -75,4 +75,23 @@ describe('TaskPluginsComponent', () => {
expect(fixture.nativeElement.textContent).toContain('重试'); expect(fixture.nativeElement.textContent).toContain('重试');
expect(fixture.componentInstance.automatic).toBeTrue(); expect(fixture.componentInstance.automatic).toBeTrue();
}); });
it('filters PC-compatible plugins and removes incompatible manual choices on target change', () => {
const fixture = TestBed.createComponent(TaskPluginsComponent), component=fixture.componentInstance;
component.toggle();
const profile={name:'default',label:'默认',authorized:true,is_default:true};
const plugin=(id:string,targets:string[])=>({enabled:true,manifest:{id,name:id,targets},profiles:[profile]});
http.expectOne('/api/plugins').flush({plugins:[plugin('android-only',['android']),plugin('mixed-only',['mixed']),plugin('desktop',['computer'])]});
component.automatic=false;component.selected={'android-only':'default'};
const emitted=jasmine.createSpy('changed');component.changed.subscribe(emitted);
fixture.componentRef.setInput('target','computer');fixture.detectChanges();
expect(component.available().map(p=>p.manifest.id)).toEqual(['desktop']);
expect(emitted).toHaveBeenCalledWith({plugins:[]});
expect(fixture.nativeElement.textContent).toContain('画面观察');
const choice:HTMLInputElement=fixture.nativeElement.querySelector('.choice input');choice.click();fixture.detectChanges();
expect(emitted).toHaveBeenCalledWith({plugins:[{id:'desktop',profile:'default'}]});
fixture.componentRef.setInput('target','android');fixture.detectChanges();
expect(component.available().map(p=>p.manifest.id)).toEqual(['android-only','mixed-only']);
expect(component.selected).toEqual({});
});
}); });
@@ -1,4 +1,4 @@
import { Component, inject, output, signal } from '@angular/core'; import { Component, inject, input, OnChanges, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { InstalledPlugin, PluginService } from '../../services/plugin.service'; import { InstalledPlugin, PluginService } from '../../services/plugin.service';
@@ -11,7 +11,8 @@ export interface TaskPluginOptions {
selector: 'app-task-plugins', standalone: true, imports: [FormsModule, RouterLink], selector: 'app-task-plugins', standalone: true, imports: [FormsModule, RouterLink],
templateUrl: './task-plugins.component.html', styleUrl: './task-plugins.component.scss' templateUrl: './task-plugins.component.html', styleUrl: './task-plugins.component.scss'
}) })
export class TaskPluginsComponent { export class TaskPluginsComponent implements OnChanges {
target = input<'android' | 'computer'>('android');
changed = output<TaskPluginOptions>(); changed = output<TaskPluginOptions>();
private api = inject(PluginService); private api = inject(PluginService);
automatic = true; automatic = true;
@@ -25,8 +26,13 @@ export class TaskPluginsComponent {
return this.automatic ? '自动' : Object.keys(this.selected).length ? `已选 ${Object.keys(this.selected).length} 个` : '本次停用'; return this.automatic ? '自动' : Object.keys(this.selected).length ? `已选 ${Object.keys(this.selected).length} 个` : '本次停用';
} }
available() { available() {
return this.plugins().filter(p => p.enabled && p.profiles.some(profile => profile.authorized) && return this.plugins().filter(p => p.enabled && (!p.manifest.workspace || !!p.manifest.server || !!p.manifest.skill) && p.profiles.some(profile => profile.authorized) &&
p.manifest.targets.some(target => target === 'android' || target === 'mixed')); p.manifest.targets.some(target => target === this.target() || (this.target() === 'android' && target === 'mixed')));
}
ngOnChanges() {
const allowed = new Set(this.available().map(p => p.manifest.id));
this.selected = Object.fromEntries(Object.entries(this.selected).filter(([id]) => allowed.has(id)));
this.emit();
} }
authorizedProfiles(plugin: InstalledPlugin) { return plugin.profiles.filter(p => p.authorized); } authorizedProfiles(plugin: InstalledPlugin) { return plugin.profiles.filter(p => p.authorized); }
toggle() { toggle() {
Loaded 100 of 474 files, more files were not shown because too many files have changed in this diff. Show more