Compare commits

...
13 Commits
193 changed files with 10332 additions and 3043 deletions

No files matched your search

+8 -1
View File
@@ -6,6 +6,9 @@ OPENAI_BASE_URL=https://api-n-cd.playones.com/v1
OPENAI_MODEL=gpt-6-luna
# 单次模型 HTTP 请求超时(秒)
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
# 失败后最多重试 5 次,由 Artemis 统一管理,SDK 不叠加重试。
@@ -19,6 +22,10 @@ ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS=3
# default 表示不发送思考强度参数;接口不支持所选值时明确报错。
OPENAI_REASONING_EFFORT=high
# 探索独立默认值;网页已保存的探索配置优先,已创建会话保留快照。
ARTEMIS_EXPLORATION_MODEL=gpt-6.1-terra
ARTEMIS_EXPLORATION_REASONING_EFFORT=high
# 1 跳过本次网页启动的 Codex MCP 与官方 artemis-use Skill 同步;默认自动安装/更新。
ARTEMIS_SKIP_CODEX_SYNC=0
@@ -85,7 +92,7 @@ ARTEMIS_BATCH_MAX_EXECUTIONS=10000
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_TOOL_TIMEOUT_SECONDS=60
ARTEMIS_PLUGIN_SHUTDOWN_TIMEOUT_SECONDS=15
+13 -3
View File
@@ -21,7 +21,7 @@
| 探索、示范、用例提炼与复跑 | [探索说明](docs/exploration-mode.md) |
| 插件协议、适配或市场协同 | [插件开发规范](docs/plugin-development.md)及插件仓库自身的 AGENTS.md、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
@@ -54,13 +54,13 @@ Windows 首次安装使用 `start.bat`,国内镜像使用 `start-cn.bat`。启
- 历史压缩、视觉摘要与工具上下文调整须保留关键文件回执和来源,维护稳定的多轮前缀;缓存率使用实际用量加权统计,不把缓存输入当作消失的 token,也不承诺固定收益。
- 设备动作回放已移除;只读历史步骤、截图和上下文回忆仍保留,不因 replay 命名误删这些能力。
- 密钥仅通过网页或环境配置写入,读取配置不得回显;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
- 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 和能力文档。
- 同步 Codex MCP/Skill 时保留已有工具白名单、禁用状态和审批策略;异常时保留原配置并告警,不生成非法 TOML 或阻断网页启动。
- Windows PowerShell 启动脚本保持兼容 Windows PowerShell 5.1,避免编码导致解析失败。升级只做 Git 快进,保护已有配置和本地修改。
@@ -75,6 +75,16 @@ Windows 首次安装使用 `start.bat`,国内镜像使用 `start-cn.bat`。启
- 设备验证沿用用户指定设备;多设备且未指定时先确认。编写应用交互测试前,先通过 ARTEMIS 探索实际页面和操作路径。
- 提交前检查 `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
- 始终保护用户任务、历史库、环境配置和凭据。不要把个人设备序列号、绝对目录或 API Key 写入源码。
+24 -1
View File
@@ -64,8 +64,14 @@ ARTEMIS 面向使用自然语言执行、探索和复跑测试的人。当前产
| 插件与证据 | `artemis/plugins` 管理安装、会话、版本固定、来源、截图、录像和附件;插件执行工具动作,宿主管理任务生命周期和最终验收 |
| 配置与交付 | `artemis/config`、两份 `artemis.jsonc` 和环境配置提供默认值;`scripts` 负责启动、镜像、独立升级和源码打包 |
插件运行辅助接口见 [SDK 与市场材料](docs/plugin-sdk.md):已批准文件用不可变快照绑定实际消费字节,受管外部工具在返回前回收后代。宿主负责 MCP 总预算,插件负责内部超时和取消。开发检查保持轻量;市场材料使用独立内容指纹与验收记录校验,不代替源码、权利来源或真实业务审查。
请求重试、业务执行与最终验收分别处理。提交使用稳定标识;创建批次或探索草稿不自动执行,查询不产生设备动作。执行准入和恢复遵守现有资源锁,任务固定所用插件版本,不能通过另建队列绕开互斥。
设备保活是辅助服务。保活所属锁或旧保活清理目录不可访问时记录警告,不使设备连接、恢复或监控退出;未取得保活所属锁时不发送保活动作,也不创建替代锁绕过所属权。执行资源锁、主服务注册锁和队列写锁仍须成功取得。
运行时准备阶段失败记录 `initialization_failed`,批次保留详细原因并暂停未开始项,修复环境后由用户继续,已失败项不自动重跑。模型请求导致的批次暂停单独标记;模型暂停结束后恢复既定调度及失败即停止策略。用户暂停、停止全部任务和服务重启后的恢复暂停均需用户继续,不被模型恢复覆盖。Flash 与 Pro/探索 Operator 的生成失败复用统一备用模型与重试预算;不完整输出不会成为动作,已有动作不因模型切换而重放。
最终验收有独立总预算,按请求超时、重试和剩余任务时间协调,保留取证结果;完整有效的结构化结论才更新正式裁定。`completed`、工具成功、探索通过、用例提炼和复跑通过各自表达自己的状态。
Responses 消息交给工具、下游解析器或正式裁定前,必须具有服务端 `completed` 状态;工具参数的原始字符串必须是完整 JSON 对象,不能使用库自动补全的半截参数。流结束或库补出的 `last` 标记不能替代完成状态。缺少终止事件、明确未完成或失败的流丢弃预览,进入现有无效输出恢复策略,最多重新请求一次,配置为零次重试时不重试;恢复保留已有工具证据,不重新提交业务任务。非流式、结构化输出和串接解析器遵守同一检查。
@@ -84,6 +90,10 @@ Responses 消息交给工具、下游解析器或正式裁定前,必须具有
执行平台与证据来源分别记录。报告和证据索引从执行记录汇总来源及实际 step_id/call_id/source_id;界面、接口、文件和终端各自只能支持其观察范围,业务后台名称或网址不能推断平台。最终复核明确事实、矛盾和未验证边界;来源清单不生成新结论,不覆盖 AI 原判、有效结论或人工复验。
原生安卓动作在派发时记录所选设备来源。安卓截图来源也可从已保存的成功截图回执读取设备、采集时间、文件路径和校验值,并引用实际 trace_id;兼容 Flash 与 Pro 的回执封装,不从模型参数补造来源。空步骤、笔记和结束声明不计入缺失动作来源;窗口枚举等无单一目标的操作保留未关联状态,该计数不等于截图丢失。未生成独立输出正文时,报告展示当前有效判定及依据,保留人工复验优先级,不把正文缺失写成验收结论缺失。
规划器的笔记工具与执行器的设备、插件工具分别绑定。规划器依据任务的实际授权插件清单制定计划,由执行器加载并验证能力;规划阶段没有绑定动作工具,不表示任务缺少这些能力,也不构成重新申请授权的依据。
MCP 与单任务 HTTP 入参拒绝未知字段,校验失败不调度任务。MCP JSON 结果保留兼容文本并提供 structuredContent;任务摘要分页明确总数与 next_offset,引用实际存储编号。跨端阶段先完整初始化,再按既有阶段 id 更新状态与依据;补丁保留其他阶段、范围、资源和顺序,合并后统一校验并保存,失败不更新内存状态。
Flash 的最终 AI 判定与分析独立配置,默认关闭;首页功能配置和 MCP 共用保存的全局设置。任务开始时固定设置,尚未开始的任务使用最新值;批量、精选及提炼用例的 Flash 复跑同样遵循。关闭时跳过最终 Checker,保留正常执行、步骤、截图、报告和人工复验;结果为“执行完成 · 未做 AI 判定”,不标记“AI确认”或测试通过。Pro 与主动探索的验收规则保持不变。
@@ -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 写入计划时拒绝新增或增加同名顶层里程碑,撤销该次写入并返回工具错误;局部替换不得夹带目标范围外的里程碑,已有重复项可继续整理,确需重复的阶段使用不同名称。
@@ -147,6 +157,7 @@ Flash 的最终 AI 判定与分析独立配置,默认关闭;首页功能配
- 通常以 4、6、8、12、16、24px 组织内部间距;沿用组件现有网格和断点。子容器允许收缩,长内容使用 `min-width: 0` 及合适换行。
- 探索面板在 600px 以下收紧外边距并允许操作换行;模型选择控件宽度跟随容器。窄屏仍可辨认状态、选择来源和发送指导。
- 列表后台刷新保留滚动、选择和折叠状态;首次加载与静默更新分开,避免周期性加载提示挤动内容。实时连接复用现有页面可见性管理。
- 结束任务的错误与阻塞说明默认只占一行,点击展开安全 Markdown 正文与已有依据,长正文限高滚动。摘要优先采用已保存的有效结论原因,否则取执行错误首行;不新增 AI 复盘请求,不改写原始记录。展开说明不触发任务切换,刷新保留展开状态。
## Elevation
@@ -183,3 +194,15 @@ Flash 的最终 AI 判定与分析独立配置,默认关闭;首页功能配
- 保留可见焦点、禁用反馈和减少动画偏好;录制红点表示实际录制,等待或执行中使用各自文案。
- 源码已更新而页面仍旧时,先核对服务目录、构建与缓存;不通过反复增加样式覆盖掩盖旧构建问题。
- 视觉变化验证代表性宽度、长标题、加载、等待回答、错误与展开状态;文档格式检查不等于渲染验证。开发检查按[前端指南](apps/showcase_ui/AGENTS.md)执行。
## Workspace plugin extensions
工作区扩展使用声明式清单版本 3,宿主提供回放面板、人工表单、任务存储和报告接口;不运行插件页面脚本,不另建队列或结果库。纯工作区插件不加入模型执行选择。接口定义见 [工作区插件](docs/workspace-plugins.md)。
回放两侧面板默认收起、独立展开,普通浮窗和影院模式共用;插件声明期望/最小/最大宽度,宿主按视口收缩,窄屏一次展开一侧,始终保持面板位于回放的左侧或右侧。任务需求只展示本次运行最初的提示词,复用安全 Markdown 渲染。人工表单字段均可留空,不调用模型、不覆盖已有执行结论或人工复验。
评审按单次任务和插件 ID 保存版本历史;运行中只读,终态可编辑或清空。停用/卸载隐藏面板与新导出字段但保留记录,任务删除时同步清理。报告只采用已安装且启用的贡献,空值保持空白;修改记录或插件状态使后续报告缓存失效。
执行详情中的 Payload 默认折叠;其他超长字段也默认折叠,展开后保留完整可复制内容并限制显示高度。原始用例面板仅由启用的工作区插件声明,额外导入列不影响未启用插件的用户。评审支持所有执行模式和目标,用户修改评审后自动保存:结果选择立即保存,备注短暂防抖;切换任务前保存,失败时保留输入和当前任务。仅用户编辑触发写入,AI 不推断人工结论。
模型网络代理策略仅由环境项 `OPENAI_USE_SYSTEM_PROXY` 控制,默认 false。同步、异步模型客户端和模型凭据检查统一遵循;不出现在前端配置中,不改变 TLS 验证与自定义 CA 设置。
+4 -4
View File
@@ -2,13 +2,13 @@
基于 [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 适合需要计划、分支探索和验证的任务。
- **交互探索**:边执行边交流,也可录制人工示范;探索成功后生成一个可编辑、复跑的用例。默认使用独立的 `gpt-6.1-sol / high` 配置,超时暂停并保留进度。见 [探索模式](docs/exploration-mode.md)。
- **插件扩展**:在插件市场安装并启用电脑控制等能力,AI 在安卓任务中按需使用;手机、电脑的步骤与证据统一留档。见 [插件与跨端任务](docs/plugins.md)。
- **交互探索**:边执行边交流,也可录制人工示范;探索成功后生成一个可编辑、复跑的用例。默认使用独立的 `gpt-6.1-terra / high` 配置,超时暂停并保留进度。见 [探索模式](docs/exploration-mode.md)。
- **插件扩展**:安装并启用电脑控制等执行能力,或使用工作区插件为回放和报告增加人工评审。手机、电脑的步骤与证据统一留档。见 [插件与跨端任务](docs/plugins.md)和[工作区插件接口](docs/workspace-plugins.md)。
- **可保存的模型配置**:支持自定义接口、模型、思考强度和超时重试,以及视频分析与录屏开关。
- **批量测试**:按新版 Excel 模板导入提示词,每项可独立指定模型、模式和参数;任务可暂停、补充指导并继续。
- **精选任务**:收藏单任务、批次或探索用例,在首页一键复跑,默认沿用已保存的配置。
@@ -57,7 +57,7 @@ cd Artemis-Eapil
3. 输入任务目标,选择 Flash、Pro 或探索后执行;仅测试电脑时勾选输入区的“仅 PC”,无需手机或模拟器在线,但需已启用并授权的电脑观察插件。在工作区查看步骤、截图和结果。流程尚不明确时,选择探索并随时交流。
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`;已保存的地址不变。直接编辑环境文件后需重启相应服务。不要提交自己的密钥。
@@ -15,6 +15,7 @@
import json
import logging
import os
import re
import sqlite3
import time
from typing import Any
@@ -235,6 +236,47 @@ class SessionRepository:
rows = cursor.fetchall()
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(
self, session_ids: list[str] | None = None, limit: int = 3
) -> dict[str, list[str]]:
@@ -349,7 +391,22 @@ class SessionRepository:
total += t
cached += _int(payload.get("cached_tokens"))
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(
(node, model_name),
{
@@ -220,6 +220,41 @@ class StepRepository:
# recovering, waiting, completed, and terminal outcomes. Other tools
# retain the historical compact payload to avoid sending large blobs.
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 {
"video_analysis",
"video_analyzer",
+3 -1
View File
@@ -12,7 +12,7 @@ from apps.admin_console.services.exploration_listing import EXPLORATION_RUNS_CTE
from apps.admin_console.services.automation_store import identifier, store
from apps.admin_console.services import automation_service as 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 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}")
def batch_detail(batch_id: str):
jobs = store.jobs(batch_id)
reviewed = task_extensions.reviewed_batch_sessions(batch_id, store)
for job in jobs:
job["human_reviewed"] = job["session_id"] in reviewed
job["test_result"] = read_reviewed_result(
job["session_id"], job["status"], job.get("error") or "", storage=store
)
+3
View File
@@ -214,6 +214,9 @@ async def diagnose_plugin(plugin_id: str, request: DiagnoseRequest):
try:
store = get_store()
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(
[PluginSelection(id=plugin_id, profile=request.profile)],
store=store,
+10
View File
@@ -84,6 +84,9 @@ def _list_sessions_sync():
with store.connection() as connection:
exploration_runs = exploration_metadata(connection)
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()
latest_recordings = session_repo.get_latest_video_recordings_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}")
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"):
row["display_name"] = row["exploration_case_name"]
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)
+24
View File
@@ -441,6 +441,24 @@ async def get_status():
)
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
global_queued = DeviceExecutionLock.get_queued_tasks()
@@ -508,6 +526,12 @@ async def get_status():
is_paused = state.is_paused
conn_profile = conn_info.get("profile") or active_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 {
"status": "paused" if is_paused else "running",
"paused_error": state.paused_error if is_paused else None,
+3
View File
@@ -284,6 +284,9 @@ app.include_router(favorites_router)
from apps.admin_console.routers.plugins import router as 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
@@ -15,6 +15,7 @@ from artemis.runtime.task_limits import task_timeout
logger = logging.getLogger(__name__)
TERMINAL = {"completed", "failed", "cancelled", "skipped", "interrupted"}
MODEL_PAUSE_REASON = "模型请求已暂停,请先恢复模型连接再继续批次"
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"])):
continue
batch.update(
status="paused", reason="服务已重启;请检查中断任务,点击继续执行未开始任务"
status="paused",
reason="服务已重启;请检查中断任务,点击继续执行未开始任务",
pause_source="recovery",
)
for job in store.jobs(batch["id"]):
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["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"):
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.
store.save_job({**job, "status": "finalizing", "terminal_status": job["status"]})
await asyncio.to_thread(export_session, sid, job["formats"])
@@ -217,10 +240,18 @@ async def tick_batches():
if batch["status"] not in {"running", "paused"}:
continue
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"}]
if active:
if state.is_paused:
batch.update(status="paused", reason="模型请求已暂停,请先恢复模型连接再继续批次")
if state.is_paused and batch["status"] == "running":
batch.update(status="paused", reason=MODEL_PAUSE_REASON, pause_source="model")
store.put("batch", batch["id"], batch)
for job in active:
# The queue records started_at at submission, then replaces it
@@ -284,7 +315,7 @@ async def tick_batches():
store.put("batch", batch["id"], batch)
continue
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)
continue
job = pending[0]
@@ -438,11 +469,14 @@ def batch_action(batch_id: str, action: str) -> dict:
if action == "pause":
if batch["status"] not in {"running", "paused"}:
raise ValueError("只能暂停正在执行的批次")
batch.update(status="paused", reason="用户暂停;当前任务继续,后续任务等待")
batch.update(
status="paused", reason="用户暂停;当前任务继续,后续任务等待", pause_source="user"
)
elif action == "resume":
if batch["status"] not in {"draft", "paused"}:
raise ValueError("只能开始草稿或继续已暂停批次")
batch.update(status="running", reason="")
batch.pop("pause_source", None)
elif action == "retry_failed":
if batch["status"] == "running":
raise ValueError("请先暂停批次")
@@ -294,7 +294,7 @@ class AutomationStore:
if batch.get("status") != "deleting":
raise ValueError("批次尚未准备删除")
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=?)",
(batch_id,),
)
@@ -312,13 +312,15 @@ class AutomationStore:
if session_id:
conn.execute("DELETE FROM automation_jobs WHERE session_id=?", (session_id,))
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,),
)
else:
conn.execute("DELETE FROM automation_jobs")
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()
+17 -4
View File
@@ -15,7 +15,7 @@ def archive_path(batch_id: str):
def cached(batch_id: str, batch: dict) -> bool:
return bool(batch.get("report_ready")) and report_export.reusable(
archive_path(batch_id), report_export.BATCH_ARCHIVE_SIGNATURE, "completed"
archive_path(batch_id), report_export.batch_archive_signature(batch_id), "completed"
)
@@ -41,7 +41,13 @@ def _snapshot(state: dict) -> dict:
def status(batch_id: str, batch: dict) -> dict:
with _lock:
state = _states.get(batch_id)
if state:
if state and state["status"] != "ready":
return _snapshot(state)
if (
state
and state.get("signature") == report_export.batch_archive_signature(batch_id)
and archive_path(batch_id).is_file()
):
return _snapshot(state)
if cached(batch_id, batch):
return {
@@ -65,11 +71,12 @@ def status(batch_id: str, batch: dict) -> dict:
def prepare(batch_id: str, batch: dict) -> dict:
cache_ready = cached(batch_id, batch)
with _lock:
current = _states.get(batch_id)
if current and current["status"] == "preparing":
return _snapshot(current)
if cached(batch_id, batch):
if cache_ready:
_states.pop(batch_id, None)
return status_from_cache()
state = {
@@ -88,11 +95,17 @@ def prepare(batch_id: str, batch: dict) -> dict:
def run() -> None:
try:
with report_export.export_lock("batch:" + batch_id):
signature = report_export.batch_archive_signature(batch_id)
latest = report_export.store.get("batch", batch_id)
if not (latest and cached(batch_id, latest)):
report_export.export_batch(batch_id, progress=update)
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:
with _lock:
state.update(
+1 -1
View File
@@ -149,7 +149,7 @@ class MediaService:
return p
LIVE_RECORDING_GRACE_SECONDS = 10.0
TRACE_STATUS_SUFFIXES = ("_PASS_", "_FAIL_", "_TESTFAIL_")
TRACE_STATUS_SUFFIXES = ("_PASS_", "_FAIL_", "_TESTFAIL_", "_BLOCKED_", "_UNVERIFIED_")
@classmethod
def is_live_recording(cls, p: Path) -> bool:
@@ -81,6 +81,7 @@ def get_model_settings() -> dict:
"reasoning_effort": settings.OPENAI_REASONING_EFFORT,
"model_presets": [
"gpt-6-luna",
"gpt-6.1-terra",
"gpt-6.1-sol",
"gpt-6-astra",
"mimo-v2.6-pro",
+89 -12
View File
@@ -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.task_lifecycle import guidance_details
from apps.admin_console.services import task_extensions
from docx import Document
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
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 = {
"completed": "执行完成",
"success": "执行完成",
@@ -208,11 +234,14 @@ def session_report(session_id: str, db_path=None) -> dict:
if status in {"failed", "interrupted"} and verification.startswith("未验证"):
verification = "未验证(任务异常终止)"
if not content:
content = (
"本任务未生成最终结论。请查看异常原因和已记录的执行步骤。"
if status in {"failed", "interrupted"}
else "本任务没有保存最终结论。以下内容仅来自已记录的执行状态和证据。"
)
lines = ["本任务未保存独立输出正文。", f"当前有效判定:{verification}"]
if test_result:
if test_result.get("reason"):
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 {
"id": session_id,
**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", ""),
"subcategory": case.get("subcategory", ""),
"preconditions": case.get("preconditions", ""),
"original_case": case.get("original_case"),
"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", []):
references = ", ".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 "编号未记录"
for ref in item.get("references", [])
)
variation = "(来源标签随步骤变化,详见原始步骤)" if item.get("label_varies") else ""
lines.append(
f"{labels.get(item['kind'], '其他')} · {item['label'] or item['target_id']} · {references}"
f"{labels.get(item['kind'], '其他')} · {item['label'] or item['target_id']}"
f"{variation} · {references}"
)
if value.get("sources_omitted"):
lines.append(f"另 {value['sources_omitted']} 个来源见原始步骤记录。")
lines.append(f"来源未完整记录的步骤:{value.get('unattributed_step_count', '未知')}。")
lines.append(
f"未关联到单一来源的步骤:{value.get('unattributed_step_count', '未知')}。"
"窗口枚举等操作可能没有单一目标;此计数不表示截图丢失。"
)
lines.append(value.get("note") or "来源清单不代表断言已验证。")
return "\n".join(lines)
@@ -340,6 +378,15 @@ def metadata(report: dict) -> list[tuple]:
("检测项目", report.get("category") or "—"),
("测试子项", report.get("subcategory") 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"))),
("任务目标", report["goal"]),
("预期结果", report["expected"] or "未指定"),
@@ -659,6 +706,11 @@ def word_report(report: dict, path: Path):
doc.add_paragraph("任务配置:" + config_text(report["task_config"]))
doc.add_heading("结论与发现", 1)
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)
for step in report["steps"]:
paragraph = doc.add_paragraph()
@@ -792,6 +844,10 @@ def excel_report(report: dict, path: Path):
wb.active.title = "任务结果"
sheet_rows(wb.active, [("项目", "内容"), *metadata(report)], (22, 82))
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
for kind, rows in markdown_blocks(report["content"]):
if kind == "table":
@@ -913,8 +969,7 @@ def session_videos(report: dict) -> list[tuple[Path, str]]:
return []
source = Path(str(candidate)).resolve()
trace_root = TRACES_PATH.resolve()
# Workers save recordings in their finalized web_<time>_<id>_PASS/FAIL
# folder. The recording row already ties that path to this session.
# The recording row already ties the finalized trace folder to this session.
if not source.is_relative_to(trace_root):
return []
# A resumed recording lives under resume_NNNN; the initial recording is
@@ -1064,6 +1119,8 @@ def export_batch(batch_id: str, progress: Callable[[int, int, str], None] | None
directory = EXPORT_ROOT / "batches" / batch_id
directory.mkdir(parents=True, exist_ok=True)
formats = batch["formats"]
with task_extensions.plugin_store().mutation_lock:
signature = batch_archive_signature(batch_id)
records = [session_report(job["session_id"]) for job in store.jobs(batch_id)]
if progress:
progress(0, len(records), "正在生成批次汇总")
@@ -1078,6 +1135,17 @@ def export_batch(batch_id: str, progress: Callable[[int, int, str], None] | None
assert wb.active is not None
wb.active.title = "批次结果"
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"
wb.save(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)} 个任务。验证通过与执行完成分别统计。"
)
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"
doc.save(str(path))
outputs.append(path)
@@ -1146,5 +1223,5 @@ def export_batch(batch_id: str, progress: Callable[[int, int, str], None] | None
if progress:
progress(len(records), len(records), "正在完成压缩包")
temporary.replace(target)
remember(target, BATCH_ARCHIVE_SIGNATURE)
remember(target, signature)
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()
+63 -6
View File
@@ -21,6 +21,7 @@ HEADERS = (
"Artemis提示词",
"任务配置(JSON)",
)
CASE_HEADERS = ("预置条件", "操作步骤", "预期结果")
MAX_ROWS = int(os.getenv("ARTEMIS_IMPORT_MAX_ROWS", "1000"))
MAX_BYTES = int(os.getenv("ARTEMIS_IMPORT_MAX_BYTES", "10485760"))
@@ -61,6 +62,15 @@ class TaskOptions(BaseModel):
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):
model_config = ConfigDict(extra="forbid")
case_id: str = Field(default="", max_length=200)
@@ -70,16 +80,29 @@ class TaskCase(BaseModel):
preconditions: str = Field(default="", max_length=10000)
goal: str = Field(min_length=1, max_length=10000)
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:
headers = (*HEADERS, *CASE_HEADERS) if original_case_enabled() else HEADERS
wb = Workbook()
ws = wb.active
assert ws is not None
ws.title = "任务"
ws.append(HEADERS)
ws.append(headers)
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]:
cell.font = Font(bold=True, color="FFFFFF")
cell.fill = PatternFill("solid", fgColor="245C78")
@@ -93,7 +116,7 @@ def template() -> bytes:
help_sheet.append(["前置约束提取及其他描述列仅作查看、搜索和报告元数据,不追加到提示词。"])
help_sheet.append(["任务配置(JSON)可留空,空字段继承批次或全局配置;禁止密钥与未知字段。"])
help_sheet.append(["示例仅供参考,不会导入执行。请在“任务”页填写自己的用例。"])
help_sheet.append(HEADERS)
help_sheet.append(headers)
help_sheet.append(
[
"示例-001",
@@ -130,7 +153,18 @@ def template() -> bytes:
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
for row in help_sheet:
for cell in row:
@@ -164,7 +198,10 @@ def parse_workbook(data: bytes) -> dict:
raise ValueError("缺少“任务”工作表,请使用下载的模板")
rows = iter(wb["任务"].iter_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("列名或顺序与新版模板不一致,请重新下载模板")
tasks, errors = [], []
for number, row in enumerate(rows, 2):
@@ -190,9 +227,29 @@ def parse_workbook(data: bytes) -> dict:
"preconditions": values[4],
"goal": values[5],
"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:
errors.append({"row": number, "message": str(exc)[:500]})
if not tasks and not errors:
@@ -1522,8 +1522,10 @@ class TaskQueueService:
from artemis.plugins.store import PluginStore
for batch in store.records("batch"):
if batch["status"] == "running":
batch.update(status="paused", reason="已停止全部任务,等待手动继续")
if batch["status"] in {"running", "paused"}:
batch.update(
status="paused", reason="已停止全部任务,等待手动继续", pause_source="user"
)
store.put("batch", batch["id"], batch)
# 1. Cancel local queue reservations
for item in state.queue_items:
+3
View File
@@ -30,6 +30,9 @@ npm run build
## 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 服务。
- 保持现有简体中文文案、颜色、间距和控件样式,菜单、弹窗和表单必须清晰可读。
- 修改任务或配置交互时覆盖加载、保存、失败和空状态;不要将执行中误标成录屏中或把完成误标成验证通过。
+1858 -2313
View File
File diff suppressed because it is too large. Load diff
+17 -12
View File
@@ -1,6 +1,6 @@
{
"name": "artemis-showcase-ui",
"version": "2.2.0",
"version": "2.4.5",
"scripts": {
"ng": "ng",
"start": "ng serve --proxy-config proxy.conf.json",
@@ -9,24 +9,29 @@
"test": "ng test"
},
"private": true,
"overrides": {
"@angular/build": {
"vite": "8.3.5"
}
},
"dependencies": {
"@angular/cdk": "^22.1.5",
"@angular/common": "^22.1.4",
"@angular/compiler": "^22.1.4",
"@angular/core": "^22.1.4",
"@angular/forms": "^22.1.4",
"@angular/platform-browser": "^22.1.4",
"@angular/platform-browser-dynamic": "^22.1.4",
"@angular/router": "^22.1.4",
"@angular/cdk": "^22.2.4",
"@angular/common": "^22.2.3",
"@angular/compiler": "^22.2.3",
"@angular/core": "^22.2.3",
"@angular/forms": "^22.2.3",
"@angular/platform-browser": "^22.2.3",
"@angular/platform-browser-dynamic": "^22.2.3",
"@angular/router": "^22.2.3",
"marked": "18.1.1",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular/build": "^22.1.6",
"@angular/cli": "^22.1.6",
"@angular/compiler-cli": "^22.1.4",
"@angular/build": "^22.2.4",
"@angular/cli": "^22.2.4",
"@angular/compiler-cli": "^22.2.3",
"@types/jasmine": "~5.1.0",
"istanbul-lib-instrument": "^6.0.3",
"jasmine-core": "~5.6.0",
@@ -129,9 +129,7 @@ limitations under the License.
<div class="item-goal" [title]="session.initial_goal || ''">
{{ session.display_name || session.initial_goal || '未命名任务' }}
</div>
@if (session.error && getTaskStatus(session) === 'failed') {
<div class="item-error" [title]="session.error">{{session.error}}</div>
}
<app-task-outcome [error]="session.error" [result]="session.test_result" [status]="getTaskStatus(session)" />
<div class="item-footer">
<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>
@@ -695,13 +693,13 @@ limitations under the License.
@if (getActionTargetText(item.data)) {
<div class="action-detail-row">
<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>
}
@if (getActionInputText(item.data)) {
<div class="action-detail-row">
<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>
}
@if (getActionCoords(item.data)) {
@@ -719,13 +717,13 @@ limitations under the License.
@if (getActionTargetText(item.data)) {
<div class="grid-item">
<span class="grid-label">目标</span>
<span class="grid-value">"{{ getActionTargetText(item.data) }}"</span>
<app-expandable-value label="目标" [value]="getActionTargetText(item.data)" />
</div>
}
@if (getActionInputText(item.data)) {
<div class="grid-item">
<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>
}
@if (getActionCoords(item.data)) {
@@ -749,19 +747,7 @@ limitations under the License.
@for (param of getActionExtraParams(item.data); track trackParam($index, param)) {
<div class="grid-item" [class.full-width]="param.key === 'Result'">
<span class="grid-label">{{ param.key }}</span>
@if (param.key === 'Result') {
<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>
}
<app-expandable-value [label]="param.key" [value]="param.value" />
</div>
}
<!-- Fallback item if all detail fields are empty -->
@@ -1008,7 +994,7 @@ limitations under the License.
@if (getAdbCommandLine(item.data)) {
<div class="action-detail-row">
<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>
}
@if (getAdbCwd(item.data)) {
@@ -1029,13 +1015,13 @@ limitations under the License.
@if (getToolTargetText(item.data)) {
<div class="action-detail-row">
<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>
}
@if (getToolInputText(item.data)) {
<div class="action-detail-row">
<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>
}
@if (getToolCoords(item.data)) {
@@ -1047,13 +1033,13 @@ limitations under the License.
@if (getToolAnalysisText(item.data)) {
<div class="action-detail-row">
<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>
}
@if (getToolGenericDetails(item.data)) {
<div class="action-detail-row">
<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>
}
}
@@ -1067,7 +1053,7 @@ limitations under the License.
@if (getAdbCommandLine(item.data)) {
<div class="grid-item full-width">
<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>
}
@if (getAdbCwd(item.data)) {
@@ -1087,7 +1073,7 @@ limitations under the License.
@for (param of plugin.args; track param.key) {
<div class="grid-item">
<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>
}
@if (plugin.result || plugin.rawResult || plugin.facts.length) {
@@ -1101,13 +1087,13 @@ limitations under the License.
@if (getToolTargetText(item.data)) {
<div class="grid-item">
<span class="grid-label">目标</span>
<span class="grid-value">"{{ getToolTargetText(item.data) }}"</span>
<app-expandable-value label="目标" [value]="getToolTargetText(item.data)" />
</div>
}
@if (getToolInputText(item.data)) {
<div class="grid-item">
<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>
}
@if (getToolCoords(item.data)) {
@@ -1119,19 +1105,19 @@ limitations under the License.
@if (getToolAnalysisText(item.data)) {
<div class="grid-item">
<span class="grid-label">分析</span>
<span class="grid-value">"{{ getToolAnalysisText(item.data) }}"</span>
<app-expandable-value label="分析" [value]="getToolAnalysisText(item.data)" />
</div>
}
@if (getToolGenericDetails(item.data)) {
<div class="grid-item">
<span class="grid-label">详情</span>
<span class="grid-value">"{{ getToolGenericDetails(item.data) }}"</span>
<app-expandable-value label="详情" [value]="getToolGenericDetails(item.data)" />
</div>
}
@for (param of getToolExtraParams(item.data); track trackParam($index, param)) {
<div class="grid-item">
<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>
}
}
@@ -3694,7 +3694,6 @@ $stream-row-height: 25px;
&.failed { border-left: 3.5px solid #dc2626; background: var(--app-error-soft, #fff7f7); }
&.completed { border-left: 3.5px solid #16a34a; }
&.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 {
display: flex;
@@ -1,3 +1,4 @@
import { TaskOutcomeComponent } from '../task-outcome/task-outcome.component';
import { ExplorationMessage, Demonstration } from '../../services/exploration.service';
import { mergeConversation } from '../../utils/exploration-timeline.util';
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 { ReportMarkdownComponent } from '../report-markdown/report-markdown.component';
import { PluginToolResultComponent } from '../plugin-tool-result/plugin-tool-result.component';
import { ExpandableValueComponent } from '../expandable-value/expandable-value.component';
import { AutomationPanelComponent } from '../automation-panel/automation-panel.component';
import { UiLabelPipe } from '../../utils/ui-label.pipe';
/**
@@ -250,7 +252,7 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote, StreamRe
@Component({
selector: 'app-agent-stream',
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',
styleUrl: './agent-stream.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
@@ -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-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.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 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" />
@@ -108,6 +108,19 @@
</div>
}
@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>}
}
@@ -37,14 +37,21 @@ input[type=checkbox]{width:15px;height:15px;accent-color:var(--app-primary-text,
@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;}
.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 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-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-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-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;}
@@ -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);}
.md-para-gap{height:8px;}
}
.job .human-review-badge{white-space:nowrap;border:1px solid var(--app-primary-border, #bfdbfe);background:var(--app-primary-soft, #eff6ff);color:var(--app-primary-text, #1d4ed8);font-weight:500;}
@@ -90,6 +90,30 @@ describe('AutomationPanelComponent batch deletion', () => {
expect(http.post).not.toHaveBeenCalled();
});
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 () => {
const fixture = TestBed.createComponent(AutomationPanelComponent);
fixture.componentInstance.tab.set('batches');
@@ -129,7 +153,7 @@ describe('AutomationPanelComponent batch deletion', () => {
{session_id:'task-b',status:'completed',case:{name:'用例 B',goal:'观察主页'}}
]});
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();
expect(agent.selectSession.calls.allArgs()).toEqual([['task-a',true],['task-b',true]]);
expect(component.tab()).toBe('batches');
@@ -152,7 +176,12 @@ describe('AutomationPanelComponent batch deletion', () => {
});
fixture.detectChanges();
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')?.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 { FavoriteButtonComponent } from '../favorite-button/favorite-button.component';
import { ReportMarkdownComponent } from '../report-markdown/report-markdown.component';
@@ -24,7 +25,7 @@ interface ArchiveProgress {
}
@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'
})
export class AutomationPanelComponent {
@@ -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);
}
@@ -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>();}
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 api: any;
beforeEach(() => {
@@ -44,7 +44,7 @@ export class ExplorationPanelComponent {
private followsLatest = true;
private requests = this.api.requests;
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'];
constructor() {
@@ -35,7 +35,7 @@ describe('Featured tasks',()=>{
expect(fixture.nativeElement.textContent).not.toContain('旧任务部分配置缺失');
fixture.nativeElement.querySelector('.configure').click();fixture.detectChanges();flushMicrotasks();
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(fixture.nativeElement.querySelector('input[aria-label="复跑模型(自定义)"]').value).toBe('original-model');
select.value='gpt-6-luna';select.dispatchEvent(new Event('change'));fixture.detectChanges();
@@ -27,7 +27,9 @@ limitations under the License.
class="floating-video-wrapper"
[class.minimized]="agentService.isVideoMinimized()"
[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.--player-top]="posY() + 'px'"
>
@@ -42,6 +44,9 @@ limitations under the License.
</span>
</div>
<div class="header-actions" (mousedown)="$event.stopPropagation()">
@if (extensionPanels(); as panels) {
<ng-container [ngTemplateOutlet]="panels.headerControls() || null" />
}
<!-- Mode Toggle Group: Video vs. Step Replay -->
@if (agentService.recordingPlaybackStatus() === 'ready' && agentService.hasCurrentSessionStepFrames() && !agentService.isVideoMinimized()) {
<div class="mode-toggle-group">
@@ -117,6 +122,9 @@ limitations under the License.
}
<!-- Video Main Content Area -->
@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">
<!-- 1. LIVE SCREEN STREAM (AUTOMATICALLY ACTIVE DURING RUNNING TEST) -->
@if (agentService.recordingPlaybackStatus() === 'live') {
@@ -488,6 +496,7 @@ limitations under the License.
}
}
</div>
</app-task-extension-panels>
}
</div>
}
@@ -28,6 +28,7 @@
}
.floating-video-wrapper {
container-type: inline-size;
position: fixed;
width: 400px;
max-width: calc(100vw - 32px);
@@ -60,8 +61,8 @@
left: 50% !important;
top: 50% !important;
transform: translate(-50%, -50%);
width: 85vw;
max-width: 960px;
width: min(calc(85vw + var(--extension-width, 0px)), calc(100vw - 32px));
max-width: calc(960px + var(--extension-width, 0px));
height: auto;
max-height: 88vh;
box-shadow: 0 24px 60px -12px rgba(15, 23, 42, 0.35);
@@ -805,16 +806,21 @@
.controls-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
justify-content: space-between;
.controls-left, .controls-right {
display: flex;
flex-wrap: wrap;
white-space: nowrap;
align-items: center;
gap: 8px;
}
.ctrl-btn {
flex-shrink: 0;
background: rgb(var(--app-surface-soft-rgb, 241 245 249) / 0.75);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
@@ -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 { signal } from '@angular/core';
import { AgentService, RecordingSource, VideoSegment } from '../../services/agent.service';
@@ -39,6 +41,8 @@ describe('FloatingVideoPlayerComponent video markers', () => {
currentSessionStepFrames: signal([frame]),
currentSession: signal({ session_id: 'test', start_time: start }),
currentSessionId: signal('test'),
sessions: signal([{ session_id: 'test', status: 'completed' }]),
selectSession: jasmine.createSpy('selectSession'),
videoSeekRequest: signal<{ seconds: number; requestId: number; sourceId?: string } | null>(null),
stepSeekRequest: signal(null),
consumeVideoAutoplay: jasmine.createSpy('consumeVideoAutoplay').and.returnValue(false)
@@ -59,11 +63,14 @@ describe('FloatingVideoPlayerComponent video markers', () => {
agentService = createAgentService();
TestBed.configureTestingModule({
imports: [FloatingVideoPlayerComponent],
providers: [{ provide: AgentService, useValue: agentService }]
providers: [provideHttpClient(), provideHttpClientTesting(), { provide: AgentService, useValue: agentService }]
});
fixture = TestBed.createComponent(FloatingVideoPlayerComponent);
fixture.detectChanges();
TestBed.inject(HttpTestingController).expectOne('/api/task-extensions/test').flush({
session_id: 'test', initial_prompt: '', editable: true, plugins: []
});
component = fixture.componentInstance;
video = component.videoRef!.nativeElement;
paused = true;
@@ -98,6 +105,23 @@ describe('FloatingVideoPlayerComponent video markers', () => {
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', () => {
const hidden = spyOnProperty(document, 'hidden', 'get').and.returnValue(false);
agentService.recordingPlaybackStatus.set('live');
@@ -26,10 +26,13 @@ import {
HostListener,
effect,
untracked,
OnDestroy
OnDestroy,
viewChild
} from '@angular/core';
import { TaskExtensionPanelsComponent } from '../task-extension-panels/task-extension-panels.component';
import { FormsModule } from '@angular/forms';
import { NgTemplateOutlet } from '@angular/common';
import { AgentService } from '../../services/agent.service';
import { StepReplayFrame } from '../../core/models/stream.model';
import { drawActionCoordinatesOnOverlay } from '../../utils/image-overlay.util';
@@ -40,13 +43,22 @@ import { buildVideoStepMarkers, VideoStepMarker } from '../../utils/video-step-m
@Component({
selector: 'app-floating-video-player',
standalone: true,
imports: [FormsModule],
imports: [FormsModule, NgTemplateOutlet, TaskExtensionPanelsComponent],
templateUrl: './floating-video-player.component.html',
styleUrl: './floating-video-player.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class FloatingVideoPlayerComponent implements OnDestroy {
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);
@ViewChild('videoRef') videoRef?: ElementRef<HTMLVideoElement>;
@@ -327,7 +339,7 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
this.isDragging = true;
this.dragStartX = event.clientX;
this.dragStartY = event.clientY;
this.initialPosX = this.posX();
this.initialPosX = this.windowLeft();
this.initialPosY = this.posY();
event.preventDefault();
@@ -11,7 +11,7 @@ describe('持久化模型与功能设置', () => {
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',
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(() => {
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
http = TestBed.inject(HttpTestingController);
@@ -1,15 +1,17 @@
import { Component, computed, input } from '@angular/core';
import { PluginToolView } from '../../utils/plugin-tool-view.util';
import { ExpandableValueComponent } from '../expandable-value/expandable-value.component';
/** Readable presentation only: the full receipt and model context stay intact. */
@Component({
selector: 'app-plugin-tool-result',
standalone: true,
imports: [ExpandableValueComponent],
template: `
@if (view().facts.length) {
<dl class="receipt-facts">
@for (fact of view().facts; track fact.key) {
<div><dt>{{ fact.key }}</dt><dd>{{ fact.value }}</dd></div>
<div><dt>{{ fact.key }}</dt><dd><app-expandable-value [label]="fact.key" [value]="fact.value" /></dd></div>
}
</dl>
}
@@ -21,7 +21,7 @@ import { renderMarkdownToHtml } from '../../utils/markdown-parser.util';
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); }
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; }
.md-table-scroll { max-width: 100%; overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 12px; }
@@ -0,0 +1,115 @@
@if (enabled() && error()) {
<div class="extension-notice" role="status">{{ error() }} <button type="button" (click)="refresh()">重试</button></div>
}
<ng-template #headerControls>
@if (data()?.plugins?.length) {
<nav class="panel-toggles" aria-label="任务扩展面板">
@for (side of ['left', 'right']; track side) {
@for (plugin of data()!.plugins; track plugin.plugin_id) {
@for (panel of plugin.workspace.panels; track panel.id) {
@if (panel.side === side) {
<button type="button" class="panel-toggle" [class.active]="opened()[key(plugin, panel)]" [attr.aria-expanded]="!!opened()[key(plugin, panel)]"
[attr.aria-label]="(opened()[key(plugin, panel)] ? '收起' : '展开') + panel.title"
[title]="(opened()[key(plugin, panel)] ? '收起' : '展开') + panel.title"
[attr.aria-controls]="'extension-' + key(plugin, panel)" (click)="toggle(plugin, panel)">
<svg viewBox="0 0 20 20" aria-hidden="true"><rect x="2" y="3" width="16" height="14" rx="3" /><path [attr.d]="side === 'left' ? 'M7 3v14' : 'M13 3v14'" /></svg>
<span class="panel-toggle-label">{{ panel.title }}</span>
<span class="material-symbols-outlined" aria-hidden="true">{{ (side === 'left') !== !!opened()[key(plugin, panel)] ? 'chevron_left' : 'chevron_right' }}</span>
</button>
}
}
}
}
</nav>
}
</ng-template>
<div class="extension-layout" [class.has-left]="panels('left').length" [class.has-right]="panels('right').length"
[style.--left-width]="width('left') + 'px'" [style.--right-width]="width('right') + 'px'">
@if (panels('left').length) {
<aside class="panel-column left-column" aria-label="任务左侧扩展">
@for (item of panels('left'); track key(item.plugin, item.panel)) {
<ng-container *ngTemplateOutlet="panelContent; context: { plugin: item.plugin, panel: item.panel }" />
}
</aside>
}
<div class="player-content"><ng-content /></div>
@if (panels('right').length) {
<aside class="panel-column right-column" aria-label="任务右侧扩展">
@for (item of panels('right'); track key(item.plugin, item.panel)) {
<ng-container *ngTemplateOutlet="panelContent; context: { plugin: item.plugin, panel: item.panel }" />
}
</aside>
}
</div>
<ng-template #panelContent let-plugin="plugin" let-panel="panel">
<section [id]="'extension-' + key(plugin, panel)" [attr.aria-label]="panel.title">
<header><h3>{{ panel.kind === 'original_case' ? (data()?.case_title || panel.title) : panel.title }}</h3><span class="panel-kind">{{ panel.kind === 'original_case' ? '原始用例' : panel.kind === 'initial_prompt' ? '原始提示词' : '人工填写 · 选填' }}</span></header>
@if (panel.kind === 'original_case') {
@if (!data()?.original_case) {
<p class="muted">此任务未记录原始用例内容;旧任务不会从 AI 提示词推测补写。</p>
}
<div class="case-section"><h4>预置条件</h4><app-report-markdown [content]="data()?.original_case?.preconditions || '未填写'" /></div>
<div class="case-section"><h4>操作步骤</h4><app-report-markdown [content]="data()?.original_case?.steps || '未填写'" /></div>
<div class="case-section"><h4>预期结果</h4><app-report-markdown [content]="data()?.original_case?.expected || '未填写'" /></div>
} @else if (panel.kind === 'initial_prompt') {
@if (data()?.initial_prompt) {
<app-report-markdown [content]="data()!.initial_prompt" />
} @else { <p class="muted">该任务未记录初始需求提示词。</p> }
} @else {
@if (!data()?.editable) { <p class="muted">任务结束后可填写评审。</p> }
<fieldset [disabled]="!data()?.editable">
@for (field of plugin.workspace.fields; track field.id) {
<div class="field" [class.choice-field]="field.kind === 'choice'" [class.note-field]="field.kind === 'text'">
<span class="field-label">{{ field.label }}</span>
@if (field.description) { <span class="muted">{{ field.description }}</span> }
@if (field.kind === 'choice') {
<div class="choice-group" role="group" [attr.aria-label]="field.label">
@for (option of field.options; track option) {
<button type="button" class="choice-button" [class.selected]="drafts[plugin.plugin_id].values[field.id] === option"
[class.pass]="option === 'Pass' || option === '通过'" [class.failed]="option === 'Failed' || option === '失败'"
[attr.aria-pressed]="drafts[plugin.plugin_id].values[field.id] === option" title="再次点击已选项可清除"
(click)="choose(plugin.plugin_id, field.id, option)">
@if (option === 'Pass' || option === 'Failed' || option === '通过' || option === '失败') {
<svg class="choice-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path [attr.d]="option === 'Pass' || option === '通过' ? 'M5 12l4 4L19 6' : 'M6 6l12 12M18 6L6 18'" />
</svg>
}
<span class="choice-label">{{ option }}</span>
</button>
}
</div>
} @else {
<textarea rows="1" [attr.aria-label]="field.label" [maxLength]="field.max_length" placeholder="添加备注(选填)"
[ngModel]="drafts[plugin.plugin_id].values[field.id] || ''" (ngModelChange)="edit(plugin.plugin_id, field.id, $event)"></textarea>
}
</div>
}
<div class="form-actions">
<button type="button" class="save" [disabled]="!!saving()" (click)="save(plugin)">{{ saving() === plugin.plugin_id ? '正在保存…' : '保存评审' }}</button>
<button type="button" class="reload" [disabled]="!!saving()" (click)="reload(plugin)" title="放弃未保存内容,读取最新记录">重新加载</button>
<span class="muted unsaved">{{ saving() === plugin.plugin_id ? '正在保存…' : drafts[plugin.plugin_id].dirty ? '等待自动保存…' : '修改后自动保存' }}</span>
</div>
</fieldset>
<nav class="review-navigation" aria-label="切换评审任务">
<div class="navigation-buttons">
<button type="button" [disabled]="!navigation().previous || !!saving() || moving()" (click)="move('previous')"><span aria-hidden="true">‹</span>上一条</button>
<span class="review-position">{{ navigation().position }} / {{ navigation().total }}</span>
<button type="button" [disabled]="!navigation().next || !!saving() || moving()" (click)="move('next')">下一条<span aria-hidden="true">›</span></button>
</div>
<span class="muted">{{ navigation().scope === 'batch' ? '同批次已结束任务 · 按执行顺序' : '当前历史列表' }}</span>
@if (drafts[plugin.plugin_id].dirty) { <span class="muted">有待保存的修改,切换前会先保存。</span> }
</nav>
@if (notice()) { <p class="notice" role="status">{{ notice() }}</p> }
@if (plugin.history.length) {
<details class="history"><summary>修改记录({{ plugin.history.length }})</summary>
@for (entry of plugin.history; track entry.revision) {
<div class="history-entry"><strong>第 {{ entry.revision }} 次 · {{ entry.saved_at * 1000 | date:'yyyy-MM-dd HH:mm:ss' }}</strong>
@for (field of entry.fields; track field.id) { <p>{{ field.label }}:{{ entry.values[field.id] || '未填写' }}</p> }
</div>
}
</details>
}
}
</section>
</ng-template>
@@ -0,0 +1,60 @@
:host { display: block; width: 100%; min-width: 0; min-height: 0; overflow: auto; container-type: inline-size; color: var(--app-text-control, #1e293b); }
.panel-toggles { display: flex; gap: 4px; padding-right: 8px; margin-right: 4px; border-right: 1px solid var(--app-border, #e2e8f0); }
button { box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; gap: 5px; padding: 6px 9px; border: 1px solid var(--app-border-control, #dbe3ec); border-radius: 8px; color: inherit; background: var(--app-surface, #fff); font: inherit; font-size: 12px; line-height: 20px; cursor: pointer; }
.panel-toggle { height: 28px; padding: 4px 7px; border-color: transparent; background: transparent; color: var(--app-text-secondary, #64748b); gap: 4px; }
.panel-toggle:hover { background: var(--app-surface-soft, #f1f5f9); }
.panel-toggle.active { background: var(--app-primary-soft, #eff6ff); color: var(--app-primary-text, #1a73e8); }
.panel-toggle svg { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-width: 1.4; }
.panel-toggle .material-symbols-outlined { font-size: 15px; }
button.save { background: #1a73e8; border-color: #1a73e8; color: white; padding: 8px 16px; font-weight: 500; }
button:disabled { opacity: .6; cursor: default; }
button:focus-visible, textarea:focus-visible { outline: 2px solid var(--app-primary-text, #1a73e8); outline-offset: 2px; }
.material-symbols-outlined { font-size: 18px; }
.extension-layout { display: grid; grid-template-columns: minmax(0, 1fr); min-height: 0; }
.has-left { grid-template-columns: minmax(0, min(var(--left-width), 55%)) minmax(0, 1fr); }
.has-right { grid-template-columns: minmax(0, 1fr) minmax(0, min(var(--right-width), 55%)); }
.has-left.has-right { grid-template-columns: minmax(0, min(var(--left-width), 32%)) minmax(0, 1fr) minmax(0, min(var(--right-width), 32%)); }
.player-content { min-width: 0; overflow: hidden; }
.panel-column { min-width: 0; max-height: min(70dvh, 680px); overflow: auto; padding: 20px; user-select: text; background: var(--app-surface, #fff); scrollbar-width: thin; scrollbar-color: var(--app-border-control, #cbd5e1) transparent; }
.left-column { border-right: 1px solid var(--app-border, #e2e8f0); }
.right-column { border-left: 1px solid var(--app-border, #e2e8f0); }
section + section { border-top: 1px solid var(--app-border, #e2e8f0); padding-top: 12px; margin-top: 12px; }
header { display: flex; justify-content: space-between; align-items: flex-start; gap: 8px; margin-bottom: 14px; overflow-wrap: anywhere; }
h3 { margin: 0; font-size: 14px; color: var(--app-text-strong, #0f172a); }
.panel-kind { flex-shrink: 0; font-size: 11px; color: var(--app-text-muted, #64748b); }
.case-section { margin: 18px 0; }
.case-section h4 { margin: 0 0 8px; font-size: 13px; color: var(--app-text-strong, #0f172a); }
.review-navigation { margin: 12px 0 0; padding: 12px 0 0; background: var(--app-surface, #fff); border-top: 1px solid var(--app-border, #e2e8f0); }
.navigation-buttons { display: flex; align-items: center; justify-content: space-between; gap: 4px; margin-bottom: 4px; }
.navigation-buttons button { white-space: nowrap; }
.review-position { font-size: 11px; color: var(--app-text-muted, #64748b); white-space: nowrap; }
.muted { display: block; font-size: 12px; color: var(--app-text-secondary, #64748b); line-height: 1.6; }
fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
.field { display: grid; gap: 8px; margin: 0 0 14px; }
.choice-field:not(:first-child) { border-top: 1px solid var(--app-border, #e2e8f0); padding-top: 14px; margin-top: 14px; }
.field-label { font-size: 13px; font-weight: 600; }
.note-field .field-label { font-size: 11px; font-weight: 400; color: var(--app-text-muted, #64748b); }
.choice-group { display: flex; flex-wrap: wrap; gap: 4px; padding: 4px; margin-top: 4px; border: 1px solid var(--app-border, #e2e8f0); border-radius: 10px; background: var(--app-surface-soft, #f8fafc); }
.choice-button { flex: 1; min-height: 34px; border-color: transparent; background: transparent; color: var(--app-text-secondary, #64748b); font-size: 13px; transition: background .15s, color .15s; }
.choice-icon { display: block; flex: 0 0 16px; width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
.choice-label { display: block; line-height: 20px; }
.choice-button:hover { background: var(--app-surface-raised, #e2e8f0); }
.choice-button.selected { border-color: var(--app-primary-border, #93c5fd); background: var(--app-primary-soft, #eff6ff); color: var(--app-primary-text, #1a73e8); font-weight: 600; }
.choice-button.pass.selected { border-color: var(--app-success-border, #a7f3d0); background: var(--app-success-soft, #ecfdf5); color: var(--app-success-text, #047857); }
.choice-button.failed.selected { border-color: var(--app-error-border, #fecdd3); background: var(--app-error-soft, #fff1f2); color: var(--app-error-text, #be123c); }
textarea { box-sizing: border-box; width: 100%; min-width: 0; padding: 6px 10px; font: inherit; font-size: 12px; line-height: 1.6; color: inherit; border: 1px solid var(--app-border, #e2e8f0); border-radius: 8px; background: var(--app-surface-soft, #f8fafc); resize: vertical; min-height: 34px; max-height: 200px; }
textarea::placeholder { color: var(--app-text-muted, #94a3b8); }
.form-actions { display: grid; grid-template-columns: auto auto; justify-content: start; align-items: center; gap: 8px; margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--app-border, #e2e8f0); }
.form-actions button { height: 36px; padding: 0 16px; white-space: nowrap; }
.unsaved { grid-column: 1 / -1; }
.reload { border-color: transparent; background: transparent; color: var(--app-text-muted, #64748b); }
.notice, .extension-notice { font-size: 12px; padding: 8px; overflow-wrap: anywhere; line-height: 1.6; }
.history { margin-top: 18px; font-size: 12px; color: var(--app-text-secondary, #64748b); }
summary { cursor: pointer; }
.history-entry { border-top: 1px solid var(--app-border, #e2e8f0); padding: 10px 0; overflow-wrap: anywhere; white-space: pre-wrap; }
@container (max-width: 640px) {
.panel-toggle-label { display: none; }
.panel-toggles { padding-right: 4px; margin-right: 0; gap: 2px; }
.panel-toggle { padding: 4px; }
.panel-column { padding: 16px; }
}
@@ -0,0 +1,221 @@
import { TestBed, ComponentFixture, fakeAsync, flushMicrotasks, tick } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TaskExtensionPanelsComponent } from './task-extension-panels.component';
import { TaskExtensions } from '../../services/task-extensions.service';
function snapshot(sessionId = 'task-one'): TaskExtensions {
return {
session_id: sessionId, initial_prompt: '# 原始需求\n\n| 项目 | 要求 |\n| --- | --- |\n| 页面 | 可见 |', editable: true,
plugins: [{
plugin_id: 'review', name: '人工评审', version: '1.0.0', revision: 0, values: { accuracy: '', note: '', correctness: '' }, history: [],
workspace: {
panels: [
{ id: 'prompt', title: '任务需求', side: 'left', kind: 'initial_prompt', width: { minimum: 240, preferred: 360, maximum: 480 } },
{ id: 'review', title: '人工评审', side: 'right', kind: 'form', width: { minimum: 260, preferred: 380, maximum: 520 } }
],
fields: [
{ id: 'accuracy', label: 'AI执行准确性', description: '人工核对步骤', kind: 'choice', options: ['Pass', 'Failed'], max_length: 100, report: true },
{ id: 'note', label: '执行备注', description: '', kind: 'text', options: [], max_length: 2000, report: true },
{ id: 'correctness', label: '测试结果正确性', description: '', kind: 'choice', options: ['Pass', 'Failed'], max_length: 100, report: true }
]
}
}]
};
}
describe('步骤回放工作区扩展', () => {
let fixture: ComponentFixture<TaskExtensionPanelsComponent>;
let component: TaskExtensionPanelsComponent;
let http: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
fixture = TestBed.createComponent(TaskExtensionPanelsComponent);
component = fixture.componentInstance;
http = TestBed.inject(HttpTestingController);
});
afterEach(() => { http.verify(); fixture.destroy(); });
function open(data = snapshot()) {
fixture.componentRef.setInput('sessionId', data.session_id);
fixture.componentRef.setInput('enabled', true);
fixture.detectChanges();
http.expectOne(`/api/task-extensions/${data.session_id}`).flush(data);
fixture.detectChanges();
}
function toggle(side: 'left' | 'right') {
const plugin = component.data()!.plugins[0];
component.toggle(plugin, plugin.workspace.panels.find(panel => panel.side === side)!);
fixture.detectChanges();
}
it('does not request disabled extensions and shows no controls without a plugin', () => {
fixture.componentRef.setInput('sessionId', 'task-one');
fixture.detectChanges();
http.expectNone('/api/task-extensions/task-one');
open({ ...snapshot(), plugins: [] });
expect(fixture.nativeElement.querySelector('nav')).toBeNull();
});
it('starts collapsed, renders the full initial Markdown prompt and honors declared widths', () => {
open();
expect(fixture.nativeElement.querySelector('aside')).toBeNull();
toggle('left');
expect(fixture.nativeElement.querySelector('h1').textContent).toBe('原始需求');
expect(fixture.nativeElement.querySelector('table td').textContent).toBe('页面');
expect(component.width('left')).toBe(360);
expect(fixture.nativeElement.querySelector('.report-prose.compact')).toBeNull();
});
it('keeps either single panel beside playback below the former stacking breakpoint', () => {
open();
for (const width of [740, 600]) {
fixture.nativeElement.style.width = `${width}px`;
for (const side of ['left', 'right'] as const) {
toggle(side);
const panel = fixture.nativeElement.querySelector('aside').getBoundingClientRect();
const player = fixture.nativeElement.querySelector('.player-content').getBoundingClientRect();
const layout = fixture.nativeElement.querySelector('.extension-layout').getBoundingClientRect();
expect(panel.top).toBe(player.top);
expect(panel.width).toBeGreaterThan(0);
expect(player.width).toBeGreaterThan(0);
expect(side === 'left' ? panel.right : player.right).toBeCloseTo(side === 'left' ? player.left : panel.left, 0);
expect(Math.max(panel.right, player.right)).toBeLessThanOrEqual(layout.right + 1);
toggle(side);
}
}
});
it('automatically saves choices and clears while keeping unfilled outcomes blank', fakeAsync(() => {
open(); toggle('right');
const pass: HTMLButtonElement = fixture.nativeElement.querySelector('.choice-button.pass');
pass.click(); fixture.detectChanges();
expect(saved().values).toEqual({ accuracy: 'Pass', note: '', correctness: '' });
pass.click(); fixture.detectChanges(); expect(saved(2).values.accuracy).toBe('');
expect(fixture.nativeElement.textContent).toContain('人工评审已自动保存');
}));
function saved(revision = 1, id = 'task-one') {
const request = http.expectOne(`/api/task-extensions/${id}/review`);
expect(request.request.method).toBe('PUT');
const body = request.request.body, data = snapshot(id);
data.plugins[0].revision = revision; data.plugins[0].values = body.values;
request.flush(data); flushMicrotasks(); fixture.detectChanges(); return body;
}
it('debounces notes and never posts just from opening a task', fakeAsync(() => {
open(); toggle('right'); tick(700); http.expectNone('/api/task-extensions/task-one/review');
component.edit('review', 'note', '第一段'); tick(400);
component.edit('review', 'note', '完整备注'); tick(599);
http.expectNone('/api/task-extensions/task-one/review'); tick(1);
expect(saved().values.note).toBe('完整备注');
}));
it('does not overwrite an automatic save with an older read response', fakeAsync(() => {
open(); component.edit('review', 'note', '已保存的新内容'); tick(600); saved(2);
component.refresh(); fixture.detectChanges();
http.expectOne('/api/task-extensions/task-one').flush(snapshot()); fixture.detectChanges();
expect(component.drafts['review'].values['note']).toBe('已保存的新内容');
expect(component.drafts['review'].revision).toBe(2);
}));
it('flushes a pending note when the review panel is closed', fakeAsync(() => {
open(); component.edit('review', 'note', '关闭前保存'); fixture.destroy();
const request = http.expectOne('/api/task-extensions/task-one/review');
expect(request.request.body.values.note).toBe('关闭前保存');
const response = snapshot(); response.plugins[0].revision = 1; response.plugins[0].values = request.request.body.values;
request.flush(response); flushMicrotasks();
}));
it('preserves draft and request receipt on failure, and retries only when requested', fakeAsync(() => {
open(); toggle('right'); const plugin = component.data()!.plugins[0];
component.edit('review', 'accuracy', 'Failed'); void component.save(plugin);
const first = http.expectOne('/api/task-extensions/task-one/review'), requestId = first.request.body.request_id;
first.flush({}, { status: 503, statusText: 'Unavailable' }); flushMicrotasks(); tick(700);
http.expectNone('/api/task-extensions/task-one/review'); void component.save(plugin);
const retry = http.expectOne('/api/task-extensions/task-one/review'); expect(retry.request.body.request_id).toBe(requestId);
retry.flush({}, { status: 409, statusText: 'Conflict' }); flushMicrotasks();
expect(component.drafts['review'].values['accuracy']).toBe('Failed');
component.edit('review', 'accuracy', ''); void component.save(plugin);
const clear = saved(); expect(clear.values.accuracy).toBe(''); expect(clear.request_id).not.toBe(requestId);
}));
it('serializes rapid edits and saves the latest choice using the returned revision', fakeAsync(() => {
const data = snapshot(); data.plugins[0].workspace.fields[0].options = ['通过', '失败']; open(data); toggle('right');
const pass: HTMLButtonElement = fixture.nativeElement.querySelector('.choice-button.pass');
const failed: HTMLButtonElement = fixture.nativeElement.querySelector('.choice-button.failed');
pass.click(); failed.click(); fixture.detectChanges();
expect(component.drafts['review'].values['accuracy']).toBe('失败'); expect(saved().values.accuracy).toBe('通过');
const second = saved(2); expect(second.values.accuracy).toBe('失败'); expect(second.expected_revision).toBe(1);
expect(component.drafts['review'].dirty).toBeFalse();
}));
it('does not apply a delayed save response to the task now displayed', fakeAsync(() => {
open(); toggle('right'); component.edit('review', 'note', '只属于第一个任务'); void component.save(component.data()!.plugins[0]);
const oldSave = http.expectOne('/api/task-extensions/task-one/review');
fixture.componentRef.setInput('sessionId', 'task-two'); fixture.detectChanges();
http.expectOne('/api/task-extensions/task-two').flush(snapshot('task-two')); fixture.detectChanges();
oldSave.flush(snapshot('task-one')); flushMicrotasks(); fixture.detectChanges();
expect(component.data()!.session_id).toBe('task-two'); expect(component.drafts['review'].values['note']).toBe('');
}));
it('flushes a pending note on direct task switches and hides disabled plugins', fakeAsync(() => {
open(); toggle('right'); component.edit('review', 'note', '自动保存草稿');
fixture.componentRef.setInput('sessionId', 'task-two'); fixture.detectChanges();
http.expectOne('/api/task-extensions/task-two').flush(snapshot('task-two'));
const body = saved(1, 'task-one'); expect(body.values.note).toBe('自动保存草稿');
fixture.componentRef.setInput('sessionId', 'task-one'); fixture.detectChanges();
const first = snapshot(); first.plugins[0].revision = 1; first.plugins[0].values = body.values;
http.expectOne('/api/task-extensions/task-one').flush(first); fixture.detectChanges();
expect(component.drafts['review'].values['note']).toBe('自动保存草稿');
window.dispatchEvent(new Event('artemis-plugin-change')); fixture.detectChanges();
http.expectOne('/api/task-extensions/task-one').flush({ ...first, plugins: [] }); fixture.detectChanges();
expect(fixture.nativeElement.querySelector('aside')).toBeNull();
}));
it('keeps running tasks read-only then enables editing when the task finishes', () => {
open({ ...snapshot(), editable: false }); toggle('right');
expect(fixture.nativeElement.querySelector('fieldset').disabled).toBeTrue();
component.save(component.data()!.plugins[0]); http.expectNone('/api/task-extensions/task-one/review');
fixture.componentRef.setInput('taskStatus', 'completed'); fixture.detectChanges();
http.expectOne('/api/task-extensions/task-one').flush(snapshot()); fixture.detectChanges();
expect(fixture.nativeElement.querySelector('fieldset').disabled).toBeFalse();
});
it('collapses the opposite panel on narrow screens', () => {
const original = Object.getOwnPropertyDescriptor(window, 'innerWidth')!;
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 600 });
try {
open(); toggle('left'); toggle('right');
expect(component.panels('left').length).toBe(0);
expect(component.panels('right').length).toBe(1);
} finally { Object.defineProperty(window, 'innerWidth', original); }
});
it('shows original case sections in order without substituting the AI prompt', () => {
const data = snapshot();
data.plugins[0].workspace.panels[0].kind = 'original_case';
data.case_title = 'CASE-001 检查设置';
data.original_case = { preconditions: '已登录', steps: '1. 打开设置\n2. 核对版本', expected: '版本可见' };
open(data); toggle('left');
const text = fixture.nativeElement.textContent;
expect(text).toContain('CASE-001 检查设置');
expect(text).toMatch(/预置条件[\s\S]*已登录[\s\S]*操作步骤[\s\S]*核对版本[\s\S]*预期结果[\s\S]*版本可见/);
expect(text).not.toContain('原始需求');
});
it('saves before navigation and keeps the current task on save failure', fakeAsync(() => {
const first = snapshot(); first.navigation = { scope: 'batch', position: 1, total: 2, previous: null, next: 'task-two' };
open(first); toggle('right'); const emitted = jasmine.createSpy('navigate'); component.navigate.subscribe(emitted);
const saveButton = fixture.nativeElement.querySelector('button.save'), navigation = fixture.nativeElement.querySelector('.review-navigation');
expect(saveButton.compareDocumentPosition(navigation) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
component.edit('review', 'note', '切换前保存'); void component.move('next'); expect(emitted).not.toHaveBeenCalled();
http.expectOne('/api/task-extensions/task-one/review').flush({}, {status: 409, statusText: 'Conflict'});
flushMicrotasks(); expect(emitted).not.toHaveBeenCalled(); expect(component.drafts['review'].dirty).toBeTrue();
void component.move('next'); saved(); flushMicrotasks(); expect(emitted).toHaveBeenCalledOnceWith('task-two');
fixture.componentRef.setInput('sessionId', 'task-two'); fixture.detectChanges();
http.expectOne('/api/task-extensions/task-two').flush(snapshot('task-two')); fixture.detectChanges();
expect(component.panels('right').length).toBe(1);
}));
});
@@ -0,0 +1,201 @@
import { Component, ChangeDetectionStrategy, HostListener, TemplateRef, DestroyRef, computed, effect, inject, input, output, signal, viewChild } from '@angular/core';
import { DatePipe, NgTemplateOutlet } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { firstValueFrom } from 'rxjs';
import { ReportMarkdownComponent } from '../report-markdown/report-markdown.component';
import { ExtensionDraft, ExtensionPanel, TaskExtension, TaskExtensions, TaskExtensionsService } from '../../services/task-extensions.service';
@Component({
selector: 'app-task-extension-panels', standalone: true,
imports: [FormsModule, NgTemplateOutlet, DatePipe, ReportMarkdownComponent],
templateUrl: './task-extension-panels.component.html',
styleUrl: './task-extension-panels.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TaskExtensionPanelsComponent {
private service = inject(TaskExtensionsService);
sessionId = input<string | null>(null);
enabled = input(false);
taskStatus = input('');
sessionIds = input<string[]>([]);
navigate = output<string>();
navigation = computed(() => {
if (this.data()?.navigation) return this.data()!.navigation!;
const ids = this.sessionIds(), index = ids.indexOf(this.sessionId() || '');
return { scope: 'history', position: index + 1, total: ids.length,
previous: index > 0 ? ids[index - 1] : null,
next: index >= 0 && index < ids.length - 1 ? ids[index + 1] : null };
});
expandedWidth = output<number>();
headerControls = viewChild<TemplateRef<unknown>>('headerControls');
data = signal<TaskExtensions | null>(null);
error = signal('');
private destroyRef = inject(DestroyRef);
private savingKeys = signal<string[]>([]);
saving = computed(() => this.savingKeys().find(key => key.startsWith(`${this.sessionId()}/`))?.split('/')[1] || null);
moving = signal(false);
private scheduled = new Map<string, { timer: ReturnType<typeof setTimeout>; run: () => Promise<boolean> }>();
private inFlight = new Map<string, Promise<boolean>>();
notice = signal('');
opened = signal<Record<string, boolean>>({});
private refreshToken = signal(0);
private identity = '';
private generation = 0;
drafts: Record<string, ExtensionDraft> = {};
constructor() {
this.destroyRef.onDestroy(() => this.flushScheduled());
effect(onCleanup => {
const id = this.sessionId();
const enabled = this.enabled();
this.refreshToken();
this.taskStatus();
this.generation++;
const identity = enabled ? id || '' : '';
if (identity !== this.identity) {
this.flushScheduled();
this.identity = identity;
this.data.set(null);
if (!identity) this.opened.set({});
this.expandedWidth.emit(0);
}
this.error.set('');
this.notice.set('');
if (!id || !enabled) return;
const subscription = this.service.read(id).subscribe({
next: data => {
this.drafts = Object.fromEntries(data.plugins.map(plugin => [plugin.plugin_id, this.service.draft(id, plugin)]));
this.data.set(data);
this.expandedWidth.emit(this.width('left') + this.width('right'));
},
error: () => this.error.set('暂时无法加载任务扩展,请重试')
});
onCleanup(() => subscription.unsubscribe());
});
}
@HostListener('window:artemis-plugin-change')
refresh(): void { this.refreshToken.update(value => value + 1); }
async move(direction: 'previous' | 'next'): Promise<void> {
const destination = this.navigation()[direction], id = this.sessionId();
if (!destination || !id || !this.data() || this.moving()) return;
this.moving.set(true);
try {
for (const plugin of this.data()!.plugins) {
if (this.drafts[plugin.plugin_id]?.dirty && !await this.save(plugin)) return;
}
if (id === this.sessionId() && !this.destroyRef.destroyed) this.navigate.emit(destination);
} finally { this.moving.set(false); }
}
private cancelScheduled(key: string): void {
const pending = this.scheduled.get(key);
if (pending) clearTimeout(pending.timer);
this.scheduled.delete(key);
}
private flushScheduled(): void {
for (const pending of [...this.scheduled.values()]) void pending.run();
}
key(plugin: TaskExtension, panel: ExtensionPanel): string { return `${plugin.plugin_id}/${panel.id}`; }
panels(side: 'left' | 'right') {
return (this.data()?.plugins || []).flatMap(plugin => plugin.workspace.panels
.filter(panel => panel.side === side && this.opened()[this.key(plugin, panel)])
.map(panel => ({ plugin, panel })));
}
width(side: 'left' | 'right'): number {
return Math.max(0, ...this.panels(side).map(({ panel }) =>
Math.max(panel.width.minimum, Math.min(panel.width.preferred, panel.width.maximum))));
}
toggle(plugin: TaskExtension, panel: ExtensionPanel): void {
const key = this.key(plugin, panel);
const next = { ...this.opened(), [key]: !this.opened()[key] };
if (next[key] && window.innerWidth < 1000) {
for (const other of this.data()?.plugins || []) {
for (const sibling of other.workspace.panels) {
if (sibling.side !== panel.side) next[this.key(other, sibling)] = false;
}
}
}
this.opened.set(next);
this.expandedWidth.emit(this.width('left') + this.width('right'));
}
@HostListener('window:resize')
resize(): void {
if (window.innerWidth < 1000 && this.panels('left').length && this.panels('right').length) {
const next = { ...this.opened() };
for (const { plugin, panel } of this.panels('left')) next[this.key(plugin, panel)] = false;
this.opened.set(next);
this.expandedWidth.emit(this.width('left') + this.width('right'));
}
}
edit(pluginId: string, field: string, value: string): void {
const id = this.sessionId(), draft = this.drafts[pluginId];
if (!id || !draft || !this.data()?.editable || draft.values[field] === value) return;
draft.values[field] = value;
draft.dirty = true;
draft.requestId = undefined;
this.notice.set('');
const key = `${id}/${pluginId}`;
this.cancelScheduled(key);
const run = () => this.saveFor(id, pluginId, draft);
this.scheduled.set(key, { run, timer: setTimeout(() => void run(), 600) });
}
choose(pluginId: string, field: string, value: string): void {
this.edit(pluginId, field, this.drafts[pluginId].values[field] === value ? '' : value);
const plugin = this.data()?.plugins.find(item => item.plugin_id === pluginId);
if (plugin) void this.save(plugin);
}
reload(plugin: TaskExtension): void {
const id = this.sessionId();
if (!id) return;
this.cancelScheduled(`${id}/${plugin.plugin_id}`);
this.service.discard(id, plugin.plugin_id);
this.refresh();
}
save(plugin: TaskExtension): Promise<boolean> {
const id = this.sessionId();
if (!id || !this.data()?.editable) return Promise.resolve(false);
return this.saveFor(id, plugin.plugin_id, this.drafts[plugin.plugin_id]);
}
private async saveFor(id: string, pluginId: string, draft: ExtensionDraft): Promise<boolean> {
const key = `${id}/${pluginId}`;
this.cancelScheduled(key);
const pending = this.inFlight.get(key);
if (pending) {
if (!await pending) return false;
return draft.dirty ? this.saveFor(id, pluginId, draft) : true;
}
if (!draft.dirty) return true;
const submitted = { ...draft.values }, generation = this.generation;
const visible = () => !this.destroyRef.destroyed && id === this.sessionId() && generation === this.generation;
this.savingKeys.update(keys => [...keys, key]);
const request = firstValueFrom(this.service.save(id, pluginId, draft)).then(data => {
const saved = data.plugins.find(plugin => plugin.plugin_id === pluginId);
if (!saved) throw new Error('未收到保存回执');
draft.revision = saved.revision;
draft.dirty = JSON.stringify(draft.values) !== JSON.stringify(submitted);
draft.requestId = undefined;
if (!draft.dirty) draft.values = { ...saved.values };
if (visible()) {
this.data.set(data);
this.notice.set(draft.dirty ? '正在保存后续修改…' : '人工评审已自动保存');
}
return true;
}).catch(error => {
this.cancelScheduled(key);
if (visible()) this.notice.set(typeof error.error?.detail === 'string'
? `保存失败:${error.error.detail};输入已保留`
: '保存失败,输入已保留,请点击保存评审重试');
return false;
}).finally(() => {
this.inFlight.delete(key);
this.savingKeys.update(keys => keys.filter(item => item !== key));
});
this.inFlight.set(key, request);
const succeeded = await request;
return succeeded && draft.dirty ? this.saveFor(id, pluginId, draft) : succeeded;
}
}
@@ -18,6 +18,28 @@ describe('历史任务检索', () => {
});
afterEach(() => http.verify());
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('已终止探索可删除,确认范围后刷新,取消不发请求', () => {
const fixture = TestBed.createComponent(TaskHistoryComponent);
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.textContent).toContain('1.1 打开并核对设置');
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('失败');
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 { FavoriteButtonComponent } from '../favorite-button/favorite-button.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';
@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>
<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>
@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(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>}
@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>
@@ -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-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)}
.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 .material-symbols-outlined{font-size:16px}
.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} 个` : '本次停用';
}
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')));
}
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()) {
<div class="exploration-settings-body">
<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">
<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()" />
@@ -10,7 +10,7 @@ import { TaskRecommendationService } from '../../core/services/task-recommendati
describe('Homepage exploration entry',()=>{
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(()=>{
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)};
@@ -1,5 +1,5 @@
<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>
@if (error()) { <div class="message error" role="alert">{{error()}}</div> }
@if (notice()) { <div class="message success" role="status">{{notice()}}</div> }
@@ -1,10 +1,12 @@
import { Injectable, inject } from '@angular/core';
import { tap } from 'rxjs';
import { HttpClient } from '@angular/common/http';
export interface PluginManifest {
id: string; name: string; version: string; description: string;
artemis_version: string; platforms: string[]; source: string;
config: Record<string, { label: string; description: string; secret: boolean; required: boolean; default: string }>;
workspace?: unknown; server?: unknown; skill?: string;
resources: string[];
targets: ('android' | 'computer' | 'mixed')[];
}
@@ -28,13 +30,13 @@ export class PluginService {
install(payload: { plugin_id?: string; version?: string; local_path?: string }) {
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) {
const { configured_secrets, ...payload } = profile;
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 }); }
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 }); }
}
@@ -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
});
}
}
+20 -7
View File
@@ -96,6 +96,7 @@ from artemis.services.llm import (
get_openai_llm,
get_llm,
invoke_llm_with_timeout_message,
with_fallback,
)
from artemis.tools.history import history_tool_declarations
from artemis.tools.android_files import android_file
@@ -780,7 +781,7 @@ class FlashRunner:
action_dict["normalized_start_coordinates"] = norm_start
action_dict["normalized_end_coordinates"] = norm_end
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:
action_dict[key] = metadata[key]
if metadata.get("plugin_id"):
@@ -1506,20 +1507,32 @@ class FlashRunner:
turn_base = len(messages) - 1
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):
interrupted, response = await control.run_interruptibly(
self._invoke_model(
fallback_llm if using_tool_fallback else llm, current_tools, messages
),
invoke_turn(),
on_pause=getattr(self.ctx, "on_task_pause", None),
on_resume=getattr(self.ctx, "on_task_resume", None),
)
else:
interrupted, response = (
False,
await self._invoke_model(
fallback_llm if using_tool_fallback else llm, current_tools, messages
),
await invoke_turn(),
)
if interrupted or (
+1
View File
@@ -412,6 +412,7 @@ class VisualStepSummarizer(StepMemoryService):
engine,
response,
source=f"lens:visual_transition:{self._model_name}",
model=self._model_name,
update_last_prompt=False,
)
+13 -3
View File
@@ -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.structured import content_to_text
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 (
analyze_task_output_wrapper,
get_adb_task_registry,
@@ -638,9 +638,19 @@ class OperatorNode:
and t.name not in {"plugin_load", "plugin_read_file", "plugin_read_result"}
]
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(
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:
return None, None, None, False
+13 -2
View File
@@ -303,8 +303,19 @@ class PlannerNode:
screen = await UnifiedMobileController(self.ctx).get_screen_data()
human_message_content.append({"type": "text", "text": screen.observation_text})
plugins = getattr(self.ctx, "plugins", None)
if plugins:
human_message_content.append({"type": "text", "text": plugins.prompt()})
plugin_catalog = plugins.prompt() if plugins else ""
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:
human_message_content.append(
{
@@ -92,6 +92,11 @@ async def exec_action(
return False, str(e)
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)
except Exception as e:
action_item.update(execution="unknown", side_effect="possible")
+1 -1
View File
@@ -8,7 +8,7 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator
class ExplorationConfig(BaseModel):
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[
"default", "none", "minimal", "low", "medium", "high", "xhigh", "max"
] = "high"
+3 -1
View File
@@ -87,7 +87,7 @@ class Settings(BaseSettings):
"""Centralized ARTEMIS runtime settings loaded from environment and .env files."""
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_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_MODEL: str = Field(default=DEFAULT_MODEL, min_length=1)
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_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)
+98 -4
View File
@@ -8,6 +8,58 @@ import json
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:
value = step.get("step_id")
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:
sources = {}
unattributed = []
@@ -23,7 +88,23 @@ def recorded_sources(steps: list[dict], *, limit: int = 100) -> dict:
result = step.get("last_execution_result")
execution = result.get("execution") if isinstance(result, dict) else None
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
missing = False
for record in records:
@@ -39,6 +120,7 @@ def recorded_sources(steps: list[dict], *, limit: int = 100) -> dict:
continue
found = True
plugin = str(record.get("plugin_id") or "")[:100]
label = str(source.get("label") or "")[:500]
key = (plugin, kind, target[:256])
if key not in sources:
sources[key] = {
@@ -46,18 +128,30 @@ def recorded_sources(steps: list[dict], *, limit: int = 100) -> dict:
"kind": kind,
"plugin_id": plugin,
"target_id": target[:256],
"label": str(source.get("label") or "")[:500],
"label": label,
"category": "interface" if kind in {"android", "computer"} else "auxiliary",
"references": [],
"reference_count": 0,
}
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]:
reference[field] = record[field][:256]
reference[field] = record[field][: 2048 if field == "artifact_path" else 256]
if isinstance(record.get("read_only"), bool):
reference["read_only"] = record["read_only"]
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
if len(entry["references"]) < 8:
entry["references"].append(reference)
+8
View File
@@ -164,6 +164,7 @@ async def execute_task(
config.with_graph_config_callbacks(graph_config_callbacks)
agent: Agent | None = None
task_started = False
try:
agent = Agent(config=config.build(), session_id=effective_sid)
await agent.init(
@@ -196,7 +197,14 @@ async def execute_task(
request.plugins = plugins
elif plugin_choices is not None:
request.plugins = TypeAdapter(list[PluginSelection]).validate_json(plugin_choices)
task_started = True
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:
if agent is not None:
await agent.clean()
+44 -9
View File
@@ -15,6 +15,7 @@
"""UI Launch and Web Console CLI Command (artemis ui)."""
import logging
import os
from pathlib import Path
import re
import shutil
@@ -34,6 +35,11 @@ import typer
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:
"""Return True when this CLI was launched through a console-script ``.exe`` shim.
@@ -308,7 +314,12 @@ def ui_command(
)
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()
stop_server(port=port, timeout=12.0)
elif is_port_in_use(port):
@@ -316,19 +327,39 @@ def ui_command(
pid_str = f" (PID: {', '.join(map(str, pids))})" if pids else ""
if sys.stdin.isatty():
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(" [bold]1[/bold] Open browser (default)")
console.print(" [bold]2[/bold] Restart server (stop existing and start here)")
console.print(" [bold]3[/bold] Cancel")
)
console.print(
_launch_text(" [dim]Choose an action:[/dim]", " [dim]请选择操作:[/dim]")
)
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:
choice = input(" Select [1-3, default 1]: ").strip()
choice = input(
_launch_text(" Select [1-3, default 1]: ", " 请输入选项 [1-3,默认 1]:")
).strip()
except (KeyboardInterrupt, EOFError):
choice = "1"
if choice == "2":
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()
stop_server(port=port, timeout=12.0)
@@ -340,8 +371,12 @@ def ui_command(
return
else:
console.print(
_launch_text(
f"\n [yellow]⚠ Port {port} is already in use by Artemis{pid_str}. "
f"Use '[bold]artemis restart[/bold]' to restart.[/yellow]\n"
"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:
webbrowser.open(local_url)
+53
View File
@@ -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,
}
+34 -9
View File
@@ -13,6 +13,7 @@ from uuid import uuid4
from PIL import Image
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.reliability import LLMExhaustedError
from artemis.llm.usage import usage_from_message
@@ -107,6 +108,7 @@ class RequestAttempt:
self.clock = time.monotonic()
self.last_publish = self.clock
self.tools: dict[int | str, dict] = {}
self.tool_fingerprints: dict[int | str, ArgumentFingerprint] = {}
self.review = ACTIVE_FINAL_REVIEW.get()
if self.review:
self.review.request_started(request_id, attempt)
@@ -126,6 +128,9 @@ class RequestAttempt:
"usage": None,
"output_chunks": 0,
"tool_argument_chars": 0,
"tool_observation_layer": "langchain_tool_call_chunks",
"argument_fingerprint_version": 1,
"tool_stats_truncated": False,
"tools": [],
"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,
@@ -167,9 +172,10 @@ class RequestAttempt:
self.data.update(
headers_at=time.time(),
http_status=response.status_code,
server_request_id=_identifier(
response.headers.get("x-request-id") or response.headers.get("request-id")
),
server_request_id=_identifier(response.headers.get("x-oneapi-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")),
phase="waiting_output",
)
@@ -196,18 +202,31 @@ class RequestAttempt:
self.data["usage"] = usage
tool_chunks = getattr(chunk, "tool_call_chunks", None) or []
# 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:
arguments = tool.get("args")
if isinstance(arguments, str):
self.data["tool_argument_chars"] += len(arguments)
index = tool.get("index")
key = index if index is not None else tool.get("id") or "unknown"
stats = self.tools.setdefault(key, {"name": None, "argument_chars": 0})
call_id = _identifier(tool.get("id"))
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")
if isinstance(name, str) and re.fullmatch(r"[\w.:-]{1,128}", 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):
stats["argument_chars"] += len(arguments)
self.data["tool_argument_chars"] += len(arguments)
fingerprint.observe(arguments)
stats.update(fingerprint.snapshot())
self.data["tools"] = list(self.tools.values())
content = getattr(chunk, "content", None)
substantive = isinstance(content, str) and bool(content)
@@ -252,6 +271,8 @@ class RequestAttempt:
if isinstance(status, int):
self.data["http_status"] = status
if request_id := _identifier(getattr(error, "request_id", None)):
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":
self.data.setdefault("timeout_scope", "transport")
@@ -301,6 +322,7 @@ async def observe_request_headers(request):
if attempt is None:
return
request.headers.setdefault("X-Client-Request-Id", attempt.data["request_id"])
request.headers.setdefault("X-Client-Attempt-Id", attempt.data["attempt_id"])
routing = {}
for name in ("thread-id", "thread_id", "session-id", "session_id"):
value = request.headers.get(name)
@@ -309,9 +331,12 @@ async def observe_request_headers(request):
attempt.data.update(
request_sent_at=time.time(),
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,
client_request_id_matches=request.headers.get("X-Client-Request-Id")
== attempt.data["request_id"],
client_attempt_id_matches=request.headers.get("X-Client-Attempt-Id")
== attempt.data["attempt_id"],
)
attempt.publish()
+15
View File
@@ -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),
}
+7 -1
View File
@@ -30,6 +30,7 @@ from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
import random
import re
from functools import lru_cache
import threading
import time
@@ -124,6 +125,11 @@ def classify_failure(error: BaseException) -> Failure:
# Programmer/request errors: retrying is useless and falling back only
# hides the bug inside a different model's output.
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)
@@ -147,7 +153,7 @@ class LLMPermanentError(LLMCallError):
class LLMExhaustedError(LLMCallError):
"""Retryable failures exhausted every attempt and the pause deadline."""
"""Retryable failures exhausted attempts and any applicable pause deadline."""
# ---------------------------------------------------------------------------
+9 -1
View File
@@ -62,6 +62,7 @@ class ModelEndpoint(BaseModel):
self.reasoning_effort,
self.enable_grounding,
self.api_base or settings.OPENAI_BASE_URL,
settings.OPENAI_USE_SYSTEM_PROXY,
hashlib.sha256(key.encode()).hexdigest(),
)
@@ -87,9 +88,10 @@ class ModelFactory:
)
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.http import model_http_options
api_key = endpoint.api_key or (
settings.OPENAI_API_KEY.get_secret_value() if settings.OPENAI_API_KEY else None
@@ -112,8 +114,14 @@ class ModelFactory:
"use_responses_api": True,
"output_version": "responses/v1",
"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(
timeout=endpoint.timeout_seconds,
**model_http_options(),
event_hooks={
"request": [observe_request_headers],
"response": [observe_response_headers],
+1 -1
View File
@@ -23,7 +23,7 @@ class ToolArgumentOverflow(ValueError):
"limit_chars": SMALL_ACTION_ARGUMENT_LIMIT,
}
super().__init__(
f"模型生成的 {name} 参数异常膨胀({characters} 字符,"
f"收到的 {name} 参数流超出安全限制({characters} 字符,"
f"保护上限 {SMALL_ACTION_ARGUMENT_LIMIT});已关闭输出流,未执行本次动作。"
)
+9
View File
@@ -280,6 +280,11 @@ class McpActionExecutor:
) -> ToolExecutionResult:
span = TraceSpan(name=raw_name, trace_type="action", ctx=self.ctx)
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 = ""
res: ActionResult | None = None
@@ -291,6 +296,7 @@ class McpActionExecutor:
observation_settled: bool | None = None
input_verification: str | None = None
expected_input: str | None = None
dispatched = False
with span:
try:
session = await self._session_or_start()
@@ -315,6 +321,7 @@ class McpActionExecutor:
or observation_anchor(state, index_elements)
)
extension = raw_name not in (REQUIRED_ACTIONS | OPTIONAL_ACTIONS)
dispatched = True
if extension:
raw_result = await session.call_raw(wire_name, wire_args)
message = next(
@@ -386,6 +393,7 @@ class McpActionExecutor:
"post_image_name": post_image_name,
"has_xml": bool(xml_list),
"status": status,
**({"source": source} if source and dispatched else {}),
}
if status == "error":
span.status = "failed"
@@ -400,6 +408,7 @@ class McpActionExecutor:
screenshot_path=shot_path,
ui_elements_text=xml_list,
metadata={
**({"source": source} if source and dispatched else {}),
"code": res.code.value,
"normalized_coordinates": res.normalized_coordinates,
"target_semantics": target_semantics,
+1
View File
@@ -532,6 +532,7 @@ class StepCapsuleLens(StepLens):
engine,
response,
source=f"lens:step_capsule:{model_label}",
model=model_label if model_label != "fallback" else None,
update_last_prompt=False,
)
except Exception as exc:
+57
View File
@@ -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()
+40
View File
@@ -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()
+7 -1
View File
@@ -43,7 +43,13 @@ def download(url: str, progress: Callable[[str, int], None]) -> bytes:
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:
raise ValueError("市场索引过大")
return PluginCatalog.model_validate_json(data)
+266
View File
@@ -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
+9 -4
View File
@@ -12,6 +12,8 @@ from packaging.specifiers import SpecifierSet
from packaging.version import Version
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}$")]
@@ -94,7 +96,7 @@ class EvidenceSource(PluginModel):
class PluginManifest(PluginModel):
schema_version: Literal[1, 2] = 1
schema_version: Literal[1, 2, 3] = 1
id: Identifier
name: str = Field(min_length=1, max_length=100)
version: str
@@ -113,6 +115,7 @@ class PluginManifest(PluginModel):
targets: list[ExecutionTarget] = Field(default_factory=lambda: ["mixed"])
observation: PluginObservation | None = None
capabilities: PluginCapabilities | None = None
workspace: WorkspaceContribution | None = None
@field_validator("version")
@classmethod
@@ -130,14 +133,16 @@ class PluginManifest(PluginModel):
@model_validator(mode="after")
def has_capability(self):
if self.server is None and self.skill is None:
raise ValueError("插件至少需要 MCP 服务或 Skill")
if self.server is None and self.skill is None and self.workspace is None:
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():
if field.secret and field.default:
raise ValueError(f"密钥字段 {name} 不允许默认值")
if self.schema_version == 1 and self.capabilities is not None:
raise ValueError("能力契约需要 schema_version=2;旧清单保持兼容")
if self.schema_version == 2:
if self.schema_version >= 2:
if self.capabilities is None:
raise ValueError("第二版插件必须声明 capabilities")
if self.observation:
+39 -2
View File
@@ -4,8 +4,8 @@ import ctypes
from ctypes import wintypes
class CaptureProcessJob:
"""A non-inheritable job handle: Windows kills captures when its owner exits."""
class ProcessJob:
"""A non-inheritable job handle that owns a Windows process tree."""
def __init__(self):
class BasicLimits(ctypes.Structure):
@@ -54,6 +54,12 @@ class CaptureProcessJob:
),
("OpenProcess", [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD], wintypes.HANDLE),
("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),
):
function = getattr(self._api, name)
@@ -84,3 +90,34 @@ class CaptureProcessJob:
if self._handle:
self._api.CloseHandle(self._handle)
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
+347
View File
@@ -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()))
+212
View File
@@ -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()
+5
View File
@@ -105,6 +105,7 @@ class PluginStore:
if not existing:
profile = PluginProfile(
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},
)
db.execute(
@@ -236,6 +237,8 @@ class PluginStore:
def selections(self, target: str = "mixed") -> list[PluginSelection]:
selections = []
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(
plugin["manifest"].get("targets", ["mixed"]), target
):
@@ -264,6 +267,8 @@ class PluginStore:
pinned = []
for choice in self.selections(target) if selections is None else selections:
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):
raise ValueError(f"插件 {manifest.name} 不支持 {target} 任务")
pinned.append(
+157
View File
@@ -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()
+68
View File
@@ -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
+1 -1
View File
@@ -35,7 +35,7 @@
},
"agent": {
"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}",
"timeout_seconds": "${env:ARTEMIS_EXPLORATION_TIMEOUT_SECONDS:-1800}",
"demonstration_timeout_seconds": "${env:ARTEMIS_DEMONSTRATION_TIMEOUT_SECONDS:-900}",
+16 -1
View File
@@ -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
# must not wait on another process's optional legacy wake-lock cleanup.
if os.environ.get("ARTEMIS_TASK_WORKER") != "1":
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 = (
(
@@ -209,6 +212,7 @@ class ScreenAwakeService:
self._monitor_thread: threading.Thread | None = None
self._shutdown_requested = False
self._owners: dict[str, FileLock] = {}
self._ownership_warnings: set[str] = set()
def start(self, device_ids: Iterable[str] | None = None) -> dict[str, str | None]:
if self._shutdown_requested:
@@ -241,13 +245,24 @@ class ScreenAwakeService:
existing = self._strategies.get(device_id)
if existing is not None:
return existing
owner = _owner_lock(device_id)
try:
owner = _owner_lock(device_id)
owner.acquire(timeout=0)
except Timeout:
# Do not cache a follower as owner: discovery can take over if
# the process holding the OS lock exits or releases the device.
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:
strategy = _configure_usb_stay_awake(device_id)
except BaseException:
+12 -1
View File
@@ -77,6 +77,8 @@ from typing import Any
import urllib.error
import urllib.request
from filelock import FileLock
from artemis.config.constants import ENV_ARTEMIS_HELPER_AUTO_INSTALL
from artemis.config.paths import get_temp_dir
from artemis.runtime.adb_endpoint import adb_command
@@ -323,7 +325,7 @@ class AccessibilityHelperManager:
if re.fullmatch(r"[0-9a-f]{32,128}", text):
self._token = text
return text
except OSError:
except FileNotFoundError:
pass
token = secrets.token_hex(24)
try:
@@ -550,6 +552,15 @@ class AccessibilityHelperManager:
root.mkdir(parents=True, exist_ok=True)
digest = hashlib.sha256(serial.encode("utf-8")).hexdigest()[:16]
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()
while True:
try:
+20 -9
View File
@@ -209,7 +209,10 @@ class Agent:
raise ValueError("任务目标必须是 android、computer 或 mixed")
self._execution_target = execution_target
if execution_target == "computer":
from artemis.platform import platform as pal_platform
self._device_context = DeviceContext(
host_platform=pal_platform.os_type.name,
device_id="computer:local",
mobile_platform=DevicePlatform.COMPUTER,
device_width=0,
@@ -584,6 +587,7 @@ class Agent:
or getattr(getattr(task, "request", None), "task_name", None)
)
device_lock = None
execution_started = False
try:
# Construct the runtime only inside the task's cleanup boundary. A
# cancelled queue wait or setup failure must not leave version pins.
@@ -761,6 +765,7 @@ class Agent:
publish_startup_progress(
"environment_ready", "Device environment is ready", session_id=str(sess_id)
)
execution_started = True
try:
if request.profile and request.profile.lower() == "flash":
logger.info(f"[{task_name}] Invoking FlashRunner reactive loop...")
@@ -792,7 +797,7 @@ class Agent:
status = flash_result.get("status")
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)
if context.data_engine:
context.data_engine.end_session("completed")
@@ -907,7 +912,7 @@ class Agent:
" see the test summary."
)
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)
if context.data_engine:
context.data_engine.end_session("completed")
@@ -972,7 +977,11 @@ class Agent:
from artemis.runtime.task_result import save_failure_result
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:
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:
"""Trace naming: assertion failures must be distinguishable from _PASS."""
"""Keep execution and the independently verified test verdict distinct."""
if task_status != "completed":
return "_FAIL"
tests = (run_outcome or {}).get("tests") or {}
if (
int(tests.get("failed", 0)) > 0
or ((run_outcome or {}).get("test_result") or {}).get("status") == "failed"
):
verdict = ((run_outcome or {}).get("test_result") or {}).get("status")
if verdict == "blocked":
return "_BLOCKED"
if verdict == "inconclusive":
return "_UNVERIFIED"
if verdict == "failed" or int(tests.get("failed", 0)) > 0:
return "_TESTFAIL"
return "_PASS"
return "_PASS" if verdict == "passed" else "_UNVERIFIED"
+39 -10
View File
@@ -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."""
deadline = float(getattr(settings, "LLM_PAUSE_TIMEOUT_SECONDS", 0.0) or 0.0)
waited = 0.0
try:
owned = pause_file.stat()
except FileNotFoundError:
return True
try:
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:
@@ -361,7 +381,7 @@ async def _run_with_recovery[T](
immediately: retrying cannot help and pausing would hang the task.
- When retryable attempts are exhausted: if a fallback model is waiting
(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
retry the same request on resume.
"""
@@ -450,7 +470,7 @@ async def _run_with_recovery[T](
)
if failure.category is FailureCategory.INVALID_OUTPUT:
# 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.
max_attempts = min(
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)
# 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:
failed_endpoints[endpoint_key] = last_error
_record_llm_event(
@@ -494,7 +518,11 @@ async def _run_with_recovery[T](
"error": str(last_error)[:1000],
"category": last_failure.category.value,
"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",
)
@@ -510,10 +538,6 @@ async def _run_with_recovery[T](
"LLM pause deadline"
f" ({settings.LLM_PAUSE_TIMEOUT_SECONDS:.0f}s) exceeded; giving up."
)
try:
pause_file.unlink(missing_ok=True)
except OSError:
pass
raise LLMExhaustedError(
str(last_error), failure=last_failure, cause=last_error
) from last_error
@@ -670,7 +694,12 @@ class RobustChatModelWrapper:
engine = self.ctx.data_engine if self.ctx else None
if engine is None:
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):
prefix = None
@@ -1232,7 +1261,7 @@ async def _with_fallback[T](
)
# 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)
try:
result = await main_call()
+3
View File
@@ -224,6 +224,7 @@ def record_llm_usage(
response: Any,
*,
source: str | None = None,
model: str | None = None,
update_last_prompt: bool = True,
) -> dict | None:
"""Meters one LLM response and records an ``llm_usage`` trace, best-effort.
@@ -251,6 +252,8 @@ def record_llm_usage(
}
if source:
payload["source"] = source
if model:
payload["model"] = model
node = CURRENT_NODE_NAME.get()
if node:
payload["node"] = str(node)
+3 -1
View File
@@ -17,6 +17,7 @@
import httpx
from artemis.config.settings import settings
from artemis.llm.http import model_http_options
from artemis.utils.logger import get_logger
@@ -91,7 +92,8 @@ async def validate_api_key(
try:
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:
if clean_provider in ("ocr", "vision", "google_vision"):
# Test Google Cloud Vision OCR API
+1 -1
View File
@@ -35,7 +35,7 @@
},
"agent": {
"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}",
"timeout_seconds": "${env:ARTEMIS_EXPLORATION_TIMEOUT_SECONDS:-1800}",
"demonstration_timeout_seconds": "${env:ARTEMIS_DEMONSTRATION_TIMEOUT_SECONDS:-900}",
+1 -1
View File
@@ -60,7 +60,7 @@ MCP 同步仅检查 `$CODEX_HOME/config.toml`;未设置或为空时使用当
报告按任务或批次归档到 `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 还收录每项任务已保存的步骤截图和录屏。
+2
View File
@@ -112,3 +112,5 @@ Flash 跨端任务由模型通过内部 `update_task_progress` 记录短阶段
批次保留原顺序、循环次数和失败停止策略,默认保留各项配置,也可明确选择统一模型和思考强度。探索用例固定收藏版本,首次无复跑配置时默认 Flash;“收藏为重新探索”会创建新的探索会话并沿用原探索配置。
单任务下载全部资料时,未变化的终态报告和资料包会复用;人工复验、结果和资料变化会重新生成。批次沿用已生成的报告包,人工复验后重新打包;旧目录格式的包首次下载时更新。运行中的下载是当前快照。录像分为“安卓”“电脑”“人工示范”目录,索引路径与实际文件一致。打包优先速度,对已压缩的图片、视频和 Office 文件直接存储,保持原画质。
安装并启用任务人工评审 1.1.0 后,批量模板在原七列之后增加“预置条件、操作步骤、预期结果”。三列独立保存原始用例,不追加到 Artemis 提示词;左侧按用例编号和标题展示,右侧可用上一条/下一条评审同批次已结束任务。未启用该插件时仍提供并校验原七列模板。
+6
View File
@@ -1,5 +1,11 @@
# 开发交接: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-26 已再次接续开发会话,当前基线为主程序本地提交 `fac4ccb`(2.2.0)。两个仓库现场、已撤回机制与后续验收范围见[本次接续记录](plans/2026-10-26-session-handoff.md)。以下继续保留原始交接快照。
+1 -1
View File
@@ -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 秒。模型调用、设备动作、录像分析计入;排队、等待回答和人工示范不计入。到时保存进度并暂停,可点击「继续探索」开启下一段;不会仅因超时把测试判成失败,累计主动耗时仍保留。
+39
View File
@@ -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