diff --git a/CHANGELOG.md b/CHANGELOG.md index fa7227f..93c201e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ - 修复国际旗舰模型输出思考过程时偶尔无法及时停止的问题;即使模型暂时没有返回新的流式片段,点击“停止”也会迅速中断当前回答。 +- Windows Node 主窗口重新整理为更宽、更清晰的桌面控制台:节点状态、能力概览和维护操作分层展示,专业软件改为独立配置行和通栏地址框,Origin、ANSYS、Blender 均可手工选择位置;Origin 自动检测也会在 COM 路径无效时继续查找注册表和标准安装目录。 + - Windows Node 现在可在“专业软件”区域自动检测或手工指定应用位置,并分别安装、更新 Origin、ANSYS、Blender 的独立运行环境;只装有部分专业软件的节点不再需要处理无关软件。 - 对话中尚未发送的文字现在会按对话自动暂存;助手回答或后台进程执行期间也可以继续发送补充信息,消息会显示为待处理并在当前工作结束后依次发送。动作按钮采用单按钮形态:忙碌且输入为空时用于停止,输入文字或加入附件后自动切回发送。切换对话或刷新页面后,草稿和待处理消息仍会保留。 diff --git a/PROGRESS.md b/PROGRESS.md index 91e8170..39b04b3 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。 -最后更新:2026-08-20(Windows Node 专业软件独立配置与 runtime 安装,未发版) +最后更新:2026-08-21(Windows Node 软件地址选择与 Origin 检测修正,未发版) --- @@ -20,7 +20,9 @@ --- ## 已完成关键能力 -- **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-21 / Unreleased / Windows Node 软件地址选择与 Origin 检测修正**:专业软件改为带边界的独立配置行和通栏只读地址框,Origin、ANSYS、Blender 均可手选位置。Origin 自动检测由单一 COM 路径扩展为 COM、卸载注册表和标准目录回退,手选的 `Origin*.exe` 同时用于 Worker 版本识别,COM 执行健康仍由 probe 独立校验。默认窗口扩大为桌面控制台比例,节点状态改为独立状态徽标,能力概览只保留软件与 adapter 的可扫读摘要,完整错误继续通过诊断复制提供。Windows Node 源码与 Origin Worker 专项 unittest、.NET build 与 diff 检查通过,无 DB、schema、HTTP API、依赖或运行方式变化。 + +- **08-20 / Unreleased / Windows Node 专业软件独立配置与 runtime 安装**:主窗口新增 Origin、ANSYS Mechanical 2024 R2、Blender 软件卡,分别显示应用位置来源和受管 runtime 状态;三种软件均可由 UI 选择自定义位置,清除后按各自的环境变量、COM、卸载注册表、PATH 或标准目录自动检测。每个软件可单独创建或更新隔离的 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、依赖或运行方式变化。 diff --git a/RUN.md b/RUN.md index 66c9d76..473dbd1 100644 --- a/RUN.md +++ b/RUN.md @@ -1099,7 +1099,7 @@ Windows Node 不需要安装脚本。解压发布包后,使用实际运行 Nod Workspace 目录固定为 `\workspaces\\`,不使用 user ID 上层目录。`current` 是当前可续作工程,`rollback` 只保留上一版用于失败恢复;每次成功续作替换 `current`,不会按 Job 永久堆积工程副本。不要手工移动 workspace 到其他节点:服务端会把后续 Job 固定调度到其 home node,节点离线时任务保持等待。备份或迁移节点时必须把数据库中的 workspace 归属和该目录作为一个整体处理。 -主窗口“专业软件”按 Origin、ANSYS Mechanical 2024 R2 和 Blender 分别管理应用位置与隔离 runtime。每张软件卡都可单独执行“安装/更新运行环境”,只会读取安装包内固定 requirements,并先在临时目录完成后再替换该软件的 `runtimes/`;有对应活动任务时拒绝替换。安装要求本机存在 Python 3.12,可通过 `py -3.12` 或 PATH 中的 `python.exe` 找到。Blender 和 ANSYS 可用“选择位置”保存当前专用账号的配置,点“自动检测”会清除手工值并重新按环境变量、Windows 卸载注册表、PATH/标准目录探测;Origin 使用 COM 注册自动检测。环境变量 `ZCBOT_BLENDER_EXE` 与 `AWP_ROOT242` 继续兼容。配置或安装完成后不必重新注册节点,后续 probe/心跳会刷新能力状态。 +主窗口“专业软件”按 Origin、ANSYS Mechanical 2024 R2 和 Blender 分别管理应用位置与隔离 runtime。每张软件卡都可单独执行“安装/更新运行环境”,只会读取安装包内固定 requirements,并先在临时目录完成后再替换该软件的 `runtimes/`;有对应活动任务时拒绝替换。安装要求本机存在 Python 3.12,可通过 `py -3.12` 或 PATH 中的 `python.exe` 找到。三种软件均可用“选择位置”保存当前专用账号的配置,点“自动检测”会清除手工值并重新探测;Origin 按 `ZCBOT_ORIGIN_EXE`、COM 注册、Windows 卸载注册表和 OriginLab 标准目录查找 `Origin*.exe`,ANSYS 与 Blender 继续按各自环境变量、注册表、PATH 或标准目录查找。环境变量 `ZCBOT_ORIGIN_EXE`、`ZCBOT_BLENDER_EXE` 与 `AWP_ROOT242` 继续兼容。选择 Origin 程序只确定安装位置和版本读取来源,实际执行仍要求 Origin COM probe 通过。配置或安装完成后不必重新注册节点,后续 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 或本机路径。 diff --git a/tests/test_origin_worker.py b/tests/test_origin_worker.py index 3fccadd..78afc53 100644 --- a/tests/test_origin_worker.py +++ b/tests/test_origin_worker.py @@ -81,6 +81,17 @@ class OriginWorkerUnitTests(unittest.TestCase): FakeRegistry.values[r"CLSID\{origin-clsid}\LocalServer32"] = "invalid" self.assertIsNone(worker._registered_origin_version(FakeRegistry)) + def test_configured_origin_executable_takes_priority_for_version(self) -> None: + with tempfile.TemporaryDirectory() as directory: + executable = Path(directory) / "Origin64.exe" + executable.touch() + with ( + patch.dict("os.environ", {"ZCBOT_ORIGIN_EXE": str(executable)}), + patch.object(worker, "_windows_file_version", return_value="2024b") as read, + ): + self.assertEqual(worker._registered_origin_version(object()), "2024b") + read.assert_called_once_with(executable.resolve()) + def test_csv_and_json_inputs_are_read_without_origin(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) diff --git a/tests/test_windows_node_source.py b/tests/test_windows_node_source.py index 3d4467e..94f85d8 100644 --- a/tests/test_windows_node_source.py +++ b/tests/test_windows_node_source.py @@ -126,7 +126,7 @@ class WindowsNodeSourceTests(unittest.TestCase): )[0] self.assertNotIn("NodeToken", diagnostics) self.assertIn("内容不包含 Node Token", form) - self.assertIn("清除本机身份并重新注册", form) + self.assertIn("清除本机身份", form) self.assertIn("管理后台删除或禁用云端旧节点", form) def test_ansys_acceptance_ui_runs_only_fixed_bundled_suite_before_gate(self) -> None: @@ -157,9 +157,11 @@ class WindowsNodeSourceTests(unittest.TestCase): 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('CreateButton("自动检测", 92)', form) + self.assertIn('CreateButton("选择位置", 92)', form) + self.assertIn('CreateButton("安装 / 更新环境", 150', form) + self.assertIn("panel.SetColumnSpan(path, 2)", form) + self.assertIn("path.ReadOnly = true", form) self.assertIn("SoftwareRuntimeManager.InstallRuntimeAsync", form) self.assertIn("item.HasActiveJobs", form) for runtime_id in ('"origin"', '"ansys"', '"blender"'): @@ -169,6 +171,9 @@ class WindowsNodeSourceTests(unittest.TestCase): self.assertIn("RegistryHive.CurrentUser", manager) self.assertIn('"ZCBOT_BLENDER_EXE"', manager) self.assertIn('"AWP_ROOT242"', manager) + self.assertIn('"ZCBOT_ORIGIN_EXE"', manager) + self.assertIn('"Origin*.exe"', manager) + self.assertIn("OriginExecutables", manager) self.assertIn('Path.Combine(runtimes, definition.RuntimeId)', manager) self.assertIn("ArgumentList.Add(argument)", manager) self.assertNotIn('"cmd.exe"', manager.lower()) @@ -177,7 +182,7 @@ class WindowsNodeSourceTests(unittest.TestCase): 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) + self.assertIn("ClientSize = new Size(1100, 760)", form) self.assertIn("FormBorderStyle.Sizable", form) self.assertIn("AutoScaleMode.Dpi", form) self.assertIn("AutoScroll = true", form) @@ -186,9 +191,10 @@ class WindowsNodeSourceTests(unittest.TestCase): self.assertIn("成功注册一次后立即失效", form) self.assertIn("CreateCard", form) self.assertIn("注册并连接", form) - self.assertIn("ContentWidth = 760", form) - self.assertIn("NodeAdapterRegistry.InstalledContracts", form) - self.assertIn("CreateCapabilityRow(contract.DisplayName, contract.Capability)", form) + self.assertIn("ContentWidth = 1020", form) + self.assertIn("CreateSubsectionTitle(\"能力概览\")", form) + self.assertIn("panel.SetColumnSpan(path, 2)", form) + self.assertIn("BorderedTableLayoutPanel", form) self.assertIn( 'runtime.Software == "OriginPro" ? "Origin" : runtime.Software', form ) diff --git a/windows-node/README.md b/windows-node/README.md index 3516643..f18ee5f 100644 --- a/windows-node/README.md +++ b/windows-node/README.md @@ -18,7 +18,7 @@ windows-node\package-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 位于 `\runtimes\\Scripts\python.exe`。全新节点默认使用 `%LocalAppData%\Zcbot\WindowsNode`;已有 `%ProgramData%\Zcbot\WindowsNode` 的升级节点继续沿用原目录。 -推荐在 Node 主窗口“专业软件”区域完成后续配置:Origin、ANSYS、Blender 各有独立软件卡,可分别自动检测应用、选择受支持的自定义位置,并单独“安装/更新运行环境”。安装动作只接受内置软件定义和随包发布的固定 requirements,不执行用户输入的命令;新环境先在同一数据根目录的临时目录构建,成功后再替换对应 runtime。Blender 与 ANSYS 的手工路径保存在当前专用 Windows 账号下,Node 启动 Worker 时注入固定环境变量;“自动检测”可清除手工路径并回到环境变量、卸载注册表、PATH/标准目录发现。Origin 继续以 COM 注册为应用事实源。 +推荐在 Node 主窗口“专业软件”区域完成后续配置:Origin、ANSYS、Blender 各有独立软件卡,均可自动检测或手工选择受支持的位置,并单独“安装/更新运行环境”。安装动作只接受内置软件定义和随包发布的固定 requirements,不执行用户输入的命令;新环境先在同一数据根目录的临时目录构建,成功后再替换对应 runtime。手工路径保存在当前专用 Windows 账号下,Node 启动 Worker 时注入固定环境变量;“自动检测”可清除手工路径并回到环境变量、COM、卸载注册表、PATH 或标准目录发现。Origin 的手选程序用于确定安装位置和读取版本,实际执行仍以 COM probe 为准。 主窗口“运行设置”可把完整数据根目录迁移到其他本机固定磁盘。Node 会先停止接收任务,已有未完成任务时拒绝开始;随后把身份、任务、Workspace 和 runtime 复制到临时目录并逐文件校验,校验成功后才切换账号级配置并重启,旧目录不会自动删除。机器级 `ZCBOT_WINDOWS_NODE_DATA_DIR` 优先于 UI 设置;设置后 UI 只读,适合统一运维。软件卡始终把 runtime 安装到当前数据根目录,后续更新不会写回默认目录。 diff --git a/windows-node/Zcbot.WindowsNode/ConfigurationForm.cs b/windows-node/Zcbot.WindowsNode/ConfigurationForm.cs index 5bbb8e6..5ceca93 100644 --- a/windows-node/Zcbot.WindowsNode/ConfigurationForm.cs +++ b/windows-node/Zcbot.WindowsNode/ConfigurationForm.cs @@ -8,14 +8,14 @@ namespace Zcbot.WindowsNode; internal sealed class ConfigurationForm : Form { - private const int ContentWidth = 760; + private const int ContentWidth = 1020; private readonly TextBox server = CreateTextBox("http://127.0.0.1:8765"); private readonly TextBox nodeName = CreateTextBox(Environment.MachineName.ToLowerInvariant()); private readonly TextBox enrollmentCode = CreateTextBox(usePassword: true); private readonly Button register = CreateButton("注册并连接", 128, primary: true); private readonly Button reconnect = CreateButton("立即重连", 112, primary: true); - private readonly Button resetIdentity = CreateButton("清除本机身份并重新注册", 220); + private readonly Button resetIdentity = CreateButton("清除本机身份", 132, danger: true); private readonly Button copyDiagnostics = CreateButton("复制诊断信息", 132); private readonly TextBox dataRoot = CreateTextBox(); private readonly Button changeDataRoot = CreateButton("更改并迁移", 124, primary: true); @@ -32,8 +32,10 @@ internal sealed class ConfigurationForm : Form private readonly Label state = new() { AutoSize = true, - Font = new Font("Microsoft YaHei UI", 13, FontStyle.Bold), - Margin = new Padding(0, 2, 0, 5), + Anchor = AnchorStyles.Right, + Font = new Font("Microsoft YaHei UI", 10, FontStyle.Bold), + Margin = new Padding(12, 2, 0, 2), + Padding = new Padding(10, 5, 10, 5), }; private readonly Label detail = CreateBodyLabel(); private readonly Label identity = CreateBodyLabel(); @@ -74,12 +76,12 @@ internal sealed class ConfigurationForm : Form adapters = NodeAdapterRegistry.CreateDefault(jobInbox); Text = "zcbot Windows Node"; AutoScaleMode = AutoScaleMode.Dpi; - ClientSize = new Size(840, 680); - MinimumSize = new Size(820, 640); + ClientSize = new Size(1100, 760); + MinimumSize = new Size(900, 680); StartPosition = FormStartPosition.CenterScreen; Font = new Font("Microsoft YaHei UI", 9); FormBorderStyle = FormBorderStyle.Sizable; - BackColor = Color.FromArgb(241, 245, 249); + BackColor = Color.FromArgb(246, 248, 252); var shell = new TableLayoutPanel { @@ -87,7 +89,7 @@ internal sealed class ConfigurationForm : Form AutoScroll = true, ColumnCount = 3, RowCount = 1, - Padding = new Padding(0, 22, 0, 22), + Padding = new Padding(0, 28, 0, 28), BackColor = BackColor, }; shell.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50)); @@ -101,7 +103,7 @@ internal sealed class ConfigurationForm : Form AutoSizeMode = AutoSizeMode.GrowAndShrink, Dock = DockStyle.Top, ColumnCount = 1, - RowCount = 7, + RowCount = 8, BackColor = BackColor, }; page.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); @@ -112,40 +114,57 @@ internal sealed class ConfigurationForm : Form AutoSize = true, Dock = DockStyle.Top, ColumnCount = 1, - Margin = new Padding(4, 0, 4, 18), + Margin = new Padding(4, 0, 4, 22), }; heading.Controls.Add(new Label + { + Text = "LOCAL EXECUTION NODE", + AutoSize = true, + Font = new Font("Segoe UI", 8, FontStyle.Bold), + ForeColor = Color.FromArgb(37, 99, 235), + Margin = new Padding(1, 0, 0, 5), + }); + heading.Controls.Add(new Label { Text = "zcbot Windows Node", AutoSize = true, - Font = new Font("Microsoft YaHei UI", 20, FontStyle.Bold), + Font = new Font("Microsoft YaHei UI", 23, FontStyle.Bold), ForeColor = Color.FromArgb(15, 23, 42), }); - heading.Controls.Add(CreateHint("连接本机科研软件与 zcbot 的受控执行节点")); + var headingHint = CreateHint("连接本机科研软件与 zcbot,安全执行受控任务"); + headingHint.Font = new Font("Microsoft YaHei UI", 10); + heading.Controls.Add(headingHint); page.Controls.Add(heading); var statusCard = CreateCard(); - statusCard.Controls.Add(CreateSectionTitle("节点")); - statusCard.Controls.Add(state); + var statusHeader = new TableLayoutPanel + { + AutoSize = true, + Dock = DockStyle.Top, + ColumnCount = 2, + Margin = new Padding(0, 0, 0, 8), + }; + statusHeader.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + statusHeader.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + statusHeader.Controls.Add(CreateSectionTitle("节点状态"), 0, 0); + statusHeader.Controls.Add(state, 1, 0); + statusCard.Controls.Add(statusHeader); statusCard.Controls.Add(detail); statusCard.Controls.Add(identity); statusCard.Controls.Add(CreateDivider()); - foreach (var contract in NodeAdapterRegistry.InstalledContracts) - { - statusCard.Controls.Add(CreateCapabilityRow(contract.DisplayName, contract.Capability)); - } + statusCard.Controls.Add(CreateSubsectionTitle("能力概览")); statusCard.Controls.Add(capabilitySummary); var resetActions = CreateActions(); resetActions.Controls.Add(reconnect); - resetActions.Controls.Add(resetIdentity); resetActions.Controls.Add(copyDiagnostics); + resetActions.Controls.Add(resetIdentity); 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)); @@ -261,10 +280,17 @@ internal sealed class ConfigurationForm : Form }; state.ForeColor = status.State switch { - NodeState.Online => Color.ForestGreen, - NodeState.Connecting => Color.DarkOrange, - NodeState.NotRegistered or NodeState.AuthenticationRequired => Color.Firebrick, - _ => Color.DimGray, + NodeState.Online => Color.FromArgb(21, 128, 61), + NodeState.Connecting => Color.FromArgb(180, 83, 9), + NodeState.NotRegistered or NodeState.AuthenticationRequired => Color.FromArgb(185, 28, 28), + _ => Color.FromArgb(71, 85, 105), + }; + state.BackColor = status.State switch + { + NodeState.Online => Color.FromArgb(240, 253, 244), + NodeState.Connecting => Color.FromArgb(255, 251, 235), + NodeState.NotRegistered or NodeState.AuthenticationRequired => Color.FromArgb(254, 242, 242), + _ => Color.FromArgb(241, 245, 249), }; detail.Text = $"{status.Message} {status.ChangedAt:HH:mm:ss}"; identity.Text = config is null @@ -296,47 +322,61 @@ internal sealed class ConfigurationForm : Form var softwareVersion = string.IsNullOrWhiteSpace(runtime.SoftwareVersion) ? $"{softwareName} 版本未知" : $"{softwareName} {runtime.SoftwareVersion}"; - var state = runtime.Health == "ready" ? "可用" : "不可用"; - return $"{adapter.DisplayName}:{state} · Adapter {adapter.AdapterVersion} · {softwareVersion}\n" - + runtime.Detail; + var state = runtime.Health == "ready" ? "可用" : "需配置"; + return $"{adapter.DisplayName} {state}\n" + + $"{softwareVersion} · Adapter {adapter.AdapterVersion}"; } private Control CreateSoftwarePanel(SoftwareDefinition definition) { - var panel = new TableLayoutPanel + var panel = new BorderedTableLayoutPanel { AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink, Dock = DockStyle.Top, - ColumnCount = 1, - Padding = new Padding(0, 10, 0, 8), - Margin = new Padding(0, 6, 0, 0), + ColumnCount = 2, + Padding = new Padding(16, 14, 16, 14), + Margin = new Padding(0, 10, 0, 0), + BackColor = Color.FromArgb(248, 250, 252), + BorderColor = Color.FromArgb(226, 232, 240), }; - panel.Controls.Add(new Label + panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + panel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + var title = 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), - }); + Font = new Font("Microsoft YaHei UI", 10, FontStyle.Bold), + ForeColor = Color.FromArgb(15, 23, 42), + Margin = new Padding(0, 0, 0, 4), + }; + panel.Controls.Add(title, 0, 0); var path = CreateTextBox(); path.ReadOnly = true; - panel.Controls.Add(path); + path.Dock = DockStyle.Fill; + path.Margin = new Padding(0, 7, 0, 10); + path.BackColor = Color.White; + panel.Controls.Add(path, 0, 1); + panel.SetColumnSpan(path, 2); var status = CreateBodyLabel(); - panel.Controls.Add(status); + status.Anchor = AnchorStyles.Left; + status.Margin = new Padding(0, 3, 16, 0); + panel.Controls.Add(status, 0, 2); var actions = CreateActions(); - var automatic = CreateButton("自动检测", 100); + actions.Anchor = AnchorStyles.Right; + actions.Dock = DockStyle.None; + actions.Margin = new Padding(18, 0, 0, 0); + var automatic = CreateButton("自动检测", 92); Button? choose = null; if (definition.PathKind != SoftwarePathKind.None) { - choose = CreateButton("选择位置", 100); + choose = CreateButton("选择位置", 92); actions.Controls.Add(choose); } - var install = CreateButton("安装/更新运行环境", 172, primary: true); + var install = CreateButton("安装 / 更新环境", 150, primary: true); actions.Controls.Add(automatic); actions.Controls.Add(install); - panel.Controls.Add(actions); + panel.Controls.Add(actions, 1, 2); var controls = new SoftwareControls(path, status, automatic, choose, install); softwareControls.Add(definition.Id, controls); automatic.Click += (_, _) => ResetSoftwareLocation(definition); @@ -370,8 +410,8 @@ internal sealed class ConfigurationForm : Form "python.exe"); var runtimeState = File.Exists(runtime) ? "运行环境已安装" : "运行环境尚未安装"; controls.Status.Text = location.Available - ? $"{location.Source} · {runtimeState}" - : $"{location.Detail} · {runtimeState}"; + ? $"{runtimeState} · {location.Source}" + : $"{runtimeState} · {location.Detail}"; 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; @@ -899,15 +939,16 @@ internal sealed class ConfigurationForm : Form private static TableLayoutPanel CreateCard() { - var card = new TableLayoutPanel + var card = new BorderedTableLayoutPanel { AutoSize = true, AutoSizeMode = AutoSizeMode.GrowAndShrink, Dock = DockStyle.Top, ColumnCount = 1, - Padding = new Padding(20, 17, 20, 17), - Margin = new Padding(0, 0, 0, 14), + Padding = new Padding(22, 20, 22, 20), + Margin = new Padding(0, 0, 0, 16), BackColor = Color.White, + BorderColor = Color.FromArgb(226, 232, 240), }; card.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); return card; @@ -922,6 +963,15 @@ internal sealed class ConfigurationForm : Form Margin = new Padding(0, 0, 0, 8), }; + private static Label CreateSubsectionTitle(string text) => new() + { + Text = text, + AutoSize = true, + Font = new Font("Microsoft YaHei UI", 9, FontStyle.Bold), + ForeColor = Color.FromArgb(51, 65, 85), + Margin = new Padding(0, 0, 0, 7), + }; + private static Label CreateBodyLabel() => new() { AutoSize = true, @@ -1135,27 +1185,6 @@ internal sealed class ConfigurationForm : Form Margin = new Padding(0, 10, 0, 12), }; - private static Panel CreateCapabilityRow(string title, string protocol) - { - var row = new Panel - { - AutoSize = false, - Height = 36, - Dock = DockStyle.Top, - BackColor = Color.FromArgb(239, 246, 255), - Margin = new Padding(0, 0, 0, 5), - Padding = new Padding(11, 7, 11, 7), - }; - row.Controls.Add(new Label - { - Text = $"{title} · {protocol}", - AutoSize = true, - Font = new Font("Microsoft YaHei UI", 9, FontStyle.Bold), - ForeColor = Color.FromArgb(29, 78, 216), - }); - return row; - } - private static TextBox CreateTextBox(string text = "", bool usePassword = false) => new() { Text = text, @@ -1166,21 +1195,32 @@ internal sealed class ConfigurationForm : Form Font = new Font("Segoe UI", 10), }; - private static Button CreateButton(string text, int width, bool primary = false) + private static Button CreateButton( + string text, + int width, + bool primary = false, + bool danger = false) { var button = new Button { Text = text, AutoSize = false, - Size = new Size(width, 36), + Size = new Size(width, 38), FlatStyle = FlatStyle.Flat, BackColor = primary ? Color.FromArgb(37, 99, 235) : Color.White, - ForeColor = primary ? Color.White : Color.FromArgb(51, 65, 85), + ForeColor = primary + ? Color.White + : danger ? Color.FromArgb(185, 28, 28) : Color.FromArgb(51, 65, 85), + Font = new Font("Microsoft YaHei UI", 9), Margin = new Padding(0, 0, 10, 0), + Cursor = Cursors.Hand, }; button.FlatAppearance.BorderColor = primary ? Color.FromArgb(37, 99, 235) - : Color.FromArgb(203, 213, 225); + : danger ? Color.FromArgb(254, 202, 202) : Color.FromArgb(203, 213, 225); + button.FlatAppearance.MouseOverBackColor = primary + ? Color.FromArgb(29, 78, 216) + : danger ? Color.FromArgb(254, 242, 242) : Color.FromArgb(248, 250, 252); return button; } @@ -1188,7 +1228,7 @@ internal sealed class ConfigurationForm : Form { AutoSize = true, FlowDirection = FlowDirection.LeftToRight, - WrapContents = false, + WrapContents = true, Dock = DockStyle.Fill, Margin = new Padding(0, 10, 0, 0), }; @@ -1216,4 +1256,26 @@ internal sealed class ConfigurationForm : Form Button Automatic, Button? Choose, Button Install); + + private sealed class BorderedTableLayoutPanel : TableLayoutPanel + { + internal Color BorderColor = Color.FromArgb(226, 232, 240); + + internal BorderedTableLayoutPanel() + { + DoubleBuffered = true; + } + + protected override void OnPaint(PaintEventArgs eventArgs) + { + base.OnPaint(eventArgs); + using var pen = new Pen(BorderColor); + eventArgs.Graphics.DrawRectangle( + pen, + 0, + 0, + Math.Max(0, ClientSize.Width - 1), + Math.Max(0, ClientSize.Height - 1)); + } + } } diff --git a/windows-node/Zcbot.WindowsNode/SoftwareRuntimeManager.cs b/windows-node/Zcbot.WindowsNode/SoftwareRuntimeManager.cs index 2f8c862..ce0bb94 100644 --- a/windows-node/Zcbot.WindowsNode/SoftwareRuntimeManager.cs +++ b/windows-node/Zcbot.WindowsNode/SoftwareRuntimeManager.cs @@ -3,6 +3,7 @@ using System.ComponentModel; using System.Diagnostics; using System.Security; using System.Text; +using System.Text.RegularExpressions; namespace Zcbot.WindowsNode; @@ -41,9 +42,9 @@ internal static class SoftwareRuntimeManager "origin", "Origin", "origin", - SoftwarePathKind.None, - null, - null, + SoftwarePathKind.Executable, + "ZCBOT_ORIGIN_EXE", + "Origin*.exe", @"adapters\origin.plot@v2\requirements.txt"), new( "ansys", @@ -89,7 +90,7 @@ internal static class SoftwareRuntimeManager return definition.Id switch { - "origin" => DetectOrigin(), + "origin" => DetectOrigin(definition), "ansys" => DetectAnsys(definition), "blender" => DetectBlender(definition), _ => new SoftwareLocation(false, null, "自动检测", "没有可用的自动检测规则。"), @@ -220,28 +221,74 @@ internal static class SoftwareRuntimeManager } } - private static SoftwareLocation DetectOrigin() + private static SoftwareLocation DetectOrigin(SoftwareDefinition definition) { + var candidates = new List<(string Path, string Source)>(); try { using var clsid = Registry.ClassesRoot.OpenSubKey(@"Origin.ApplicationSI\CLSID"); var clsidValue = clsid?.GetValue(null) as string; - if (string.IsNullOrWhiteSpace(clsidValue)) + if (!string.IsNullOrWhiteSpace(clsidValue)) { - return new SoftwareLocation(false, null, "COM 注册", "未找到 Origin COM 注册信息。"); + using var server = Registry.ClassesRoot.OpenSubKey($@"CLSID\{clsidValue}\LocalServer32"); + var executable = ExecutableFromCommand(server?.GetValue(null) as string); + if (executable is not null) + { + candidates.Add((executable, "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); + // Continue with uninstall metadata and standard install directories. } + candidates.AddRange(UninstallLocations("Origin") + .SelectMany(OriginExecutables) + .Select(path => (path, "注册表"))); + foreach (var variable in new[] { "ProgramFiles", "ProgramW6432" }) + { + var root = Environment.GetEnvironmentVariable(variable); + if (string.IsNullOrWhiteSpace(root)) continue; + var originRoot = Path.Combine(root, "OriginLab"); + if (!Directory.Exists(originRoot)) continue; + try + { + candidates.AddRange(Directory.EnumerateDirectories(originRoot) + .OrderByDescending(item => item, StringComparer.OrdinalIgnoreCase) + .SelectMany(OriginExecutables) + .Select(path => (path, "标准目录"))); + } + catch (Exception exception) when ( + exception is IOException or SecurityException or UnauthorizedAccessException) + { + // Continue with candidates already collected from other sources. + } + } + return FirstLocation(definition, candidates); + } + + private static IEnumerable OriginExecutables(string location) + { + if (File.Exists(location)) + { + yield return location; + yield break; + } + if (!Directory.Exists(location)) yield break; + IEnumerable files; + try + { + files = Directory.EnumerateFiles(location, "Origin*.exe", SearchOption.TopDirectoryOnly) + .OrderByDescending(item => item, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + catch (Exception exception) when ( + exception is IOException or SecurityException or UnauthorizedAccessException) + { + yield break; + } + foreach (var file in files) yield return file; } private static SoftwareLocation DetectAnsys(SoftwareDefinition definition) @@ -311,9 +358,9 @@ internal static class SoftwareRuntimeManager }; var valid = File.Exists(required) && (definition.PathKind != SoftwarePathKind.Executable - || Path.GetFileName(required).Equals( - definition.RequiredRelativePath, - StringComparison.OrdinalIgnoreCase)); + || MatchesExecutable( + Path.GetFileName(required), + definition.RequiredRelativePath!)); return valid ? new SoftwareLocation(true, path, source, $"已检测到 {required}") : new SoftwareLocation( @@ -329,6 +376,18 @@ internal static class SoftwareRuntimeManager } } + private static bool MatchesExecutable(string filename, string pattern) + { + if (!pattern.Contains('*')) + { + return filename.Equals(pattern, StringComparison.OrdinalIgnoreCase); + } + var parts = pattern.Split('*', 2); + return filename.StartsWith(parts[0], StringComparison.OrdinalIgnoreCase) + && filename.EndsWith(parts[1], StringComparison.OrdinalIgnoreCase) + && filename.Length >= parts[0].Length + parts[1].Length; + } + private static string? ReadConfiguredPath(string id) { using var key = Registry.CurrentUser.OpenSubKey(SettingsKey); @@ -401,10 +460,15 @@ internal static class SoftwareRuntimeManager 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"; + var value = Environment.ExpandEnvironmentVariables(command.Trim().TrimEnd('\0')); + var match = Regex.Match( + value, + "^\\s*(?:\"(?[^\"]+\\.exe)\"|(?.+?\\.exe))(?:\\s|$)", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + if (!match.Success) return null; + var candidate = match.Groups["quoted"].Success + ? match.Groups["quoted"].Value + : match.Groups["plain"].Value; return File.Exists(candidate) ? Path.GetFullPath(candidate) : null; } diff --git a/windows-node/adapters/origin.plot@v2/adapter.json b/windows-node/adapters/origin.plot@v2/adapter.json index ae11a69..17c04e7 100644 --- a/windows-node/adapters/origin.plot@v2/adapter.json +++ b/windows-node/adapters/origin.plot@v2/adapter.json @@ -1,6 +1,6 @@ { "capability": "origin.plot@v2", - "adapter_version": "1.0.0", + "adapter_version": "1.0.1", "runtime": "python", "runtime_id": "origin", "entrypoint": "worker.py", diff --git a/windows-node/adapters/origin.plot@v2/worker.py b/windows-node/adapters/origin.plot@v2/worker.py index 2be4569..24760bf 100644 --- a/windows-node/adapters/origin.plot@v2/worker.py +++ b/windows-node/adapters/origin.plot@v2/worker.py @@ -75,7 +75,7 @@ PANEL_LAYOUT = { (2, 1): {"left": 13, "right": 13, "top": 14, "bottom": 12, "xgap": 0, "ygap": 10}, (2, 2): {"left": 9, "right": 4, "top": 14, "bottom": 12, "xgap": 11, "ygap": 10}, } -ADAPTER_VERSION = "1.0.0" +ADAPTER_VERSION = "1.0.1" def _server_executable(command: str) -> Path: @@ -185,6 +185,14 @@ def _windows_file_version(path: Path) -> str: def _registered_origin_version(winreg: Any) -> str | None: + configured = os.environ.get("ZCBOT_ORIGIN_EXE") + if configured: + try: + executable = Path(configured).expanduser().resolve(strict=True) + if executable.is_file() and executable.name.lower().startswith("origin"): + return _windows_file_version(executable) + except (OSError, ValueError): + pass try: with winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, r"Origin.ApplicationSI\CLSID") as clsid_key: clsid = winreg.QueryValueEx(clsid_key, None)[0]