diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index fe92115..a4aaa4b 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -3,7 +3,10 @@ name: Build Windows Installer
on:
push:
tags: ['v*']
- workflow_dispatch: # 允许手动触发
+ # P132: a manual "Run workflow" now builds the same Setup installer + zip and attaches
+ # them to the run (no Release). Before, the packaging steps were tag-only, so a manual
+ # run produced nothing to download.
+ workflow_dispatch:
permissions:
contents: write
@@ -70,19 +73,18 @@ jobs:
# vendor/python/ before electron-builder runs. Idempotent — safe to re-run.
# This is a build-time network op; end users have no network requirement.
- name: Prepare bundled Python runtime
- if: startsWith(github.ref, 'refs/tags/')
+ if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch'
shell: pwsh
run: ./scripts/prepare-python.ps1
- name: Build
run: npm run build
- # P110: NSIS setup retired (2026-08-04) — users found the installer repeatedly
- # unusable, so tag builds now ship ONLY the portable single-file exe.
- # npm run dist = prepare-python + build + electron-builder (target: portable
- # from electron-builder.yml) → release/Millwright-Portable--x64.exe
- - name: Package (portable / 免安装版)
- if: startsWith(github.ref, 'refs/tags/')
+ # npm run dist = prepare-python + build + electron-builder (targets from
+ # electron-builder.yml, P129): release/Millwright-Setup--x64.exe (NSIS
+ # installer, auto-update) + release/Millwright--x64.zip (extract-and-run).
+ - name: Package (Setup installer + zip)
+ if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch'
run: npm run dist
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -93,13 +95,15 @@ jobs:
# that class of accident impossible. It also confirms the installer filename carries
# the version from package.json (the old electron-builder cache bug).
- name: Verify packaged payload
- if: startsWith(github.ref, 'refs/tags/')
+ id: verify
+ if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch'
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$res = 'release/win-unpacked/resources'
$version = (Get-Content package.json -Raw | ConvertFrom-Json).version
Write-Host "package.json version: ${version}"
+ "version=${version}" >> $env:GITHUB_OUTPUT
$required = @(
"$res/sidecar/sw_agent/server.py",
@@ -151,16 +155,24 @@ jobs:
if ($y -notmatch [regex]::Escape($version)) { Write-Host "::error::latest.yml version mismatch"; exit 1 }
Write-Host "installer + latest.yml: OK"
- # P110 v3: extract-and-run zip (nsis + portable exe both retired).
- # npm run dist with the zip target produces release/*.zip containing the
- # full win-unpacked tree — download, extract, run Millwright.exe.
+ # P132: the run page offers the INSTALLER. The old artifact was release/win-unpacked/**,
+ # which GitHub serves as one zip that extracts to a bare app folder — easy to mistake
+ # for "the build has no installer". archive:false uploads each file as-is, so the
+ # artifact downloads as the .exe / .zip itself (the artifact is named after the file).
+ - name: Upload artifact (Setup installer)
+ if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch'
+ uses: actions/upload-artifact@v7
+ with:
+ path: release/Millwright-Setup-${{ steps.verify.outputs.version }}-x64.exe
+ archive: false
+ retention-days: 30
- - name: Upload artifact (portable directory)
- if: startsWith(github.ref, 'refs/tags/')
+ - name: Upload artifact (zip, extract-and-run)
+ if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch'
uses: actions/upload-artifact@v7
with:
- name: Millwright
- path: release/win-unpacked/**
+ path: release/Millwright-${{ steps.verify.outputs.version }}-x64.zip
+ archive: false
retention-days: 30
- name: Create Release
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 639368d..d7fb9dd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,95 @@
## [Unreleased]
+## [0.2.132] - 2026-10-02
+
+### Fixed (P133 — tools built the part in a SolidWorks the user could not see)
+
+**What the user saw:** a full session reported every step built and verified (the
+`build_part` volume matched, faces were listed, a screenshot was taken), yet the user's
+SolidWorks window stayed empty.
+
+**Cause:** the sidecar connects with `Dispatch("SldWorks.Application")`. That attaches to the
+running SolidWorks only when COM can reach it. When it cannot, COM silently **starts a new,
+invisible SolidWorks** and every tool runs there. The usual reason COM cannot reach it is that
+Millwright and SolidWorks run at different privilege levels, e.g. one of them "Run as
+administrator" (the running-object table is separate per integrity level). Nothing ever set
+`Visible`, so the user could not see it.
+
+**Fix:**
+- **On connect:** if the instance is not visible, the sidecar shows it (`Visible`,
+ `UserControl`), checks the SLDWORKS.exe process list before and after to tell "started a
+ second instance" from "SolidWorks was not running", and records a note that names the
+ likely cause and the fix. The note also says whether Millwright itself is elevated.
+- **Where the note appears:** the sidecar attaches it to tool results as `_connection`, and
+ the chat shows it once per session.
+
+**`mass_properties` "CreateMassProperty/2 unavailable":** a COM object is always callable
+(`__call__` forwards to its default member). Under late binding, `getattr(ext,
+"CreateMassProperty")` already returns the MassProperty object, and the code then called it
+again. `sw_get` now returns a COM object instead of calling it, which fixes the same trap
+for every object-returning member read through it. `mass_properties` uses `sw_get`.
+
+### Tests
+- **`sidecar/tests/test_p133_visibility.py`:** `sw_get` with late-bound objects,
+ `mass_properties` with a late-bound `CreateMassProperty`, a hidden second instance shown
+ and reported, and a hidden only instance shown. Fails 5/5 on 0.2.131.
+- **Totals:** 197 JS + 63 Python tests.
+
+## [0.2.131] - 2026-10-02
+
+### Changed (P132 — providers × protocols, installer on the run page)
+
+**Settings: providers now serve both protocols.** Most providers expose an
+OpenAI-compatible URL and an Anthropic-compatible one. DeepSeek, for example, serves
+`https://api.deepseek.com` and `https://api.deepseek.com/anthropic`.
+- **Quick-fill buttons, both protocols:** before, they only appeared under the OpenAI
+ protocol and always filled the OpenAI URL. They now appear under both protocols and fill
+ the URL **for the selected protocol**.
+- **Model list per provider:** the model list shows only the active provider's models plus
+ *Custom model*. Before, it listed every vendor's models under a protocol.
+- **Protocol switch:** switching protocol keeps the provider when it serves both, and
+ keeps the model if that provider lists it. Otherwise it falls back to the protocol's
+ official endpoint.
+- **Data:** presets are reorganised as `PROVIDERS` (per-protocol URLs, models, defaults).
+ New pure helpers `providerForURL` / `modelOptions` / `switchProtocol` /
+ `applyProviderPreset` are covered by tests. `MODEL_PRESETS` and
+ `OPENAI_COMPATIBLE_PROVIDERS` remain as derived lists.
+- **Anthropic-compatible URLs added:**
+ - DeepSeek `…/anthropic`
+ - Kimi `api.moonshot.cn/anthropic`
+ - MiniMax `api.minimax.io/anthropic`
+ - GLM `open.bigmodel.cn/api/anthropic`
+ - Qwen `dashscope.aliyuncs.com/apps/anthropic`
+ - Ollama `localhost:11434`
+
+**Model IDs**
+- **DeepSeek:** V4.1 Flash (2026-09-10) is `deepseek-flash`. DeepSeek retired
+ `deepseek-v4-flash`, which is now only routed over, so it is removed from the presets.
+ `deepseek-v4-pro` stays.
+- **Kimi:** `kimi-k3` is added and suggested.
+- **Qwen:** `qwen3.8-max` is added and suggested.
+
+**Build: the installer is on the run page.**
+- **What looked like "only a zip":** the Actions artifact was `release/win-unpacked/**`.
+ GitHub serves that as one zip which extracts to a bare app folder, so it was easy to read
+ as "the build has no installer". The Release itself did carry the Setup exe.
+- **Artifacts now:** they are the Setup installer and the zip, uploaded with
+ `archive: false`, so they download as the `.exe` / `.zip` themselves.
+- **Manual runs:** a manual **Run workflow** now packages and verifies too, and attaches
+ the installer to the run without creating a Release. Before, it only ran the checks.
+
+### Tests
+- **`tests/presets.test.mjs`:**
+ - every provider URL is valid
+ - suggested models are listed
+ - no retired DeepSeek id remains
+ - URL → provider resolution
+ - the per-provider model list
+ - protocol switching
+ - quick-fill by protocol
+- **Totals:** 197 JS + 58 Python tests.
+
## [0.2.130] - 2026-10-01
### Fixed (P131 — current models, and what P129/P130 broke)
diff --git a/README.md b/README.md
index d3b0b65..edb69ca 100644
--- a/README.md
+++ b/README.md
@@ -28,12 +28,12 @@
-
+
-
+
@@ -86,7 +86,7 @@ Millwright:
- **Agentic tool loop.** Observe → reason → act. The model chains multiple tool calls, reads structured JSON back from each one, and recovers from errors instead of failing silently.
- **Visual understanding.** Reorient, rotate, screenshot, and analyze the model — via a multimodal main model or a dedicated vision model.
- **Resident execution engine.** A persistent Python sidecar holds one COM connection open across an entire multi-step task.
-- **Developer-friendly.** 191 TypeScript/Node tests plus a Python suite (`pytest sidecar/tests`) for the sidecar, a typed IPC boundary, and a `SKIP_SW_CONNECT` mode for UI-only development without SolidWorks installed.
+- **Developer-friendly.** 197 TypeScript/Node tests plus a Python suite (`pytest sidecar/tests`) for the sidecar, a typed IPC boundary, and a `SKIP_SW_CONNECT` mode for UI-only development without SolidWorks installed.
## Cross-version compatibility
@@ -158,19 +158,21 @@ A `Millwright-*-x64.zip` is also published alongside the Setup installer for use
## Supported AI providers
-| Provider | Protocol | Base URL | Suggested model |
+| Provider | OpenAI-compatible URL | Anthropic-compatible URL | Suggested model |
|---|---|---|---|
-| OpenAI | OpenAI | `https://api.openai.com/v1` | `gpt-6-astra` (GPT-6 Astra) |
-| Anthropic | Anthropic | `https://api.anthropic.com` | `claude-opus-5-5` (Opus 5.5, default) / `claude-fable-5-1` (Fable 5.1, most capable) |
-| DeepSeek | OpenAI-compatible | `https://api.deepseek.com` | `deepseek-v4-pro` |
-| Kimi / Moonshot | OpenAI-compatible | `https://api.moonshot.cn/v1` | `kimi-k3` |
-| MiniMax | OpenAI-compatible | `https://api.minimaxi.com/v1` | `minimax-m3` |
-| Alibaba Bailian (Qwen) | OpenAI-compatible | `https://dashscope.aliyuncs.com/compatible-mode/v1` | `qwen-3.8max` |
-| Zhipu (GLM) | OpenAI-compatible | `https://open.bigmodel.cn/api/paas/v4` | `glm-4.6` |
-| SiliconFlow | OpenAI-compatible | `https://api.siliconflow.cn/v1` | — |
-| Ollama (local) | OpenAI-compatible | `http://localhost:11434/v1` | — |
-
-> Model IDs move fast — check your provider's docs for the current lineup. Agentic tool calling requires a model that supports function calling; GPT-6 Astra, Claude Opus 5.5 / Fable 5.1, DeepSeek V4, Kimi K3, MiniMax M3, and GLM-4.6 are first-class targets.
+| OpenAI | `https://api.openai.com/v1` | — | `gpt-6-astra` (GPT-6 Astra) |
+| Anthropic | — | `https://api.anthropic.com` | `claude-opus-5-5` (Opus 5.5, default) / `claude-fable-5-1` (Fable 5.1, most capable) |
+| DeepSeek | `https://api.deepseek.com` | `https://api.deepseek.com/anthropic` | `deepseek-v4-pro` (strong) / `deepseek-flash` (V4.1 Flash, fast) |
+| Kimi / Moonshot | `https://api.moonshot.cn/v1` | `https://api.moonshot.cn/anthropic` | `kimi-k3` |
+| MiniMax | `https://api.minimax.io/v1` | `https://api.minimax.io/anthropic` | `minimax-m3` |
+| Alibaba Bailian (Qwen) | `https://dashscope.aliyuncs.com/compatible-mode/v1` | `https://dashscope.aliyuncs.com/apps/anthropic` | `qwen3.8-max` |
+| Zhipu (GLM) | `https://open.bigmodel.cn/api/paas/v4` | `https://open.bigmodel.cn/api/anthropic` | `glm-4.6` |
+| SiliconFlow | `https://api.siliconflow.cn/v1` | — | — |
+| Ollama (local) | `http://localhost:11434/v1` | `http://localhost:11434` | — |
+
+> In ⚙️ Settings, pick the protocol first: the provider quick-fill buttons then fill that provider's URL **for the selected protocol**, and the model list shows only that provider's models (plus *Custom model*). Switching protocol keeps the provider when it serves both.
+
+> Model IDs move fast — check your provider's docs for the current lineup. Agentic tool calling requires a model that supports function calling; GPT-6 Astra, Claude Opus 5.5 / Fable 5.1, DeepSeek V4 Pro / V4.1 Flash, Kimi K3, MiniMax M3, and GLM-4.6 are first-class targets.
>
> The newest models reject request fields that older ones accepted: GPT-6 Astra needs `max_completion_tokens`, takes no `temperature`, and refuses `reasoning_effort` alongside tools on `/chat/completions`; Claude Opus 5.5 and Fable 5.1 reject `temperature` and `budget_tokens` and always think (depth is set with `effort`). Millwright detects these models by ID and sends the right fields — the **Reasoning depth** setting maps onto each model's own controls.
@@ -246,7 +248,7 @@ Contributions welcome — see [CONTRIBUTING.md](docs/CONTRIBUTING.md). We especi
- [x] **v0.1** — MVP: Electron shell, LLM adapters, COM bridge, first tool set
- [x] **v0.2** — Python sidecar, agentic tool loop, dual-engine fallback, vision feedback, confirmation cards, Apache-2.0 open source
-- [x] **v0.2.4 → v0.2.130** — Extensive hardening against real SolidWorks installs ← *current*: the sketch → feature → cut → visual-verification loop now runs end to end on real hardware
+- [x] **v0.2.4 → v0.2.132** — Extensive hardening against real SolidWorks installs ← *current*: the sketch → feature → cut → visual-verification loop now runs end to end on real hardware
- [ ] **v0.3** — Streaming tool calls, sketching on model faces (not just reference planes), hole wizard, sheet metal, drawing annotations, remaining `#VERIFY` parameters confirmed
- [ ] **v1.0** — MCP server, multi-CAD support
diff --git a/README.zh-CN.md b/README.zh-CN.md
index ddb3835..ee73d31 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -27,12 +27,12 @@
-
+
-
+
@@ -85,7 +85,7 @@ Millwright:
- **Agent 工具循环。** 观察 → 推理 → 执行。模型串联多次工具调用,读取每次返回的结构化 JSON,出错能自愈而不是静默失败。
- **视觉理解。** 可翻转、旋转、截屏,再做分析——既支持多模态主模型,也支持独立视觉模型。
- **常驻执行引擎。** 常驻 Python 边车在一整个多步任务中复用同一条 COM 连接。
-- **开发者友好。** 191 个 TS/Node 单元测试,另有独立的 Python 测试套件(`pytest sidecar/tests`),类型化 IPC 边界,`SKIP_SW_CONNECT` 纯 UI 开发模式(无需 SolidWorks)。
+- **开发者友好。** 197 个 TS/Node 单元测试,另有独立的 Python 测试套件(`pytest sidecar/tests`),类型化 IPC 边界,`SKIP_SW_CONNECT` 纯 UI 开发模式(无需 SolidWorks)。
## 跨版本兼容
@@ -157,19 +157,21 @@ npm run dev
## 支持的 AI 服务商
-| 服务商 | 协议 | Base URL | 推荐模型 |
-| ----------- | --------- | --------------------------------------------------- | ----------------------------------- |
-| OpenAI | OpenAI | `https://api.openai.com/v1` | `gpt-6-astra`(GPT-6 Astra) |
-| Anthropic | Anthropic | `https://api.anthropic.com` | `claude-opus-5-5`(Opus 5.5,默认)/ `claude-fable-5-1`(Fable 5.1,最强) |
-| DeepSeek | OpenAI 兼容 | `https://api.deepseek.com` | `deepseek-v4-pro`(强) / `deepseek-v4-flash`(快) |
-| Kimi / 月之暗面 | OpenAI 兼容 | `https://api.moonshot.cn/v1` | `kimi-k2.5` |
-| MiniMax | OpenAI 兼容 | `https://api.minimaxi.com/v1` | `minimax-m3`(512K 上下文) |
-| 智谱 (GLM) | OpenAI 兼容 | `https://open.bigmodel.cn/api/paas/v4` | `glm-4.6` |
-| 阿里百炼 (Qwen) | OpenAI 兼容 | `https://dashscope.aliyuncs.com/compatible-mode/v1` | `qwen3.7-max` |
-| 硅基流动 | OpenAI 兼容 | `https://api.siliconflow.cn/v1` | —(用户自填) |
-| Ollama(本地) | OpenAI 兼容 | `http://localhost:11434/v1` | —(用户自填) |
-
-> 各家型号更新很快,请以服务商官方文档为准。Agent 工具调用需要模型支持 function calling;GPT-6 Astra、Claude Opus 5.5 / Fable 5.1、DeepSeek V4、Kimi K2、MiniMax M3、GLM-4.6 是一等公民。
+| 服务商 | OpenAI 兼容地址 | Anthropic 兼容地址 | 推荐模型 |
+| --- | --- | --- | --- |
+| OpenAI | `https://api.openai.com/v1` | — | `gpt-6-astra`(GPT-6 Astra) |
+| Anthropic | — | `https://api.anthropic.com` | `claude-opus-5-5`(Opus 5.5,默认)/ `claude-fable-5-1`(Fable 5.1,最强) |
+| DeepSeek | `https://api.deepseek.com` | `https://api.deepseek.com/anthropic` | `deepseek-v4-pro`(强)/ `deepseek-flash`(V4.1 Flash,快) |
+| Kimi / 月之暗面 | `https://api.moonshot.cn/v1` | `https://api.moonshot.cn/anthropic` | `kimi-k3` |
+| MiniMax | `https://api.minimax.io/v1` | `https://api.minimax.io/anthropic` | `minimax-m3`(512K 上下文) |
+| 智谱 (GLM) | `https://open.bigmodel.cn/api/paas/v4` | `https://open.bigmodel.cn/api/anthropic` | `glm-4.6` |
+| 阿里百炼 (Qwen) | `https://dashscope.aliyuncs.com/compatible-mode/v1` | `https://dashscope.aliyuncs.com/apps/anthropic` | `qwen3.8-max` |
+| 硅基流动 | `https://api.siliconflow.cn/v1` | — | —(用户自填) |
+| Ollama(本地) | `http://localhost:11434/v1` | `http://localhost:11434` | —(用户自填) |
+
+> 在 ⚙️ 设置里先选协议:服务商快捷按钮会填入该服务商**对应协议**的地址,模型下拉框只列出该服务商的模型(外加「自定义模型」)。切换协议时,如果该服务商两种协议都支持,会自动换成另一种协议的地址。
+
+> 各家型号更新很快,请以服务商官方文档为准。Agent 工具调用需要模型支持 function calling;GPT-6 Astra、Claude Opus 5.5 / Fable 5.1、DeepSeek V4 Pro / V4.1 Flash、Kimi K3、MiniMax M3、GLM-4.6 是一等公民。
>
> 新一代模型会拒绝老模型能接受的请求字段:GPT-6 Astra 要求 `max_completion_tokens`、不接受 `temperature`,并且在 `/chat/completions` 上不允许 `reasoning_effort` 与工具同时出现;Claude Opus 5.5 和 Fable 5.1 拒绝 `temperature` 和 `budget_tokens`,并且始终开启思考(深度用 `effort` 控制)。Millwright 按模型 ID 自动识别并发送正确的字段——设置里的「推理深度」会映射到各模型自己的控制参数上。OpenAI `gpt-5.x` / o 系列同样已自动识别。
@@ -245,7 +247,7 @@ SolidWorks
- [x] **v0.1** — MVP:Electron 骨架、LLM 适配器、COM 桥接、首批工具
- [x] **v0.2** — Python 边车、agent 工具循环、双引擎降级、视觉反馈、确认卡片,Apache-2.0 开源
-- [x] **v0.2.4 → v0.2.130** — 大量真机加固 ← *当前*:草图 → 特征 → 切除 → 视觉核验的完整闭环已在真机上端到端跑通
+- [x] **v0.2.4 → v0.2.132** — 大量真机加固 ← *当前*:草图 → 特征 → 切除 → 视觉核验的完整闭环已在真机上端到端跑通
- [ ] **v0.3** — 流式工具调用、在模型面上画草图(而非仅基准面)、孔向导、钣金、工程图标注、剩余 `# VERIFY` 参数完成核验
- [ ] **v1.0** — MCP server、多 CAD 支持
diff --git a/package.json b/package.json
index eab98c6..b461744 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "millwright",
- "version": "0.2.130",
+ "version": "0.2.132",
"description": "Open-source AI automation for SolidWorks — talk to your CAD.",
"keywords": [
"solidworks",
diff --git a/sidecar/sw_agent/bridge.py b/sidecar/sw_agent/bridge.py
index b08fdc8..183b998 100644
--- a/sidecar/sw_agent/bridge.py
+++ b/sidecar/sw_agent/bridge.py
@@ -22,6 +22,7 @@
from __future__ import annotations
import os
+import subprocess
from typing import Any
from sw_agent import typeinfo
@@ -78,9 +79,81 @@ def sw_get(obj, name: str, *args):
is callable, call it; otherwise return it. Only for NO-ARG or fully-given-arg reads.
"""
attr = getattr(obj, name)
+ # P133: a COM object (CDispatch / early-bound wrapper) is ALWAYS callable — __call__
+ # forwards to its default member. When getattr already handed back an object, the
+ # member was a zero-arg method late binding invoked as a property; calling the result
+ # again hit its DISPID_VALUE and failed (mass_properties: "CreateMassProperty/2
+ # unavailable" on a machine where both exist).
+ if not args and hasattr(attr, "_oleobj_"):
+ return attr
return attr(*args) if callable(attr) else attr
+def _sw_pids() -> set[int] | None:
+ """P133: PIDs of running SLDWORKS.exe processes; None when they cannot be listed."""
+ if os.name != "nt":
+ return None
+ try:
+ out = subprocess.run(
+ ["tasklist", "/FI", "IMAGENAME eq SLDWORKS.exe", "/FO", "CSV", "/NH"],
+ capture_output=True, text=True, errors="replace", timeout=8, check=False,
+ creationflags=0x08000000, # CREATE_NO_WINDOW
+ ).stdout
+ except Exception: # noqa: BLE001
+ return None
+ pids: set[int] = set()
+ for line in out.splitlines():
+ cols = [c.strip().strip('"') for c in line.split('","')]
+ if len(cols) > 1 and cols[1].isdigit():
+ pids.add(int(cols[1]))
+ return pids
+
+
+def _is_elevated() -> bool:
+ if os.name != "nt":
+ return False
+ try:
+ import ctypes
+ return bool(ctypes.windll.shell32.IsUserAnAdmin())
+ except Exception: # noqa: BLE001
+ return False
+
+
+def ensure_visible(app, pids_before: set[int] | None) -> str | None:
+ """P133: make sure the SolidWorks we attached to is one the user can SEE.
+
+ `Dispatch("SldWorks.Application")` attaches to the running SolidWorks only when COM can
+ reach it. When it cannot — typically Millwright and SolidWorks running at different
+ privilege levels ("Run as administrator" on one of them; the ROT is per integrity
+ level) — COM silently STARTS A NEW, INVISIBLE SolidWorks and every tool works on that
+ one: the user watched an empty window while a whole part was built and "verified".
+ A visible instance → None. Otherwise show it and return a note for the user.
+ """
+ try:
+ if bool(app.Visible):
+ return None
+ except Exception: # noqa: BLE001 — cannot tell; do not guess
+ return None
+ try:
+ app.Visible = True
+ except Exception: # noqa: BLE001
+ pass
+ try:
+ app.UserControl = True # keep it open for the user after we let go
+ except Exception: # noqa: BLE001
+ pass
+ after = _sw_pids()
+ second = bool(pids_before) and after is not None and bool(after - pids_before)
+ if not second:
+ return ("SolidWorks 之前没有可见窗口,已启动/显示 SolidWorks;之后的建模都在这个窗口里进行。")
+ note = ("你已打开的 SolidWorks 无法被连接,系统因此新启动了一个 SolidWorks 实例(现已显示出来),"
+ "模型会建在这个新窗口里,而不是你原来的窗口。通常原因是两者权限级别不同——其中一个是"
+ "「以管理员身份运行」的。")
+ if _is_elevated():
+ note += "Millwright 当前正以管理员身份运行。"
+ return note + "要连接到你自己的窗口:关闭 Millwright 和新出现的 SolidWorks,然后以同样的方式(都不用管理员)重新打开两者。"
+
+
def as_iface(obj, *ifaces):
"""Make members of `ifaces` reachable on `obj`. Returns (obj, note) — never raises.
@@ -142,6 +215,7 @@ def __init__(self) -> None:
self._model = None # P122: flagged ActiveDoc cache (same underlying object → reuse)
self._model_key = None
self.scratch: dict[str, Any] = {}
+ self.connect_note: str | None = None # P133: see ensure_visible
# ---- Connection ----
def _connect(self):
@@ -153,7 +227,10 @@ def _connect(self):
pass
errors: list[str] = []
raw = None
+ pids_before = _sw_pids()
# P73: Dispatch covers ROT + class-factory in one call (SW is a singleton server)
+ # P133: … unless COM cannot reach the running one — then it starts a hidden second
+ # instance. ensure_visible() below catches that.
try:
raw = win32com.client.dynamic.Dispatch("SldWorks.Application")
except Exception as e: # noqa: BLE001
@@ -174,11 +251,14 @@ def _connect(self):
if BINDING == "early":
try:
from win32com.client import gencache
- return gencache.EnsureDispatch(raw)
+ app = gencache.EnsureDispatch(raw)
+ self.connect_note = ensure_visible(app, pids_before)
+ return app
except Exception: # noqa: BLE001 — makepy unavailable → late anyway
pass
app = win32com.client.dynamic.Dispatch(getattr(raw, "_oleobj_", raw))
typeinfo.flag_methods(app, "ISldWorks")
+ self.connect_note = ensure_visible(app, pids_before)
return app
@property
diff --git a/sidecar/sw_agent/server.py b/sidecar/sw_agent/server.py
index 0bff7fd..00d2b11 100644
--- a/sidecar/sw_agent/server.py
+++ b/sidecar/sw_agent/server.py
@@ -206,6 +206,10 @@ def work():
data["_state"] = ctx.doc_state()
except Exception: # noqa: BLE001
pass
+ # P133: we are driving a SolidWorks the user did not open (see
+ # bridge.ensure_visible) — say so on every result until the session reconnects
+ if getattr(ctx, "connect_note", None):
+ data["_connection"] = ctx.connect_note
if mutating:
adv = _advisory(ctx)
if adv:
diff --git a/sidecar/sw_agent/tools/query.py b/sidecar/sw_agent/tools/query.py
index a0accdd..db11762 100644
--- a/sidecar/sw_agent/tools/query.py
+++ b/sidecar/sw_agent/tools/query.py
@@ -19,10 +19,8 @@ def mass_properties(ctx: Context):
mp = None
for maker in ("CreateMassProperty", "CreateMassProperty2"):
try:
- fn = getattr(ext, maker, None)
- if fn is None:
- continue
- mp = fn() if callable(fn) else fn
+ # P133: sw_get — the late-bound getattr may already BE the MassProperty object
+ mp = sw_get(ext, maker)
if mp is not None:
break
except Exception: # noqa: BLE001 — try the next API name (version differences)
diff --git a/sidecar/tests/test_p133_visibility.py b/sidecar/tests/test_p133_visibility.py
new file mode 100644
index 0000000..b26d266
--- /dev/null
+++ b/sidecar/tests/test_p133_visibility.py
@@ -0,0 +1,67 @@
+"""P133 — the SolidWorks the tools drive must be one the user can see; COM objects are
+always callable, so sw_get must not call an object it already got back."""
+from __future__ import annotations
+
+from sw_agent import bridge
+from sw_agent.tools import query
+
+
+class _ComObj:
+ """Stands in for a win32com CDispatch: has _oleobj_, and __call__ hits DISPID_VALUE."""
+
+ _oleobj_ = object()
+
+ def __init__(self, **attrs):
+ self.__dict__.update(attrs)
+
+ def __call__(self, *a):
+ raise RuntimeError("DISPID_VALUE: member not found")
+
+
+def test_sw_get_returns_a_com_object_instead_of_calling_it():
+ mp = _ComObj(Mass=1.0)
+ ext = _ComObj(CreateMassProperty=mp) # late binding already invoked the method
+ assert bridge.sw_get(ext, "CreateMassProperty") is mp
+ assert bridge.sw_get(_ComObj(GetTitle=lambda: "零件1"), "GetTitle") == "零件1" # real method
+ assert bridge.sw_get(_ComObj(Mass=2.5), "Mass") == 2.5 # plain value
+
+
+def test_mass_properties_with_late_bound_create_mass_property():
+ mp = _ComObj(Mass=1.0082, Volume=129258.19e-9, SurfaceArea=0.0251, CenterOfMass=(0.0, 0.0075, 0.0))
+
+ class Ctx:
+ model = _ComObj(Extension=_ComObj(CreateMassProperty=mp))
+
+ out = query.mass_properties(Ctx())
+ assert out["mass_kg"] == 1.0082
+ assert round(out["volume_mm3"]) == 129258
+ assert out["center_of_mass_mm"] == [0.0, 7.5, 0.0]
+
+
+class _App:
+ def __init__(self, visible):
+ self.Visible = visible
+ self.UserControl = False
+
+
+def test_visible_instance_needs_no_note():
+ app = _App(True)
+ assert bridge.ensure_visible(app, {100}) is None
+ assert app.Visible is True
+
+
+def test_hidden_second_instance_is_shown_and_reported(monkeypatch):
+ monkeypatch.setattr(bridge, "_sw_pids", lambda: {100, 200}) # 200 started by COM
+ monkeypatch.setattr(bridge, "_is_elevated", lambda: True)
+ app = _App(False)
+ note = bridge.ensure_visible(app, {100})
+ assert app.Visible is True and app.UserControl is True
+ assert "新启动了一个 SolidWorks" in note and "管理员" in note
+
+
+def test_hidden_only_instance_is_shown(monkeypatch):
+ monkeypatch.setattr(bridge, "_sw_pids", lambda: {300})
+ app = _App(False)
+ note = bridge.ensure_visible(app, set()) # SolidWorks was not running before
+ assert app.Visible is True
+ assert "新启动" not in note
diff --git a/src/main/agent/agent-loop-sidecar.ts b/src/main/agent/agent-loop-sidecar.ts
index 96d081b..eaa3417 100644
--- a/src/main/agent/agent-loop-sidecar.ts
+++ b/src/main/agent/agent-loop-sidecar.ts
@@ -192,6 +192,8 @@ export async function runSidecarAgent(
let backupDone = false;
// P125: surface SolidWorks version advisory once per session
let advisoryShown = false;
+ // P133: "a second, hidden SolidWorks was started" notice — once per session
+ let connectionShown = false;
// P131: op_ids are namespaced per run. The sidecar's idempotency cache outlives a chat
// session, and some providers restart their tool-call ids every conversation (Kimi's
// `functions.:`) — a bare call.id could hand a NEW call an OLD session's result.
@@ -510,6 +512,12 @@ export async function runSidecarAgent(
// P130: stash the wall-clock duration on the call so the UI card and the session
// export can show it.
call.durationMs = r.durationMs;
+ // P133: the sidecar is driving a SolidWorks instance the user did not open (COM
+ // started a hidden second one) — tell the user once, in plain words
+ if (r.data?._connection && !connectionShown) {
+ connectionShown = true;
+ opts.onEvent?.({ type: 'text', text: `\n⚠️ ${r.data._connection}\n` });
+ }
// P125: surface unverified SolidWorks version advisory once
if (r.ok && r.data?._advisory && !advisoryShown) {
advisoryShown = true;
diff --git a/src/renderer/components/SettingsModal.tsx b/src/renderer/components/SettingsModal.tsx
index 21f24a0..dea328a 100644
--- a/src/renderer/components/SettingsModal.tsx
+++ b/src/renderer/components/SettingsModal.tsx
@@ -8,7 +8,10 @@
import { useEffect, useMemo, useState } from 'react';
import type { LLMConfig, LLMErrorInfo, ThemeName, SWStatus } from '../../shared/types';
-import { DEFAULT_URLS, MODEL_PRESETS, OPENAI_COMPATIBLE_PROVIDERS } from '../../shared/presets';
+import {
+ DEFAULT_URLS, applyProviderPreset, modelOptions, providerForURL, providersFor, switchProtocol,
+ type ProviderPreset,
+} from '../../shared/presets';
import type { ThemeTokens } from '../themes';
import { useLocale, useT } from '../i18n/LocaleContext';
import { LOCALE_LABELS } from '../i18n/strings';
@@ -52,9 +55,11 @@ export function SettingsModal({
setDraft(config);
}, [config]);
- // Dropdown options: if the current model is not in the preset list, show it as "custom"
- const presets = MODEL_PRESETS[draft.protocol];
- const modelIsPreset = presets.some((p) => p.value === draft.model);
+ // Dropdown options: the base URL's provider's models + "custom" (P132); a model not in
+ // that list shows as "custom"
+ const presets = modelOptions(draft.protocol, draft.baseURL);
+ const activeProvider = providerForURL(draft.baseURL);
+ const modelIsPreset = presets.some((p) => p.value !== 'custom' && p.value === draft.model);
const selectValue = modelIsPreset ? draft.model : 'custom';
const customModel = modelIsPreset ? '' : draft.model;
@@ -74,29 +79,18 @@ export function SettingsModal({
});
};
+ // P132: switching protocol keeps the provider when it serves both (DeepSeek's OpenAI URL
+ // becomes its /anthropic URL) and keeps the model if that provider lists it; otherwise
+ // the protocol's official endpoint and its recommended model.
const handleProtocol = (p: 'anthropic' | 'openai') => {
- // P131: pair the default URL with a model that endpoint actually serves. The first
- // OpenAI-protocol preset is DeepSeek, so switching gave api.openai.com + deepseek-v4-pro.
- const official = OPENAI_COMPATIBLE_PROVIDERS.find((x) => x.url === DEFAULT_URLS[p]);
- setDraft((d) => ({
- ...d,
- protocol: p,
- baseURL: DEFAULT_URLS[p],
- model: official?.suggestedModel ?? MODEL_PRESETS[p][0].value,
- }));
+ setDraft((d) => ({ ...d, protocol: p, ...switchProtocol(d, p) }));
setTestStatus({ kind: 'idle' });
};
- // P131: a quick-fill button sets the whole provider, not just its URL — the suggested
- // model and the per-provider context / output defaults were declared but never applied.
- const applyProvider = (p: (typeof OPENAI_COMPATIBLE_PROVIDERS)[number]) => {
- setDraft((d) => ({
- ...d,
- baseURL: p.url,
- ...(p.suggestedModel ? { model: p.suggestedModel } : {}),
- ...(p.contextWindow ? { contextWindow: p.contextWindow } : {}),
- ...(p.maxTokens ? { maxTokens: p.maxTokens } : {}),
- }));
+ // P131/P132: a quick-fill button sets the whole provider — the URL for the CURRENT
+ // protocol, one of its models, and its context / output defaults.
+ const applyProvider = (p: ProviderPreset) => {
+ setDraft((d) => ({ ...d, ...applyProviderPreset(p, d.protocol, d.model) }));
setTestStatus({ kind: 'idle' });
};
@@ -302,34 +296,33 @@ export function SettingsModal({
placeholder={DEFAULT_URLS[draft.protocol]}
style={{ ...fieldStyle, marginBottom: 4 }}
/>
- {draft.protocol === 'openai' && (
-
-
- {tr('settings.quickFill')}
-
-
- {OPENAI_COMPATIBLE_PROVIDERS.map((p) => (
+ {/* P132: providers serving the selected protocol; each fills ITS URL for it */}
+