fix(node): improve software setup interface

This commit is contained in:
caoqianming 2026-08-21 08:54:54 +08:00
parent 0a01a94c93
commit ab33a68c2f
10 changed files with 263 additions and 108 deletions

View File

@ -10,6 +10,8 @@
- 修复国际旗舰模型输出思考过程时偶尔无法及时停止的问题;即使模型暂时没有返回新的流式片段,点击“停止”也会迅速中断当前回答。
- Windows Node 主窗口重新整理为更宽、更清晰的桌面控制台节点状态、能力概览和维护操作分层展示专业软件改为独立配置行和通栏地址框Origin、ANSYS、Blender 均可手工选择位置Origin 自动检测也会在 COM 路径无效时继续查找注册表和标准安装目录。
- Windows Node 现在可在“专业软件”区域自动检测或手工指定应用位置,并分别安装、更新 Origin、ANSYS、Blender 的独立运行环境;只装有部分专业软件的节点不再需要处理无关软件。
- 对话中尚未发送的文字现在会按对话自动暂存;助手回答或后台进程执行期间也可以继续发送补充信息,消息会显示为待处理并在当前工作结束后依次发送。动作按钮采用单按钮形态:忙碌且输入为空时用于停止,输入文字或加入附件后自动切回发送。切换对话或刷新页面后,草稿和待处理消息仍会保留。

View File

@ -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、依赖或运行方式变化。

2
RUN.md
View File

@ -1099,7 +1099,7 @@ Windows Node 不需要安装脚本。解压发布包后,使用实际运行 Nod
Workspace 目录固定为 `<data_root>\workspaces\<workspace-id>\`,不使用 user ID 上层目录。`current` 是当前可续作工程,`rollback` 只保留上一版用于失败恢复;每次成功续作替换 `current`,不会按 Job 永久堆积工程副本。不要手工移动 workspace 到其他节点:服务端会把后续 Job 固定调度到其 home node节点离线时任务保持等待。备份或迁移节点时必须把数据库中的 workspace 归属和该目录作为一个整体处理。
主窗口“专业软件”按 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/心跳会刷新能力状态。
主窗口“专业软件”按 Origin、ANSYS Mechanical 2024 R2 和 Blender 分别管理应用位置与隔离 runtime。每张软件卡都可单独执行“安装/更新运行环境”,只会读取安装包内固定 requirements并先在临时目录完成后再替换该软件的 `runtimes/<runtime_id>`;有对应活动任务时拒绝替换。安装要求本机存在 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 或本机路径。

View File

@ -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)

View File

@ -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
)

View File

@ -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 位于 `<data_root>\runtimes\<runtime_id>\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 安装到当前数据根目录,后续更新不会写回默认目录。

View File

@ -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));
}
}
}

View File

@ -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<string> OriginExecutables(string location)
{
if (File.Exists(location))
{
yield return location;
yield break;
}
if (!Directory.Exists(location)) yield break;
IEnumerable<string> 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*(?:\"(?<quoted>[^\"]+\\.exe)\"|(?<plain>.+?\\.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;
}

View File

@ -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",

View File

@ -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]