feat(node): manage software setup in UI

This commit is contained in:
caoqianming 2026-08-20 16:46:02 +08:00
parent 28347b940f
commit b830dbfeec
14 changed files with 866 additions and 331 deletions

View File

@ -8,6 +8,8 @@
## Unreleased
- Windows Node 现在可在“专业软件”区域自动检测或手工指定应用位置,并分别安装、更新 Origin、ANSYS、Blender 的独立运行环境;只装有部分专业软件的节点不再需要处理无关软件。
- 对话中尚未发送的文字现在会按对话自动暂存;助手回答或后台进程执行期间也可以继续发送补充信息,消息会显示为待处理并在当前工作结束后依次发送。动作按钮采用单按钮形态:忙碌且输入为空时用于停止,输入文字或加入附件后自动切回发送。切换对话或刷新页面后,草稿和待处理消息仍会保留。
- Windows Node 的任务、Workspace 和受管运行时现在可以从界面整体迁移到其他本机磁盘;迁移完成前会校验全部文件并保留旧目录,管理员也可以用机器级环境变量统一指定位置。

View File

@ -462,7 +462,7 @@ scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB)
云端控制面使用独立的 `software_node_enrollments``software_nodes`,不复用用户外部系统连接。管理员创建的一次性注册码具有 128 bit 随机熵,数据库只保存 SHA-256 摘要;节点注册在行锁事务中校验有效期、预期名称和服务端支持的能力,成功后原子消费。注册码只建立节点身份,不要求管理员逐项授权专业软件能力。每个节点获得独立高熵 Token数据库只保存 bcrypt 强哈希,明文仅在注册响应出现一次。
专业软件采用“共享能力契约 + 本机 adapter”边界。仓库根目录 `software-contracts/*.json` 是语言无关的声明式源码事实源,描述 capability、请求 JSON Schema、输入额度、输出 manifest、feature 与最低 adapter 版本Core 按文件签名热加载契约,只负责身份、账本、调度、传输、摘要与最终发布,不包含 Origin 或其他软件的操作分支。热加载先完整校验新快照再原子切换写入中或非法版本继续使用上一份有效快照。Windows Node 是稳定的可信宿主,只负责持久化 job 目录、下载/上传、恢复、取消,以及从程序目录 `adapters/*/adapter.json` 发现 adapter发布过程把对应源码合同复制进各 adapter 目录Host 只读取 manifest 同目录指向的这一份合同并做通用 JSON Schema 校验,不再读取共享目录或采用本地优先 fallback。这样完整 Node 包与独立 adapter 包具有相同部署单元manifest、合同、Worker 和依赖必须整体更新避免旧合同静默覆盖新合同。manifest 声明 capability、实际 adapter 版本、运行类型、入口和契约文件,并可声明 11440 分钟的单任务超时;旧 manifest 缺省时保持 30 分钟,版本与时限不编译进 EXE。Node 在心跳中上报实际加载合同的 SHA-256 和 Workspace 协议版本,服务端只向明确兼容的节点调度 Workspace Job;具体软件探测和二次语义校验属于 Worker。Worker 可以是受管 Python 脚本或独立 EXE进程只接收 job 目录,并`--probe` 返回运行状态,通过 `state.json`、`terminal.json` 与固定输出目录交接Python、COM 或某个 SDK 都不属于通用协议。当前 Origin、ANSYS 和 Blender adapter 使用 Python 只是实现选择。
专业软件采用“共享能力契约 + 本机 adapter”边界。仓库根目录 `software-contracts/*.json` 是语言无关的声明式源码事实源,描述 capability、请求 JSON Schema、输入额度、输出 manifest、feature 与最低 adapter 版本Core 按文件签名热加载契约,只负责身份、账本、调度、传输、摘要与最终发布,不包含 Origin 或其他软件的操作分支。热加载先完整校验新快照再原子切换写入中或非法版本继续使用上一份有效快照。Windows Node 是稳定的可信宿主,只负责持久化 job 目录、下载/上传、恢复、取消,以及从程序目录 `adapters/*/adapter.json` 发现 adapter发布过程把对应源码合同复制进各 adapter 目录Host 只读取 manifest 同目录指向的这一份合同并做通用 JSON Schema 校验,不再读取共享合同目录或采用本地优先 fallback。这样完整 Node 包与独立 adapter 包具有相同部署单元manifest、合同、Worker 和依赖必须整体更新避免旧合同静默覆盖新合同。manifest 声明 capability、实际 adapter 版本、运行类型、入口和契约文件,并可声明 11440 分钟的单任务超时;旧 manifest 缺省时保持 30 分钟,版本与时限不编译进 EXE。Node 在心跳中上报实际加载合同的 SHA-256 和 Workspace 协议版本,服务端只向明确兼容的节点调度 Workspace Job。Host 另提供本机运维层:按 `runtime_id` 合并软件卡,允许管理员自动检测或选择预定义应用位置,并只按内置软件清单及固定 requirements 单独构建受管 runtimeUI 不接受任意安装命令任务请求仍不能影响解释器、应用路径或安装过程。Host 将已验证的位置以固定环境变量注入对应 WorkerWorker 的 `--probe` 继续承担最终软件版本、SDK/COM 和执行门健康校验。Worker 可以是受管 Python 脚本或独立 EXE进程只接收 job 目录,并通过 `state.json`、`terminal.json` 与固定输出目录交接Python、COM 或某个 SDK 都不属于通用任务协议。当前 Origin、ANSYS 和 Blender adapter 使用 Python 只是实现选择。
扩展现有 capability 的 feature 时修改共享契约、对应 Worker 和测试,不修改 Core 调度、Job 生命周期或 Node EXE增加新专业软件时新增契约与一个 adapter 目录。Cloud 会在下一次契约查询时获得新校验和工具 schemaNode 在启动时从本地目录获得能力、版本和入口,并在首次连接及每次心跳中上报全部已发现 adapter。服务端用本次上报中自己具有共享契约的 capability 原子替换节点能力列表,未知能力不进入调度;管理端只读展示该结果,不保存人工能力选择。现有节点因此不需要清身份或重新注册,升级 Node 后重启一次即可在下一次心跳同步。当前选择明确的运维边界zcbot 契约可热更新,不要求重启;本机 adapter 更新时先退出托盘 Node整体替换 adapter 目录后重新启动,不重装、不替换 Node EXE也不建设自动更新平台。当前 Node 仍按整机单执行槽保守串行,未来只有真实并行软件需求出现时,才把 slot 账本升级为 per-capability 租约,而不改变 Job 协议。
@ -478,7 +478,7 @@ Recipe 是专业软件的声明式目标状态,不是任意脚本或逐次鼠
Blender 首个入口为 `blender.scene.author@v1`,以设备目标状态而非 Python、逐步按键或任意节点脚本表达三维创作。adapter 0.1.0 的首个 `rotary_kiln` feature 接受长度、直径、壳体/衬里厚度、斜度、支撑档数及驱动、燃烧器、窑头尾罩、平台、物料等受控参数,由固定 `bpy` Worker 创建模型、相机、灯光和材质;输出 `.blend`、PNG 预览、场景清单、溯源及可选 GLB。受管 Python 只负责发现并以 `--background --factory-startup --disable-autoexec` 拉起已安装的 Blender实际 `bpy` 在 Blender 自带 Python 内执行请求不能提供脚本、命令行、本机路径、URL 或插件。`.blend` 作为 Workspace state续作先验证并打开上一版工程再以完整目标状态生成新版本后续修改器、节点、动画和高质量渲染继续作为同一业务方向的版本化 feature 增量开放,不把首个设备构造器固化成 Blender 的能力上限。
专业软件的连续加工以独立 `software_workspaces` 为稳定边界Job 只记录一次操作。首个 Job 创建独立 `workspace_id`,两者不共用 ID服务端保存 user 归属、capability、`home_node_id`、`head_job_id`、容量与留存元数据Node 物理目录只使用 `<data_root>/workspaces/<workspace_id>/`,不再按 user 建上层目录。`data_root` 默认是 `%ProgramData%/Zcbot/WindowsNode`,专用运行账号可在 UI 中选择其他本机固定磁盘,机器级 `ZCBOT_WINDOWS_NODE_DATA_DIR` 拥有最高优先级并使 UI 只读;账号级选择保存在 HKCU避免把根目录定位信息放入其自身。迁移先停止调度并等待本机任务及导出收尾在目标同级临时目录复制并逐文件校验后才原子切换旧目录不自动删除。这样用户统计集中在数据库完成节点只管理实际落到本机的 workspace也避免多节点各自维护一套用户目录树。Workspace 一旦首次调度即粘在 home node后续 Job 必须以当前 head 为 source、串行回到同一节点节点离线时等待而不复制工程或静默改派。
专业软件的连续加工以独立 `software_workspaces` 为稳定边界Job 只记录一次操作。首个 Job 创建独立 `workspace_id`,两者不共用 ID服务端保存 user 归属、capability、`home_node_id`、`head_job_id`、容量与留存元数据Node 物理目录只使用 `<data_root>/workspaces/<workspace_id>/`,不再按 user 建上层目录。全新节点的 `data_root` 默认是当前专用账号的 `%LocalAppData%/Zcbot/WindowsNode`,以便无需提权即可从 UI 完成首次配置;升级节点若已有 `%ProgramData%/Zcbot/WindowsNode` 则继续原位使用不做隐式迁移。专用运行账号可在 UI 中选择其他本机固定磁盘,机器级 `ZCBOT_WINDOWS_NODE_DATA_DIR` 拥有最高优先级并使 UI 只读;账号级选择保存在 HKCU避免把根目录定位信息放入其自身。迁移先停止调度并等待本机任务及导出收尾在目标同级临时目录复制并逐文件校验后才原子切换旧目录不自动删除。这样用户统计集中在数据库完成节点只管理实际落到本机的 workspace也避免多节点各自维护一套用户目录树。Workspace 一旦首次调度即粘在 home node后续 Job 必须以当前 head 为 source、串行回到同一节点节点离线时等待而不复制工程或静默改派。
Node 对每个 Workspace 只保留 `current``rollback` 两代。续作开始前把 `current` 原子切换为 `rollback`Worker 打开该工程并将新状态写入 Job 输出区;成功后输出区提升为新 `current`,失败或进程中断则恢复 `rollback`。因此连续加工不会为每个 Job 永久复制一份工程物理空间上限约为当前工程加一份回滚工程Job 账本仍完整保留参数、摘要和 local manifest。`software_job_revise(source_job_id, operation, outputs)` 只允许从 Workspace 当前 head 继续,复用已登记输入并重新经过当前契约校验。当前 Origin adapter 以 OPJU 为 workspace stateANSYS 静力 capability 仍是无状态一次性执行,契约中的 `workspace: null` 明确保持原发布流程。

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-08-20(对话草稿暂存与回答中排队发送,未发版)
最后更新:2026-08-20(Windows Node 专业软件独立配置与 runtime 安装,未发版)
---
@ -20,6 +20,8 @@
---
## 已完成关键能力
- **08-20 / Unreleased / Windows Node 专业软件独立配置与 runtime 安装**:主窗口新增 Origin、ANSYS Mechanical 2024 R2、Blender 软件卡,分别显示应用位置来源和受管 runtime 状态Blender/ANSYS 可由 UI 选择自定义位置清除后按环境变量、卸载注册表、PATH/标准目录自动检测Origin 保持 COM 注册检测。每个软件可单独创建或更新隔离的 Python 3.12 runtime安装只使用内置 runtime 定义与固定 requirements拒绝活动任务期间替换并先在临时目录完成后再切换配置后的路径由 Host 作为固定子进程环境传给 Worker任务请求仍不能提供路径或命令。移除重复承担软件安装和开机启动的统一 BAT发布包直接运行 EXE当前账号的开机启动也完全由 UI 管理;全新节点默认使用可写的 `%LocalAppData%` 数据根目录,已有 `%ProgramData%` 节点原位兼容。
- **08-20 / Unreleased / 对话草稿自动暂存与回答中排队发送**未发送文字按用户和对话隔离保存于浏览器本地切换对话或刷新页面后自动恢复发送成功后清除助手回答、停止收尾或后台进程执行期间消息及附件作为可移除的待处理卡按序保存当前工作结束后严格串行启动下一轮。composer 对齐 Codex 单按钮形态:忙碌且输入为空时显示“停止”,键入文字或加入附件后同一按钮切回“发送”并走排队语义。新对话在 task 懒创建与首条消息两个请求之间会把草稿迁到真实 task发送失败仍可找回输入采用 200ms 合并写入并覆盖润色、语音转写和引用修改入口。JavaScript 语法、队列/草稿专项及既有前端测试通过,无 DB、schema、migration、HTTP API、依赖或运行方式变化。
- **08-20 / Unreleased / Windows Node 数据根目录自定义与安全迁移**:新增 UI 数据目录选择、打开和整体迁移,机器级 `ZCBOT_WINDOWS_NODE_DATA_DIR` 优先且使 UI 只读,未配置节点继续使用 `%ProgramData%`;迁移拒绝活动任务、非固定盘、嵌套或非空目标及空间不足,停止连接并等待任务/导出收尾后在临时目录复制和逐文件 SHA-256 校验成功才保存账号级路径并重启旧目录保留。安装器、runtime、Workspace、诊断和磁盘余量上报统一跟随实际数据根目录65 项 Windows Node/软件节点专项 unittest、.NET build 与 diff 检查通过,未迁移真实节点数据、未连接生产数据库。

