Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a3bbaa3fb | ||
|
|
506e3bf1c5 | ||
|
|
c6d2050ff0 | ||
|
|
0c3ca69c1d | ||
|
|
486e6d550d | ||
|
|
b53ca18595 | ||
|
|
ac3d8cf54a | ||
|
|
e90da727d4 | ||
|
|
4dc2057d43 | ||
|
|
baf069854b | ||
|
|
6b2c0f199d | ||
|
|
bb873ec0e0 | ||
|
|
8380057548 |
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 写入源码。
|
||||||
|
|||||||
@@ -64,8 +64,14 @@ ARTEMIS 面向使用自然语言执行、探索和复跑测试的人。当前产
|
|||||||
| 插件与证据 | `artemis/plugins` 管理安装、会话、版本固定、来源、截图、录像和附件;插件执行工具动作,宿主管理任务生命周期和最终验收 |
|
| 插件与证据 | `artemis/plugins` 管理安装、会话、版本固定、来源、截图、录像和附件;插件执行工具动作,宿主管理任务生命周期和最终验收 |
|
||||||
| 配置与交付 | `artemis/config`、两份 `artemis.jsonc` 和环境配置提供默认值;`scripts` 负责启动、镜像、独立升级和源码打包 |
|
| 配置与交付 | `artemis/config`、两份 `artemis.jsonc` 和环境配置提供默认值;`scripts` 负责启动、镜像、独立升级和源码打包 |
|
||||||
|
|
||||||
|
插件运行辅助接口见 [SDK 与市场材料](docs/plugin-sdk.md):已批准文件用不可变快照绑定实际消费字节,受管外部工具在返回前回收后代。宿主负责 MCP 总预算,插件负责内部超时和取消。开发检查保持轻量;市场材料使用独立内容指纹与验收记录校验,不代替源码、权利来源或真实业务审查。
|
||||||
|
|
||||||
请求重试、业务执行与最终验收分别处理。提交使用稳定标识;创建批次或探索草稿不自动执行,查询不产生设备动作。执行准入和恢复遵守现有资源锁,任务固定所用插件版本,不能通过另建队列绕开互斥。
|
请求重试、业务执行与最终验收分别处理。提交使用稳定标识;创建批次或探索草稿不自动执行,查询不产生设备动作。执行准入和恢复遵守现有资源锁,任务固定所用插件版本,不能通过另建队列绕开互斥。
|
||||||
|
|
||||||
|
设备保活是辅助服务。保活所属锁或旧保活清理目录不可访问时记录警告,不使设备连接、恢复或监控退出;未取得保活所属锁时不发送保活动作,也不创建替代锁绕过所属权。执行资源锁、主服务注册锁和队列写锁仍须成功取得。
|
||||||
|
|
||||||
|
运行时准备阶段失败记录 `initialization_failed`,批次保留详细原因并暂停未开始项,修复环境后由用户继续,已失败项不自动重跑。模型请求导致的批次暂停单独标记;模型暂停结束后恢复既定调度及失败即停止策略。用户暂停、停止全部任务和服务重启后的恢复暂停均需用户继续,不被模型恢复覆盖。Flash 与 Pro/探索 Operator 的生成失败复用统一备用模型与重试预算;不完整输出不会成为动作,已有动作不因模型切换而重放。
|
||||||
|
|
||||||
最终验收有独立总预算,按请求超时、重试和剩余任务时间协调,保留取证结果;完整有效的结构化结论才更新正式裁定。`completed`、工具成功、探索通过、用例提炼和复跑通过各自表达自己的状态。
|
最终验收有独立总预算,按请求超时、重试和剩余任务时间协调,保留取证结果;完整有效的结构化结论才更新正式裁定。`completed`、工具成功、探索通过、用例提炼和复跑通过各自表达自己的状态。
|
||||||
|
|
||||||
Responses 消息交给工具、下游解析器或正式裁定前,必须具有服务端 `completed` 状态;工具参数的原始字符串必须是完整 JSON 对象,不能使用库自动补全的半截参数。流结束或库补出的 `last` 标记不能替代完成状态。缺少终止事件、明确未完成或失败的流丢弃预览,进入现有无效输出恢复策略,最多重新请求一次,配置为零次重试时不重试;恢复保留已有工具证据,不重新提交业务任务。非流式、结构化输出和串接解析器遵守同一检查。
|
Responses 消息交给工具、下游解析器或正式裁定前,必须具有服务端 `completed` 状态;工具参数的原始字符串必须是完整 JSON 对象,不能使用库自动补全的半截参数。流结束或库补出的 `last` 标记不能替代完成状态。缺少终止事件、明确未完成或失败的流丢弃预览,进入现有无效输出恢复策略,最多重新请求一次,配置为零次重试时不重试;恢复保留已有工具证据,不重新提交业务任务。非流式、结构化输出和串接解析器遵守同一检查。
|
||||||
@@ -84,6 +90,10 @@ Responses 消息交给工具、下游解析器或正式裁定前,必须具有
|
|||||||
|
|
||||||
执行平台与证据来源分别记录。报告和证据索引从执行记录汇总来源及实际 step_id/call_id/source_id;界面、接口、文件和终端各自只能支持其观察范围,业务后台名称或网址不能推断平台。最终复核明确事实、矛盾和未验证边界;来源清单不生成新结论,不覆盖 AI 原判、有效结论或人工复验。
|
执行平台与证据来源分别记录。报告和证据索引从执行记录汇总来源及实际 step_id/call_id/source_id;界面、接口、文件和终端各自只能支持其观察范围,业务后台名称或网址不能推断平台。最终复核明确事实、矛盾和未验证边界;来源清单不生成新结论,不覆盖 AI 原判、有效结论或人工复验。
|
||||||
|
|
||||||
|
原生安卓动作在派发时记录所选设备来源。安卓截图来源也可从已保存的成功截图回执读取设备、采集时间、文件路径和校验值,并引用实际 trace_id;兼容 Flash 与 Pro 的回执封装,不从模型参数补造来源。空步骤、笔记和结束声明不计入缺失动作来源;窗口枚举等无单一目标的操作保留未关联状态,该计数不等于截图丢失。未生成独立输出正文时,报告展示当前有效判定及依据,保留人工复验优先级,不把正文缺失写成验收结论缺失。
|
||||||
|
|
||||||
|
规划器的笔记工具与执行器的设备、插件工具分别绑定。规划器依据任务的实际授权插件清单制定计划,由执行器加载并验证能力;规划阶段没有绑定动作工具,不表示任务缺少这些能力,也不构成重新申请授权的依据。
|
||||||
|
|
||||||
MCP 与单任务 HTTP 入参拒绝未知字段,校验失败不调度任务。MCP JSON 结果保留兼容文本并提供 structuredContent;任务摘要分页明确总数与 next_offset,引用实际存储编号。跨端阶段先完整初始化,再按既有阶段 id 更新状态与依据;补丁保留其他阶段、范围、资源和顺序,合并后统一校验并保存,失败不更新内存状态。
|
MCP 与单任务 HTTP 入参拒绝未知字段,校验失败不调度任务。MCP JSON 结果保留兼容文本并提供 structuredContent;任务摘要分页明确总数与 next_offset,引用实际存储编号。跨端阶段先完整初始化,再按既有阶段 id 更新状态与依据;补丁保留其他阶段、范围、资源和顺序,合并后统一校验并保存,失败不更新内存状态。
|
||||||
|
|
||||||
Flash 的最终 AI 判定与分析独立配置,默认关闭;首页功能配置和 MCP 共用保存的全局设置。任务开始时固定设置,尚未开始的任务使用最新值;批量、精选及提炼用例的 Flash 复跑同样遵循。关闭时跳过最终 Checker,保留正常执行、步骤、截图、报告和人工复验;结果为“执行完成 · 未做 AI 判定”,不标记“AI确认”或测试通过。Pro 与主动探索的验收规则保持不变。
|
Flash 的最终 AI 判定与分析独立配置,默认关闭;首页功能配置和 MCP 共用保存的全局设置。任务开始时固定设置,尚未开始的任务使用最新值;批量、精选及提炼用例的 Flash 复跑同样遵循。关闭时跳过最终 Checker,保留正常执行、步骤、截图、报告和人工复验;结果为“执行完成 · 未做 AI 判定”,不标记“AI确认”或测试通过。Pro 与主动探索的验收规则保持不变。
|
||||||
@@ -94,7 +104,7 @@ 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 写入计划时拒绝新增或增加同名顶层里程碑,撤销该次写入并返回工具错误;局部替换不得夹带目标范围外的里程碑,已有重复项可继续整理,确需重复的阶段使用不同名称。
|
普通 Pro 的计划全部完成后,执行循环返回既有收尾和最终验收流程,不要求额外设备动作;有新指导或验收反馈时先处理反馈,探索模式仍须交付完整报告。完成计划不等于测试通过。Operator 写入计划时拒绝新增或增加同名顶层里程碑,撤销该次写入并返回工具错误;局部替换不得夹带目标范围外的里程碑,已有重复项可继续整理,确需重复的阶段使用不同名称。
|
||||||
|
|
||||||
@@ -147,6 +157,7 @@ Flash 的最终 AI 判定与分析独立配置,默认关闭;首页功能配
|
|||||||
- 通常以 4、6、8、12、16、24px 组织内部间距;沿用组件现有网格和断点。子容器允许收缩,长内容使用 `min-width: 0` 及合适换行。
|
- 通常以 4、6、8、12、16、24px 组织内部间距;沿用组件现有网格和断点。子容器允许收缩,长内容使用 `min-width: 0` 及合适换行。
|
||||||
- 探索面板在 600px 以下收紧外边距并允许操作换行;模型选择控件宽度跟随容器。窄屏仍可辨认状态、选择来源和发送指导。
|
- 探索面板在 600px 以下收紧外边距并允许操作换行;模型选择控件宽度跟随容器。窄屏仍可辨认状态、选择来源和发送指导。
|
||||||
- 列表后台刷新保留滚动、选择和折叠状态;首次加载与静默更新分开,避免周期性加载提示挤动内容。实时连接复用现有页面可见性管理。
|
- 列表后台刷新保留滚动、选择和折叠状态;首次加载与静默更新分开,避免周期性加载提示挤动内容。实时连接复用现有页面可见性管理。
|
||||||
|
- 结束任务的错误与阻塞说明默认只占一行,点击展开安全 Markdown 正文与已有依据,长正文限高滚动。摘要优先采用已保存的有效结论原因,否则取执行错误首行;不新增 AI 复盘请求,不改写原始记录。展开说明不触发任务切换,刷新保留展开状态。
|
||||||
|
|
||||||
## Elevation
|
## Elevation
|
||||||
|
|
||||||
@@ -183,3 +194,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.2.0**:新增无需手机在线的仅 PC 测试、全局串行队列、暗色模式、Markdown 表格及可折叠复制的工具结果;修复安卓输入清空、观测恢复、排队状态与最终验收匹配。升级内容、验证范围与配套 Cua 插件见 [2.2.0 发行说明](docs/releases/2.2.0.md)。上一版本见 [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.5**:补齐插件开发辅助接口与市场材料检查,提供已批准文件快照、受管外部工具、故障自测和可运行模板。新 SDK 需 ARTEMIS 2.4.5;现有插件继续按自身清单兼容旧版本。见 [2.4.5 发行说明](docs/releases/2.4.5.md)与 [SDK 使用说明](docs/plugin-sdk.md)。人工评审插件保持 1.1.0;任务说明折叠见 [2.4.4](docs/releases/2.4.4.md),批次异常隔离见 [2.4.3](docs/releases/2.4.3.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 模板导入提示词,每项可独立指定模型、模式和参数;任务可暂停、补充指导并继续。
|
||||||
- **精选任务**:收藏单任务、批次或探索用例,在首页一键复跑,默认沿用已保存的配置。
|
- **精选任务**:收藏单任务、批次或探索用例,在首页一键复跑,默认沿用已保存的配置。
|
||||||
@@ -57,7 +57,7 @@ cd Artemis-Eapil
|
|||||||
3. 输入任务目标,选择 Flash、Pro 或探索后执行;仅测试电脑时勾选输入区的“仅 PC”,无需手机或模拟器在线,但需已启用并授权的电脑观察插件。在工作区查看步骤、截图和结果。流程尚不明确时,选择探索并随时交流。
|
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`;已保存的地址不变。直接编辑环境文件后需重启相应服务。不要提交自己的密钥。
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -441,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()
|
||||||
@@ -508,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,
|
||||||
|
|||||||
@@ -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"])
|
||||||
@@ -217,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
|
||||||
@@ -284,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]
|
||||||
@@ -438,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(
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from apps.admin_console.services.export_cache import (
|
|||||||
)
|
)
|
||||||
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.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
|
||||||
@@ -35,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": "执行完成",
|
||||||
@@ -208,11 +234,14 @@ 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),
|
**guidance_details(session_id, db_path=repo.db_path),
|
||||||
@@ -238,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),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -303,17 +334,24 @@ def source_text(value: dict) -> str:
|
|||||||
for item in value.get("sources", []):
|
for item in value.get("sources", []):
|
||||||
references = ", ".join(
|
references = ", ".join(
|
||||||
" / ".join(
|
" / ".join(
|
||||||
f"{key}={ref[key]}" for key in ("step_id", "call_id", "source_id") if ref.get(key)
|
f"{key}={ref[key]}"
|
||||||
|
for key in ("step_id", "call_id", "source_id", "trace_id")
|
||||||
|
if ref.get(key)
|
||||||
)
|
)
|
||||||
or "编号未记录"
|
or "编号未记录"
|
||||||
for ref in item.get("references", [])
|
for ref in item.get("references", [])
|
||||||
)
|
)
|
||||||
|
variation = "(来源标签随步骤变化,详见原始步骤)" if item.get("label_varies") else ""
|
||||||
lines.append(
|
lines.append(
|
||||||
f"{labels.get(item['kind'], '其他')} · {item['label'] or item['target_id']} · {references}"
|
f"{labels.get(item['kind'], '其他')} · {item['label'] or item['target_id']}"
|
||||||
|
f"{variation} · {references}"
|
||||||
)
|
)
|
||||||
if value.get("sources_omitted"):
|
if value.get("sources_omitted"):
|
||||||
lines.append(f"另 {value['sources_omitted']} 个来源见原始步骤记录。")
|
lines.append(f"另 {value['sources_omitted']} 个来源见原始步骤记录。")
|
||||||
lines.append(f"来源未完整记录的步骤:{value.get('unattributed_step_count', '未知')}。")
|
lines.append(
|
||||||
|
f"未关联到单一来源的步骤:{value.get('unattributed_step_count', '未知')}。"
|
||||||
|
"窗口枚举等操作可能没有单一目标;此计数不表示截图丢失。"
|
||||||
|
)
|
||||||
lines.append(value.get("note") or "来源清单不代表断言已验证。")
|
lines.append(value.get("note") or "来源清单不代表断言已验证。")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
@@ -340,6 +378,15 @@ 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 "未指定"),
|
||||||
@@ -659,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()
|
||||||
@@ -792,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":
|
||||||
@@ -913,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
|
||||||
@@ -1064,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 = [("任务编号", "任务名称", "执行状态", "验证结论", "异常原因")]
|
||||||
@@ -1078,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)
|
||||||
@@ -1088,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)
|
||||||
@@ -1146,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
|
||||||
@@ -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"))
|
||||||
|
|
||||||
@@ -61,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)
|
||||||
@@ -70,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")
|
||||||
@@ -93,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",
|
||||||
@@ -130,7 +153,18 @@ def template() -> bytes:
|
|||||||
json.dumps({"profile": "flash", "execution_target": "computer"}, ensure_ascii=False),
|
json.dumps({"profile": "flash", "execution_target": "computer"}, ensure_ascii=False),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
for column in range(1, 8):
|
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:
|
||||||
@@ -164,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):
|
||||||
@@ -190,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:
|
||||||
|
|||||||
@@ -1522,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
+1859
-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.2.0",
|
"version": "2.4.5",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
"start": "ng serve --proxy-config proxy.conf.json",
|
"start": "ng serve --proxy-config proxy.conf.json",
|
||||||
@@ -9,24 +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",
|
"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",
|
||||||
|
|||||||
@@ -129,9 +129,7 @@ limitations under the License.
|
|||||||
<div class="item-goal" [title]="session.initial_goal || ''">
|
<div class="item-goal" [title]="session.initial_goal || ''">
|
||||||
{{ session.display_name || session.initial_goal || '未命名任务' }}
|
{{ session.display_name || session.initial_goal || '未命名任务' }}
|
||||||
</div>
|
</div>
|
||||||
@if (session.error && getTaskStatus(session) === 'failed') {
|
<app-task-outcome [error]="session.error" [result]="session.test_result" [status]="getTaskStatus(session)" />
|
||||||
<div class="item-error" [title]="session.error">{{session.error}}</div>
|
|
||||||
}
|
|
||||||
<div class="item-footer">
|
<div class="item-footer">
|
||||||
<app-task-id-copy [taskId]="session.session_id" />
|
<app-task-id-copy [taskId]="session.session_id" />
|
||||||
<span class="item-date">{{ session.start_time * 1000 | date:'MM/dd/yy' }} · 已用 {{ formatTaskDuration(session.start_time, session.end_time) }}</span>
|
<span class="item-date">{{ session.start_time * 1000 | date:'MM/dd/yy' }} · 已用 {{ formatTaskDuration(session.start_time, session.end_time) }}</span>
|
||||||
@@ -695,13 +693,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)) {
|
||||||
@@ -719,13 +717,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)) {
|
||||||
@@ -749,19 +747,7 @@ limitations under the License.
|
|||||||
@for (param of getActionExtraParams(item.data); track trackParam($index, param)) {
|
@for (param of getActionExtraParams(item.data); track trackParam($index, param)) {
|
||||||
<div class="grid-item" [class.full-width]="param.key === 'Result'">
|
<div class="grid-item" [class.full-width]="param.key === 'Result'">
|
||||||
<span class="grid-label">{{ param.key }}</span>
|
<span class="grid-label">{{ param.key }}</span>
|
||||||
@if (param.key === 'Result') {
|
<app-expandable-value [label]="param.key" [value]="param.value" />
|
||||||
<div class="action-result-preview">{{ param.value | slice:0:180 }}{{ param.value.length > 180 ? '…' : '' }}</div>
|
|
||||||
<details class="action-result-detail">
|
|
||||||
<summary>
|
|
||||||
<span class="result-expand-label">展开完整结果</span>
|
|
||||||
<span class="result-collapse-label">收起结果</span>
|
|
||||||
<span class="result-length">{{ param.value.length }} 字符</span>
|
|
||||||
</summary>
|
|
||||||
<pre tabindex="0" aria-label="完整操作结果,可选择复制">{{ param.value }}</pre>
|
|
||||||
</details>
|
|
||||||
} @else {
|
|
||||||
<span class="grid-value code-pill">{{ param.value }}</span>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<!-- Fallback item if all detail fields are empty -->
|
<!-- Fallback item if all detail fields are empty -->
|
||||||
@@ -1008,7 +994,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)) {
|
||||||
@@ -1029,13 +1015,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)) {
|
||||||
@@ -1047,13 +1033,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>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1067,7 +1053,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)) {
|
||||||
@@ -1087,7 +1073,7 @@ 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 || plugin.rawResult || plugin.facts.length) {
|
@if (plugin.result || plugin.rawResult || plugin.facts.length) {
|
||||||
@@ -1101,13 +1087,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)) {
|
||||||
@@ -1119,19 +1105,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>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3694,7 +3694,6 @@ $stream-row-height: 25px;
|
|||||||
&.failed { border-left: 3.5px solid #dc2626; background: var(--app-error-soft, #fff7f7); }
|
&.failed { border-left: 3.5px solid #dc2626; background: var(--app-error-soft, #fff7f7); }
|
||||||
&.completed { border-left: 3.5px solid #16a34a; }
|
&.completed { border-left: 3.5px solid #16a34a; }
|
||||||
&.cancelled, &.interrupted { border-left: 3.5px solid #ea580c; }
|
&.cancelled, &.interrupted { border-left: 3.5px solid #ea580c; }
|
||||||
.item-error { color: var(--app-error-text, #b91c1c); font-size: 11px; line-height: 1.4; overflow-wrap: anywhere; }
|
|
||||||
|
|
||||||
.item-header {
|
.item-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { TaskOutcomeComponent } from '../task-outcome/task-outcome.component';
|
||||||
import { ExplorationMessage, Demonstration } from '../../services/exploration.service';
|
import { ExplorationMessage, Demonstration } from '../../services/exploration.service';
|
||||||
import { mergeConversation } from '../../utils/exploration-timeline.util';
|
import { mergeConversation } from '../../utils/exploration-timeline.util';
|
||||||
import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component';
|
import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component';
|
||||||
@@ -6,6 +7,7 @@ import { LlmRequestStatusComponent } from '../llm-request-status/llm-request-sta
|
|||||||
import { TaskLifecycleComponent } from '../task-lifecycle/task-lifecycle.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 { 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';
|
||||||
/**
|
/**
|
||||||
@@ -250,7 +252,7 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote, StreamRe
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-agent-stream',
|
selector: 'app-agent-stream',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [PluginToolResultComponent, ReportMarkdownComponent, LlmRequestStatusComponent, TaskLifecycleComponent, TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, OverlayModule, TaskHistoryComponent, AutomationPanelComponent, TestResultBadgeComponent, TestResultCardComponent, TaskBatchFilterComponent],
|
imports: [TaskOutcomeComponent,ExpandableValueComponent, PluginToolResultComponent, ReportMarkdownComponent, LlmRequestStatusComponent, TaskLifecycleComponent, TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, OverlayModule, TaskHistoryComponent, AutomationPanelComponent, TestResultBadgeComponent, TestResultCardComponent, TaskBatchFilterComponent],
|
||||||
templateUrl: './agent-stream.component.html',
|
templateUrl: './agent-stream.component.html',
|
||||||
styleUrl: './agent-stream.component.scss',
|
styleUrl: './agent-stream.component.scss',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
|||||||
+15
-2
@@ -17,7 +17,7 @@
|
|||||||
<div class="report-heading"><app-favorite-button [sourceId]="r.id" /><strong class="report-title">{{r.name}}</strong></div>
|
<div class="report-heading"><app-favorite-button [sourceId]="r.id" /><strong class="report-title">{{r.name}}</strong></div>
|
||||||
<div class="report-badges"><span [class.failed]="r.status === 'failed'">{{label(r.status)}}</span><app-test-result-badge [result]="r.test_result" [executionStatus]="r.status" /></div>
|
<div class="report-badges"><span [class.failed]="r.status === 'failed'">{{label(r.status)}}</span><app-test-result-badge [result]="r.test_result" [executionStatus]="r.status" /></div>
|
||||||
@if(r.test_result; as result){<app-test-result-card [result]="result" [sessionId]="r.id" [executionStatus]="r.status" (reviewSaved)="reviewSaved($event)" />}
|
@if(r.test_result; as result){<app-test-result-card [result]="result" [sessionId]="r.id" [executionStatus]="r.status" (reviewSaved)="reviewSaved($event)" />}
|
||||||
@if (r.error) { <div class="report-error" role="status"><strong>异常原因</strong><p>{{r.error}}</p></div> }
|
@if (r.error) { <app-task-outcome [error]="r.error" [status]="r.status" /> }
|
||||||
</div>
|
</div>
|
||||||
<div class="report-meta"><div><strong>任务目标与预期</strong><p>{{r.goal || '未记录'}}</p></div>@if(r.expected){<div><strong>预期结果</strong><p>{{r.expected}}</p></div>}</div>
|
<div class="report-meta"><div><strong>任务目标与预期</strong><p>{{r.goal || '未记录'}}</p></div>@if(r.expected){<div><strong>预期结果</strong><p>{{r.expected}}</p></div>}</div>
|
||||||
<app-evidence-sources [value]="r.evidence_sources || null" />
|
<app-evidence-sources [value]="r.evidence_sources || null" />
|
||||||
@@ -108,6 +108,19 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if(b.report_error){<p class="error">报告生成失败:{{b.report_error}}</p>}
|
@if(b.report_error){<p class="error">报告生成失败:{{b.report_error}}</p>}
|
||||||
@for(j of b.jobs; track j.session_id){<div class="job" [class.selected]="agent.currentSessionId() === j.session_id"><app-favorite-button [sourceId]="j.session_id" /><button (click)="openBatchTask(j.session_id)" [title]="j.case.goal" [attr.aria-pressed]="agent.currentSessionId() === j.session_id">{{j.case.case_id ? j.case.case_id + ' ' : ''}}{{j.case.name}}@if(j.repeat_count > 1){ · 第 {{j.iteration}} / {{j.repeat_count}} 轮}</button><span>{{label(j.status)}}</span><app-test-result-badge [result]="j.test_result" [executionStatus]="j.status" [compact]="true" />@if(j.error){<small>{{j.error}}</small>}</div>}
|
@for(j of b.jobs; track j.session_id){
|
||||||
|
<div class="job" [class.selected]="agent.currentSessionId() === j.session_id">
|
||||||
|
<div class="job-heading">
|
||||||
|
<app-favorite-button [sourceId]="j.session_id" />
|
||||||
|
<button (click)="openBatchTask(j.session_id)" [title]="j.case.goal" [attr.aria-pressed]="agent.currentSessionId() === j.session_id">{{j.case.case_id ? j.case.case_id + ' ' : ''}}{{j.case.name}}@if(j.repeat_count > 1){ · 第 {{j.iteration}} / {{j.repeat_count}} 轮}</button>
|
||||||
|
</div>
|
||||||
|
<div class="job-statuses">
|
||||||
|
<span>{{label(j.status)}}</span>
|
||||||
|
<app-test-result-badge [result]="j.test_result" [executionStatus]="j.status" [compact]="true" />
|
||||||
|
@if(j.human_reviewed){<span class="human-review-badge" title="人工评审已有保存内容,不代表测试通过">人工已审</span>}
|
||||||
|
</div>
|
||||||
|
<app-task-outcome [error]="j.error" [result]="j.test_result" [status]="j.status" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</section>}
|
</section>}
|
||||||
}
|
}
|
||||||
+11
-2
@@ -37,14 +37,21 @@ input[type=checkbox]{width:15px;height:15px;accent-color:var(--app-primary-text,
|
|||||||
@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: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;}
|
.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:var(--app-text-control, #334155);background:var(--app-surface-soft, #f8fafc);border-radius:8px;padding:12px;border:1px solid var(--app-border, #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 var(--app-border, #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:var(--app-surface-soft, #f1f5f9);color:var(--app-text-secondary, #475569);border-radius:5px;padding:3px 7px;}.job small{width:100%;}details{margin:14px 0;}summary{color:var(--app-text-secondary, #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);}
|
||||||
|
.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{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 var(--app-primary-border, #dbeafe);border-radius:10px;background:var(--app-primary-soft, #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:var(--app-text-strong, #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 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-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: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 var(--app-border, #e2e8f0);border-radius:8px;background:var(--app-surface, #fff);}strong{font-size:12px;color:var(--app-text-secondary, #475569);}p{margin:5px 0 0;white-space:pre-wrap;color:var(--app-text-control, #1e293b);}}
|
.report-meta{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:10px;margin:12px 0;div{min-width:0;padding:10px 12px;border:1px solid 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 var(--app-border, #e2e8f0);border-radius:10px;background:var(--app-surface, #fff);h3{margin-bottom:12px;}}
|
.report-content{margin-top:14px;padding:14px;border:1px solid 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:var(--app-text-control, #1e293b);line-height:1.75;}
|
.report-body{overflow-x:auto;overflow-wrap:anywhere;color:var(--app-text-control, #1e293b);line-height:1.75;}
|
||||||
@@ -58,3 +65,5 @@ input[type=checkbox]{width:15px;height:15px;accent-color:var(--app-primary-text,
|
|||||||
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);}
|
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;}
|
||||||
+31
-2
@@ -90,6 +90,30 @@ describe('AutomationPanelComponent batch deletion', () => {
|
|||||||
expect(http.post).not.toHaveBeenCalled();
|
expect(http.post).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows only saved human review badges and removes them on a batch refresh', async () => {
|
||||||
|
const fixture = TestBed.createComponent(AutomationPanelComponent);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||||
|
component.tab.set('batches');
|
||||||
|
const batch = { id: 'review-batch', name: '评审批次', status: 'completed', jobs: [
|
||||||
|
{ session_id: 'reviewed', status: 'completed', human_reviewed: true, case: { name: '已评用例' } },
|
||||||
|
{ session_id: 'empty', status: 'completed', human_reviewed: false, case: { name: '空白用例' } },
|
||||||
|
{ session_id: 'legacy', status: 'completed', case: { name: '旧版任务' } }
|
||||||
|
] };
|
||||||
|
component.batch.set(batch);
|
||||||
|
fixture.detectChanges();
|
||||||
|
const rows = fixture.nativeElement.querySelectorAll('.job');
|
||||||
|
expect(rows[0].querySelector('.human-review-badge').textContent).toBe('人工已审');
|
||||||
|
expect(rows[1].querySelector('.human-review-badge')).toBeNull();
|
||||||
|
expect(rows[2].querySelector('.human-review-badge')).toBeNull();
|
||||||
|
expect(fixture.nativeElement.textContent).not.toContain('人工未审');
|
||||||
|
(http.get.and as any).callFake((url: string) => of(url.endsWith('/review-batch')
|
||||||
|
? { ...batch, jobs: batch.jobs.map(job => ({ ...job, human_reviewed: false })) } : []));
|
||||||
|
await component.refresh();
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.querySelector('.human-review-badge')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('confirms deletion and removes the batch from the visible list', async () => {
|
it('confirms deletion and removes the batch from the visible list', async () => {
|
||||||
const fixture = TestBed.createComponent(AutomationPanelComponent);
|
const fixture = TestBed.createComponent(AutomationPanelComponent);
|
||||||
fixture.componentInstance.tab.set('batches');
|
fixture.componentInstance.tab.set('batches');
|
||||||
@@ -129,7 +153,7 @@ describe('AutomationPanelComponent batch deletion', () => {
|
|||||||
{session_id:'task-b',status:'completed',case:{name:'用例 B',goal:'观察主页'}}
|
{session_id:'task-b',status:'completed',case:{name:'用例 B',goal:'观察主页'}}
|
||||||
]});
|
]});
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
const jobs: HTMLButtonElement[] = [...fixture.nativeElement.querySelectorAll('.job > button')];
|
const jobs: HTMLButtonElement[] = [...fixture.nativeElement.querySelectorAll('.job-heading > button')];
|
||||||
jobs[0].click(); jobs[1].click();
|
jobs[0].click(); jobs[1].click();
|
||||||
expect(agent.selectSession.calls.allArgs()).toEqual([['task-a',true],['task-b',true]]);
|
expect(agent.selectSession.calls.allArgs()).toEqual([['task-a',true],['task-b',true]]);
|
||||||
expect(component.tab()).toBe('batches');
|
expect(component.tab()).toBe('batches');
|
||||||
@@ -152,7 +176,12 @@ describe('AutomationPanelComponent batch deletion', () => {
|
|||||||
});
|
});
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
const root = fixture.nativeElement as HTMLElement;
|
const root = fixture.nativeElement as HTMLElement;
|
||||||
expect(root.querySelector('.report-error')?.textContent).toContain('连续 3 次');
|
const explanation = root.querySelector<HTMLDetailsElement>('app-task-outcome details')!;
|
||||||
|
expect(explanation.open).toBeFalse();
|
||||||
|
expect(explanation.querySelector('summary')?.textContent).toContain('连续 3 次');
|
||||||
|
explanation.querySelector('summary')!.click();
|
||||||
|
expect(explanation.open).toBeTrue();
|
||||||
|
expect(explanation.querySelector('.body')?.textContent).toContain('连续 3 次');
|
||||||
expect(root.querySelector('.report-body h2')?.textContent).toBe('结果');
|
expect(root.querySelector('.report-body h2')?.textContent).toBe('结果');
|
||||||
expect(root.querySelector('.report-body')?.textContent).toContain('第一行');
|
expect(root.querySelector('.report-body')?.textContent).toContain('第一行');
|
||||||
expect(root.querySelector('.report-body')?.textContent).toContain('第二行');
|
expect(root.querySelector('.report-body')?.textContent).toContain('第二行');
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { TaskOutcomeComponent } from '../task-outcome/task-outcome.component';
|
||||||
import { Component, DestroyRef, computed, effect, inject, output, signal } from '@angular/core';
|
import { Component, DestroyRef, computed, effect, inject, output, signal } from '@angular/core';
|
||||||
import { FavoriteButtonComponent } from '../favorite-button/favorite-button.component';
|
import { FavoriteButtonComponent } from '../favorite-button/favorite-button.component';
|
||||||
import { ReportMarkdownComponent } from '../report-markdown/report-markdown.component';
|
import { ReportMarkdownComponent } from '../report-markdown/report-markdown.component';
|
||||||
@@ -24,7 +25,7 @@ interface ArchiveProgress {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-automation-panel', standalone: true, imports: [FavoriteButtonComponent, ReportMarkdownComponent, CommonModule, FormsModule, TestResultBadgeComponent, TestResultCardComponent, TaskPluginsComponent, EvidenceSourcesComponent],
|
selector: 'app-automation-panel', standalone: true, imports: [TaskOutcomeComponent,FavoriteButtonComponent, ReportMarkdownComponent, CommonModule, FormsModule, TestResultBadgeComponent, TestResultCardComponent, TaskPluginsComponent, EvidenceSourcesComponent],
|
||||||
templateUrl: './automation-panel.component.html', styleUrl: './automation-panel.component.scss'
|
templateUrl: './automation-panel.component.html', styleUrl: './automation-panel.component.scss'
|
||||||
})
|
})
|
||||||
export class AutomationPanelComponent {
|
export class AutomationPanelComponent {
|
||||||
|
|||||||
+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);
|
||||||
|
}
|
||||||
+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() {
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
+8
-2
@@ -28,6 +28,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.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);
|
||||||
@@ -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);
|
||||||
@@ -805,16 +806,21 @@
|
|||||||
|
|
||||||
.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 {
|
||||||
|
flex-shrink: 0;
|
||||||
background: rgb(var(--app-surface-soft-rgb, 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);
|
||||||
|
|||||||
+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();
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
+3
-1
@@ -1,15 +1,17 @@
|
|||||||
import { Component, computed, input } from '@angular/core';
|
import { Component, computed, input } from '@angular/core';
|
||||||
import { PluginToolView } from '../../utils/plugin-tool-view.util';
|
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. */
|
/** Readable presentation only: the full receipt and model context stay intact. */
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-plugin-tool-result',
|
selector: 'app-plugin-tool-result',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
|
imports: [ExpandableValueComponent],
|
||||||
template: `
|
template: `
|
||||||
@if (view().facts.length) {
|
@if (view().facts.length) {
|
||||||
<dl class="receipt-facts">
|
<dl class="receipt-facts">
|
||||||
@for (fact of view().facts; track fact.key) {
|
@for (fact of view().facts; track fact.key) {
|
||||||
<div><dt>{{ fact.key }}</dt><dd>{{ fact.value }}</dd></div>
|
<div><dt>{{ fact.key }}</dt><dd><app-expandable-value [label]="fact.key" [value]="fact.value" /></dd></div>
|
||||||
}
|
}
|
||||||
</dl>
|
</dl>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { renderMarkdownToHtml } from '../../utils/markdown-parser.util';
|
|||||||
li { margin: 4px 0; }
|
li { margin: 4px 0; }
|
||||||
blockquote { margin: 10px 0; padding: 6px 12px; border-left: 3px solid var(--app-primary-border, #93c5fd); background: var(--app-surface-soft, #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: var(--app-surface-soft, #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: var(--app-surface-soft, #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; }
|
||||||
|
|||||||
+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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,28 @@ describe('历史任务检索', () => {
|
|||||||
});
|
});
|
||||||
afterEach(() => http.verify());
|
afterEach(() => http.verify());
|
||||||
|
|
||||||
|
it('历史错误独立折叠,展开不选择任务且后台刷新保留展开状态', () => {
|
||||||
|
const fixture = TestBed.createComponent(TaskHistoryComponent);
|
||||||
|
const row = {session_id:'blocked', name:'前置条件缺失', status:'failed', start_time:100,
|
||||||
|
error:'[web-blocked] FlashRunner failed: **目标未绑定**\n\n- 未执行删除'};
|
||||||
|
http.expectOne(r => r.url === '/api/automation/history').flush({total:1,items:[row]});
|
||||||
|
fixture.detectChanges();
|
||||||
|
const host = fixture.nativeElement as HTMLElement;
|
||||||
|
const details = host.querySelector<HTMLDetailsElement>('app-task-outcome details')!;
|
||||||
|
expect(details.closest('button')).toBeNull();
|
||||||
|
expect(details.open).toBeFalse();
|
||||||
|
details.querySelector('summary')!.click();
|
||||||
|
expect(details.open).toBeTrue();
|
||||||
|
expect(TestBed.inject(AgentService).selectSession).not.toHaveBeenCalled();
|
||||||
|
fixture.componentInstance.load(true);
|
||||||
|
http.expectOne(r => r.url === '/api/automation/history').flush({total:1,items:[{...row}]});
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(host.querySelector('app-task-outcome details')).toBe(details);
|
||||||
|
expect(details.open).toBeTrue();
|
||||||
|
expect(details.querySelector('strong')!.textContent).toBe('目标未绑定');
|
||||||
|
fixture.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
it('已终止探索可删除,确认范围后刷新,取消不发请求', () => {
|
it('已终止探索可删除,确认范围后刷新,取消不发请求', () => {
|
||||||
const fixture = TestBed.createComponent(TaskHistoryComponent);
|
const fixture = TestBed.createComponent(TaskHistoryComponent);
|
||||||
const row = {session_id:'stopped', status:'cancelled', exploration_kind:'exploration', exploration_status:'paused', start_time:100};
|
const row = {session_id:'stopped', status:'cancelled', exploration_kind:'exploration', exploration_status:'paused', start_time:100};
|
||||||
@@ -268,7 +290,7 @@ describe('历史任务检索', () => {
|
|||||||
expect(row.getAttribute('title')).toBe('请打开设置首页并核对两个真实分类');
|
expect(row.getAttribute('title')).toBe('请打开设置首页并核对两个真实分类');
|
||||||
expect(row.textContent).toContain('1.1 打开并核对设置');
|
expect(row.textContent).toContain('1.1 打开并核对设置');
|
||||||
expect(row.textContent).toContain('总耗时 1分5秒');
|
expect(row.textContent).toContain('总耗时 1分5秒');
|
||||||
expect(row.textContent).toContain('任务执行超时');
|
expect(fixture.nativeElement.querySelector('app-task-outcome summary').textContent).toContain('任务执行超时');
|
||||||
expect(fixture.nativeElement.querySelector('.status-failed .history-status').textContent).toContain('失败');
|
expect(fixture.nativeElement.querySelector('.status-failed .history-status').textContent).toContain('失败');
|
||||||
fixture.destroy();
|
fixture.destroy();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { TaskOutcomeComponent } from '../task-outcome/task-outcome.component';
|
||||||
import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component';
|
import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component';
|
||||||
import { FavoriteButtonComponent } from '../favorite-button/favorite-button.component';
|
import { FavoriteButtonComponent } from '../favorite-button/favorite-button.component';
|
||||||
import { TaskModeBadgeComponent } from '../task-mode-badge/task-mode-badge.component';
|
import { TaskModeBadgeComponent } from '../task-mode-badge/task-mode-badge.component';
|
||||||
@@ -9,13 +10,13 @@ import { HttpClient, HttpParams } from '@angular/common/http';
|
|||||||
import { AgentService } from '../../services/agent.service';
|
import { AgentService } from '../../services/agent.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector:'app-task-history', standalone:true, imports:[CommonModule,FormsModule,TaskIdCopyComponent,TestResultBadgeComponent,TaskModeBadgeComponent,FavoriteButtonComponent],
|
selector:'app-task-history', standalone:true, imports:[TaskOutcomeComponent,CommonModule,FormsModule,TaskIdCopyComponent,TestResultBadgeComponent,TaskModeBadgeComponent,FavoriteButtonComponent],
|
||||||
template:`<section aria-label="历史任务"><h3>历史任务 <small>{{total()}} 条</small><small class="query-status" role="status">{{loading() ? '正在查询…' : ''}}</small></h3>
|
template:`<section aria-label="历史任务"><h3>历史任务 <small>{{total()}} 条</small><small class="query-status" role="status">{{loading() ? '正在查询…' : ''}}</small></h3>
|
||||||
<input aria-label="搜索历史任务" placeholder="搜索名称、目标、任务编号或标签" [(ngModel)]="query" (ngModelChange)="search()">
|
<input aria-label="搜索历史任务" placeholder="搜索名称、目标、任务编号或标签" [(ngModel)]="query" (ngModelChange)="search()">
|
||||||
<div class="filters"><label>起始日期<input type="date" [(ngModel)]="start" (ngModelChange)="search()"></label><label>结束日期<input type="date" [(ngModel)]="end" (ngModelChange)="search()"></label><label>状态<select [(ngModel)]="status" (ngModelChange)="search()"><option value="">全部状态</option><option value="completed">执行完成</option><option value="failed">失败</option><option value="cancelled">已取消</option><option value="interrupted">中断</option><option value="skipped">跳过</option></select></label></div>
|
<div class="filters"><label>起始日期<input type="date" [(ngModel)]="start" (ngModelChange)="search()"></label><label>结束日期<input type="date" [(ngModel)]="end" (ngModelChange)="search()"></label><label>状态<select [(ngModel)]="status" (ngModelChange)="search()"><option value="">全部状态</option><option value="completed">执行完成</option><option value="failed">失败</option><option value="cancelled">已取消</option><option value="interrupted">中断</option><option value="skipped">跳过</option></select></label></div>
|
||||||
@if(error()){<p role="alert">{{error()}}</p>}
|
@if(error()){<p role="alert">{{error()}}</p>}
|
||||||
@for(group of groups(); track group.key){<details [open]="!collapsed.has(group.key)" (toggle)="toggle(group.key,$event)"><summary>{{group.title}} <small>本页 {{group.rows.length}} 条</small></summary>
|
@for(group of groups(); track group.key){<details [open]="!collapsed.has(group.key)" (toggle)="toggle(group.key,$event)"><summary>{{group.title}} <small>本页 {{group.rows.length}} 条</small></summary>
|
||||||
@for(row of group.rows; track row.session_id){<div class="history-item" [class]="'history-item status-'+row.status"><div class="history-heading"><app-favorite-button [sourceId]="row.session_id" /><button class="row" [class.selected]="agent.currentSessionId() === row.session_id" (click)="agent.selectSession(row.session_id,true)" [title]="row.initial_goal || ''"><strong>{{row.name || row.initial_goal}}</strong><span class="history-meta"><app-task-mode-badge [session]="row" />@if(row.exploration_kind === "validation"){<span>用例复跑 · v{{row.case_version}}</span>}<b class="history-status">{{label(row.status)}}</b><app-test-result-badge [result]="row.test_result" [executionStatus]="row.status" [compact]="true" /> <span>{{row.end_time ? '完成于 '+(row.end_time*1000 | date:'MM-dd HH:mm:ss') : '开始于 '+(row.start_time*1000 | date:'MM-dd HH:mm:ss')}}</span><span>总耗时 {{duration(row.start_time,row.end_time)}}</span></span>@if(row.error){<span class="history-error" [title]="row.error">{{row.error}}</span>}</button></div><app-task-id-copy [taskId]="row.session_id" />@if(agent.canDeleteSession(row)){<button class="delete-history" (click)="remove(row.session_id, row.exploration_kind)" aria-label="删除此历史任务"><span class="material-symbols-outlined">delete</span></button>}</div>}
|
@for(row of group.rows; track row.session_id){<div class="history-item" [class]="'history-item status-'+row.status"><div class="history-heading"><app-favorite-button [sourceId]="row.session_id" /><button class="row" [class.selected]="agent.currentSessionId() === row.session_id" (click)="agent.selectSession(row.session_id,true)" [title]="row.initial_goal || ''"><strong>{{row.name || row.initial_goal}}</strong><span class="history-meta"><app-task-mode-badge [session]="row" />@if(row.exploration_kind === "validation"){<span>用例复跑 · v{{row.case_version}}</span>}<b class="history-status">{{label(row.status)}}</b><app-test-result-badge [result]="row.test_result" [executionStatus]="row.status" [compact]="true" /> <span>{{row.end_time ? '完成于 '+(row.end_time*1000 | date:'MM-dd HH:mm:ss') : '开始于 '+(row.start_time*1000 | date:'MM-dd HH:mm:ss')}}</span><span>总耗时 {{duration(row.start_time,row.end_time)}}</span></span></button></div><app-task-outcome [error]="row.error" [result]="row.test_result" [status]="row.status" /><app-task-id-copy [taskId]="row.session_id" />@if(agent.canDeleteSession(row)){<button class="delete-history" (click)="remove(row.session_id, row.exploration_kind)" aria-label="删除此历史任务"><span class="material-symbols-outlined">delete</span></button>}</div>}
|
||||||
</details>}
|
</details>}
|
||||||
@if(!loading() && !total()){<p>没有符合条件的历史任务。</p>}
|
@if(!loading() && !total()){<p>没有符合条件的历史任务。</p>}
|
||||||
<div class="pager"><button [disabled]="page === 1 || loading()" (click)="page=page-1;load()">上一页</button><span>{{page}} / {{pages()}}</span><button [disabled]="page >= pages() || loading()" (click)="page=page+1;load()">下一页</button></div>
|
<div class="pager"><button [disabled]="page === 1 || loading()" (click)="page=page-1;load()">上一页</button><span>{{page}} / {{pages()}}</span><button [disabled]="page >= pages() || loading()" (click)="page=page+1;load()">下一页</button></div>
|
||||||
@@ -51,7 +52,6 @@ import { AgentService } from '../../services/agent.service';
|
|||||||
.status-completed .history-status,.status-success .history-status{background:var(--app-success-soft, #dcfce7);color:var(--app-success-text, #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:var(--app-error-soft, #fee2e2);color:var(--app-error-text, #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:var(--app-warning-soft, #fef3c7);color:var(--app-warning-text, #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:var(--app-error-text, #b91c1c);line-height:1.4}
|
|
||||||
.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{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:var(--app-error-text, #b91c1c)}
|
.delete-history:hover{color:var(--app-error-text, #b91c1c)}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { TaskOutcomeComponent } from './task-outcome.component';
|
||||||
|
|
||||||
|
describe('任务结束说明', () => {
|
||||||
|
function create(status = 'failed', error = '[web-example] FlashRunner failed: **前置受阻**\n\n- 未找到目标设备\n- 未执行删除') {
|
||||||
|
const fixture = TestBed.createComponent(TaskOutcomeComponent);
|
||||||
|
fixture.componentRef.setInput('status', status);
|
||||||
|
fixture.componentRef.setInput('error', error);
|
||||||
|
fixture.detectChanges();
|
||||||
|
return fixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('默认只显示一行摘要,展开后渲染完整 Markdown,不选择外层任务', () => {
|
||||||
|
const fixture = create();
|
||||||
|
const host = fixture.nativeElement as HTMLElement;
|
||||||
|
host.style.width = '280px';
|
||||||
|
const details = host.querySelector('details')!;
|
||||||
|
expect(details.open).toBeFalse();
|
||||||
|
expect(host.querySelector('.summary-text')!.textContent).toBe('前置受阻');
|
||||||
|
expect(host.textContent).not.toContain('FlashRunner failed');
|
||||||
|
expect(host.querySelector('button details')).toBeNull();
|
||||||
|
const select = jasmine.createSpy('外层任务选择');
|
||||||
|
host.addEventListener('click', select);
|
||||||
|
host.querySelector('summary')!.click();
|
||||||
|
expect(details.open).toBeTrue();
|
||||||
|
expect(select).not.toHaveBeenCalled();
|
||||||
|
expect(host.querySelector('.body strong')!.textContent).toBe('前置受阻');
|
||||||
|
expect(host.querySelectorAll('.body li').length).toBe(2);
|
||||||
|
fixture.componentRef.setInput('error', '[web-example] FlashRunner failed: **更新说明**\n\n- 已有记录');
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(host.querySelector('details')).toBe(details);
|
||||||
|
expect(details.open).toBeTrue();
|
||||||
|
host.querySelector('summary')!.click();
|
||||||
|
expect(details.open).toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('优先采用已有结论,同时保留不同的执行错误和依据', () => {
|
||||||
|
const fixture = create();
|
||||||
|
fixture.componentRef.setInput('result', {status:'blocked', reason:'目标设备未绑定', evidence:['截图中仅有 **添加设备**']});
|
||||||
|
fixture.detectChanges();
|
||||||
|
const host = fixture.nativeElement as HTMLElement;
|
||||||
|
expect(host.querySelector('.summary-text')!.textContent).toBe('目标设备未绑定');
|
||||||
|
expect(host.textContent).toContain('未执行删除');
|
||||||
|
expect(host.textContent).toContain('已记录依据');
|
||||||
|
expect(host.textContent).toContain('添加设备');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('完成但结论阻塞也展示说明,运行中和无内容时不展示', () => {
|
||||||
|
const fixture = create('running');
|
||||||
|
expect(fixture.nativeElement.querySelector('details')).toBeNull();
|
||||||
|
fixture.componentRef.setInput('status', 'completed');
|
||||||
|
fixture.componentRef.setInput('error', '');
|
||||||
|
fixture.componentRef.setInput('result', {status:'blocked',reason:'缺少预置数据',evidence:[]});
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.querySelector('.summary-text').textContent).toBe('缺少预置数据');
|
||||||
|
fixture.componentRef.setInput('result', null);
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.nativeElement.querySelector('details')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('不重复相同说明,不执行错误正文中的 HTML 或危险链接', () => {
|
||||||
|
const error = '**失败**\n<script>alert(1)</script>\n[打开](javascript:alert(1))';
|
||||||
|
const fixture = create('failed', error);
|
||||||
|
fixture.componentRef.setInput('result', {status:'blocked',reason:error,evidence:[]});
|
||||||
|
fixture.detectChanges();
|
||||||
|
const host = fixture.nativeElement as HTMLElement;
|
||||||
|
expect(host.querySelectorAll('app-report-markdown').length).toBe(1);
|
||||||
|
expect(host.querySelector('script')).toBeNull();
|
||||||
|
expect(host.querySelector('a[href^="javascript:"]')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { Component, computed, input } from '@angular/core';
|
||||||
|
import { CaseTestResult } from '../../core/models/session.model';
|
||||||
|
import { ReportMarkdownComponent } from '../report-markdown/report-markdown.component';
|
||||||
|
|
||||||
|
/** Read-only presentation of persisted results; opening it never requests a model. */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-task-outcome',
|
||||||
|
standalone: true,
|
||||||
|
imports: [ReportMarkdownComponent],
|
||||||
|
template: `@if (visible()) {
|
||||||
|
<details (click)="$event.stopPropagation()" (keydown)="$event.stopPropagation()">
|
||||||
|
<summary aria-label="任务说明,展开或收起">
|
||||||
|
<span class="chevron" aria-hidden="true">›</span>
|
||||||
|
<span class="summary-text">{{summary()}}</span>
|
||||||
|
<span class="hint">详情</span>
|
||||||
|
</summary>
|
||||||
|
<div class="body">
|
||||||
|
@if (reason()) { <app-report-markdown [content]="reason()" /> }
|
||||||
|
@if (message() && message() !== reason()) {
|
||||||
|
@if (reason()) { <p class="label">执行记录</p> }
|
||||||
|
<app-report-markdown [content]="message()" />
|
||||||
|
}
|
||||||
|
@if (result()?.evidence?.length) {
|
||||||
|
<p class="label">已记录依据</p>
|
||||||
|
<ul>@for (item of result()!.evidence; track $index) {<li><app-report-markdown [content]="item" /></li>}</ul>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
}`,
|
||||||
|
styles: [`
|
||||||
|
:host { display: block; min-width: 0; max-width: 100%; }
|
||||||
|
details { margin: 4px 0; font-size: 12px; color: var(--app-text-secondary, #475569); }
|
||||||
|
summary { display: flex; align-items: center; gap: 6px; min-width: 0; min-height: 26px; cursor: pointer; list-style: none; border-radius: 4px; }
|
||||||
|
summary::-webkit-details-marker { display: none; }
|
||||||
|
summary:focus-visible { outline: 2px solid var(--app-primary-text, #1a73e8); outline-offset: 2px; }
|
||||||
|
.summary-text { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.chevron { flex: none; font-size: 18px; line-height: 20px; }
|
||||||
|
details[open] .chevron { transform: rotate(90deg); }
|
||||||
|
.hint { flex: none; font-size: 11px; color: var(--app-text-muted, #64748b); }
|
||||||
|
.body { max-height: 280px; overflow: auto; padding: 8px 10px; margin-top: 4px; border: 1px solid var(--app-border, #e2e8f0); border-radius: 6px; background: var(--app-surface, #fff); cursor: text; }
|
||||||
|
.label { font-size: 11px; color: var(--app-text-muted, #64748b); margin: 10px 0 4px; }
|
||||||
|
ul { margin: 4px 0; padding-left: 20px; }
|
||||||
|
`]
|
||||||
|
})
|
||||||
|
export class TaskOutcomeComponent {
|
||||||
|
error = input<string | null>();
|
||||||
|
result = input<CaseTestResult | null>();
|
||||||
|
status = input('');
|
||||||
|
// Strip only the known SDK envelope, preserving the actual explanation and diagnostics.
|
||||||
|
private explanation(value: string | null | undefined): string {
|
||||||
|
return (value || '').replace(/^\[[^\]\r\n]+\]\s*FlashRunner failed:\s*/, '').trim();
|
||||||
|
}
|
||||||
|
message = computed(() => this.explanation(this.error()));
|
||||||
|
reason = computed(() => this.explanation(this.result()?.reason));
|
||||||
|
visible = computed(() => ['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(this.status())
|
||||||
|
&& !!(this.message() || (this.result()?.status !== 'passed' && this.reason())));
|
||||||
|
summary = computed(() => {
|
||||||
|
const line = (this.reason() || this.message()).split(/\r?\n/).find(part => part.trim()) || '';
|
||||||
|
return line.replace(/^\s{0,3}(?:#{1,6}\s+|[-*+]\s+|>\s*)/, '')
|
||||||
|
.replace(/!?\[([^\]]+)\]\([^)]*\)/g, '$1').replace(/(\*\*|__|~~|`)/g, '');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ export class TaskPluginsComponent implements OnChanges {
|
|||||||
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 === this.target() || (this.target() === 'android' && target === 'mixed')));
|
p.manifest.targets.some(target => target === this.target() || (this.target() === 'android' && target === 'mixed')));
|
||||||
}
|
}
|
||||||
ngOnChanges() {
|
ngOnChanges() {
|
||||||
|
|||||||
@@ -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'];
|
||||||
@@ -1518,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()" />
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { TaskRecommendationService } from '../../core/services/task-recommendati
|
|||||||
|
|
||||||
describe('Homepage exploration entry',()=>{
|
describe('Homepage exploration entry',()=>{
|
||||||
let api:any,agent:any,router:any,system: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)};
|
||||||
|
|||||||
@@ -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,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 }); }
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
|
||||||
|
export interface ExtensionField {
|
||||||
|
id: string; label: string; description: string; kind: 'choice' | 'text';
|
||||||
|
options: string[]; max_length: number; report: boolean;
|
||||||
|
}
|
||||||
|
export interface ExtensionPanel {
|
||||||
|
id: string; title: string; side: 'left' | 'right'; kind: 'initial_prompt' | 'original_case' | 'form';
|
||||||
|
width: { preferred: number; minimum: number; maximum: number };
|
||||||
|
}
|
||||||
|
export interface TaskExtension {
|
||||||
|
plugin_id: string; name: string; version: string; revision: number;
|
||||||
|
workspace: { panels: ExtensionPanel[]; fields: ExtensionField[] };
|
||||||
|
values: Record<string, string>;
|
||||||
|
history: { revision: number; saved_at: number; values: Record<string, string>; fields: ExtensionField[] }[];
|
||||||
|
}
|
||||||
|
export interface TaskExtensions {
|
||||||
|
session_id: string; initial_prompt: string; editable: boolean; plugins: TaskExtension[];
|
||||||
|
case_title?: string;
|
||||||
|
original_case?: { preconditions: string; steps: string; expected: string } | null;
|
||||||
|
navigation?: { scope: 'batch'; position: number; total: number; previous: string | null; next: string | null } | null;
|
||||||
|
}
|
||||||
|
export interface ExtensionDraft {
|
||||||
|
values: Record<string, string>; revision: number; dirty: boolean; requestId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class TaskExtensionsService {
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
private drafts = new Map<string, ExtensionDraft>();
|
||||||
|
read(sessionId: string) {
|
||||||
|
return this.http.get<TaskExtensions>(`/api/task-extensions/${encodeURIComponent(sessionId)}`);
|
||||||
|
}
|
||||||
|
draft(sessionId: string, plugin: TaskExtension): ExtensionDraft {
|
||||||
|
const key = `${sessionId}/${plugin.plugin_id}`;
|
||||||
|
let draft = this.drafts.get(key);
|
||||||
|
if (!draft || (!draft.dirty && plugin.revision >= draft.revision)) {
|
||||||
|
draft = { values: { ...plugin.values }, revision: plugin.revision, dirty: false };
|
||||||
|
this.drafts.set(key, draft);
|
||||||
|
}
|
||||||
|
return draft;
|
||||||
|
}
|
||||||
|
discard(sessionId: string, pluginId: string) { this.drafts.delete(`${sessionId}/${pluginId}`); }
|
||||||
|
save(sessionId: string, pluginId: string, draft: ExtensionDraft) {
|
||||||
|
draft.requestId ||= crypto.randomUUID();
|
||||||
|
return this.http.put<TaskExtensions>(`/api/task-extensions/${encodeURIComponent(sessionId)}/${encodeURIComponent(pluginId)}`, {
|
||||||
|
values: { ...draft.values }, expected_revision: draft.revision, request_id: draft.requestId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -96,6 +96,7 @@ from artemis.services.llm import (
|
|||||||
get_openai_llm,
|
get_openai_llm,
|
||||||
get_llm,
|
get_llm,
|
||||||
invoke_llm_with_timeout_message,
|
invoke_llm_with_timeout_message,
|
||||||
|
with_fallback,
|
||||||
)
|
)
|
||||||
from artemis.tools.history import history_tool_declarations
|
from artemis.tools.history import history_tool_declarations
|
||||||
from artemis.tools.android_files import android_file
|
from artemis.tools.android_files import android_file
|
||||||
@@ -780,7 +781,7 @@ class FlashRunner:
|
|||||||
action_dict["normalized_start_coordinates"] = norm_start
|
action_dict["normalized_start_coordinates"] = norm_start
|
||||||
action_dict["normalized_end_coordinates"] = norm_end
|
action_dict["normalized_end_coordinates"] = norm_end
|
||||||
action_dict.update(metadata.get("target_semantics") or {})
|
action_dict.update(metadata.get("target_semantics") or {})
|
||||||
for key in ("input_verification", "execution", "side_effect"):
|
for key in ("input_verification", "execution", "side_effect", "source"):
|
||||||
if key in metadata:
|
if key in metadata:
|
||||||
action_dict[key] = metadata[key]
|
action_dict[key] = metadata[key]
|
||||||
if metadata.get("plugin_id"):
|
if metadata.get("plugin_id"):
|
||||||
@@ -1506,20 +1507,32 @@ class FlashRunner:
|
|||||||
turn_base = len(messages) - 1
|
turn_base = len(messages) - 1
|
||||||
current_tools = report_only_tools if is_final else self._get_tools()
|
current_tools = report_only_tools if is_final else self._get_tools()
|
||||||
|
|
||||||
|
async def invoke_turn():
|
||||||
|
if using_tool_fallback:
|
||||||
|
return await self._invoke_model(fallback_llm, current_tools, messages)
|
||||||
|
|
||||||
|
async def invoke_fallback():
|
||||||
|
nonlocal fallback_llm
|
||||||
|
if fallback_llm is None:
|
||||||
|
fallback_llm = get_llm(self.ctx, name="operator", use_fallback=True)
|
||||||
|
return await self._invoke_model(fallback_llm, current_tools, messages)
|
||||||
|
|
||||||
|
return await with_fallback(
|
||||||
|
lambda: self._invoke_model(llm, current_tools, messages),
|
||||||
|
invoke_fallback,
|
||||||
|
none_should_fallback=False,
|
||||||
|
)
|
||||||
|
|
||||||
if isinstance(control, TaskControl):
|
if isinstance(control, TaskControl):
|
||||||
interrupted, response = await control.run_interruptibly(
|
interrupted, response = await control.run_interruptibly(
|
||||||
self._invoke_model(
|
invoke_turn(),
|
||||||
fallback_llm if using_tool_fallback else llm, current_tools, messages
|
|
||||||
),
|
|
||||||
on_pause=getattr(self.ctx, "on_task_pause", None),
|
on_pause=getattr(self.ctx, "on_task_pause", None),
|
||||||
on_resume=getattr(self.ctx, "on_task_resume", None),
|
on_resume=getattr(self.ctx, "on_task_resume", None),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
interrupted, response = (
|
interrupted, response = (
|
||||||
False,
|
False,
|
||||||
await self._invoke_model(
|
await invoke_turn(),
|
||||||
fallback_llm if using_tool_fallback else llm, current_tools, messages
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if interrupted or (
|
if interrupted or (
|
||||||
|
|||||||
@@ -412,6 +412,7 @@ class VisualStepSummarizer(StepMemoryService):
|
|||||||
engine,
|
engine,
|
||||||
response,
|
response,
|
||||||
source=f"lens:visual_transition:{self._model_name}",
|
source=f"lens:visual_transition:{self._model_name}",
|
||||||
|
model=self._model_name,
|
||||||
update_last_prompt=False,
|
update_last_prompt=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ from artemis.mcp.action_specs import OPERATOR_SHELL_ORDER, operator_shell_tool
|
|||||||
from artemis.llm.usage import usage_from_message
|
from artemis.llm.usage import usage_from_message
|
||||||
from artemis.llm.structured import content_to_text
|
from artemis.llm.structured import content_to_text
|
||||||
from artemis.runtime.task_control import TaskControl, has_pending_instruction
|
from artemis.runtime.task_control import TaskControl, has_pending_instruction
|
||||||
from artemis.services.llm import acomplete, get_llm, invoke_llm_with_timeout_message
|
from artemis.services.llm import acomplete, get_llm, invoke_llm_with_timeout_message, with_fallback
|
||||||
from artemis.tools.command_tool import (
|
from artemis.tools.command_tool import (
|
||||||
analyze_task_output_wrapper,
|
analyze_task_output_wrapper,
|
||||||
get_adb_task_registry,
|
get_adb_task_registry,
|
||||||
@@ -638,9 +638,19 @@ class OperatorNode:
|
|||||||
and t.name not in {"plugin_load", "plugin_read_file", "plugin_read_result"}
|
and t.name not in {"plugin_load", "plugin_read_file", "plugin_read_result"}
|
||||||
]
|
]
|
||||||
traced_tools.extend(plugins.langchain_tools())
|
traced_tools.extend(plugins.langchain_tools())
|
||||||
bound_llm = active_llm.bind_tools(tools=traced_tools)
|
|
||||||
|
async def invoke(model):
|
||||||
|
bound = model.bind_tools(tools=traced_tools)
|
||||||
|
return await invoke_llm_with_timeout_message(acomplete(bound, current_messages))
|
||||||
|
|
||||||
interrupted, response = await self._await_or_pause(
|
interrupted, response = await self._await_or_pause(
|
||||||
invoke_llm_with_timeout_message(acomplete(bound_llm, current_messages))
|
invoke(active_llm)
|
||||||
|
if using_tool_fallback
|
||||||
|
else with_fallback(
|
||||||
|
lambda: invoke(base_llm),
|
||||||
|
lambda: invoke(get_llm(ctx=self.ctx, name="operator", use_fallback=True)),
|
||||||
|
none_should_fallback=False,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
if interrupted:
|
if interrupted:
|
||||||
return None, None, None, False
|
return None, None, None, False
|
||||||
|
|||||||
@@ -303,8 +303,19 @@ class PlannerNode:
|
|||||||
screen = await UnifiedMobileController(self.ctx).get_screen_data()
|
screen = await UnifiedMobileController(self.ctx).get_screen_data()
|
||||||
human_message_content.append({"type": "text", "text": screen.observation_text})
|
human_message_content.append({"type": "text", "text": screen.observation_text})
|
||||||
plugins = getattr(self.ctx, "plugins", None)
|
plugins = getattr(self.ctx, "plugins", None)
|
||||||
if plugins:
|
plugin_catalog = plugins.prompt() if plugins else ""
|
||||||
human_message_content.append({"type": "text", "text": plugins.prompt()})
|
if plugin_catalog:
|
||||||
|
human_message_content.append(
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": "规划与执行的工具分工:规划器只使用本阶段绑定的笔记工具制定计划;"
|
||||||
|
"下列插件清单描述执行器获授权的能力。执行器可以调用 plugin_load 加载插件。"
|
||||||
|
"规划器当前工具列表里没有 plugin_load 或设备动作,不表示执行器缺少这些能力。"
|
||||||
|
"将需要的插件、待观察的目标和验收条件写入计划,由执行器根据实际加载与操作回执"
|
||||||
|
"确认可用性;未取得回执时保留待确认,不预先记录为插件不可用或要求用户补授权。",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
human_message_content.append({"type": "text", "text": plugin_catalog})
|
||||||
if screenshot_b64:
|
if screenshot_b64:
|
||||||
human_message_content.append(
|
human_message_content.append(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -92,6 +92,11 @@ async def exec_action(
|
|||||||
return False, str(e)
|
return False, str(e)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
from artemis.data_engine.evidence_sources import android_action_source
|
||||||
|
|
||||||
|
source = android_action_source(getattr(getattr(ctx, "device", None), "device_id", None))
|
||||||
|
if source:
|
||||||
|
action_item["source"] = source
|
||||||
res = await session.call(tool_name, wire_args)
|
res = await session.call(tool_name, wire_args)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
action_item.update(execution="unknown", side_effect="possible")
|
action_item.update(execution="unknown", side_effect="possible")
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|||||||
class ExplorationConfig(BaseModel):
|
class ExplorationConfig(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
model: str = Field(default="gpt-6.1-sol", min_length=1, max_length=200)
|
model: str = Field(default="gpt-6.1-terra", min_length=1, max_length=200)
|
||||||
reasoning_effort: Literal[
|
reasoning_effort: Literal[
|
||||||
"default", "none", "minimal", "low", "medium", "high", "xhigh", "max"
|
"default", "none", "minimal", "low", "medium", "high", "xhigh", "max"
|
||||||
] = "high"
|
] = "high"
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ class Settings(BaseSettings):
|
|||||||
"""Centralized ARTEMIS runtime settings loaded from environment and .env files."""
|
"""Centralized ARTEMIS runtime settings loaded from environment and .env files."""
|
||||||
|
|
||||||
ARTEMIS_PLUGIN_MARKETPLACE_URL: str = (
|
ARTEMIS_PLUGIN_MARKETPLACE_URL: str = (
|
||||||
"https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/catalog.json"
|
"https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/catalog-v4.json"
|
||||||
)
|
)
|
||||||
ARTEMIS_PLUGIN_START_TIMEOUT_SECONDS: float = Field(default=30, gt=0, le=300)
|
ARTEMIS_PLUGIN_START_TIMEOUT_SECONDS: float = Field(default=30, gt=0, le=300)
|
||||||
ARTEMIS_PLUGIN_TOOL_TIMEOUT_SECONDS: float = Field(default=60, gt=0, le=3600)
|
ARTEMIS_PLUGIN_TOOL_TIMEOUT_SECONDS: float = Field(default=60, gt=0, le=3600)
|
||||||
@@ -109,6 +109,8 @@ class Settings(BaseSettings):
|
|||||||
OPENAI_BASE_URL: str | None = "https://api-n-cd.playones.com/v1"
|
OPENAI_BASE_URL: str | None = "https://api-n-cd.playones.com/v1"
|
||||||
OPENAI_MODEL: str = Field(default=DEFAULT_MODEL, min_length=1)
|
OPENAI_MODEL: str = Field(default=DEFAULT_MODEL, min_length=1)
|
||||||
OPENAI_TIMEOUT_SECONDS: float = Field(default=300.0, gt=0)
|
OPENAI_TIMEOUT_SECONDS: float = Field(default=300.0, gt=0)
|
||||||
|
# Environment only; deliberately absent from the saved UI/task configuration.
|
||||||
|
OPENAI_USE_SYSTEM_PROXY: bool = False
|
||||||
OPENAI_MAX_RETRIES: int = Field(default=5, ge=0, le=10)
|
OPENAI_MAX_RETRIES: int = Field(default=5, ge=0, le=10)
|
||||||
OPENAI_RETRY_INTERVAL_SECONDS: float = Field(default=10.0, ge=0, le=300)
|
OPENAI_RETRY_INTERVAL_SECONDS: float = Field(default=10.0, ge=0, le=300)
|
||||||
OPENAI_RATE_LIMIT_RETRY_SECONDS: float = Field(default=60.0, ge=0, le=3600)
|
OPENAI_RATE_LIMIT_RETRY_SECONDS: float = Field(default=60.0, ge=0, le=3600)
|
||||||
|
|||||||
@@ -8,6 +8,58 @@ import json
|
|||||||
SOURCE_KINDS = {"android", "computer", "terminal", "api", "file", "other"}
|
SOURCE_KINDS = {"android", "computer", "terminal", "api", "file", "other"}
|
||||||
|
|
||||||
|
|
||||||
|
def android_action_source(device_id: object) -> dict | None:
|
||||||
|
"""Stamp a native action at dispatch, using its actual selected device."""
|
||||||
|
if not isinstance(device_id, str) or not device_id or device_id == "computer:local":
|
||||||
|
return None
|
||||||
|
return {"kind": "android", "target_id": device_id, "label": "安卓屏幕"}
|
||||||
|
|
||||||
|
|
||||||
|
def _android_screenshot_records(step: dict) -> list[dict]:
|
||||||
|
"""Project explicit capture receipts; never infer provenance from tool args."""
|
||||||
|
records = []
|
||||||
|
seen = set()
|
||||||
|
for tool in step.get("generic_tools") or []:
|
||||||
|
if not isinstance(tool, dict) or (
|
||||||
|
tool.get("type"),
|
||||||
|
tool.get("name"),
|
||||||
|
tool.get("status"),
|
||||||
|
) != ("tool", "android_file", "success"):
|
||||||
|
continue
|
||||||
|
payload = tool.get("payload")
|
||||||
|
receipt = payload.get("result") if isinstance(payload, dict) else None
|
||||||
|
if not isinstance(receipt, dict) or (
|
||||||
|
receipt.get("ok") is not True
|
||||||
|
or receipt.get("verified") is not True
|
||||||
|
or receipt.get("action") != "screenshot"
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
serial, path, digest = (
|
||||||
|
receipt.get("device_serial"),
|
||||||
|
receipt.get("trace_path"),
|
||||||
|
receipt.get("sha256"),
|
||||||
|
)
|
||||||
|
if not all(isinstance(value, str) and value for value in (serial, path, digest)):
|
||||||
|
continue
|
||||||
|
captured_at = receipt.get("captured_at")
|
||||||
|
captured_at = captured_at if isinstance(captured_at, str) else None
|
||||||
|
key = (serial, path, digest, captured_at)
|
||||||
|
if key in seen:
|
||||||
|
# Nested tool decorators can record the same completed capture twice.
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
records.append(
|
||||||
|
{
|
||||||
|
"source": {"kind": "android", "target_id": serial, "label": "安卓屏幕"},
|
||||||
|
"trace_id": tool.get("trace_id"),
|
||||||
|
"artifact_path": path,
|
||||||
|
"sha256": digest,
|
||||||
|
"captured_at": captured_at,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
def _step_reference(step: dict) -> dict:
|
def _step_reference(step: dict) -> dict:
|
||||||
value = step.get("step_id")
|
value = step.get("step_id")
|
||||||
return {
|
return {
|
||||||
@@ -16,6 +68,19 @@ def _step_reference(step: dict) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _represented_capture(action: dict, captures: list[dict]) -> bool:
|
||||||
|
if action.get("action") != "android_file":
|
||||||
|
return False
|
||||||
|
args = action.get("args") or {}
|
||||||
|
if not isinstance(args, dict) or args.get("action") != "screenshot":
|
||||||
|
return False
|
||||||
|
filename = args.get("filename")
|
||||||
|
return any(
|
||||||
|
capture["artifact_path"].replace("\\", "/").rsplit("/", 1)[-1] == filename
|
||||||
|
for capture in captures
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def recorded_sources(steps: list[dict], *, limit: int = 100) -> dict:
|
def recorded_sources(steps: list[dict], *, limit: int = 100) -> dict:
|
||||||
sources = {}
|
sources = {}
|
||||||
unattributed = []
|
unattributed = []
|
||||||
@@ -23,7 +88,23 @@ def recorded_sources(steps: list[dict], *, limit: int = 100) -> dict:
|
|||||||
result = step.get("last_execution_result")
|
result = step.get("last_execution_result")
|
||||||
execution = result.get("execution") if isinstance(result, dict) else None
|
execution = result.get("execution") if isinstance(result, dict) else None
|
||||||
records = execution if isinstance(execution, list) else step.get("action_taken")
|
records = execution if isinstance(execution, list) else step.get("action_taken")
|
||||||
records = records if isinstance(records, list) else [records]
|
records = records if isinstance(records, list) else [records] if records else []
|
||||||
|
captures = _android_screenshot_records(step)
|
||||||
|
records = [
|
||||||
|
record
|
||||||
|
for record in records
|
||||||
|
if not isinstance(record, dict)
|
||||||
|
or (
|
||||||
|
record.get("action")
|
||||||
|
not in {"save_note", "read_note", "list_notes", "report_task_status"}
|
||||||
|
and not _represented_capture(record, captures)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
records = [*records, *captures]
|
||||||
|
if not records:
|
||||||
|
# A planning or completion turn with no recorded action is not a
|
||||||
|
# missing action source. Screenshots alone do not supply provenance.
|
||||||
|
continue
|
||||||
found = False
|
found = False
|
||||||
missing = False
|
missing = False
|
||||||
for record in records:
|
for record in records:
|
||||||
@@ -39,6 +120,7 @@ def recorded_sources(steps: list[dict], *, limit: int = 100) -> dict:
|
|||||||
continue
|
continue
|
||||||
found = True
|
found = True
|
||||||
plugin = str(record.get("plugin_id") or "")[:100]
|
plugin = str(record.get("plugin_id") or "")[:100]
|
||||||
|
label = str(source.get("label") or "")[:500]
|
||||||
key = (plugin, kind, target[:256])
|
key = (plugin, kind, target[:256])
|
||||||
if key not in sources:
|
if key not in sources:
|
||||||
sources[key] = {
|
sources[key] = {
|
||||||
@@ -46,18 +128,30 @@ def recorded_sources(steps: list[dict], *, limit: int = 100) -> dict:
|
|||||||
"kind": kind,
|
"kind": kind,
|
||||||
"plugin_id": plugin,
|
"plugin_id": plugin,
|
||||||
"target_id": target[:256],
|
"target_id": target[:256],
|
||||||
"label": str(source.get("label") or "")[:500],
|
"label": label,
|
||||||
"category": "interface" if kind in {"android", "computer"} else "auxiliary",
|
"category": "interface" if kind in {"android", "computer"} else "auxiliary",
|
||||||
"references": [],
|
"references": [],
|
||||||
"reference_count": 0,
|
"reference_count": 0,
|
||||||
}
|
}
|
||||||
reference = _step_reference(step)
|
reference = _step_reference(step)
|
||||||
for field in ("call_id", "source_id"):
|
for field in (
|
||||||
|
"call_id",
|
||||||
|
"source_id",
|
||||||
|
"trace_id",
|
||||||
|
"artifact_path",
|
||||||
|
"sha256",
|
||||||
|
"captured_at",
|
||||||
|
):
|
||||||
if isinstance(record.get(field), str) and record[field]:
|
if isinstance(record.get(field), str) and record[field]:
|
||||||
reference[field] = record[field][:256]
|
reference[field] = record[field][: 2048 if field == "artifact_path" else 256]
|
||||||
if isinstance(record.get("read_only"), bool):
|
if isinstance(record.get("read_only"), bool):
|
||||||
reference["read_only"] = record["read_only"]
|
reference["read_only"] = record["read_only"]
|
||||||
entry = sources[key]
|
entry = sources[key]
|
||||||
|
if not entry.get("label_varies") and entry["label"] != label:
|
||||||
|
# The same window can navigate between tabs/pages. Its first
|
||||||
|
# title is not a valid label for all later evidence.
|
||||||
|
entry["label"] = ""
|
||||||
|
entry["label_varies"] = True
|
||||||
entry["reference_count"] += 1
|
entry["reference_count"] += 1
|
||||||
if len(entry["references"]) < 8:
|
if len(entry["references"]) < 8:
|
||||||
entry["references"].append(reference)
|
entry["references"].append(reference)
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ async def execute_task(
|
|||||||
config.with_graph_config_callbacks(graph_config_callbacks)
|
config.with_graph_config_callbacks(graph_config_callbacks)
|
||||||
|
|
||||||
agent: Agent | None = None
|
agent: Agent | None = None
|
||||||
|
task_started = False
|
||||||
try:
|
try:
|
||||||
agent = Agent(config=config.build(), session_id=effective_sid)
|
agent = Agent(config=config.build(), session_id=effective_sid)
|
||||||
await agent.init(
|
await agent.init(
|
||||||
@@ -196,7 +197,14 @@ async def execute_task(
|
|||||||
request.plugins = plugins
|
request.plugins = plugins
|
||||||
elif plugin_choices is not None:
|
elif plugin_choices is not None:
|
||||||
request.plugins = TypeAdapter(list[PluginSelection]).validate_json(plugin_choices)
|
request.plugins = TypeAdapter(list[PluginSelection]).validate_json(plugin_choices)
|
||||||
|
task_started = True
|
||||||
await agent.run_task(request=request)
|
await agent.run_task(request=request)
|
||||||
|
except Exception as exc:
|
||||||
|
if not task_started and effective_sid:
|
||||||
|
from artemis.runtime.task_result import save_failure_result
|
||||||
|
|
||||||
|
save_failure_result(str(effective_sid), str(exc), code="initialization_failed")
|
||||||
|
raise
|
||||||
finally:
|
finally:
|
||||||
if agent is not None:
|
if agent is not None:
|
||||||
await agent.clean()
|
await agent.clean()
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"""UI Launch and Web Console CLI Command (artemis ui)."""
|
"""UI Launch and Web Console CLI Command (artemis ui)."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
@@ -34,6 +35,11 @@ import typer
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _launch_text(english: str, chinese: str) -> str:
|
||||||
|
"""Honor the language selected by the Windows launch wrapper."""
|
||||||
|
return chinese if os.environ.get("ARTEMIS_LAUNCH_LANG") == "zh-CN" else english
|
||||||
|
|
||||||
|
|
||||||
def running_via_console_script_shim() -> bool:
|
def running_via_console_script_shim() -> bool:
|
||||||
"""Return True when this CLI was launched through a console-script ``.exe`` shim.
|
"""Return True when this CLI was launched through a console-script ``.exe`` shim.
|
||||||
|
|
||||||
@@ -308,7 +314,12 @@ def ui_command(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if restart:
|
if restart:
|
||||||
console.print(f" [yellow]🔄 Restart requested. Recycling port {port}...[/yellow]")
|
console.print(
|
||||||
|
_launch_text(
|
||||||
|
f" [yellow]🔄 Restart requested. Recycling port {port}...[/yellow]",
|
||||||
|
f" [yellow]🔄 已请求重启,正在释放端口 {port}...[/yellow]",
|
||||||
|
)
|
||||||
|
)
|
||||||
load_session_reconciler()
|
load_session_reconciler()
|
||||||
stop_server(port=port, timeout=12.0)
|
stop_server(port=port, timeout=12.0)
|
||||||
elif is_port_in_use(port):
|
elif is_port_in_use(port):
|
||||||
@@ -316,19 +327,39 @@ def ui_command(
|
|||||||
pid_str = f" (PID: {', '.join(map(str, pids))})" if pids else ""
|
pid_str = f" (PID: {', '.join(map(str, pids))})" if pids else ""
|
||||||
if sys.stdin.isatty():
|
if sys.stdin.isatty():
|
||||||
console.print(
|
console.print(
|
||||||
f"\n [yellow]⚠ Port {port} is already in use by an active Artemis server{pid_str}.[/yellow]"
|
_launch_text(
|
||||||
|
f"\n [yellow]⚠ Port {port} is already in use by an active Artemis server{pid_str}.[/yellow]",
|
||||||
|
f"\n [yellow]⚠ 检测到 ARTEMIS 服务正在运行,占用端口 {port}{pid_str}。[/yellow]",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
console.print(" [dim]Choose an action:[/dim]")
|
console.print(
|
||||||
console.print(" [bold]1[/bold] Open browser (default)")
|
_launch_text(" [dim]Choose an action:[/dim]", " [dim]请选择操作:[/dim]")
|
||||||
console.print(" [bold]2[/bold] Restart server (stop existing and start here)")
|
)
|
||||||
console.print(" [bold]3[/bold] Cancel")
|
console.print(
|
||||||
|
_launch_text(
|
||||||
|
" [bold]1[/bold] Open browser (default)",
|
||||||
|
" [bold]1[/bold] 打开浏览器(默认)",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
console.print(
|
||||||
|
_launch_text(
|
||||||
|
" [bold]2[/bold] Restart server (stop existing and start here)",
|
||||||
|
" [bold]2[/bold] 重启服务(停止现有服务并在此启动,会中断正在运行的任务)",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
console.print(_launch_text(" [bold]3[/bold] Cancel", " [bold]3[/bold] 取消"))
|
||||||
try:
|
try:
|
||||||
choice = input(" Select [1-3, default 1]: ").strip()
|
choice = input(
|
||||||
|
_launch_text(" Select [1-3, default 1]: ", " 请输入选项 [1-3,默认 1]:")
|
||||||
|
).strip()
|
||||||
except (KeyboardInterrupt, EOFError):
|
except (KeyboardInterrupt, EOFError):
|
||||||
choice = "1"
|
choice = "1"
|
||||||
if choice == "2":
|
if choice == "2":
|
||||||
console.print(
|
console.print(
|
||||||
f"\n [yellow]🔄 Terminating previous Artemis instance on port {port}...[/yellow]"
|
_launch_text(
|
||||||
|
f"\n [yellow]🔄 Terminating previous Artemis instance on port {port}...[/yellow]",
|
||||||
|
f"\n [yellow]🔄 正在停止端口 {port} 上的现有 ARTEMIS 服务...[/yellow]",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
load_session_reconciler()
|
load_session_reconciler()
|
||||||
stop_server(port=port, timeout=12.0)
|
stop_server(port=port, timeout=12.0)
|
||||||
@@ -340,8 +371,12 @@ def ui_command(
|
|||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
console.print(
|
console.print(
|
||||||
f"\n [yellow]⚠ Port {port} is already in use by Artemis{pid_str}. "
|
_launch_text(
|
||||||
f"Use '[bold]artemis restart[/bold]' to restart.[/yellow]\n"
|
f"\n [yellow]⚠ Port {port} is already in use by Artemis{pid_str}. "
|
||||||
|
"Use '[bold]artemis restart[/bold]' to restart.[/yellow]\n",
|
||||||
|
f"\n [yellow]⚠ 检测到 ARTEMIS 服务正在运行,占用端口 {port}{pid_str}。"
|
||||||
|
"如需重启,请运行 '[bold]artemis restart[/bold]'。[/yellow]\n",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
if open_browser:
|
if open_browser:
|
||||||
webbrowser.open(local_url)
|
webbrowser.open(local_url)
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""Bounded fingerprints of received argument deltas, without retaining text."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
|
||||||
|
PREFIX_BYTES = 4096
|
||||||
|
MAX_PREFIXES = 16
|
||||||
|
MAX_TRACKED_TOOLS = 64
|
||||||
|
|
||||||
|
|
||||||
|
class ArgumentFingerprint:
|
||||||
|
def __init__(self):
|
||||||
|
self.characters = 0
|
||||||
|
self.bytes = 0
|
||||||
|
self.deltas = 0
|
||||||
|
self.encoding_valid = True
|
||||||
|
self.digest = hashlib.sha256()
|
||||||
|
self.prefixes: list[dict] = []
|
||||||
|
|
||||||
|
def observe(self, arguments: str) -> None:
|
||||||
|
self.characters += len(arguments)
|
||||||
|
if not arguments:
|
||||||
|
return
|
||||||
|
self.deltas += 1
|
||||||
|
if not self.encoding_valid:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
encoded = arguments.encode("utf-8")
|
||||||
|
except UnicodeEncodeError:
|
||||||
|
# Diagnostics must not repair, reject or otherwise alter the stream.
|
||||||
|
self.encoding_valid = False
|
||||||
|
return
|
||||||
|
offset = 0
|
||||||
|
while offset < len(encoded) and len(self.prefixes) < MAX_PREFIXES:
|
||||||
|
size = min(PREFIX_BYTES - self.bytes % PREFIX_BYTES, len(encoded) - offset)
|
||||||
|
self.digest.update(encoded[offset : offset + size])
|
||||||
|
self.bytes += size
|
||||||
|
offset += size
|
||||||
|
if self.bytes % PREFIX_BYTES == 0:
|
||||||
|
self.prefixes.append({"bytes": self.bytes, "sha256": self.digest.hexdigest()})
|
||||||
|
if offset < len(encoded):
|
||||||
|
self.digest.update(encoded[offset:])
|
||||||
|
self.bytes += len(encoded) - offset
|
||||||
|
|
||||||
|
def snapshot(self) -> dict:
|
||||||
|
return {
|
||||||
|
"argument_chars": self.characters,
|
||||||
|
"argument_bytes": self.bytes if self.encoding_valid else None,
|
||||||
|
"argument_sha256": self.digest.hexdigest() if self.encoding_valid else None,
|
||||||
|
"argument_deltas": self.deltas,
|
||||||
|
"argument_prefixes": [dict(prefix) for prefix in self.prefixes],
|
||||||
|
"argument_encoding_valid": self.encoding_valid,
|
||||||
|
}
|
||||||
+35
-10
@@ -13,6 +13,7 @@ from uuid import uuid4
|
|||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from artemis.data_engine.context_vars import CURRENT_NODE_NAME, CURRENT_TRACE_ID
|
from artemis.data_engine.context_vars import CURRENT_NODE_NAME, CURRENT_TRACE_ID
|
||||||
|
from artemis.llm.argument_fingerprint import ArgumentFingerprint, MAX_TRACKED_TOOLS
|
||||||
from artemis.llm.final_review import ACTIVE_FINAL_REVIEW, FinalReviewBudgetExceeded
|
from artemis.llm.final_review import ACTIVE_FINAL_REVIEW, FinalReviewBudgetExceeded
|
||||||
from artemis.llm.reliability import LLMExhaustedError
|
from artemis.llm.reliability import LLMExhaustedError
|
||||||
from artemis.llm.usage import usage_from_message
|
from artemis.llm.usage import usage_from_message
|
||||||
@@ -107,6 +108,7 @@ class RequestAttempt:
|
|||||||
self.clock = time.monotonic()
|
self.clock = time.monotonic()
|
||||||
self.last_publish = self.clock
|
self.last_publish = self.clock
|
||||||
self.tools: dict[int | str, dict] = {}
|
self.tools: dict[int | str, dict] = {}
|
||||||
|
self.tool_fingerprints: dict[int | str, ArgumentFingerprint] = {}
|
||||||
self.review = ACTIVE_FINAL_REVIEW.get()
|
self.review = ACTIVE_FINAL_REVIEW.get()
|
||||||
if self.review:
|
if self.review:
|
||||||
self.review.request_started(request_id, attempt)
|
self.review.request_started(request_id, attempt)
|
||||||
@@ -126,6 +128,9 @@ class RequestAttempt:
|
|||||||
"usage": None,
|
"usage": None,
|
||||||
"output_chunks": 0,
|
"output_chunks": 0,
|
||||||
"tool_argument_chars": 0,
|
"tool_argument_chars": 0,
|
||||||
|
"tool_observation_layer": "langchain_tool_call_chunks",
|
||||||
|
"argument_fingerprint_version": 1,
|
||||||
|
"tool_stats_truncated": False,
|
||||||
"tools": [],
|
"tools": [],
|
||||||
"step_id": str(self.step_id) if self.step_id else None,
|
"step_id": str(self.step_id) if self.step_id else None,
|
||||||
"parent_trace_id": str(self.parent_id) if self.parent_id else None,
|
"parent_trace_id": str(self.parent_id) if self.parent_id else None,
|
||||||
@@ -167,9 +172,10 @@ class RequestAttempt:
|
|||||||
self.data.update(
|
self.data.update(
|
||||||
headers_at=time.time(),
|
headers_at=time.time(),
|
||||||
http_status=response.status_code,
|
http_status=response.status_code,
|
||||||
server_request_id=_identifier(
|
server_request_id=_identifier(response.headers.get("x-oneapi-request-id"))
|
||||||
response.headers.get("x-request-id") or response.headers.get("request-id")
|
or _identifier(response.headers.get("x-request-id"))
|
||||||
),
|
or _identifier(response.headers.get("request-id")),
|
||||||
|
response_x_request_id=_identifier(response.headers.get("x-request-id")),
|
||||||
server_trace_id=_identifier(response.headers.get("x-trace-id")),
|
server_trace_id=_identifier(response.headers.get("x-trace-id")),
|
||||||
phase="waiting_output",
|
phase="waiting_output",
|
||||||
)
|
)
|
||||||
@@ -196,18 +202,31 @@ class RequestAttempt:
|
|||||||
self.data["usage"] = usage
|
self.data["usage"] = usage
|
||||||
tool_chunks = getattr(chunk, "tool_call_chunks", None) or []
|
tool_chunks = getattr(chunk, "tool_call_chunks", None) or []
|
||||||
# Responses exposes the same argument delta in content and tool_call_chunks.
|
# Responses exposes the same argument delta in content and tool_call_chunks.
|
||||||
# Count only the latter; retain lengths/names, never argument bodies.
|
# Count only the latter; retain bounded fingerprints, never argument bodies.
|
||||||
for tool in tool_chunks:
|
for tool in tool_chunks:
|
||||||
|
arguments = tool.get("args")
|
||||||
|
if isinstance(arguments, str):
|
||||||
|
self.data["tool_argument_chars"] += len(arguments)
|
||||||
index = tool.get("index")
|
index = tool.get("index")
|
||||||
key = index if index is not None else tool.get("id") or "unknown"
|
call_id = _identifier(tool.get("id"))
|
||||||
stats = self.tools.setdefault(key, {"name": None, "argument_chars": 0})
|
key = index if index is not None else call_id or "unknown"
|
||||||
|
if key not in self.tools and len(self.tools) >= MAX_TRACKED_TOOLS:
|
||||||
|
self.data["tool_stats_truncated"] = True
|
||||||
|
continue
|
||||||
|
stats = self.tools.setdefault(key, {"name": None})
|
||||||
|
if call_id:
|
||||||
|
stats["call_id"] = call_id
|
||||||
|
if index is not None:
|
||||||
|
stats["chunk_index"] = index
|
||||||
name = tool.get("name")
|
name = tool.get("name")
|
||||||
if isinstance(name, str) and re.fullmatch(r"[\w.:-]{1,128}", name):
|
if isinstance(name, str) and re.fullmatch(r"[\w.:-]{1,128}", name):
|
||||||
stats["name"] = name
|
stats["name"] = name
|
||||||
arguments = tool.get("args")
|
fingerprint = self.tool_fingerprints.get(key)
|
||||||
|
if fingerprint is None:
|
||||||
|
fingerprint = self.tool_fingerprints[key] = ArgumentFingerprint()
|
||||||
if isinstance(arguments, str):
|
if isinstance(arguments, str):
|
||||||
stats["argument_chars"] += len(arguments)
|
fingerprint.observe(arguments)
|
||||||
self.data["tool_argument_chars"] += len(arguments)
|
stats.update(fingerprint.snapshot())
|
||||||
self.data["tools"] = list(self.tools.values())
|
self.data["tools"] = list(self.tools.values())
|
||||||
content = getattr(chunk, "content", None)
|
content = getattr(chunk, "content", None)
|
||||||
substantive = isinstance(content, str) and bool(content)
|
substantive = isinstance(content, str) and bool(content)
|
||||||
@@ -252,7 +271,9 @@ class RequestAttempt:
|
|||||||
if isinstance(status, int):
|
if isinstance(status, int):
|
||||||
self.data["http_status"] = status
|
self.data["http_status"] = status
|
||||||
if request_id := _identifier(getattr(error, "request_id", None)):
|
if request_id := _identifier(getattr(error, "request_id", None)):
|
||||||
self.data["server_request_id"] = request_id
|
self.data["error_request_id"] = request_id
|
||||||
|
if not self.data.get("server_request_id"):
|
||||||
|
self.data["server_request_id"] = request_id
|
||||||
if category == "timeout":
|
if category == "timeout":
|
||||||
self.data.setdefault("timeout_scope", "transport")
|
self.data.setdefault("timeout_scope", "transport")
|
||||||
if response is not None:
|
if response is not None:
|
||||||
@@ -301,6 +322,7 @@ async def observe_request_headers(request):
|
|||||||
if attempt is None:
|
if attempt is None:
|
||||||
return
|
return
|
||||||
request.headers.setdefault("X-Client-Request-Id", attempt.data["request_id"])
|
request.headers.setdefault("X-Client-Request-Id", attempt.data["request_id"])
|
||||||
|
request.headers.setdefault("X-Client-Attempt-Id", attempt.data["attempt_id"])
|
||||||
routing = {}
|
routing = {}
|
||||||
for name in ("thread-id", "thread_id", "session-id", "session_id"):
|
for name in ("thread-id", "thread_id", "session-id", "session_id"):
|
||||||
value = request.headers.get(name)
|
value = request.headers.get(name)
|
||||||
@@ -309,9 +331,12 @@ async def observe_request_headers(request):
|
|||||||
attempt.data.update(
|
attempt.data.update(
|
||||||
request_sent_at=time.time(),
|
request_sent_at=time.time(),
|
||||||
client_request_id=_identifier(request.headers.get("X-Client-Request-Id")),
|
client_request_id=_identifier(request.headers.get("X-Client-Request-Id")),
|
||||||
|
client_attempt_id=_identifier(request.headers.get("X-Client-Attempt-Id")),
|
||||||
routing_header_fingerprints=routing,
|
routing_header_fingerprints=routing,
|
||||||
client_request_id_matches=request.headers.get("X-Client-Request-Id")
|
client_request_id_matches=request.headers.get("X-Client-Request-Id")
|
||||||
== attempt.data["request_id"],
|
== attempt.data["request_id"],
|
||||||
|
client_attempt_id_matches=request.headers.get("X-Client-Attempt-Id")
|
||||||
|
== attempt.data["attempt_id"],
|
||||||
)
|
)
|
||||||
attempt.publish()
|
attempt.publish()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
"""Environment-only network policy shared by model calls and credential checks."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from artemis.config.settings import settings
|
||||||
|
|
||||||
|
|
||||||
|
def model_http_options() -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"trust_env": settings.OPENAI_USE_SYSTEM_PROXY,
|
||||||
|
# Proxy opt-out must not disable configured CA certificates or TLS checks.
|
||||||
|
"verify": httpx.create_ssl_context(trust_env=True),
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
import random
|
import random
|
||||||
|
import re
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -124,6 +125,11 @@ def classify_failure(error: BaseException) -> Failure:
|
|||||||
# Programmer/request errors: retrying is useless and falling back only
|
# Programmer/request errors: retrying is useless and falling back only
|
||||||
# hides the bug inside a different model's output.
|
# hides the bug inside a different model's output.
|
||||||
return Failure(FailureCategory.BAD_REQUEST, False, False)
|
return Failure(FailureCategory.BAD_REQUEST, False, False)
|
||||||
|
# langchain-openai 1.7 raises this ValueError for a response.failed event
|
||||||
|
# without provider error details, before our completion guard sees it.
|
||||||
|
# Preserve the bounded invalid-output retry policy for these failed streams.
|
||||||
|
if isinstance(error, ValueError) and re.fullmatch(r"Response \S+ failed\.", str(error)):
|
||||||
|
return Failure(FailureCategory.INVALID_OUTPUT, True, True)
|
||||||
return Failure(FailureCategory.UNKNOWN, True, True)
|
return Failure(FailureCategory.UNKNOWN, True, True)
|
||||||
|
|
||||||
|
|
||||||
@@ -147,7 +153,7 @@ class LLMPermanentError(LLMCallError):
|
|||||||
|
|
||||||
|
|
||||||
class LLMExhaustedError(LLMCallError):
|
class LLMExhaustedError(LLMCallError):
|
||||||
"""Retryable failures exhausted every attempt and the pause deadline."""
|
"""Retryable failures exhausted attempts and any applicable pause deadline."""
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ class ModelEndpoint(BaseModel):
|
|||||||
self.reasoning_effort,
|
self.reasoning_effort,
|
||||||
self.enable_grounding,
|
self.enable_grounding,
|
||||||
self.api_base or settings.OPENAI_BASE_URL,
|
self.api_base or settings.OPENAI_BASE_URL,
|
||||||
|
settings.OPENAI_USE_SYSTEM_PROXY,
|
||||||
hashlib.sha256(key.encode()).hexdigest(),
|
hashlib.sha256(key.encode()).hexdigest(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -87,9 +88,10 @@ class ModelFactory:
|
|||||||
)
|
)
|
||||||
|
|
||||||
from langchain_openai import ChatOpenAI
|
from langchain_openai import ChatOpenAI
|
||||||
from openai import DefaultAsyncHttpxClient
|
from openai import DefaultAsyncHttpxClient, DefaultHttpxClient
|
||||||
|
|
||||||
from artemis.llm.diagnostics import observe_request_headers, observe_response_headers
|
from artemis.llm.diagnostics import observe_request_headers, observe_response_headers
|
||||||
|
from artemis.llm.http import model_http_options
|
||||||
|
|
||||||
api_key = endpoint.api_key or (
|
api_key = endpoint.api_key or (
|
||||||
settings.OPENAI_API_KEY.get_secret_value() if settings.OPENAI_API_KEY else None
|
settings.OPENAI_API_KEY.get_secret_value() if settings.OPENAI_API_KEY else None
|
||||||
@@ -112,8 +114,14 @@ class ModelFactory:
|
|||||||
"use_responses_api": True,
|
"use_responses_api": True,
|
||||||
"output_version": "responses/v1",
|
"output_version": "responses/v1",
|
||||||
"store": False,
|
"store": False,
|
||||||
|
# The explicit clients own proxy policy, including the synchronous path.
|
||||||
|
"openai_proxy": "",
|
||||||
|
"http_client": DefaultHttpxClient(
|
||||||
|
timeout=endpoint.timeout_seconds, **model_http_options()
|
||||||
|
),
|
||||||
"http_async_client": DefaultAsyncHttpxClient(
|
"http_async_client": DefaultAsyncHttpxClient(
|
||||||
timeout=endpoint.timeout_seconds,
|
timeout=endpoint.timeout_seconds,
|
||||||
|
**model_http_options(),
|
||||||
event_hooks={
|
event_hooks={
|
||||||
"request": [observe_request_headers],
|
"request": [observe_request_headers],
|
||||||
"response": [observe_response_headers],
|
"response": [observe_response_headers],
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class ToolArgumentOverflow(ValueError):
|
|||||||
"limit_chars": SMALL_ACTION_ARGUMENT_LIMIT,
|
"limit_chars": SMALL_ACTION_ARGUMENT_LIMIT,
|
||||||
}
|
}
|
||||||
super().__init__(
|
super().__init__(
|
||||||
f"模型生成的 {name} 参数异常膨胀({characters} 字符,"
|
f"收到的 {name} 参数流超出安全限制({characters} 字符,"
|
||||||
f"保护上限 {SMALL_ACTION_ARGUMENT_LIMIT});已关闭输出流,未执行本次动作。"
|
f"保护上限 {SMALL_ACTION_ARGUMENT_LIMIT});已关闭输出流,未执行本次动作。"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -280,6 +280,11 @@ class McpActionExecutor:
|
|||||||
) -> ToolExecutionResult:
|
) -> ToolExecutionResult:
|
||||||
span = TraceSpan(name=raw_name, trace_type="action", ctx=self.ctx)
|
span = TraceSpan(name=raw_name, trace_type="action", ctx=self.ctx)
|
||||||
span.payload = {"args": args}
|
span.payload = {"args": args}
|
||||||
|
from artemis.data_engine.evidence_sources import android_action_source
|
||||||
|
|
||||||
|
source = android_action_source(
|
||||||
|
getattr(getattr(self.ctx, "device", None), "device_id", None)
|
||||||
|
)
|
||||||
|
|
||||||
message = ""
|
message = ""
|
||||||
res: ActionResult | None = None
|
res: ActionResult | None = None
|
||||||
@@ -291,6 +296,7 @@ class McpActionExecutor:
|
|||||||
observation_settled: bool | None = None
|
observation_settled: bool | None = None
|
||||||
input_verification: str | None = None
|
input_verification: str | None = None
|
||||||
expected_input: str | None = None
|
expected_input: str | None = None
|
||||||
|
dispatched = False
|
||||||
with span:
|
with span:
|
||||||
try:
|
try:
|
||||||
session = await self._session_or_start()
|
session = await self._session_or_start()
|
||||||
@@ -315,6 +321,7 @@ class McpActionExecutor:
|
|||||||
or observation_anchor(state, index_elements)
|
or observation_anchor(state, index_elements)
|
||||||
)
|
)
|
||||||
extension = raw_name not in (REQUIRED_ACTIONS | OPTIONAL_ACTIONS)
|
extension = raw_name not in (REQUIRED_ACTIONS | OPTIONAL_ACTIONS)
|
||||||
|
dispatched = True
|
||||||
if extension:
|
if extension:
|
||||||
raw_result = await session.call_raw(wire_name, wire_args)
|
raw_result = await session.call_raw(wire_name, wire_args)
|
||||||
message = next(
|
message = next(
|
||||||
@@ -386,6 +393,7 @@ class McpActionExecutor:
|
|||||||
"post_image_name": post_image_name,
|
"post_image_name": post_image_name,
|
||||||
"has_xml": bool(xml_list),
|
"has_xml": bool(xml_list),
|
||||||
"status": status,
|
"status": status,
|
||||||
|
**({"source": source} if source and dispatched else {}),
|
||||||
}
|
}
|
||||||
if status == "error":
|
if status == "error":
|
||||||
span.status = "failed"
|
span.status = "failed"
|
||||||
@@ -400,6 +408,7 @@ class McpActionExecutor:
|
|||||||
screenshot_path=shot_path,
|
screenshot_path=shot_path,
|
||||||
ui_elements_text=xml_list,
|
ui_elements_text=xml_list,
|
||||||
metadata={
|
metadata={
|
||||||
|
**({"source": source} if source and dispatched else {}),
|
||||||
"code": res.code.value,
|
"code": res.code.value,
|
||||||
"normalized_coordinates": res.normalized_coordinates,
|
"normalized_coordinates": res.normalized_coordinates,
|
||||||
"target_semantics": target_semantics,
|
"target_semantics": target_semantics,
|
||||||
|
|||||||
@@ -532,6 +532,7 @@ class StepCapsuleLens(StepLens):
|
|||||||
engine,
|
engine,
|
||||||
response,
|
response,
|
||||||
source=f"lens:step_capsule:{model_label}",
|
source=f"lens:step_capsule:{model_label}",
|
||||||
|
model=model_label if model_label != "fallback" else None,
|
||||||
update_last_prompt=False,
|
update_last_prompt=False,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""Private launch barrier for sdk.run_process; stdlib-only under Python -I."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
def watch_parent(expected: int):
|
||||||
|
while True:
|
||||||
|
if os.getppid() != expected:
|
||||||
|
os.killpg(os.getpid(), signal.SIGKILL)
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
status = Path(sys.argv[1])
|
||||||
|
if os.name != "nt":
|
||||||
|
threading.Thread(target=watch_parent, args=(int(sys.argv[2]),), daemon=True).start()
|
||||||
|
# Until the parent owns our job/session and sends this line, no business
|
||||||
|
# process can start. Buffering is local; tool stdin uses a separate pipe.
|
||||||
|
control = sys.stdin.buffer.readline(1024 * 1024 + 1)
|
||||||
|
if not control.endswith(b"\n") or len(control) > 1024 * 1024:
|
||||||
|
return
|
||||||
|
request = json.loads(control)
|
||||||
|
size = request["input_size"]
|
||||||
|
if type(size) is not int or not 0 <= size <= 64 * 1024 * 1024:
|
||||||
|
return
|
||||||
|
content = sys.stdin.buffer.read(size) if request["has_input"] else None
|
||||||
|
if content is not None and len(content) != size:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
request["argv"],
|
||||||
|
shell=False,
|
||||||
|
stdin=None if request["has_input"] else subprocess.DEVNULL,
|
||||||
|
input=content,
|
||||||
|
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
response = {"returncode": result.returncode}
|
||||||
|
except OSError:
|
||||||
|
response = {"launch_failed": True}
|
||||||
|
pending = status.with_suffix(".tmp")
|
||||||
|
pending.write_text(json.dumps(response), encoding="utf-8")
|
||||||
|
pending.replace(status)
|
||||||
|
# Remain the session/group leader until the owner reclaims the whole tree.
|
||||||
|
while True:
|
||||||
|
time.sleep(60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Synthetic tool used only by the opt-in SDK fault suite. No device/network I/O."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
mode = sys.argv[1]
|
||||||
|
if mode == "sleep":
|
||||||
|
time.sleep(60)
|
||||||
|
elif mode == "tree":
|
||||||
|
child = subprocess.Popen(
|
||||||
|
[sys.executable, "-I", "-B", __file__, "sleep"],
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0,
|
||||||
|
)
|
||||||
|
Path(sys.argv[2]).write_text(json.dumps({"pid": child.pid}), encoding="utf-8")
|
||||||
|
if sys.argv[3] == "linger":
|
||||||
|
time.sleep(60)
|
||||||
|
elif mode == "echo":
|
||||||
|
data = sys.stdin.buffer.read()
|
||||||
|
sys.stdout.buffer.write(data)
|
||||||
|
sys.stderr.buffer.write(b"synthetic-stderr")
|
||||||
|
elif mode == "overflow":
|
||||||
|
while True:
|
||||||
|
os.write(1, b"x" * 65536)
|
||||||
|
elif mode == "failure":
|
||||||
|
raise SystemExit(7)
|
||||||
|
else:
|
||||||
|
raise SystemExit(2)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -43,7 +43,13 @@ def download(url: str, progress: Callable[[str, int], None]) -> bytes:
|
|||||||
|
|
||||||
|
|
||||||
def load_catalog() -> PluginCatalog:
|
def load_catalog() -> PluginCatalog:
|
||||||
data = download(settings.ARTEMIS_PLUGIN_MARKETPLACE_URL, lambda *_: None)
|
url = settings.ARTEMIS_PLUGIN_MARKETPLACE_URL
|
||||||
|
# Strict older hosts cannot parse newer panel kinds, even if incompatible.
|
||||||
|
# Preserve their indexes; upgrade only the exact official saved addresses.
|
||||||
|
official = "https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/"
|
||||||
|
if url in {official + "catalog.json", official + "catalog-v3.json"}:
|
||||||
|
url = official + "catalog-v4.json"
|
||||||
|
data = download(url, lambda *_: None)
|
||||||
if len(data) > 2 * 1024 * 1024:
|
if len(data) > 2 * 1024 * 1024:
|
||||||
raise ValueError("市场索引过大")
|
raise ValueError("市场索引过大")
|
||||||
return PluginCatalog.model_validate_json(data)
|
return PluginCatalog.model_validate_json(data)
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
"""Evidence-bound market materials. Declarations still need source/rights review."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from contextlib import contextmanager
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
|
import stat
|
||||||
|
import tempfile
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from artemis.config.settings import settings
|
||||||
|
from artemis.plugins.installer import _extract
|
||||||
|
from artemis.plugins.models import PluginManifest
|
||||||
|
|
||||||
|
REPORT_NAME = "MARKET.json"
|
||||||
|
CHECKS = (
|
||||||
|
"installation",
|
||||||
|
"tool_contract",
|
||||||
|
"failure_partial",
|
||||||
|
"timeout_cancel",
|
||||||
|
"process_tree",
|
||||||
|
"file_binding",
|
||||||
|
"resource_conflicts",
|
||||||
|
"evidence_export",
|
||||||
|
"history_uninstall",
|
||||||
|
"secret_redaction",
|
||||||
|
"real_task",
|
||||||
|
"source_review",
|
||||||
|
)
|
||||||
|
FORBIDDEN_DIRECTORIES = {".git", ".ruff_cache", ".pytest_cache", "__pycache__", ".venv"}
|
||||||
|
|
||||||
|
|
||||||
|
class SubmissionModel(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid", strict=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Component(SubmissionModel):
|
||||||
|
name: str = Field(min_length=1)
|
||||||
|
paths: list[str] = Field(min_length=1)
|
||||||
|
license: str = Field(min_length=1)
|
||||||
|
permission: Literal["permitted", "local_only", "unknown"]
|
||||||
|
license_file: str
|
||||||
|
source_file: str
|
||||||
|
|
||||||
|
|
||||||
|
class CheckRecord(SubmissionModel):
|
||||||
|
status: Literal["passed", "failed", "unverified", "not_applicable"]
|
||||||
|
evidence_file: str = ""
|
||||||
|
evidence_sha256: str = ""
|
||||||
|
notes: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class Traits(SubmissionModel):
|
||||||
|
uses_subprocesses: bool | None
|
||||||
|
file_inputs_for_writes: bool | None
|
||||||
|
|
||||||
|
|
||||||
|
class MarketSubmission(SubmissionModel):
|
||||||
|
schema_version: Literal[1]
|
||||||
|
plugin_id: str
|
||||||
|
plugin_version: str
|
||||||
|
content_sha256: str = Field(pattern=r"^[a-f0-9]{64}$")
|
||||||
|
traits: Traits
|
||||||
|
components: list[Component] = Field(min_length=1)
|
||||||
|
checks: dict[str, CheckRecord]
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def package_directory(path: Path) -> Iterator[tuple[Path, str | None]]:
|
||||||
|
"""Inspect a directory or safely unpack a ZIP without installing or hooks."""
|
||||||
|
path = path.resolve(strict=True)
|
||||||
|
if path.is_dir():
|
||||||
|
yield path, None
|
||||||
|
return
|
||||||
|
if not path.is_file():
|
||||||
|
raise ValueError("检查输入必须是普通插件目录或 ZIP 文件")
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
data = stream.read(settings.ARTEMIS_PLUGIN_DOWNLOAD_MAX_BYTES + 1)
|
||||||
|
if len(data) > settings.ARTEMIS_PLUGIN_DOWNLOAD_MAX_BYTES:
|
||||||
|
raise ValueError("插件 ZIP 超过下载大小限制")
|
||||||
|
with tempfile.TemporaryDirectory(prefix="artemis-plugin-check-") as temporary:
|
||||||
|
directory = Path(temporary)
|
||||||
|
_extract(data, directory)
|
||||||
|
archive_sha256 = hashlib.sha256(data).hexdigest()
|
||||||
|
yield directory, archive_sha256
|
||||||
|
# Re-read only to reject changes; never relabel the inspected snapshot.
|
||||||
|
current_digest = hashlib.sha256()
|
||||||
|
current_size = 0
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
while chunk := stream.read(1024 * 1024):
|
||||||
|
current_size += len(chunk)
|
||||||
|
if current_size > settings.ARTEMIS_PLUGIN_DOWNLOAD_MAX_BYTES:
|
||||||
|
raise ValueError("检查期间 ZIP 发生变化或超过大小限制")
|
||||||
|
current_digest.update(chunk)
|
||||||
|
if current_digest.hexdigest() != archive_sha256:
|
||||||
|
raise ValueError("检查期间 ZIP 发生变化,检查记录失效")
|
||||||
|
|
||||||
|
|
||||||
|
def _relative(value: str, *, root_allowed: bool = False) -> str:
|
||||||
|
if root_allowed and value == ".":
|
||||||
|
return value
|
||||||
|
path = PurePosixPath(value)
|
||||||
|
if (
|
||||||
|
not value
|
||||||
|
or "\\" in value
|
||||||
|
or ":" in value
|
||||||
|
or path.is_absolute()
|
||||||
|
or any(p in {".", ".."} for p in value.split("/"))
|
||||||
|
or any(ord(c) < 32 for c in value)
|
||||||
|
or str(path) != value
|
||||||
|
):
|
||||||
|
raise ValueError("市场材料必须使用包内普通相对路径")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def package_files(directory: Path) -> dict[str, str]:
|
||||||
|
"""Hash every distributable file, excluding only the self-referential report."""
|
||||||
|
directory = directory.resolve(strict=True)
|
||||||
|
files = {}
|
||||||
|
seen = set()
|
||||||
|
size = 0
|
||||||
|
for path in sorted(directory.rglob("*")):
|
||||||
|
relative = path.relative_to(directory)
|
||||||
|
name = relative.as_posix()
|
||||||
|
if path.is_symlink() or path.is_junction() or not path.resolve().is_relative_to(directory):
|
||||||
|
raise ValueError("市场包不能包含链接")
|
||||||
|
if name.casefold() in seen:
|
||||||
|
raise ValueError("市场包存在大小写冲突的路径")
|
||||||
|
seen.add(name.casefold())
|
||||||
|
if len(seen) > 20000:
|
||||||
|
raise ValueError("市场包超过文件数量限制")
|
||||||
|
if any(p.casefold() in FORBIDDEN_DIRECTORIES for p in relative.parts):
|
||||||
|
raise ValueError(f"市场包包含缓存、依赖环境或版本库目录:{name}")
|
||||||
|
if path.is_dir():
|
||||||
|
continue
|
||||||
|
info = path.stat()
|
||||||
|
if not stat.S_ISREG(info.st_mode):
|
||||||
|
raise ValueError("市场包只能包含普通文件")
|
||||||
|
if name == REPORT_NAME:
|
||||||
|
continue
|
||||||
|
lower = path.name.casefold()
|
||||||
|
if (
|
||||||
|
lower.endswith((".pyc", ".pyo"))
|
||||||
|
or lower == ".env"
|
||||||
|
or (
|
||||||
|
lower.startswith(".env.")
|
||||||
|
and not lower.endswith((".example", ".sample", ".template"))
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise ValueError(f"市场包不能包含缓存或个人环境配置:{name}")
|
||||||
|
_relative(name)
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
read = 0
|
||||||
|
with path.open("rb") as handle:
|
||||||
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||||
|
read += len(chunk)
|
||||||
|
size += len(chunk)
|
||||||
|
if size > settings.ARTEMIS_PLUGIN_EXTRACT_MAX_BYTES:
|
||||||
|
raise ValueError("市场包超过展开大小或文件数量限制")
|
||||||
|
digest.update(chunk)
|
||||||
|
if read != info.st_size:
|
||||||
|
raise ValueError("文件校验期间大小发生变化")
|
||||||
|
files[name] = digest.hexdigest()
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
def content_fingerprint(files: dict[str, str]) -> str:
|
||||||
|
encoded = json.dumps(files, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||||
|
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def draft_submission(manifest: PluginManifest, files: dict[str, str]) -> dict:
|
||||||
|
"""Drafts never fabricate passed results or distribution permission."""
|
||||||
|
return {
|
||||||
|
"schema_version": 1,
|
||||||
|
"plugin_id": manifest.id,
|
||||||
|
"plugin_version": manifest.version,
|
||||||
|
"content_sha256": content_fingerprint(files),
|
||||||
|
"traits": {"uses_subprocesses": None, "file_inputs_for_writes": None},
|
||||||
|
"components": [
|
||||||
|
{
|
||||||
|
"name": "请拆分原始代码、适配层与第三方资料",
|
||||||
|
"paths": ["."],
|
||||||
|
"license": manifest.license or "待补充",
|
||||||
|
"permission": "unknown",
|
||||||
|
"license_file": "LICENSE",
|
||||||
|
"source_file": "SOURCE.md",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"checks": {
|
||||||
|
name: {"status": "unverified", "evidence_file": "", "evidence_sha256": "", "notes": ""}
|
||||||
|
for name in CHECKS
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _document(directory: Path, files: dict[str, str], value: str) -> str:
|
||||||
|
name = _relative(value)
|
||||||
|
if name not in files or not 0 < (directory / name).stat().st_size <= 1024 * 1024:
|
||||||
|
raise ValueError("许可、来源与验证记录必须是非空包内文档,且不超过 1 MiB")
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def validate_submission(
|
||||||
|
directory: Path, manifest: PluginManifest, files: dict[str, str]
|
||||||
|
) -> MarketSubmission:
|
||||||
|
path = directory / REPORT_NAME
|
||||||
|
if not path.is_file() or path.stat().st_size > 1024 * 1024:
|
||||||
|
raise ValueError("市场检查需要包内 MARKET.json;先生成草稿并补充真实记录")
|
||||||
|
report = MarketSubmission.model_validate_json(path.read_text("utf-8"))
|
||||||
|
if (report.plugin_id, report.plugin_version) != (manifest.id, manifest.version):
|
||||||
|
raise ValueError("市场材料的插件 ID/版本与清单不一致")
|
||||||
|
if report.content_sha256 != content_fingerprint(files):
|
||||||
|
raise ValueError("包内容已变化,旧的市场验证记录失效")
|
||||||
|
if not manifest.license.strip():
|
||||||
|
raise ValueError("市场清单必须声明许可证或具体的 LicenseRef")
|
||||||
|
if None in (report.traits.uses_subprocesses, report.traits.file_inputs_for_writes):
|
||||||
|
raise ValueError("必须明确是否使用子进程、是否将文件用于写操作")
|
||||||
|
covered = set()
|
||||||
|
for component in report.components:
|
||||||
|
if component.permission != "permitted" or component.license.strip().casefold() in {
|
||||||
|
"",
|
||||||
|
"unknown",
|
||||||
|
"todo",
|
||||||
|
"待补充",
|
||||||
|
}:
|
||||||
|
raise ValueError(f"组件未明确允许市场分发或未提供许可证:{component.name}")
|
||||||
|
_document(directory, files, component.license_file)
|
||||||
|
_document(directory, files, component.source_file)
|
||||||
|
for scope in component.paths:
|
||||||
|
scope = _relative(scope, root_allowed=True)
|
||||||
|
selected = {
|
||||||
|
name
|
||||||
|
for name in files
|
||||||
|
if scope == "." or name == scope or name.startswith(scope + "/")
|
||||||
|
}
|
||||||
|
if not selected:
|
||||||
|
raise ValueError("来源组件声明了不存在的文件范围")
|
||||||
|
covered.update(selected)
|
||||||
|
if set(files) - covered:
|
||||||
|
raise ValueError("市场包有文件未记录来源与分发许可")
|
||||||
|
if set(report.checks) != set(CHECKS):
|
||||||
|
raise ValueError("市场验证项目缺失或包含未知项目")
|
||||||
|
optional = {
|
||||||
|
"tool_contract": manifest.server is None,
|
||||||
|
"timeout_cancel": manifest.server is None,
|
||||||
|
"process_tree": not report.traits.uses_subprocesses,
|
||||||
|
"file_binding": not report.traits.file_inputs_for_writes,
|
||||||
|
"resource_conflicts": not manifest.resources,
|
||||||
|
}
|
||||||
|
for name, record in report.checks.items():
|
||||||
|
if not record.notes.strip():
|
||||||
|
raise ValueError(f"验证项目必须说明实际范围或不适用原因:{name}")
|
||||||
|
if record.status == "not_applicable" and optional.get(name, False):
|
||||||
|
continue
|
||||||
|
if record.status != "passed":
|
||||||
|
raise ValueError(f"市场验证项目未通过:{name}")
|
||||||
|
evidence = _document(directory, files, record.evidence_file)
|
||||||
|
if files[evidence] != record.evidence_sha256:
|
||||||
|
raise ValueError(f"验证记录 SHA256 不一致:{name}")
|
||||||
|
return report
|
||||||
@@ -12,6 +12,8 @@ from packaging.specifiers import SpecifierSet
|
|||||||
from packaging.version import Version
|
from packaging.version import Version
|
||||||
from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator, model_validator
|
from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator, model_validator
|
||||||
|
|
||||||
|
from artemis.plugins.workspace import WorkspaceContribution
|
||||||
|
|
||||||
Identifier = Annotated[str, Field(pattern=r"^[a-z][a-z0-9_-]{0,47}$")]
|
Identifier = Annotated[str, Field(pattern=r"^[a-z][a-z0-9_-]{0,47}$")]
|
||||||
|
|
||||||
|
|
||||||
@@ -94,7 +96,7 @@ class EvidenceSource(PluginModel):
|
|||||||
|
|
||||||
|
|
||||||
class PluginManifest(PluginModel):
|
class PluginManifest(PluginModel):
|
||||||
schema_version: Literal[1, 2] = 1
|
schema_version: Literal[1, 2, 3] = 1
|
||||||
id: Identifier
|
id: Identifier
|
||||||
name: str = Field(min_length=1, max_length=100)
|
name: str = Field(min_length=1, max_length=100)
|
||||||
version: str
|
version: str
|
||||||
@@ -113,6 +115,7 @@ class PluginManifest(PluginModel):
|
|||||||
targets: list[ExecutionTarget] = Field(default_factory=lambda: ["mixed"])
|
targets: list[ExecutionTarget] = Field(default_factory=lambda: ["mixed"])
|
||||||
observation: PluginObservation | None = None
|
observation: PluginObservation | None = None
|
||||||
capabilities: PluginCapabilities | None = None
|
capabilities: PluginCapabilities | None = None
|
||||||
|
workspace: WorkspaceContribution | None = None
|
||||||
|
|
||||||
@field_validator("version")
|
@field_validator("version")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -130,14 +133,16 @@ class PluginManifest(PluginModel):
|
|||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def has_capability(self):
|
def has_capability(self):
|
||||||
if self.server is None and self.skill is None:
|
if self.server is None and self.skill is None and self.workspace is None:
|
||||||
raise ValueError("插件至少需要 MCP 服务或 Skill")
|
raise ValueError("插件至少需要 MCP 服务、Skill 或工作区扩展")
|
||||||
|
if self.workspace is not None and self.schema_version != 3:
|
||||||
|
raise ValueError("工作区扩展需要 schema_version=3")
|
||||||
for name, field in self.config.items():
|
for name, field in self.config.items():
|
||||||
if field.secret and field.default:
|
if field.secret and field.default:
|
||||||
raise ValueError(f"密钥字段 {name} 不允许默认值")
|
raise ValueError(f"密钥字段 {name} 不允许默认值")
|
||||||
if self.schema_version == 1 and self.capabilities is not None:
|
if self.schema_version == 1 and self.capabilities is not None:
|
||||||
raise ValueError("能力契约需要 schema_version=2;旧清单保持兼容")
|
raise ValueError("能力契约需要 schema_version=2;旧清单保持兼容")
|
||||||
if self.schema_version == 2:
|
if self.schema_version >= 2:
|
||||||
if self.capabilities is None:
|
if self.capabilities is None:
|
||||||
raise ValueError("第二版插件必须声明 capabilities")
|
raise ValueError("第二版插件必须声明 capabilities")
|
||||||
if self.observation:
|
if self.observation:
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import ctypes
|
|||||||
from ctypes import wintypes
|
from ctypes import wintypes
|
||||||
|
|
||||||
|
|
||||||
class CaptureProcessJob:
|
class ProcessJob:
|
||||||
"""A non-inheritable job handle: Windows kills captures when its owner exits."""
|
"""A non-inheritable job handle that owns a Windows process tree."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
class BasicLimits(ctypes.Structure):
|
class BasicLimits(ctypes.Structure):
|
||||||
@@ -54,6 +54,12 @@ class CaptureProcessJob:
|
|||||||
),
|
),
|
||||||
("OpenProcess", [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD], wintypes.HANDLE),
|
("OpenProcess", [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD], wintypes.HANDLE),
|
||||||
("AssignProcessToJobObject", [wintypes.HANDLE, wintypes.HANDLE], wintypes.BOOL),
|
("AssignProcessToJobObject", [wintypes.HANDLE, wintypes.HANDLE], wintypes.BOOL),
|
||||||
|
("TerminateJobObject", [wintypes.HANDLE, wintypes.UINT], wintypes.BOOL),
|
||||||
|
(
|
||||||
|
"QueryInformationJobObject",
|
||||||
|
[wintypes.HANDLE, ctypes.c_int, wintypes.LPVOID, wintypes.DWORD, wintypes.LPVOID],
|
||||||
|
wintypes.BOOL,
|
||||||
|
),
|
||||||
("CloseHandle", [wintypes.HANDLE], wintypes.BOOL),
|
("CloseHandle", [wintypes.HANDLE], wintypes.BOOL),
|
||||||
):
|
):
|
||||||
function = getattr(self._api, name)
|
function = getattr(self._api, name)
|
||||||
@@ -84,3 +90,34 @@ class CaptureProcessJob:
|
|||||||
if self._handle:
|
if self._handle:
|
||||||
self._api.CloseHandle(self._handle)
|
self._api.CloseHandle(self._handle)
|
||||||
self._handle = None
|
self._handle = None
|
||||||
|
|
||||||
|
def terminate(self):
|
||||||
|
if self._handle and not self._api.TerminateJobObject(self._handle, 1):
|
||||||
|
raise ctypes.WinError(ctypes.get_last_error())
|
||||||
|
|
||||||
|
def active_processes(self) -> int:
|
||||||
|
if not self._handle:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
class Accounting(ctypes.Structure):
|
||||||
|
_fields_ = [
|
||||||
|
("user_time", ctypes.c_int64),
|
||||||
|
("kernel_time", ctypes.c_int64),
|
||||||
|
("period_user_time", ctypes.c_int64),
|
||||||
|
("period_kernel_time", ctypes.c_int64),
|
||||||
|
("page_faults", wintypes.DWORD),
|
||||||
|
("total", wintypes.DWORD),
|
||||||
|
("active", wintypes.DWORD),
|
||||||
|
("terminated", wintypes.DWORD),
|
||||||
|
]
|
||||||
|
|
||||||
|
accounting = Accounting()
|
||||||
|
if not self._api.QueryInformationJobObject(
|
||||||
|
self._handle, 1, ctypes.byref(accounting), ctypes.sizeof(accounting), None
|
||||||
|
):
|
||||||
|
raise ctypes.WinError(ctypes.get_last_error())
|
||||||
|
return accounting.active
|
||||||
|
|
||||||
|
|
||||||
|
# Keep the recording implementation and its callers on the same ownership primitive.
|
||||||
|
CaptureProcessJob = ProcessJob
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
"""Plugin author helpers; no scheduler, device actions or implicit retries.
|
||||||
|
|
||||||
|
Use snapshot_file before approving a write and consume its immutable bytes, not the
|
||||||
|
original pathname. run_process owns the tool and its descendants on every exit.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections.abc import Iterator, Mapping, Sequence
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import signal
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from artemis.plugins.recording_process import ProcessJob
|
||||||
|
|
||||||
|
MAX_INPUT_BYTES = 64 * 1024 * 1024
|
||||||
|
DEFAULT_OUTPUT_BYTES = 4 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
class FileVerificationError(ValueError):
|
||||||
|
"""The input cannot be bound to its explicitly approved digest."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class VerifiedFile:
|
||||||
|
"""Bounded, immutable input. No original path is exposed for reopening."""
|
||||||
|
|
||||||
|
data: bytes = field(repr=False)
|
||||||
|
sha256: str = field(init=False)
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
if not isinstance(self.data, bytes):
|
||||||
|
raise TypeError("快照内容必须是不可变 bytes")
|
||||||
|
object.__setattr__(self, "sha256", hashlib.sha256(self.data).hexdigest())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def size(self) -> int:
|
||||||
|
return len(self.data)
|
||||||
|
|
||||||
|
def chunks(self, size: int = 256 * 1024) -> Iterator[memoryview]:
|
||||||
|
if type(size) is not int or size <= 0:
|
||||||
|
raise ValueError("分块大小必须是正整数")
|
||||||
|
content = memoryview(self.data)
|
||||||
|
for offset in range(0, len(content), size):
|
||||||
|
yield content[offset : offset + size]
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot_file(
|
||||||
|
path: Path | str, expected_sha256: str, *, max_bytes: int = MAX_INPUT_BYTES
|
||||||
|
) -> VerifiedFile:
|
||||||
|
"""Copy once, then verify the exact bytes that will be consumed by the caller.
|
||||||
|
|
||||||
|
The source must already be authorized by the plugin. This is not a path or
|
||||||
|
permission sandbox. Memory use is bounded by max_bytes plus one read byte.
|
||||||
|
"""
|
||||||
|
if type(max_bytes) is not int or max_bytes <= 0:
|
||||||
|
raise ValueError("文件大小上限必须是正整数")
|
||||||
|
if not isinstance(expected_sha256, str) or not re.fullmatch(
|
||||||
|
r"[a-fA-F0-9]{64}", expected_sha256
|
||||||
|
):
|
||||||
|
raise FileVerificationError("必须明确提供已批准的 SHA256")
|
||||||
|
source = Path(path)
|
||||||
|
if source.is_symlink() or not stat.S_ISREG(source.lstat().st_mode):
|
||||||
|
raise FileVerificationError("输入必须是普通文件,不能是链接或设备")
|
||||||
|
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
|
||||||
|
descriptor = os.open(source, flags)
|
||||||
|
with os.fdopen(descriptor, "rb") as handle:
|
||||||
|
info = os.fstat(handle.fileno())
|
||||||
|
if not stat.S_ISREG(info.st_mode) or info.st_size > max_bytes:
|
||||||
|
raise FileVerificationError("输入不是普通文件或超过大小上限")
|
||||||
|
data = handle.read(max_bytes + 1)
|
||||||
|
if len(data) > max_bytes:
|
||||||
|
raise FileVerificationError("输入超过大小上限")
|
||||||
|
snapshot = VerifiedFile(data)
|
||||||
|
if snapshot.sha256 != expected_sha256.lower():
|
||||||
|
raise FileVerificationError("输入 SHA256 与批准值不一致;不得开始写操作")
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
|
||||||
|
class ManagedProcessError(RuntimeError):
|
||||||
|
"""Stable failure metadata. Do not infer a business verdict or retry a write."""
|
||||||
|
|
||||||
|
def __init__(self, code: str, *, may_have_started: bool = True):
|
||||||
|
super().__init__(code)
|
||||||
|
self.error_code = code
|
||||||
|
self.may_have_started = may_have_started
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessTimedOut(ManagedProcessError, TimeoutError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ProcessResult:
|
||||||
|
returncode: int
|
||||||
|
stdout: bytes = field(repr=False)
|
||||||
|
stderr: bytes = field(repr=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def _cleanup(process: asyncio.subprocess.Process, job: ProcessJob | None) -> None:
|
||||||
|
error = None
|
||||||
|
try:
|
||||||
|
if job is not None:
|
||||||
|
job.terminate()
|
||||||
|
async with asyncio.timeout(5):
|
||||||
|
while job.active_processes():
|
||||||
|
await asyncio.sleep(0.02)
|
||||||
|
elif os.name != "nt":
|
||||||
|
# The bootstrap stays alive until cleanup, pinning the owned PGID.
|
||||||
|
try:
|
||||||
|
os.killpg(process.pid, signal.SIGKILL)
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
except (OSError, TimeoutError) as exc:
|
||||||
|
error = exc
|
||||||
|
finally:
|
||||||
|
if job is not None:
|
||||||
|
job.close()
|
||||||
|
if process.returncode is None:
|
||||||
|
try:
|
||||||
|
process.kill()
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
# Process.wait also waits for pipe EOF. A paused, full output reader must be
|
||||||
|
# drained separately; it cannot serve as evidence that the OS process lives.
|
||||||
|
try:
|
||||||
|
async with asyncio.timeout(5):
|
||||||
|
while process.returncode is None:
|
||||||
|
await asyncio.sleep(0.02)
|
||||||
|
except TimeoutError as exc:
|
||||||
|
error = exc
|
||||||
|
if error is not None:
|
||||||
|
raise ManagedProcessError("process_cleanup_unconfirmed") from error
|
||||||
|
|
||||||
|
|
||||||
|
async def _finish_cleanup(task: asyncio.Task[None]) -> None:
|
||||||
|
cancelled = False
|
||||||
|
while not task.done():
|
||||||
|
try:
|
||||||
|
await asyncio.shield(task)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
cancelled = True
|
||||||
|
task.result()
|
||||||
|
if cancelled:
|
||||||
|
raise asyncio.CancelledError
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_output(stream: asyncio.StreamReader, consumed: list[int], limit: int) -> bytes:
|
||||||
|
chunks = []
|
||||||
|
while chunk := await stream.read(65536):
|
||||||
|
consumed[0] += len(chunk)
|
||||||
|
if consumed[0] > limit:
|
||||||
|
raise ManagedProcessError("process_output_limit")
|
||||||
|
chunks.append(chunk)
|
||||||
|
return b"".join(chunks)
|
||||||
|
|
||||||
|
|
||||||
|
async def _drain(stream: asyncio.StreamReader | None) -> None:
|
||||||
|
if stream is not None:
|
||||||
|
while await stream.read(65536):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def _wait_result(process: asyncio.subprocess.Process, path: Path) -> int:
|
||||||
|
while not path.exists():
|
||||||
|
if process.returncode is not None:
|
||||||
|
raise ManagedProcessError("process_worker_interrupted")
|
||||||
|
await asyncio.sleep(0.02)
|
||||||
|
if path.stat().st_size > 4096:
|
||||||
|
raise ManagedProcessError("process_invalid_status")
|
||||||
|
try:
|
||||||
|
result = json.loads(path.read_text("utf-8"))
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
raise ValueError
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
raise ManagedProcessError("process_invalid_status") from exc
|
||||||
|
if result.get("launch_failed") is True:
|
||||||
|
raise ManagedProcessError("process_launch_failed", may_have_started=False)
|
||||||
|
if type(result.get("returncode")) is not int:
|
||||||
|
raise ManagedProcessError("process_invalid_status")
|
||||||
|
return result["returncode"]
|
||||||
|
|
||||||
|
|
||||||
|
async def run_process(
|
||||||
|
argv: Sequence[str],
|
||||||
|
*,
|
||||||
|
cwd: Path | str,
|
||||||
|
timeout: float,
|
||||||
|
env: Mapping[str, str] | None = None,
|
||||||
|
input_data: bytes | None = None,
|
||||||
|
max_output_bytes: int = DEFAULT_OUTPUT_BYTES,
|
||||||
|
) -> ProcessResult:
|
||||||
|
"""Run one fixed argv without a shell; reclaim descendants even on normal exit.
|
||||||
|
|
||||||
|
Windows assigns a waiting bootstrap to a Job Object *before* it can start the
|
||||||
|
tool. POSIX uses a dedicated session. Cancellation completes cleanup before
|
||||||
|
propagating. Inputs are at most 64 MiB; stdout+stderr share a bounded budget.
|
||||||
|
Return codes/output are tool results, never a test verdict. No retries occur.
|
||||||
|
"""
|
||||||
|
if (
|
||||||
|
not isinstance(argv, Sequence)
|
||||||
|
or isinstance(argv, (str, bytes))
|
||||||
|
or not argv
|
||||||
|
or any(not isinstance(arg, str) or "\0" in arg for arg in argv)
|
||||||
|
):
|
||||||
|
raise ValueError("必须提供固定的 argv 字符串列表,不能使用 shell 命令文本")
|
||||||
|
if not argv[0] or isinstance(timeout, bool) or not math.isfinite(timeout) or timeout <= 0:
|
||||||
|
raise ValueError("启动程序和有限正数超时是必填项")
|
||||||
|
if type(max_output_bytes) is not int or max_output_bytes <= 0:
|
||||||
|
raise ValueError("输出上限必须是正整数")
|
||||||
|
if input_data is not None and (
|
||||||
|
not isinstance(input_data, bytes) or len(input_data) > MAX_INPUT_BYTES
|
||||||
|
):
|
||||||
|
raise ValueError("标准输入必须是至多 64 MiB 的 bytes")
|
||||||
|
directory = Path(cwd).resolve(strict=True)
|
||||||
|
if not directory.is_dir():
|
||||||
|
raise ValueError("工作目录必须是已存在的目录")
|
||||||
|
control = (
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"argv": list(argv),
|
||||||
|
"has_input": input_data is not None,
|
||||||
|
"input_size": len(input_data or b""),
|
||||||
|
}
|
||||||
|
).encode()
|
||||||
|
+ b"\n"
|
||||||
|
)
|
||||||
|
if len(control) > 1024 * 1024:
|
||||||
|
raise ValueError("启动参数超过大小上限")
|
||||||
|
job = None
|
||||||
|
process: asyncio.subprocess.Process | None = None
|
||||||
|
cleanup: asyncio.Task[None] | None = None
|
||||||
|
tasks: list[asyncio.Task] = []
|
||||||
|
|
||||||
|
async def reclaim():
|
||||||
|
nonlocal cleanup
|
||||||
|
if process is None:
|
||||||
|
if job is not None:
|
||||||
|
job.close()
|
||||||
|
return
|
||||||
|
if cleanup is None:
|
||||||
|
cleanup = asyncio.create_task(_cleanup(process, job))
|
||||||
|
await _finish_cleanup(cleanup)
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(prefix="artemis-plugin-process-") as temporary:
|
||||||
|
status = Path(temporary) / "status.json"
|
||||||
|
try:
|
||||||
|
job = ProcessJob() if os.name == "nt" else None
|
||||||
|
except OSError as exc:
|
||||||
|
raise ManagedProcessError("process_ownership_failed", may_have_started=False) from exc
|
||||||
|
launch = asyncio.create_task(
|
||||||
|
asyncio.create_subprocess_exec(
|
||||||
|
sys.executable,
|
||||||
|
"-I",
|
||||||
|
"-B",
|
||||||
|
str(Path(__file__).with_name("_process_worker.py")),
|
||||||
|
str(status),
|
||||||
|
str(os.getpid()),
|
||||||
|
cwd=str(directory),
|
||||||
|
env=dict(env) if env is not None else None,
|
||||||
|
stdin=asyncio.subprocess.PIPE,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0,
|
||||||
|
start_new_session=os.name != "nt",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
cancelled = False
|
||||||
|
while process is None:
|
||||||
|
try:
|
||||||
|
process = await asyncio.shield(launch)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
cancelled = True
|
||||||
|
except OSError as exc:
|
||||||
|
raise ManagedProcessError(
|
||||||
|
"process_launch_failed", may_have_started=False
|
||||||
|
) from exc
|
||||||
|
if cancelled:
|
||||||
|
raise asyncio.CancelledError
|
||||||
|
if job is not None:
|
||||||
|
try:
|
||||||
|
job.assign(process.pid)
|
||||||
|
except OSError as exc:
|
||||||
|
raise ManagedProcessError(
|
||||||
|
"process_ownership_failed", may_have_started=False
|
||||||
|
) from exc
|
||||||
|
stdin, stdout, stderr = process.stdin, process.stdout, process.stderr
|
||||||
|
assert stdin is not None and stdout is not None and stderr is not None
|
||||||
|
consumed = [0]
|
||||||
|
|
||||||
|
async def feed():
|
||||||
|
try:
|
||||||
|
stdin.write(control)
|
||||||
|
await stdin.drain()
|
||||||
|
if input_data:
|
||||||
|
stdin.write(input_data)
|
||||||
|
await stdin.drain()
|
||||||
|
except (BrokenPipeError, ConnectionResetError):
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
stdin.close()
|
||||||
|
|
||||||
|
async def wait_tool():
|
||||||
|
code = await _wait_result(process, status)
|
||||||
|
await reclaim()
|
||||||
|
return code
|
||||||
|
|
||||||
|
tasks = [
|
||||||
|
asyncio.create_task(feed()),
|
||||||
|
asyncio.create_task(_read_output(stdout, consumed, max_output_bytes)),
|
||||||
|
asyncio.create_task(_read_output(stderr, consumed, max_output_bytes)),
|
||||||
|
asyncio.create_task(wait_tool()),
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
_, stdout, stderr, code = await asyncio.wait_for(asyncio.gather(*tasks), timeout)
|
||||||
|
except TimeoutError as exc:
|
||||||
|
raise ProcessTimedOut("process_timeout") from exc
|
||||||
|
return ProcessResult(code, stdout, stderr)
|
||||||
|
finally:
|
||||||
|
for task in tasks:
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
async def finalize():
|
||||||
|
try:
|
||||||
|
await reclaim()
|
||||||
|
finally:
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
if process is not None:
|
||||||
|
try:
|
||||||
|
async with asyncio.timeout(5):
|
||||||
|
await asyncio.gather(_drain(process.stdout), _drain(process.stderr))
|
||||||
|
await process.wait()
|
||||||
|
except TimeoutError as exc:
|
||||||
|
raise ManagedProcessError("process_cleanup_unconfirmed") from exc
|
||||||
|
|
||||||
|
await _finish_cleanup(asyncio.create_task(finalize()))
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
"""Run opt-in, offline SDK fault regressions in temporary directories only."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
from contextlib import suppress
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
|
||||||
|
import psutil
|
||||||
|
|
||||||
|
from artemis.plugins.evidence import decode_receipt, persist_attachments
|
||||||
|
from artemis.plugins.models import PluginCallResult
|
||||||
|
from artemis.plugins.sdk import (
|
||||||
|
FileVerificationError,
|
||||||
|
ManagedProcessError,
|
||||||
|
ProcessTimedOut,
|
||||||
|
run_process,
|
||||||
|
snapshot_file,
|
||||||
|
)
|
||||||
|
|
||||||
|
PROBE = Path(__file__).with_name("_sdk_probe.py").resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def _require(condition, message):
|
||||||
|
if not condition:
|
||||||
|
raise AssertionError(message)
|
||||||
|
|
||||||
|
|
||||||
|
def _owned_process(marker: Path, started: float):
|
||||||
|
if not marker.exists():
|
||||||
|
return None
|
||||||
|
pid = json.loads(marker.read_text("utf-8"))["pid"]
|
||||||
|
try:
|
||||||
|
process = psutil.Process(pid)
|
||||||
|
if process.status() == psutil.STATUS_ZOMBIE:
|
||||||
|
return None
|
||||||
|
_require(process.create_time() >= started - 1, "测试 PID 的启动时间不匹配")
|
||||||
|
_require(str(PROBE) in process.cmdline(), "测试 PID 的脚本身份不匹配")
|
||||||
|
return process
|
||||||
|
except psutil.NoSuchProcess:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _tree_case(root: Path, mode: str):
|
||||||
|
marker = root / f"{mode}.json"
|
||||||
|
started = time.time()
|
||||||
|
task = asyncio.create_task(
|
||||||
|
run_process(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
"-I",
|
||||||
|
"-B",
|
||||||
|
str(PROBE),
|
||||||
|
"tree",
|
||||||
|
str(marker),
|
||||||
|
"exit" if mode == "normal" else "linger",
|
||||||
|
],
|
||||||
|
cwd=root,
|
||||||
|
timeout=2 if mode == "timeout" else 10,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
if mode == "cancel":
|
||||||
|
async with asyncio.timeout(5):
|
||||||
|
while not marker.exists() and not task.done():
|
||||||
|
await asyncio.sleep(0.02)
|
||||||
|
_require(marker.exists(), "取消探针没有启动")
|
||||||
|
task.cancel()
|
||||||
|
try:
|
||||||
|
await task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise AssertionError("取消没有传播")
|
||||||
|
elif mode == "timeout":
|
||||||
|
try:
|
||||||
|
await task
|
||||||
|
except ProcessTimedOut:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise AssertionError("超时没有生效")
|
||||||
|
else:
|
||||||
|
result = await task
|
||||||
|
_require(result.returncode == 0, "模拟工具异常退出")
|
||||||
|
_require(marker.exists(), "后代进程探针没有启动")
|
||||||
|
async with asyncio.timeout(3):
|
||||||
|
while _owned_process(marker, started) is not None:
|
||||||
|
await asyncio.sleep(0.02)
|
||||||
|
finally:
|
||||||
|
if not task.done():
|
||||||
|
task.cancel()
|
||||||
|
with suppress(asyncio.CancelledError, ManagedProcessError):
|
||||||
|
await task
|
||||||
|
# Failing implementations must not leave the fault suite's own sleeper alive.
|
||||||
|
process = _owned_process(marker, started)
|
||||||
|
if process is not None:
|
||||||
|
process.kill()
|
||||||
|
process.wait(timeout=5)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_checks() -> dict:
|
||||||
|
checks = []
|
||||||
|
with tempfile.TemporaryDirectory(prefix="artemis-sdk-faults-") as temporary:
|
||||||
|
root = Path(temporary)
|
||||||
|
source = root / "synthetic.rom"
|
||||||
|
approved = b"approved firmware bytes\x00\xff"
|
||||||
|
source.write_bytes(approved)
|
||||||
|
digest = hashlib.sha256(approved).hexdigest()
|
||||||
|
snapshot = snapshot_file(source, digest)
|
||||||
|
for replacement in (b"X" * len(approved), b"", b"Y" * (len(approved) + 10)):
|
||||||
|
source.write_bytes(replacement)
|
||||||
|
_require(b"".join(snapshot.chunks(3)) == approved, "快照受原文件修改影响")
|
||||||
|
try:
|
||||||
|
snapshot_file(source, digest)
|
||||||
|
except FileVerificationError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise AssertionError("摘要不符仍被接收")
|
||||||
|
_require(all(part.readonly for part in snapshot.chunks()), "分块内容可被修改")
|
||||||
|
checks.append("file_replacement_truncation_growth_and_digest")
|
||||||
|
result = await run_process(
|
||||||
|
[sys.executable, "-I", "-B", str(PROBE), "echo"],
|
||||||
|
cwd=root,
|
||||||
|
timeout=5,
|
||||||
|
input_data=snapshot.data,
|
||||||
|
)
|
||||||
|
_require(
|
||||||
|
result.stdout == approved and result.stderr == b"synthetic-stderr",
|
||||||
|
"二进制输入输出不一致",
|
||||||
|
)
|
||||||
|
checks.append("binary_input_and_separate_output")
|
||||||
|
failed = await run_process(
|
||||||
|
[sys.executable, "-I", "-B", str(PROBE), "failure"], cwd=root, timeout=5
|
||||||
|
)
|
||||||
|
_require(failed.returncode == 7, "非零退出码被隐藏")
|
||||||
|
checks.append("nonzero_returncode")
|
||||||
|
for mode in ("normal", "timeout", "cancel"):
|
||||||
|
await _tree_case(root, mode)
|
||||||
|
checks.append(f"descendants_reclaimed_on_{mode}")
|
||||||
|
try:
|
||||||
|
await run_process(
|
||||||
|
[sys.executable, "-I", "-B", str(PROBE), "overflow"],
|
||||||
|
cwd=root,
|
||||||
|
timeout=5,
|
||||||
|
max_output_bytes=1024,
|
||||||
|
)
|
||||||
|
except ManagedProcessError as exc:
|
||||||
|
_require(exc.error_code == "process_output_limit", "输出超限错误类型不正确")
|
||||||
|
else:
|
||||||
|
raise AssertionError("输出超限没有停止")
|
||||||
|
checks.append("output_limit_stops_process")
|
||||||
|
trace = root / "synthetic-trace"
|
||||||
|
artifacts = trace / "artifacts/plugins/sdk-faults"
|
||||||
|
artifacts.mkdir(parents=True)
|
||||||
|
sample = artifacts / "sample.txt"
|
||||||
|
sample.write_bytes(b"synthetic evidence")
|
||||||
|
receipt = {"name": sample.name, "sha256": hashlib.sha256(sample.read_bytes()).hexdigest()}
|
||||||
|
_require(len(persist_attachments(trace, "sdk-faults", [receipt])) == 1, "附件未被回读")
|
||||||
|
for invalid in ({**receipt, "sha256": "0" * 64}, {**receipt, "name": "../outside.txt"}):
|
||||||
|
try:
|
||||||
|
persist_attachments(trace, "sdk-faults", [invalid])
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise AssertionError("证据摘要不符或路径穿越未拒绝")
|
||||||
|
checks.append("evidence_hash_and_path_rejection")
|
||||||
|
for execution in ("partial", "unknown"):
|
||||||
|
outcome = PluginCallResult(
|
||||||
|
status="success",
|
||||||
|
structured_content={
|
||||||
|
"artemis": {
|
||||||
|
"version": 1,
|
||||||
|
"execution": execution,
|
||||||
|
"side_effect": "possible",
|
||||||
|
"retryable": True,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
decode_receipt(outcome)
|
||||||
|
_require(
|
||||||
|
not outcome.retryable and outcome.requires_observation, "不确定写操作被允许重试"
|
||||||
|
)
|
||||||
|
checks.append("partial_unknown_never_authorize_retries")
|
||||||
|
return {
|
||||||
|
"status": "passed",
|
||||||
|
"scope": "sdk_faults_only",
|
||||||
|
"python": sys.version.split()[0],
|
||||||
|
"platform": sys.platform,
|
||||||
|
"checks": checks,
|
||||||
|
"real_device_actions": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--json-output", type=Path)
|
||||||
|
args = parser.parse_args()
|
||||||
|
result = asyncio.run(run_checks())
|
||||||
|
if args.json_output:
|
||||||
|
args.json_output.write_text(
|
||||||
|
json.dumps(result, ensure_ascii=False, indent=2) + "\n", "utf-8"
|
||||||
|
)
|
||||||
|
print(json.dumps(result, ensure_ascii=False))
|
||||||
|
print("仅验证 SDK 自身;插件仍需对真实入口做故障注入、证据与任务验收。")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -105,6 +105,7 @@ class PluginStore:
|
|||||||
if not existing:
|
if not existing:
|
||||||
profile = PluginProfile(
|
profile = PluginProfile(
|
||||||
is_default=True,
|
is_default=True,
|
||||||
|
authorized=manifest.server is None and manifest.skill is None,
|
||||||
values={k: v.default for k, v in manifest.config.items() if not v.secret},
|
values={k: v.default for k, v in manifest.config.items() if not v.secret},
|
||||||
)
|
)
|
||||||
db.execute(
|
db.execute(
|
||||||
@@ -236,6 +237,8 @@ class PluginStore:
|
|||||||
def selections(self, target: str = "mixed") -> list[PluginSelection]:
|
def selections(self, target: str = "mixed") -> list[PluginSelection]:
|
||||||
selections = []
|
selections = []
|
||||||
for plugin in self.list():
|
for plugin in self.list():
|
||||||
|
if not plugin["manifest"].get("server") and not plugin["manifest"].get("skill"):
|
||||||
|
continue
|
||||||
if not plugin["enabled"] or not self._supports_task(
|
if not plugin["enabled"] or not self._supports_task(
|
||||||
plugin["manifest"].get("targets", ["mixed"]), target
|
plugin["manifest"].get("targets", ["mixed"]), target
|
||||||
):
|
):
|
||||||
@@ -264,6 +267,8 @@ class PluginStore:
|
|||||||
pinned = []
|
pinned = []
|
||||||
for choice in self.selections(target) if selections is None else selections:
|
for choice in self.selections(target) if selections is None else selections:
|
||||||
manifest, profile, _ = self.resolve(choice)
|
manifest, profile, _ = self.resolve(choice)
|
||||||
|
if manifest.server is None and manifest.skill is None:
|
||||||
|
raise ValueError(f"插件 {manifest.name} 是工作区扩展,无需加入执行任务")
|
||||||
if not self._supports_task(manifest.targets, target):
|
if not self._supports_task(manifest.targets, target):
|
||||||
raise ValueError(f"插件 {manifest.name} 不支持 {target} 任务")
|
raise ValueError(f"插件 {manifest.name} 不支持 {target} 任务")
|
||||||
pinned.append(
|
pinned.append(
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""Plugin development checks and a separate market materials gate."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from artemis.plugins.evidence import validate_tools
|
||||||
|
from artemis.plugins.market import (
|
||||||
|
REPORT_NAME,
|
||||||
|
content_fingerprint,
|
||||||
|
draft_submission,
|
||||||
|
package_directory,
|
||||||
|
package_files,
|
||||||
|
validate_submission,
|
||||||
|
)
|
||||||
|
from artemis.plugins.models import PluginManifest, PluginProfile
|
||||||
|
from artemis.plugins.session import PluginSession, resolve_server
|
||||||
|
from artemis.plugins.store import contained_path
|
||||||
|
|
||||||
|
|
||||||
|
async def check(
|
||||||
|
directory: Path,
|
||||||
|
live: bool,
|
||||||
|
values: dict[str, str],
|
||||||
|
*,
|
||||||
|
market: bool = False,
|
||||||
|
fingerprint: bool = False,
|
||||||
|
prepare_market: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
if prepare_market and not directory.is_dir():
|
||||||
|
raise ValueError("请在插件目录生成市场草稿,再打包 ZIP")
|
||||||
|
with package_directory(directory) as (root, archive_sha256):
|
||||||
|
manifest = PluginManifest.model_validate_json((root / "plugin.json").read_text("utf-8"))
|
||||||
|
manifest.check_compatible()
|
||||||
|
if manifest.skill:
|
||||||
|
skill = contained_path(root, manifest.skill)
|
||||||
|
if (
|
||||||
|
not skill.is_file()
|
||||||
|
or skill.stat().st_size > 256 * 1024
|
||||||
|
or not skill.read_text("utf-8").strip()
|
||||||
|
):
|
||||||
|
raise ValueError("缺少有效的 Skill 或超过大小限制")
|
||||||
|
if manifest.schema_version not in {2, 3}:
|
||||||
|
raise ValueError("新市场插件必须适配 schema_version=2 或 3")
|
||||||
|
if any(name not in manifest.config or manifest.config[name].secret for name in values):
|
||||||
|
raise ValueError("--values 只能包含清单中声明的普通配置,不得包含密钥")
|
||||||
|
files = package_files(root) if market or fingerprint or prepare_market else None
|
||||||
|
if prepare_market:
|
||||||
|
assert files is not None
|
||||||
|
draft = draft_submission(manifest, files)
|
||||||
|
with (root / REPORT_NAME).open("x", encoding="utf-8") as handle:
|
||||||
|
handle.write(json.dumps(draft, ensure_ascii=False, indent=2) + "\n")
|
||||||
|
return {
|
||||||
|
"status": "draft",
|
||||||
|
"plugin_id": manifest.id,
|
||||||
|
"version": manifest.version,
|
||||||
|
"content_sha256": draft["content_sha256"],
|
||||||
|
}
|
||||||
|
submission = None
|
||||||
|
if market:
|
||||||
|
assert files is not None
|
||||||
|
submission = validate_submission(root, manifest, files)
|
||||||
|
handshake = bool((live or market) and manifest.server)
|
||||||
|
if handshake:
|
||||||
|
server = resolve_server(manifest, PluginProfile(values=values), root)
|
||||||
|
server.env["PYTHONDONTWRITEBYTECODE"] = "1"
|
||||||
|
session = await PluginSession(server, root).start()
|
||||||
|
try:
|
||||||
|
validate_tools(manifest, session.tools)
|
||||||
|
finally:
|
||||||
|
await session.aclose()
|
||||||
|
if files is not None and package_files(root) != files:
|
||||||
|
raise ValueError("MCP 握手期间包内容发生变化,检查记录失效")
|
||||||
|
if market:
|
||||||
|
assert files is not None
|
||||||
|
if validate_submission(root, manifest, files) != submission:
|
||||||
|
raise ValueError("MCP 握手期间市场记录发生变化,检查记录失效")
|
||||||
|
result = {
|
||||||
|
"status": "passed",
|
||||||
|
"plugin_id": manifest.id,
|
||||||
|
"version": manifest.version,
|
||||||
|
"scope": "market_materials" if market else "development",
|
||||||
|
"mcp_handshake": handshake,
|
||||||
|
}
|
||||||
|
if files is not None:
|
||||||
|
result["content_sha256"] = content_fingerprint(files)
|
||||||
|
if archive_sha256 is not None:
|
||||||
|
result["archive_sha256"] = archive_sha256
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("directory", type=Path, help="插件目录或 ZIP;不会安装到现有插件库")
|
||||||
|
parser.add_argument(
|
||||||
|
"--live", action="store_true", help="仅启动/关闭 MCP 并核对声明,不调用工具"
|
||||||
|
)
|
||||||
|
parser.add_argument("--values", default="{}", help="普通配置 JSON;不得包含密钥")
|
||||||
|
modes = parser.add_mutually_exclusive_group()
|
||||||
|
modes.add_argument(
|
||||||
|
"--market", action="store_true", help="核对市场分发、内容绑定和验收材料,含 MCP 握手"
|
||||||
|
)
|
||||||
|
modes.add_argument(
|
||||||
|
"--prepare-market", action="store_true", help="创建未验证的 MARKET.json 草稿,不覆盖旧文件"
|
||||||
|
)
|
||||||
|
modes.add_argument(
|
||||||
|
"--fingerprint", action="store_true", help="输出包内容摘要;不宣称市场验收通过"
|
||||||
|
)
|
||||||
|
parser.add_argument("--json-output", type=Path, help="将检查结果写到包外 JSON 文件")
|
||||||
|
args = parser.parse_args()
|
||||||
|
result = {}
|
||||||
|
can_write = True
|
||||||
|
try:
|
||||||
|
if args.json_output and (
|
||||||
|
args.json_output.resolve() == args.directory.resolve()
|
||||||
|
or (
|
||||||
|
args.directory.is_dir()
|
||||||
|
and args.json_output.resolve().is_relative_to(args.directory.resolve())
|
||||||
|
)
|
||||||
|
):
|
||||||
|
can_write = False
|
||||||
|
raise ValueError("检查输出必须在插件包之外,不能覆盖输入包")
|
||||||
|
values = json.loads(args.values)
|
||||||
|
if not isinstance(values, dict) or any(not isinstance(v, str) for v in values.values()):
|
||||||
|
raise ValueError("--values 必须是字符串值的 JSON 对象")
|
||||||
|
result = asyncio.run(
|
||||||
|
check(
|
||||||
|
args.directory.resolve(),
|
||||||
|
args.live,
|
||||||
|
values,
|
||||||
|
market=args.market,
|
||||||
|
fingerprint=args.fingerprint,
|
||||||
|
prepare_market=args.prepare_market,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except (ValueError, OSError, RuntimeError) as exc:
|
||||||
|
message = (
|
||||||
|
"输入格式或清单/市场材料字段不合法"
|
||||||
|
if isinstance(exc, (json.JSONDecodeError, ValidationError))
|
||||||
|
else str(exc)
|
||||||
|
)
|
||||||
|
result = {"status": "failed", "error": message}
|
||||||
|
if args.json_output and can_write:
|
||||||
|
args.json_output.write_text(
|
||||||
|
json.dumps(result, ensure_ascii=False, indent=2) + "\n", "utf-8"
|
||||||
|
)
|
||||||
|
print(json.dumps(result, ensure_ascii=False))
|
||||||
|
print("检查范围不包括源码正确性与真实业务通过;市场材料仍需来源审查和真实任务验收。")
|
||||||
|
if result["status"] == "failed":
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""Declarative workspace contributions; no plugin JavaScript or model execution."""
|
||||||
|
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceModel(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|
||||||
|
class PanelWidth(WorkspaceModel):
|
||||||
|
preferred: int = Field(default=340, ge=200, le=1000)
|
||||||
|
minimum: int = Field(default=240, ge=160, le=800)
|
||||||
|
maximum: int = Field(default=480, ge=200, le=1200)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def ordered(self):
|
||||||
|
if not self.minimum <= self.preferred <= self.maximum:
|
||||||
|
raise ValueError("面板宽度必须满足 minimum ≤ preferred ≤ maximum")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceField(WorkspaceModel):
|
||||||
|
id: str = Field(pattern=r"^[a-z][a-z0-9_]{0,47}$")
|
||||||
|
label: str = Field(min_length=1, max_length=100)
|
||||||
|
description: str = Field(default="", max_length=1000)
|
||||||
|
kind: Literal["choice", "text"]
|
||||||
|
options: list[str] = Field(default_factory=list, max_length=12)
|
||||||
|
max_length: int = Field(default=2000, ge=1, le=10000)
|
||||||
|
report: bool = True
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def valid_options(self):
|
||||||
|
if self.kind == "choice" and not self.options:
|
||||||
|
raise ValueError("选择字段必须声明 options")
|
||||||
|
if self.kind == "text" and self.options:
|
||||||
|
raise ValueError("文本字段不接受 options")
|
||||||
|
if len(set(self.options)) != len(self.options) or any(
|
||||||
|
not option.strip() or len(option) > min(100, self.max_length) for option in self.options
|
||||||
|
):
|
||||||
|
raise ValueError("选项必须非空、唯一且不超过长度限制")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspacePanel(WorkspaceModel):
|
||||||
|
id: str = Field(pattern=r"^[a-z][a-z0-9_]{0,47}$")
|
||||||
|
title: str = Field(min_length=1, max_length=100)
|
||||||
|
side: Literal["left", "right"]
|
||||||
|
kind: Literal["initial_prompt", "original_case", "form"]
|
||||||
|
width: PanelWidth = Field(default_factory=PanelWidth)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceContribution(WorkspaceModel):
|
||||||
|
panels: list[WorkspacePanel] = Field(min_length=1, max_length=2)
|
||||||
|
fields: list[WorkspaceField] = Field(default_factory=list, max_length=20)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def unique_contributions(self):
|
||||||
|
if len({panel.id for panel in self.panels}) != len(self.panels) or len(
|
||||||
|
{panel.kind for panel in self.panels}
|
||||||
|
) != len(self.panels):
|
||||||
|
raise ValueError("面板 id 和 kind 不得重复")
|
||||||
|
if len({field.id for field in self.fields}) != len(self.fields):
|
||||||
|
raise ValueError("工作区字段 id 不得重复")
|
||||||
|
if bool(self.fields) != any(panel.kind == "form" for panel in self.panels):
|
||||||
|
raise ValueError("表单面板与 fields 必须同时声明")
|
||||||
|
return self
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
},
|
},
|
||||||
"agent": {
|
"agent": {
|
||||||
"exploration": {
|
"exploration": {
|
||||||
"model": "${env:ARTEMIS_EXPLORATION_MODEL:-gpt-6.1-sol}",
|
"model": "${env:ARTEMIS_EXPLORATION_MODEL:-gpt-6.1-terra}",
|
||||||
"reasoning_effort": "${env:ARTEMIS_EXPLORATION_REASONING_EFFORT:-high}",
|
"reasoning_effort": "${env:ARTEMIS_EXPLORATION_REASONING_EFFORT:-high}",
|
||||||
"timeout_seconds": "${env:ARTEMIS_EXPLORATION_TIMEOUT_SECONDS:-1800}",
|
"timeout_seconds": "${env:ARTEMIS_EXPLORATION_TIMEOUT_SECONDS:-1800}",
|
||||||
"demonstration_timeout_seconds": "${env:ARTEMIS_DEMONSTRATION_TIMEOUT_SECONDS:-900}",
|
"demonstration_timeout_seconds": "${env:ARTEMIS_DEMONSTRATION_TIMEOUT_SECONDS:-900}",
|
||||||
|
|||||||
@@ -101,7 +101,10 @@ def _configure_usb_stay_awake(device_id: str) -> str | None:
|
|||||||
# Migration cleanup belongs to the long-lived host. A queued task worker
|
# Migration cleanup belongs to the long-lived host. A queued task worker
|
||||||
# must not wait on another process's optional legacy wake-lock cleanup.
|
# must not wait on another process's optional legacy wake-lock cleanup.
|
||||||
if os.environ.get("ARTEMIS_TASK_WORKER") != "1":
|
if os.environ.get("ARTEMIS_TASK_WORKER") != "1":
|
||||||
ScreenAwakeLease(device_id).cleanup_unowned_references()
|
try:
|
||||||
|
ScreenAwakeLease(device_id).cleanup_unowned_references()
|
||||||
|
except OSError as exc:
|
||||||
|
logger.warning(f"Could not prepare optional legacy awake cleanup on {device_id}: {exc}")
|
||||||
|
|
||||||
commands = (
|
commands = (
|
||||||
(
|
(
|
||||||
@@ -209,6 +212,7 @@ class ScreenAwakeService:
|
|||||||
self._monitor_thread: threading.Thread | None = None
|
self._monitor_thread: threading.Thread | None = None
|
||||||
self._shutdown_requested = False
|
self._shutdown_requested = False
|
||||||
self._owners: dict[str, FileLock] = {}
|
self._owners: dict[str, FileLock] = {}
|
||||||
|
self._ownership_warnings: set[str] = set()
|
||||||
|
|
||||||
def start(self, device_ids: Iterable[str] | None = None) -> dict[str, str | None]:
|
def start(self, device_ids: Iterable[str] | None = None) -> dict[str, str | None]:
|
||||||
if self._shutdown_requested:
|
if self._shutdown_requested:
|
||||||
@@ -241,13 +245,24 @@ class ScreenAwakeService:
|
|||||||
existing = self._strategies.get(device_id)
|
existing = self._strategies.get(device_id)
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
return existing
|
return existing
|
||||||
owner = _owner_lock(device_id)
|
|
||||||
try:
|
try:
|
||||||
|
owner = _owner_lock(device_id)
|
||||||
owner.acquire(timeout=0)
|
owner.acquire(timeout=0)
|
||||||
except Timeout:
|
except Timeout:
|
||||||
# Do not cache a follower as owner: discovery can take over if
|
# Do not cache a follower as owner: discovery can take over if
|
||||||
# the process holding the OS lock exits or releases the device.
|
# the process holding the OS lock exits or releases the device.
|
||||||
return None
|
return None
|
||||||
|
except OSError as exc:
|
||||||
|
# Awake is optional. Never bypass ownership or let an ACL / I/O
|
||||||
|
# failure abort device connection, resume, or the host monitor.
|
||||||
|
if device_id not in self._ownership_warnings:
|
||||||
|
logger.warning(
|
||||||
|
f"Could not acquire awake ownership on {device_id}; skipping optional "
|
||||||
|
f"stay-awake policy: {exc}"
|
||||||
|
)
|
||||||
|
self._ownership_warnings.add(device_id)
|
||||||
|
return None
|
||||||
|
self._ownership_warnings.discard(device_id)
|
||||||
try:
|
try:
|
||||||
strategy = _configure_usb_stay_awake(device_id)
|
strategy = _configure_usb_stay_awake(device_id)
|
||||||
except BaseException:
|
except BaseException:
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ from typing import Any
|
|||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
|
from filelock import FileLock
|
||||||
|
|
||||||
from artemis.config.constants import ENV_ARTEMIS_HELPER_AUTO_INSTALL
|
from artemis.config.constants import ENV_ARTEMIS_HELPER_AUTO_INSTALL
|
||||||
from artemis.config.paths import get_temp_dir
|
from artemis.config.paths import get_temp_dir
|
||||||
from artemis.runtime.adb_endpoint import adb_command
|
from artemis.runtime.adb_endpoint import adb_command
|
||||||
@@ -323,7 +325,7 @@ class AccessibilityHelperManager:
|
|||||||
if re.fullmatch(r"[0-9a-f]{32,128}", text):
|
if re.fullmatch(r"[0-9a-f]{32,128}", text):
|
||||||
self._token = text
|
self._token = text
|
||||||
return text
|
return text
|
||||||
except OSError:
|
except FileNotFoundError:
|
||||||
pass
|
pass
|
||||||
token = secrets.token_hex(24)
|
token = secrets.token_hex(24)
|
||||||
try:
|
try:
|
||||||
@@ -550,6 +552,15 @@ class AccessibilityHelperManager:
|
|||||||
root.mkdir(parents=True, exist_ok=True)
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
digest = hashlib.sha256(serial.encode("utf-8")).hexdigest()[:16]
|
digest = hashlib.sha256(serial.encode("utf-8")).hexdigest()[:16]
|
||||||
path = root / f"{digest}.mutex"
|
path = root / f"{digest}.mutex"
|
||||||
|
# An age-based lease alone can be stolen from a live, delayed owner.
|
||||||
|
# Retain the legacy lease for coexistence with older Artemis processes,
|
||||||
|
# but serialize current processes with a kernel lock released on exit.
|
||||||
|
with FileLock(path.with_suffix(".guard"), timeout=_PROVISION_MUTEX_TIMEOUT_SECONDS):
|
||||||
|
with self._legacy_provision_mutex(path, serial):
|
||||||
|
yield
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _legacy_provision_mutex(self, path: Path, serial: str):
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
|
|||||||
+20
-9
@@ -209,7 +209,10 @@ class Agent:
|
|||||||
raise ValueError("任务目标必须是 android、computer 或 mixed")
|
raise ValueError("任务目标必须是 android、computer 或 mixed")
|
||||||
self._execution_target = execution_target
|
self._execution_target = execution_target
|
||||||
if execution_target == "computer":
|
if execution_target == "computer":
|
||||||
|
from artemis.platform import platform as pal_platform
|
||||||
|
|
||||||
self._device_context = DeviceContext(
|
self._device_context = DeviceContext(
|
||||||
|
host_platform=pal_platform.os_type.name,
|
||||||
device_id="computer:local",
|
device_id="computer:local",
|
||||||
mobile_platform=DevicePlatform.COMPUTER,
|
mobile_platform=DevicePlatform.COMPUTER,
|
||||||
device_width=0,
|
device_width=0,
|
||||||
@@ -584,6 +587,7 @@ class Agent:
|
|||||||
or getattr(getattr(task, "request", None), "task_name", None)
|
or getattr(getattr(task, "request", None), "task_name", None)
|
||||||
)
|
)
|
||||||
device_lock = None
|
device_lock = None
|
||||||
|
execution_started = False
|
||||||
try:
|
try:
|
||||||
# Construct the runtime only inside the task's cleanup boundary. A
|
# Construct the runtime only inside the task's cleanup boundary. A
|
||||||
# cancelled queue wait or setup failure must not leave version pins.
|
# cancelled queue wait or setup failure must not leave version pins.
|
||||||
@@ -761,6 +765,7 @@ class Agent:
|
|||||||
publish_startup_progress(
|
publish_startup_progress(
|
||||||
"environment_ready", "Device environment is ready", session_id=str(sess_id)
|
"environment_ready", "Device environment is ready", session_id=str(sess_id)
|
||||||
)
|
)
|
||||||
|
execution_started = True
|
||||||
try:
|
try:
|
||||||
if request.profile and request.profile.lower() == "flash":
|
if request.profile and request.profile.lower() == "flash":
|
||||||
logger.info(f"[{task_name}] Invoking FlashRunner reactive loop...")
|
logger.info(f"[{task_name}] Invoking FlashRunner reactive loop...")
|
||||||
@@ -792,7 +797,7 @@ class Agent:
|
|||||||
|
|
||||||
status = flash_result.get("status")
|
status = flash_result.get("status")
|
||||||
if status == "completed":
|
if status == "completed":
|
||||||
logger.info(f"✅ Automation '{task_name}' is success ✅")
|
logger.info(f"Automation '{task_name}' execution completed.")
|
||||||
await task.finalize(content=output, state=last_state_snapshot)
|
await task.finalize(content=output, state=last_state_snapshot)
|
||||||
if context.data_engine:
|
if context.data_engine:
|
||||||
context.data_engine.end_session("completed")
|
context.data_engine.end_session("completed")
|
||||||
@@ -907,7 +912,7 @@ class Agent:
|
|||||||
" see the test summary."
|
" see the test summary."
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.info(f"✅ Automation '{task_name}' is success ✅")
|
logger.info(f"Automation '{task_name}' execution completed.")
|
||||||
await task.finalize(content=output, state=last_state_snapshot)
|
await task.finalize(content=output, state=last_state_snapshot)
|
||||||
if context.data_engine:
|
if context.data_engine:
|
||||||
context.data_engine.end_session("completed")
|
context.data_engine.end_session("completed")
|
||||||
@@ -972,7 +977,11 @@ class Agent:
|
|||||||
from artemis.runtime.task_result import save_failure_result
|
from artemis.runtime.task_result import save_failure_result
|
||||||
|
|
||||||
if sess_id:
|
if sess_id:
|
||||||
save_failure_result(str(sess_id), err)
|
save_failure_result(
|
||||||
|
str(sess_id),
|
||||||
|
err,
|
||||||
|
code="execution_failed" if execution_started else "initialization_failed",
|
||||||
|
)
|
||||||
if context.data_engine:
|
if context.data_engine:
|
||||||
context.data_engine.end_session("failed")
|
context.data_engine.end_session("failed")
|
||||||
|
|
||||||
@@ -1681,13 +1690,15 @@ def attach_test_summary(output, run_outcome: dict | None):
|
|||||||
|
|
||||||
|
|
||||||
def resolve_trace_suffix(task_status: str, run_outcome: dict | None) -> str:
|
def resolve_trace_suffix(task_status: str, run_outcome: dict | None) -> str:
|
||||||
"""Trace naming: assertion failures must be distinguishable from _PASS."""
|
"""Keep execution and the independently verified test verdict distinct."""
|
||||||
if task_status != "completed":
|
if task_status != "completed":
|
||||||
return "_FAIL"
|
return "_FAIL"
|
||||||
tests = (run_outcome or {}).get("tests") or {}
|
tests = (run_outcome or {}).get("tests") or {}
|
||||||
if (
|
verdict = ((run_outcome or {}).get("test_result") or {}).get("status")
|
||||||
int(tests.get("failed", 0)) > 0
|
if verdict == "blocked":
|
||||||
or ((run_outcome or {}).get("test_result") or {}).get("status") == "failed"
|
return "_BLOCKED"
|
||||||
):
|
if verdict == "inconclusive":
|
||||||
|
return "_UNVERIFIED"
|
||||||
|
if verdict == "failed" or int(tests.get("failed", 0)) > 0:
|
||||||
return "_TESTFAIL"
|
return "_TESTFAIL"
|
||||||
return "_PASS"
|
return "_PASS" if verdict == "passed" else "_UNVERIFIED"
|
||||||
+45
-16
@@ -317,12 +317,32 @@ async def _wait_for_resume(pause_file: Path) -> bool:
|
|||||||
"""Wait for the pause file to be cleared. Returns False on deadline."""
|
"""Wait for the pause file to be cleared. Returns False on deadline."""
|
||||||
deadline = float(getattr(settings, "LLM_PAUSE_TIMEOUT_SECONDS", 0.0) or 0.0)
|
deadline = float(getattr(settings, "LLM_PAUSE_TIMEOUT_SECONDS", 0.0) or 0.0)
|
||||||
waited = 0.0
|
waited = 0.0
|
||||||
while pause_file.exists():
|
try:
|
||||||
if deadline > 0 and waited >= deadline:
|
owned = pause_file.stat()
|
||||||
return False
|
except FileNotFoundError:
|
||||||
await asyncio.sleep(1)
|
return True
|
||||||
waited += 1
|
try:
|
||||||
return True
|
while pause_file.exists():
|
||||||
|
if deadline > 0 and waited >= deadline:
|
||||||
|
return False
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
waited += 1
|
||||||
|
return True
|
||||||
|
finally:
|
||||||
|
# Cancellation (stop, guidance, outer deadline) also ends this wait.
|
||||||
|
# Do not leave a global pause behind or remove a newer request's pause.
|
||||||
|
try:
|
||||||
|
current = pause_file.stat()
|
||||||
|
if (current.st_ino, current.st_mtime_ns, current.st_size) == (
|
||||||
|
owned.st_ino,
|
||||||
|
owned.st_mtime_ns,
|
||||||
|
owned.st_size,
|
||||||
|
):
|
||||||
|
pause_file.unlink(missing_ok=True)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
except OSError as exc:
|
||||||
|
llm_logger.warning("Could not clear finished LLM pause: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
async def _wait_for_breaker(key: str) -> None:
|
async def _wait_for_breaker(key: str) -> None:
|
||||||
@@ -361,7 +381,7 @@ async def _run_with_recovery[T](
|
|||||||
immediately: retrying cannot help and pausing would hang the task.
|
immediately: retrying cannot help and pausing would hang the task.
|
||||||
- When retryable attempts are exhausted: if a fallback model is waiting
|
- When retryable attempts are exhausted: if a fallback model is waiting
|
||||||
(with_fallback), raise LLMExhaustedError so it takes over immediately;
|
(with_fallback), raise LLMExhaustedError so it takes over immediately;
|
||||||
final acceptance also raises instead of pausing an already-finished task.
|
final acceptance and invalid output also raise instead of globally pausing.
|
||||||
Other calls pause (bounded by settings.LLM_PAUSE_TIMEOUT_SECONDS) and
|
Other calls pause (bounded by settings.LLM_PAUSE_TIMEOUT_SECONDS) and
|
||||||
retry the same request on resume.
|
retry the same request on resume.
|
||||||
"""
|
"""
|
||||||
@@ -450,7 +470,7 @@ async def _run_with_recovery[T](
|
|||||||
)
|
)
|
||||||
if failure.category is FailureCategory.INVALID_OUTPUT:
|
if failure.category is FailureCategory.INVALID_OUTPUT:
|
||||||
# One fresh response can recover a generation loop. Repeated
|
# One fresh response can recover a generation loop. Repeated
|
||||||
# runaway output must reach the existing fallback/pause path,
|
# runaway output must reach fallback or fail this request,
|
||||||
# even when ordinary network retries are configured higher.
|
# even when ordinary network retries are configured higher.
|
||||||
max_attempts = min(
|
max_attempts = min(
|
||||||
max_attempts, total_attempts + max(0, policy.max_attempts - attempt)
|
max_attempts, total_attempts + max(0, policy.max_attempts - attempt)
|
||||||
@@ -485,7 +505,11 @@ async def _run_with_recovery[T](
|
|||||||
ACTIVE_ATTEMPT.reset(observation_token)
|
ACTIVE_ATTEMPT.reset(observation_token)
|
||||||
|
|
||||||
# Retryable attempts exhausted.
|
# Retryable attempts exhausted.
|
||||||
if _FALLBACK_AVAILABLE.get() or ACTIVE_FINAL_REVIEW.get() is not None:
|
if (
|
||||||
|
_FALLBACK_AVAILABLE.get()
|
||||||
|
or ACTIVE_FINAL_REVIEW.get() is not None
|
||||||
|
or last_failure.category is FailureCategory.INVALID_OUTPUT
|
||||||
|
):
|
||||||
if failed_endpoints is not None and endpoint_key:
|
if failed_endpoints is not None and endpoint_key:
|
||||||
failed_endpoints[endpoint_key] = last_error
|
failed_endpoints[endpoint_key] = last_error
|
||||||
_record_llm_event(
|
_record_llm_event(
|
||||||
@@ -494,7 +518,11 @@ async def _run_with_recovery[T](
|
|||||||
"error": str(last_error)[:1000],
|
"error": str(last_error)[:1000],
|
||||||
"category": last_failure.category.value,
|
"category": last_failure.category.value,
|
||||||
"retryable": True,
|
"retryable": True,
|
||||||
"handover": "fallback" if _FALLBACK_AVAILABLE.get() else "final_review_end",
|
"handover": "fallback"
|
||||||
|
if _FALLBACK_AVAILABLE.get()
|
||||||
|
else "final_review_end"
|
||||||
|
if ACTIVE_FINAL_REVIEW.get() is not None
|
||||||
|
else "task_failed",
|
||||||
},
|
},
|
||||||
status="failed",
|
status="failed",
|
||||||
)
|
)
|
||||||
@@ -510,10 +538,6 @@ async def _run_with_recovery[T](
|
|||||||
"LLM pause deadline"
|
"LLM pause deadline"
|
||||||
f" ({settings.LLM_PAUSE_TIMEOUT_SECONDS:.0f}s) exceeded; giving up."
|
f" ({settings.LLM_PAUSE_TIMEOUT_SECONDS:.0f}s) exceeded; giving up."
|
||||||
)
|
)
|
||||||
try:
|
|
||||||
pause_file.unlink(missing_ok=True)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
raise LLMExhaustedError(
|
raise LLMExhaustedError(
|
||||||
str(last_error), failure=last_failure, cause=last_error
|
str(last_error), failure=last_failure, cause=last_error
|
||||||
) from last_error
|
) from last_error
|
||||||
@@ -670,7 +694,12 @@ class RobustChatModelWrapper:
|
|||||||
engine = self.ctx.data_engine if self.ctx else None
|
engine = self.ctx.data_engine if self.ctx else None
|
||||||
if engine is None:
|
if engine is None:
|
||||||
engine = _get_current_data_engine()
|
engine = _get_current_data_engine()
|
||||||
record_llm_usage(engine, response, source=self._endpoint_key())
|
record_llm_usage(
|
||||||
|
engine,
|
||||||
|
response,
|
||||||
|
source=self._endpoint_key(),
|
||||||
|
model=self.endpoint.model_name if self.endpoint else None,
|
||||||
|
)
|
||||||
|
|
||||||
def _diagnostics(self, args, kwargs):
|
def _diagnostics(self, args, kwargs):
|
||||||
prefix = None
|
prefix = None
|
||||||
@@ -1232,7 +1261,7 @@ async def _with_fallback[T](
|
|||||||
)
|
)
|
||||||
|
|
||||||
# The contextvar must be reset BEFORE fallback_call runs: the fallback has
|
# The contextvar must be reset BEFORE fallback_call runs: the fallback has
|
||||||
# no further fallback behind it, so its own exhaustion should pause.
|
# no further fallback behind it, so apply its category's terminal policy.
|
||||||
fallback_token = _FALLBACK_AVAILABLE.set(True)
|
fallback_token = _FALLBACK_AVAILABLE.set(True)
|
||||||
try:
|
try:
|
||||||
result = await main_call()
|
result = await main_call()
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ def record_llm_usage(
|
|||||||
response: Any,
|
response: Any,
|
||||||
*,
|
*,
|
||||||
source: str | None = None,
|
source: str | None = None,
|
||||||
|
model: str | None = None,
|
||||||
update_last_prompt: bool = True,
|
update_last_prompt: bool = True,
|
||||||
) -> dict | None:
|
) -> dict | None:
|
||||||
"""Meters one LLM response and records an ``llm_usage`` trace, best-effort.
|
"""Meters one LLM response and records an ``llm_usage`` trace, best-effort.
|
||||||
@@ -251,6 +252,8 @@ def record_llm_usage(
|
|||||||
}
|
}
|
||||||
if source:
|
if source:
|
||||||
payload["source"] = source
|
payload["source"] = source
|
||||||
|
if model:
|
||||||
|
payload["model"] = model
|
||||||
node = CURRENT_NODE_NAME.get()
|
node = CURRENT_NODE_NAME.get()
|
||||||
if node:
|
if node:
|
||||||
payload["node"] = str(node)
|
payload["node"] = str(node)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from artemis.config.settings import settings
|
from artemis.config.settings import settings
|
||||||
|
from artemis.llm.http import model_http_options
|
||||||
|
|
||||||
from artemis.utils.logger import get_logger
|
from artemis.utils.logger import get_logger
|
||||||
|
|
||||||
@@ -91,7 +92,8 @@ async def validate_api_key(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
timeout=timeout if timeout is not None else settings.ARTEMIS_CREDENTIAL_TIMEOUT_SECONDS
|
timeout=timeout if timeout is not None else settings.ARTEMIS_CREDENTIAL_TIMEOUT_SECONDS,
|
||||||
|
**(model_http_options() if clean_provider == "openai" else {}),
|
||||||
) as client:
|
) as client:
|
||||||
if clean_provider in ("ocr", "vision", "google_vision"):
|
if clean_provider in ("ocr", "vision", "google_vision"):
|
||||||
# Test Google Cloud Vision OCR API
|
# Test Google Cloud Vision OCR API
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
},
|
},
|
||||||
"agent": {
|
"agent": {
|
||||||
"exploration": {
|
"exploration": {
|
||||||
"model": "${env:ARTEMIS_EXPLORATION_MODEL:-gpt-6.1-sol}",
|
"model": "${env:ARTEMIS_EXPLORATION_MODEL:-gpt-6.1-terra}",
|
||||||
"reasoning_effort": "${env:ARTEMIS_EXPLORATION_REASONING_EFFORT:-high}",
|
"reasoning_effort": "${env:ARTEMIS_EXPLORATION_REASONING_EFFORT:-high}",
|
||||||
"timeout_seconds": "${env:ARTEMIS_EXPLORATION_TIMEOUT_SECONDS:-1800}",
|
"timeout_seconds": "${env:ARTEMIS_EXPLORATION_TIMEOUT_SECONDS:-1800}",
|
||||||
"demonstration_timeout_seconds": "${env:ARTEMIS_DEMONSTRATION_TIMEOUT_SECONDS:-900}",
|
"demonstration_timeout_seconds": "${env:ARTEMIS_DEMONSTRATION_TIMEOUT_SECONDS:-900}",
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ MCP 同步仅检查 `$CODEX_HOME/config.toml`;未设置或为空时使用当
|
|||||||
|
|
||||||
报告按任务或批次归档到 `artifacts/mcp_exports/`,包含本地路径及服务下载链接;删除批次时会清理对应导出副本。运行中导出是快照;执行完成不等于验证通过,报告会保留未验证、失败、跳过与中断状态。
|
报告按任务或批次归档到 `artifacts/mcp_exports/`,包含本地路径及服务下载链接;删除批次时会清理对应导出副本。运行中导出是快照;执行完成不等于验证通过,报告会保留未验证、失败、跳过与中断状态。
|
||||||
|
|
||||||
新安装模型默认使用 `gpt-6-luna`,预设 `gpt-6-luna`、`gpt-6.1-sol`、`gpt-6-astra`、`mimo-v2.6-pro`;首页、兜底模型与精选任务使用统一选择控件,也支持自定义模型名称。已保存的模型配置保留。思考强度最高支持 `max`,默认 `high`,选“由模型默认”不发送该参数;接口不支持时明确报错。单次请求 300 秒,首次失败后最多再试 5 次,普通错误每次等待 10 秒,429 每次等待 60 秒,SDK 不叠加重试。首选模型连续 3 次没有有效工具调用时,当前步骤改用 `gpt-6-luna`(high),最多再试 3 次;下一步重新使用首选模型。兜底模型及次数可通过 `.env.example` 中的 `OPENAI_TOOL_FALLBACK_*` 和 `ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS` 配置。请求耗尽后沿用任务暂停/恢复机制,鉴权或非法参数立即失败。视频分析与录屏是独立开关,默认均关闭。每项批量用例可覆盖模式、模型、设备、录屏和超时等配置,不影响其他任务。暂停先返回 `pausing`,确认状态为 `task_paused` 才表示设备已释放;Pro 等待模型回复或只读诊断时可及时中断,恢复后重新观察设备。AI 可用 `mobile_manage_task(action="inject_instruction")` 提交指导并恢复;指导由执行器在安全检查点领取,可能中断当前模型等待并重新观察设备;接收回执不保证任务结束前已消费,已经开始的设备动作会安全结束。用 `guidance_history` 核对指导是否被领取、在任务流程中核对实际执行情况,或用 `resume` 单独恢复。
|
新安装模型默认使用 `gpt-6-luna`,预设 `gpt-6-luna`、`gpt-6.1-terra`、`gpt-6.1-sol`、`gpt-6-astra`、`mimo-v2.6-pro`;首页、兜底模型与精选任务使用统一选择控件,也支持自定义模型名称。已保存的模型配置保留。思考强度最高支持 `max`,默认 `high`,选“由模型默认”不发送该参数;接口不支持时明确报错。单次请求 300 秒,首次失败后最多再试 5 次,普通错误每次等待 10 秒,429 每次等待 60 秒,SDK 不叠加重试。首选模型连续 3 次没有有效工具调用时,当前步骤改用 `gpt-6-luna`(high),最多再试 3 次;下一步重新使用首选模型。兜底模型及次数可通过 `.env.example` 中的 `OPENAI_TOOL_FALLBACK_*` 和 `ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS` 配置。请求耗尽后沿用任务暂停/恢复机制,鉴权或非法参数立即失败。视频分析与录屏是独立开关,默认均关闭。每项批量用例可覆盖模式、模型、设备、录屏和超时等配置,不影响其他任务。暂停先返回 `pausing`,确认状态为 `task_paused` 才表示设备已释放;Pro 等待模型回复或只读诊断时可及时中断,恢复后重新观察设备。AI 可用 `mobile_manage_task(action="inject_instruction")` 提交指导并恢复;指导由执行器在安全检查点领取,可能中断当前模型等待并重新观察设备;接收回执不保证任务结束前已消费,已经开始的设备动作会安全结束。用 `guidance_history` 核对指导是否被领取、在任务流程中核对实际执行情况,或用 `resume` 单独恢复。
|
||||||
|
|
||||||
429 等待、兜底模型及次数也可在网页“模型配置 → 稳定性与兜底模型”或通过已授权的 `mobile_config(section="model")` 配置。批次 ZIP 还收录每项任务已保存的步骤截图和录屏。
|
429 等待、兜底模型及次数也可在网页“模型配置 → 稳定性与兜底模型”或通过已授权的 `mobile_config(section="model")` 配置。批次 ZIP 还收录每项任务已保存的步骤截图和录屏。
|
||||||
|
|
||||||
|
|||||||
@@ -112,3 +112,5 @@ Flash 跨端任务由模型通过内部 `update_task_progress` 记录短阶段
|
|||||||
批次保留原顺序、循环次数和失败停止策略,默认保留各项配置,也可明确选择统一模型和思考强度。探索用例固定收藏版本,首次无复跑配置时默认 Flash;“收藏为重新探索”会创建新的探索会话并沿用原探索配置。
|
批次保留原顺序、循环次数和失败停止策略,默认保留各项配置,也可明确选择统一模型和思考强度。探索用例固定收藏版本,首次无复跑配置时默认 Flash;“收藏为重新探索”会创建新的探索会话并沿用原探索配置。
|
||||||
|
|
||||||
单任务下载全部资料时,未变化的终态报告和资料包会复用;人工复验、结果和资料变化会重新生成。批次沿用已生成的报告包,人工复验后重新打包;旧目录格式的包首次下载时更新。运行中的下载是当前快照。录像分为“安卓”“电脑”“人工示范”目录,索引路径与实际文件一致。打包优先速度,对已压缩的图片、视频和 Office 文件直接存储,保持原画质。
|
单任务下载全部资料时,未变化的终态报告和资料包会复用;人工复验、结果和资料变化会重新生成。批次沿用已生成的报告包,人工复验后重新打包;旧目录格式的包首次下载时更新。运行中的下载是当前快照。录像分为“安卓”“电脑”“人工示范”目录,索引路径与实际文件一致。打包优先速度,对已压缩的图片、视频和 Office 文件直接存储,保持原画质。
|
||||||
|
|
||||||
|
安装并启用任务人工评审 1.1.0 后,批量模板在原七列之后增加“预置条件、操作步骤、预期结果”。三列独立保存原始用例,不追加到 Artemis 提示词;左侧按用例编号和标题展示,右侧可用上一条/下一条评审同批次已结束任务。未启用该插件时仍提供并校验原七列模板。
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
# 开发交接:2026-10-19
|
# 开发交接:2026-10-19
|
||||||
|
|
||||||
|
2026-10-28 发布主线整理为 **ARTEMIS 2.4.0**,配套人工评审插件 **1.1.0**。本轮改动、配置和验证边界见 [2.4.0 发行说明](releases/2.4.0.md);下方记录为各阶段历史快照。
|
||||||
|
|
||||||
|
2026-10-28 配套升级 OpenAI/LangChain 与安全依赖,审计范围、版本、验证及容器构建限制见[依赖安全更新记录](plans/2026-10-28-dependency-security.md)。
|
||||||
|
|
||||||
|
2026-10-28 会话已接续至 **ARTEMIS 2.3.0**,最新提交、插件发布状态、产品约定和待验证范围见[本次交接记录](plans/2026-10-28-session-handoff.md)及[2.3.0 发行说明](releases/2.3.0.md)。以下各日期内容保留为历史快照,不代表当前待办。
|
||||||
|
|
||||||
2026-10-27 发布主线已整理为 **ARTEMIS 2.2.0**,配套插件为 **Cua 0.38.0+artemis.1**,Helper 为 **1.2.3**。最新升级内容、验证与未测范围以[2.2.0 发行说明](releases/2.2.0.md)为准;下方“尚未发布”、分叉计数和候选版本均属于对应日期快照。插件从独立工作树发布,原插件主目录的 OCU 开发现场保留。
|
2026-10-27 发布主线已整理为 **ARTEMIS 2.2.0**,配套插件为 **Cua 0.38.0+artemis.1**,Helper 为 **1.2.3**。最新升级内容、验证与未测范围以[2.2.0 发行说明](releases/2.2.0.md)为准;下方“尚未发布”、分叉计数和候选版本均属于对应日期快照。插件从独立工作树发布,原插件主目录的 OCU 开发现场保留。
|
||||||
|
|
||||||
2026-10-26 已再次接续开发会话,当前基线为主程序本地提交 `fac4ccb`(2.2.0)。两个仓库现场、已撤回机制与后续验收范围见[本次接续记录](plans/2026-10-26-session-handoff.md)。以下继续保留原始交接快照。
|
2026-10-26 已再次接续开发会话,当前基线为主程序本地提交 `fac4ccb`(2.2.0)。两个仓库现场、已撤回机制与后续验收范围见[本次接续记录](plans/2026-10-26-session-handoff.md)。以下继续保留原始交接快照。
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
## 模型与超时
|
## 模型与超时
|
||||||
|
|
||||||
探索独立保存模型配置,默认 `gpt-6.1-sol / high`,预设也提供 `gpt-6-astra`,允许自定义模型。执行、人工示范分析、用例提炼和结束后的讨论使用这套配置。修改默认值仅影响新探索;已创建会话保留配置快照,Flash/Pro 默认值不变。
|
探索独立保存模型配置,默认 `gpt-6.1-terra / high`,预设也提供 `gpt-6-astra`,允许自定义模型。执行、人工示范分析、用例提炼和结束后的讨论使用这套配置。修改默认值仅影响新探索;已创建会话保留配置快照,Flash/Pro 默认值不变。
|
||||||
|
|
||||||
默认每段主动执行 1800 秒。模型调用、设备动作、录像分析计入;排队、等待回答和人工示范不计入。到时保存进度并暂停,可点击「继续探索」开启下一段;不会仅因超时把测试判成失败,累计主动耗时仍保留。
|
默认每段主动执行 1800 秒。模型调用、设备动作、录像分析计入;排队、等待回答和人工示范不计入。到时保存进度并暂停,可点击「继续探索」开启下一段;不会仅因超时把测试判成失败,累计主动耗时仍保留。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# 模型流式参数诊断
|
||||||
|
|
||||||
|
小型内置动作的参数流仍受 16384 字符保护。超限时关闭输出流、丢弃不完整参数,不执行该次动作;一次新响应重试和可用备用端点耗尽后,只结束当前任务,不进入共享的模型暂停。批次仍遵循“失败即停止”、人工暂停和取消设置。网络故障、认证错误和最终验收保留各自的恢复规则。
|
||||||
|
|
||||||
|
## 请求关联
|
||||||
|
|
||||||
|
每个模型请求尝试沿用现有 `llm_diagnostic` 记录,不另建日志数据库。
|
||||||
|
|
||||||
|
| 字段 / 请求头 | 含义 |
|
||||||
|
| --- | --- |
|
||||||
|
| `request_id` / `X-Client-Request-Id` | 同一次恢复循环内保持稳定的逻辑请求编号;切换备用调用或恢复暂停后可能建立新的逻辑请求 |
|
||||||
|
| `attempt_id` / `X-Client-Attempt-Id` | 本次客户端尝试编号;重试时变化,`previous_attempt_id` 关联前次尝试 |
|
||||||
|
| `client_*_id_matches` | 实际发送的请求头是否与本地编号一致;调用方已指定的头保留原值 |
|
||||||
|
| `server_request_id` | 首个有效的 `X-Oneapi-Request-Id`、`X-Request-Id` 或 `Request-Id`,按此前后顺序选择 |
|
||||||
|
| `response_x_request_id` | 响应中的原始 `X-Request-Id`,不推定它来自模型上游 |
|
||||||
|
| `error_request_id` | SDK 异常带回的编号,不覆盖已经从响应头读取的编号 |
|
||||||
|
| `response_id` | SDK 实际返回的 Responses 响应编号 |
|
||||||
|
|
||||||
|
编号只接收有长度限制的可打印白名单字符。请求头不控制会话亲和性,不替换 `thread-id` / `session-id`。网关自身的重试应使用独立编号或序号,不能假装是一次新的客户端尝试。
|
||||||
|
|
||||||
|
## 参数指纹 v1
|
||||||
|
|
||||||
|
`tool_observation_layer=langchain_tool_call_chunks` 明确表示客户端观测的是 SDK 转换后的参数片段,不是原始 SSE 帧。每工具记录实际可得的 `call_id`、`chunk_index`、工具名,以及:
|
||||||
|
|
||||||
|
- `argument_chars`:参数片段的 Unicode 字符数之和。
|
||||||
|
- `argument_bytes` / `argument_sha256`:按原顺序连接片段后,其 UTF-8 字节长度和 SHA256;不解析、重排参数 JSON,也不规范化空白或 Unicode。
|
||||||
|
- `argument_deltas`:非空参数片段数,不能直接与网关原始事件数比较。
|
||||||
|
- `argument_prefixes`:每 4096 字节的累计 SHA256,最多保留前 16 个。可以在分片大小不同、上游预读或中途取消时比较相同字节位置。
|
||||||
|
- `argument_encoding_valid`:遇到不能编码为 UTF-8 的片段时为 false,当前总字节数和总哈希留空,避免伪造可比结果;诊断本身不改变执行流程。
|
||||||
|
|
||||||
|
最多记录 64 个工具;超过时设置 `tool_stats_truncated=true`,总参数字符计数仍继续。诊断不保留参数正文。哈希仅证明已收到的字节前缀,不能证明输出完整或动作成功;完整性仍由响应终止状态和解析校验决定。
|
||||||
|
|
||||||
|
跨三端优先使用 `response_id + call_id` 关联。LangChain 的 `chunk_index` 可能因文本或推理子块不同于原始 `output_index`,缺失编号时不能按数组位置猜测。正常的 `arguments.done`、`output_item.done`、`response.completed` 快照不重复计入参数;自定义工具转换、非空初始参数或协议异常需要先核对 SDK 映射,不能仅凭跨层哈希不同认定某一端重复发送。
|
||||||
|
|
||||||
|
历史日志没有保存的原始帧无法由新增诊断恢复。HTTP 200 只说明响应头已到达;客户端保护后出现的 499 或 Broken pipe 也不能证明最初原因。网关写入本机下游 writer 成功不等于客户端已消费或执行。
|
||||||
|
|
||||||
|
## 离线验证
|
||||||
|
|
||||||
|
`tests/unit/test_llm_stream_guard.py` 验证保护、有限重试、相同/不同备用端点,以及不会创建或清除其他请求的全局暂停。`tests/unit/test_request_diagnostics.py` 验证请求编号、超时重试、响应头及单次参数计数。`tests/unit/test_argument_fingerprint.py` 验证不同分片、中文和 emoji、固定字节前缀、有界统计与异常编码。这些测试不调用真实模型或设备。
|
||||||
Loaded 100 of 193 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user