From 12c5c1730114957d1790738c927939eee35c9679 Mon Sep 17 00:00:00 2001 From: Raylan LIN Date: Fri, 2 Oct 2026 02:02:30 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20P132=20providers=20=C3=97=20protoco?= =?UTF-8?q?ls=20in=20Settings,=20deepseek-flash,=20installer=20on=20the=20?= =?UTF-8?q?Actions=20run=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings: presets are organised by provider (PROVIDERS), each with an OpenAI-compatible and/or Anthropic-compatible URL. The quick-fill row shows under both protocols and fills the provider's URL for the selected protocol; the model dropdown lists only the active provider's models + Custom; switching protocol keeps the provider when it serves both (e.g. https://api.deepseek.com <-> https://api.deepseek.com/anthropic) and keeps the model when that provider lists it. Model ids: DeepSeek V4.1 Flash is deepseek-flash (deepseek-v4-flash retired); kimi-k3 and qwen3.8-max added and suggested. Anthropic-compatible URLs added for DeepSeek, Kimi, MiniMax, GLM, Qwen and Ollama. Build: the Actions artifacts are now the Setup installer and the zip, uploaded unzipped (archive: false) instead of the win-unpacked folder that GitHub served as a zip; a manual "Run workflow" also packages, verifies and attaches the installer (no Release). Docs: README / README.zh-CN provider tables list both URLs per provider; CHANGELOG. Tests: provider resolution, per-provider model list, protocol switch, quick-fill. 197 JS + 58 Python tests pass; typecheck, lint, ruff, compileall, renderer build OK. --- .github/workflows/build.yml | 44 ++-- CHANGELOG.md | 54 +++++ README.md | 34 +-- README.zh-CN.md | 36 +-- package.json | 2 +- src/renderer/components/SettingsModal.tsx | 75 +++---- src/renderer/i18n/strings.ts | 6 +- src/shared/presets.ts | 259 ++++++++++++++++++---- tests/presets.test.mjs | 61 +++++ 9 files changed, 430 insertions(+), 141 deletions(-) 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..3c6f548 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,60 @@ ## [Unreleased] +## [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..8b780e7 100644 --- a/README.md +++ b/README.md @@ -28,12 +28,12 @@

- version + version electron react typescript python - tests + tests license

@@ -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.131** — 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..b182a89 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -27,12 +27,12 @@

- version + version electron react typescript python - tests + tests license

@@ -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.131** — 大量真机加固 ← *当前*:草图 → 特征 → 切除 → 视觉核验的完整闭环已在真机上端到端跑通 - [ ] **v0.3** — 流式工具调用、在模型面上画草图(而非仅基准面)、孔向导、钣金、工程图标注、剩余 `# VERIFY` 参数完成核验 - [ ] **v1.0** — MCP server、多 CAD 支持 diff --git a/package.json b/package.json index eab98c6..9d061e3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "millwright", - "version": "0.2.130", + "version": "0.2.131", "description": "Open-source AI automation for SolidWorks — talk to your CAD.", "keywords": [ "solidworks", 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 */} +
+

+ {tr('settings.quickFill')} +

+
+ {providersFor(draft.protocol).map((p) => { + const active = activeProvider?.id === p.id; + return ( - ))} -
+ ); + })}
- )} - {draft.protocol === 'anthropic' && ( -

- {tr('settings.anthropicDefault')} -

- )} +
{/* API Key */} diff --git a/src/renderer/i18n/strings.ts b/src/renderer/i18n/strings.ts index c16dfd3..b1fdc85 100644 --- a/src/renderer/i18n/strings.ts +++ b/src/renderer/i18n/strings.ts @@ -75,8 +75,7 @@ const zh: Dict = { 'settings.notDetected': '未检测到', 'settings.protocol': 'API 协议', 'settings.openaiCompat': 'OpenAI 兼容', - 'settings.quickFill': '兼容服务商快捷填充:', - 'settings.anthropicDefault': '默认使用 Anthropic 官方端点', + 'settings.quickFill': '服务商快捷填充(按当前协议填入对应地址):', 'settings.model': '模型', 'settings.customModelPlaceholder': '如 deepseek-v4-pro, kimi-k3, minimax-m3', 'settings.systemPrompt': '系统提示词', @@ -247,8 +246,7 @@ const en: Dict = { 'settings.notDetected': 'Not detected', 'settings.protocol': 'API protocol', 'settings.openaiCompat': 'OpenAI-compatible', - 'settings.quickFill': 'Quick-fill compatible providers:', - 'settings.anthropicDefault': 'Uses the official Anthropic endpoint by default', + 'settings.quickFill': 'Quick-fill a provider (its URL for the selected protocol):', 'settings.model': 'Model', 'settings.customModelPlaceholder': 'e.g. deepseek-v4-pro, kimi-k3, minimax-m3', 'settings.systemPrompt': 'System prompt', diff --git a/src/shared/presets.ts b/src/shared/presets.ts index ad0c2a7..ef193ab 100644 --- a/src/shared/presets.ts +++ b/src/shared/presets.ts @@ -5,6 +5,10 @@ // P131: GPT-6 Astra (2026-09-03), Claude Opus 5.5 and Claude Fable 5.1 become the // recommended OpenAI / Anthropic models. Saved ids that left the list still load — the // Settings dropdown shows any non-preset id as "Custom model". +// P132: presets are organised by PROVIDER. Most providers serve both wire protocols, at +// different URLs (DeepSeek: / vs /anthropic); Settings picks the URL for the selected +// protocol and lists only that provider's models. DeepSeek V4.1 Flash (2026-09-10) is +// `deepseek-flash` — `deepseek-v4-flash` was retired (requests are only routed over). import type { LLMProtocol, ModelPreset, LLMConfig } from './types'; @@ -13,58 +17,221 @@ export const DEFAULT_URLS: Record = { openai: 'https://api.openai.com/v1', }; -export const MODEL_PRESETS: Record = { - anthropic: [ - { label: 'Claude Opus 5.5 (1M ctx, recommended)', value: 'claude-opus-5-5' }, - { label: 'Claude Fable 5.1 (1M ctx, most capable)', value: 'claude-fable-5-1' }, - { label: 'Claude Sonnet 4.6', value: 'claude-sonnet-4-6' }, - { label: 'Custom model', value: 'custom' }, - ], - openai: [ - // —— Major Chinese providers (all use the OpenAI-compatible protocol) —— - { label: 'DeepSeek V4 Pro (强)', value: 'deepseek-v4-pro' }, - { label: 'DeepSeek V4 Flash (快)', value: 'deepseek-v4-flash' }, - { label: 'Kimi K2.5 (Moonshot)', value: 'kimi-k2.5' }, - { label: 'MiniMax M3 (512K ctx)', value: 'minimax-m3' }, - { label: 'Qwen 3.7 Max (阿里百炼)', value: 'qwen3.7-max' }, - { label: 'GLM-4.6 (智谱)', value: 'glm-4.6' }, - // —— OpenAI official —— - { label: 'GPT-6 Astra (1M ctx, recommended)', value: 'gpt-6-astra' }, - { label: 'GPT-5.6 Sol', value: 'gpt-5.6-sol' }, - { label: 'GPT-4.1', value: 'gpt-4.1' }, - { label: 'GPT-4o Mini', value: 'gpt-4o-mini' }, - { label: 'Custom model', value: 'custom' }, - ], -}; - -/** - * Common OpenAI-compatible provider URL hints. - * P54: refreshed IDs and aligned with second-part table in APPLY.md. - * Each entry flags whether `tools` / function calling has been verified to work. - */ -export const OPENAI_COMPATIBLE_PROVIDERS: Array<{ +export interface ProviderPreset { + id: string; name: string; - url: string; - /** Whether the provider supports OpenAI-style `tools` / function calling */ - supportsTools?: boolean; - /** Recommended agent model for this provider */ + /** Base URL per wire protocol. A protocol the provider does not serve is absent. */ + urls: Partial>; + /** This provider's model ids (the "Custom model" option is added by the UI). */ + models: ModelPreset[]; + /** Model selected when switching to this provider. */ suggestedModel?: string; - /** P54: per-provider recommended context window (tokens). Used as the default in Settings. */ + /** Whether the provider supports `tools` / function calling */ + supportsTools?: boolean; + /** P54: recommended context window (tokens), applied by the quick-fill button. */ contextWindow?: number; - /** P54: per-provider recommended max output (tokens). Used as the default in Settings. */ + /** P54: recommended max output (tokens), applied by the quick-fill button. */ maxTokens?: number; -}> = [ - // OpenAI official — GPT-6 / GPT-5.x / o series REQUIRE max_completion_tokens (handled in adapter) - { name: 'OpenAI', url: 'https://api.openai.com/v1', supportsTools: true, suggestedModel: 'gpt-6-astra', contextWindow: 1_050_000, maxTokens: 32_768 }, - { name: 'DeepSeek', url: 'https://api.deepseek.com', supportsTools: true, suggestedModel: 'deepseek-v4-pro', contextWindow: 1_048_576, maxTokens: 32_768 }, - { name: 'Kimi / Moonshot', url: 'https://api.moonshot.cn/v1', supportsTools: true, suggestedModel: 'kimi-k2.5', contextWindow: 262_144, maxTokens: 32_768 }, - { name: 'MiniMax', url: 'https://api.minimax.io/v1', supportsTools: true, suggestedModel: 'minimax-m3', contextWindow: 512_000, maxTokens: 32_768 }, - { name: 'Zhipu (GLM)', url: 'https://open.bigmodel.cn/api/paas/v4', supportsTools: true, suggestedModel: 'glm-4.6', contextWindow: 200_000, maxTokens: 32_768 }, - { name: 'Alibaba Bailian (Qwen)', url: 'https://dashscope.aliyuncs.com/compatible-mode/v1', supportsTools: true, suggestedModel: 'qwen3.7-max', contextWindow: 262_144, maxTokens: 32_768 }, - { name: 'SiliconFlow', url: 'https://api.siliconflow.cn/v1', supportsTools: true, contextWindow: 128_000, maxTokens: 32_768 }, - { name: 'Ollama (local)', url: 'http://localhost:11434/v1', supportsTools: false, contextWindow: 32_768, maxTokens: 8_192 }, +} + +export const PROVIDERS: ProviderPreset[] = [ + { + // GPT-6 / GPT-5.x / o series REQUIRE max_completion_tokens (handled in the adapter) + id: 'openai', + name: 'OpenAI', + urls: { openai: 'https://api.openai.com/v1' }, + models: [ + { label: 'GPT-6 Astra (1M ctx, recommended)', value: 'gpt-6-astra' }, + { label: 'GPT-5.6 Sol', value: 'gpt-5.6-sol' }, + { label: 'GPT-4.1', value: 'gpt-4.1' }, + { label: 'GPT-4o Mini', value: 'gpt-4o-mini' }, + ], + suggestedModel: 'gpt-6-astra', + supportsTools: true, + contextWindow: 1_050_000, + maxTokens: 32_768, + }, + { + id: 'anthropic', + name: 'Anthropic', + urls: { anthropic: 'https://api.anthropic.com' }, + models: [ + { label: 'Claude Opus 5.5 (1M ctx, recommended)', value: 'claude-opus-5-5' }, + { label: 'Claude Fable 5.1 (1M ctx, most capable)', value: 'claude-fable-5-1' }, + { label: 'Claude Sonnet 4.6', value: 'claude-sonnet-4-6' }, + ], + suggestedModel: 'claude-opus-5-5', + supportsTools: true, + contextWindow: 1_000_000, + maxTokens: 32_768, + }, + { + id: 'deepseek', + name: 'DeepSeek', + urls: { openai: 'https://api.deepseek.com', anthropic: 'https://api.deepseek.com/anthropic' }, + models: [ + { label: 'DeepSeek V4 Pro (强)', value: 'deepseek-v4-pro' }, + { label: 'DeepSeek V4.1 Flash (快,支持图片)', value: 'deepseek-flash' }, + ], + suggestedModel: 'deepseek-v4-pro', + supportsTools: true, + contextWindow: 1_048_576, + maxTokens: 32_768, + }, + { + id: 'kimi', + name: 'Kimi / Moonshot', + urls: { openai: 'https://api.moonshot.cn/v1', anthropic: 'https://api.moonshot.cn/anthropic' }, + models: [ + { label: 'Kimi K3', value: 'kimi-k3' }, + { label: 'Kimi K2.5', value: 'kimi-k2.5' }, + ], + suggestedModel: 'kimi-k3', + supportsTools: true, + contextWindow: 262_144, + maxTokens: 32_768, + }, + { + id: 'minimax', + name: 'MiniMax', + urls: { openai: 'https://api.minimax.io/v1', anthropic: 'https://api.minimax.io/anthropic' }, + models: [{ label: 'MiniMax M3 (512K ctx)', value: 'minimax-m3' }], + suggestedModel: 'minimax-m3', + supportsTools: true, + contextWindow: 512_000, + maxTokens: 32_768, + }, + { + id: 'zhipu', + name: 'Zhipu (GLM)', + urls: { openai: 'https://open.bigmodel.cn/api/paas/v4', anthropic: 'https://open.bigmodel.cn/api/anthropic' }, + models: [{ label: 'GLM-4.6', value: 'glm-4.6' }], + suggestedModel: 'glm-4.6', + supportsTools: true, + contextWindow: 200_000, + maxTokens: 32_768, + }, + { + id: 'qwen', + name: 'Alibaba Bailian (Qwen)', + urls: { + openai: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + anthropic: 'https://dashscope.aliyuncs.com/apps/anthropic', + }, + models: [ + { label: 'Qwen 3.8 Max', value: 'qwen3.8-max' }, + { label: 'Qwen 3.7 Max', value: 'qwen3.7-max' }, + ], + suggestedModel: 'qwen3.8-max', + supportsTools: true, + contextWindow: 262_144, + maxTokens: 32_768, + }, + { + id: 'siliconflow', + name: 'SiliconFlow', + urls: { openai: 'https://api.siliconflow.cn/v1' }, + models: [], + supportsTools: true, + contextWindow: 128_000, + maxTokens: 32_768, + }, + { + id: 'ollama', + name: 'Ollama (local)', + urls: { openai: 'http://localhost:11434/v1', anthropic: 'http://localhost:11434' }, + models: [], + supportsTools: false, + contextWindow: 32_768, + maxTokens: 8_192, + }, ]; +const CUSTOM: ModelPreset = { label: 'Custom model', value: 'custom' }; + +function normURL(url?: string): string { + return (url ?? '').trim().toLowerCase().replace(/\/+$/, ''); +} + +function hostOf(url: string): string { + try { return new URL(url).host; } catch { return ''; } +} + +/** + * P132: the provider a base URL belongs to. An exact URL match wins (either protocol); + * otherwise the host decides, so a hand-edited path (`https://api.deepseek.com/v1`) still + * resolves. A gateway we don't know returns undefined. + */ +export function providerForURL(url?: string): ProviderPreset | undefined { + const u = normURL(url); + if (!u) return undefined; + const exact = PROVIDERS.find((p) => Object.values(p.urls).some((x) => normURL(x) === u)); + if (exact) return exact; + const host = hostOf(u); + return host ? PROVIDERS.find((p) => Object.values(p.urls).some((x) => hostOf(x!) === host)) : undefined; +} + +/** Providers that serve a given protocol (the quick-fill row). */ +export function providersFor(protocol: LLMProtocol): ProviderPreset[] { + return PROVIDERS.filter((p) => !!p.urls[protocol]); +} + +/** P132: model dropdown — the base URL's provider's models + "Custom model" only. */ +export function modelOptions(protocol: LLMProtocol, baseURL?: string): ModelPreset[] { + const p = providerForURL(baseURL); + return p && p.urls[protocol] ? [...p.models, CUSTOM] : [CUSTOM]; +} + +/** Model to use on `p`: keep the current one if this provider lists it. */ +function modelFor(p: ProviderPreset, current?: string): string { + if (current && p.models.some((m) => m.value === current)) return current; + return p.suggestedModel ?? p.models[0]?.value ?? ''; +} + +/** + * P132: switching protocol keeps the provider when it serves the other protocol too + * (DeepSeek `https://api.deepseek.com` ⇄ `https://api.deepseek.com/anthropic`), and falls + * back to the protocol's official endpoint otherwise. + */ +export function switchProtocol( + cfg: Pick, + protocol: LLMProtocol, +): Pick { + const cur = providerForURL(cfg.baseURL); + const target = cur?.urls[protocol] ? cur : providerForURL(DEFAULT_URLS[protocol])!; + return { baseURL: target.urls[protocol]!, model: modelFor(target, cfg.model) }; +} + +/** P132: quick-fill — URL for the current protocol, a model of this provider, its defaults. */ +export function applyProviderPreset( + p: ProviderPreset, + protocol: LLMProtocol, + currentModel?: string, +): Partial { + return { + baseURL: p.urls[protocol] ?? '', + model: modelFor(p, currentModel), + ...(p.contextWindow ? { contextWindow: p.contextWindow } : {}), + ...(p.maxTokens ? { maxTokens: p.maxTokens } : {}), + }; +} + +/** Every preset model per protocol (+ custom) — kept for callers that want a flat list. */ +export const MODEL_PRESETS: Record = { + anthropic: [...providersFor('anthropic').flatMap((p) => p.models), CUSTOM], + openai: [...providersFor('openai').flatMap((p) => p.models), CUSTOM], +}; + +/** OpenAI-compatible endpoints, flattened from PROVIDERS. */ +export const OPENAI_COMPATIBLE_PROVIDERS = providersFor('openai').map((p) => ({ + name: p.name, + url: p.urls.openai!, + supportsTools: p.supportsTools, + suggestedModel: p.suggestedModel, + contextWindow: p.contextWindow, + maxTokens: p.maxTokens, +})); + export const DEFAULT_CONFIG: LLMConfig = { protocol: 'openai', baseURL: 'https://api.deepseek.com', diff --git a/tests/presets.test.mjs b/tests/presets.test.mjs index 7b2e404..4657a69 100644 --- a/tests/presets.test.mjs +++ b/tests/presets.test.mjs @@ -13,6 +13,12 @@ import { MODEL_PRESETS, DEFAULT_CONFIG, OPENAI_COMPATIBLE_PROVIDERS, + PROVIDERS, + providerForURL, + providersFor, + modelOptions, + switchProtocol, + applyProviderPreset, } from '../dist/main/shared/presets.js'; import { validateConfig } from '../dist/main/main/llm/factory.js'; @@ -74,3 +80,58 @@ test('presets: DEFAULT_CONFIG 温度和 maxTokens 在合理范围', () => { `异常 maxTokens: ${DEFAULT_CONFIG.maxTokens}`, ); }); + +// ===== P132: provider-organised presets ===== + +test('presets: every provider URL is valid and its suggested model is one of its models', () => { + for (const p of PROVIDERS) { + for (const url of Object.values(p.urls)) assert.doesNotThrow(() => new URL(url), `${p.id}: ${url}`); + if (p.suggestedModel) { + assert.ok(p.models.some((m) => m.value === p.suggestedModel), `${p.id}: ${p.suggestedModel}`); + } + } +}); + +test('presets: retired DeepSeek ids are gone, deepseek-flash is listed', () => { + const ids = PROVIDERS.flatMap((p) => p.models.map((m) => m.value)); + assert.equal(ids.includes('deepseek-v4-flash'), false); + assert.ok(ids.includes('deepseek-flash')); +}); + +test('presets: a URL resolves to its provider, by exact URL or by host', () => { + assert.equal(providerForURL('https://api.deepseek.com')?.id, 'deepseek'); + assert.equal(providerForURL('https://api.deepseek.com/anthropic/')?.id, 'deepseek'); + assert.equal(providerForURL('https://api.deepseek.com/v1')?.id, 'deepseek'); + assert.equal(providerForURL('https://my-gateway.example.com/v1'), undefined); +}); + +test('presets: the model list shows only the provider\'s models + custom', () => { + const ds = modelOptions('openai', 'https://api.deepseek.com').map((m) => m.value); + assert.deepEqual(ds, ['deepseek-v4-pro', 'deepseek-flash', 'custom']); + assert.deepEqual(modelOptions('anthropic', 'https://api.deepseek.com/anthropic').map((m) => m.value), + ['deepseek-v4-pro', 'deepseek-flash', 'custom']); + assert.deepEqual(modelOptions('openai', 'https://my-gateway.example.com/v1').map((m) => m.value), ['custom']); + assert.deepEqual(modelOptions('anthropic', 'https://api.openai.com/v1').map((m) => m.value), ['custom']); +}); + +test('presets: switching protocol keeps the provider and swaps its URL', () => { + assert.deepEqual(switchProtocol({ baseURL: 'https://api.deepseek.com', model: 'deepseek-flash' }, 'anthropic'), + { baseURL: 'https://api.deepseek.com/anthropic', model: 'deepseek-flash' }); + assert.deepEqual(switchProtocol({ baseURL: 'https://api.moonshot.cn/anthropic', model: 'kimi-k3' }, 'openai'), + { baseURL: 'https://api.moonshot.cn/v1', model: 'kimi-k3' }); + // OpenAI has no Anthropic endpoint → the official Anthropic endpoint + its model + assert.deepEqual(switchProtocol({ baseURL: 'https://api.openai.com/v1', model: 'gpt-6-astra' }, 'anthropic'), + { baseURL: 'https://api.anthropic.com', model: 'claude-opus-5-5' }); + assert.deepEqual(switchProtocol({ baseURL: 'https://api.anthropic.com', model: 'claude-opus-5-5' }, 'openai'), + { baseURL: 'https://api.openai.com/v1', model: 'gpt-6-astra' }); +}); + +test('presets: quick-fill uses the URL for the selected protocol', () => { + const ds = PROVIDERS.find((p) => p.id === 'deepseek'); + assert.equal(applyProviderPreset(ds, 'anthropic').baseURL, 'https://api.deepseek.com/anthropic'); + assert.equal(applyProviderPreset(ds, 'openai').baseURL, 'https://api.deepseek.com'); + assert.equal(applyProviderPreset(ds, 'openai', 'deepseek-flash').model, 'deepseek-flash'); + assert.equal(applyProviderPreset(ds, 'openai', 'gpt-6-astra').model, 'deepseek-v4-pro'); + assert.ok(providersFor('anthropic').every((p) => p.urls.anthropic)); + assert.equal(providersFor('anthropic').some((p) => p.id === 'openai'), false); +}); From 97945038cb9ffadcc12a6c572a8b79f49e10d9db Mon Sep 17 00:00:00 2001 From: Raylan LIN Date: Fri, 2 Oct 2026 02:19:53 +0000 Subject: [PATCH 2/3] fix: P133 show the SolidWorks instance the tools drive; mass_properties under late binding The sidecar connects with Dispatch("SldWorks.Application"). When COM cannot reach the running SolidWorks (typically a privilege-level mismatch: one of the two "Run as administrator"), it silently starts a new, invisible SolidWorks and every tool runs there - a whole part was built and verified while the user's window stayed empty. On connect the sidecar now checks Visible: a hidden instance is made visible (Visible, UserControl), the SLDWORKS.exe list before/after tells a second instance from "SolidWorks was not running", and a note naming the cause and the fix (plus whether Millwright is elevated) rides on tool results as _connection; the chat shows it once per session. mass_properties failed with "CreateMassProperty/2 unavailable": a COM object is always callable (__call__ -> default member), so the late-bound MassProperty object that getattr already returned was called again. sw_get now returns COM objects as-is; mass_properties uses it. Tests: sidecar/tests/test_p133_visibility.py (fails 5/5 on 0.2.131). 197 JS + 63 Python tests pass; typecheck, lint, ruff, compileall OK. --- CHANGELOG.md | 35 ++++++++++++ README.md | 6 +- README.zh-CN.md | 6 +- package.json | 2 +- sidecar/sw_agent/bridge.py | 82 ++++++++++++++++++++++++++- sidecar/sw_agent/server.py | 4 ++ sidecar/sw_agent/tools/query.py | 6 +- sidecar/tests/test_p133_visibility.py | 67 ++++++++++++++++++++++ src/main/agent/agent-loop-sidecar.ts | 8 +++ 9 files changed, 204 insertions(+), 12 deletions(-) create mode 100644 sidecar/tests/test_p133_visibility.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c6f548..d7fb9dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,41 @@ ## [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) diff --git a/README.md b/README.md index 8b780e7..edb69ca 100644 --- a/README.md +++ b/README.md @@ -28,12 +28,12 @@

- version + version electron react typescript python - tests + tests license

@@ -248,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.131** — 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 b182a89..ee73d31 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -27,12 +27,12 @@

- version + version electron react typescript python - tests + tests license

@@ -247,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.131** — 大量真机加固 ← *当前*:草图 → 特征 → 切除 → 视觉核验的完整闭环已在真机上端到端跑通 +- [x] **v0.2.4 → v0.2.132** — 大量真机加固 ← *当前*:草图 → 特征 → 切除 → 视觉核验的完整闭环已在真机上端到端跑通 - [ ] **v0.3** — 流式工具调用、在模型面上画草图(而非仅基准面)、孔向导、钣金、工程图标注、剩余 `# VERIFY` 参数完成核验 - [ ] **v1.0** — MCP server、多 CAD 支持 diff --git a/package.json b/package.json index 9d061e3..b461744 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "millwright", - "version": "0.2.131", + "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..b56ba54 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, + 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; From 4c4f256e90515483d37064ed4fbfa53e061af5b1 Mon Sep 17 00:00:00 2001 From: Raylan LIN Date: Fri, 2 Oct 2026 02:22:40 +0000 Subject: [PATCH 3/3] fix: P133 explicit check=False on the tasklist subprocess (ruff PLW1510 in CI) --- sidecar/sw_agent/bridge.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sidecar/sw_agent/bridge.py b/sidecar/sw_agent/bridge.py index b56ba54..183b998 100644 --- a/sidecar/sw_agent/bridge.py +++ b/sidecar/sw_agent/bridge.py @@ -96,7 +96,7 @@ def _sw_pids() -> set[int] | None: try: out = subprocess.run( ["tasklist", "/FI", "IMAGENAME eq SLDWORKS.exe", "/FO", "CSV", "/NH"], - capture_output=True, text=True, errors="replace", timeout=8, + capture_output=True, text=True, errors="replace", timeout=8, check=False, creationflags=0x08000000, # CREATE_NO_WINDOW ).stdout except Exception: # noqa: BLE001