23
RUN.md
View File

@ -1091,26 +1091,23 @@ Zcbot.WindowsNode.exe enroll `
Zcbot.WindowsNode.exe
```
Windows Node 发布目录包含纯 BAT 安装入口,不依赖 PowerShell。使用实际运行 Node 的专用 Windows 账号双击 `install-windows-node.bat`;安装器会分别检测 Origin/OriginPro 与 `C:\Program Files\ANSYS Inc\v242`(或 `AWP_ROOT242`)中的 Mechanical 2024 R2检测到后自动通过 `py -3.12` 或 PATH 定位 Python 3.12,并为两者安装相互隔离的固定 runtime未检测到的软件会跳过。Python 依赖默认显式使用清华 PyPI 镜像;如需切换官方源或院内镜像,在启动安装器前设置 `ZCBOT_PIP_INDEX_URL`。最后通过 `schtasks.exe` 注册该账号登录后的启动任务,结束后保留窗口。也可在命令提示符执行:
Windows Node 不需要安装脚本。解压发布包后,使用实际运行 Node 的专用 Windows 账号直接启动 `Zcbot.WindowsNode.exe`:在主窗口注册节点,按需勾选“登录 Windows 后自动启动节点”,再到“专业软件”区域逐项配置并安装本机实际拥有的软件。开机启动保存为当前账号的 Windows `Run` 项,不安装 Windows Service也不要求管理员创建计划任务。
```bat
cd /d D:\ZcbotNode
install-windows-node.bat
```
专业软件 runtime 安装要求系统能通过 `py -3.12` 或 PATH 中的 `python.exe` 找到 Python 3.12。依赖默认显式使用清华 PyPI 镜像;如需切换官方源或院内镜像,在启动 Node 前设置 `ZCBOT_PIP_INDEX_URL`。解释器位于 `<data_root>\runtimes\origin|ansys|blender\Scripts\python.exe`;如需由集中运维提供既有受管解释器,机器级 `ZCBOT_ADAPTER_ORIGIN_PYTHON`、`ZCBOT_ADAPTER_ANSYS_PYTHON` 或 `ZCBOT_ADAPTER_BLENDER_PYTHON` 继续兼容,旧名 `ZCBOT_ORIGIN_PYTHON` 暂时兼容。`node.json`、可恢复任务、workspace 和 runtime 集中保存在同一数据根目录,不会因替换程序目录而丢失。任务请求无权选择解释器、脚本、安装依赖或应用路径。
若 Python 未加入 PATH可把绝对路径作为第一个参数例如 `install-windows-node.bat "C:\Python312\python.exe"`。默认数据根目录是 `%ProgramData%\Zcbot\WindowsNode`,解释器位于 `<data_root>\runtimes\origin|ansys|blender\Scripts\python.exe`。如需使用其他受管解释器,使用机器级 `ZCBOT_ADAPTER_ORIGIN_PYTHON`、`ZCBOT_ADAPTER_ANSYS_PYTHON` 或 `ZCBOT_ADAPTER_BLENDER_PYTHON`;旧名 `ZCBOT_ORIGIN_PYTHON` 暂时兼容。`node.json`、可恢复任务、workspace 和 runtime 集中保存在同一数据根目录,不会因替换程序目录而丢失。运行时固定依赖见各 adapter 的 `requirements.txt`;任务请求无权选择解释器、脚本或路径。
数据根目录可在 Node 主窗口“运行设置”中迁移到其他本机固定磁盘。已有未完成任务时 UI 会拒绝迁移;开始后先停止接收新任务,把整个根目录复制到目标同级临时目录并逐文件 SHA-256 校验,成功后才保存账号级路径并重启。旧目录不会自动删除,确认新目录正常后再由管理员手工清理。机器级 `ZCBOT_WINDOWS_NODE_DATA_DIR` 的优先级高于 UI 配置,存在时 UI 只读;设置或修改该环境变量后必须重启 Node并由管理员自行保证完整数据已经迁入。统一安装器会读取同一环境变量或当前专用账号的 UI 配置。
数据根目录可在 Node 主窗口“运行设置”中迁移到其他本机固定磁盘。已有未完成任务时 UI 会拒绝迁移;开始后先停止接收新任务,把整个根目录复制到目标同级临时目录并逐文件 SHA-256 校验,成功后才保存账号级路径并重启。旧目录不会自动删除,确认新目录正常后再由管理员手工清理。机器级 `ZCBOT_WINDOWS_NODE_DATA_DIR` 的优先级高于 UI 配置,存在时 UI 只读;设置或修改该环境变量后必须重启 Node并由管理员自行保证完整数据已经迁入。软件卡安装的 runtime 始终跟随当前数据根目录。
Workspace 目录固定为 `<data_root>\workspaces\<workspace-id>\`,不使用 user ID 上层目录。`current` 是当前可续作工程,`rollback` 只保留上一版用于失败恢复;每次成功续作替换 `current`,不会按 Job 永久堆积工程副本。不要手工移动 workspace 到其他节点:服务端会把后续 Job 固定调度到其 home node节点离线时任务保持等待。备份或迁移节点时必须把数据库中的 workspace 归属和该目录作为一个整体处理。
Blender 能力为 `blender.scene.author@v1`。首版 `rotary_kiln` 接受完整的回转窑目标状态,生成 `scene.blend`、`preview.png`、`scene-manifest.json`、`provenance.json` 和可选 `scene.glb``.blend` 留在 Workspace 作为下一次修改的事实源。安装器会从 `ZCBOT_BLENDER_EXE`、PATH 或 `%ProgramFiles%\Blender Foundation\Blender *\blender.exe` 探测 Blender非标准安装位置应先设置机器级 `ZCBOT_BLENDER_EXE`,再重新启动 Node。启动器固定使用 Blender 后台、factory startup 和禁止自动脚本模式,不读取用户启动文件,也不接受请求传入 Python、插件、命令行、URL 或本机路径。
主窗口“专业软件”按 Origin、ANSYS Mechanical 2024 R2 和 Blender 分别管理应用位置与隔离 runtime。每张软件卡都可单独执行“安装/更新运行环境”,只会读取安装包内固定 requirements并先在临时目录完成后再替换该软件的 `runtimes/<runtime_id>`;有对应活动任务时拒绝替换。安装要求本机存在 Python 3.12,可通过 `py -3.12` 或 PATH 中的 `python.exe` 找到。Blender 和 ANSYS 可用“选择位置”保存当前专用账号的配置点“自动检测”会清除手工值并重新按环境变量、Windows 卸载注册表、PATH/标准目录探测Origin 使用 COM 注册自动检测。环境变量 `ZCBOT_BLENDER_EXE``AWP_ROOT242` 继续兼容。配置或安装完成后不必重新注册节点,后续 probe/心跳会刷新能力状态。
Blender 能力为 `blender.scene.author@v1`。首版 `rotary_kiln` 接受完整的回转窑目标状态,生成 `scene.blend`、`preview.png`、`scene-manifest.json`、`provenance.json` 和可选 `scene.glb``.blend` 留在 Workspace 作为下一次修改的事实源。Node 会优先使用 UI 中指定的 `blender.exe`,否则从 `ZCBOT_BLENDER_EXE`、Windows 卸载注册表、PATH 或 `%ProgramFiles%\Blender Foundation\Blender *\blender.exe` 探测 Blender。启动器固定使用 Blender 后台、factory startup 和禁止自动脚本模式,不读取用户启动文件,也不接受请求传入 Python、插件、命令行、URL 或本机路径。
ANSYS 能力为 `ansys.geometry.inspect@v1``ansys.mechanical.static_structural@v2`,只面向 Mechanical 2024 R2revision 242。adapter 0.3.0 使用 PyMechanical 0.11.3前者导入几何并输出面清单、单位、包围盒、Named Selection 和预览,后者可按已有 Named Selection、全局轴极值面或平面法向/偏置/容差建立支撑与载荷选区再执行真实静力求解。v2 直接替换旧 v1旧请求不会在新版节点调度。Node 会从 `adapters/` 自动发现并在每次心跳上报全部能力,现有节点不需要清身份或重新注册,但必须整体替换发布目录并重启。默认 probe 仍返回不可用;在目标机器完成真实许可证与基准算例验收前,不得设置 `ZCBOT_ANSYS_242_VALIDATED=1`
普通几何先提交 `ansys.geometry.inspect@v1``outputs` 传空数组;成功后以 `geometry-manifest.json` 的单位、包围盒、面质心/法向/面积和 `geometry-preview.png` 判断边界区域。规则板件可在静力 v2 中使用 `{"type":"extreme_face","axis":"x","side":"min","expected_count":1}`;已知平面可使用 `{"type":"planar_faces","normal":[1,0,0],"offset":200,"tolerance":0.01,"expected_count":1}`,其中偏置和容差沿用 `analysis.unit_system` 的长度单位;已有 CAD Named Selection 则使用 `{"type":"named_selection","name":"fixed_mount"}`。每个 boundary/load 还必须提供唯一的 ASCII `name`,用于 Mechanical 树、反力结果和溯源。`expected_count` 可省略,但自动化生产请求应尽量提供;数量不符、没有平面、查询为空或名称冲突都会在网格和求解前失败。复杂装配或歧义区域不得只凭预览猜测,应回到 CAD 建立 Named Selection 后再提交。
验收使用安装包自带的 NIST FTC-11 AP203 几何adapter 通过与生产请求相同的 `planar_faces` 法向、偏置、容差及 `expected_count=1` 建立 `fixed_face`、`load_face`;不再走验收专用选面旁路。先退出托盘 Node、整体替换新版发布目录、重新运行统一安装器并启动 Node确认托盘仍显示 ANSYS 待验收。打开 Node 主窗口,在“ANSYS Mechanical 真机验收”卡片点击“运行内置基准验收”只需选择报告父目录UI 会使用固定参数执行一次完整进程树取消和连续 20 次求解,不能通过界面改变脚本、次数、试件或执行门条件。验收成功后先在许可证管理端确认席位均已归还,再点击“开启 ANSYS 执行门”;该按钮只会在本次 `acceptance-report.json` 明确通过后启用,写入机器级环境变量需要管理员权限。随后从托盘完全退出并重新启动 Node。内置 STEP 来源于 NIST MBE PMI Validation and Conformance Testing Project可不受限制地使用文件头保留来源说明。
验收使用安装包自带的 NIST FTC-11 AP203 几何adapter 通过与生产请求相同的 `planar_faces` 法向、偏置、容差及 `expected_count=1` 建立 `fixed_face`、`load_face`;不再走验收专用选面旁路。先退出托盘 Node、整体替换新版发布目录并启动,在 ANSYS 软件卡中安装/更新运行环境,确认能力仍显示待验收。随后在“ANSYS Mechanical 真机验收”卡片点击“运行内置基准验收”只需选择报告父目录UI 会使用固定参数执行一次完整进程树取消和连续 20 次求解,不能通过界面改变脚本、次数、试件或执行门条件。验收成功后先在许可证管理端确认席位均已归还,再点击“开启 ANSYS 执行门”;该按钮只会在本次 `acceptance-report.json` 明确通过后启用,写入机器级环境变量需要管理员权限。随后从托盘完全退出并重新启动 Node。内置 STEP 来源于 NIST MBE PMI Validation and Conformance Testing Project可不受限制地使用文件头保留来源说明。
节点卡片的“复制诊断信息”可把 Node 版本、系统、数据目录、执行门状态和每个 adapter 的软件版本、health、detail、受管 runtime 路径复制到对话,不包含 Node Token。若 UI 无法运行验收,可先退出托盘 Node再用同一专用 Windows 账号在 PowerShell 执行以下等价命令(`--work-root` 必须是尚不存在的新目录):
@ -1147,11 +1144,11 @@ Web 用户登录后,文件栏 Job 中心会聚合本人最近任务。活动
两个 JSON 的职责不同:`adapter.json` 只描述本机如何启动实际版本、runtime、入口和契约文件名`origin.plot.v2.json` 描述云端与本机共同遵守的业务请求/输出契约。前者不能替代后者;独立打包脚本只是把根目录的同一份业务契约复制进 adapter 交付目录,不维护第二份源码。
只更新 Origin adapter 时,不需要编译、重装或替换 Node EXE运行 `windows-node\package-origin-adapter.bat` 可直接把 manifest、Worker、依赖清单和契约打成 `dist\origin.plot@v2-adapter.zip`。在 Node 机器上先从托盘退出 zcbot Windows Node备份并整体替换 EXE 同级的 `adapters\origin.plot@v2\`,再从开始菜单或登录任务启动 Node。不要在任务执行中覆盖目录。若 `requirements.txt` 发生变化,需重新运行统一安装器更新受管 Python runtime;仅 `adapter.json`、契约或 `worker.py` 变化时无需。Node 启动后会用 Worker 的 `--probe` 核对 manifest 与 Worker 版本,版本不一致时该能力保持不可用。
只更新 Origin adapter 时,不需要编译、重装或替换 Node EXE运行 `windows-node\package-origin-adapter.bat` 可直接把 manifest、Worker、依赖清单和契约打成 `dist\origin.plot@v2-adapter.zip`。在 Node 机器上先从托盘退出 zcbot Windows Node备份并整体替换 EXE 同级的 `adapters\origin.plot@v2\`,再启动 Node。不要在任务执行中覆盖目录。若 `requirements.txt` 发生变化,在对应软件卡中“安装/更新运行环境”;仅 `adapter.json`、契约或 `worker.py` 变化时无需更新 runtime。Node 启动后会用 Worker 的 `--probe` 核对 manifest 与 Worker 版本,版本不一致时该能力保持不可用。
Blender adapter 可独立运行 `windows-node\package-blender-adapter.bat` 打成 `dist\blender.scene.author@v1-adapter.zip`,替换规则与 Origin 相同。Blender 的 `requirements.txt` 当前不安装第三方包;首次增加 Blender 能力时仍需运行统一安装器创建轻量启动 runtime`bpy` 不安装到该 runtime。
Blender adapter 可独立运行 `windows-node\package-blender-adapter.bat` 打成 `dist\blender.scene.author@v1-adapter.zip`,替换规则与 Origin 相同。Blender 的 `requirements.txt` 当前不安装第三方包;首次增加 Blender 能力时在 Blender 软件卡中创建轻量启动 runtime`bpy` 不安装到该 runtime。
注册配置写入 `<data_root>\node.json`Token 使用 DPAPI `LocalMachine` 加密ACL 仅允许注册账号和 `SYSTEM`。应始终用同一专用 Windows 账号执行统一安装器、注册并运行 Node。当前 MVP 以该账号的登录后计划任务启动,不安装 Windows Service。
注册配置写入 `<data_root>\node.json`Token 使用 DPAPI `LocalMachine` 加密ACL 仅允许注册账号和 `SYSTEM`。应始终用同一专用 Windows 账号配置、注册并运行 Node。当前 MVP 可由 UI 写入该账号的登录启动项,不安装 Windows Service。
直接双击 EXE 启动托盘 UI红点为未注册/身份失效,黄点为连接中,绿点为在线;双击托盘图标打开配置窗。原 CLI 注册入口继续保留,无 UI 模式使用 `Zcbot.WindowsNode.exe run --headless`

View File

@ -63,7 +63,11 @@ class WindowsNodeSourceTests(unittest.TestCase):
source = "\n".join(
path.read_text(encoding="utf-8")
for path in PROJECT.glob("*.cs")
if path.name not in {"AdapterProcessRunner.cs", "ConfigurationForm.cs"}
if path.name not in {
"AdapterProcessRunner.cs",
"ConfigurationForm.cs",
"SoftwareRuntimeManager.cs",
}
)
for forbidden in ("Process.Start", "cmd.exe", "powershell.exe", "LabTalk"):
self.assertNotIn(forbidden, source)
@ -81,6 +85,9 @@ class WindowsNodeSourceTests(unittest.TestCase):
connection = (PROJECT / "NodeConnectionLoop.cs").read_text(encoding="utf-8")
self.assertIn('"ZCBOT_WINDOWS_NODE_DATA_DIR"', settings)
self.assertIn("Environment.SpecialFolder.LocalApplicationData", settings)
self.assertIn("Environment.SpecialFolder.CommonApplicationData", settings)
self.assertIn('Directory.Exists(legacy)', settings)
self.assertLess(
settings.index("Environment.GetEnvironmentVariable"),
settings.index("Registry.CurrentUser.OpenSubKey"),
@ -128,7 +135,6 @@ class WindowsNodeSourceTests(unittest.TestCase):
runner = (PROJECT / "AdapterProcessRunner.cs").read_text(encoding="utf-8")
self.assertIn("ANSYS Mechanical 真机验收", form)
self.assertIn("运行内置基准验收", form)
self.assertNotIn("new OpenFileDialog", form)
self.assertIn("AcceptanceReportPassed", form)
self.assertIn('"ZCBOT_ANSYS_242_VALIDATED", "1"', form)
self.assertIn("EnvironmentVariableTarget.Machine", form)
@ -142,8 +148,33 @@ class WindowsNodeSourceTests(unittest.TestCase):
acceptance = form.split("private async Task RunAnsysAcceptanceAsync", 1)[1].split(
"private void EnableAnsysGate", 1
)[0]
self.assertNotIn("new OpenFileDialog", acceptance)
self.assertNotIn("Process.Start", acceptance)
def test_professional_software_ui_uses_fixed_per_runtime_installers(self) -> None:
form = (PROJECT / "ConfigurationForm.cs").read_text(encoding="utf-8")
manager = (PROJECT / "SoftwareRuntimeManager.cs").read_text(encoding="utf-8")
runner = (PROJECT / "AdapterProcessRunner.cs").read_text(encoding="utf-8")
self.assertIn('CreateSectionTitle("专业软件")', form)
self.assertIn('CreateButton("自动检测", 100)', form)
self.assertIn('CreateButton("选择位置", 100)', form)
self.assertIn('CreateButton("安装/更新运行环境", 172', form)
self.assertIn("SoftwareRuntimeManager.InstallRuntimeAsync", form)
self.assertIn("item.HasActiveJobs", form)
for runtime_id in ('"origin"', '"ansys"', '"blender"'):
self.assertIn(runtime_id, manager)
self.assertIn('@"Software\\Zcbot\\WindowsNode\\Software"', manager)
self.assertIn("RegistryHive.LocalMachine", manager)
self.assertIn("RegistryHive.CurrentUser", manager)
self.assertIn('"ZCBOT_BLENDER_EXE"', manager)
self.assertIn('"AWP_ROOT242"', manager)
self.assertIn('Path.Combine(runtimes, definition.RuntimeId)', manager)
self.assertIn("ArgumentList.Add(argument)", manager)
self.assertNotIn('"cmd.exe"', manager.lower())
self.assertNotIn('"powershell.exe"', manager.lower())
self.assertIn("SoftwareRuntimeManager.ApplyProcessEnvironment", runner)
def test_configuration_window_is_resizable_and_dpi_safe(self) -> None:
form = (PROJECT / "ConfigurationForm.cs").read_text(encoding="utf-8")
self.assertIn("ClientSize = new Size(840, 680)", form)
@ -198,73 +229,27 @@ class WindowsNodeSourceTests(unittest.TestCase):
self.assertIn("JobDisplaySnapshot", models)
self.assertNotIn("origin_running", inbox + connection + models)
def test_unified_installer_registers_login_startup(self) -> None:
launcher = (ROOT / "install-windows-node.bat").read_text(encoding="utf-8")
self.assertNotIn("powershell", launcher.lower())
self.assertIn('set "NODE_EXE=%~dp0Zcbot.WindowsNode.exe"', launcher)
self.assertIn('schtasks.exe /Create /TN "Zcbot Windows Node"', launcher)
self.assertIn("/SC ONLOGON", launcher)
self.assertIn("/RL LIMITED", launcher)
self.assertIn("/IT", launcher)
self.assertIn("pause >nul", launcher)
def test_startup_and_runtime_installation_are_owned_by_the_ui(self) -> None:
startup = (PROJECT / "StartupRegistration.cs").read_text(encoding="utf-8")
form = (PROJECT / "ConfigurationForm.cs").read_text(encoding="utf-8")
manager = (PROJECT / "SoftwareRuntimeManager.cs").read_text(encoding="utf-8")
def test_unified_installer_detects_origin_and_python_312(self) -> None:
script = (ROOT / "install-windows-node.bat").read_text(encoding="utf-8")
self.assertIn('set "NODE_DATA_ROOT=%ZCBOT_WINDOWS_NODE_DATA_DIR%"', script)
self.assertIn('reg.exe query "HKCU\\Software\\Zcbot\\WindowsNode" /v DataRoot', script)
self.assertFalse((ROOT / "install-windows-node.bat").exists())
self.assertIn('Text = "登录 Windows 后自动启动节点"', form)
self.assertIn("StartupRegistration.SetEnabled", form)
self.assertIn('@"Software\\Microsoft\\Windows\\CurrentVersion\\Run"', startup)
self.assertIn("Environment.ProcessPath", startup)
self.assertIn("FindPython312Async", manager)
self.assertIn('new BootstrapPython("py.exe", ["-3.12"])', manager)
self.assertIn('new BootstrapPython("python.exe", [])', manager)
self.assertIn('"ZCBOT_PIP_INDEX_URL"', manager)
self.assertIn(
'if not defined NODE_DATA_ROOT set "NODE_DATA_ROOT=%ProgramData%\\Zcbot\\WindowsNode"',
script,
)
self.assertIn(":detect_origin", script)
self.assertIn('reg.exe query "HKCR\\Origin.ApplicationSI\\CLSID"', script)
self.assertIn(":create_python_runtime", script)
self.assertIn("py.exe -3.12", script)
self.assertIn("sys.version_info[:2] == (3, 12)", script)
self.assertIn(
'set "ORIGIN_RUNTIME_DIR=%NODE_DATA_ROOT%\\runtimes\\origin"',
script,
)
self.assertIn(
'set "ANSYS_RUNTIME_DIR=%NODE_DATA_ROOT%\\runtimes\\ansys"',
script,
)
self.assertIn(":detect_ansys_242", script)
self.assertIn("v242\\aisol\\bin\\winx64\\AnsysWBU.exe", script)
self.assertIn(
'call :create_python_runtime "%ORIGIN_RUNTIME_DIR%" "%~1"', script
)
self.assertIn(
'call :create_python_runtime "%ANSYS_RUNTIME_DIR%" "%~1"', script
)
self.assertIn('py.exe -3.12 -m venv "%~1"', script)
self.assertIn('python.exe -m venv "%~1"', script)
self.assertNotIn('"%PYTHON_EXE%"', script)
def test_unified_installer_detects_blender_and_creates_launcher_runtime(self) -> None:
script = (ROOT / "install-windows-node.bat").read_text(encoding="utf-8")
self.assertIn(":detect_blender", script)
self.assertIn("ZCBOT_BLENDER_EXE", script)
self.assertIn("Blender Foundation\\Blender *", script)
self.assertIn(
'set "BLENDER_RUNTIME_DIR=%NODE_DATA_ROOT%\\runtimes\\blender"',
script,
)
self.assertIn(
'call :create_python_runtime "%BLENDER_RUNTIME_DIR%" "%~1"', script
'"https://pypi.tuna.tsinghua.edu.cn/simple/"', manager
)
def test_unified_installer_uses_configurable_domestic_pypi(self) -> None:
script = (ROOT / "install-windows-node.bat").read_text(encoding="utf-8")
self.assertIn(
'if not defined ZCBOT_PIP_INDEX_URL set "ZCBOT_PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple/"',
script,
)
self.assertEqual(script.count('--index-url "%ZCBOT_PIP_INDEX_URL%"'), 2)
def test_publish_output_contains_the_complete_installer_payload(self) -> None:
def test_publish_output_contains_the_complete_node_payload(self) -> None:
project = (PROJECT / "Zcbot.WindowsNode.csproj").read_text(encoding="utf-8")
self.assertIn("..\\install-windows-node.bat", project)
self.assertNotIn("install-windows-node.bat", project)
self.assertIn("..\\adapters\\**\\*", project)
self.assertIn("adapters\\%(RecursiveDir)%(Filename)%(Extension)", project)
self.assertIn("..\\..\\software-contracts\\origin.plot.v2.json", project)
@ -305,7 +290,8 @@ class WindowsNodeSourceTests(unittest.TestCase):
self.assertIn('set "SELF_CONTAINED=false"', script)
self.assertIn('if /I "%~1"=="--self-contained"', script)
self.assertIn("dotnet publish", script)
self.assertIn('"install-windows-node.bat"', script)
self.assertNotIn('"install-windows-node.bat"', script)
self.assertIn("start Zcbot.WindowsNode.exe", script)
self.assertIn('for /r "%PUBLISH_DIR%\\adapters" %%F in (adapter.json)', script)
self.assertIn('do if exist "%%~fF"', script)
self.assertIn("ADAPTER_CONTRACT_COUNT", script)

View File

@ -14,21 +14,19 @@ windows-node\package-windows-node.bat
固定 Origin Worker 通过统一的 `origin.plot@v2` / `series[]` 数据角色模型支持 `line`、`scatter`、`line_scatter`、`column`、`bar`、`grouped_column`、`y_error`、`contour`、`surface_3d`、`ternary` 和 `heatmap`,并支持毫米画布、轴范围/尺度/刻度排版、标题、图例及逐系列样式。adapter 0.6.0 提供 `multi_panel`:可组成 14 个二维 panel在每个 panel 混合基础二维系列、绑定左右 Y 轴并添加对称 X/Y 误差列adapter 0.7.0 增加 `box`、`histogram`、`stacked_column`、`bubble` 和 `band`adapter 0.8.0 再增加 `area`、`stacked_area`、`polar`、`pie` 和 `stacked_bar`。Worker 生成 OPJU、PNG、SVG、PDF、plot spec、provenance 与原子 `terminal.json`。热图输入必须是完整、等间距且坐标不重复的规则 XYZ 网格;所有堆叠系列必须逐行共享相同 X 值。运行时独立于 zcbot 服务端 Python。发布目录包含统一安装入口使用实际运行 Node 的专用 Windows 账号直接双击:
日常只更新 adapter 时运行 `package-origin-adapter.bat`,无需编译 Node。退出托盘 Node 后,整体替换安装目录中的 `adapters\origin.plot@v2\` 再启动即可;不要在任务执行期间覆盖文件。只有依赖清单变化才需要重新运行统一安装器
日常只更新 adapter 时运行 `package-origin-adapter.bat`,无需编译 Node。退出托盘 Node 后,整体替换安装目录中的 `adapters\origin.plot@v2\` 再启动即可;不要在任务执行期间覆盖文件。只有依赖清单变化才需要在对应软件卡中更新 runtime
```text
install-windows-node.bat
```
Windows Node 不再提供统一安装脚本。解压发布包后直接运行 `Zcbot.WindowsNode.exe`,在主窗口完成节点注册、登录后自动启动、应用位置配置和各软件 runtime 的独立安装。runtime 安装依次尝试 `py -3.12` 和 PATH 中的 `python.exe`,要求 Python 3.12;依赖默认通过清华 PyPI 镜像安装,需要改用官方源或院内镜像时,在启动 Node 前设置 `ZCBOT_PIP_INDEX_URL`。runtime 位于 `<data_root>\runtimes\<runtime_id>\Scripts\python.exe`。全新节点默认使用 `%LocalAppData%\Zcbot\WindowsNode`;已有 `%ProgramData%\Zcbot\WindowsNode` 的升级节点继续沿用原目录。
安装器是纯 BAT不依赖 PowerShell它分别检测 Origin/OriginPro、ANSYS Mechanical 2024 R2 和 Blender依次尝试 `py -3.12` 和 PATH 中的 `python.exe`,并安装隔离的 `origin`/`ansys`/`blender` runtime未检测到的软件会明确跳过。Blender runtime 只是标准库启动器,`bpy` 始终在 Blender 自带 Python 内运行。Python 依赖默认通过清华 PyPI 镜像安装;需要改用官方源或院内镜像时,先设置 `ZCBOT_PIP_INDEX_URL`。最后通过 `schtasks.exe` 为当前专用账号注册登录后启动任务成功或失败后都会保留窗口。Python 未加入 PATH 时,可在命令提示符执行 `install-windows-node.bat "C:\Python312\python.exe"`。runtime 位于 `<data_root>\runtimes\<runtime_id>\Scripts\python.exe`;数据根目录默认是 `%ProgramData%\Zcbot\WindowsNode`
推荐在 Node 主窗口“专业软件”区域完成后续配置Origin、ANSYS、Blender 各有独立软件卡,可分别自动检测应用、选择受支持的自定义位置,并单独“安装/更新运行环境”。安装动作只接受内置软件定义和随包发布的固定 requirements不执行用户输入的命令新环境先在同一数据根目录的临时目录构建成功后再替换对应 runtime。Blender 与 ANSYS 的手工路径保存在当前专用 Windows 账号下Node 启动 Worker 时注入固定环境变量“自动检测”可清除手工路径并回到环境变量、卸载注册表、PATH/标准目录发现。Origin 继续以 COM 注册为应用事实源。
主窗口“运行设置”可把完整数据根目录迁移到其他本机固定磁盘。Node 会先停止接收任务已有未完成任务时拒绝开始随后把身份、任务、Workspace 和 runtime 复制到临时目录并逐文件校验,校验成功后才切换账号级配置并重启,旧目录不会自动删除。机器级 `ZCBOT_WINDOWS_NODE_DATA_DIR` 优先于 UI 设置;设置后 UI 只读,适合统一运维。统一安装器同时读取环境变量或该专用账号的 UI 配置,后续更新不会把 runtime 写回默认目录。
主窗口“运行设置”可把完整数据根目录迁移到其他本机固定磁盘。Node 会先停止接收任务已有未完成任务时拒绝开始随后把身份、任务、Workspace 和 runtime 复制到临时目录并逐文件校验,校验成功后才切换账号级配置并重启,旧目录不会自动删除。机器级 `ZCBOT_WINDOWS_NODE_DATA_DIR` 优先于 UI 设置;设置后 UI 只读,适合统一运维。软件卡始终把 runtime 安装到当前数据根目录,后续更新不会写回默认目录。
Blender adapter 0.1.0 以 `rotary_kiln` 目标状态生成参数化回转窑,支持长度、直径、壳体/耐火衬里厚度、斜度、28 档支撑,以及驱动、燃烧器、窑头尾罩、检修平台和窑内物料开关;输出可续作 `scene.blend`、自动预览、场景清单、溯源和可选 GLB。启动命令固定包含 background、factory startup 和禁止自动脚本标志,请求不接受 Python、命令行、路径、URL 或插件。只更新该 adapter 时运行 `package-blender-adapter.bat`,退出托盘 Node 后整体替换 `adapters\blender.scene.author@v1\` 并重启;若节点没有 `runtimes\blender`再运行统一安装器
Blender adapter 0.1.0 以 `rotary_kiln` 目标状态生成参数化回转窑,支持长度、直径、壳体/耐火衬里厚度、斜度、28 档支撑,以及驱动、燃烧器、窑头尾罩、检修平台和窑内物料开关;输出可续作 `scene.blend`、自动预览、场景清单、溯源和可选 GLB。启动命令固定包含 background、factory startup 和禁止自动脚本标志,请求不接受 Python、命令行、路径、URL 或插件。只更新该 adapter 时运行 `package-blender-adapter.bat`,退出托盘 Node 后整体替换 `adapters\blender.scene.author@v1\` 并重启;若节点没有 `runtimes\blender`在 Blender 软件卡中单独安装即可
ANSYS adapter 0.2.0 已实现 Mechanical 2024 R2 / PyMechanical 0.11.3 的真实线性静力流程,可从 STEP/Parasolid/IGES 导入 Named Selection应用固定材料/网格/支撑/力或压力,并导出 MECHDAT、汇总、结果表、结果图、solver log 和溯源。契约不接收 Python、APDL、Journal、工程文件或本机路径。Node 主窗口提供“复制诊断信息”和“ANSYS Mechanical 真机验收”卡片;验收使用内置 NIST FTC-11 STEP 并自动建立两个端面 Named Selection用户只需选择报告目录。该入口不受执行门影响但只有固定的取消与连续 20 次求解全部通过后,界面才允许写入机器级执行门。完成专用节点的许可证、取消、残留进程和许可证释放验收前,能力仍保持不可调度,具体步骤见根目录 `RUN.md`
注册和运行必须使用同一专用 Windows 账号。MVP 通过该账号的登录后计划任务自动启动,不以 Windows Service 在 Session 0 运行。
注册和运行必须使用同一专用 Windows 账号。MVP 由主窗口按需写入该账号的登录启动项,不以 Windows Service 在 Session 0 运行。
```powershell
dotnet build windows-node/Zcbot.WindowsNode/Zcbot.WindowsNode.csproj

