From bd80caefa24649614f523e264b28dd641a67c76a Mon Sep 17 00:00:00 2001 From: "Angnuo Li (from Dev Box)" Date: Mon, 12 Oct 2026 17:15:29 +0800 Subject: [PATCH 1/2] feat: add OpenClaw local models and unified model selection Integrate the official llama.cpp provider with explicit Qwen 4B/9B preparation, scoped model selection, stable provider pickers, and revision-checked hot default switching. Preserve cloud configuration and sandbox boundaries, with pinned Windows runtime compatibility fixes and regression coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- NOTICE | 19 + README.md | 46 + README.zh-CN.md | 46 + deploy.py | 9 +- deployer/openclaw_upgrade.py | 3 +- deployer/webview_bridge.py | 7 +- deployer/windows_setup.py | 130 ++ desktop/electron-builder.yml | 6 + desktop/renderer/env.d.ts | 9 +- desktop/renderer/src/browser-openclaw.test.ts | 26 + desktop/renderer/src/browser-openclaw.ts | 50 + .../src/components/DefaultModelPicker.test.ts | 130 ++ .../src/components/DefaultModelPicker.vue | 139 ++ .../LocalModelDownloadDialog.test.ts | 456 ++++++ .../components/LocalModelDownloadDialog.vue | 383 +++++ .../components/LocalModelSetupDialog.test.ts | 819 +++++++++++ .../src/components/LocalModelSetupDialog.vue | 993 +++++++++++++ .../components/LocalModelWizardStep.test.ts | 77 + .../src/components/LocalModelWizardStep.vue | 283 ++++ .../src/components/ModelProviderGroups.vue | 219 +++ .../src/components/ModelSetupDialog.test.ts | 263 +++- .../src/components/ModelSetupDialog.vue | 270 +++- .../components/chat/ChatMessageList.test.ts | 38 + .../src/components/chat/ChatMessageList.vue | 20 +- .../components/chat/ChatModelPicker.test.ts | 682 +++++++++ .../src/components/chat/ChatModelPicker.vue | 484 ++++++ .../composables/use-local-model-operation.ts | 68 + desktop/renderer/src/i18n/en-US.ts | 211 +++ desktop/renderer/src/i18n/zh-CN.ts | 186 +++ desktop/renderer/src/stores/chat.test.ts | 123 +- desktop/renderer/src/stores/chat.ts | 133 +- .../src/utils/configured-models.test.ts | 44 + .../renderer/src/utils/configured-models.ts | 102 ++ .../renderer/src/utils/local-model-catalog.ts | 26 + .../utils/local-model-presentation.test.ts | 73 + .../src/utils/local-model-presentation.ts | 67 + .../src/utils/local-model-transition.test.ts | 106 ++ .../src/utils/local-model-transition.ts | 56 + .../renderer/src/utils/model-provider.test.ts | 88 +- desktop/renderer/src/utils/model-provider.ts | 66 +- desktop/renderer/src/views/ChatView.vue | 20 +- .../src/views/SettingsView.models.test.ts | 623 ++++++++ desktop/renderer/src/views/SettingsView.vue | 796 ++++++---- desktop/src/constants.ts | 2 + desktop/src/default-model-selection.test.ts | 248 ++++ desktop/src/default-model-selection.ts | 163 +++ desktop/src/gateway-client.test.ts | 64 + desktop/src/gateway-client.ts | 60 +- desktop/src/local-model-cache.test.ts | 49 + desktop/src/local-model-catalog.ts | 35 + desktop/src/local-model-files.test.ts | 112 ++ desktop/src/local-model-files.ts | 125 ++ .../local-model-gateway.integration.test.ts | 297 ++++ desktop/src/local-model-ipc.test.ts | 49 + desktop/src/local-model-ipc.ts | 45 + ...ocal-model-preparation.integration.test.ts | 217 +++ ...cal-model-provider-cli.integration.test.ts | 71 + .../local-model-runtime-publication.test.ts | 128 ++ desktop/src/local-model-service.test.ts | 1192 +++++++++++++++ desktop/src/local-model-service.ts | 1303 +++++++++++++++++ desktop/src/local-model-types.ts | 138 ++ desktop/src/main.ts | 302 +++- desktop/src/model-setup.test.ts | 22 +- desktop/src/model-setup.ts | 35 +- desktop/src/openclaw-fs-safe-cleanup.test.ts | 268 ++++ desktop/src/openclaw-fs-safe-compat.mjs | 278 ++++ desktop/src/openclaw-fs-safe-compat.test.ts | 241 +++ desktop/src/openclaw-local-model-compat.mjs | 222 +++ .../src/openclaw-local-model-compat.test.ts | 146 ++ desktop/src/openclaw-upgrade-recovery.test.ts | 2 +- desktop/src/openclaw-upgrade-recovery.ts | 5 +- desktop/src/preload.ts | 30 +- .../session-model-gateway.integration.test.ts | 316 ++++ desktop/src/session-model-ipc.test.ts | 38 + desktop/src/session-model-ipc.ts | 24 + desktop/src/session-model-preload.test.ts | 40 + desktop/src/session-model-service.test.ts | 683 +++++++++ desktop/src/session-model-service.ts | 527 +++++++ desktop/src/session-model-types.ts | 13 + tests/test_openclaw_upgrade.py | 28 +- tests/test_webview_bridge.py | 8 + tests/test_windows_setup_upgrade.py | 89 ++ 82 files changed, 15524 insertions(+), 486 deletions(-) create mode 100644 desktop/renderer/src/components/DefaultModelPicker.test.ts create mode 100644 desktop/renderer/src/components/DefaultModelPicker.vue create mode 100644 desktop/renderer/src/components/LocalModelDownloadDialog.test.ts create mode 100644 desktop/renderer/src/components/LocalModelDownloadDialog.vue create mode 100644 desktop/renderer/src/components/LocalModelSetupDialog.test.ts create mode 100644 desktop/renderer/src/components/LocalModelSetupDialog.vue create mode 100644 desktop/renderer/src/components/LocalModelWizardStep.test.ts create mode 100644 desktop/renderer/src/components/LocalModelWizardStep.vue create mode 100644 desktop/renderer/src/components/ModelProviderGroups.vue create mode 100644 desktop/renderer/src/components/chat/ChatMessageList.test.ts create mode 100644 desktop/renderer/src/components/chat/ChatModelPicker.test.ts create mode 100644 desktop/renderer/src/components/chat/ChatModelPicker.vue create mode 100644 desktop/renderer/src/composables/use-local-model-operation.ts create mode 100644 desktop/renderer/src/utils/configured-models.test.ts create mode 100644 desktop/renderer/src/utils/configured-models.ts create mode 100644 desktop/renderer/src/utils/local-model-catalog.ts create mode 100644 desktop/renderer/src/utils/local-model-presentation.test.ts create mode 100644 desktop/renderer/src/utils/local-model-presentation.ts create mode 100644 desktop/renderer/src/utils/local-model-transition.test.ts create mode 100644 desktop/renderer/src/utils/local-model-transition.ts create mode 100644 desktop/renderer/src/views/SettingsView.models.test.ts create mode 100644 desktop/src/default-model-selection.test.ts create mode 100644 desktop/src/default-model-selection.ts create mode 100644 desktop/src/local-model-cache.test.ts create mode 100644 desktop/src/local-model-catalog.ts create mode 100644 desktop/src/local-model-files.test.ts create mode 100644 desktop/src/local-model-files.ts create mode 100644 desktop/src/local-model-gateway.integration.test.ts create mode 100644 desktop/src/local-model-ipc.test.ts create mode 100644 desktop/src/local-model-ipc.ts create mode 100644 desktop/src/local-model-preparation.integration.test.ts create mode 100644 desktop/src/local-model-provider-cli.integration.test.ts create mode 100644 desktop/src/local-model-runtime-publication.test.ts create mode 100644 desktop/src/local-model-service.test.ts create mode 100644 desktop/src/local-model-service.ts create mode 100644 desktop/src/local-model-types.ts create mode 100644 desktop/src/openclaw-fs-safe-cleanup.test.ts create mode 100644 desktop/src/openclaw-fs-safe-compat.mjs create mode 100644 desktop/src/openclaw-fs-safe-compat.test.ts create mode 100644 desktop/src/openclaw-local-model-compat.mjs create mode 100644 desktop/src/openclaw-local-model-compat.test.ts create mode 100644 desktop/src/session-model-gateway.integration.test.ts create mode 100644 desktop/src/session-model-ipc.test.ts create mode 100644 desktop/src/session-model-ipc.ts create mode 100644 desktop/src/session-model-preload.test.ts create mode 100644 desktop/src/session-model-service.test.ts create mode 100644 desktop/src/session-model-service.ts create mode 100644 desktop/src/session-model-types.ts diff --git a/NOTICE b/NOTICE index a64ac4c..6579b5b 100644 --- a/NOTICE +++ b/NOTICE @@ -10,6 +10,25 @@ included below or can be found in the respective project repositories. - Source: https://github.com/openclaw - License: See OpenClaw repository for license details +## OpenClaw fs-safe Windows move compatibility backport +- Source: https://github.com/openclaw/fs-safe +- Revision: 6ae2e2e5086f2c48b813f78cb65fd787c9879a3f +- Copyright: Copyright (c) 2026 openclaw +- License: MIT License (text below) +- Use: in-memory backport of Windows read-open timestamp handling for the exact fs-safe 0.8.5 + module, with MicroClaw's digest-bound cleanup extension for deferred Windows metadata changes. + Both move and cleanup modules are source-hash pinned. Installed files are unchanged; path, + file identity and post-copy checks remain active. + +## OpenClaw llama.cpp exact-model setup adapter +- Source: https://github.com/openclaw/openclaw +- Package: @openclaw/llama-cpp-provider@2026.9.3 +- Use: source-hash-pinned in-memory model-chooser adaptation for Qwen3.5 4B and 9B. + Adds bounded Windows runtime-directory publication retries after transient locks, + retaining directory-identity checks and official pre/post-install validation. + Artifact verification, runtime management and config persistence remain OpenClaw-owned. + Installed provider files are unchanged. + ## OpenClaw Windows Node - Source: https://github.com/openclaw/openclaw-windows-node - Revision: fc9add75eda78daf548d80a55ffb64e63b159961 diff --git a/README.md b/README.md index f256985..f6d6232 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,52 @@ On first launch, the desktop app opens a SetupWizard only when model configurati You can add or edit models any time under **Settings → Models**. Credentials are stored locally by the desktop app. If `.env` already contains `MODEL_BASE_URL`, `MODEL_API_KEY`, and `MODEL_NAME`, MicroClaw can prefill or auto-apply that configuration. +#### Local models (experimental) + +**Settings → Models** lists cloud providers and **Local model** (llama.cpp) in compact cards. Existing saved custom API connections remain manageable, but the overview has no generic add-compatible-API button or unconfigured placeholder card. **Manage** expands the connection editor inline. Saving a connection there does not change the default model or send an AI test prompt; initial onboarding retains its existing flow. The default model is selected separately. + +The model button beside the chat composer searches configured models and the two managed local presets, **Qwen3.5 4B Q4_K_M** and **Qwen3.5 9B Q4_K_M**. Selection changes **only the current conversation**, using OpenClaw session overrides. New conversations carry their selected agent's explicit session key, including the main agent, so a model can be selected before the first message in multi-agent configurations. **Use agent default** clears that override without changing agent/global defaults, fallbacks, or other conversations. First use shows the exact model download size, missing embeddings, runtime disk reserve and CPU/GPU mode. Downloads require confirmation; selection changes only after successful preparation. Cancel/failure keeps the previous selection. Already-prepared models switch directly with an inline **Switching...** indicator, without briefly opening a preparation dialog or running setup/inference tests again. Preparation, failures and interrupted-operation recovery still use a persistent dialog. + +Both the chat model picker and **Settings → Models → Default model** use the same collapsible provider groups. Only the currently selected model's provider opens by default; headings can be toggled with a click, Enter or Space. Search reveals matching groups, and clearing it restores the previous folds. Folding only changes the list display, never model configuration or downloads. The settings picker still changes the global default, while the chat picker changes only its conversation. + +The chat picker shows **Loading models...** on its button for the first catalogue read, then opens the complete list. Later openings retain the previous list and file statuses while refreshing, replacing them together instead of repeatedly shrinking and repositioning the panel. Closing or leaving the button while the first read is pending prevents a late popup. + +Changing an existing cloud **default model** uses a revision-guarded `config.patch` and waits for the Gateway's applied revision, rather than restarting the entire Gateway. Only the primary default changes; fallbacks, model policy, credentials, agent overrides and session overrides are retained. Copilot selection/catalogue reads use prepared model metadata, not full provider discovery. A rejected, uncertain or still-applying change reports an error without an automatic retry or rollback; an explicit upstream restart requirement is not reported as a successful hot switch. + +The installer prepares the pinned official llama.cpp provider without downloading model weights or the inference runtime. The installer discloses the provider and its declared capabilities; explicit user-disabled plugin policies are preserved. The local card shows **Connected** when the provider is installed and enabled, independently of whether either model is downloaded. This is provider availability, not an inference health check. Older installations can explicitly review and enable the component in local settings. + +- Opening model settings reads saved configuration, small router presets and metadata of the two pinned cache files. Plugin status reads never call ClawHub search, full hardware discovery, inference or downloads. Online component lookup occurs only during an explicit component review/install action. +- Model rows show downloaded/not-downloaded or unknown file state. Missing files go through first-use preparation rather than becoming the active model. Cached weights can be reused after cancelled preparation; merely browsing never reads GGUF contents or scans the cache. Existing external connections remain separate. +- **Download and select** immediately replaces confirmation with progress and shows the Gateway's current download/runtime message. Repeated clicks cannot resubmit consent. Preparation errors stay visible; retrying reuses complete, hash-verified cached weights rather than downloading them again. +- **Model selection does not run “Test AI connection” or a tool-use probe.** **Start using** submits a revision-guarded `config.patch` and confirms the saved configuration has been applied using read-only revision checks. It does not generate a test reply or read a temporary file. Connection and model/tool compatibility errors are reported during actual chats. Configuration validation, conflicting-write protection and sandbox permissions remain unchanged; selection success does not certify model capabilities. +- **Managed models**: 4B is listed first as the lightweight choice; 9B has higher resource requirements and can be slow on CPU. Choosing 4B always prepares 4B, never an automatic 9B replacement. OpenClaw still owns hardware admission, download hashes, runtime installation and cancellation. A version/source-hash-pinned in-memory adapter adds exact selection to the 2026.9.3 provider's wizard, preserves existing model routes, and fails closed if its source or exact-model receipts change. The underlying OpenClaw installation is not modified. +- **Existing server**: expand **Advanced options and information** to connect to a llama-server you already run on `localhost`, `127.0.0.1`, or `::1`. Supply a key only if that service requires it. MicroClaw does not own or stop this server. +- Cloud and local connections are retained when changing models. Selection does not add automatic cloud fallbacks; any previously authored OpenClaw fallback or agent/session override still needs to be reviewed separately. Local inference does not make network-dependent skills offline. +- Cloud setup remains accessible while a local wizard is unfinished. Only saving or switching a model asks to cancel conflicting local setup first; declining leaves it untouched. The only unconfigured catalog entries offered are the two pinned local presets; existing saved connections are retained. Local connection management stays under advanced local settings. +- The integration checks the Gateway's advertised setup methods and supported 2026.9.x versions. If the provider is missing from its built-in official catalog, MicroClaw verifies the official ClawHub package identity and requests `@openclaw/llama-cpp-provider@2026.9.3`, not a floating latest release. Downloads, runtime prerequisites and GPU support remain subject to the official provider. +- Actual configuration writes remain protected in Windows Node + MXC mode, while a response is active, or until conflicting setup has safely ended. Preparing a local model does not itself block existing chats; applying a model change still does. After a disconnection, resume the retained operation rather than starting it again. Cancellation can be refused while OpenClaw finishes a protected step. + +**Windows compatibility:** MicroClaw includes the [upstream Windows file-open timestamp fix](https://github.com/openclaw/fs-safe/commit/6ae2e2e5086f2c48b813f78cb65fd787c9879a3f) for the exact `fs-safe@0.8.5` dependency bundled with the supported OpenClaw runtime. MicroClaw additionally handles deferred `ctime` changes after copying: only regular, single-link files with unchanged identity, size and modification time can be reconsidered, and their contents must match the SHA-256 captured during copying. Verification uses bounded buffers, rechecks the open handle and path, and checks the exact fingerprint again after mutation authorization before deletion. Genuine changes remain errors; hardlink aliases do not use this exception. A version- and SHA-256-pinned preload validates both move and cleanup modules and applies the changes in memory; installed OpenClaw files are not modified. Other dependency versions are left unchanged, and modified 0.8.5 source is rejected rather than patched speculatively. These checks run during file moves, not when opening model settings. + +The pinned llama.cpp adapter also retries transient Windows runtime-directory publication failures (`EPERM`, `EACCES`, `EBUSY`) up to five times over 3.1 seconds. Each attempt checks the original directory identities and requires the destination to remain absent. Permanent errors remain visible; archive hashes, pre/post-install version checks, cancellation and sandbox enforcement are unchanged. + +Official plugin installation and the real Electron setup flow have been verified through model download consent on Windows, and complete preparation has been checked with private copies of cached 4B/embedding weights and a freshly installed runtime. This does not run inference or certify its speed. GGUF weights are never downloaded automatically. The existing **Other models** OpenAI-compatible endpoint configuration also remains available for a separately managed local server. + +Local CPU replies can take longer, especially with the initial agent context. While the Gateway remains connected, the chat UI allows a bounded longer wait and checks history for a completed reply. Inactivity alone is shown as a waiting notice, not a model connection failure; a successful reply clears that inferred notice without hiding actual provider errors. + +For developers, run the opt-in contract check against an installed OpenClaw entry: + +```powershell +$env:MICROCLAW_TEST_OPENCLAW_ENTRY = 'C:\path\to\openclaw\openclaw.mjs' +npm --prefix desktop test -- src/local-model-gateway.integration.test.ts +``` + +Setting `MICROCLAW_TEST_INSTALL_LOCAL_PROVIDER=1` additionally installs the official plugin and checks separate exact-4B and exact-9B download consent steps in a disposable state directory. It checks cancellation without accepting the model download or modifying the user's OpenClaw configuration. Set `MICROCLAW_OPENCLAW_TEST_PACKAGE_DIR` to the OpenClaw package directory when running `src/openclaw-fs-safe-compat.test.ts` to verify both real pinned modules and confirm their installed bytes remain unchanged. Also set `MICROCLAW_TEST_LOCAL_PLUGIN_DIR` to an existing official plugin directory to exercise 100 offline copy/cleanup cycles and preservation of a concurrently changed source. `src/openclaw-fs-safe-cleanup.test.ts` covers timestamp, content, path and authorization races without an installed runtime. + +Alternatively, set `MICROCLAW_TEST_LOCAL_PLUGIN_DIR` to an existing official llama.cpp plugin directory to exercise direct selection against a disposable Gateway and a local endpoint that rejects all inference requests. The check requires a successful configuration switch with zero inference requests and does not touch the user's selected model. + +To exercise complete preparation, additionally set `MICROCLAW_TEST_LOCAL_CACHE` to a cache containing the pinned 4B and embedding files and run `npm --prefix desktop test -- src/local-model-preparation.integration.test.ts`. It copies those files into a disposable state directory, requires zero model/embedding download bytes in the consent receipt, and installs/verifies the official runtime. The runtime archive may be downloaded; model weights are not. The check does not select the model, run inference or change the source cache/user configuration. + ### 3. Start working immediately The default home screen is not empty. It ships with starter task cards such as daily news, desktop organization, and travel help, plus prompt suggestions for common work. The bundled and managed skills are already installed, so optional service-specific credentials can be added later only when a particular workflow needs them. diff --git a/README.zh-CN.md b/README.zh-CN.md index 2120234..d1d11be 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -88,6 +88,52 @@ MicroClaw 仅支持 **Windows 10/11**。对大多数用户来说,真正需要 之后你可以在 **设置 → 模型** 中随时添加或修改。凭据由桌面应用本地保存,不需要手动创建配置文件。如果 `.env` 已经包含 `MODEL_BASE_URL`、`MODEL_API_KEY` 和 `MODEL_NAME`,MicroClaw 可以直接预填甚至自动写入配置。 +#### 本地模型(实验性) + +**设置 → 模型** 以统一卡片展示云端提供商和“本地模型”(llama.cpp);已有自定义 API 连接仍可管理,但总览不再提供“添加兼容 API”按钮或未配置的占位卡片。点击“管理”在原卡片中展开连接编辑。这里保存连接不切换默认模型,也不发送 AI 测试消息;初次配置仍保留原有引导流程。默认模型单独选择。 + +聊天输入框旁的模型按钮支持搜索已配置模型,以及 **Qwen3.5 4B Q4_K_M**、**Qwen3.5 9B Q4_K_M** 两个托管本地预设,通过 OpenClaw 会话覆盖设置**只切换当前会话**。新建对话的会话标识会明确携带所选智能体的归属(包括主智能体),多智能体配置下也可在第一条消息发送前选模。“使用智能体默认模型”仅清除此会话覆盖,不修改智能体或全局默认值、回退模型及其他会话。首次选择会展示精确模型大小、缺少的嵌入模型、运行时预留磁盘及 CPU/GPU 模式,明确确认后才下载,准备成功后才切换;失败或取消保留原模型。已准备的模型直接切换,在原选模控件显示“切换中…”,不再闪现准备弹窗,也不运行设置或 AI 测试。需要准备、发生错误或恢复中断操作时,仍保留可阅读和操作的弹窗。 + +聊天选模菜单和 **设置 → 模型 → 默认模型** 共用可折叠的提供商分组,默认只展开各自当前所选模型所属的提供商。点击分组标题或使用 Enter、空格键即可展开/收起;搜索自动展开匹配分组,清空后恢复之前的折叠状态。折叠仅改变列表显示,不切换模型、不改配置、不触发下载。设置中的选择仍修改全局默认模型,聊天中的选择仍只影响当前会话。 + +聊天选模首次读取清单时在按钮上显示“读取模型中…”,清单完整后再展开。之后再次打开时保留上次的列表和文件状态,在后台刷新后一次性更新,不再分批清空、补入提供商而导致面板反复变高或跳动。首次读取期间关闭或移开按钮焦点,不会在读取完成后突然弹出菜单。 + +切换已有云端**默认模型**时,通过带版本保护的 `config.patch` 更新,并等待 Gateway 确认应用该版本,不再无条件重启整个 Gateway。仅更改默认主模型,保留回退模型、模型权限策略、凭据以及 Agent 和会话覆盖。Copilot 选模和清单读取使用已准备的模型元数据,不触发完整提供商发现。拒绝、结果不明或仍在应用时会明确报错,不自动重试或回滚;若上游明确要求重启,也不会误报为热切换成功。 + +安装器会准备固定版本的官方 llama.cpp provider,但不下载模型权重和推理运行时。安装前会披露组件及其声明能力,并保留用户明确禁用的插件策略。组件安装并启用后,本地卡片即显示“已连接”,不取决于模型是否下载;它表示提供商可用,不是推理健康检查。旧安装可在本地设置中明确审阅并启用组件。 + +- 打开模型设置只读取已有配置、小型路由配置和两个固定缓存文件的元数据。插件状态刷新不查询 ClawHub,不扫描硬件、不推理、不下载;只有用户明确审阅或安装组件时才查找在线安装来源。 +- 模型列表显示“已下载 / 未下载”或未知状态。文件缺失时进入首次准备流程,不会立即切换当前模型;取消准备后保留的完整缓存可复用。浏览不会读取 GGUF 内容或扫描缓存目录,已有外部服务连接仍单独保留。 +- 点击 **下载并选用** 后立即显示进度及 Gateway 当前的下载/运行组件安装消息,不再停留在确认页,也不能重复提交确认。准备失败会明确显示原因;重试时复用完整且通过哈希验证的缓存,不重复下载模型。 +- **选用模型不再运行“Test AI connection”或工具调用测试。** 点击“开始使用”后,通过带版本保护的 `config.patch` 保存选择,再只读核对配置是否生效,不生成测试回复或读取临时文件。连接、模型和工具兼容性问题在实际聊天时提示。配置校验、并发写入保护和沙箱权限不变;“已选用”不表示模型能力已经验证。 +- **托管模型**:4B 排在前面作为轻量选择,9B 资源要求更高,CPU 推理可能较慢。选 4B 就只准备 4B,不会自动换成 9B。OpenClaw 继续负责硬件准入、下载哈希、运行时安装和取消。MicroClaw 对指定 2026.9.3 provider 应用版本/源文件哈希固定的内存适配,为官方向导增加精确选择并保留已有模型路由;源码或确认回执不匹配时明确拒绝,不修改已安装的 OpenClaw 文件。 +- **连接已有服务**:展开“高级选项与说明”,接入自行运行在 `localhost`、`127.0.0.1` 或 `::1` 的 llama-server,仅在服务要求时填写密钥。MicroClaw 不接管或停止该服务。 +- 切换模型会保留云端及本地连接,不新增自动云端兜底。已有 OpenClaw fallback、agent 或会话模型覆盖仍需单独检查。本地推理不代表联网技能可以离线运行。 +- 本地向导未结束时仍可打开云端设置。只有实际保存或切换模型时,才会询问是否先取消冲突的本地配置;拒绝后保持原状。未配置的目录项仅限这两个固定本地预设,其他已有连接仍保留;本地连接管理放在高级选项中。 +- 集成会检查 Gateway 的公开设置接口及支持的 2026.9.x 版本。内置官方目录没有该插件时,MicroClaw 核对 ClawHub 官方包身份并请求固定版本 `@openclaw/llama-cpp-provider@2026.9.3`,不追随浮动最新版。下载、运行时依赖与 GPU 支持仍以官方插件为准。 +- 实际配置写入仍受 Windows Node + MXC、当前回复和未结束配置操作的保护。准备本地模型本身不再阻止已有聊天,真正应用模型变更时仍会保护当前请求。断线后恢复原操作,不重复启动;OpenClaw 正在完成受保护步骤时可能暂不接受取消。 + +**Windows 兼容处理:**MicroClaw 为受支持 OpenClaw 中的指定 `fs-safe@0.8.5` 依赖回移了[上游 Windows 文件打开时间戳修复](https://github.com/openclaw/fs-safe/commit/6ae2e2e5086f2c48b813f78cb65fd787c9879a3f),并补充复制后 `ctime` 延迟更新的收尾处理:仅允许身份、大小和修改时间不变的单链接普通文件进入复核,且内容必须与复制时记录的 SHA-256 一致。复核采用固定大小缓冲区,核对打开的文件句柄和路径,并在删除授权后再次比对完整身份。真实修改仍报错,硬链接别名不使用该例外。预加载模块核对版本及移动、清理两个模块的 SHA-256 后,仅在内存中应用修复,不修改已安装的 OpenClaw 文件。其他依赖版本保持原样;修改过的 0.8.5 源码会被明确拒绝。这些检查只在文件移动时执行,不会在打开模型设置时读取模型内容。 + +固定版本的 llama.cpp 适配还会对 Windows 运行组件目录发布时的短暂占用(`EPERM`、`EACCES`、`EBUSY`)最多重试五次,累计等待 3.1 秒。每次重试均核对原目录身份,并要求目标仍不存在。持续失败仍明确报错;压缩包哈希、安装前后版本校验、取消和沙箱保护均不改变。 + +官方插件安装和真正的 Electron 桌面引导已在 Windows 上推进到下载确认步骤;完整准备流程也已使用 4B/embedding 缓存的独立副本及新安装的运行组件验证,不进行推理或承诺推理速度。不会自动下载 GGUF 模型文件。自行管理的本地服务也可继续使用原有“其他模型”中的 OpenAI 兼容接口配置。 + +CPU 模式处理较长的初始上下文时可能需要更多时间。Gateway 仍连接时,界面会延长有限等待并核对历史回复,不会仅凭一段时间没有输出就判断模型断网。成功回复会清除这种等待提示,但真实的模型接口错误仍会保留。 + +开发者可按需检查已安装的 OpenClaw: + +```powershell +$env:MICROCLAW_TEST_OPENCLAW_ENTRY = 'C:\path\to\openclaw\openclaw.mjs' +npm --prefix desktop test -- src/local-model-gateway.integration.test.ts +``` + +额外设置 `MICROCLAW_TEST_INSTALL_LOCAL_PROVIDER=1` 会在一次性状态目录安装官方插件并推进到模型下载确认步骤,检查取消流程,不会同意模型下载或修改用户的 OpenClaw 配置。运行 `src/openclaw-fs-safe-compat.test.ts` 时,可将 `MICROCLAW_OPENCLAW_TEST_PACKAGE_DIR` 指向 OpenClaw 安装目录,核验两个指定模块及已安装文件内容未被修改;再设置 `MICROCLAW_TEST_LOCAL_PLUGIN_DIR` 指向已有官方插件目录,可离线执行 100 次复制/清理并检查并发修改的源文件仍被保留。`src/openclaw-fs-safe-cleanup.test.ts` 无需已安装运行时即可覆盖时间戳、内容、路径及授权竞态。 + +也可将 `MICROCLAW_TEST_LOCAL_PLUGIN_DIR` 指向已有的官方 llama.cpp 插件目录,在一次性 Gateway 和拒绝全部推理请求的本地端点上检查直接选用流程。该检查要求配置切换成功且推理请求数为零,不改动用户当前模型。 + +完整准备检查还需将 `MICROCLAW_TEST_LOCAL_CACHE` 指向包含固定 4B 和 embedding 文件的缓存,再运行 `npm --prefix desktop test -- src/local-model-preparation.integration.test.ts`。它将模型文件复制到一次性状态目录,要求确认回执中的模型和 embedding 下载量均为零,再安装、校验官方运行组件。运行组件压缩包可能需要下载,但不下载模型权重,不选用模型、不推理,也不改变原缓存或用户配置。 + ### 3. 安装完就能开始做事 默认首页不是空白状态,而是自带每日资讯、桌面整理、出行助手等推荐任务卡片,以及一组常见提示词建议。内置技能和托管技能已经安装好,只有在某些特定工作流需要接入外部服务时,才需要额外补充对应服务自己的 API 凭据。 diff --git a/deploy.py b/deploy.py index e187984..04f1e45 100644 --- a/deploy.py +++ b/deploy.py @@ -447,7 +447,9 @@ def _build_welcome_page(self): page, text=( "This will install the MicroClaw desktop app\n" - "and configure skills, sandbox, and plugins." + "and configure skills, sandbox, and plugins.\n" + "Includes official llama.cpp provider capabilities.\n" + "Models and runtime downloads require separate confirmation." ), font=self._font_subtitle, bg=CARD_BG, @@ -927,6 +929,11 @@ def _build_install_steps(self, ws): (62, "Copying bundled assets...", lambda: self._copy_bundled_assets(ws)), (65, "Writing API keys...", lambda: self._write_env_file()), (70, "Writing MicroClaw configuration...", ws.write_config), + ( + 76, + "Preparing official local provider (no model download)...", + ws.install_local_model_provider, + ), (80, "Installing web search provider...", ws.install_search_provider_plugin), (85, "Provisioning AppContainer sandbox...", ws.provision_appcontainer), (94, "Validating MicroClaw update...", ws.verify_openclaw_upgrade), diff --git a/deployer/openclaw_upgrade.py b/deployer/openclaw_upgrade.py index e0cb5c3..f2b1d5a 100644 --- a/deployer/openclaw_upgrade.py +++ b/deployer/openclaw_upgrade.py @@ -51,6 +51,7 @@ class UpgradeBackupMode(StrEnum): Path("_internal"), ) _MANAGED_STATE_PATHS = _MANAGED_STATE_CORE_PATHS + _UNINSTALLER_STATE_PATHS +_ALLOWED_MANAGED_STATE_PATHS = _MANAGED_STATE_PATHS + (Path("extensions") / "llama-cpp",) def managed_state_paths(*, include_uninstaller: bool) -> tuple[Path, ...]: @@ -726,7 +727,7 @@ def _validate_manifest(self) -> None: paths = self.manifest.managed_paths if not isinstance(paths, list) or not all(isinstance(path, str) for path in paths): raise ValueError("schema 2 manifest must declare managed paths") - allowed = {path.as_posix() for path in _MANAGED_STATE_PATHS} + allowed = {path.as_posix() for path in _ALLOWED_MANAGED_STATE_PATHS} if len(paths) != len(set(paths)) or any(path not in allowed for path in paths): raise ValueError("manifest contains an invalid managed path") if self.manifest.backup_mode == UpgradeBackupMode.FULL and paths: diff --git a/deployer/webview_bridge.py b/deployer/webview_bridge.py index 1f21c1e..e5216a7 100644 --- a/deployer/webview_bridge.py +++ b/deployer/webview_bridge.py @@ -247,7 +247,7 @@ def _assets_dir(): "zh": { "language": "语言", "welcomeTitle": "欢迎来到 MicroClaw", - "legalLine": "点击“快速安装”,即表示你同意 MicroClaw", + "legalLine": "安装包含官方 llama.cpp 提供商及其声明能力;模型与推理运行时在首次选用时另行确认下载。点击“快速安装”,即表示你同意 MicroClaw", "serviceAgreement": "MicroClaw 最终用户许可协议", "and": "和", "privacyStatement": "隐私声明", @@ -286,6 +286,7 @@ def _assets_dir(): "config": "正在写入 MicroClaw 配置…", "compileCache": "正在预热启动缓存…", "searchProvider": "正在安装网络搜索插件…", + "localProvider": "正在准备本地模型提供商(不下载模型)…", "sandbox": "正在配置 AppContainer 沙箱…", "verifyUpgrade": "正在验证 MicroClaw 更新…", "uninstaller": "正在安装卸载程序…", @@ -302,7 +303,7 @@ def _assets_dir(): "en": { "language": "Language", "welcomeTitle": "Welcome to MicroClaw", - "legalLine": 'By clicking "Quick Install", you agree to the MicroClaw', + "legalLine": 'Includes the official llama.cpp provider and its declared capabilities. Model and inference runtime downloads require separate first-use confirmation. By clicking "Quick Install", you agree to the MicroClaw', "serviceAgreement": "MicroClaw End User License Agreement", "and": "and", "privacyStatement": "Privacy Statement", @@ -341,6 +342,7 @@ def _assets_dir(): "config": "Writing MicroClaw configuration...", "compileCache": "Warming up V8 compile cache...", "searchProvider": "Installing web search provider...", + "localProvider": "Preparing local model provider (no model download)...", "sandbox": "Provisioning AppContainer sandbox...", "verifyUpgrade": "Validating MicroClaw update...", "uninstaller": "Installing uninstaller...", @@ -598,6 +600,7 @@ def _build_install_steps(self, ws): (62, steps["assets"], lambda: self._copy_bundled_assets(), LOCAL_RETRIES), (65, steps["apiKeys"], lambda: self._write_env_file(), LOCAL_RETRIES), (70, steps["config"], ws.write_config, LOCAL_RETRIES), + (76, steps["localProvider"], ws.install_local_model_provider, NETWORK_RETRIES), ( 80, steps["searchProvider"], diff --git a/deployer/windows_setup.py b/deployer/windows_setup.py index 51103d8..12a1b4b 100644 --- a/deployer/windows_setup.py +++ b/deployer/windows_setup.py @@ -69,6 +69,10 @@ NPM_REGISTRY_HUAWEI = "https://repo.huaweicloud.com/repository/npm/" _PARALLEL_PLUGIN_ID = "parallel" _PARALLEL_PLUGIN_PACKAGE = "@openclaw/parallel-plugin" +_LOCAL_MODEL_PLUGIN_ID = "llama-cpp" +_LOCAL_MODEL_PLUGIN_PACKAGE = "@openclaw/llama-cpp-provider" +_LOCAL_MODEL_PLUGIN_VERSION = "2026.9.3" +_LOCAL_MODEL_PLUGIN_SHA256 = "0092cdf1656a013d6028f5e238eaf21fb41b1459facde3a3ea563a7567a846aa" _PARALLEL_FREE_PROVIDER = "parallel-free" _SEARCH_PROVIDERS_REQUIRING_KEY = {"brave", "tavily"} @@ -2017,6 +2021,8 @@ def prepare_openclaw_upgrade(self) -> bool: selected_managed_paths = managed_state_paths( include_uninstaller=not self._uninstaller_current_for_upgrade ) + if not (Path.home() / ".openclaw" / "extensions" / _LOCAL_MODEL_PLUGIN_ID).exists(): + selected_managed_paths += (Path("extensions") / _LOCAL_MODEL_PLUGIN_ID,) transaction = OpenClawUpgradeTransaction.create( microclaw_root=DEFAULT_DESKTOP_DIR, state_dir=Path.home() / ".openclaw", @@ -3822,6 +3828,130 @@ def _terminate_process_tree( process.kill() process.wait(timeout=5) + def _local_model_provider_is_current(self, state_dir: Path) -> bool: + plugin = state_dir / "extensions" / _LOCAL_MODEL_PLUGIN_ID + if not plugin.exists(): + return False + try: + package = json.loads((plugin / "package.json").read_text(encoding="utf-8")) + manifest = json.loads((plugin / "openclaw.plugin.json").read_text(encoding="utf-8")) + return ( + package.get("name") == _LOCAL_MODEL_PLUGIN_PACKAGE + and package.get("version") == _LOCAL_MODEL_PLUGIN_VERSION + and manifest.get("id") == _LOCAL_MODEL_PLUGIN_ID + and _LOCAL_MODEL_PLUGIN_ID in manifest.get("providers", []) + and hashlib.sha256((plugin / "dist" / "index.js").read_bytes()).hexdigest() + == _LOCAL_MODEL_PLUGIN_SHA256 + ) + except (OSError, ValueError, TypeError) as error: + self.log.error(f"Could not verify the local model provider: {error}") + return False + + def install_local_model_provider(self) -> bool: + """Prepare the reviewed official provider, never model weights or model selection.""" + state_dir = Path.home() / ".openclaw" + config_path = state_dir / "openclaw.json" + try: + config = json.loads(config_path.read_text(encoding="utf-8")) + plugins = config.get("plugins", {}) + if ( + plugins.get("enabled") is False + or _LOCAL_MODEL_PLUGIN_ID in plugins.get("deny", []) + or plugins.get("entries", {}).get(_LOCAL_MODEL_PLUGIN_ID, {}).get("enabled") + is False + ): + self.log.info("Preserving the user's disabled local provider policy") + return True + current = self._local_model_provider_is_current(state_dir) + enabled = ( + plugins.get("entries", {}).get(_LOCAL_MODEL_PLUGIN_ID, {}).get("enabled") is True + ) + allowed = not plugins.get("allow") or _LOCAL_MODEL_PLUGIN_ID in plugins["allow"] + if current and enabled and allowed: + self.log.info("The official Qwen 4B/9B provider is already ready") + return True + if not current and (state_dir / "extensions" / _LOCAL_MODEL_PLUGIN_ID).exists(): + self.log.error( + "The installed llama.cpp provider is not the pinned supported package. Review or remove that provider before updating." + ) + return False + context = self._weixin_cli_context(state_dir) + installation = self._detect_openclaw_installation() + preload = DEFAULT_DESKTOP_DIR / "resources" / "openclaw-fs-safe-compat.mjs" + if context is None or installation is None or not preload.is_file(): + self.log.error( + "The OpenClaw runtime or filesystem compatibility preload is unavailable" + ) + return False + command, env = context + env["MICROCLAW_FS_SAFE_COMPAT"] = "1" + env["MICROCLAW_OPENCLAW_PACKAGE_DIR"] = str(installation.package_dir) + env["NODE_OPTIONS"] = ( + env.get("NODE_OPTIONS", "") + f' --import "{preload.as_uri()}"' + ).strip() + if not current: + result = self._run( + command + + [ + "plugins", + "install", + f"clawhub:{_LOCAL_MODEL_PLUGIN_PACKAGE}@{_LOCAL_MODEL_PLUGIN_VERSION}", + "--accept-capabilities", + ], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=300, + env=env, + ) + if result.returncode != 0: + self.log.error( + f"Local provider installation failed: {(result.stderr or result.stdout).strip()}" + ) + return False + if not self._local_model_provider_is_current(state_dir): + self.log.error( + "The installed local provider does not match the supported official source" + ) + return False + result = self._run( + command + ["plugins", "enable", _LOCAL_MODEL_PLUGIN_ID, "--accept-capabilities"], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=120, + env=env, + ) + if result.returncode != 0: + self.log.error( + f"Local provider enablement failed: {(result.stderr or result.stdout).strip()}" + ) + return False + after = json.loads(config_path.read_text(encoding="utf-8")) + if after.get("agents") != config.get("agents") or after.get("models") != config.get( + "models" + ): + self.log.error( + "Local provider preparation unexpectedly changed model selection; installation stopped" + ) + return False + if ( + after.get("plugins", {}) + .get("entries", {}) + .get(_LOCAL_MODEL_PLUGIN_ID, {}) + .get("enabled") + is not True + ): + self.log.error("The official local provider was not enabled") + return False + self.log.success("Local provider ready; 4B/9B weights remain opt-in") + return True + except (OSError, ValueError, TypeError, subprocess.TimeoutExpired) as error: + self.log.error(f"Local provider preparation failed: {error}") + return False + def install_search_provider_plugin(self) -> bool: """Install the keyless Parallel provider selected by a fresh install.""" state_dir = Path.home() / ".openclaw" diff --git a/desktop/electron-builder.yml b/desktop/electron-builder.yml index 6b26da9..3913b25 100644 --- a/desktop/electron-builder.yml +++ b/desktop/electron-builder.yml @@ -51,6 +51,12 @@ extraResources: to: github-copilot-auth-worker.js - from: src/openclaw-approval-replay-compat.mjs to: openclaw-approval-replay-compat.mjs + - from: src/openclaw-fs-safe-compat.mjs + to: openclaw-fs-safe-compat.mjs + - from: src/openclaw-local-model-compat.mjs + to: openclaw-local-model-compat.mjs + - from: ../NOTICE + to: NOTICE - from: resources/node.exe to: node.exe - from: resources/openclaw/ diff --git a/desktop/renderer/env.d.ts b/desktop/renderer/env.d.ts index 1f393c4..e45a64e 100644 --- a/desktop/renderer/env.d.ts +++ b/desktop/renderer/env.d.ts @@ -9,7 +9,7 @@ declare module "*.vue" { interface ChatEventPayload { runId: string; sessionKey: string; - state: "delta" | "final" | "aborted" | "error"; + state: "status" | "delta" | "final" | "aborted" | "error"; message?: unknown; deltaText?: string; replace?: boolean; @@ -190,7 +190,7 @@ interface OpenClawAPI { needsSetup(): Promise; read(): Promise; readEnv(): Promise>; - write(config: any): Promise; + write(config: any, expectedConfig?: any): Promise; }; settings: { get(): Promise; @@ -301,6 +301,11 @@ interface OpenClawAPI { }; }; model: { + selectDefault( + params: import("../src/default-model-selection").DefaultModelSelection, + ): Promise<{ modelRef: string }>; + session: import("../src/session-model-types").SessionModelAPI; + local: import("../src/local-model-types").LocalModelAPI; testConnection(params: { baseUrl: string; apiKey: string; diff --git a/desktop/renderer/src/browser-openclaw.test.ts b/desktop/renderer/src/browser-openclaw.test.ts index ca81a4f..09bc5d1 100644 --- a/desktop/renderer/src/browser-openclaw.test.ts +++ b/desktop/renderer/src/browser-openclaw.test.ts @@ -25,4 +25,30 @@ describe("createBrowserOpenClawMock", () => { const removed = await api.agents.remove("code-geek"); expect(removed.agents.map((agent) => agent.id)).not.toContain("code-geek"); }); + + it("keeps local mutations unavailable without inventing a pending operation", async () => { + const api = createBrowserOpenClawMock(); + for (const result of await Promise.all([ + api.model.local.getCapabilities(), + api.model.local.detect(), + api.model.local.inspectProvider(), + api.model.local.installProvider({ confirmed: true }), + api.model.local.prepare({ authChoice: "llama-cpp" }), + api.model.local.activate({ kind: "existing-model", modelRef: "llama-cpp/model" }), + api.model.local.answer({ operationId: "operation", stepId: "step", value: true }), + api.model.local.cancel("operation"), + api.model.local.resume(), + ])) { + expect(result).toMatchObject({ + ok: false, + error: expect.stringContaining("unavailable in browser development"), + }); + } + await expect(api.model.local.getOperation()).resolves.toEqual({ ok: true, value: null }); + await expect(api.model.local.getSnapshot()).resolves.toEqual({ + ok: true, + value: { models: [] }, + }); + expect(api.model.local.onEvent(vi.fn())).toBeTypeOf("function"); + }); }); diff --git a/desktop/renderer/src/browser-openclaw.ts b/desktop/renderer/src/browser-openclaw.ts index 5879a8c..68b0a5f 100644 --- a/desktop/renderer/src/browser-openclaw.ts +++ b/desktop/renderer/src/browser-openclaw.ts @@ -223,6 +223,56 @@ export function createBrowserOpenClawMock(): OpenClawAPI { cron: { list: async () => ({ jobs: [] }) }, channels: { list: async () => ({ channels: [] }) }, model: { + selectDefault: async () => { + throw new Error("Default model selection requires the desktop application"); + }, + session: { + get: async (sessionKey) => ({ sessionKey, modelRef: null, overridden: false }), + select: async () => { + throw new Error("Session model selection requires the desktop application"); + }, + }, + local: { + getSnapshot: async () => ({ ok: true, value: { models: [] } }), + getCapabilities: async () => ({ + ok: false, + error: "Local models unavailable in browser development", + }), + detect: async () => ({ + ok: false, + error: "Local models unavailable in browser development", + }), + inspectProvider: async () => ({ + ok: false, + error: "Local models unavailable in browser development", + }), + installProvider: async () => ({ + ok: false, + error: "Local models unavailable in browser development", + }), + prepare: async () => ({ + ok: false, + error: "Local models unavailable in browser development", + }), + activate: async () => ({ + ok: false, + error: "Local models unavailable in browser development", + }), + answer: async () => ({ + ok: false, + error: "Local models unavailable in browser development", + }), + cancel: async () => ({ + ok: false, + error: "Local models unavailable in browser development", + }), + getOperation: async () => ({ ok: true, value: null }), + resume: async () => ({ + ok: false, + error: "Local models unavailable in browser development", + }), + onEvent: noopSub, + }, testConnection: async () => ({ ok: true, message: "Connection successful" }), prepareGitHubCopilot: async () => ({ restartRequired: false }), startGitHubCopilotLogin: async () => ({ sessionId: "browser-dev" }), diff --git a/desktop/renderer/src/components/DefaultModelPicker.test.ts b/desktop/renderer/src/components/DefaultModelPicker.test.ts new file mode 100644 index 0000000..e4552e5 --- /dev/null +++ b/desktop/renderer/src/components/DefaultModelPicker.test.ts @@ -0,0 +1,130 @@ +import { defineComponent } from "vue"; +import { flushPromises, mount } from "@vue/test-utils"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { setLocale } from "@/i18n"; +import DefaultModelPicker from "./DefaultModelPicker.vue"; +import type { ConfiguredModel } from "@/utils/configured-models"; + +const PopoverStub = defineComponent({ + props: { visible: Boolean }, + emits: ["update:visible"], + template: `
`, +}); +const models: ConfiguredModel[] = [ + { providerKey: "github-copilot", id: "gpt-5.5", name: "GPT-5.5", source: "auth-managed" }, + { providerKey: "github-copilot", id: "claude", name: "Claude", source: "auth-managed" }, + { providerKey: "llama-cpp", id: "qwen3.5-4b-q4_k_m", name: "Qwen3.5 4B", source: "managed" }, + { providerKey: "llama-cpp", id: "qwen3.5-9b-q4_k_m", name: "Qwen3.5 9B", source: "managed" }, +]; +const wrappers: Array> = []; +beforeEach(() => setLocale("en-US")); +afterEach(() => { + wrappers.splice(0).forEach((wrapper) => wrapper.unmount()); + document.body.replaceChildren(); +}); +async function open() { + const wrapper = mount(DefaultModelPicker, { + attachTo: document.body, + props: { + models, + modelValue: "github-copilot/gpt-5.5", + modelStatus: (model) => (model.providerKey === "llama-cpp" ? "Not downloaded" : ""), + }, + global: { stubs: { "el-popover": PopoverStub } }, + }); + wrappers.push(wrapper); + await wrapper.get('[data-testid="default-model-picker"]').trigger("click"); + return wrapper; +} +describe("DefaultModelPicker", () => { + it("shows switching in the trigger without changing the selected model before confirmation", async () => { + const wrapper = await open(); + await wrapper.setProps({ switching: true }); + const trigger = wrapper.get('[data-testid="default-model-picker"]'); + expect(trigger.text()).toContain("Switching..."); + expect(trigger.attributes("aria-busy")).toBe("true"); + expect(trigger.attributes("disabled")).toBeDefined(); + expect(trigger.attributes("aria-expanded")).toBe("false"); + await wrapper.get('[data-model-ref="llama-cpp/qwen3.5-4b-q4_k_m"]').trigger("click"); + expect(wrapper.emitted("change")).toBeUndefined(); + await wrapper.setProps({ switching: false }); + expect(trigger.text()).toContain("GPT-5.5"); + }); + + it("groups the settings default list and expands only the current default's provider", async () => { + const wrapper = await open(); + expect(wrapper.get('[data-testid="default-model-picker"]').text()).toContain( + "GitHub Copilot · GPT-5.5", + ); + expect(wrapper.get('[data-provider-group="github-copilot"]').attributes("aria-expanded")).toBe( + "true", + ); + expect(wrapper.get('[data-provider-group="llama-cpp"]').attributes("aria-expanded")).toBe( + "false", + ); + expect(wrapper.get('[data-model-ref="llama-cpp/qwen3.5-4b-q4_k_m"]').isVisible()).toBe(false); + await wrapper.get('[data-provider-group="github-copilot"]').trigger("click"); + await wrapper.get('[data-provider-group="llama-cpp"]').trigger("click"); + expect(wrapper.get('[data-model-ref="github-copilot/gpt-5.5"]').isVisible()).toBe(false); + expect(wrapper.get('[data-model-ref="llama-cpp/qwen3.5-4b-q4_k_m"]').isVisible()).toBe(true); + expect(wrapper.emitted("change")).toBeUndefined(); + }); + + it("reveals search results without losing previous folds or selecting any model", async () => { + const wrapper = await open(); + await wrapper.get(".model-search").setValue("qwen"); + expect(wrapper.get('[data-model-ref="llama-cpp/qwen3.5-4b-q4_k_m"]').isVisible()).toBe(true); + expect(wrapper.find('[data-provider-group="github-copilot"]').exists()).toBe(false); + await wrapper.get(".model-search").setValue("absent"); + expect(wrapper.text()).toContain("No matching models."); + await wrapper.get(".clear-search").trigger("click"); + expect(wrapper.get('[data-provider-group="github-copilot"]').attributes("aria-expanded")).toBe( + "true", + ); + expect(wrapper.get('[data-provider-group="llama-cpp"]').attributes("aria-expanded")).toBe( + "false", + ); + expect(wrapper.emitted("change")).toBeUndefined(); + }); + + it("emits an exact local selection without changing its displayed default before the parent saves", async () => { + const wrapper = await open(); + await wrapper.get('[data-provider-group="llama-cpp"]').trigger("click"); + const choice = wrapper.get('[data-model-ref="llama-cpp/qwen3.5-4b-q4_k_m"]'); + expect(choice.text()).toContain("Not downloaded"); + await choice.trigger("click"); + expect(wrapper.emitted("change")).toEqual([["llama-cpp/qwen3.5-4b-q4_k_m"]]); + expect(wrapper.get('[data-testid="default-model-picker"]').attributes("aria-expanded")).toBe( + "false", + ); + expect(wrapper.get('[data-testid="default-model-picker"]').text()).toContain("GPT-5.5"); + }); + + it("preserves collapsed groups on reopening and refreshes the current default without inventing one", async () => { + const wrapper = await open(); + await wrapper.get('[data-provider-group="github-copilot"]').trigger("click"); + await wrapper.get('[data-testid="default-model-picker"]').trigger("click"); + await wrapper.get('[data-testid="default-model-picker"]').trigger("click"); + await wrapper.setProps({ models: [...models] }); + expect(wrapper.get('[data-provider-group="github-copilot"]').attributes("aria-expanded")).toBe( + "false", + ); + await wrapper.setProps({ modelValue: "" }); + expect(wrapper.get('[data-testid="default-model-picker"]').text()).toContain("Select a model"); + expect(wrapper.emitted("change")).toBeUndefined(); + }); + + it("closes and blocks selection when mutations become unavailable", async () => { + const wrapper = await open(); + await wrapper.setProps({ disabled: true }); + await flushPromises(); + expect( + wrapper.get('[data-testid="default-model-picker"]').attributes("disabled"), + ).toBeDefined(); + expect(wrapper.get('[data-testid="default-model-picker"]').attributes("aria-expanded")).toBe( + "false", + ); + await wrapper.get('[data-model-ref="github-copilot/gpt-5.5"]').trigger("click"); + expect(wrapper.emitted("change")).toBeUndefined(); + }); +}); diff --git a/desktop/renderer/src/components/DefaultModelPicker.vue b/desktop/renderer/src/components/DefaultModelPicker.vue new file mode 100644 index 0000000..b99b533 --- /dev/null +++ b/desktop/renderer/src/components/DefaultModelPicker.vue @@ -0,0 +1,139 @@ + + + + + diff --git a/desktop/renderer/src/components/LocalModelDownloadDialog.test.ts b/desktop/renderer/src/components/LocalModelDownloadDialog.test.ts new file mode 100644 index 0000000..4a9a1e0 --- /dev/null +++ b/desktop/renderer/src/components/LocalModelDownloadDialog.test.ts @@ -0,0 +1,456 @@ +import { DOMWrapper, mount, flushPromises } from "@vue/test-utils"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createBrowserOpenClawMock } from "@/browser-openclaw"; +import { setLocale } from "@/i18n"; +import type { LocalModelOperation } from "../../../src/local-model-types"; +import { LOCAL_MODEL_PRESETS } from "../../../src/local-model-catalog"; +import LocalModelDownloadDialog from "./LocalModelDownloadDialog.vue"; + +const modelRef = LOCAL_MODEL_PRESETS[0].modelRef; +let listener: (operation: LocalModelOperation) => void; +let active: LocalModelOperation; +const wrappers: Array> = []; +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} +beforeEach(() => { + setLocale("en-US"); + active = { + id: "download-4b", + kind: "prepare", + phase: "step", + agentId: "main", + modelRef, + busy: true, + cancellationPending: false, + step: { id: "consent", type: "confirm", initialValue: false }, + download: { + modelRef, + modelName: "Qwen3.5 4B", + modelBytes: 2740937888, + embeddingBytes: 328577056, + runtimeDiskBytes: 2 * 1024 ** 3, + backend: "cpu", + runtimeNote: "", + }, + }; + const api = createBrowserOpenClawMock(); + api.model.local.getCapabilities = vi.fn().mockResolvedValue({ + ok: true, + value: { + supported: true, + mutable: true, + blockers: [], + gatewayVersion: "2026.9.3", + plugin: { installed: true, enabled: true, version: "2026.9.3", canInstall: false }, + }, + }); + api.model.local.getSnapshot = vi.fn().mockResolvedValue({ + ok: true, + value: { + models: [], + catalog: LOCAL_MODEL_PRESETS.map((model) => ({ + ...model, + configured: false, + fileStatus: "not-downloaded", + })), + }, + }); + api.model.local.getOperation = vi.fn().mockResolvedValue({ ok: true, value: null }); + api.model.local.onEvent = vi.fn((callback) => { + listener = callback; + return () => {}; + }); + api.model.local.prepare = vi.fn().mockResolvedValue({ ok: true, value: active }); + api.model.local.answer = vi + .fn() + .mockResolvedValue({ ok: true, value: { ...active, phase: "starting", step: undefined } }); + api.model.local.cancel = vi + .fn() + .mockResolvedValue({ ok: true, value: { ...active, phase: "cancelled", busy: false } }); + api.model.local.activate = vi.fn(); + api.model.session.select = vi.fn(async ({ sessionKey, modelRef }) => ({ + sessionKey, + modelRef, + overridden: true, + })); + api.model.testConnection = vi.fn(); + api.config.write = vi.fn(); + window.openclaw = api; +}); +afterEach(() => { + wrappers.splice(0).forEach((wrapper) => wrapper.unmount()); + document.body.replaceChildren(); + vi.restoreAllMocks(); +}); +async function open(sessionKey: string | null = "agent:main:one") { + const wrapper = mount(LocalModelDownloadDialog, { + props: { modelRef, sessionKey: sessionKey ?? undefined, streaming: false }, + global: { stubs: { teleport: true } }, + }); + wrappers.push(wrapper); + await flushPromises(); + return wrapper; +} +describe("fixed local model first-use dialog", () => { + it.each(["session", "default"])( + "never mounts a modal during slow metadata or prepared %s selection", + async (scope) => { + const capabilities = await window.openclaw.model.local.getCapabilities(); + const metadata = deferred(); + vi.mocked(window.openclaw.model.local.getCapabilities).mockReturnValue(metadata.promise); + vi.mocked(window.openclaw.model.local.getSnapshot).mockResolvedValue({ + ok: true, + value: { models: [{ modelRef, name: "Qwen", fileStatus: "present" }] }, + }); + const sessionSelection = + deferred>>(); + const defaultSelection = + deferred>>(); + vi.mocked(window.openclaw.model.session.select).mockReturnValue(sessionSelection.promise); + vi.mocked(window.openclaw.model.local.activate).mockReturnValue(defaultSelection.promise); + const inserted: Element[] = []; + const observer = new MutationObserver((records) => { + for (const record of records) { + for (const node of record.addedNodes) { + if ( + node instanceof Element && + (node.matches(".download-overlay") || node.querySelector(".download-overlay")) + ) + inserted.push(node); + } + } + }); + observer.observe(document.body, { childList: true, subtree: true }); + try { + const wrapper = mount(LocalModelDownloadDialog, { + attachTo: document.body, + props: { modelRef, sessionKey: scope === "session" ? "agent:main:fresh" : undefined }, + }); + wrappers.push(wrapper); + await flushPromises(); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + expect(wrapper.emitted("busy")?.at(-1)).toEqual([true]); + expect(window.openclaw.model.session.select).not.toHaveBeenCalled(); + metadata.resolve(capabilities); + await flushPromises(); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + expect(wrapper.emitted("busy")?.at(-1)).toEqual([true]); + if (scope === "session") { + expect(window.openclaw.model.session.select).toHaveBeenCalledExactlyOnceWith({ + sessionKey: "agent:main:fresh", + modelRef, + }); + sessionSelection.resolve({ sessionKey: "agent:main:fresh", modelRef, overridden: true }); + } else { + expect(window.openclaw.model.local.activate).toHaveBeenCalledExactlyOnceWith({ + kind: "existing-model", + modelRef, + }); + defaultSelection.resolve({ + ok: true, + value: { ...active, id: "apply", kind: "activate", phase: "ready", busy: false }, + }); + } + await flushPromises(); + expect(wrapper.emitted("selected")).toHaveLength(1); + expect(wrapper.emitted("close")).toHaveLength(1); + expect(wrapper.emitted("busy")?.at(-1)).toEqual([false]); + expect(inserted).toEqual([]); + expect(window.openclaw.model.local.prepare).not.toHaveBeenCalled(); + expect(window.openclaw.model.testConnection).not.toHaveBeenCalled(); + expect(window.openclaw.config.write).not.toHaveBeenCalled(); + } finally { + observer.disconnect(); + } + }, + ); + + it("still requests preparation when cached weights are not yet configured", async () => { + vi.mocked(window.openclaw.model.local.getSnapshot).mockResolvedValue({ + ok: true, + value: { + models: [], + catalog: [{ ...LOCAL_MODEL_PRESETS[0], configured: false, fileStatus: "present" }], + }, + }); + const wrapper = await open(); + expect(wrapper.find('[role="dialog"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="confirm-local-download"]').exists()).toBe(true); + expect(window.openclaw.model.local.prepare).toHaveBeenCalledOnce(); + expect(window.openclaw.model.session.select).not.toHaveBeenCalled(); + }); + + it("shows recovery instead of leaving an interrupted default selection invisible", async () => { + vi.mocked(window.openclaw.model.local.getSnapshot).mockResolvedValue({ + ok: true, + value: { models: [{ modelRef, name: "Qwen", fileStatus: "present" }] }, + }); + const activation: LocalModelOperation = { + ...active, + id: "apply", + kind: "activate", + phase: "activating", + step: undefined, + }; + vi.mocked(window.openclaw.model.local.activate).mockResolvedValue({ + ok: true, + value: activation, + }); + const wrapper = await open(null); + expect(wrapper.find('[role="dialog"]').exists()).toBe(false); + listener({ ...activation, phase: "interrupted", busy: false }); + await flushPromises(); + expect(wrapper.find('[role="dialog"]').exists()).toBe(true); + expect(wrapper.text()).toContain("Resume"); + expect(wrapper.emitted("busy")?.at(-1)).toEqual([false]); + expect(wrapper.emitted("selected")).toBeUndefined(); + }); + + it("renders errors and controls at the document root outside the composer stacking context", async () => { + vi.mocked(window.openclaw.model.local.getSnapshot).mockResolvedValue({ + ok: true, + value: { models: [{ modelRef, name: "Qwen", fileStatus: "present" }] }, + }); + vi.mocked(window.openclaw.model.session.select).mockRejectedValue( + new Error("The session model could not be selected"), + ); + const composer = document.createElement("div"); + composer.style.cssText = "position: relative; z-index: 1; overflow: hidden"; + document.body.appendChild(composer); + const wrapper = mount(LocalModelDownloadDialog, { + attachTo: composer, + props: { modelRef, sessionKey: "agent:main:session-fresh" }, + }); + wrappers.push(wrapper); + await flushPromises(); + expect(composer.querySelector('[role="dialog"]')).toBeNull(); + const overlay = document.body.querySelector(".download-overlay"); + expect(overlay?.parentElement).toBe(document.body); + const dialog = new DOMWrapper(document.body).get('[role="dialog"]'); + expect(dialog.attributes("aria-modal")).toBe("true"); + expect(dialog.get('[role="alert"]').text()).toBe("The session model could not be selected"); + await dialog.get('[aria-label="Close"]').trigger("click"); + expect(wrapper.emitted("close")).toHaveLength(1); + wrapper.unmount(); + expect(document.body.querySelector(".download-overlay")).toBeNull(); + }); + + it("replaces consent with gateway progress after confirmation instead of offering download again", async () => { + const wrapper = await open(); + window.openclaw.model.local.answer = vi.fn(async () => { + const progress = { + ...active, + step: { + id: "progress-1", + type: "progress" as const, + executor: "gateway" as const, + message: "Downloading Qwen3.5 4B... 25% (0.7/2.7 GB, 20 MB/s)", + }, + }; + listener(progress); + return { ok: true as const, value: progress }; + }); + await wrapper.get('[data-testid="confirm-local-download"]').trigger("click"); + await flushPromises(); + expect(wrapper.find('[data-testid="confirm-local-download"]').exists()).toBe(false); + expect(wrapper.find("dl").exists()).toBe(false); + expect(wrapper.get('[data-testid="local-download-progress"]').text()).toContain("25%"); + expect(wrapper.find("progress").exists()).toBe(true); + expect(wrapper.text()).toContain("Cancel"); + expect(window.openclaw.model.local.answer).toHaveBeenCalledOnce(); + expect(window.openclaw.model.session.select).not.toHaveBeenCalled(); + listener({ + ...active, + step: { + id: "progress-2", + type: "progress", + executor: "gateway", + message: "Downloading llama.cpp runtime... 80%", + }, + }); + await flushPromises(); + expect(wrapper.get('[data-testid="local-download-progress"]').text()).toContain("80%"); + expect(wrapper.find('[data-testid="confirm-local-download"]').exists()).toBe(false); + }); + + it("shows immediate feedback and prevents duplicate confirmation while IPC is pending", async () => { + let resolve!: (result: Awaited>) => void; + window.openclaw.model.local.answer = vi.fn( + () => + new Promise>>((done) => { + resolve = done; + }), + ); + const wrapper = await open(); + const button = wrapper.get('[data-testid="confirm-local-download"]'); + await button.trigger("click"); + expect(wrapper.find('[data-testid="confirm-local-download"]').exists()).toBe(false); + expect(wrapper.text()).toContain("Starting the confirmed download"); + await button.trigger("click"); + expect(window.openclaw.model.local.answer).toHaveBeenCalledOnce(); + resolve({ ok: true, value: { ...active, phase: "starting", step: undefined } }); + await flushPromises(); + expect(wrapper.find("progress").exists()).toBe(true); + }); + + it("shows runtime installation failure after progress and does not leave old consent visible", async () => { + const wrapper = await open(); + listener({ + ...active, + step: { + id: "runtime", + type: "progress", + executor: "gateway", + message: "Installing llama.cpp runtime", + }, + }); + await flushPromises(); + listener({ + ...active, + phase: "error", + busy: false, + step: undefined, + error: "Runtime installation failed: EPERM", + }); + await flushPromises(); + expect(wrapper.get('[role="alert"]').text()).toContain("EPERM"); + expect(wrapper.find('[data-testid="confirm-local-download"]').exists()).toBe(false); + expect(wrapper.find("progress").exists()).toBe(false); + expect(window.openclaw.model.local.answer).not.toHaveBeenCalled(); + expect(window.openclaw.model.session.select).not.toHaveBeenCalled(); + }); + + it("reopens an ongoing download as progress without repeating confirmation or preparation", async () => { + const progress = { + ...active, + step: { + id: "runtime", + type: "progress" as const, + executor: "gateway" as const, + message: "Preparing runtime", + }, + }; + vi.mocked(window.openclaw.model.local.getOperation).mockResolvedValue({ + ok: true, + value: progress, + }); + const wrapper = await open(); + expect(wrapper.get('[data-testid="local-download-progress"]').text()).toBe("Preparing runtime"); + expect(wrapper.find('[data-testid="confirm-local-download"]').exists()).toBe(false); + expect(window.openclaw.model.local.answer).not.toHaveBeenCalled(); + expect(window.openclaw.model.local.prepare).not.toHaveBeenCalled(); + }); + + it("selects a default only after exact preparation, not when consent opens", async () => { + window.openclaw.model.local.activate = vi.fn().mockResolvedValue({ + ok: true, + value: { + ...active, + id: "apply-default", + kind: "activate", + phase: "ready", + busy: false, + modelRef, + }, + }); + const wrapper = await open(null); + expect(window.openclaw.model.local.activate).not.toHaveBeenCalled(); + listener({ ...active, phase: "prepared", busy: false, preparedModelRef: modelRef }); + await flushPromises(); + expect(window.openclaw.model.local.activate).toHaveBeenCalledWith({ + kind: "existing-model", + modelRef, + }); + expect(window.openclaw.model.session.select).not.toHaveBeenCalled(); + expect(wrapper.emitted("selected")).toHaveLength(1); + }); + it("prepares the precise selected ID but waits for explicit download confirmation", async () => { + const wrapper = await open(); + expect(window.openclaw.model.local.prepare).toHaveBeenCalledWith({ + authChoice: "llama-cpp", + modelRef, + agentId: "main", + }); + expect(wrapper.text()).toContain("2.7 GB"); + expect(wrapper.text()).toContain("CPU inference can be slow"); + expect(window.openclaw.model.local.answer).not.toHaveBeenCalled(); + expect(window.openclaw.model.session.select).not.toHaveBeenCalled(); + await wrapper.get('[data-testid="confirm-local-download"]').trigger("click"); + expect(window.openclaw.model.local.answer).toHaveBeenCalledWith({ + operationId: active.id, + stepId: "consent", + value: true, + }); + expect(window.openclaw.model.testConnection).not.toHaveBeenCalled(); + }); + it("selects only the original conversation after exact preparation succeeds", async () => { + const wrapper = await open(); + listener({ + ...active, + phase: "prepared", + busy: false, + preparedModelRef: modelRef, + step: undefined, + }); + await flushPromises(); + expect(window.openclaw.model.session.select).toHaveBeenCalledWith({ + sessionKey: "agent:main:one", + modelRef, + }); + expect(window.openclaw.config.write).not.toHaveBeenCalled(); + expect(window.openclaw.model.local.activate).not.toHaveBeenCalled(); + expect(wrapper.emitted("selected")).toHaveLength(1); + }); + it("retains the previous selection if preparation fails", async () => { + const wrapper = await open(); + listener({ ...active, phase: "error", busy: false, error: "Download failed" }); + await flushPromises(); + expect(wrapper.text()).toContain("Download failed"); + expect(window.openclaw.model.session.select).not.toHaveBeenCalled(); + expect(wrapper.emitted("selected")).toBeUndefined(); + }); + it("waits for cancellation acknowledgement before closing", async () => { + window.openclaw.model.local.cancel = vi + .fn() + .mockResolvedValue({ ok: true, value: { ...active, cancellationPending: true } }); + const wrapper = await open(); + await wrapper.get('[aria-label="Close"]').trigger("click"); + await flushPromises(); + expect(wrapper.emitted("close")).toBeUndefined(); + expect(window.openclaw.model.session.select).not.toHaveBeenCalled(); + }); + it("selects a prepared file directly without setup or inference checks", async () => { + vi.mocked(window.openclaw.model.local.getSnapshot).mockResolvedValue({ + ok: true, + value: { models: [{ modelRef, name: "Qwen", fileStatus: "present" }] }, + }); + await open(); + expect(window.openclaw.model.session.select).toHaveBeenCalledOnce(); + expect(window.openclaw.model.local.prepare).not.toHaveBeenCalled(); + expect(window.openclaw.model.testConnection).not.toHaveBeenCalled(); + }); + it("does not auto-switch a conversation after its picker has been unmounted", async () => { + const wrapper = await open(); + wrapper.unmount(); + listener({ ...active, phase: "prepared", busy: false, preparedModelRef: modelRef }); + await flushPromises(); + expect(window.openclaw.model.session.select).not.toHaveBeenCalled(); + }); + it("does not select a different prepared model or cancel another operation", async () => { + vi.mocked(window.openclaw.model.local.getOperation).mockResolvedValue({ + ok: true, + value: { ...active, modelRef: LOCAL_MODEL_PRESETS[1].modelRef }, + }); + const wrapper = await open(); + expect(wrapper.find('[role="alert"]').exists()).toBe(true); + expect(window.openclaw.model.local.prepare).not.toHaveBeenCalled(); + await wrapper.get('[aria-label="Close"]').trigger("click"); + await flushPromises(); + expect(window.openclaw.model.local.cancel).not.toHaveBeenCalled(); + }); +}); diff --git a/desktop/renderer/src/components/LocalModelDownloadDialog.vue b/desktop/renderer/src/components/LocalModelDownloadDialog.vue new file mode 100644 index 0000000..a22bfe4 --- /dev/null +++ b/desktop/renderer/src/components/LocalModelDownloadDialog.vue @@ -0,0 +1,383 @@ + + + + + diff --git a/desktop/renderer/src/components/LocalModelSetupDialog.test.ts b/desktop/renderer/src/components/LocalModelSetupDialog.test.ts new file mode 100644 index 0000000..ca331f1 --- /dev/null +++ b/desktop/renderer/src/components/LocalModelSetupDialog.test.ts @@ -0,0 +1,819 @@ +import { flushPromises, mount } from "@vue/test-utils"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { setLocale } from "@/i18n"; +import LocalModelSetupDialog from "./LocalModelSetupDialog.vue"; +import { LOCAL_MODEL_PRESETS } from "../../../src/local-model-catalog"; +import type { + LocalModelAPI, + LocalModelCapabilities, + LocalModelDetection, + LocalModelOperation, + LocalModelResult, + LocalModelSnapshot, +} from "../../../src/local-model-types"; + +const ok = (value: T): LocalModelResult => ({ ok: true, value }); +const operation = (patch: Partial = {}): LocalModelOperation => ({ + id: "prepare-1", + kind: "prepare", + phase: "starting", + authChoice: "llama-cpp", + agentId: "main", + busy: true, + cancellationPending: false, + ...patch, +}); +const capabilities: LocalModelCapabilities = { + gatewayVersion: "2026.9.3", + supported: true, + mutable: true, + blockers: [], + plugin: { installed: true, enabled: true, canInstall: false }, +}; +const detection: LocalModelDetection = { + choices: [ + { id: "llama-cpp", label: "Official managed" }, + { id: "llama-cpp-existing-server", label: "Official existing" }, + ], + candidates: [ + { + kind: "prepared-model", + modelRef: "llama-cpp/exact", + label: "Exact", + detail: "Official candidate details", + recommended: true, + }, + ], + agentId: "main", +}; +const snapshot: LocalModelSnapshot = { + models: [{ modelRef: "llama-cpp/exact", name: "Exact" }], +}; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +describe("LocalModelSetupDialog", () => { + let api: LocalModelAPI; + let listener: (value: LocalModelOperation) => void; + const unsubscribe = vi.fn(); + const wrappers: Array> = []; + async function open(props = {}) { + const wrapper = mount(LocalModelSetupDialog, { props }); + wrappers.push(wrapper); + await flushPromises(); + return wrapper; + } + beforeEach(() => { + setLocale("en-US"); + api = { + getSnapshot: vi.fn().mockResolvedValue(ok(snapshot)), + getCapabilities: vi.fn().mockResolvedValue(ok(capabilities)), + detect: vi.fn().mockResolvedValue(ok(detection)), + inspectProvider: vi.fn().mockResolvedValue( + ok({ + pluginId: "llama-cpp", + source: "official catalog", + version: "2026.9.3", + capabilities: { permissions: ["Download model files", "Start local runtime"] }, + reviewToken: "review-1", + }), + ), + installProvider: vi.fn().mockResolvedValue(ok(capabilities)), + prepare: vi.fn().mockResolvedValue(ok(operation())), + activate: vi.fn().mockResolvedValue(ok(operation({ kind: "activate", phase: "activating" }))), + answer: vi.fn().mockResolvedValue(ok(operation())), + cancel: vi.fn().mockResolvedValue(ok(operation({ cancellationPending: true }))), + getOperation: vi.fn().mockResolvedValue(ok(null)), + resume: vi.fn().mockResolvedValue(ok(operation())), + onEvent: vi.fn((callback) => { + listener = callback; + return unsubscribe; + }), + }; + window.openclaw = { model: { local: api } } as unknown as typeof window.openclaw; + }); + afterEach(() => { + wrappers.splice(0).forEach((wrapper) => wrapper.unmount()); + vi.clearAllMocks(); + }); + + it("switches a prepared preset from the provider panel without opening another modal or duplicating success", async () => { + const preset = LOCAL_MODEL_PRESETS[0]; + vi.mocked(api.getSnapshot).mockResolvedValue( + ok({ + models: [{ modelRef: preset.modelRef, name: preset.name, fileStatus: "present" }], + catalog: [{ ...preset, configured: true, fileStatus: "present" }], + }), + ); + const callbacks = new Set<(value: LocalModelOperation) => void>(); + vi.mocked(api.onEvent).mockImplementation((callback) => { + callbacks.add(callback); + return () => { + callbacks.delete(callback); + }; + }); + const activation = operation({ + id: "fixed-activate", + kind: "activate", + phase: "activating", + modelRef: preset.modelRef, + }); + vi.mocked(api.activate).mockResolvedValue(ok(activation)); + const wrapper = await open({ inline: true }); + const button = wrapper.get(`[data-model-ref="${preset.modelRef}"]`); + await button.trigger("click"); + await flushPromises(); + for (const callback of callbacks) callback(activation); + await flushPromises(); + expect(button.text()).toContain("Switching..."); + expect(button.attributes("disabled")).toBeDefined(); + expect(button.attributes("aria-busy")).toBe("true"); + expect(document.body.querySelector(".download-overlay")).toBeNull(); + for (const callback of callbacks) callback({ ...activation, phase: "ready", busy: false }); + await flushPromises(); + expect(wrapper.emitted("configured")).toHaveLength(1); + expect(wrapper.emitted("close")).toHaveLength(1); + expect(api.activate).toHaveBeenCalledExactlyOnceWith({ + kind: "existing-model", + modelRef: preset.modelRef, + }); + expect(api.prepare).not.toHaveBeenCalled(); + }); + + it("keeps recovery and cancellation available when new configuration changes are locked", async () => { + vi.mocked(api.getCapabilities).mockResolvedValue(ok({ ...capabilities, mutable: false })); + vi.mocked(api.getOperation).mockResolvedValue(ok(operation({ phase: "interrupted" }))); + const wrapper = await open(); + expect(wrapper.get('[data-testid="local-resume"]').attributes("disabled")).toBeUndefined(); + expect(wrapper.get('[data-testid="local-cancel"]').attributes("disabled")).toBeUndefined(); + await wrapper.get('[data-testid="local-resume"]').trigger("click"); + expect(api.resume).toHaveBeenCalledOnce(); + expect(api.prepare).not.toHaveBeenCalled(); + expect(api.answer).not.toHaveBeenCalled(); + }); + + it("updates the exact target when another saved model is chosen while the inline editor stays open", async () => { + vi.mocked(api.getSnapshot).mockResolvedValue( + ok({ + models: [ + { modelRef: "llama-cpp/first", name: "First", fileStatus: "present" }, + { modelRef: "llama-cpp/second", name: "Second", fileStatus: "present" }, + ], + }), + ); + const wrapper = await open({ inline: true, targetModelRef: "llama-cpp/first" }); + expect(wrapper.find('[role="dialog"]').exists()).toBe(false); + await wrapper.setProps({ targetModelRef: "llama-cpp/second" }); + await wrapper.get('[data-testid="local-activate"]').trigger("click"); + expect(api.activate).toHaveBeenCalledWith({ + kind: "existing-model", + modelRef: "llama-cpp/second", + }); + expect(api.detect).not.toHaveBeenCalled(); + }); + + it("shows saved models before a slow status refresh without running full discovery", async () => { + const status = deferred>(); + vi.mocked(api.getSnapshot).mockResolvedValue( + ok({ ...snapshot, configuredModel: "llama-cpp/exact" }), + ); + vi.mocked(api.getCapabilities).mockReturnValue(status.promise); + const wrapper = await open(); + expect(wrapper.get('[data-testid="configured-local-models"]').text()).toContain("Exact"); + expect(wrapper.get('[data-testid="configured-local-models"]').text()).toContain( + "Current default", + ); + expect(wrapper.find('[data-testid="local-activate"]').exists()).toBe(false); + expect(wrapper.find('[data-testid="local-current-done"]').exists()).toBe(true); + expect(wrapper.text()).toContain("Refreshing status in the background"); + expect(wrapper.get('[data-testid="local-close"]').attributes("disabled")).toBeUndefined(); + expect(api.detect).not.toHaveBeenCalled(); + await wrapper.get('[data-testid="local-close"]').trigger("click"); + expect(wrapper.emitted("close")).toHaveLength(1); + status.resolve(ok(capabilities)); + await flushPromises(); + expect(api.prepare).not.toHaveBeenCalled(); + expect(api.activate).not.toHaveBeenCalled(); + }); + + it("reopens and refreshes known state without any full model discovery", async () => { + const first = await open(); + first.unmount(); + const wrapper = await open(); + await wrapper.get('[data-testid="local-refresh"]').trigger("click"); + await flushPromises(); + expect(api.getSnapshot).toHaveBeenCalledTimes(3); + expect(api.detect).not.toHaveBeenCalled(); + expect(api.installProvider).not.toHaveBeenCalled(); + expect(api.prepare).not.toHaveBeenCalled(); + }); + + it("shows a download recovery action instead of preparing a deleted configured model for use", async () => { + vi.mocked(api.getSnapshot).mockResolvedValue( + ok({ + models: [{ modelRef: "llama-cpp/exact", name: "Qwen 9B", fileStatus: "missing" }], + configuredModel: "llama-cpp/exact", + }), + ); + const wrapper = await open({ targetModelRef: "llama-cpp/exact" }); + expect(wrapper.get('[data-testid="configured-local-models"]').text()).toContain( + "Download needed", + ); + expect(wrapper.get('[data-testid="configured-local-models"]').text()).not.toContain( + "Current default", + ); + expect(wrapper.get('[data-testid="local-files-missing"]').text()).toContain( + "files are missing", + ); + expect(wrapper.find('[data-testid="local-activate"]').exists()).toBe(false); + expect(wrapper.find("#local-candidate").exists()).toBe(false); + expect(wrapper.find('[data-testid="local-current-done"]').exists()).toBe(false); + expect(api.prepare).not.toHaveBeenCalled(); + expect(api.activate).not.toHaveBeenCalled(); + expect(api.detect).not.toHaveBeenCalled(); + await wrapper.get('[data-testid="local-download-again"]').trigger("click"); + expect(api.prepare).toHaveBeenCalledExactlyOnceWith({ authChoice: "llama-cpp" }); + expect(api.answer).not.toHaveBeenCalled(); + }); + + it("does not offer a missing model returned by full discovery and does not show a stale ready receipt", async () => { + vi.mocked(api.getSnapshot).mockResolvedValue( + ok({ + models: [{ modelRef: "llama-cpp/exact", name: "Qwen 9B", fileStatus: "missing" }], + }), + ); + vi.mocked(api.getOperation).mockResolvedValue( + ok( + operation({ + kind: "activate", + phase: "ready", + busy: false, + modelRef: "llama-cpp/exact", + }), + ), + ); + const wrapper = await open(); + await wrapper.get('[data-testid="local-discover"]').trigger("click"); + await flushPromises(); + expect(wrapper.find('[data-testid="local-activate"]').exists()).toBe(false); + expect(wrapper.find('[data-testid="local-operation"]').exists()).toBe(false); + expect(wrapper.find('[data-testid="local-download-again"]').exists()).toBe(true); + expect(wrapper.emitted("configured")).toBeUndefined(); + }); + + it("reports unknown file state without claiming that externally managed files were deleted", async () => { + vi.mocked(api.getSnapshot).mockResolvedValue( + ok({ + models: [ + { modelRef: "llama-cpp/exact", name: "Managed", fileStatus: "unknown" }, + { modelRef: "llama-cpp/external", name: "External", fileStatus: "external" }, + ], + }), + ); + const wrapper = await open(); + expect(wrapper.get('[data-testid="configured-local-models"]').text()).toContain( + "File status unknown", + ); + expect(wrapper.find('[data-testid="local-download-again"]').exists()).toBe(false); + }); + + it("clears the missing-file notice after preparation restores the file", async () => { + vi.mocked(api.getSnapshot) + .mockResolvedValueOnce( + ok({ + models: [{ modelRef: "llama-cpp/exact", name: "Qwen 9B", fileStatus: "missing" }], + }), + ) + .mockResolvedValue( + ok({ + models: [{ modelRef: "llama-cpp/exact", name: "Qwen 9B", fileStatus: "present" }], + }), + ); + const wrapper = await open(); + listener(operation({ phase: "prepared", busy: false, preparedModelRef: "llama-cpp/exact" })); + await flushPromises(); + expect(wrapper.find('[data-testid="local-files-missing"]').exists()).toBe(false); + expect(wrapper.get('[data-testid="local-activate"]').attributes("disabled")).toBeUndefined(); + expect(api.detect).not.toHaveBeenCalled(); + }); + + it("still shows saved models if a background plugin status lookup fails", async () => { + vi.mocked(api.getCapabilities).mockResolvedValue({ + ok: false, + error: "Plugin status unavailable", + }); + const wrapper = await open(); + expect(wrapper.get('[data-testid="configured-local-models"]').text()).toContain("Exact"); + expect(wrapper.text()).toContain("Plugin status unavailable"); + expect(wrapper.get('[data-testid="local-close"]').attributes("disabled")).toBeUndefined(); + expect(wrapper.get('[data-testid="local-activate"]').attributes("disabled")).toBeDefined(); + expect(api.detect).not.toHaveBeenCalled(); + }); + + it("does not hide configuration read errors behind an apparently empty model list", async () => { + vi.mocked(api.getSnapshot).mockResolvedValue({ ok: false, error: "Saved config unavailable" }); + const wrapper = await open(); + expect(wrapper.text()).toContain("Saved config unavailable"); + expect(api.detect).not.toHaveBeenCalled(); + expect(api.prepare).not.toHaveBeenCalled(); + }); + + it("reads saved state on mount without full discovery and separates prepared from ready", async () => { + const wrapper = await open(); + expect(wrapper.text()).toContain("MicroClaw does not add automatic cloud fallback"); + expect(wrapper.text()).toContain( + "Existing OpenClaw fallback settings may still route requests", + ); + expect(api.getCapabilities).toHaveBeenCalledOnce(); + expect(api.getSnapshot).toHaveBeenCalledOnce(); + expect(api.detect).not.toHaveBeenCalled(); + expect(api.prepare).not.toHaveBeenCalled(); + expect(api.installProvider).not.toHaveBeenCalled(); + expect(api.activate).not.toHaveBeenCalled(); + await wrapper.get('[data-testid="prepare-llama-cpp"]').trigger("click"); + expect(api.onEvent).toHaveBeenCalledBefore(vi.mocked(api.prepare)); + listener(operation({ phase: "prepared", busy: false, preparedModelRef: "llama-cpp/exact" })); + await flushPromises(); + expect(wrapper.text()).toContain("Prepared — not enabled"); + expect(wrapper.emitted("configured")).toBeUndefined(); + expect(api.activate).not.toHaveBeenCalled(); + await wrapper.get('[data-testid="local-activate"]').trigger("click"); + expect(api.activate).toHaveBeenCalledWith({ + kind: "existing-model", + modelRef: "llama-cpp/exact", + }); + listener(operation({ id: "activation", kind: "activate", phase: "applying" })); + await flushPromises(); + expect(wrapper.emitted("configured")).toBeUndefined(); + listener(operation({ id: "activation", kind: "activate", phase: "ready", busy: false })); + await flushPromises(); + expect(wrapper.emitted("configured")).toHaveLength(1); + expect(wrapper.text()).toContain("without an AI test"); + expect(wrapper.text()).not.toContain("Verified and applied"); + }); + + it("requires an explicit boolean confirmation and does not auto-confirm initialValue", async () => { + const wrapper = await open(); + listener( + operation({ + phase: "step", + step: { + id: "download-consent", + type: "confirm", + initialValue: true, + message: "Download both inference and embedding files?", + }, + }), + ); + await flushPromises(); + expect(api.answer).not.toHaveBeenCalled(); + const decline = wrapper.findAll("button").find((button) => button.text() === "Decline")!; + await decline.trigger("click"); + expect(api.answer).toHaveBeenCalledWith({ + operationId: "prepare-1", + stepId: "download-consent", + value: false, + }); + expect(wrapper.emitted("configured")).toBeUndefined(); + }); + + it("renders upstream text literally and clears sensitive answers after sending", async () => { + const wrapper = await open(); + listener( + operation({ + phase: "step", + step: { + id: "credential", + type: "text", + sensitive: true, + initialValue: "do-not-prefill", + message: "Never execute this https://example.com", + }, + }), + ); + await flushPromises(); + expect(wrapper.find(".wizard-step b").exists()).toBe(false); + expect(wrapper.find(".wizard-step a").exists()).toBe(false); + const input = wrapper.get('input[type="password"]'); + expect((input.element as HTMLInputElement).value).toBe(""); + await input.setValue("secret-answer"); + await wrapper.get(".wizard-step form").trigger("submit"); + expect(api.answer).toHaveBeenCalledWith({ + operationId: "prepare-1", + stepId: "credential", + value: "secret-answer", + }); + expect((input.element as HTMLInputElement).value).toBe(""); + }); + + it("does not submit gateway-owned progress or client-ineligible actions", async () => { + const wrapper = await open(); + for (const type of ["progress", "action"] as const) { + listener( + operation({ + phase: "step", + step: { + id: type, + type, + executor: "gateway", + message: "Preparing files, no percent supplied", + }, + }), + ); + await flushPromises(); + expect(wrapper.find(".wizard-step progress").exists()).toBe(true); + expect(wrapper.find(".wizard-step form").exists()).toBe(false); + } + expect(api.answer).not.toHaveBeenCalled(); + }); + + it("does not overwrite a newer event with a late prepare response", async () => { + const late = deferred>(); + vi.mocked(api.prepare).mockReturnValue(late.promise); + const wrapper = await open(); + await wrapper.get('[data-testid="prepare-llama-cpp"]').trigger("click"); + listener(operation({ phase: "prepared", busy: false, preparedModelRef: "llama-cpp/exact" })); + await flushPromises(); + late.resolve(ok(operation())); + await flushPromises(); + expect(wrapper.get('[data-testid="local-operation"]').text()).toContain( + "Prepared — not enabled", + ); + expect(wrapper.get('[data-testid="local-activate"]').attributes("disabled")).toBeUndefined(); + }); + + it("ignores a stale state load after an operation event", async () => { + const late = deferred>(); + vi.mocked(api.getOperation).mockReturnValue(late.promise); + const wrapper = await open(); + listener(operation({ phase: "interrupted", busy: false })); + late.resolve(ok(null)); + await flushPromises(); + expect(wrapper.find('[data-testid="local-resume"]').exists()).toBe(true); + }); + + it("shows errors without pretending the default was rolled back or configured", async () => { + const wrapper = await open(); + vi.mocked(api.prepare).mockResolvedValue({ ok: false, error: "Disk capacity is insufficient" }); + await wrapper.get('[data-testid="prepare-llama-cpp"]').trigger("click"); + await flushPromises(); + expect(wrapper.text()).toContain("Disk capacity is insufficient"); + expect(wrapper.emitted("configured")).toBeUndefined(); + listener(operation({ phase: "error", busy: false, error: "Gateway disconnected after apply" })); + await flushPromises(); + expect(wrapper.text()).toContain("Configuration may already have been applied"); + expect(wrapper.text()).not.toContain("default model was not switched"); + listener( + operation({ + phase: "error", + busy: false, + rejectedBeforePromotion: true, + error: "Validation rejected", + }), + ); + await flushPromises(); + expect(wrapper.text()).toContain("default model was not switched"); + }); + + it("waits for Gateway cancellation confirmation and cannot close a protected operation", async () => { + const wrapper = await open(); + listener(operation()); + await flushPromises(); + expect(wrapper.get('[data-testid="local-close"]').attributes("disabled")).toBeUndefined(); + await wrapper.get('[data-testid="local-close"]').trigger("click"); + await flushPromises(); + expect(api.cancel).toHaveBeenCalledWith("prepare-1"); + expect(wrapper.get('[data-testid="local-close"]').attributes("disabled")).toBeDefined(); + expect(wrapper.get('[data-testid="local-cancel-pending"]').text()).toContain( + "has not confirmed cancellation", + ); + expect(wrapper.text()).not.toContain("Cancellation confirmed"); + expect(wrapper.emitted("close")).toBeUndefined(); + listener(operation({ phase: "cancelled", busy: false, cancellationPending: false })); + await flushPromises(); + expect(wrapper.text()).toContain("Cancellation confirmed"); + await wrapper.get('[data-testid="local-close"]').trigger("click"); + expect(wrapper.emitted("close")).toHaveLength(1); + }); + + it("closes active setup only after cancellation is confirmed", async () => { + const wrapper = await open(); + listener(operation()); + vi.mocked(api.cancel).mockResolvedValue( + ok(operation({ phase: "cancelled", busy: false, cancellationPending: false })), + ); + await flushPromises(); + await wrapper.get('[data-testid="local-close"]').trigger("click"); + await flushPromises(); + expect(api.cancel).toHaveBeenCalledOnce(); + expect(wrapper.emitted("close")).toHaveLength(1); + expect(api.answer).not.toHaveBeenCalled(); + }); + + it("shows a concise Chinese download card and keeps full consent in collapsed details", async () => { + const wrapper = await open(); + setLocale("zh-CN"); + const message = [ + "Runs on Gateway host desktop-host (win32/x64), using the CPU.", + "64 GiB RAM; 1721.3 GiB free disk.", + "Qwen3.5 9B (Q4_K_M) fits the 39.2 GiB CPU memory budget with a 64K context. Runtime verification checks the actual model before activation.", + "OpenClaw will check a real tool call before making this your default model.", + "", + "Download Qwen3.5 9B (Q4_K_M) (5.7 GB), the local embedding model (about 0.3 GB), and the verified CPU runtime, then use this model?", + ].join("\n"); + listener( + operation({ + phase: "step", + step: { id: "download", type: "confirm", executor: "client", message, initialValue: false }, + }), + ); + await flushPromises(); + expect(wrapper.get('[data-testid="download-summary"]').text()).toContain("Qwen3.5 9B"); + const details = wrapper.get('[data-testid="download-details"]'); + expect(details.attributes("open")).toBeUndefined(); + expect(details.text()).toContain(message); + expect(details.text()).toContain("Gateway 版本"); + expect(wrapper.find('[data-testid="local-cancel"]').exists()).toBe(false); + expect(wrapper.find('[data-testid="local-refresh"]').exists()).toBe(false); + const visible = wrapper.element.cloneNode(true) as HTMLElement; + for (const detail of visible.querySelectorAll("details:not([open])")) { + detail.replaceChildren(detail.querySelector("summary")!.cloneNode(true)); + } + const copy = visible.textContent!.replace(/\s+/g, " ").trim(); + expect(copy.length).toBeLessThan(200); + expect(copy).toContain("约 6 GB"); + expect(copy).toContain("开始下载"); + expect(copy).not.toMatch(/Gateway|embedding|Q4_K_M|回退|desktop-host/); + expect(api.answer).not.toHaveBeenCalled(); + const confirm = wrapper.findAll("button").find((button) => button.text() === "开始下载")!; + await confirm.trigger("click"); + expect(api.answer).toHaveBeenCalledWith({ + operationId: "prepare-1", + stepId: "download", + value: true, + }); + }); + + it("leaves unknown or additional confirmation terms visible", async () => { + const wrapper = await open(); + const message = "Download a model? Additional license or permission terms require review."; + listener(operation({ phase: "step", step: { id: "unknown", type: "confirm", message } })); + await flushPromises(); + expect(wrapper.find('[data-testid="download-summary"]').exists()).toBe(false); + expect(wrapper.get(".wizard-step > .upstream-text").text()).toBe(message); + expect(wrapper.get('[data-testid="local-details"]').attributes("open")).toBeUndefined(); + expect(api.answer).not.toHaveBeenCalled(); + }); + + it("resumes interrupted work explicitly without replaying any answer", async () => { + vi.mocked(api.getOperation).mockResolvedValue( + ok(operation({ phase: "interrupted", busy: false })), + ); + const wrapper = await open(); + expect(wrapper.get('[data-testid="prepare-llama-cpp"]').attributes("disabled")).toBeDefined(); + expect(api.resume).not.toHaveBeenCalled(); + await wrapper.get('[data-testid="local-resume"]').trigger("click"); + expect(api.resume).toHaveBeenCalledOnce(); + expect(api.answer).not.toHaveBeenCalled(); + expect(api.prepare).not.toHaveBeenCalled(); + }); + + it("does not select an arbitrary candidate when preparedModelRef is absent", async () => { + vi.mocked(api.getSnapshot).mockResolvedValue( + ok({ + models: [...snapshot.models, { modelRef: "llama-cpp/other", name: "Other" }], + }), + ); + const wrapper = await open(); + listener(operation({ phase: "prepared", busy: false })); + await flushPromises(); + expect(api.getSnapshot).toHaveBeenCalledTimes(2); + expect(api.detect).not.toHaveBeenCalled(); + expect(wrapper.get('[data-testid="local-activate"]').attributes("disabled")).toBeDefined(); + await wrapper + .get("#local-candidate") + .setValue(JSON.stringify(["existing-model", "llama-cpp/other"])); + await wrapper.get('[data-testid="local-activate"]').trigger("click"); + expect(api.activate).toHaveBeenCalledWith({ + kind: "existing-model", + modelRef: "llama-cpp/other", + }); + }); + + it("uses an exact configured model rather than the first saved model", async () => { + vi.mocked(api.getSnapshot).mockResolvedValue( + ok({ + models: [...snapshot.models, { modelRef: "llama-cpp/saved", name: "Saved" }], + configuredModel: "llama-cpp/saved", + }), + ); + const wrapper = await open({ targetModelRef: "llama-cpp/saved" }); + await wrapper.get('[data-testid="local-activate"]').trigger("click"); + expect(api.activate).toHaveBeenCalledWith({ + kind: "existing-model", + modelRef: "llama-cpp/saved", + }); + }); + + it("does not replay a historical ready receipt as a new configuration event", async () => { + vi.mocked(api.getOperation).mockResolvedValue( + ok( + operation({ + id: "previous-activation", + kind: "activate", + phase: "ready", + busy: false, + }), + ), + ); + const wrapper = await open(); + expect(wrapper.emitted("configured")).toBeUndefined(); + expect(wrapper.get('[data-testid="prepare-llama-cpp"]').attributes("disabled")).toBeUndefined(); + }); + + it("does not choose a cloud candidate as a fallback when local detection is empty", async () => { + vi.mocked(api.getSnapshot).mockResolvedValue( + ok({ + models: [], + configuredModel: "cloud/remote", + }), + ); + const wrapper = await open(); + listener(operation({ phase: "prepared", busy: false })); + await flushPromises(); + expect(wrapper.get('[data-testid="local-activate"]').attributes("disabled")).toBeDefined(); + expect(api.activate).not.toHaveBeenCalled(); + }); + + it("does not mistake the old configured model for an unidentified prepared result", async () => { + vi.mocked(api.getSnapshot).mockResolvedValue( + ok({ + models: [{ modelRef: "llama-cpp/old", name: "Old" }], + configuredModel: "llama-cpp/old", + }), + ); + const wrapper = await open(); + listener(operation({ phase: "prepared", busy: false })); + await flushPromises(); + expect(wrapper.get('[data-testid="local-activate"]').attributes("disabled")).toBeDefined(); + expect(api.activate).not.toHaveBeenCalled(); + }); + + it("reloads saved configuration after preparation without waiting for an old snapshot", async () => { + const late = deferred>(); + vi.mocked(api.getSnapshot).mockReturnValueOnce(late.promise).mockResolvedValue(ok(snapshot)); + const wrapper = await open(); + listener(operation({ phase: "prepared", busy: false, preparedModelRef: "llama-cpp/exact" })); + await flushPromises(); + late.resolve(ok({ models: [] })); + await flushPromises(); + expect(api.getSnapshot).toHaveBeenCalledTimes(2); + expect(api.detect).not.toHaveBeenCalled(); + expect(wrapper.get('[data-testid="local-activate"]').attributes("disabled")).toBeUndefined(); + }); + + it("disables unsupported and policy-locked capabilities with reasons", async () => { + vi.mocked(api.getCapabilities).mockResolvedValue( + ok({ ...capabilities, supported: false, mutable: false, blockers: ["MXC config lock"] }), + ); + const wrapper = await open(); + expect(wrapper.text()).toContain("MXC config lock"); + expect(wrapper.text()).toContain("Configuration changes are locked"); + expect(wrapper.get('[data-testid="prepare-llama-cpp"]').attributes("disabled")).toBeDefined(); + expect(api.detect).not.toHaveBeenCalled(); + expect(api.prepare).not.toHaveBeenCalled(); + }); + + it("honors explicitly discovered choices and streaming without scanning on mount", async () => { + vi.mocked(api.detect).mockResolvedValue( + ok({ ...detection, choices: [{ id: "llama-cpp-existing-server", label: "Existing" }] }), + ); + const wrapper = await open({ streaming: true }); + expect( + wrapper.get('[data-testid="prepare-llama-cpp-existing-server"]').attributes("disabled"), + ).toBeDefined(); + await wrapper.setProps({ streaming: false }); + expect(api.detect).not.toHaveBeenCalled(); + await wrapper.get('[data-testid="local-discover"]').trigger("click"); + await flushPromises(); + expect(api.detect).toHaveBeenCalledExactlyOnceWith({ refresh: true }); + expect(wrapper.get('[data-testid="prepare-llama-cpp"]').attributes("disabled")).toBeDefined(); + await wrapper.get('[data-testid="prepare-llama-cpp-existing-server"]').trigger("click"); + expect(api.prepare).toHaveBeenCalledWith({ authChoice: "llama-cpp-existing-server" }); + }); + + it("shows review details and requires explicit consent before installation", async () => { + vi.mocked(api.getCapabilities).mockResolvedValue( + ok({ + ...capabilities, + plugin: { installed: false, enabled: false, canInstall: true }, + }), + ); + const wrapper = await open(); + expect(api.inspectProvider).not.toHaveBeenCalled(); + expect(api.installProvider).not.toHaveBeenCalled(); + await wrapper.get('[data-testid="provider-review"]').trigger("click"); + await flushPromises(); + expect(wrapper.text()).toContain("official catalog"); + expect(wrapper.text()).toContain("Start local runtime"); + expect(wrapper.get('[data-testid="provider-install"]').attributes("disabled")).toBeDefined(); + await wrapper.get('[data-testid="provider-confirm"]').setValue(true); + await wrapper.get('[data-testid="provider-install"]').trigger("click"); + expect(api.installProvider).toHaveBeenCalledWith({ confirmed: true, reviewToken: "review-1" }); + expect(api.prepare).not.toHaveBeenCalled(); + }); + + it("requires fresh review and consent after installation requests staged capability review", async () => { + vi.mocked(api.getCapabilities) + .mockResolvedValueOnce( + ok({ + ...capabilities, + plugin: { installed: false, enabled: false, canInstall: true }, + }), + ) + .mockResolvedValue(ok(capabilities)); + vi.mocked(api.inspectProvider) + .mockResolvedValueOnce( + ok({ + pluginId: "llama-cpp", + source: "ClawHub @openclaw/llama-cpp-provider", + version: "2026.9.3", + capabilities: {}, + reviewToken: "source-review", + }), + ) + .mockResolvedValueOnce( + ok({ + pluginId: "llama-cpp", + source: "ClawHub @openclaw/llama-cpp-provider", + version: "2026.9.3", + capabilities: { permissions: ["Start local runtime", "Download model files"] }, + reviewToken: "staged-manifest-review", + }), + ); + vi.mocked(api.installProvider) + .mockResolvedValueOnce({ ok: false, error: "Staged manifest capability review required" }) + .mockResolvedValueOnce(ok(capabilities)); + + const wrapper = await open(); + await wrapper.get('[data-testid="provider-review"]').trigger("click"); + await flushPromises(); + expect(wrapper.get('[data-testid="provider-capabilities-unknown"]').text()).toContain( + "not a full permissions review", + ); + expect(wrapper.text()).toContain("ClawHub @openclaw/llama-cpp-provider"); + expect(wrapper.text()).toContain("source and version and authorize attempting"); + expect(wrapper.text()).not.toContain("source, version, and capabilities and authorize"); + await wrapper.get('[data-testid="provider-confirm"]').setValue(true); + await wrapper.get('[data-testid="provider-install"]').trigger("click"); + await flushPromises(); + expect(api.installProvider).toHaveBeenNthCalledWith(1, { + confirmed: true, + reviewToken: "source-review", + }); + expect(wrapper.text()).toContain("Staged manifest capability review required"); + expect(wrapper.find('[data-testid="provider-confirm"]').exists()).toBe(false); + expect(wrapper.get('[data-testid="provider-review"]').attributes("disabled")).toBeUndefined(); + expect(wrapper.get('[data-testid="provider-review"]').text()).toBe("Review provider again"); + expect(api.inspectProvider).toHaveBeenCalledOnce(); + expect(api.installProvider).toHaveBeenCalledOnce(); + + await wrapper.get('[data-testid="provider-review"]').trigger("click"); + await flushPromises(); + expect(wrapper.find('[data-testid="provider-capabilities-unknown"]').exists()).toBe(false); + expect(wrapper.text()).toContain("Start local runtime"); + expect(wrapper.get('[data-testid="provider-install"]').attributes("disabled")).toBeDefined(); + expect(api.installProvider).toHaveBeenCalledOnce(); + await wrapper.get('[data-testid="provider-confirm"]').setValue(true); + await wrapper.get('[data-testid="provider-install"]').trigger("click"); + await flushPromises(); + expect(api.installProvider).toHaveBeenNthCalledWith(2, { + confirmed: true, + reviewToken: "staged-manifest-review", + }); + expect(api.prepare).not.toHaveBeenCalled(); + }); + + it("unsubscribes from main operation events on close", async () => { + const wrapper = await open(); + wrapper.unmount(); + expect(unsubscribe).toHaveBeenCalledOnce(); + }); + + it("shows the default-agent override caveat without changing that agent", async () => { + const wrapper = await open({ + defaultAgentOverride: { agentId: "main", modelRef: "cloud/pinned" }, + }); + expect(wrapper.get('[data-testid="default-agent-override"]').text()).toContain( + "Changing the global default does not change this override", + ); + expect(api.prepare).not.toHaveBeenCalled(); + expect(api.activate).not.toHaveBeenCalled(); + }); +}); diff --git a/desktop/renderer/src/components/LocalModelSetupDialog.vue b/desktop/renderer/src/components/LocalModelSetupDialog.vue new file mode 100644 index 0000000..4f8a4b8 --- /dev/null +++ b/desktop/renderer/src/components/LocalModelSetupDialog.vue @@ -0,0 +1,993 @@ + + + + + diff --git a/desktop/renderer/src/components/LocalModelWizardStep.test.ts b/desktop/renderer/src/components/LocalModelWizardStep.test.ts new file mode 100644 index 0000000..fd82a7b --- /dev/null +++ b/desktop/renderer/src/components/LocalModelWizardStep.test.ts @@ -0,0 +1,77 @@ +import { mount } from "@vue/test-utils"; +import { beforeEach, describe, expect, it } from "vitest"; +import { setLocale } from "@/i18n"; +import LocalModelWizardStep from "./LocalModelWizardStep.vue"; +import type { LocalModelStep } from "../../../src/local-model-types"; + +describe("LocalModelWizardStep", () => { + beforeEach(() => setLocale("en-US")); + const open = (step: LocalModelStep) => + mount(LocalModelWizardStep, { props: { step, disabled: false } }); + + it("preserves typed select values rather than sending option indexes or labels", async () => { + const wrapper = open({ + id: "select", + type: "select", + options: [{ label: "CPU", value: { backend: "cpu" } }], + }); + expect(wrapper.get("button").attributes("disabled")).toBeDefined(); + await wrapper.get("select").setValue(0); + await wrapper.get("form").trigger("submit"); + expect(wrapper.emitted("answer")).toEqual([[{ backend: "cpu" }]]); + }); + + it("sends a typed multiselect list", async () => { + const wrapper = open({ + id: "multi", + type: "multiselect", + options: [ + { label: "First", value: 5 }, + { label: "Second", value: { id: "embedding" } }, + ], + }); + await wrapper.findAll("input")[0].setValue(true); + await wrapper.findAll("input")[1].setValue(true); + await wrapper.get("form").trigger("submit"); + expect(wrapper.emitted("answer")).toEqual([[[5, { id: "embedding" }]]]); + }); + + it.each(["note", "action"] as const)( + "allows an explicit Continue for a client %s", + async (type) => { + const wrapper = open({ + id: type, + type, + executor: "client", + message: "Review this instruction", + }); + await wrapper.get("form").trigger("submit"); + expect(wrapper.emitted("answer")).toEqual([[undefined]]); + }, + ); + + it("requires a click for true confirmation and prevents Enter from consenting", async () => { + const wrapper = open({ id: "confirm", type: "confirm", initialValue: true }); + await wrapper.get("form").trigger("submit"); + expect(wrapper.emitted("answer")).toBeUndefined(); + await wrapper.findAll("button")[1].trigger("click"); + expect(wrapper.emitted("answer")).toEqual([[true]]); + }); + + it("does not let the concise card auto-consent or change the answer type", async () => { + const wrapper = mount(LocalModelWizardStep, { + props: { + step: { id: "summary", type: "confirm", initialValue: true, message: "Original consent" }, + disabled: false, + downloadSummary: { modelName: "Qwen3.5 9B", downloadGB: 6, backend: "cpu" }, + }, + }); + expect(wrapper.get("details").attributes("open")).toBeUndefined(); + expect(wrapper.text()).toContain("Runtime components are an additional download"); + await wrapper.get("form").trigger("submit"); + expect(wrapper.emitted("answer")).toBeUndefined(); + const decline = wrapper.findAll("button").find((button) => button.text() === "Not now")!; + await decline.trigger("click"); + expect(wrapper.emitted("answer")).toEqual([[false]]); + }); +}); diff --git a/desktop/renderer/src/components/LocalModelWizardStep.vue b/desktop/renderer/src/components/LocalModelWizardStep.vue new file mode 100644 index 0000000..e140add --- /dev/null +++ b/desktop/renderer/src/components/LocalModelWizardStep.vue @@ -0,0 +1,283 @@ + + + + + diff --git a/desktop/renderer/src/components/ModelProviderGroups.vue b/desktop/renderer/src/components/ModelProviderGroups.vue new file mode 100644 index 0000000..5216209 --- /dev/null +++ b/desktop/renderer/src/components/ModelProviderGroups.vue @@ -0,0 +1,219 @@ + + + + + diff --git a/desktop/renderer/src/components/ModelSetupDialog.test.ts b/desktop/renderer/src/components/ModelSetupDialog.test.ts index 947a565..291e177 100644 --- a/desktop/renderer/src/components/ModelSetupDialog.test.ts +++ b/desktop/renderer/src/components/ModelSetupDialog.test.ts @@ -1,6 +1,9 @@ import { defineComponent } from "vue"; import { flushPromises, mount } from "@vue/test-utils"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ElMessageBox } from "element-plus"; +import { createBrowserOpenClawMock } from "@/browser-openclaw"; +import type { LocalModelOperation } from "../../../src/local-model-types"; import ModelSetupDialog from "./ModelSetupDialog.vue"; const InputStub = defineComponent({ @@ -19,7 +22,7 @@ const SelectStub = defineComponent({ }, emits: ["update:modelValue", "change"], template: - '', + "", }); const componentStubs = { @@ -37,7 +40,7 @@ const componentStubs = { "el-option-group": { template: "" }, }; -function mountDialog(props: { initialFamily?: "github-copilot" } = {}) { +function mountDialog(props: Partial["$props"]> = {}) { return mount(ModelSetupDialog, { props: { modelValue: true, ...props }, global: { stubs: componentStubs }, @@ -108,7 +111,7 @@ describe("ModelSetupDialog", () => { async function openCustomForm(wrapper: ReturnType) { const cards = wrapper.findAll(".model-family-card"); - expect(cards).toHaveLength(4); + expect(cards).toHaveLength(5); await cards[3].trigger("click"); expect(wrapper.text()).toContain("Configure custom provider"); await wrapper.find(".primary-action").trigger("click"); @@ -116,14 +119,14 @@ describe("ModelSetupDialog", () => { async function openGitHubCopilotForm(wrapper: ReturnType) { const cards = wrapper.findAll(".model-family-card"); - expect(cards).toHaveLength(4); + expect(cards).toHaveLength(5); await cards[2].trigger("click"); expect(wrapper.text()).toContain("GitHub Copilot"); await wrapper.find(".primary-action").trigger("click"); await flushPromises(); } - it("shows only MicroClaw-managed providers, Copilot, and custom setup", () => { + it("shows managed providers, Copilot, custom setup, and a first-time local entry", () => { const wrapper = mountDialog(); const cards = wrapper.findAll(".model-family-card"); expect(cards.map((card) => card.text())).toEqual([ @@ -131,6 +134,7 @@ describe("ModelSetupDialog", () => { "MiniMax", "GitHub Copilot", "Other model", + "Local model", ]); expect(cards[2].findAll(".family-icon img")).toHaveLength(1); const copilotLogo = decodeURIComponent( @@ -141,6 +145,111 @@ describe("ModelSetupDialog", () => { expect(wrapper.text()).not.toContain("OpenClaw catalog"); }); + it("edits a custom connection inline without inference or changing the default and preserves its ID", async () => { + const config = { + models: { + providers: { + private: { + baseUrl: "https://old.example/v1", + apiKey: "existing-key", + models: [{ id: "model", name: "My model", input: ["text", "image"], reasoning: true }], + }, + cloud: { models: [{ id: "default" }] }, + }, + }, + agents: { + defaults: { + model: { primary: "cloud/default", fallbacks: ["private/model"] }, + models: { "private/model": {}, "cloud/default": {} }, + }, + }, + }; + read.mockResolvedValue(config); + const wrapper = mountDialog({ + inline: true, + manageOnly: true, + currentProvider: { + familyId: "custom", + providerKey: "private", + modelName: "model", + displayName: "My model", + baseUrl: "https://new.example/v1", + apiKey: "existing-key", + apiFormat: "openai-chat", + reasoningEffort: "low", + input: ["text", "image"], + }, + }); + await flushPromises(); + expect(wrapper.find('[role="dialog"]').exists()).toBe(false); + expect(wrapper.find('[role="region"]').exists()).toBe(true); + await wrapper.get(".primary-action").trigger("mousedown"); + await flushPromises(); + await vi.runAllTimersAsync(); + expect(testConnection).not.toHaveBeenCalled(); + expect(write).toHaveBeenCalledOnce(); + const saved = write.mock.calls[0][0]; + expect(write.mock.calls[0][1]).toEqual(config); + expect(saved.models.providers.private.baseUrl).toBe("https://new.example/v1"); + expect(saved.models.providers.custom).toBeUndefined(); + expect(saved.models.providers.private.models[0].input).toEqual(["text", "image"]); + expect(saved.models.providers.private.models[0].reasoning).toBe(true); + expect(saved.agents).toEqual(config.agents); + wrapper.unmount(); + }); + + it("opens the local dialog from the family grid without starting an operation", async () => { + const wrapper = mountDialog(); + await wrapper.findAll(".model-family-card")[4].trigger("click"); + expect(wrapper.find(".primary-action").text()).toBe("Set up a local model"); + await wrapper.find(".primary-action").trigger("click"); + await flushPromises(); + expect(wrapper.find(".local-model-panel").exists()).toBe(true); + expect(testConnection).not.toHaveBeenCalled(); + expect(write).not.toHaveBeenCalled(); + }); + + it("adds a new model from an inline editor without renaming existing session/default references", async () => { + const config = { + models: { + providers: { + custom: { + baseUrl: "http://127.0.0.1:1234/v1", + models: [{ id: "old", name: "Existing model" }], + }, + }, + }, + agents: { defaults: { model: { primary: "custom/old", fallbacks: ["custom/old"] } } }, + }; + read.mockResolvedValue(config); + const wrapper = mountDialog({ + inline: true, + manageOnly: true, + currentProvider: { + familyId: "custom", + providerKey: "custom", + modelName: "old", + displayName: "Existing model", + baseUrl: "http://127.0.0.1:1234/v1", + apiKey: "", + apiFormat: "openai-chat", + }, + }); + await flushPromises(); + await wrapper.findAll("input")[1].setValue("new-model"); + await wrapper.get(".primary-action").trigger("keydown", { key: "Enter" }); + await flushPromises(); + await vi.runAllTimersAsync(); + expect(testConnection).not.toHaveBeenCalled(); + const saved = write.mock.calls[0][0]; + expect(saved.agents).toEqual(config.agents); + expect(saved.models.providers.custom.models.map((model: { id: string }) => model.id)).toEqual([ + "old", + "new-model", + ]); + wrapper.unmount(); + }); + it("opens directly on the requested GitHub Copilot recovery form", async () => { const wrapper = mountDialog({ initialFamily: "github-copilot" }); await flushPromises(); @@ -202,7 +311,7 @@ describe("ModelSetupDialog", () => { message: "Connection successful", baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", }); - const wrapper = mountDialog(); + const wrapper = mountDialog({ singleProvider: true }); await wrapper.find(".text-action").trigger("click"); await wrapper.findAll("input")[1].setValue("new-key"); @@ -228,6 +337,148 @@ describe("ModelSetupDialog", () => { expect(restart).toHaveBeenCalledOnce(); }); + it("keeps cloud, official local-service fields, and Copilot authentication when adding a connection", async () => { + const retained = { + cloud: { models: [{ id: "remote" }], unknown: true }, + "llama-cpp": { localService: { managed: true }, models: [{ id: "local" }] }, + }; + const auth = { + profiles: { "github-copilot:github": { provider: "github-copilot", mode: "token" } }, + }; + read.mockResolvedValue({ + models: { providers: retained }, + auth, + agents: { defaults: { models: { "github-copilot/gpt-5.4": { alias: "Copilot" } } } }, + }); + const wrapper = mountDialog({ singleProvider: true }); + await wrapper.find(".text-action").trigger("click"); + await wrapper.findAll("input")[1].setValue("new-key"); + await wrapper.find(".primary-action").trigger("mousedown"); + await flushPromises(); + await vi.runAllTimersAsync(); + const next = write.mock.calls[0][0]; + expect(write.mock.calls[0][1]).toBe(await read.mock.results.at(-1)!.value); + expect(next.models.providers).toMatchObject(retained); + expect(next.auth).toEqual(auth); + expect(next.agents.defaults.models["github-copilot/gpt-5.4"]).toEqual({ alias: "Copilot" }); + }); + + it("does not use generic configuration for the official llama-cpp provider", async () => { + const wrapper = mountDialog(); + await openCustomForm(wrapper); + await wrapper.findAll("input")[0].setValue("llama-cpp"); + await wrapper.find(".key-actions .primary-action").trigger("mousedown"); + await flushPromises(); + expect(wrapper.find(".local-model-panel").exists()).toBe(true); + expect(testConnection).not.toHaveBeenCalled(); + expect(write).not.toHaveBeenCalled(); + }); + + it("keeps configured providers when choosing an authenticated Copilot model in the legacy single-provider flow", async () => { + const providers = { + "llama-cpp": { models: [{ id: "saved" }], localService: { managed: true } }, + cloud: { models: [{ id: "remote" }] }, + }; + read.mockResolvedValue({ models: { providers } }); + getGitHubCopilotStatus.mockResolvedValue({ authenticated: true }); + listGitHubCopilotModels.mockResolvedValue([{ id: "github-copilot/gpt-5.4", name: "GPT 5.4" }]); + const wrapper = mountDialog({ initialFamily: "github-copilot", singleProvider: true }); + await flushPromises(); + await wrapper.find("select").setValue("github-copilot/gpt-5.4"); + await wrapper.find(".key-actions .primary-action").trigger("mousedown"); + await flushPromises(); + await vi.runAllTimersAsync(); + expect(write.mock.calls[0][0].models.providers).toEqual(providers); + expect(write.mock.calls[0][1]).toEqual({ models: { providers } }); + }); + + it("uses the original missing-config snapshot for a first cloud setup", async () => { + read.mockResolvedValue(null); + const wrapper = mountDialog(); + await wrapper.find(".text-action").trigger("click"); + await wrapper.findAll("input")[1].setValue("new-key"); + await wrapper.find(".primary-action").trigger("mousedown"); + await flushPromises(); + await vi.runAllTimersAsync(); + expect(write.mock.calls[0][1]).toBeNull(); + }); + + it("surfaces a stale config rejection without restarting or claiming configuration success", async () => { + write.mockRejectedValueOnce(new Error("Configuration changed; reload before saving")); + const wrapper = mountDialog(); + await wrapper.find(".text-action").trigger("click"); + await wrapper.findAll("input")[1].setValue("new-key"); + await wrapper.find(".primary-action").trigger("mousedown"); + await flushPromises(); + expect(wrapper.text()).toContain("Configuration changed; reload before saving"); + expect(restart).not.toHaveBeenCalled(); + expect(wrapper.emitted("configured")).toBeUndefined(); + }); + + it("warns before setup when the default agent has a model override", async () => { + read.mockResolvedValue({ agents: { list: [{ id: "main", model: "cloud/override" }] } }); + const wrapper = mountDialog(); + await flushPromises(); + expect(wrapper.get('[data-testid="default-agent-override"]').text()).toContain( + "Changing the global default does not change this override", + ); + expect(wrapper.text()).toContain("cloud/override"); + expect(write).not.toHaveBeenCalled(); + }); + + it("blocks cloud setup while a reply is streaming", async () => { + const wrapper = mountDialog({ streaming: true }); + await wrapper.find(".text-action").trigger("click"); + await wrapper.findAll("input")[1].setValue("new-key"); + expect(wrapper.find(".key-actions .primary-action").attributes("disabled")).toBeDefined(); + await wrapper.find(".key-actions .primary-action").trigger("mousedown"); + expect(testConnection).not.toHaveBeenCalled(); + expect(write).not.toHaveBeenCalled(); + }); + + it("keeps cloud configuration usable and cancels local setup only after valid connection and consent", async () => { + const local = createBrowserOpenClawMock().model.local; + const active: LocalModelOperation = { + id: "pending-local", + kind: "prepare", + phase: "step", + agentId: "main", + busy: true, + cancellationPending: false, + }; + local.getOperation = vi.fn().mockResolvedValue({ ok: true, value: active }); + local.cancel = vi.fn(async () => { + const done = { ...active, phase: "cancelled" as const, busy: false }; + vi.mocked(local.getOperation).mockResolvedValue({ ok: true, value: done }); + return { ok: true as const, value: done }; + }); + window.openclaw.model.local = local; + const confirmation = vi.spyOn(ElMessageBox, "confirm").mockResolvedValue("confirm" as never); + try { + const wrapper = mountDialog(); + await openCustomForm(wrapper); + await flushPromises(); + expect(wrapper.get(".model-setup-close").attributes("disabled")).toBeUndefined(); + expect(wrapper.get(".key-actions .primary-action").attributes("disabled")).toBeUndefined(); + expect(local.cancel).not.toHaveBeenCalled(); + const inputs = wrapper.findAll("input"); + await inputs[0].setValue("cloud-new"); + await inputs[1].setValue("model"); + await inputs[2].setValue("https://model.example/v1"); + await inputs[3].setValue("key"); + await wrapper.get(".key-actions .primary-action").trigger("mousedown"); + await flushPromises(); + await vi.runAllTimersAsync(); + expect(testConnection).toHaveBeenCalledBefore(vi.mocked(local.cancel)); + expect(local.cancel).toHaveBeenCalledExactlyOnceWith("pending-local"); + expect(write).toHaveBeenCalledOnce(); + expect(wrapper.emitted("configured")).toHaveLength(1); + wrapper.unmount(); + } finally { + confirmation.mockRestore(); + } + }); + it("shows an Other model path with custom provider fields", async () => { const wrapper = mountDialog(); await openCustomForm(wrapper); diff --git a/desktop/renderer/src/components/ModelSetupDialog.vue b/desktop/renderer/src/components/ModelSetupDialog.vue index 78cd256..3f70175 100644 --- a/desktop/renderer/src/components/ModelSetupDialog.vue +++ b/desktop/renderer/src/components/ModelSetupDialog.vue @@ -1,6 +1,19 @@