Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c3ca69c1d | ||
|
|
486e6d550d | ||
|
|
b53ca18595 | ||
|
|
ac3d8cf54a | ||
|
|
e90da727d4 | ||
|
|
4dc2057d43 | ||
|
|
baf069854b | ||
|
|
6b2c0f199d | ||
|
|
bb873ec0e0 | ||
|
|
8380057548 | ||
|
|
be6e8c2abd | ||
|
|
fac4ccb760 |
No files matched your search
+8
-1
@@ -6,6 +6,9 @@ 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
|
||||||
# 可选的后台步骤截图摘要单次超时,独立于上面的主模型请求超时。
|
# 可选的后台步骤截图摘要单次超时,独立于上面的主模型请求超时。
|
||||||
ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS=120
|
ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS=120
|
||||||
# 失败后最多重试 5 次,由 Artemis 统一管理,SDK 不叠加重试。
|
# 失败后最多重试 5 次,由 Artemis 统一管理,SDK 不叠加重试。
|
||||||
@@ -19,6 +22,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
|
||||||
|
|
||||||
@@ -85,7 +92,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
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
| 探索、示范、用例提炼与复跑 | [探索说明](docs/exploration-mode.md) |
|
| 探索、示范、用例提炼与复跑 | [探索说明](docs/exploration-mode.md) |
|
||||||
| 插件协议、适配或市场协同 | [插件开发规范](docs/plugin-development.md)及插件仓库自身的 AGENTS.md、DESIGN.md |
|
| 插件协议、适配或市场协同 | [插件开发规范](docs/plugin-development.md)及插件仓库自身的 AGENTS.md、DESIGN.md |
|
||||||
| 远程 Python SDK | [SDK 设计](packages/artemis-client/DESIGN.md),保持标准库薄客户端边界 |
|
| 远程 Python SDK | [SDK 设计](packages/artemis-client/DESIGN.md),保持标准库薄客户端边界 |
|
||||||
| 继续历史开发、确认版本或发布 | [迁入时的开发快照](docs/development-handoff.md)、[2.1.0 发行说明](docs/releases/2.1.0.md),再核对实时 Git 状态;`docs/plans/` 保留各自阶段的状态 |
|
| 继续历史开发、确认版本或发布 | [README 当前发行入口](README.md)、对应发行说明及实时 Git 状态;[开发快照](docs/development-handoff.md)与 `docs/plans/` 仅作为历史线索,不替代当前源码与发布包核验 |
|
||||||
|
|
||||||
## Quick Commands
|
## Quick Commands
|
||||||
|
|
||||||
@@ -54,13 +54,13 @@ Windows 首次安装使用 `start.bat`,国内镜像使用 `start-cn.bat`。启
|
|||||||
- 历史压缩、视觉摘要与工具上下文调整须保留关键文件回执和来源,维护稳定的多轮前缀;缓存率使用实际用量加权统计,不把缓存输入当作消失的 token,也不承诺固定收益。
|
- 历史压缩、视觉摘要与工具上下文调整须保留关键文件回执和来源,维护稳定的多轮前缀;缓存率使用实际用量加权统计,不把缓存输入当作消失的 token,也不承诺固定收益。
|
||||||
- 设备动作回放已移除;只读历史步骤、截图和上下文回忆仍保留,不因 replay 命名误删这些能力。
|
- 设备动作回放已移除;只读历史步骤、截图和上下文回忆仍保留,不因 replay 命名误删这些能力。
|
||||||
- 密钥仅通过网页或环境配置写入,读取配置不得回显;MCP 不接受密钥。全局配置修改影响后续任务,已保存配置优先于新安装默认值。
|
- 密钥仅通过网页或环境配置写入,读取配置不得回显;MCP 不接受密钥。全局配置修改影响后续任务,已保存配置优先于新安装默认值。
|
||||||
- 新安装普通任务默认 `gpt-6-luna / high`,探索默认 `gpt-6.1-sol / 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 入口和必要文档;默认值同步 `config/artemis.jsonc` 与 `artemis/resources/config/artemis.jsonc`。
|
- 新增可调参数集中进入配置层,按作用域更新环境示例、网页/MCP 入口和文档;明确仅供环境配置的参数不增加前端入口。JSON 默认值同步 `config/artemis.jsonc` 与 `artemis/resources/config/artemis.jsonc`。
|
||||||
- MCP 工具增删时同步工具注册、CLI 安装白名单、测试、Skill 和能力文档。
|
- MCP 工具增删时同步工具注册、CLI 安装白名单、测试、Skill 和能力文档。
|
||||||
- 同步 Codex MCP/Skill 时保留已有工具白名单、禁用状态和审批策略;异常时保留原配置并告警,不生成非法 TOML 或阻断网页启动。
|
- 同步 Codex MCP/Skill 时保留已有工具白名单、禁用状态和审批策略;异常时保留原配置并告警,不生成非法 TOML 或阻断网页启动。
|
||||||
- Windows PowerShell 启动脚本保持兼容 Windows PowerShell 5.1,避免编码导致解析失败。升级只做 Git 快进,保护已有配置和本地修改。
|
- Windows PowerShell 启动脚本保持兼容 Windows PowerShell 5.1,避免编码导致解析失败。升级只做 Git 快进,保护已有配置和本地修改。
|
||||||
@@ -75,6 +75,16 @@ Windows 首次安装使用 `start.bat`,国内镜像使用 `start-cn.bat`。启
|
|||||||
- 设备验证沿用用户指定设备;多设备且未指定时先确认。编写应用交互测试前,先通过 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
|
||||||
|
|
||||||
- 始终保护用户任务、历史库、环境配置和凭据。不要把个人设备序列号、绝对目录或 API Key 写入源码。
|
- 始终保护用户任务、历史库、环境配置和凭据。不要把个人设备序列号、绝对目录或 API Key 写入源码。
|
||||||
|
|||||||
@@ -52,22 +52,48 @@ components:
|
|||||||
|
|
||||||
ARTEMIS 面向使用自然语言执行、探索和复跑测试的人。当前产品包含 Flash、Pro、探索三种入口,按需调用安卓设备和电脑插件,提供批次、精选任务、历史、报告及证据。用户应能看懂当前在做什么、何时需要回答、哪些结果已有证据。
|
ARTEMIS 面向使用自然语言执行、探索和复跑测试的人。当前产品包含 Flash、Pro、探索三种入口,按需调用安卓设备和电脑插件,提供批次、精选任务、历史、报告及证据。用户应能看懂当前在做什么、何时需要回答、哪些结果已有证据。
|
||||||
|
|
||||||
本文记录主程序的职责边界及 `apps/showcase_ui` 的现有视觉与交互规则,不提出新的主题改版。远程 SDK 的协议边界见[局部设计](packages/artemis-client/DESIGN.md);独立插件仓库维护适配器设计和构建规则。阶段性进度见[开发快照](docs/development-handoff.md),发行状态见[2.1.0 说明](docs/releases/2.1.0.md)。
|
本文记录主程序的职责边界及 `apps/showcase_ui` 的视觉与交互规则,包括浅色和暗色外观。远程 SDK 的协议边界见[局部设计](packages/artemis-client/DESIGN.md);独立插件仓库维护适配器设计和构建规则。阶段性进度见[开发快照](docs/development-handoff.md),当前工作区版本与发布状态见[2.2.0 说明](docs/releases/2.2.0.md)。
|
||||||
|
|
||||||
## System architecture
|
## System architecture
|
||||||
|
|
||||||
| 层 | 职责与源码入口 |
|
| 层 | 职责与源码入口 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 网页、MCP、远程 SDK | 请求建模、交互与结果读取。`apps/showcase_ui`、`mcp_server`、`packages/artemis-client` 共同使用后台服务;MCP 和薄 SDK 不另启完整后台 |
|
| 网页、MCP、远程 SDK | 请求建模、交互与结果读取。`apps/showcase_ui`、`mcp_server`、`packages/artemis-client` 共同使用后台服务;MCP 和薄 SDK 不另启完整后台 |
|
||||||
| HTTP 与业务服务 | `apps/admin_console` 管理配置、统一队列、批次、收藏、历史、人工复验和导出;`TaskQueueService` 及现有资源锁负责执行准入与互斥 |
|
| 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/agents`、`artemis/runtime` 执行 Flash/Pro;`artemis/exploration` 管理交流、预算、示范与用例版本;`artemis/llm`、`artemis/services/llm.py` 统一 Responses 请求 |
|
||||||
| 插件与证据 | `artemis/plugins` 管理安装、会话、版本固定、来源、截图、录像和附件;插件执行工具动作,宿主管理任务生命周期和最终验收 |
|
| 插件与证据 | `artemis/plugins` 管理安装、会话、版本固定、来源、截图、录像和附件;插件执行工具动作,宿主管理任务生命周期和最终验收 |
|
||||||
| 配置与交付 | `artemis/config`、两份 `artemis.jsonc` 和环境配置提供默认值;`scripts` 负责启动、镜像、独立升级和源码打包 |
|
| 配置与交付 | `artemis/config`、两份 `artemis.jsonc` 和环境配置提供默认值;`scripts` 负责启动、镜像、独立升级和源码打包 |
|
||||||
|
|
||||||
请求重试、业务执行与最终验收分别处理。提交使用稳定标识;创建批次或探索草稿不自动执行,查询不产生设备动作。执行准入和恢复遵守现有资源锁,任务固定所用插件版本,不能通过另建队列绕开互斥。
|
请求重试、业务执行与最终验收分别处理。提交使用稳定标识;创建批次或探索草稿不自动执行,查询不产生设备动作。执行准入和恢复遵守现有资源锁,任务固定所用插件版本,不能通过另建队列绕开互斥。
|
||||||
|
|
||||||
|
设备保活是辅助服务。保活所属锁或旧保活清理目录不可访问时记录警告,不使设备连接、恢复或监控退出;未取得保活所属锁时不发送保活动作,也不创建替代锁绕过所属权。执行资源锁、主服务注册锁和队列写锁仍须成功取得。
|
||||||
|
|
||||||
|
运行时准备阶段失败记录 `initialization_failed`,批次保留详细原因并暂停未开始项,修复环境后由用户继续,已失败项不自动重跑。模型请求导致的批次暂停单独标记;模型暂停结束后恢复既定调度及失败即停止策略。用户暂停、停止全部任务和服务重启后的恢复暂停均需用户继续,不被模型恢复覆盖。Flash 与 Pro/探索 Operator 的生成失败复用统一备用模型与重试预算;不完整输出不会成为动作,已有动作不因模型切换而重放。
|
||||||
|
|
||||||
最终验收有独立总预算,按请求超时、重试和剩余任务时间协调,保留取证结果;完整有效的结构化结论才更新正式裁定。`completed`、工具成功、探索通过、用例提炼和复跑通过各自表达自己的状态。
|
最终验收有独立总预算,按请求超时、重试和剩余任务时间协调,保留取证结果;完整有效的结构化结论才更新正式裁定。`completed`、工具成功、探索通过、用例提炼和复跑通过各自表达自己的状态。
|
||||||
|
|
||||||
|
Responses 消息交给工具、下游解析器或正式裁定前,必须具有服务端 `completed` 状态;工具参数的原始字符串必须是完整 JSON 对象,不能使用库自动补全的半截参数。流结束或库补出的 `last` 标记不能替代完成状态。缺少终止事件、明确未完成或失败的流丢弃预览,进入现有无效输出恢复策略,最多重新请求一次,配置为零次重试时不重试;恢复保留已有工具证据,不重新提交业务任务。非流式、结构化输出和串接解析器遵守同一检查。
|
||||||
|
|
||||||
|
普通应用启动及重试只负责启动或恢复前台,不隐式停止或重启应用;停止是独立的显式动作。焦点窗口为空时,使用已恢复活动所在任务的归属补充判断;只有可见候选任务、没有已恢复活动时继续等待。超时返回焦点与任务诊断,不升级为强制停止。
|
||||||
|
|
||||||
|
测试执行遵循用户任务指令和中途指导;明确的次数及禁止要求交给执行模型结合观察和历史遵守。执行层不额外编译自然语言约束、维护动作配额、比对提交内容指纹或请求 AI 二次审批。Flash、Pro 和插件动作经既有参数、目标及插件授权校验后调用工具;资源锁、取消与暂停、真实执行回执、输入状态观察和结果不明时不自动重发的行为继续保留。历史任务中的旧约束日志仅作为原始诊断记录,不再参与执行判定。
|
||||||
|
|
||||||
|
取消来源由拥有中断动作的运行时明确赋值,并穿过任务控制及模型等待包装层传递。用户停止、指导替换、暂停、任务或检查替换、已知外层时限,与单次请求超时、传输超时及最终验收总预算分开记录。没有来源的取消保留未知,HTTP 499 不能单独证明用户取消或代理故障;取消不进入模型重试,也不保存取消消息中的指导正文。
|
||||||
|
|
||||||
|
模型诊断复用现有 trace:自动重试保留本地请求编号,用尝试编号和上次尝试编号关联,重试及终止事件带对应尝试编号。可用的出站、服务端请求、Responses 响应及服务端链路编号随记录导出,仅采集白名单字段;历史缺失的来源及编号不补推。
|
||||||
|
|
||||||
|
任务耗时复用现有队列、工作进程控制文件与验收 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 与主动探索的验收规则保持不变。
|
Flash 的最终 AI 判定与分析独立配置,默认关闭;首页功能配置和 MCP 共用保存的全局设置。任务开始时固定设置,尚未开始的任务使用最新值;批量、精选及提炼用例的 Flash 复跑同样遵循。关闭时跳过最终 Checker,保留正常执行、步骤、截图、报告和人工复验;结果为“执行完成 · 未做 AI 判定”,不标记“AI确认”或测试通过。Pro 与主动探索的验收规则保持不变。
|
||||||
|
|
||||||
示范来源保存在元数据中,贯穿分析、提问、规划、执行、验收和用例生成。补充网址不会隐式把电脑示范改到安卓;用户明确改端或提出跨端步骤时按其要求执行。证据必须对应真实来源、窗口、采集时间与操作。
|
示范来源保存在元数据中,贯穿分析、提问、规划、执行、验收和用例生成。补充网址不会隐式把电脑示范改到安卓;用户明确改端或提出跨端步骤时按其要求执行。证据必须对应真实来源、窗口、采集时间与操作。
|
||||||
@@ -76,11 +102,13 @@ Flash 的最终 AI 判定与分析独立配置,默认关闭;首页功能配
|
|||||||
|
|
||||||
收藏保存独立模板与执行配置,用例复跑固定版本;删除原任务不破坏已保存模板。历史与导出的有效结论合并人工复验,不能用新裁定改写旧证据。
|
收藏保存独立模板与执行配置,用例复跑固定版本;删除原任务不破坏已保存模板。历史与导出的有效结论合并人工复验,不能用新裁定改写旧证据。
|
||||||
|
|
||||||
普通任务新安装默认 `gpt-6-luna / high`,探索单独使用 `gpt-6.1-sol / high`,保留已有配置。上下文参照上限 300k、默认历史压缩预算 80k,均不表示整条任务累计 token 限额。优化稳定前缀时保留关键文件回执、路径、校验和来源;费用收益依实际网关用量及计价判断。
|
普通任务新安装默认 `gpt-6-luna / high`,探索单独使用 `gpt-6.1-terra / high`,保留已有配置。上下文参照上限 300k、默认历史压缩预算 80k,均不表示整条任务累计 token 限额。优化稳定前缀时保留关键文件回执、路径、校验和来源;费用收益依实际网关用量及计价判断。
|
||||||
|
|
||||||
|
普通 Pro 的计划全部完成后,执行循环返回既有收尾和最终验收流程,不要求额外设备动作;有新指导或验收反馈时先处理反馈,探索模式仍须交付完整报告。完成计划不等于测试通过。Operator 写入计划时拒绝新增或增加同名顶层里程碑,撤销该次写入并返回工具错误;局部替换不得夹带目标范围外的里程碑,已有重复项可继续整理,确需重复的阶段使用不同名称。
|
||||||
|
|
||||||
## Colors
|
## Colors
|
||||||
|
|
||||||
整体使用浅灰画布、白色内容面和深色正文。首页主要操作使用蓝色,探索发送及相关主操作保留现有靛色;绿色、琥珀色、红色用于结果与需要处理的状态。新控件复用所在区域的语义色。
|
浅色使用浅灰画布、白色内容面和深色正文;暗色使用深蓝灰画布、分层内容面和浅色正文。首页主要操作使用蓝色,探索发送及相关主操作保留靛色;绿色、琥珀色、红色用于结果与需要处理的状态。新控件复用所在区域的语义色。
|
||||||
|
|
||||||
| 用途 | 值与来源 |
|
| 用途 | 值与来源 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -88,7 +116,23 @@ Flash 的最终 AI 判定与分析独立配置,默认关闭;首页功能配
|
|||||||
| 首页操作 / 悬停 | `#1a73e8` / `#1557b0`,来自[首页样式](apps/showcase_ui/src/app/pages/home/home.component.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) |
|
| 探索操作 / 浅色状态面 | `#4f46e5` / `#eef2ff`,来自[探索面板](apps/showcase_ui/src/app/components/exploration-panel/exploration-panel.component.scss) |
|
||||||
| 正文层级 / 边框 | 强调 `#0f172a`、次级 `#475569`、辅助 `#64748b`;常规边框 `#e2e8f0` |
|
| 正文层级 / 边框 | 强调 `#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` |
|
||||||
|
|
||||||
|
状态文字、状态背景、边框和高光分别引用对应变量;保留实体主按钮的色彩与白色按钮文字。截图、录像和用户文件使用原始颜色,不对页面整体施加反色滤镜。
|
||||||
|
|
||||||
加载、空状态、断开、未验证与失败使用明确文案。执行完成可以有完成标识,测试裁定仍需独立展示;不能让绿色完成态替代测试通过结论。
|
加载、空状态、断开、未验证与失败使用明确文案。执行完成可以有完成标识,测试裁定仍需独立展示;不能让绿色完成态替代测试通过结论。
|
||||||
|
|
||||||
@@ -100,6 +144,8 @@ Flash 的最终 AI 判定与分析独立配置,默认关闭;首页功能配
|
|||||||
|
|
||||||
模型控件正文为 13px,探索输入正文为 14px,用例标题为 18px;辅助信息依现有组件使用 11–12px。保留字号、字重、留白形成的层次,不通过大段加粗堆叠状态。Markdown、表格、长链接和中文长标题应换行,不撑破容器。
|
模型控件正文为 13px,探索输入正文为 14px,用例标题为 18px;辅助信息依现有组件使用 11–12px。保留字号、字重、留白形成的层次,不通过大段加粗堆叠状态。Markdown、表格、长链接和中文长标题应换行,不撑破容器。
|
||||||
|
|
||||||
|
计划、笔记和报告共用 Markdown 渲染,支持标题、表格与列对齐、嵌套列表、任务清单、引用、分隔线、链接、图片、删除线及围栏代码块。宽表格与代码在自身区域横向滚动,保留代码空白和字面内容。计划中的 `[/]` 进行中、`verify` / `assert@end` 和 `finding` 标识继续可辨认。原始 HTML 按文字展示,链接与图片限制安全协议,并保留 Angular 的 HTML 清理。
|
||||||
|
|
||||||
图标沿用 Material Symbols Outlined,常规全局尺寸 20px;组件局部按原有尺寸调整。图标与相邻文字垂直居中,独立图标按钮提供可访问名称。
|
图标沿用 Material Symbols Outlined,常规全局尺寸 20px;组件局部按原有尺寸调整。图标与相邻文字垂直居中,独立图标按钮提供可访问名称。
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
@@ -129,8 +175,12 @@ Flash 的最终 AI 判定与分析独立配置,默认关闭;首页功能配
|
|||||||
| 探索建议 | 推荐内容为用户口吻的可编辑回复;点击只填草稿,用户发送后才继续任务 | [探索组件](apps/showcase_ui/src/app/components/exploration-panel/exploration-panel.component.ts) |
|
| 探索建议 | 推荐内容为用户口吻的可编辑回复;点击只填草稿,用户发送后才继续任务 | [探索组件](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) |
|
| 历史标题与星标 | 星标只占标题,第二行状态与编号正常左对齐;状态徽标沿用 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) |
|
| 验收状态 | 展示准备证据、核对、生成与校验等阶段及可用的耗时、预算、重试诊断;未完成的流式文字不变为正式通过标签 | [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) |
|
| 截图、录像与文件 | 显示真实来源和缺口;按时间定位并保留跳转前的播放/暂停状态,文件成功以真实保存回执为准 | [工作区说明](docs/automation-workspace.md) |
|
||||||
| 保存和错误反馈 | 配置加载、保存、空值、失败与密钥已配置状态有明确提示;空密钥保留已有值,连接测试与保存分开 | [模型配置](apps/showcase_ui/src/app/components/model-settings/model-settings.component.ts) |
|
| 保存和错误反馈 | 配置加载、保存、空值、失败与密钥已配置状态有明确提示;空密钥保留已有值,连接测试与保存分开 | [模型配置](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) |
|
||||||
|
|
||||||
这些规则对应已接受的现有行为。改动后结合实际层叠样式、渲染尺寸和相关状态检查,不能只核对一处声明。
|
这些规则对应已接受的现有行为。改动后结合实际层叠样式、渲染尺寸和相关状态检查,不能只核对一处声明。
|
||||||
|
|
||||||
@@ -141,3 +191,15 @@ Flash 的最终 AI 判定与分析独立配置,默认关闭;首页功能配
|
|||||||
- 保留可见焦点、禁用反馈和减少动画偏好;录制红点表示实际录制,等待或执行中使用各自文案。
|
- 保留可见焦点、禁用反馈和减少动画偏好;录制红点表示实际录制,等待或执行中使用各自文案。
|
||||||
- 源码已更新而页面仍旧时,先核对服务目录、构建与缓存;不通过反复增加样式覆盖掩盖旧构建问题。
|
- 源码已更新而页面仍旧时,先核对服务目录、构建与缓存;不通过反复增加样式覆盖掩盖旧构建问题。
|
||||||
- 视觉变化验证代表性宽度、长标题、加载、等待回答、错误与展开状态;文档格式检查不等于渲染验证。开发检查按[前端指南](apps/showcase_ui/AGENTS.md)执行。
|
- 视觉变化验证代表性宽度、长标题、加载、等待回答、错误与展开状态;文档格式检查不等于渲染验证。开发检查按[前端指南](apps/showcase_ui/AGENTS.md)执行。
|
||||||
|
|
||||||
|
## Workspace plugin extensions
|
||||||
|
|
||||||
|
工作区扩展使用声明式清单版本 3,宿主提供回放面板、人工表单、任务存储和报告接口;不运行插件页面脚本,不另建队列或结果库。纯工作区插件不加入模型执行选择。接口定义见 [工作区插件](docs/workspace-plugins.md)。
|
||||||
|
|
||||||
|
回放两侧面板默认收起、独立展开,普通浮窗和影院模式共用;插件声明期望/最小/最大宽度,宿主按视口收缩,窄屏一次展开一侧,始终保持面板位于回放的左侧或右侧。任务需求只展示本次运行最初的提示词,复用安全 Markdown 渲染。人工表单字段均可留空,不调用模型、不覆盖已有执行结论或人工复验。
|
||||||
|
|
||||||
|
评审按单次任务和插件 ID 保存版本历史;运行中只读,终态可编辑或清空。停用/卸载隐藏面板与新导出字段但保留记录,任务删除时同步清理。报告只采用已安装且启用的贡献,空值保持空白;修改记录或插件状态使后续报告缓存失效。
|
||||||
|
|
||||||
|
执行详情中的 Payload 默认折叠;其他超长字段也默认折叠,展开后保留完整可复制内容并限制显示高度。原始用例面板仅由启用的工作区插件声明,额外导入列不影响未启用插件的用户。评审支持所有执行模式和目标,用户修改评审后自动保存:结果选择立即保存,备注短暂防抖;切换任务前保存,失败时保留输入和当前任务。仅用户编辑触发写入,AI 不推断人工结论。
|
||||||
|
|
||||||
|
模型网络代理策略仅由环境项 `OPENAI_USE_SYSTEM_PROXY` 控制,默认 false。同步、异步模型客户端和模型凭据检查统一遵循;不出现在前端配置中,不改变 TLS 验证与自定义 CA 设置。
|
||||||
@@ -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.1.0 已发布**:新增 Flash 最终 AI 判定与分析开关,默认关闭;网页字体和图标随源码分发,修复 Google 字体访问超时导致的构建与启动失败。功能与升级步骤见 [2.1.0 发行说明](docs/releases/2.1.0.md);Python 3.12 启动修复见 [2.0.1](docs/releases/2.0.1.md),探索、插件和精选任务等功能见 [2.0.0](docs/releases/2.0.0.md)。
|
**2.4.3**:异常模型输出耗尽重试后只失败当前任务,避免触发全局暂停;补充请求关联和有界参数摘要,便于定位流式异常。见 [2.4.3 发行说明](docs/releases/2.4.3.md)。人工评审插件保持 1.1.0;列表优化见 [2.4.2](docs/releases/2.4.2.md),安装修复见 [2.4.1](docs/releases/2.4.1.md)。
|
||||||
|
|
||||||
## 主要功能
|
## 主要功能
|
||||||
|
|
||||||
- **自然语言任务**: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,10 +17,12 @@
|
|||||||
|
|
||||||
## 安装与启动
|
## 安装与启动
|
||||||
|
|
||||||
准备一台开启 USB 调试并完成授权的 Android 手机,或已启动的 Android 模拟器。首次安装需要联网。可使用 Git 克隆或下载 1.1.1 及更新版本的发布源码包;新版 ZIP 解压后也保留 Git 更新能力。
|
Android 或跨端测试需准备一台开启 USB 调试并完成授权的 Android 手机,或已启动的 Android 模拟器;仅 PC 测试无需手机在线,但需启用并授权电脑观察插件。首次安装需要联网。可使用 Git 克隆或下载 1.1.1 及更新版本的发布源码包;新版 ZIP 解压后也保留 Git 更新能力。
|
||||||
|
|
||||||
网页字体和 Material Symbols 图标已随源码提供,网页构建及显示无需访问 Google Fonts。
|
网页字体和 Material Symbols 图标已随源码提供,网页构建及显示无需访问 Google Fonts。
|
||||||
|
|
||||||
|
顶部导航的太阳/月亮按钮可一键切换亮暗主题,自动记住当前浏览器的选择;首次使用跟随系统。计划、笔记和报告支持 Markdown 表格、嵌套列表、任务清单、引用、链接及代码块,宽表格可在内容区横向滚动。
|
||||||
|
|
||||||
### Windows
|
### Windows
|
||||||
|
|
||||||
在 PowerShell 中执行:
|
在 PowerShell 中执行:
|
||||||
@@ -52,16 +54,16 @@ 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`;已保存的地址不变。直接编辑环境文件后需重启相应服务。不要提交自己的密钥。
|
||||||
|
|
||||||
“功能开关”默认将任务截图长边限制为 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 和主动探索不受影响。
|
首页“功能配置”提供“Flash 最终 AI 判定与分析”,默认关闭。切换后自动保存并提示结果,失败恢复原值;无需重启服务。开启后,Flash 在执行结束时根据用例需求和执行步骤进行独立复核;关闭后显示“执行完成 · 未做 AI 判定”,保留执行步骤、截图和报告,不自动记为测试通过。此设置适用于所有 Flash 任务,包括批量、精选、MCP/SDK 和探索提炼用例的 Flash 复跑;保存后对尚未开始的任务生效,运行中的任务保持原设置。Pro 和主动探索不受影响。其余功能配置仍需点击底部保存按钮。
|
||||||
|
|
||||||
报告会区分“执行完成”和“验证通过”;没有验证证据的结果标为“未验证”。任务步骤与截图可查看,设备动作回放不提供。
|
报告会区分“执行完成”和“验证通过”;没有验证证据的结果标为“未验证”。任务步骤与截图可查看,设备动作回放不提供。
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
@@ -123,7 +124,9 @@ def recover_batches():
|
|||||||
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
|
||||||
batch.update(
|
batch.update(
|
||||||
status="paused", reason="服务已重启;请检查中断任务,点击继续执行未开始任务"
|
status="paused",
|
||||||
|
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 +167,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 +198,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 +240,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 +315,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]
|
||||||
@@ -437,11 +469,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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,8 +41,14 @@ 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)
|
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)
|
||||||
if cached(batch_id, batch):
|
if cached(batch_id, batch):
|
||||||
return {
|
return {
|
||||||
"status": "ready",
|
"status": "ready",
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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-3"
|
||||||
|
|
||||||
|
|
||||||
|
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": "执行完成",
|
||||||
@@ -166,6 +193,7 @@ 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 {}
|
||||||
@@ -206,13 +234,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),
|
||||||
@@ -225,6 +257,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,
|
||||||
@@ -234,7 +267,9 @@ 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),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -246,6 +281,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"]),
|
||||||
@@ -268,10 +378,22 @@ 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"))),
|
||||||
(
|
(
|
||||||
@@ -558,7 +680,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)
|
||||||
@@ -572,6 +706,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()
|
||||||
@@ -705,6 +844,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":
|
||||||
@@ -826,8 +969,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
|
||||||
@@ -977,7 +1119,9 @@ 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"]
|
||||||
records = [session_report(job["session_id"]) for job in store.jobs(batch_id)]
|
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)]
|
||||||
if progress:
|
if progress:
|
||||||
progress(0, len(records), "正在生成批次汇总")
|
progress(0, len(records), "正在生成批次汇总")
|
||||||
rows = [("任务编号", "任务名称", "执行状态", "验证结论", "异常原因")]
|
rows = [("任务编号", "任务名称", "执行状态", "验证结论", "异常原因")]
|
||||||
@@ -991,6 +1135,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)
|
||||||
@@ -1001,6 +1156,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)
|
||||||
@@ -1059,5 +1223,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,6 +62,7 @@ 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",
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -1514,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:
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ 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 服务。
|
||||||
- 保持现有简体中文文案、颜色、间距和控件样式,菜单、弹窗和表单必须清晰可读。
|
- 保持现有简体中文文案、颜色、间距和控件样式,菜单、弹窗和表单必须清晰可读。
|
||||||
- 修改任务或配置交互时覆盖加载、保存、失败和空状态;不要将执行中误标成录屏中或把完成误标成验证通过。
|
- 修改任务或配置交互时覆盖加载、保存、失败和空状态;不要将执行中误标成录屏中或把完成误标成验证通过。
|
||||||
|
|||||||
Generated
+1872
-2314
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "artemis-showcase-ui",
|
"name": "artemis-showcase-ui",
|
||||||
"version": "2.1.0",
|
"version": "2.4.3",
|
||||||
"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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,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>
|
||||||
@@ -183,160 +183,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>
|
||||||
@@ -514,6 +361,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 +370,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">
|
||||||
@@ -847,13 +695,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 +719,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 +747,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 +996,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 +1017,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 +1035,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 +1055,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 +1075,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 +1089,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 +1107,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 +1194,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
@@ -3,7 +3,10 @@ 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';
|
||||||
/**
|
/**
|
||||||
@@ -248,7 +251,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: [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
|
||||||
@@ -504,12 +507,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(() => {
|
||||||
@@ -959,6 +963,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 +1455,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) {
|
||||||
|
|||||||
+15
-1
@@ -20,6 +20,7 @@
|
|||||||
@if (r.error) { <div class="report-error" role="status"><strong>异常原因</strong><p>{{r.error}}</p></div> }
|
@if (r.error) { <div class="report-error" role="status"><strong>异常原因</strong><p>{{r.error}}</p></div> }
|
||||||
</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>
|
||||||
|
@if(j.error){<small>{{j.error}}</small>}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</section>}
|
</section>}
|
||||||
}
|
}
|
||||||
+55
-45
@@ -1,60 +1,70 @@
|
|||||||
: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-error{margin-top:11px;padding:10px 12px;background:var(--app-error-soft, #fff1f2);border-left:3px solid #e11d48;border-radius:6px;color:var(--app-error-text, #9f1239);strong{font-size:12px;}p{margin:4px 0 0;color:var(--app-error-text, #9f1239);white-space:pre-wrap;}}
|
||||||
.report-meta{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:10px;margin:12px 0;div{min-width:0;padding:10px 12px;border:1px solid #e2e8f0;border-radius:8px;background:#fff;}strong{font-size:12px;color:#475569;}p{margin:5px 0 0;white-space:pre-wrap;color:#1e293b;}}
|
.report-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-content{margin-top:14px;padding:14px;border:1px solid #e2e8f0;border-radius:10px;background:#fff;h3{margin-bottom:12px;}}
|
.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-body{overflow-x:auto;overflow-wrap:anywhere;color:#1e293b;line-height:1.75;}
|
.report-body{overflow-x:auto;overflow-wrap:anywhere;color:var(--app-text-control, #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;}
|
||||||
+25
-1
@@ -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');
|
||||||
|
|||||||
@@ -11,6 +11,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 +24,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: [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 {
|
||||||
|
|||||||
+124
-124
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
+27
@@ -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] || '来源类型未记录';
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
@@ -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);
|
||||||
|
}
|
||||||
+43
-43
@@ -1,78 +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 { 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; }
|
||||||
.demo { border:1px solid #bbf7d0; border-radius:12px; padding:12px; background:#f0fdf4; }
|
.demo { border:1px solid var(--app-success-border, #bbf7d0); border-radius:12px; padding:12px; background:var(--app-success-soft, #f0fdf4); }
|
||||||
.demo.warning { background:#fffbeb; border-color:#fcd34d; }
|
.demo.warning { background:var(--app-warning-soft, #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}
|
||||||
.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 .manual-control {display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap;margin:0 0 10px}
|
.composer .manual-control {display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap;margin:0 0 10px}
|
||||||
.composer .manual-control label {display:flex;flex-direction:row;align-items:center;gap:8px;margin:0;min-width:0}
|
.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 select {width:auto;min-width:110px;height:36px;padding:6px 10px}
|
||||||
.composer .manual-control button {align-self:center;height:36px;white-space:nowrap}
|
.composer .manual-control button {align-self:center;height:36px;white-space:nowrap}
|
||||||
.composer-status {font-size:11px;border-radius:20px;padding:3px 8px;background:#eef2ff;color:#4f46e5}
|
.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:#92400e;background:#fef3c7}
|
.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
-1
@@ -9,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(() => {
|
||||||
|
|||||||
+1
-1
@@ -44,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() {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
+10
-1
@@ -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>
|
||||||
}
|
}
|
||||||
+119
-113
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-1
@@ -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');
|
||||||
|
|||||||
+15
-3
@@ -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();
|
||||||
|
|
||||||
|
|||||||
+46
-46
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-1
@@ -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> }
|
||||||
|
|||||||
+15
-15
@@ -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; } }
|
||||||
+39
-1
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+29
@@ -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 {
|
||||||
|
|||||||
+40
-18
@@ -29,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()" />
|
||||||
@@ -52,7 +52,7 @@ 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" [disabled]="busy() || loading() || !loaded()" />
|
<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>
|
<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>
|
||||||
@@ -77,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);
|
||||||
|
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 };
|
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 {
|
||||||
@@ -113,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 文件权限。')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-5
@@ -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);
|
||||||
@@ -221,7 +221,7 @@ describe('持久化模型与功能设置', () => {
|
|||||||
http.expectNone('/api/system/capability-settings');
|
http.expectNone('/api/system/capability-settings');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Flash 最终 AI 判定默认关闭,保存失败保留选择,成功后重新打开仍保留', async () => {
|
it('Flash 判定切换即保存,失败恢复原值,重新切换后持久生效', async () => {
|
||||||
const fixture = TestBed.createComponent(CapabilitySettingsComponent);
|
const fixture = TestBed.createComponent(CapabilitySettingsComponent);
|
||||||
const capabilities = { ...fixture.componentInstance.config };
|
const capabilities = { ...fixture.componentInstance.config };
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
@@ -234,17 +234,20 @@ describe('持久化模型与功能设置', () => {
|
|||||||
expect(toggle.checked).toBeFalse();
|
expect(toggle.checked).toBeFalse();
|
||||||
expect(root.textContent).toContain('不影响 Pro');
|
expect(root.textContent).toContain('不影响 Pro');
|
||||||
toggle.click(); fixture.detectChanges(); await fixture.whenStable();
|
toggle.click(); fixture.detectChanges(); await fixture.whenStable();
|
||||||
root.querySelector<HTMLButtonElement>('button')!.click();
|
|
||||||
let request = http.expectOne('/api/system/capability-settings');
|
let request = http.expectOne('/api/system/capability-settings');
|
||||||
expect(request.request.body.flash_final_check_enabled).toBeTrue();
|
expect(request.request.body.flash_final_check_enabled).toBeTrue();
|
||||||
|
expect(fixture.componentInstance.busy()).toBeTrue();
|
||||||
request.flush({}, {status:500,statusText:'Error'});
|
request.flush({}, {status:500,statusText:'Error'});
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
expect(fixture.componentInstance.config.flash_final_check_enabled).toBeTrue();
|
await fixture.whenStable();
|
||||||
|
expect(fixture.componentInstance.config.flash_final_check_enabled).toBeFalse();
|
||||||
|
expect(toggle.checked).toBeFalse();
|
||||||
expect(fixture.componentInstance.error()).toContain('保存失败');
|
expect(fixture.componentInstance.error()).toContain('保存失败');
|
||||||
root.querySelector<HTMLButtonElement>('button')!.click();
|
toggle.click(); fixture.detectChanges(); await fixture.whenStable();
|
||||||
request = http.expectOne('/api/system/capability-settings');
|
request = http.expectOne('/api/system/capability-settings');
|
||||||
const saved = request.request.body;
|
const saved = request.request.body;
|
||||||
request.flush(saved);
|
request.flush(saved);
|
||||||
|
expect(fixture.componentInstance.message()).toContain('无需重启');
|
||||||
fixture.destroy();
|
fixture.destroy();
|
||||||
const reopened = TestBed.createComponent(CapabilitySettingsComponent);
|
const reopened = TestBed.createComponent(CapabilitySettingsComponent);
|
||||||
reopened.detectChanges();
|
reopened.detectChanges();
|
||||||
@@ -253,4 +256,24 @@ describe('持久化模型与功能设置', () => {
|
|||||||
expect(reopened.nativeElement.querySelector('[aria-label="Flash 最终 AI 判定与分析"]').checked).toBeTrue();
|
expect(reopened.nativeElement.querySelector('[aria-label="Flash 最终 AI 判定与分析"]').checked).toBeTrue();
|
||||||
reopened.destroy();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -78,27 +78,27 @@ 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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
+68
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+58
@@ -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
-1
@@ -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 {
|
||||||
|
|||||||
+1
-1
@@ -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);
|
||||||
|
|||||||
+115
@@ -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>
|
||||||
+60
@@ -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; }
|
||||||
|
}
|
||||||
+221
@@ -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);
|
||||||
|
}));
|
||||||
|
});
|
||||||
+201
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,9 +25,9 @@ import { AgentService } from '../../services/agent.service';
|
|||||||
h3{display:flex;align-items:center;gap:6px;font-size:14px;margin:14px 0 8px}
|
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}
|
.query-status{margin-left:auto;font-size:11px;font-weight:400;white-space:nowrap}
|
||||||
small{opacity:.6}
|
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,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%}
|
input{width:100%}
|
||||||
select option{background:white;color:#1e293b}
|
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{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0}
|
||||||
.filters label{flex:1;min-width:110px}
|
.filters label{flex:1;min-width:110px}
|
||||||
.filters input,.filters select{display:block;width:100%}
|
.filters input,.filters select{display:block;width:100%}
|
||||||
@@ -35,26 +35,26 @@ import { AgentService } from '../../services/agent.service';
|
|||||||
.row{display:flex;flex-direction:column;gap:6px;width:100%;text-align:left;cursor:pointer;margin:5px 0;overflow-wrap:anywhere}
|
.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 strong{font-weight:600;display:flex;align-items:center;min-height:28px;padding-left:33px}
|
||||||
.row.selected{border-color:#60a5fa;background:#3b82f615}
|
.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{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-completed,.history-item.status-success{border-left-color:#16a34a}
|
||||||
.history-item.status-failed{border-left-color:#dc2626;background:#fffafa}
|
.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:#fffdf8}
|
.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:#64748b}
|
.history-item.status-skipped{border-left-color:var(--app-border-control, #64748b)}
|
||||||
.history-item:has(.row.selected){border-color:#60a5fa;background:#eff6ff}
|
.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{border:0;padding:0 35px 0 0;margin:0 0 4px}
|
||||||
.history-item .row.selected{background:transparent}
|
.history-item .row.selected{background:transparent}
|
||||||
.history-heading{position:relative;min-width:0}
|
.history-heading{position:relative;min-width:0}
|
||||||
.history-heading>app-favorite-button{position:absolute;left:0;top:0;z-index:1}
|
.history-heading>app-favorite-button{position:absolute;left:0;top:0;z-index:1}
|
||||||
.history-heading>.row{min-width:0;width:100%}
|
.history-heading>.row{min-width:0;width:100%}
|
||||||
.history-meta{display:flex;gap:5px;align-items:center;flex-wrap:wrap;color:#475569;font-size:11px}
|
.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:#f1f5f9;color:#334155}
|
.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:#dcfce7;color:#166534}
|
.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:#fee2e2;color:#991b1b}
|
.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:#fef3c7;color:#92400e}
|
.status-cancelled .history-status,.status-interrupted .history-status{background:var(--app-warning-soft, #fef3c7);color:var(--app-warning-text, #92400e)}
|
||||||
.history-error{font-size:11px;color:#b91c1c;line-height:1.4}
|
.history-error{font-size:11px;color:var(--app-error-text, #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{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 .material-symbols-outlined{font-size:16px}
|
||||||
.delete-history:hover{color:#b91c1c}
|
.delete-history:hover{color:var(--app-error-text, #b91c1c)}
|
||||||
.pager{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:12px 0}
|
.pager{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:12px 0}
|
||||||
button:disabled{opacity:.4}
|
button:disabled{opacity:.4}
|
||||||
p{opacity:.7}
|
p{opacity:.7}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
+3
-3
@@ -15,12 +15,12 @@ export function testResultSourceLabel(result: CaseTestResult | null | undefined)
|
|||||||
template: `<span [class]="'test-result-badge ' + status()" [class.compact]="compact()" [class.prominent]="prominent()" [title]="sourceLabel() + ':' + (result()?.reason || '执行状态与测试结论分别记录')"><span class="material-symbols-outlined" aria-hidden="true">{{icon()}}</span>{{label()}}</span>`,
|
template: `<span [class]="'test-result-badge ' + status()" [class.compact]="compact()" [class.prominent]="prominent()" [title]="sourceLabel() + ':' + (result()?.reason || '执行状态与测试结论分别记录')"><span class="material-symbols-outlined" aria-hidden="true">{{icon()}}</span>{{label()}}</span>`,
|
||||||
styles: [`
|
styles: [`
|
||||||
:host{display:inline-flex;vertical-align:middle;max-width:100%}
|
:host{display:inline-flex;vertical-align:middle;max-width:100%}
|
||||||
.test-result-badge{display:inline-flex;align-items:center;gap:4px;white-space:nowrap;border-radius:6px;border:1px solid #cbd5e1;padding:3px 8px;font-size:12px;font-weight:700;line-height:1.5;background:#f1f5f9;color:#475569}
|
.test-result-badge{display:inline-flex;align-items:center;gap:4px;white-space:nowrap;border-radius:6px;border:1px solid var(--app-border-control, #cbd5e1);padding:3px 8px;font-size:12px;font-weight:700;line-height:1.5;background:var(--app-surface-soft, #f1f5f9);color:var(--app-text-secondary, #475569)}
|
||||||
.material-symbols-outlined{font-size:16px;line-height:1}
|
.material-symbols-outlined{font-size:16px;line-height:1}
|
||||||
.passed{background:#15803d;color:#fff;border-color:#15803d}
|
.passed{background:#15803d;color:#fff;border-color:#15803d}
|
||||||
.failed{background:#b91c1c;color:#fff;border-color:#b91c1c}
|
.failed{background:#b91c1c;color:#fff;border-color:#b91c1c}
|
||||||
.inconclusive{background:#fff7ed;color:#9a3412;border-color:#fdba74}
|
.inconclusive{background:var(--app-warning-soft, #fff7ed);color:var(--app-warning-text, #9a3412);border-color:var(--app-warning-border, #fdba74)}
|
||||||
.blocked{background:#fef3c7;color:#92400e;border-color:#fbbf24}
|
.blocked{background:var(--app-warning-soft, #fef3c7);color:var(--app-warning-text, #92400e);border-color:#fbbf24}
|
||||||
.compact{box-sizing:border-box;height:20px;padding:0 6px;font-size:11px;line-height:14px;gap:3px;border-radius:5px}
|
.compact{box-sizing:border-box;height:20px;padding:0 6px;font-size:11px;line-height:14px;gap:3px;border-radius:5px}
|
||||||
.compact .material-symbols-outlined{font-size:13px}
|
.compact .material-symbols-outlined{font-size:13px}
|
||||||
.prominent{padding:4px 9px;font-size:14px;gap:5px}
|
.prominent{padding:4px 9px;font-size:14px;gap:5px}
|
||||||
|
|||||||
+14
-14
@@ -29,24 +29,24 @@ import { TestResultBadgeComponent, testResultSourceLabel } from './test-result-b
|
|||||||
</section>`,
|
</section>`,
|
||||||
styles: [`
|
styles: [`
|
||||||
:host{display:block;margin:8px 0}
|
:host{display:block;margin:8px 0}
|
||||||
.test-result-card{border:1px solid #cbd5e1;border-left:3px solid #64748b;border-radius:10px;padding:12px 16px;background:#f8fafc;color:#334155}
|
.test-result-card{border:1px solid var(--app-border-control, #cbd5e1);border-left:3px solid var(--app-border-control, #64748b);border-radius:10px;padding:12px 16px;background:var(--app-surface-soft, #f8fafc);color:var(--app-text-control, #334155)}
|
||||||
.passed{border-color:#bbf7d0;border-left-color:#15803d;background:#f0fdf4}
|
.passed{border-color:var(--app-success-border, #bbf7d0);border-left-color:#15803d;background:var(--app-success-soft, #f0fdf4)}
|
||||||
.failed{border-color:#fecaca;border-left-color:#b91c1c;background:#fef2f2}
|
.failed{border-color:var(--app-error-border, #fecaca);border-left-color:#b91c1c;background:var(--app-error-soft, #fef2f2)}
|
||||||
.inconclusive,.blocked{border-color:#fde68a;border-left-color:#b45309;background:#fffbeb}
|
.inconclusive,.blocked{border-color:var(--app-warning-border, #fde68a);border-left-color:#b45309;background:var(--app-warning-soft, #fffbeb)}
|
||||||
header{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap}
|
header{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap}
|
||||||
.caption{font-size:12px;font-weight:600;color:#475569}
|
.caption{font-size:12px;font-weight:600;color:var(--app-text-secondary, #475569)}
|
||||||
.caption small{font-size:11px;font-weight:400;margin-left:7px;color:#64748b}
|
.caption small{font-size:11px;font-weight:400;margin-left:7px;color:var(--app-text-muted, #64748b)}
|
||||||
.reason{font-size:13px;line-height:1.7;margin:8px 0 0;white-space:pre-wrap;overflow-wrap:anywhere;color:#1e293b}
|
.reason{font-size:13px;line-height:1.7;margin:8px 0 0;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--app-text-control, #1e293b)}
|
||||||
details{margin-top:6px;font-size:12px;line-height:1.7;color:#475569}
|
details{margin-top:6px;font-size:12px;line-height:1.7;color:var(--app-text-secondary, #475569)}
|
||||||
summary{cursor:pointer;width:fit-content;padding:2px 0;font-weight:600}
|
summary{cursor:pointer;width:fit-content;padding:2px 0;font-weight:600}
|
||||||
ol{padding-left:22px;margin:8px 0 0}li{padding:3px 0;white-space:pre-wrap;overflow-wrap:anywhere}
|
ol{padding-left:22px;margin:8px 0 0}li{padding:3px 0;white-space:pre-wrap;overflow-wrap:anywhere}
|
||||||
.review-time{margin:5px 0 0;color:#64748b;font-size:11px}
|
.review-time{margin:5px 0 0;color:var(--app-text-muted, #64748b);font-size:11px}
|
||||||
.review-toolbar,.review-actions{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-top:8px;font-size:12px}
|
.review-toolbar,.review-actions{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-top:8px;font-size:12px}
|
||||||
button{border:1px solid #cbd5e1;border-radius:6px;padding:4px 9px;background:#fff;color:#475569;font:inherit;cursor:pointer;line-height:1.5}
|
button{border:1px solid var(--app-border-control, #cbd5e1);border-radius:6px;padding:4px 9px;background:var(--app-surface, #fff);color:var(--app-text-secondary, #475569);font:inherit;cursor:pointer;line-height:1.5}
|
||||||
button:hover{border-color:#94a3b8;background:#f8fafc}button:disabled{opacity:.55;cursor:wait}button:focus-visible{outline:2px solid #3b82f6;outline-offset:2px}
|
button:hover{border-color:var(--app-border-control, #94a3b8);background:var(--app-surface-soft, #f8fafc)}button:disabled{opacity:.55;cursor:wait}button:focus-visible{outline:2px solid #3b82f6;outline-offset:2px}
|
||||||
.review-toolbar span{color:#15803d}.review-form{border-top:1px solid #cbd5e1;margin-top:10px;padding-top:10px;font-size:12px}
|
.review-toolbar span{color:var(--app-success-text, #15803d)}.review-form{border-top:1px solid var(--app-border-control, #cbd5e1);margin-top:10px;padding-top:10px;font-size:12px}
|
||||||
label{display:block;color:#475569}textarea{display:block;box-sizing:border-box;width:100%;resize:vertical;border:1px solid #cbd5e1;border-radius:6px;margin-top:5px;padding:7px 9px;color:#1e293b;background:#fff;font:inherit;line-height:1.6}
|
label{display:block;color:var(--app-text-secondary, #475569)}textarea{display:block;box-sizing:border-box;width:100%;resize:vertical;border:1px solid var(--app-border-control, #cbd5e1);border-radius:6px;margin-top:5px;padding:7px 9px;color:var(--app-text-control, #1e293b);background:var(--app-surface, #fff);font:inherit;line-height:1.6}
|
||||||
.confirm-pass{color:#166534;border-color:#86efac;background:#f0fdf4}.confirm-fail{color:#991b1b;border-color:#fca5a5;background:#fef2f2}.review-error{color:#b91c1c;margin:8px 0 0}
|
.confirm-pass{color:var(--app-success-text, #166534);border-color:var(--app-success-border, #86efac);background:var(--app-success-soft, #f0fdf4)}.confirm-fail{color:var(--app-error-text, #991b1b);border-color:var(--app-error-border, #fca5a5);background:var(--app-error-soft, #fef2f2)}.review-error{color:var(--app-error-text, #b91c1c);margin:8px 0 0}
|
||||||
@media(max-width:480px){.test-result-card{padding:10px 12px}}
|
@media(max-width:480px){.test-result-card{padding:10px 12px}}
|
||||||
`]
|
`]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export const MODEL_PRESETS = ['gpt-6-luna', 'gpt-6.1-sol', 'gpt-6-astra', 'mimo-v2.6-pro'];
|
export const MODEL_PRESETS = ['gpt-6-luna', 'gpt-6.1-terra', 'gpt-6.1-sol', 'gpt-6-astra', 'mimo-v2.6-pro'];
|
||||||
@@ -21,6 +21,15 @@ export interface ModelInfo {
|
|||||||
architecture?: string;
|
architecture?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TaskLifecycle {
|
||||||
|
schema_version: number;
|
||||||
|
status: string;
|
||||||
|
phase: string;
|
||||||
|
observed_at: number | null;
|
||||||
|
durations: Record<string, number | null>;
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CaseTestResult {
|
export interface CaseTestResult {
|
||||||
status: 'passed' | 'failed' | 'inconclusive' | 'blocked';
|
status: 'passed' | 'failed' | 'inconclusive' | 'blocked';
|
||||||
reason: string;
|
reason: string;
|
||||||
@@ -39,6 +48,7 @@ export interface CaseReviewResponse {
|
|||||||
|
|
||||||
export interface TaskQueueItem {
|
export interface TaskQueueItem {
|
||||||
session_id: string;
|
session_id: string;
|
||||||
|
phase?: string | null;
|
||||||
batch_id?: string | null;
|
batch_id?: string | null;
|
||||||
goal: string;
|
goal: string;
|
||||||
profile?: string;
|
profile?: string;
|
||||||
@@ -51,6 +61,7 @@ export interface TaskQueueItem {
|
|||||||
|
|
||||||
export interface Session {
|
export interface Session {
|
||||||
session_id: string;
|
session_id: string;
|
||||||
|
phase?: string | null;
|
||||||
profile?: string | null;
|
profile?: string | null;
|
||||||
exploration_id?: string;
|
exploration_id?: string;
|
||||||
exploration_latest_run_id?: string;
|
exploration_latest_run_id?: string;
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
<section class="hero-section">
|
<section class="hero-section">
|
||||||
<h1 class="hero-title">
|
<h1 class="hero-title">
|
||||||
|
|
||||||
你想在手机上完成什么任务?
|
你想在{{ pcOnly() ? '电脑' : '手机' }}上完成什么任务?
|
||||||
</h1>
|
</h1>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -1367,9 +1367,9 @@
|
|||||||
<!-- Prerequisites Drop Alert if any blocker probe is not passing -->
|
<!-- Prerequisites Drop Alert if any blocker probe is not passing -->
|
||||||
@if (hasReadinessReport() && !isReady()) {
|
@if (hasReadinessReport() && !isReady()) {
|
||||||
<div class="launcher-warning-bar" (click)="setTab('diagnostics')">
|
<div class="launcher-warning-bar" (click)="setTab('diagnostics')">
|
||||||
<span class="material-symbols-outlined warn-icon">{{ adbProbe()?.summary === 'Device Locked' ? 'lock' : 'warning' }}</span>
|
<span class="material-symbols-outlined warn-icon">{{ !pcOnly() && adbProbe()?.summary === 'Device Locked' ? 'lock' : 'warning' }}</span>
|
||||||
<span>
|
<span>
|
||||||
{{ adbProbe()?.summary === 'Device Locked'
|
{{ pcOnly() ? '运行环境或模型凭据尚未就绪。点击此处打开系统设置。' : adbProbe()?.summary === 'Device Locked'
|
||||||
? 'Android 设备已锁定,请先解锁并进入主屏幕。'
|
? 'Android 设备已锁定,请先解锁并进入主屏幕。'
|
||||||
: '运行环境、模型凭据或 Android 设备尚未就绪。点击此处打开系统设置。' }}
|
: '运行环境、模型凭据或 Android 设备尚未就绪。点击此处打开系统设置。' }}
|
||||||
</span>
|
</span>
|
||||||
@@ -1379,7 +1379,17 @@
|
|||||||
|
|
||||||
<!-- Task composer -->
|
<!-- Task composer -->
|
||||||
<div class="composer-card" [class.is-pro-active]="selectedProfile() === 'pro'">
|
<div class="composer-card" [class.is-pro-active]="selectedProfile() === 'pro'">
|
||||||
<app-task-plugins (changed)="taskPlugins.set($event)" />
|
<div class="composer-options">
|
||||||
|
<app-task-plugins [target]="pcOnly() ? 'computer' : 'android'" (changed)="taskPlugins.set($event)" />
|
||||||
|
<label class="pc-only-option" [class.is-selected]="pcOnly()" title="仅测试 PC 桌面端,无需手机或模拟器在线">
|
||||||
|
<input type="checkbox" [ngModel]="pcOnly()" (ngModelChange)="pcOnly.set($event)" [disabled]="isSubmitting()" [attr.aria-describedby]="pcOnly() ? 'pc-only-description' : null" />
|
||||||
|
<span class="material-symbols-outlined" aria-hidden="true">desktop_windows</span>
|
||||||
|
<span>仅 PC</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
@if (pcOnly()) {
|
||||||
|
<p class="pc-only-hint" id="pc-only-description">通过已启用并授权的电脑插件执行,无需手机或模拟器在线。</p>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- Smart Intent Recommendation Strip (if detected) -->
|
<!-- Smart Intent Recommendation Strip (if detected) -->
|
||||||
@if (showIntentSuggestion()) {
|
@if (showIntentSuggestion()) {
|
||||||
@@ -1406,7 +1416,7 @@
|
|||||||
rows="3"
|
rows="3"
|
||||||
[(ngModel)]="taskGoal"
|
[(ngModel)]="taskGoal"
|
||||||
[disabled]="isSubmitting()"
|
[disabled]="isSubmitting()"
|
||||||
placeholder="描述你希望在手机上完成的任务,例如:打开 Gmail 并查找最新邮件…"
|
[placeholder]="pcOnly() ? '描述你希望在电脑上完成的任务,例如:打开浏览器并检查网页…' : '描述你希望在手机上完成的任务,例如:打开 Gmail 并查找最新邮件…'"
|
||||||
(keydown.control.enter)="runTask()"
|
(keydown.control.enter)="runTask()"
|
||||||
(keydown.meta.enter)="runTask()"
|
(keydown.meta.enter)="runTask()"
|
||||||
></textarea>
|
></textarea>
|
||||||
@@ -1431,7 +1441,7 @@
|
|||||||
<p class="desc-line">
|
<p class="desc-line">
|
||||||
<span class="mode-name">Pro</span>
|
<span class="mode-name">Pro</span>
|
||||||
<span class="bullet">·</span>
|
<span class="bullet">·</span>
|
||||||
<span>先规划、再检查结果,遇到操作受阻时自动恢复。支持 <strong>超过 100 步的</strong> 工作流和实时手机监控(<strong>15–40 秒</strong> / 步)。</span>
|
<span>先规划、再检查结果,遇到操作受阻时自动恢复。支持 <strong>超过 100 步的</strong> 工作流和实时{{ pcOnly() ? '电脑' : '手机' }}监控(<strong>15–40 秒</strong> / 步)。</span>
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -1487,7 +1497,7 @@
|
|||||||
class="btn-run-composer"
|
class="btn-run-composer"
|
||||||
[class.btn-run-pro]="selectedProfile() === 'pro'"
|
[class.btn-run-pro]="selectedProfile() === 'pro'"
|
||||||
[disabled]="!taskGoal().trim() || isSubmitting() || !isReady()"
|
[disabled]="!taskGoal().trim() || isSubmitting() || !isReady()"
|
||||||
[title]="!isReady() ? (adbProbe()?.summary === 'Device Locked' ? '请先解锁 Android 设备' : '请先完成系统设置') : '执行任务'"
|
[title]="!isReady() ? (!pcOnly() && adbProbe()?.summary === 'Device Locked' ? '请先解锁 Android 设备' : '请先完成系统设置') : '执行任务'"
|
||||||
(click)="runTask()"
|
(click)="runTask()"
|
||||||
>
|
>
|
||||||
@if (isSubmitting()) {
|
@if (isSubmitting()) {
|
||||||
@@ -1508,7 +1518,7 @@
|
|||||||
@if(explorationSettingsOpen()) {
|
@if(explorationSettingsOpen()) {
|
||||||
<div class="exploration-settings-body">
|
<div class="exploration-settings-body">
|
||||||
<label class="model-label">探索模型<input list="explore-models" [(ngModel)]="config.model" [disabled]="explorationSettingsBusy()" /></label>
|
<label class="model-label">探索模型<input list="explore-models" [(ngModel)]="config.model" [disabled]="explorationSettingsBusy()" /></label>
|
||||||
<datalist id="explore-models"><option value="gpt-6.1-sol"></option><option value="gpt-6-astra"></option><option value="gpt-6-luna"></option><option value="mimo-v2.6-pro"></option></datalist>
|
<datalist id="explore-models"><option value="gpt-6.1-terra"></option><option value="gpt-6.1-sol"></option><option value="gpt-6-astra"></option><option value="gpt-6-luna"></option><option value="mimo-v2.6-pro"></option></datalist>
|
||||||
<div class="exploration-fields">
|
<div class="exploration-fields">
|
||||||
<app-setting-slider label="思考强度" [(value)]="config.reasoning_effort" [values]="reasoningValues" [labels]="reasoningLabels" hint="越高越适合复杂推理,也可能等待更久。" [disabled]="explorationSettingsBusy()" />
|
<app-setting-slider label="思考强度" [(value)]="config.reasoning_effort" [values]="reasoningValues" [labels]="reasoningLabels" hint="越高越适合复杂推理,也可能等待更久。" [disabled]="explorationSettingsBusy()" />
|
||||||
<app-setting-slider label="每段执行时限" [(value)]="config.timeout_seconds" [values]="executionTimes" [labels]="executionTimeLabels" hint="到时暂停并保存进度,点击继续即可接着探索。" [disabled]="explorationSettingsBusy()" />
|
<app-setting-slider label="每段执行时限" [(value)]="config.timeout_seconds" [values]="executionTimes" [labels]="executionTimeLabels" hint="到时暂停并保存进度,点击继续即可接着探索。" [disabled]="explorationSettingsBusy()" />
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,6 @@
|
|||||||
import { TestBed, fakeAsync, flushMicrotasks } from '@angular/core/testing';
|
import { TestBed, fakeAsync, flushMicrotasks } from '@angular/core/testing';
|
||||||
import { signal } from '@angular/core';
|
import { signal } from '@angular/core';
|
||||||
|
import { of } from 'rxjs';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { HomeComponent } from './home.component';
|
import { HomeComponent } from './home.component';
|
||||||
import { AgentService } from '../../services/agent.service';
|
import { AgentService } from '../../services/agent.service';
|
||||||
@@ -8,12 +9,13 @@ import { SystemService } from '../../services/system.service';
|
|||||||
import { TaskRecommendationService } from '../../core/services/task-recommendation.service';
|
import { TaskRecommendationService } from '../../core/services/task-recommendation.service';
|
||||||
|
|
||||||
describe('Homepage exploration entry',()=>{
|
describe('Homepage exploration entry',()=>{
|
||||||
let api:any,agent:any,router:any;
|
let api:any,agent:any,router:any,system:any;
|
||||||
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};
|
||||||
beforeEach(()=>{
|
beforeEach(()=>{
|
||||||
api={settings:jasmine.createSpy().and.resolveTo(config),create:jasmine.createSpy().and.resolveTo({id:'conversation'}),act:jasmine.createSpy().and.resolveTo({run:{id:'run'}})};
|
api={settings:jasmine.createSpy().and.resolveTo(config),create:jasmine.createSpy().and.resolveTo({id:'conversation'}),act:jasmine.createSpy().and.resolveTo({run:{id:'run'}})};
|
||||||
agent={runTask:jasmine.createSpy(),selectSession:jasmine.createSpy(),fetchSessions:jasmine.createSpy(),fetchStatus:jasmine.createSpy()};router={navigate:jasmine.createSpy().and.resolveTo(true)};
|
agent={runTask:jasmine.createSpy(),selectSession:jasmine.createSpy(),fetchSessions:jasmine.createSpy(),fetchStatus:jasmine.createSpy()};router={navigate:jasmine.createSpy().and.resolveTo(true)};
|
||||||
TestBed.configureTestingModule({providers:[{provide:AgentService,useValue:agent},{provide:ExplorationService,useValue:api},{provide:SystemService,useValue:{isReady:()=>true}},{provide:TaskRecommendationService,useValue:{allTasks:signal([])}},{provide:Router,useValue:router}]});
|
system={isReady:signal(true),isComputerReady:signal(true)};
|
||||||
|
TestBed.configureTestingModule({providers:[{provide:AgentService,useValue:agent},{provide:ExplorationService,useValue:api},{provide:SystemService,useValue:system},{provide:TaskRecommendationService,useValue:{allTasks:signal([])}},{provide:Router,useValue:router}]});
|
||||||
});
|
});
|
||||||
it('creates and starts exploration using independent settings and opens its run',fakeAsync(()=>{
|
it('creates and starts exploration using independent settings and opens its run',fakeAsync(()=>{
|
||||||
const home=TestBed.runInInjectionContext(()=>new HomeComponent());home.setProfile('exploration');flushMicrotasks();home.taskGoal.set('探索流程');home.runTask();flushMicrotasks();
|
const home=TestBed.runInInjectionContext(()=>new HomeComponent());home.setProfile('exploration');flushMicrotasks();home.taskGoal.set('探索流程');home.runTask();flushMicrotasks();
|
||||||
@@ -26,4 +28,28 @@ describe('Homepage exploration entry',()=>{
|
|||||||
const createId=api.create.calls.mostRecent().args[3],startId=api.act.calls.mostRecent().args[3];expect(home.errorMessage()).toContain('失败');
|
const createId=api.create.calls.mostRecent().args[3],startId=api.act.calls.mostRecent().args[3];expect(home.errorMessage()).toContain('失败');
|
||||||
api.act.and.resolveTo({run:{id:'run'}});home.runTask();flushMicrotasks();expect(api.create.calls.mostRecent().args[3]).toBe(createId);expect(api.act.calls.mostRecent().args[3]).toBe(startId);
|
api.act.and.resolveTo({run:{id:'run'}});home.runTask();flushMicrotasks();expect(api.create.calls.mostRecent().args[3]).toBe(createId);expect(api.act.calls.mostRecent().args[3]).toBe(startId);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
for (const profile of ['flash','pro','exploration'] as const) {
|
||||||
|
it(`submits a PC-only ${profile} task while Android is unavailable`,fakeAsync(()=>{
|
||||||
|
system.isReady.set(false);
|
||||||
|
const home=TestBed.runInInjectionContext(()=>new HomeComponent());
|
||||||
|
home.pcOnly.set(true);home.selectedProfile.set(profile);home.explorationConfig.set(config);
|
||||||
|
home.taskGoal.set('检查电脑浏览器');home.taskPlugins.set({plugins:[{id:'cua',profile:'default'}]});
|
||||||
|
agent.runTask.and.returnValue(of({status:'started'}));
|
||||||
|
expect(home.isReady()).toBeTrue();home.runTask();flushMicrotasks();
|
||||||
|
const options={plugins:[{id:'cua',profile:'default'}],execution_target:'computer'};
|
||||||
|
if(profile==='exploration') expect(api.create).toHaveBeenCalledWith('检查电脑浏览器',config,options,jasmine.any(String));
|
||||||
|
else expect(agent.runTask.calls.mostRecent().args[5]).toEqual(options);
|
||||||
|
expect(router.navigate).toHaveBeenCalledWith(['/workspace']);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
it('restores Android requirements when unchecked and keeps PC runtime requirements',()=>{
|
||||||
|
system.isReady.set(false);
|
||||||
|
const home=TestBed.runInInjectionContext(()=>new HomeComponent());home.pcOnly.set(true);
|
||||||
|
expect(home.isReady()).toBeTrue();home.pcOnly.set(false);
|
||||||
|
expect(home.isReady()).toBeFalse();expect(home.taskOptions().execution_target).toBeUndefined();
|
||||||
|
home.pcOnly.set(true);system.isComputerReady.set(false);home.taskGoal.set('检查桌面');home.runTask();
|
||||||
|
expect(home.isReady()).toBeFalse();expect(agent.runTask).not.toHaveBeenCalled();expect(api.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -324,7 +324,14 @@ export class HomeComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
// Computed helper states delegating to SystemService
|
// Computed helper states delegating to SystemService
|
||||||
taskPlugins = signal<TaskPluginOptions>({});
|
taskPlugins = signal<TaskPluginOptions>({});
|
||||||
public isReady = computed(() => this.systemService.isReady());
|
public pcOnly = signal(false);
|
||||||
|
public taskOptions = computed(() => ({
|
||||||
|
...this.taskPlugins(),
|
||||||
|
...(this.pcOnly() ? { execution_target: 'computer' } : {})
|
||||||
|
}));
|
||||||
|
public isReady = computed(() => this.pcOnly()
|
||||||
|
? this.systemService.isComputerReady()
|
||||||
|
: this.systemService.isReady());
|
||||||
public hasReadinessReport = computed(() => this.systemService.hasReadinessReport());
|
public hasReadinessReport = computed(() => this.systemService.hasReadinessReport());
|
||||||
public isLoading = computed(() => this.systemService.isLoading());
|
public isLoading = computed(() => this.systemService.isLoading());
|
||||||
public isRestartingAdb = computed(() => this.systemService.isRestartingAdb());
|
public isRestartingAdb = computed(() => this.systemService.isRestartingAdb());
|
||||||
@@ -828,7 +835,7 @@ export class HomeComponent implements OnInit, OnDestroy {
|
|||||||
const config = this.explorationConfig();
|
const config = this.explorationConfig();
|
||||||
if (!config) { this.errorMessage.set('请先读取探索配置。'); this.isSubmitting.set(false); return; }
|
if (!config) { this.errorMessage.set('请先读取探索配置。'); this.isSubmitting.set(false); return; }
|
||||||
if (!this.explorationConfigValid()) { this.errorMessage.set('示范提前提醒须小于人工示范时限。'); this.isSubmitting.set(false); return; }
|
if (!this.explorationConfigValid()) { this.errorMessage.set('示范提前提醒须小于人工示范时限。'); this.isSubmitting.set(false); return; }
|
||||||
const taskConfig = {...this.taskPlugins()};
|
const taskConfig = {...this.taskOptions()};
|
||||||
const key = JSON.stringify([goal, config, taskConfig]);
|
const key = JSON.stringify([goal, config, taskConfig]);
|
||||||
const ids = this.explorationRequests.get(key) || {create: crypto.randomUUID(), start: crypto.randomUUID()};
|
const ids = this.explorationRequests.get(key) || {create: crypto.randomUUID(), start: crypto.randomUUID()};
|
||||||
this.explorationRequests.set(key, ids);
|
this.explorationRequests.set(key, ids);
|
||||||
@@ -873,7 +880,7 @@ export class HomeComponent implements OnInit, OnDestroy {
|
|||||||
this.selectedProfile() === 'pro'
|
this.selectedProfile() === 'pro'
|
||||||
? { verificationLevel: this.verificationLevel().id, explorerMode: this.explorerMode().id }
|
? { verificationLevel: this.verificationLevel().id, explorerMode: this.explorerMode().id }
|
||||||
: undefined,
|
: undefined,
|
||||||
{ ...this.taskPlugins() }
|
{ ...this.taskOptions() }
|
||||||
)
|
)
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
|
|||||||
@@ -15,8 +15,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
$bg-base: #e2e5ea;
|
$bg-base: #e2e5ea;
|
||||||
$glass-bg: rgba(255, 255, 255, 0.45);
|
$glass-bg: rgb(var(--app-surface-rgb, 255 255 255) / 0.45);
|
||||||
$glass-border: rgba(255, 255, 255, 0.6);
|
$glass-border: rgb(var(--app-border-rgb, 255 255 255) / 0.6);
|
||||||
|
|
||||||
.workspace-container {
|
.workspace-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -40,7 +40,7 @@ $glass-border: rgba(255, 255, 255, 0.6);
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: linear-gradient(135deg, #e2e5ea 0%, #cfd6df 100%);
|
background: linear-gradient(135deg, var(--app-primary-soft, #e2e5ea) 0%, var(--app-primary-soft, #cfd6df) 100%);
|
||||||
|
|
||||||
.glow {
|
.glow {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -94,8 +94,8 @@ $glass-border: rgba(255, 255, 255, 0.6);
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: rgba(255, 255, 255, 0.35);
|
background: rgb(var(--app-surface-rgb, 255 255 255) / 0.35);
|
||||||
border-right: 1px solid rgba(255, 255, 255, 0.8);
|
border-right: 1px solid rgb(var(--app-border-rgb, 255 255 255) / 0.8);
|
||||||
border-left: none;
|
border-left: none;
|
||||||
border-top: none;
|
border-top: none;
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
@@ -106,7 +106,7 @@ $glass-border: rgba(255, 255, 255, 0.6);
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: none;
|
border: none;
|
||||||
background-color: #ffffff;
|
background-color: var(--app-surface, #ffffff);
|
||||||
box-shadow: -6px 0 24px rgba(0, 0, 0, 0.03);
|
box-shadow: -6px 0 24px rgba(0, 0, 0, 0.03);
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -126,7 +126,7 @@ $glass-border: rgba(255, 255, 255, 0.6);
|
|||||||
margin: 0 -6px;
|
margin: 0 -6px;
|
||||||
|
|
||||||
&:hover, .workspace-container.is-resizing & {
|
&:hover, .workspace-container.is-resizing & {
|
||||||
background: rgba(255, 255, 255, 0.15);
|
background: rgb(var(--app-surface-rgb, 255 255 255) / 0.15);
|
||||||
|
|
||||||
.resizer-handle {
|
.resizer-handle {
|
||||||
background: rgba(94, 114, 228, 0.8);
|
background: rgba(94, 114, 228, 0.8);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<main class="plugins-page">
|
<main class="plugins-page">
|
||||||
<header><div><span class="eyebrow">ARTEMIS · 扩展能力</span><h1>插件</h1><p>为安卓测试扩展电脑等操作能力,AI 按任务需要自主调用。</p></div>
|
<header><div><span class="eyebrow">ARTEMIS · 扩展能力</span><h1>插件</h1><p>扩展任务操作能力,也可为回放界面和报告添加人工评审等功能。</p></div>
|
||||||
<button (click)="refresh(); refreshCatalog()" [disabled]="busy() || loading() || catalogLoading()">刷新</button></header>
|
<button (click)="refresh(); refreshCatalog()" [disabled]="busy() || loading() || catalogLoading()">刷新</button></header>
|
||||||
@if (error()) { <div class="message error" role="alert">{{error()}}</div> }
|
@if (error()) { <div class="message error" role="alert">{{error()}}</div> }
|
||||||
@if (notice()) { <div class="message success" role="status">{{notice()}}</div> }
|
@if (notice()) { <div class="message success" role="status">{{notice()}}</div> }
|
||||||
|
|||||||
@@ -1,30 +1,30 @@
|
|||||||
:host { display: block; color: #1e293b; font-size: 14px; }
|
:host { display: block; color: var(--app-text-control, #1e293b); font-size: 14px; }
|
||||||
.plugins-page { max-width: 1120px; padding: 88px 32px 56px; margin: auto; }
|
.plugins-page { max-width: 1120px; padding: 88px 32px 56px; margin: auto; }
|
||||||
header, .card-top, footer, .inline, .install-progress > div { display: flex; align-items: center; gap: 12px; }
|
header, .card-top, footer, .inline, .install-progress > div { display: flex; align-items: center; gap: 12px; }
|
||||||
header { justify-content: space-between; margin-bottom: 28px; }
|
header { justify-content: space-between; margin-bottom: 28px; }
|
||||||
h1 { margin: 8px 0; font-size: 30px; letter-spacing: -.6px; } h2 { margin: 0 0 6px; font-size: 17px; }
|
h1 { margin: 8px 0; font-size: 30px; letter-spacing: -.6px; } h2 { margin: 0 0 6px; font-size: 17px; }
|
||||||
p { color: #64748b; line-height: 1.7; white-space: pre-wrap; margin: 8px 0 16px; }
|
p { color: var(--app-text-muted, #64748b); line-height: 1.7; white-space: pre-wrap; margin: 8px 0 16px; }
|
||||||
.eyebrow { color: #3b6e9c; font-size: 11px; letter-spacing: 1.4px; font-weight: 600; }
|
.eyebrow { color: var(--app-primary-text, #3b6e9c); font-size: 11px; letter-spacing: 1.4px; font-weight: 600; }
|
||||||
button { border: 1px solid #dce3eb; border-radius: 20px; background: #fff; color: #334155; padding: 8px 16px; font-size: 13px; cursor: pointer; white-space: nowrap; }
|
button { border: 1px solid var(--app-primary-border, #dce3eb); border-radius: 20px; background: var(--app-surface, #fff); color: var(--app-text-control, #334155); padding: 8px 16px; font-size: 13px; cursor: pointer; white-space: nowrap; }
|
||||||
button:hover:not(:disabled) { border-color: #9fb7d3; background: #f0f6ff; } button:disabled { opacity: .5; cursor: not-allowed; }
|
button:hover:not(:disabled) { border-color: var(--app-primary-border, #9fb7d3); background: var(--app-primary-soft, #f0f6ff); } button:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
button.primary { color: #fff; background: #2563eb; border-color: #2563eb; } button.danger { color: #b91c1c; border-color: #fecaca; }
|
button.primary { color: #fff; background: #2563eb; border-color: #2563eb; } button.danger { color: var(--app-error-text, #b91c1c); border-color: var(--app-error-border, #fecaca); }
|
||||||
.text-button { border: 0; margin-left: auto; color: #64748b; } :focus-visible { outline: 2px solid #3b82f6; outline-offset: 3px; }
|
.text-button { border: 0; margin-left: auto; color: var(--app-text-muted, #64748b); } :focus-visible { outline: 2px solid #3b82f6; outline-offset: 3px; }
|
||||||
.tabs { display: flex; gap: 4px; width: fit-content; margin-bottom: 24px; background: #f1f5f9; padding: 4px; border-radius: 24px; }
|
.tabs { display: flex; gap: 4px; width: fit-content; margin-bottom: 24px; background: var(--app-surface-soft, #f1f5f9); padding: 4px; border-radius: 24px; }
|
||||||
.tabs button { border: 0; background: transparent; }.tabs button.active { color: #1d4ed8; background: #fff; box-shadow: 0 1px 4px #0f172a0d; font-weight: 600; }.tabs span { margin-left: 8px; }
|
.tabs button { border: 0; background: transparent; }.tabs button.active { color: var(--app-primary-text, #1d4ed8); background: var(--app-surface, #fff); box-shadow: 0 1px 4px #0f172a0d; font-weight: 600; }.tabs span { margin-left: 8px; }
|
||||||
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 420px), 1fr)); gap: 18px; }
|
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 420px), 1fr)); gap: 18px; }
|
||||||
.card { padding: 22px; background: #fff; border: 1px solid #e1e7ef; border-radius: 14px; }.card > p { min-height: 48px; margin-top: 18px; }.card footer { margin-top: 20px; flex-wrap: wrap; }
|
.card { padding: 22px; background: var(--app-surface, #fff); border: 1px solid var(--app-primary-border, #e1e7ef); border-radius: 14px; }.card > p { min-height: 48px; margin-top: 18px; }.card footer { margin-top: 20px; flex-wrap: wrap; }
|
||||||
.plugin-icon { display: grid; place-items: center; width: 44px; height: 44px; flex-shrink: 0; color: #4477ba; background: #eef4ff; border-radius: 12px; }
|
.plugin-icon { display: grid; place-items: center; width: 44px; height: 44px; flex-shrink: 0; color: var(--app-primary-text, #4477ba); background: var(--app-primary-soft, #eef4ff); border-radius: 12px; }
|
||||||
.meta, small { color: #64748b; font-size: 12px; line-height: 1.6; }.badge { margin-left: auto; white-space: nowrap; padding: 4px 9px; border-radius: 20px; background: #f1f5f9; font-size: 11px; color: #64748b; }.badge.enabled { background: #ecfdf5; color: #047857; }
|
.meta, small { color: var(--app-text-muted, #64748b); font-size: 12px; line-height: 1.6; }.badge { margin-left: auto; white-space: nowrap; padding: 4px 9px; border-radius: 20px; background: var(--app-surface-soft, #f1f5f9); font-size: 11px; color: var(--app-text-muted, #64748b); }.badge.enabled { background: var(--app-success-soft, #ecfdf5); color: var(--app-success-text, #047857); }
|
||||||
.empty { padding: 55px 24px; text-align: center; border: 1px dashed #cbd5e1; border-radius: 14px; background: #ffffff80; margin-bottom: 24px; }.empty > span { font-size: 36px; color: #94a3b8; margin-bottom: 16px; }
|
.empty { padding: 55px 24px; text-align: center; border: 1px dashed var(--app-border-control, #cbd5e1); border-radius: 14px; background: rgb(var(--app-surface-rgb, 255 255 255) / 0.5020); margin-bottom: 24px; }.empty > span { font-size: 36px; color: var(--app-text-faint, #94a3b8); margin-bottom: 16px; }
|
||||||
.message { padding: 12px 16px; border-radius: 8px; margin-bottom: 18px; line-height: 1.6; }.error { color: #b91c1c; background: #fff1f2; }.success { color: #047857; background: #ecfdf5; }
|
.message { padding: 12px 16px; border-radius: 8px; margin-bottom: 18px; line-height: 1.6; }.error { color: var(--app-error-text, #b91c1c); background: var(--app-error-soft, #fff1f2); }.success { color: var(--app-success-text, #047857); background: var(--app-success-soft, #ecfdf5); }
|
||||||
.install-progress { background: #fff; border: 1px solid #dbeafe; padding: 18px; border-radius: 12px; margin-bottom: 20px; }.install-progress > div { justify-content: space-between; }progress { width: 100%; height: 8px; margin-top: 12px; accent-color: #2563eb; }
|
.install-progress { background: var(--app-surface, #fff); border: 1px solid var(--app-primary-border, #dbeafe); padding: 18px; border-radius: 12px; margin-bottom: 20px; }.install-progress > div { justify-content: space-between; }progress { width: 100%; height: 8px; margin-top: 12px; accent-color: var(--app-primary-text, #2563eb); }
|
||||||
details { margin: 16px 0; }summary { cursor: pointer; color: #475569; padding: 8px 0; }.advanced { padding: 18px 22px; border: 1px solid #e2e8f0; border-radius: 12px; margin-top: 24px; }
|
details { margin: 16px 0; }summary { cursor: pointer; color: var(--app-text-secondary, #475569); padding: 8px 0; }.advanced { padding: 18px 22px; border: 1px solid var(--app-border, #e2e8f0); border-radius: 12px; margin-top: 24px; }
|
||||||
label { display: flex; flex-direction: column; gap: 7px; margin: 16px 0; font-size: 13px; }input, select, textarea { border: 1px solid #d5dde7; background: #fff; border-radius: 8px; padding: 10px 12px; color: #1e293b; font-size: 13px; min-width: 0; width: 100%; }textarea { resize: vertical; font-family: ui-monospace, 'Microsoft YaHei', monospace; line-height: 1.6; }.inline > input, .inline > select { flex: 1; }
|
label { display: flex; flex-direction: column; gap: 7px; margin: 16px 0; font-size: 13px; }input, select, textarea { border: 1px solid var(--app-primary-border, #d5dde7); background: var(--app-surface, #fff); border-radius: 8px; padding: 10px 12px; color: var(--app-text-control, #1e293b); font-size: 13px; min-width: 0; width: 100%; }textarea { resize: vertical; font-family: ui-monospace, 'Microsoft YaHei', monospace; line-height: 1.6; }.inline > input, .inline > select { flex: 1; }
|
||||||
.modal-backdrop { position: fixed; inset: 0; background: #0f172a50; backdrop-filter: blur(3px); display: grid; place-items: center; z-index: 1300; padding: 24px; }.dialog { max-width: 620px; width: 100%; max-height: 90vh; overflow-y: auto; background: #fff; padding: 28px; border-radius: 16px; box-shadow: 0 24px 80px #0f172a30; }.dialog.small { max-width: 430px; }.dialog header { margin-bottom: 12px; }.dialog footer { justify-content: flex-end; margin-top: 24px; }.two-columns { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }.checkbox { flex-direction: row; align-items: center; gap: 10px; }.checkbox input { width: auto; }
|
.modal-backdrop { position: fixed; inset: 0; background: #0f172a50; backdrop-filter: blur(3px); display: grid; place-items: center; z-index: 1300; padding: 24px; }.dialog { max-width: 620px; width: 100%; max-height: 90vh; overflow-y: auto; background: var(--app-surface, #fff); padding: 28px; border-radius: 16px; box-shadow: 0 24px 80px #0f172a30; }.dialog.small { max-width: 430px; }.dialog header { margin-bottom: 12px; }.dialog footer { justify-content: flex-end; margin-top: 24px; }.two-columns { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }.checkbox { flex-direction: row; align-items: center; gap: 10px; }.checkbox input { width: auto; }
|
||||||
@media (max-width: 640px) { .plugins-page { padding: 80px 16px 32px; }.card { padding: 18px; }.dialog { padding: 20px; }.two-columns { grid-template-columns: 1fr; gap: 0; }.card-top { gap: 8px; }h2 { font-size: 15px; } }
|
@media (max-width: 640px) { .plugins-page { padding: 80px 16px 32px; }.card { padding: 18px; }.dialog { padding: 20px; }.two-columns { grid-template-columns: 1fr; gap: 0; }.card-top { gap: 8px; }h2 { font-size: 15px; } }
|
||||||
|
|
||||||
.loading-icon { animation: spin 1s linear infinite; }
|
.loading-icon { animation: spin 1s linear infinite; }
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
@media (prefers-reduced-motion: reduce) { .loading-icon { animation: none; } }
|
@media (prefers-reduced-motion: reduce) { .loading-icon { animation: none; } }
|
||||||
|
|
||||||
.badge.preview { background: #fffbeb; color: #92400e; }
|
.badge.preview { background: var(--app-warning-soft, #fffbeb); color: var(--app-warning-text, #92400e); }
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
:host {display:block;height:100vh;width:100%;overflow:hidden;color:#334155}
|
:host {display:block;height:100vh;width:100%;overflow:hidden;color:var(--app-text-control, #334155)}
|
||||||
.workspace-container {display:flex;height:100%;background:#f8fafc;overflow:hidden}
|
.workspace-container {display:flex;height:100%;background:var(--app-surface-soft, #f8fafc);overflow:hidden}
|
||||||
.left-panel {flex:1;min-width:420px;display:flex;flex-direction:column;overflow:hidden;background:#f8fafc}
|
.left-panel {flex:1;min-width:420px;display:flex;flex-direction:column;overflow:hidden;background:var(--app-surface-soft, #f8fafc)}
|
||||||
app-agent-stream,app-exploration-panel {flex:1;min-height:0}
|
app-agent-stream,app-exploration-panel {flex:1;min-height:0}
|
||||||
.right-panel {min-width:280px;max-width:50%;display:flex;flex-direction:column;background:white;border-left:1px solid #e2e8f0}
|
.right-panel {min-width:280px;max-width:50%;display:flex;flex-direction:column;background:var(--app-surface, white);border-left:1px solid var(--app-border, #e2e8f0)}
|
||||||
.resizer {width:8px;margin:0 -4px;cursor:col-resize;z-index:5;display:flex;align-items:center;justify-content:center}
|
.resizer {width:8px;margin:0 -4px;cursor:col-resize;z-index:5;display:flex;align-items:center;justify-content:center}
|
||||||
.resizer-handle {width:3px;height:32px;border-radius:4px;background:#cbd5e1}
|
.resizer-handle {width:3px;height:32px;border-radius:4px;background:var(--app-surface-raised, #cbd5e1)}
|
||||||
.is-resizing {cursor:col-resize;user-select:none}
|
.is-resizing {cursor:col-resize;user-select:none}
|
||||||
.task-composer {flex:0 0 auto;margin:0 20px 16px;padding:12px 16px;background:#ffffffec;border:1px solid #dce5ee;border-radius:16px;box-shadow:0 6px 24px #0f172a08;font-size:13px}
|
.task-composer {flex:0 0 auto;margin:0 20px 16px;padding:12px 16px;background:rgb(var(--app-surface-rgb, 255 255 255) / 0.9255);border:1px solid var(--app-border, #dce5ee);border-radius:16px;box-shadow:0 6px 24px #0f172a08;font-size:13px}
|
||||||
.composer-heading {display:flex;gap:12px;align-items:center;margin-bottom:8px}.composer-heading strong{white-space:nowrap}.composer-heading span{overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:#64748b;font-size:12px}
|
.composer-heading {display:flex;gap:12px;align-items:center;margin-bottom:8px}.composer-heading strong{white-space:nowrap}.composer-heading span{overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:var(--app-text-muted, #64748b);font-size:12px}
|
||||||
textarea {box-sizing:border-box;width:100%;border:0;background:transparent;resize:vertical;min-height:44px;max-height:140px;line-height:1.6;color:#0f172a;font:inherit;outline:none}
|
textarea {box-sizing:border-box;width:100%;border:0;background:transparent;resize:vertical;min-height:44px;max-height:140px;line-height:1.6;color:var(--app-text-strong, #0f172a);font:inherit;outline:none}
|
||||||
textarea:focus-visible{box-shadow:0 2px #93c5fd}textarea:disabled{color:#94a3b8}
|
textarea:focus-visible{box-shadow:0 2px #93c5fd}textarea:disabled{color:var(--app-text-faint, #94a3b8)}
|
||||||
.composer-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:8px}.composer-actions small{flex:1;color:#64748b;min-width:180px}
|
.composer-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:8px}.composer-actions small{flex:1;color:var(--app-text-muted, #64748b);min-width:180px}
|
||||||
button{font:inherit;border:1px solid #dbe3ec;border-radius:8px;padding:6px 12px;background:white;color:#475569;cursor:pointer}button.primary{background:#0f172a;color:white;border-color:#0f172a}button:disabled{opacity:.45;cursor:default}
|
button{font:inherit;border:1px solid var(--app-border, #dbe3ec);border-radius:8px;padding:6px 12px;background:var(--app-surface, white);color:var(--app-text-secondary, #475569);cursor:pointer}button.primary{background:#0f172a;color:white;border-color:var(--app-border-control, #0f172a)}button:disabled{opacity:.45;cursor:default}
|
||||||
.feedback{font-size:12px;margin:0 0 8px;color:#047857}.error{color:#b91c1c}.origin-note{margin:12px 0;padding:10px 12px;border:1px solid #dbe3ec;border-radius:10px;font-size:12px}.origin-note button{margin-left:10px}
|
.feedback{font-size:12px;margin:0 0 8px;color:var(--app-success-text, #047857)}.error{color:var(--app-error-text, #b91c1c)}.origin-note{margin:12px 0;padding:10px 12px;border:1px solid var(--app-border, #dbe3ec);border-radius:10px;font-size:12px}.origin-note button{margin-left:10px}
|
||||||
@media(max-width:800px){.left-panel{min-width:320px}.right-panel{min-width:240px}.task-composer{margin:0 10px 10px;padding:10px}}
|
@media(max-width:800px){.left-panel{min-width:320px}.right-panel{min-width:240px}.task-composer{margin:0 10px 10px;padding:10px}}
|
||||||
@@ -104,7 +104,7 @@ describe('AgentService browser connection lifecycle', () => {
|
|||||||
}
|
}
|
||||||
const status = http.expectOne('/api/status');
|
const status = http.expectOne('/api/status');
|
||||||
const sessions = http.expectOne('/api/sessions');
|
const sessions = http.expectOne('/api/sessions');
|
||||||
const guidance = http.expectOne('/api/tasks/selected-task/guidance');
|
const guidance = http.expectOne('/api/tasks/selected-task/guidance?details=true');
|
||||||
tick(UI_READ_TIMEOUT_MS);
|
tick(UI_READ_TIMEOUT_MS);
|
||||||
expect(status.cancelled).toBeTrue();
|
expect(status.cancelled).toBeTrue();
|
||||||
expect(sessions.cancelled).toBeTrue();
|
expect(sessions.cancelled).toBeTrue();
|
||||||
@@ -113,8 +113,41 @@ describe('AgentService browser connection lifecycle', () => {
|
|||||||
service.fetchStatus();
|
service.fetchStatus();
|
||||||
service.fetchSessions();
|
service.fetchSessions();
|
||||||
service.fetchGuidance('selected-task');
|
service.fetchGuidance('selected-task');
|
||||||
http.expectOne('/api/tasks/selected-task/guidance').flush([]);
|
http.expectOne('/api/tasks/selected-task/guidance?details=true').flush([]);
|
||||||
flushSnapshots();
|
flushSnapshots();
|
||||||
service.destroy();
|
service.destroy();
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
it('refreshes lifecycle with unchanged receipts and accepts the older guidance array', () => {
|
||||||
|
service = TestBed.inject(AgentService);
|
||||||
|
flushSnapshots();
|
||||||
|
service.currentSessionId.set('progress-task');
|
||||||
|
const instructions = [{id:'g1', instruction:'检查现有证据', status:'unapplied', timestamp:10, accepted_at:10}];
|
||||||
|
const lifecycle = {schema_version:1, status:'completed', phase:'finished', observed_at:30, durations:{execution:20}};
|
||||||
|
service.fetchGuidance('progress-task');
|
||||||
|
http.expectOne('/api/tasks/progress-task/guidance?details=true').flush({instructions, lifecycle});
|
||||||
|
expect(service.guidanceEvents()[0].status).toBe('unapplied');
|
||||||
|
expect(service.currentLifecycle()?.durations['execution']).toBe(20);
|
||||||
|
service.fetchGuidance('progress-task');
|
||||||
|
http.expectOne('/api/tasks/progress-task/guidance?details=true').flush({instructions, lifecycle:{...lifecycle, durations:{execution:25}}});
|
||||||
|
expect(service.currentLifecycle()?.durations['execution']).toBe(25);
|
||||||
|
service.fetchGuidance('progress-task');
|
||||||
|
http.expectOne('/api/tasks/progress-task/guidance?details=true').flush(instructions);
|
||||||
|
expect(service.currentLifecycle()).toBeNull();
|
||||||
|
expect(service.guidanceEvents().length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores lifecycle and guidance returned after selecting another task', () => {
|
||||||
|
service = TestBed.inject(AgentService);
|
||||||
|
flushSnapshots();
|
||||||
|
service.currentSessionId.set('old-task');
|
||||||
|
service.fetchGuidance('old-task');
|
||||||
|
service.currentSessionId.set('new-task');
|
||||||
|
http.expectOne('/api/tasks/old-task/guidance?details=true').flush({
|
||||||
|
instructions:[{id:'old', status:'consumed'}],
|
||||||
|
lifecycle:{schema_version:1, status:'running', phase:'running', observed_at:30, durations:{execution:20}}
|
||||||
|
});
|
||||||
|
expect(service.currentLifecycle()).toBeNull();
|
||||||
|
expect(service.guidanceEvents()).toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -20,7 +20,7 @@ import { Observable, finalize, tap, timeout } from 'rxjs';
|
|||||||
import { UI_READ_TIMEOUT_MS } from '../core/http-timeouts';
|
import { UI_READ_TIMEOUT_MS } from '../core/http-timeouts';
|
||||||
import { SharedLiveStream } from './shared-live-stream';
|
import { SharedLiveStream } from './shared-live-stream';
|
||||||
|
|
||||||
import { Session, ModelInfo, TaskQueueItem, AgentStatusResponse, SessionUsage, CaseReviewResponse } from '../core/models/session.model';
|
import { Session, ModelInfo, TaskQueueItem, AgentStatusResponse, SessionUsage, CaseReviewResponse, TaskLifecycle } from '../core/models/session.model';
|
||||||
import { ProTuningDefaults, ProTuningOptions } from '../core/models/pro-tuning.model';
|
import { ProTuningDefaults, ProTuningOptions } from '../core/models/pro-tuning.model';
|
||||||
import { StepItemData, StepReplayFrame, LLMStreamResetEventData, StreamResetNotice, DEFAULT_STREAM_RESET_MESSAGE, PersistedCheckerStream, StreamSegment } from '../core/models/stream.model';
|
import { StepItemData, StepReplayFrame, LLMStreamResetEventData, StreamResetNotice, DEFAULT_STREAM_RESET_MESSAGE, PersistedCheckerStream, StreamSegment } from '../core/models/stream.model';
|
||||||
import { extractStepReplayFrames } from '../utils/action-formatter.util';
|
import { extractStepReplayFrames } from '../utils/action-formatter.util';
|
||||||
@@ -73,7 +73,8 @@ export interface GuidanceEvent {
|
|||||||
instruction: string;
|
instruction: string;
|
||||||
source: string;
|
source: string;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
status: 'pending' | 'consumed';
|
status: 'pending' | 'reading' | 'consumed' | 'unapplied' | 'unconfirmed';
|
||||||
|
accepted_at?: number;
|
||||||
consumed_at?: number;
|
consumed_at?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,6 +192,7 @@ export class AgentService {
|
|||||||
}
|
}
|
||||||
const finalSession: Session = {
|
const finalSession: Session = {
|
||||||
...s,
|
...s,
|
||||||
|
phase: isTerminal ? null : (activeMatch?.phase || pendingMatch?.phase || null),
|
||||||
batch_id: s.batch_id || activeMatch?.batch_id || pendingMatch?.batch_id || null,
|
batch_id: s.batch_id || activeMatch?.batch_id || pendingMatch?.batch_id || null,
|
||||||
status: sStatus,
|
status: sStatus,
|
||||||
device_serial: serial,
|
device_serial: serial,
|
||||||
@@ -230,6 +232,7 @@ export class AgentService {
|
|||||||
const newSession: Session = {
|
const newSession: Session = {
|
||||||
...this.explorationSessionFields(at),
|
...this.explorationSessionFields(at),
|
||||||
session_id: sid,
|
session_id: sid,
|
||||||
|
phase: at.phase || null,
|
||||||
batch_id: at.batch_id || null,
|
batch_id: at.batch_id || null,
|
||||||
display_name: at.display_name,
|
display_name: at.display_name,
|
||||||
initial_goal: at.goal || goal || '',
|
initial_goal: at.goal || goal || '',
|
||||||
@@ -278,6 +281,7 @@ export class AgentService {
|
|||||||
public currentSessionId = signal<string | null>(null);
|
public currentSessionId = signal<string | null>(null);
|
||||||
public sessionLogs = signal<any[]>([]); // Dynamic array of all raw events received
|
public sessionLogs = signal<any[]>([]); // Dynamic array of all raw events received
|
||||||
public guidanceEvents = signal<GuidanceEvent[]>([]);
|
public guidanceEvents = signal<GuidanceEvent[]>([]);
|
||||||
|
public currentLifecycle = signal<TaskLifecycle | null>(null);
|
||||||
private guidanceSignature = '';
|
private guidanceSignature = '';
|
||||||
public isSessionContentLoading = signal<boolean>(false);
|
public isSessionContentLoading = signal<boolean>(false);
|
||||||
public agentStatus = signal<string>('idle'); // Status of the agent runner process
|
public agentStatus = signal<string>('idle'); // Status of the agent runner process
|
||||||
@@ -838,6 +842,7 @@ export class AgentService {
|
|||||||
this.currentSessionId.set(null);
|
this.currentSessionId.set(null);
|
||||||
this.sessionLogs.set([]);
|
this.sessionLogs.set([]);
|
||||||
this.guidanceEvents.set([]);
|
this.guidanceEvents.set([]);
|
||||||
|
this.currentLifecycle.set(null);
|
||||||
this.guidanceSignature = '';
|
this.guidanceSignature = '';
|
||||||
this.currentNotes.set({});
|
this.currentNotes.set({});
|
||||||
return;
|
return;
|
||||||
@@ -855,6 +860,7 @@ export class AgentService {
|
|||||||
this.currentSessionId.set(sessionId);
|
this.currentSessionId.set(sessionId);
|
||||||
this.sessionLogs.set([]); // Reset logs for new session selection
|
this.sessionLogs.set([]); // Reset logs for new session selection
|
||||||
this.guidanceEvents.set([]);
|
this.guidanceEvents.set([]);
|
||||||
|
this.currentLifecycle.set(null);
|
||||||
this.guidanceSignature = '';
|
this.guidanceSignature = '';
|
||||||
this.fetchGuidance(sessionId);
|
this.fetchGuidance(sessionId);
|
||||||
this.activePauseCardKey = null;
|
this.activePauseCardKey = null;
|
||||||
@@ -1408,12 +1414,14 @@ export class AgentService {
|
|||||||
public fetchGuidance(sessionId: string): void {
|
public fetchGuidance(sessionId: string): void {
|
||||||
if (this.destroyed || this.currentSessionId() !== sessionId || this.guidanceRequestsPending.has(sessionId)) return;
|
if (this.destroyed || this.currentSessionId() !== sessionId || this.guidanceRequestsPending.has(sessionId)) return;
|
||||||
this.guidanceRequestsPending.add(sessionId);
|
this.guidanceRequestsPending.add(sessionId);
|
||||||
this.http.get<GuidanceEvent[]>(`/api/tasks/${encodeURIComponent(sessionId)}/guidance`).pipe(
|
this.http.get<GuidanceEvent[] | {instructions: GuidanceEvent[]; lifecycle: TaskLifecycle}>(`/api/tasks/${encodeURIComponent(sessionId)}/guidance?details=true`).pipe(
|
||||||
timeout(UI_READ_TIMEOUT_MS),
|
timeout(UI_READ_TIMEOUT_MS),
|
||||||
finalize(() => this.guidanceRequestsPending.delete(sessionId))
|
finalize(() => this.guidanceRequestsPending.delete(sessionId))
|
||||||
).subscribe({
|
).subscribe({
|
||||||
next: (items) => {
|
next: (data) => {
|
||||||
if (this.currentSessionId() !== sessionId) return;
|
if (this.currentSessionId() !== sessionId) return;
|
||||||
|
const items = Array.isArray(data) ? data : data.instructions;
|
||||||
|
this.currentLifecycle.set(Array.isArray(data) ? null : data.lifecycle);
|
||||||
const signature = JSON.stringify(items);
|
const signature = JSON.stringify(items);
|
||||||
if (signature !== this.guidanceSignature) {
|
if (signature !== this.guidanceSignature) {
|
||||||
this.guidanceSignature = signature;
|
this.guidanceSignature = signature;
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { tap } from 'rxjs';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
|
||||||
export interface PluginManifest {
|
export interface PluginManifest {
|
||||||
id: string; name: string; version: string; description: string;
|
id: string; name: string; version: string; description: string;
|
||||||
artemis_version: string; platforms: string[]; source: string;
|
artemis_version: string; platforms: string[]; source: string;
|
||||||
config: Record<string, { label: string; description: string; secret: boolean; required: boolean; default: string }>;
|
config: Record<string, { label: string; description: string; secret: boolean; required: boolean; default: string }>;
|
||||||
|
workspace?: unknown; server?: unknown; skill?: string;
|
||||||
resources: string[];
|
resources: string[];
|
||||||
targets: ('android' | 'computer' | 'mixed')[];
|
targets: ('android' | 'computer' | 'mixed')[];
|
||||||
}
|
}
|
||||||
@@ -28,13 +30,13 @@ export class PluginService {
|
|||||||
install(payload: { plugin_id?: string; version?: string; local_path?: string }) {
|
install(payload: { plugin_id?: string; version?: string; local_path?: string }) {
|
||||||
return this.http.post<{ job_id: string }>('/api/plugins/install', payload);
|
return this.http.post<{ job_id: string }>('/api/plugins/install', payload);
|
||||||
}
|
}
|
||||||
progress(id: string) { return this.http.get<InstallProgress>(`/api/plugins/install/${encodeURIComponent(id)}`); }
|
progress(id: string) { return this.http.get<InstallProgress>(`/api/plugins/install/${encodeURIComponent(id)}`).pipe(tap(() => window.dispatchEvent(new Event('artemis-plugin-change')))); }
|
||||||
save(id: string, profile: PluginProfile) {
|
save(id: string, profile: PluginProfile) {
|
||||||
const { configured_secrets, ...payload } = profile;
|
const { configured_secrets, ...payload } = profile;
|
||||||
return this.http.put<InstalledPlugin>(`/api/plugins/${encodeURIComponent(id)}/profiles`, payload);
|
return this.http.put<InstalledPlugin>(`/api/plugins/${encodeURIComponent(id)}/profiles`, payload);
|
||||||
}
|
}
|
||||||
enable(id: string, enabled: boolean) { return this.http.put<InstalledPlugin>(`/api/plugins/${encodeURIComponent(id)}/enabled`, { enabled }); }
|
enable(id: string, enabled: boolean) { return this.http.put<InstalledPlugin>(`/api/plugins/${encodeURIComponent(id)}/enabled`, { enabled }).pipe(tap(() => window.dispatchEvent(new Event('artemis-plugin-change')))); }
|
||||||
diagnose(id: string, profile: string) { return this.http.post<{ ok: boolean; message: string }>(`/api/plugins/${encodeURIComponent(id)}/diagnose`, { profile }); }
|
diagnose(id: string, profile: string) { return this.http.post<{ ok: boolean; message: string }>(`/api/plugins/${encodeURIComponent(id)}/diagnose`, { profile }); }
|
||||||
uninstall(id: string) { return this.http.delete(`/api/plugins/${encodeURIComponent(id)}`); }
|
uninstall(id: string) { return this.http.delete(`/api/plugins/${encodeURIComponent(id)}`).pipe(tap(() => window.dispatchEvent(new Event('artemis-plugin-change')))); }
|
||||||
custom(manifest: unknown, skill_content: string) { return this.http.post('/api/plugins/custom', { manifest, skill_content }); }
|
custom(manifest: unknown, skill_content: string) { return this.http.post('/api/plugins/custom', { manifest, skill_content }); }
|
||||||
}
|
}
|
||||||
@@ -50,6 +50,20 @@ describe('SystemService readiness polling', () => {
|
|||||||
|
|
||||||
afterEach(() => http.verify());
|
afterEach(() => http.verify());
|
||||||
|
|
||||||
|
it('allows PC readiness without ADB, an online phone, or Android recording tools', () => {
|
||||||
|
const ready = report(50);
|
||||||
|
ready.probes = ['python_runtime','system_config','openai_api_key','android_adb','toolchain'].map(id => ({
|
||||||
|
id, category:'runtime', title:id, status: id === 'android_adb' || id === 'toolchain' ? 'fail' : 'pass',
|
||||||
|
is_blocker:true, summary:'',description:'',metadata:{installed:false},actions:[]
|
||||||
|
}));
|
||||||
|
service.readinessReport.set(ready);
|
||||||
|
expect(service.isReady()).toBeFalse();expect(service.isComputerReady()).toBeTrue();
|
||||||
|
for(const id of ['python_runtime','system_config','openai_api_key']) {
|
||||||
|
service.readinessReport.set({...ready,probes:ready.probes.map(p=>({...p,status:p.id===id?'fail':p.status}))});
|
||||||
|
expect(service.isComputerReady()).withContext(id).toBeFalse();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('shares one HTTP request across overlapping readiness callers', () => {
|
it('shares one HTTP request across overlapping readiness callers', () => {
|
||||||
let firstTimestamp = 0;
|
let firstTimestamp = 0;
|
||||||
let secondTimestamp = 0;
|
let secondTimestamp = 0;
|
||||||
|
|||||||
@@ -116,6 +116,11 @@ export class SystemService {
|
|||||||
public isReady = computed(() => {
|
public isReady = computed(() => {
|
||||||
return this.isEnvironmentReady() && this.isCredentialsReady() && this.isDeviceReady();
|
return this.isEnvironmentReady() && this.isCredentialsReady() && this.isDeviceReady();
|
||||||
});
|
});
|
||||||
|
public isComputerReady = computed(() => {
|
||||||
|
return this.pythonProbe()?.status === 'pass'
|
||||||
|
&& this.configProbe()?.status === 'pass'
|
||||||
|
&& this.isCredentialsReady();
|
||||||
|
});
|
||||||
public blockerCount = computed(() => this.totalStepCount());
|
public blockerCount = computed(() => this.totalStepCount());
|
||||||
public passedBlockerCount = computed(() => this.passedStepCount());
|
public passedBlockerCount = computed(() => this.passedStepCount());
|
||||||
public probes = computed(() => this.readinessReport()?.probes ?? []);
|
public probes = computed(() => this.readinessReport()?.probes ?? []);
|
||||||
|
|||||||
Loaded 100 of 299 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user