View File

@ -325,6 +325,9 @@ internal sealed class AdapterProcessRunner(AdapterDescriptor descriptor, JobInbo
startInfo.Environment["PYTHONUTF8"] = "1";
startInfo.Environment["PYTHONIOENCODING"] = "utf-8";
}
SoftwareRuntimeManager.ApplyProcessEnvironment(
startInfo,
descriptor.Manifest.RuntimeId);
return Process.Start(startInfo)
?? throw new InvalidOperationException("Adapter worker did not start.");
}

View File

@ -53,6 +53,7 @@ internal sealed class ConfigurationForm : Form
private readonly DataGridView jobGrid = CreateJobGrid();
private readonly JobInboxStore jobInbox = new(NodePaths.ForCurrentMachine().JobsDirectory);
private readonly NodeAdapterRegistry adapters;
private readonly Dictionary<string, SoftwareControls> softwareControls = new(StringComparer.Ordinal);
private readonly System.Windows.Forms.Timer jobRefreshTimer = new() { Interval = 1000 };
private readonly TableLayoutPanel registrationCard;
private readonly TableLayoutPanel ansysAcceptanceCard;
@ -60,6 +61,7 @@ internal sealed class ConfigurationForm : Form
private NodeConfig? currentConfig;
private string? passedAcceptanceReport;
private CancellationTokenSource? acceptanceStop;
private CancellationTokenSource? runtimeInstallStop;
private bool dataRootMigrationInProgress;
internal event Func<EnrollOptions, Task>? RegisterRequested;
@ -140,6 +142,16 @@ internal sealed class ConfigurationForm : Form
statusCard.Controls.Add(resetActions);
page.Controls.Add(statusCard);
var softwareCard = CreateCard();
softwareCard.Controls.Add(CreateSectionTitle("专业软件"));
softwareCard.Controls.Add(CreateHint(
"每台 Node 只需配置并安装本机实际拥有的软件。应用位置可自动检测或手工指定;运行环境按软件独立安装。"));
foreach (var definition in SoftwareRuntimeManager.Definitions)
{
softwareCard.Controls.Add(CreateSoftwarePanel(definition));
}
page.Controls.Add(softwareCard);
ansysAcceptanceCard = CreateCard();
ansysAcceptanceCard.Controls.Add(CreateSectionTitle("ANSYS Mechanical 真机验收"));
ansysAcceptanceCard.Controls.Add(CreateHint(
@ -222,12 +234,15 @@ internal sealed class ConfigurationForm : Form
{
acceptanceStop?.Cancel();
acceptanceStop?.Dispose();
runtimeInstallStop?.Cancel();
runtimeInstallStop?.Dispose();
jobRefreshTimer.Dispose();
};
var ansysAdapter = adapters.Find("ansys.mechanical.static_structural@v2");
ansysAcceptanceCard.Visible = ansysAdapter?.SupportsLocalAcceptance == true;
enableAnsysGate.Enabled = false;
UpdateAnsysAcceptanceStatus();
RefreshSoftwareCards();
RefreshJobs();
ApplyStatus(NodeStatus.Create(NodeState.NotRegistered, "尚未注册"), null);
}
@ -286,6 +301,214 @@ internal sealed class ConfigurationForm : Form
+ runtime.Detail;
}
private Control CreateSoftwarePanel(SoftwareDefinition definition)
{
var panel = new TableLayoutPanel
{
AutoSize = true,
AutoSizeMode = AutoSizeMode.GrowAndShrink,
Dock = DockStyle.Top,
ColumnCount = 1,
Padding = new Padding(0, 10, 0, 8),
Margin = new Padding(0, 6, 0, 0),
};
panel.Controls.Add(new Label
{
Text = definition.DisplayName,
AutoSize = true,
Font = new Font("Microsoft YaHei UI", 9, FontStyle.Bold),
ForeColor = Color.FromArgb(30, 41, 59),
Margin = new Padding(0, 0, 0, 5),
});
var path = CreateTextBox();
path.ReadOnly = true;
panel.Controls.Add(path);
var status = CreateBodyLabel();
panel.Controls.Add(status);
var actions = CreateActions();
var automatic = CreateButton("自动检测", 100);
Button? choose = null;
if (definition.PathKind != SoftwarePathKind.None)
{
choose = CreateButton("选择位置", 100);
actions.Controls.Add(choose);
}
var install = CreateButton("安装/更新运行环境", 172, primary: true);
actions.Controls.Add(automatic);
actions.Controls.Add(install);
panel.Controls.Add(actions);
var controls = new SoftwareControls(path, status, automatic, choose, install);
softwareControls.Add(definition.Id, controls);
automatic.Click += (_, _) => ResetSoftwareLocation(definition);
if (choose is not null)
{
choose.Click += (_, _) => ChooseSoftwareLocation(definition);
}
install.Click += async (_, _) => await InstallSoftwareRuntimeAsync(definition);
return panel;
}
private void RefreshSoftwareCards()
{
foreach (var definition in SoftwareRuntimeManager.Definitions)
{
RefreshSoftwareCard(definition);
}
}
private void RefreshSoftwareCard(SoftwareDefinition definition)
{
var controls = softwareControls[definition.Id];
var location = SoftwareRuntimeManager.ResolveLocation(definition);
controls.Path.Text = location.Path ?? "未检测到应用位置";
controls.Path.ForeColor = location.Available ? Color.FromArgb(51, 65, 85) : Color.Firebrick;
var runtime = Path.Combine(
NodePaths.ForCurrentMachine().RootDirectory,
"runtimes",
definition.RuntimeId,
"Scripts",
"python.exe");
var runtimeState = File.Exists(runtime) ? "运行环境已安装" : "运行环境尚未安装";
controls.Status.Text = location.Available
? $"{location.Source} · {runtimeState}"
: $"{location.Detail} · {runtimeState}";
controls.Status.ForeColor = location.Available ? Color.FromArgb(71, 85, 105) : Color.Firebrick;
controls.Install.Enabled = location.Available && runtimeInstallStop is null;
controls.Automatic.Enabled = runtimeInstallStop is null;
if (controls.Choose is not null) controls.Choose.Enabled = runtimeInstallStop is null;
}
private void ResetSoftwareLocation(SoftwareDefinition definition)
{
try
{
SoftwareRuntimeManager.ClearConfiguredPath(definition);
adapters.InvalidateRuntime(definition.RuntimeId);
RefreshSoftwareCard(definition);
RefreshCapabilitySummary();
}
catch (Exception exception) when (
exception is IOException or SecurityException or UnauthorizedAccessException)
{
MessageBox.Show(exception.Message, "自动检测失败",
MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
private void ChooseSoftwareLocation(SoftwareDefinition definition)
{
string? selected = null;
if (definition.PathKind == SoftwarePathKind.Executable)
{
using var dialog = new OpenFileDialog
{
Title = $"选择 {definition.DisplayName} 可执行文件",
Filter = $"{definition.RequiredRelativePath}|{definition.RequiredRelativePath}|可执行文件 (*.exe)|*.exe",
CheckFileExists = true,
Multiselect = false,
};
if (dialog.ShowDialog(this) == DialogResult.OK) selected = dialog.FileName;
}
else if (definition.PathKind == SoftwarePathKind.Directory)
{
using var dialog = new FolderBrowserDialog
{
Description = $"选择 {definition.DisplayName} 安装根目录",
UseDescriptionForTitle = true,
ShowNewFolderButton = false,
};
if (dialog.ShowDialog(this) == DialogResult.OK) selected = dialog.SelectedPath;
}
if (selected is null) return;
try
{
SoftwareRuntimeManager.SaveConfiguredPath(definition, selected);
adapters.InvalidateRuntime(definition.RuntimeId);
RefreshSoftwareCard(definition);
RefreshCapabilitySummary();
}
catch (Exception exception) when (
exception is ArgumentException
or IOException
or InvalidDataException
or InvalidOperationException
or SecurityException
or UnauthorizedAccessException)
{
MessageBox.Show(exception.Message, "应用位置无效",
MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
private async Task InstallSoftwareRuntimeAsync(SoftwareDefinition definition)
{
if (adapters.All.Any(item =>
definition.RuntimeId.Equals(item.RuntimeId, StringComparison.Ordinal)
&& item.HasActiveJobs))
{
MessageBox.Show(
$"{definition.DisplayName} 仍有任务在执行,暂不能更换运行环境。",
"暂不能安装", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
var answer = MessageBox.Show(
$"将为 {definition.DisplayName} 创建独立 Python 3.12 运行环境并安装固定依赖。是否继续?",
"安装专业软件运行环境", MessageBoxButtons.YesNo, MessageBoxIcon.Question,
MessageBoxDefaultButton.Button2);
if (answer != DialogResult.Yes) return;
runtimeInstallStop = new CancellationTokenSource();
RefreshSoftwareCards();
var controls = softwareControls[definition.Id];
controls.Status.ForeColor = Color.DarkOrange;
var progress = new Progress<string>(line => controls.Status.Text = line);
try
{
var result = await SoftwareRuntimeManager.InstallRuntimeAsync(
definition,
progress,
runtimeInstallStop.Token);
adapters.InvalidateRuntime(definition.RuntimeId);
controls.Status.ForeColor = Color.ForestGreen;
controls.Status.Text = result.Detail;
MessageBox.Show(
$"{result.Detail}\n{result.RuntimePath}",
"安装完成", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
catch (OperationCanceledException)
{
controls.Status.ForeColor = Color.DimGray;
controls.Status.Text = "安装已取消,原运行环境保持不变。";
}
catch (Exception exception) when (
exception is IOException
or InvalidDataException
or InvalidOperationException
or SecurityException
or UnauthorizedAccessException
or System.ComponentModel.Win32Exception)
{
controls.Status.ForeColor = Color.Firebrick;
controls.Status.Text = $"安装失败:{exception.Message}";
MessageBox.Show(exception.Message, "安装失败",
MessageBoxButtons.OK, MessageBoxIcon.Error);
}
finally
{
runtimeInstallStop.Dispose();
runtimeInstallStop = null;
RefreshSoftwareCards();
RefreshCapabilitySummary();
}
}
private void RefreshCapabilitySummary()
{
capabilitySummary.Text = currentConfig is null
? "注册后启用"
: string.Join("\n", adapters.All.Select(FormatAdapterStatus));
}
private void CopyDiagnostics()
{
if (currentConfig is null)
@ -986,4 +1209,11 @@ internal sealed class ConfigurationForm : Form
layout.Controls.Add(control);
layout.Controls.Add(CreateHint(hint));
}
private sealed record SoftwareControls(
TextBox Path,
Label Status,
Button Automatic,
Button? Choose,
Button Install);
}

View File

@ -13,6 +13,7 @@ internal interface INodeAdapter
IReadOnlyList<string> Features { get; }
bool HasActiveJobs { get; }
string RunningDetail { get; }
string? RuntimeId { get; }
bool SupportsLocalAcceptance { get; }
string RuntimePath { get; }
string ContractPath { get; }
@ -20,6 +21,7 @@ internal interface INodeAdapter
string? WorkspaceStateFilename { get; }
IReadOnlyList<string> PreviewOutputIds { get; }
AdapterRuntimeStatus DetectRuntime();
void InvalidateRuntime();
Task<AdapterAcceptanceResult> RunLocalAcceptanceAsync(
string workRoot,
IProgress<string> progress,
@ -249,6 +251,7 @@ internal sealed class ProcessNodeAdapter : INodeAdapter
public IReadOnlyList<string> Features => Descriptor.Contract.Features;
public bool HasActiveJobs => runner.HasActiveJobs;
public string RunningDetail => Descriptor.Manifest.RunningDetail;
public string? RuntimeId => Descriptor.Manifest.RuntimeId;
public bool SupportsLocalAcceptance =>
Capability.Equals(
"ansys.mechanical.static_structural@v2", StringComparison.Ordinal)
@ -270,6 +273,12 @@ internal sealed class ProcessNodeAdapter : INodeAdapter
return cachedRuntime;
}
public void InvalidateRuntime()
{
cachedRuntime = null;
runtimeCheckedAt = default;
}
public bool ValidateRequest(JsonElement request) =>
Descriptor.Contract.RequestSchema.Evaluate(request, SchemaOptions).IsValid;
@ -311,6 +320,15 @@ internal sealed class NodeAdapterRegistry
internal INodeAdapter? Find(string capability) =>
adapters.TryGetValue(capability, out var adapter) ? adapter : null;
internal void InvalidateRuntime(string runtimeId)
{
foreach (var adapter in adapters.Values.Where(
item => runtimeId.Equals(item.RuntimeId, StringComparison.Ordinal)))
{
adapter.InvalidateRuntime();
}
}
private static IReadOnlyList<AdapterDescriptor> DiscoverDescriptors()
{
if (!Directory.Exists(AdapterRoot)) return [];

View File

@ -38,6 +38,12 @@ internal static class NodeDataRootSettings
Normalize(configured), IsEnvironmentManaged: false, "user");
}
var legacy = LegacyDefaultRoot();
if (Directory.Exists(legacy))
{
return new NodeDataRootSelection(
legacy, IsEnvironmentManaged: false, "legacy-default");
}
return new NodeDataRootSelection(DefaultRoot(), IsEnvironmentManaged: false, "default");
}
@ -67,6 +73,10 @@ internal static class NodeDataRootSettings
}
private static string DefaultRoot() => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Zcbot", "WindowsNode");
private static string LegacyDefaultRoot() => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"Zcbot", "WindowsNode");
}

View File

@ -0,0 +1,530 @@
using Microsoft.Win32;
using System.ComponentModel;
using System.Diagnostics;
using System.Security;
using System.Text;
namespace Zcbot.WindowsNode;
internal enum SoftwarePathKind
{
None,
Executable,
Directory,
}
internal sealed record SoftwareDefinition(
string Id,
string DisplayName,
string RuntimeId,
SoftwarePathKind PathKind,
string? EnvironmentVariable,
string? RequiredRelativePath,
string RequirementsRelativePath);
internal sealed record SoftwareLocation(
bool Available,
string? Path,
string Source,
string Detail);
internal sealed record RuntimeInstallResult(string RuntimePath, string Detail);
internal static class SoftwareRuntimeManager
{
private const string SettingsKey = @"Software\Zcbot\WindowsNode\Software";
private const string DefaultIndexUrl = "https://pypi.tuna.tsinghua.edu.cn/simple/";
internal static IReadOnlyList<SoftwareDefinition> Definitions { get; } =
[
new(
"origin",
"Origin",
"origin",
SoftwarePathKind.None,
null,
null,
@"adapters\origin.plot@v2\requirements.txt"),
new(
"ansys",
"ANSYS Mechanical 2024 R2",
"ansys",
SoftwarePathKind.Directory,
"AWP_ROOT242",
@"aisol\bin\winx64\AnsysWBU.exe",
@"adapters\ansys.mechanical.static_structural@v2\requirements.txt"),
new(
"blender",
"Blender",
"blender",
SoftwarePathKind.Executable,
"ZCBOT_BLENDER_EXE",
"blender.exe",
@"adapters\blender.scene.author@v1\requirements.txt"),
];
internal static SoftwareDefinition ByRuntimeId(string runtimeId) =>
Definitions.Single(item => item.RuntimeId.Equals(runtimeId, StringComparison.Ordinal));
internal static SoftwareLocation ResolveLocation(SoftwareDefinition definition)
{
var configured = ReadConfiguredPath(definition.Id);
if (!string.IsNullOrWhiteSpace(configured))
{
return Location(definition, configured, "界面配置", strict: true);
}
if (definition.EnvironmentVariable is not null)
{
var environment = Environment.GetEnvironmentVariable(definition.EnvironmentVariable);
if (!string.IsNullOrWhiteSpace(environment))
{
return Location(
definition,
environment,
$"环境变量 {definition.EnvironmentVariable}",
strict: true);
}
}
return definition.Id switch
{
"origin" => DetectOrigin(),
"ansys" => DetectAnsys(definition),
"blender" => DetectBlender(definition),
_ => new SoftwareLocation(false, null, "自动检测", "没有可用的自动检测规则。"),
};
}
internal static void SaveConfiguredPath(SoftwareDefinition definition, string path)
{
var location = Location(definition, path, "界面配置", strict: true);
if (!location.Available || location.Path is null)
{
throw new InvalidDataException(location.Detail);
}
using var key = Registry.CurrentUser.CreateSubKey(SettingsKey, writable: true)
?? throw new InvalidOperationException("无法保存专业软件设置。");
key.SetValue(definition.Id, location.Path, RegistryValueKind.String);
}
internal static void ClearConfiguredPath(SoftwareDefinition definition)
{
using var key = Registry.CurrentUser.OpenSubKey(SettingsKey, writable: true);
key?.DeleteValue(definition.Id, throwOnMissingValue: false);
}
internal static void ApplyProcessEnvironment(ProcessStartInfo startInfo, string? runtimeId)
{
if (runtimeId is null)
{
return;
}
var definition = Definitions.SingleOrDefault(
item => item.RuntimeId.Equals(runtimeId, StringComparison.Ordinal));
if (definition?.EnvironmentVariable is null)
{
return;
}
var location = ResolveLocation(definition);
if (location.Available && location.Path is not null)
{
startInfo.Environment[definition.EnvironmentVariable] = location.Path;
}
}
internal static async Task<RuntimeInstallResult> InstallRuntimeAsync(
SoftwareDefinition definition,
IProgress<string> progress,
CancellationToken cancellationToken)
{
var location = ResolveLocation(definition);
if (!location.Available)
{
throw new InvalidOperationException(
$"尚未检测到 {definition.DisplayName}{location.Detail}");
}
var requirements = ResolveBundledFile(definition.RequirementsRelativePath);
var root = NodePaths.ForCurrentMachine().RootDirectory;
var runtimes = Path.Combine(root, "runtimes");
Directory.CreateDirectory(runtimes);
NodeConfigStore.RestrictDirectory(root);
var runtime = Path.Combine(runtimes, definition.RuntimeId);
var staging = Path.Combine(
runtimes,
$".{definition.RuntimeId}.installing-{Guid.NewGuid():N}");
var backup = Path.Combine(
runtimes,
$".{definition.RuntimeId}.rollback-{Guid.NewGuid():N}");
var stagingPython = Path.Combine(staging, "Scripts", "python.exe");
var movedExisting = false;
try
{
progress.Report("正在查找 Python 3.12…");
var bootstrap = await FindPython312Async(cancellationToken);
progress.Report("正在创建隔离运行环境…");
await RunAsync(
bootstrap.FileName,
bootstrap.PrefixArguments.Concat(["-m", "venv", staging]),
AppContext.BaseDirectory,
progress,
cancellationToken);
if (!File.Exists(stagingPython))
{
throw new InvalidDataException("Python venv 未生成 Scripts\\python.exe。");
}
await VerifyPython312Async(stagingPython, cancellationToken);
progress.Report("正在安装 adapter 依赖…");
var indexUrl = Environment.GetEnvironmentVariable("ZCBOT_PIP_INDEX_URL");
if (string.IsNullOrWhiteSpace(indexUrl))
{
indexUrl = DefaultIndexUrl;
}
await RunAsync(
stagingPython,
["-m", "pip", "install", "--index-url", indexUrl, "--requirement", requirements],
AppContext.BaseDirectory,
progress,
cancellationToken);
if (Directory.Exists(runtime))
{
Directory.Move(runtime, backup);
movedExisting = true;
}
Directory.Move(staging, runtime);
if (movedExisting)
{
Directory.Delete(backup, recursive: true);
}
NodeConfigStore.RestrictDirectory(runtime);
return new RuntimeInstallResult(
Path.Combine(runtime, "Scripts", "python.exe"),
$"{definition.DisplayName} 运行环境安装完成。");
}
catch
{
if (movedExisting && !Directory.Exists(runtime) && Directory.Exists(backup))
{
Directory.Move(backup, runtime);
}
throw;
}
finally
{
DeleteDirectoryIfPresent(staging);
DeleteDirectoryIfPresent(backup);
}
}
private static SoftwareLocation DetectOrigin()
{
try
{
using var clsid = Registry.ClassesRoot.OpenSubKey(@"Origin.ApplicationSI\CLSID");
var clsidValue = clsid?.GetValue(null) as string;
if (string.IsNullOrWhiteSpace(clsidValue))
{
return new SoftwareLocation(false, null, "COM 注册", "未找到 Origin COM 注册信息。");
}
using var server = Registry.ClassesRoot.OpenSubKey($@"CLSID\{clsidValue}\LocalServer32");
var command = server?.GetValue(null) as string;
var executable = ExecutableFromCommand(command);
return executable is null
? new SoftwareLocation(false, null, "COM 注册", "Origin COM 服务路径无效。")
: new SoftwareLocation(true, executable, "COM 注册", $"已检测到 {executable}");
}
catch (Exception exception) when (
exception is IOException or SecurityException or UnauthorizedAccessException)
{
return new SoftwareLocation(false, null, "COM 注册", exception.Message);
}
}
private static SoftwareLocation DetectAnsys(SoftwareDefinition definition)
{
var candidates = new List<(string Path, string Source)>();
foreach (var location in UninstallLocations("ANSYS"))
{
candidates.Add((location, "注册表"));
candidates.Add((Path.Combine(location, "v242"), "注册表"));
}
candidates.Add((@"C:\Program Files\ANSYS Inc\v242", "标准目录"));
return FirstLocation(definition, candidates);
}
private static SoftwareLocation DetectBlender(SoftwareDefinition definition)
{
var candidates = new List<(string Path, string Source)>();
var path = FindOnPath("blender.exe");
if (path is not null)
{
candidates.Add((path, "PATH"));
}
candidates.AddRange(UninstallLocations("Blender")
.Select(location => (Path.Combine(location, "blender.exe"), "注册表")));
foreach (var variable in new[] { "ProgramFiles", "ProgramW6432" })
{
var root = Environment.GetEnvironmentVariable(variable);
if (string.IsNullOrWhiteSpace(root)) continue;
var blenderRoot = Path.Combine(root, "Blender Foundation");
if (!Directory.Exists(blenderRoot)) continue;
candidates.AddRange(Directory.EnumerateDirectories(blenderRoot, "Blender *")
.OrderByDescending(item => item, StringComparer.OrdinalIgnoreCase)
.Select(item => (Path.Combine(item, "blender.exe"), "标准目录")));
}
return FirstLocation(definition, candidates);
}
private static SoftwareLocation FirstLocation(
SoftwareDefinition definition,
IEnumerable<(string Path, string Source)> candidates)
{
foreach (var candidate in candidates)
{
var location = Location(definition, candidate.Path, candidate.Source, strict: false);
if (location.Available)
{
return location;
}
}
return new SoftwareLocation(false, null, "自动检测", "没有找到有效的安装位置。");
}
private static SoftwareLocation Location(
SoftwareDefinition definition,
string value,
string source,
bool strict)
{
try
{
var path = Path.GetFullPath(Environment.ExpandEnvironmentVariables(value.Trim().Trim('"')));
var required = definition.PathKind switch
{
SoftwarePathKind.Executable => path,
SoftwarePathKind.Directory => Path.Combine(path, definition.RequiredRelativePath!),
_ => path,
};
var valid = File.Exists(required)
&& (definition.PathKind != SoftwarePathKind.Executable
|| Path.GetFileName(required).Equals(
definition.RequiredRelativePath,
StringComparison.OrdinalIgnoreCase));
return valid
? new SoftwareLocation(true, path, source, $"已检测到 {required}")
: new SoftwareLocation(
false,
strict ? path : null,
source,
$"指定位置缺少 {definition.RequiredRelativePath}。");
}
catch (Exception exception) when (
exception is ArgumentException or IOException or NotSupportedException or SecurityException)
{
return new SoftwareLocation(false, null, source, exception.Message);
}
}
private static string? ReadConfiguredPath(string id)
{
using var key = Registry.CurrentUser.OpenSubKey(SettingsKey);
return key?.GetValue(id) as string;
}
private static IReadOnlyList<string> UninstallLocations(string displayNameFragment)
{
var result = new List<string>();
foreach (var hive in new[] { RegistryHive.LocalMachine, RegistryHive.CurrentUser })
{
foreach (var view in new[] { RegistryView.Registry64, RegistryView.Registry32 })
{
RegistryKey? baseKey = null;
RegistryKey? uninstall = null;
try
{
baseKey = RegistryKey.OpenBaseKey(hive, view);
uninstall = baseKey.OpenSubKey(
@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall");
if (uninstall is null) continue;
foreach (var name in uninstall.GetSubKeyNames())
{
using var entry = uninstall.OpenSubKey(name);
var displayName = entry?.GetValue("DisplayName") as string;
var location = entry?.GetValue("InstallLocation") as string;
if (!string.IsNullOrWhiteSpace(displayName)
&& displayName.Contains(displayNameFragment, StringComparison.OrdinalIgnoreCase)
&& !string.IsNullOrWhiteSpace(location))
{
result.Add(location);
}
}
}
catch (Exception exception) when (
exception is IOException or SecurityException or UnauthorizedAccessException)
{
// A registry view can be unavailable to the current node account.
}
finally
{
uninstall?.Dispose();
baseKey?.Dispose();
}
}
}
return result.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
}
private static string? FindOnPath(string filename)
{
var path = Environment.GetEnvironmentVariable("PATH");
if (string.IsNullOrWhiteSpace(path)) return null;
foreach (var directory in path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
{
try
{
var candidate = Path.Combine(directory.Trim().Trim('"'), filename);
if (File.Exists(candidate)) return Path.GetFullPath(candidate);
}
catch (Exception exception) when (
exception is ArgumentException or IOException or NotSupportedException)
{
// Ignore malformed PATH entries and continue with the next one.
}
}
return null;
}
private static string? ExecutableFromCommand(string? command)
{
if (string.IsNullOrWhiteSpace(command)) return null;
var value = Environment.ExpandEnvironmentVariables(command.Trim());
var candidate = value.StartsWith('"')
? value[1..].Split('"', 2)[0]
: value.Split(".exe", 2, StringSplitOptions.None)[0] + ".exe";
return File.Exists(candidate) ? Path.GetFullPath(candidate) : null;
}
private static string ResolveBundledFile(string relativePath)
{
var root = Path.GetFullPath(AppContext.BaseDirectory);
var path = Path.GetFullPath(Path.Combine(root, relativePath));
if (!path.StartsWith(root, StringComparison.OrdinalIgnoreCase) || !File.Exists(path))
{
throw new InvalidDataException("Adapter requirements 文件缺失或路径非法。");
}
return path;
}
private static async Task<BootstrapPython> FindPython312Async(
CancellationToken cancellationToken)
{
var candidates = new[]
{
new BootstrapPython("py.exe", ["-3.12"]),
new BootstrapPython("python.exe", []),
};
foreach (var candidate in candidates)
{
try
{
await RunAsync(
candidate.FileName,
candidate.PrefixArguments.Concat(
["-c", "import sys; raise SystemExit(0 if sys.version_info[:2] == (3, 12) else 1)"]),
AppContext.BaseDirectory,
progress: null,
cancellationToken);
return candidate;
}
catch (Exception exception) when (
exception is InvalidOperationException or Win32Exception)
{
// Try the next fixed Python 3.12 discovery method.
}
}
throw new InvalidOperationException(
"未找到 Python 3.12。请先安装 Python 3.12 并启用 py launcher 或加入 PATH。");
}
private static Task VerifyPython312Async(string python, CancellationToken cancellationToken) =>
RunAsync(
python,
["-c", "import sys; raise SystemExit(0 if sys.version_info[:2] == (3, 12) else 1)"],
AppContext.BaseDirectory,
progress: null,
cancellationToken);
private static async Task RunAsync(
string filename,
IEnumerable<string> arguments,
string workingDirectory,
IProgress<string>? progress,
CancellationToken cancellationToken)
{
var startInfo = new ProcessStartInfo
{
FileName = filename,
WorkingDirectory = workingDirectory,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8,
};
foreach (var argument in arguments)
{
startInfo.ArgumentList.Add(argument);
}
startInfo.Environment["PYTHONUTF8"] = "1";
startInfo.Environment["PYTHONIOENCODING"] = "utf-8";
using var process = Process.Start(startInfo)
?? throw new InvalidOperationException("运行环境安装进程未启动。");
var stdout = RelayAsync(process.StandardOutput, progress);
var stderr = RelayAsync(process.StandardError, progress);
try
{
await process.WaitForExitAsync(cancellationToken);
}
catch (OperationCanceledException)
{
if (!process.HasExited) process.Kill(entireProcessTree: true);
throw;
}
await Task.WhenAll(stdout, stderr);
if (process.ExitCode != 0)
{
throw new InvalidOperationException(
$"运行环境安装进程失败,退出码 {process.ExitCode}。");
}
}
private static async Task RelayAsync(StreamReader reader, IProgress<string>? progress)
{
while (await reader.ReadLineAsync() is { } line)
{
if (!string.IsNullOrWhiteSpace(line)) progress?.Report(line);
}
}
private static void DeleteDirectoryIfPresent(string path)
{
try
{
if (Directory.Exists(path)) Directory.Delete(path, recursive: true);
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
// A successful runtime swap must not be reported as failed only because
// antivirus software still holds a handle to the disposable directory.
}
}
private sealed record BootstrapPython(
string FileName,
IReadOnlyList<string> PrefixArguments);
}

View File

@ -40,10 +40,5 @@
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
<Content Include="..\install-windows-node.bat">
<Link>install-windows-node.bat</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
</ItemGroup>
</Project>

View File

@ -1,235 +0,0 @@
@echo off
setlocal EnableExtensions
cd /d "%~dp0"
set "NODE_EXE=%~dp0Zcbot.WindowsNode.exe"
set "NODE_DATA_ROOT=%ZCBOT_WINDOWS_NODE_DATA_DIR%"
if not defined NODE_DATA_ROOT (
for /f "tokens=2,*" %%A in ('reg.exe query "HKCU\Software\Zcbot\WindowsNode" /v DataRoot 2^>nul ^| findstr.exe /I /C:"DataRoot"') do set "NODE_DATA_ROOT=%%B"
)
if not defined NODE_DATA_ROOT set "NODE_DATA_ROOT=%ProgramData%\Zcbot\WindowsNode"
set "ORIGIN_REQUIREMENTS=%~dp0adapters\origin.plot@v2\requirements.txt"
set "ORIGIN_RUNTIME_DIR=%NODE_DATA_ROOT%\runtimes\origin"
set "ORIGIN_RUNTIME_PYTHON=%ORIGIN_RUNTIME_DIR%\Scripts\python.exe"
set "ANSYS_REQUIREMENTS=%~dp0adapters\ansys.mechanical.static_structural@v2\requirements.txt"
set "ANSYS_RUNTIME_DIR=%NODE_DATA_ROOT%\runtimes\ansys"
set "ANSYS_RUNTIME_PYTHON=%ANSYS_RUNTIME_DIR%\Scripts\python.exe"
set "BLENDER_REQUIREMENTS=%~dp0adapters\blender.scene.author@v1\requirements.txt"
set "BLENDER_RUNTIME_DIR=%NODE_DATA_ROOT%\runtimes\blender"
set "BLENDER_RUNTIME_PYTHON=%BLENDER_RUNTIME_DIR%\Scripts\python.exe"
if not defined ZCBOT_PIP_INDEX_URL set "ZCBOT_PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple/"
echo [INFO] Installing zcbot Windows Node...
echo [INFO] Data root: "%NODE_DATA_ROOT%"
if not exist "%NODE_EXE%" (
echo [ERR] Zcbot.WindowsNode.exe was not found beside this installer.
goto :failed
)
call :detect_origin
if errorlevel 1 (
echo [WARN] Origin/OriginPro was not detected; skipped the Origin runtime.
goto :install_ansys
)
echo [INFO] Origin/OriginPro detected.
if not exist "%ORIGIN_REQUIREMENTS%" (
echo [ERR] Origin worker requirements are missing: %ORIGIN_REQUIREMENTS%
goto :failed
)
if not exist "%ORIGIN_RUNTIME_PYTHON%" (
echo [INFO] Creating Origin Python runtime...
call :create_python_runtime "%ORIGIN_RUNTIME_DIR%" "%~1"
if errorlevel 1 (
echo [ERR] Failed to create the Origin Python runtime.
goto :failed
)
)
if not exist "%ORIGIN_RUNTIME_PYTHON%" (
echo [ERR] Python venv did not create its interpreter: %ORIGIN_RUNTIME_PYTHON%
goto :failed
)
"%ORIGIN_RUNTIME_PYTHON%" -c "import sys; raise SystemExit(0 if sys.version_info[:2] == (3, 12) else 1)"
if errorlevel 1 (
echo [ERR] Existing Origin runtime is not Python 3.12: %ORIGIN_RUNTIME_PYTHON%
goto :failed
)
echo [INFO] Installing Origin worker dependencies...
"%ORIGIN_RUNTIME_PYTHON%" -m pip install --index-url "%ZCBOT_PIP_INDEX_URL%" --requirement "%ORIGIN_REQUIREMENTS%"
if errorlevel 1 (
echo [ERR] Failed to install the Origin worker dependencies.
goto :failed
)
"%ORIGIN_RUNTIME_PYTHON%" -c "import originpro, openpyxl, numpy, pymupdf; print('[OK] Origin worker Python packages are available.')"
if errorlevel 1 (
echo [ERR] Origin runtime import verification failed.
goto :failed
)
echo [OK] Origin runtime installed: %ORIGIN_RUNTIME_PYTHON%
:install_ansys
call :detect_ansys_242
if errorlevel 1 (
echo [WARN] ANSYS Mechanical 2024 R2 was not detected; skipped the ANSYS runtime.
goto :install_blender
)
echo [INFO] ANSYS Mechanical 2024 R2 detected.
if not exist "%ANSYS_REQUIREMENTS%" (
echo [ERR] ANSYS worker requirements are missing: %ANSYS_REQUIREMENTS%
goto :failed
)
if not exist "%ANSYS_RUNTIME_PYTHON%" (
echo [INFO] Creating ANSYS Python runtime...
call :create_python_runtime "%ANSYS_RUNTIME_DIR%" "%~1"
if errorlevel 1 (
echo [ERR] Failed to create the ANSYS Python runtime.
goto :failed
)
)
if not exist "%ANSYS_RUNTIME_PYTHON%" (
echo [ERR] Python venv did not create its interpreter: %ANSYS_RUNTIME_PYTHON%
goto :failed
)
"%ANSYS_RUNTIME_PYTHON%" -c "import sys; raise SystemExit(0 if sys.version_info[:2] == (3, 12) else 1)"
if errorlevel 1 (
echo [ERR] Existing ANSYS runtime is not Python 3.12: %ANSYS_RUNTIME_PYTHON%
goto :failed
)
echo [INFO] Installing ANSYS worker dependencies...
"%ANSYS_RUNTIME_PYTHON%" -m pip install --index-url "%ZCBOT_PIP_INDEX_URL%" --requirement "%ANSYS_REQUIREMENTS%"
if errorlevel 1 (
echo [ERR] Failed to install the ANSYS worker dependencies.
goto :failed
)
"%ANSYS_RUNTIME_PYTHON%" -c "from ansys.mechanical.core import App; print('[OK] ANSYS worker Python packages are available.')"
if errorlevel 1 (
echo [ERR] ANSYS runtime import verification failed.
goto :failed
)
echo [OK] ANSYS runtime installed: %ANSYS_RUNTIME_PYTHON%
:install_blender
call :detect_blender
if errorlevel 1 (
echo [WARN] Blender was not detected; skipped the Blender launcher runtime.
goto :install_startup
)
echo [INFO] Blender detected.
if not exist "%BLENDER_REQUIREMENTS%" (
echo [ERR] Blender adapter requirements marker is missing: %BLENDER_REQUIREMENTS%
goto :failed
)
if not exist "%BLENDER_RUNTIME_PYTHON%" (
echo [INFO] Creating Blender launcher runtime...
call :create_python_runtime "%BLENDER_RUNTIME_DIR%" "%~1"
if errorlevel 1 (
echo [ERR] Failed to create the Blender launcher runtime.
goto :failed
)
)
if not exist "%BLENDER_RUNTIME_PYTHON%" (
echo [ERR] Python venv did not create its interpreter: %BLENDER_RUNTIME_PYTHON%
goto :failed
)
"%BLENDER_RUNTIME_PYTHON%" -c "import sys; raise SystemExit(0 if sys.version_info[:2] == (3, 12) else 1)"
if errorlevel 1 (
echo [ERR] Existing Blender launcher runtime is not Python 3.12: %BLENDER_RUNTIME_PYTHON%
goto :failed
)
echo [OK] Blender launcher runtime installed: %BLENDER_RUNTIME_PYTHON%
:install_startup
echo [INFO] Registering startup task for %USERDOMAIN%\%USERNAME%...
schtasks.exe /Create /TN "Zcbot Windows Node" /SC ONLOGON /RU "%USERDOMAIN%\%USERNAME%" /RL LIMITED /IT /TR "\"%NODE_EXE%\"" /F
if errorlevel 1 (
echo [ERR] Failed to register the Windows Node startup task.
goto :failed
)
echo [OK] Windows Node installation completed.
echo [INFO] Start Zcbot.WindowsNode.exe to register or connect the node.
goto :finish_ok
:detect_origin
reg.exe query "HKCR\Origin.ApplicationSI\CLSID" >nul 2>&1
if not errorlevel 1 exit /b 0
reg.exe query "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" /s /f "OriginLab" /d >nul 2>&1
if not errorlevel 1 exit /b 0
reg.exe query "HKLM\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall" /s /f "OriginLab" /d >nul 2>&1
if not errorlevel 1 exit /b 0
reg.exe query "HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" /s /f "OriginLab" /d >nul 2>&1
if not errorlevel 1 exit /b 0
exit /b 1
:detect_ansys_242
if defined AWP_ROOT242 if exist "%AWP_ROOT242%\aisol\bin\winx64\AnsysWBU.exe" exit /b 0
if exist "C:\Program Files\ANSYS Inc\v242\aisol\bin\winx64\AnsysWBU.exe" exit /b 0
exit /b 1
:detect_blender
if defined ZCBOT_BLENDER_EXE if exist "%ZCBOT_BLENDER_EXE%" exit /b 0
where.exe blender.exe >nul 2>&1
if not errorlevel 1 exit /b 0
for /d %%D in ("%ProgramFiles%\Blender Foundation\Blender *") do (
if exist "%%~fD\blender.exe" exit /b 0
)
exit /b 1
:create_python_runtime
if not "%~2"=="" (
if not exist "%~2" (
echo [ERR] Python executable was not found: %~2
exit /b 1
)
"%~2" -c "import sys; raise SystemExit(0 if sys.version_info[:2] == (3, 12) else 1)"
if errorlevel 1 (
echo [ERR] The specified Python must be Python 3.12: %~2
exit /b 1
)
echo [INFO] Using Python 3.12: %~2
"%~2" -m venv "%~1"
exit /b %ERRORLEVEL%
)
where.exe py.exe >nul 2>&1
if not errorlevel 1 (
py.exe -3.12 -c "import sys; raise SystemExit(0 if sys.version_info[:2] == (3, 12) else 1)" >nul 2>&1
if not errorlevel 1 (
echo [INFO] Using Python 3.12 through py.exe.
py.exe -3.12 -m venv "%~1"
exit /b %ERRORLEVEL%
)
)
where.exe python.exe >nul 2>&1
if not errorlevel 1 (
python.exe -c "import sys; raise SystemExit(0 if sys.version_info[:2] == (3, 12) else 1)" >nul 2>&1
if not errorlevel 1 (
echo [INFO] Using Python 3.12 from PATH.
python.exe -m venv "%~1"
exit /b %ERRORLEVEL%
)
)
echo [ERR] Python 3.12 was not found.
echo [INFO] Add Python 3.12 to PATH or run: install-windows-node.bat "C:\path\python.exe"
exit /b 1
:failed
echo.
echo [ERR] Installation failed.
echo [INFO] Press any key to close this window.
pause >nul
exit /b 1
:finish_ok
echo.
echo [INFO] Press any key to close this window.
pause >nul
exit /b 0

View File

@ -62,7 +62,6 @@ if errorlevel 1 (
for %%F in (
"Zcbot.WindowsNode.exe"
"install-windows-node.bat"
) do (
if not exist "!PUBLISH_DIR!\%%~F" (
echo [ERR] Published package is incomplete: %%~F
@ -108,7 +107,7 @@ if not defined SHA256 (
echo [OK] Package: !ARCHIVE_PATH!
echo [OK] SHA256: !SHA256!
echo [INFO] Extract the ZIP and double-click install-windows-node.bat.
echo [INFO] Extract the ZIP and start Zcbot.WindowsNode.exe.
goto :finish_ok
:failed