diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..a9386fda --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Keep SQLx migration checksums stable across Windows and Git clients. +src-tauri/migrations/*.sql text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b1d2c51..aaef4f4e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -132,4 +132,18 @@ jobs: - name: Rust dependency audit working-directory: src-tauri - run: cargo audit --ignore RUSTSEC-2026-0187 --ignore RUSTSEC-2026-0185 --ignore RUSTSEC-2023-0071 + shell: pwsh + run: | + $quinnTree = cargo tree --target all -i quinn-proto 2>&1 | Out-String + if ($quinnTree -notmatch "nothing to print") { + Write-Error "RUSTSEC-2026-0185 ignore is no longer valid: quinn-proto is in the compiled Cargo tree." + exit 1 + } + + $rsaTree = cargo tree --target all -i rsa 2>&1 | Out-String + if ($rsaTree -notmatch "nothing to print") { + Write-Error "RUSTSEC-2023-0071 ignore is no longer valid: rsa is in the compiled Cargo tree." + exit 1 + } + + cargo audit --ignore RUSTSEC-2026-0185 --ignore RUSTSEC-2023-0071 diff --git a/.gitignore b/.gitignore index db362e2b..c1d1e097 100644 --- a/.gitignore +++ b/.gitignore @@ -25,9 +25,20 @@ dist-ssr # Trier OS Specific Exclusions .env +.dev-app-data/ *.db *.db-shm *.db-wal +*.db-journal +*.sqlite +*.sqlite3 +*.sqlite-shm +*.sqlite-wal +*.sqlite-journal +*.sqlite3-shm +*.sqlite3-wal +*.sqlite3-journal +keys.meta # Developer scratch artifacts *.bak @@ -78,6 +89,12 @@ src-tauri/target-check/ # Split parts ARE tracked (each <100 MB for GitHub) # !src-tauri/resources/splits/ +# Local CUDA llama.cpp payloads are too large for normal Git; use the +# runtime/download cache or Git LFS if these need to be versioned. +src-tauri/resources/bitnet_engine/cublas*_12.dll +src-tauri/resources/bitnet_engine/cudart64_12.dll +src-tauri/resources/bitnet_engine/ggml-cuda.dll + # Local media tooling used for README capture output vendor/ffmpeg-release-essentials.zip vendor/ffmpeg-workspace/ diff --git a/CHANGELOG.md b/CHANGELOG.md index ac452036..449f2c22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,32 @@ All notable changes are documented here. Versions follow [Semantic Versioning](h --- +## [1.10.0] - 2026-06-29 + +### Local Runtimes + +- **CUDA-ready llama.cpp packaging** - release builds now bundle the local llama.cpp / BitNet engine folder when present, including CUDA runtime DLLs for NVIDIA systems while keeping large runtime payloads out of normal source Git. +- **GPU load routing** - local GGUF models now expose GPU/CPU/hybrid load preferences and use GPU offload by default for larger llama.cpp models when hardware supports it. +- **Model startup truthfulness** - llama.cpp loading now treats `503 Loading model` as warmup instead of immediate failure, and the UI surfaces resource-lane badges so users can see whether a model is running on GPU, CPU, or hybrid memory. + +### IDE + +- **Agent routing** - Agent Mode now keeps one tool-capable primary agent and moves non-tool-capable selections into Chat Coding companion roles instead of blocking the whole run. +- **Beginner model recommendation** - Granite 4.1 8B is promoted as the preferred local IDE Agent recommendation for 16 GB VRAM systems after local tool-calling validation. + +### Release Safety + +- **Migration byte guard** - SQL migration files are pinned to LF line endings so Windows checkouts do not mutate old SQLx migration checksums during release builds. + +## [1.0.9] - 2026-06-28 + +### Security + +- **PDF parsing hardening** - rebuilt with the vendored `lopdf` nesting guard so deeply nested PDF objects fail safely instead of risking stack exhaustion during PDF handling. +- **Release metadata** - bumped package, Tauri, Cargo, About modal, installer guidance, and offline export sample versions for the security rebuild. + +--- + ## [1.0.8] — 2026-06-26 ### Game Engine — Stability & Agent Reliability diff --git a/ChatGTP Playwrite IDE Audit.MD b/ChatGTP Playwrite IDE Audit.MD new file mode 100644 index 00000000..bbed7e0e --- /dev/null +++ b/ChatGTP Playwrite IDE Audit.MD @@ -0,0 +1,191 @@ +# ChatGTP Playwrite IDE Audit + +Date: 2026-06-28 +Scope: More AI by Trier OS IDE functions only. + +## Executive Summary + +The mocked no-money IDE Playwright suite now passes across the main IDE workflows I could exercise safely: model selection, command palette, editor/Monaco controls, markdown preview, file creation, center tabs, terminal, inbox directives, Search/Replace, Git panel, Agent permissions, Conductor assignment, command approval, write-file refresh, and responsive layout. + +I also ran a tiny local Ollama smoke test. Ollama itself is reachable and `qwen:latest` returned `IDE_OK`, but `bitnet-b1.58-2b:latest` is advertised by Ollama and fails to load from disk. That is a real user-facing risk if the IDE auto-selects or recommends that local model without validating generation readiness. + +## Fix Pass - 2026-06-28 + +Fixed in this pass: + +- Command palette modal now sits above the app header and closes with Escape. +- IDE local model selection now probes actual generation readiness before accepting a local/Ollama model for IDE work. +- IDE context persistence now stores metadata only and rehydrates file content from disk on send. +- Manual context add/remove now uses normalized file paths instead of file names. +- Active Hard Stop now has dedicated long-running-agent Playwright coverage that verifies `kernel_cancel_all_sessions` and `cancel_agent_loop` IPC. +- `ide-complete-audit.spec.ts` has a named package script entry. + +Live model evidence: + +- Installed `granite4.1:8b` through local Ollama. It returned a native `tool_calls` payload for a fake `write_file` tool and then wrote `F:\Agent IDE test\granite_ide_chat_probe.txt` with `GRANITE_IDE_CHAT_OK`. +- No DeepSeek environment key name was available in this shell. `OPENAI_API_KEY` was available, so I used `gpt-5.5` for one tiny API tool-call smoke. It wrote `F:\Agent IDE test\openai_api_tool_probe.txt` with `OPENAI_IDE_TOOL_OK`. + +## Model Routing Matrix Pass - 2026-06-28 + +Goal tested: prove local Chat Coding models and API coding models can be selected together, either manually or through a swarm/tool route. + +Results: + +- Manual Chat Coding local+API: passed in mocked UI. `execute_prompt` receives both `test-2` local and `deepseek-v4-flash-openrouter-free` API model ids. +- Manual Agent/tool Coding local+API: passed in mocked UI. Agent Mode chooses one primary tool agent and routes every other selected model as a Chat Coding companion/reviewer, so non-tool models still participate without being misrepresented as tool-call agents. +- Manual Agent/tool Coding with a non-tool model: passed in mocked UI. A deliberately chat-only OpenRouter fixture is moved to Chat Coding companion while `test-2` remains the primary tool agent. +- Manual Agent/tool Coding with chat-only selected first: passed in mocked UI. The chat-only model no longer blocks a later tool-capable model from starting Agent Mode. +- Manual Agent/tool Coding with Write Files on and a read-only tool primary: passed in mocked UI. The primary starts with read-only tools and the companion stays in Chat Coding instead of forcing the entire team to Chat Coding. +- Conductor mixed local+API tool swarm: passed in mocked UI. `execute_conductor_session` receives `deepseek-v4-pro-openrouter-free` as conductor and both local/API worker ids. +- Live provider-boundary local Chat Coding: passed with `granite4.1:8b`, artifact `F:\Agent IDE test\local_chat_probe_response.md`. +- Live provider-boundary local tool-call coding: passed with `granite4.1:8b`, artifact `F:\Agent IDE test\local_tool_probe.txt`. +- Live provider-boundary API Chat Coding: passed with OpenAI `gpt-5.5`, artifact `F:\Agent IDE test\openai_chat_probe_response.md`. +- Live provider-boundary API tool-call coding: passed with OpenAI `gpt-5.5`, artifact `F:\Agent IDE test\openai_tool_probe.txt`. +- Live mixed local+API proofs: passed for manual-chat-style parallel provider calls and conductor-style tool-worker mix. +- DeepSeek V4 Flash/Pro live test: skipped because no DeepSeek key environment name was available to this shell. + +Evidence files: + +```text +F:\Agent IDE test\live_ide_model_matrix_summary.json +scripts\live_ide_model_matrix_smoke.cjs +e2e\ide-model-routing-matrix.spec.ts +``` + +Important UX decision: + +Manual Agent Mode does not run multiple selected models as independent tool agents. It runs one eligible primary tool agent and launches the other selected models as Chat Coding companions with review/draft instructions and no automatic file writes. A chat-only model cannot block a tool-capable selected model from starting. If the primary tool route is read-only while Write Files is on, the app downgrades only that primary run to read-only tools instead of moving the whole team to Chat Coding. For coordinated mixed local/API multi-tool-agent work, the proven path is Conductor mode. The UI now says that directly in the Agent controls. + +## Verification Run + +Passed: + +```powershell +node scripts\test_coe_ide_contract.cjs +node scripts\test_ide_release_blockers_contract.cjs +node scripts\test_monaco_editor_contract.cjs +.\node_modules\.bin\playwright.cmd test ide-complete-audit.spec.ts ide-release-blockers.spec.ts ide-world-class-layout.spec.ts --reporter=list --workers=1 +.\node_modules\.bin\playwright.cmd test ide-model-routing-matrix.spec.ts ide-complete-audit.spec.ts ide-release-blockers.spec.ts ide-world-class-layout.spec.ts --reporter=list --workers=1 +node scripts\live_ide_model_matrix_smoke.cjs --out "F:\Agent IDE test" +.\node_modules\.bin\tsc.cmd --noEmit +npm run lint +``` + +Final Playwright result: + +```text +16 passed +``` + +Local model smoke: + +```text +Ollama /api/tags: reachable, 12 local models listed after Granite install. +bitnet-b1.58-2b:latest: failed to load model blob. +qwen:latest: returned IDE_OK, done=true, total_duration about 2.7s. +granite4.1:8b: installed successfully, returned native tool_calls for write_file. +``` + +Online API live smoke: + +```text +DeepSeek: skipped because no DeepSeek key name was available to this shell. +OpenAI gpt-5.5: passed native tool-call smoke and wrote OPENAI_IDE_TOOL_OK. +OpenAI gpt-5.5: passed Chat Coding file-block smoke and wrote API_CHAT_OK. +``` + +## Playwright Coverage Added + +New focused spec: + +```text +e2e/ide-complete-audit.spec.ts +``` + +Test harness updates: + +```text +e2e/mock-tauri.ts +e2e/helpers.ts +e2e/ide-release-blockers.spec.ts +e2e/ide-world-class-layout.spec.ts +e2e/ide-model-routing-matrix.spec.ts +``` + +The mock harness now covers IDE commands needed by the audit traversal: workspace context collection, terminal execution/cancel, Git status/diff/stage actions, session tools, write confirmation, command approval, and Hard Stop IPC. + +## Findings + +### P1 - Command Palette Can Render Under The Fixed Header + +Evidence: Playwright could see the `Close command palette` button, but normal click was intercepted by the COE/header metrics area. Source cause is z-index ordering: `.app-header` uses `z-index: 2147482000`, while `.ide-command-palette-overlay` uses `z-index: 12000`. + +Impact: A user may open the IDE command palette and be unable to click its close button in some viewport/header states. Overlay click outside the palette still works when clicking below the header, but the visible close button is not reliable. + +Status: fixed. The overlay z-index now clears the fixed app header, Escape closes the palette, and Playwright verifies Escape plus close-button dismissal. + +### P1 - Local Model List Can Contain Broken Models + +Evidence: Ollama listed `bitnet-b1.58-2b:latest`, but `/api/generate` failed with an unable-to-load-model blob error. `qwen:latest` responded successfully. + +Impact: The IDE may show or pick a local model that looks installed but cannot generate. Users would experience failed agent runs even though the model appears available. + +Status: fixed. Local/Ollama IDE model selection now runs a tiny capped generation probe, caches ready/failed state in the UI, and blocks broken models with repair/re-pull guidance. + +### P2 - IDE Context Files Persist Full File Contents In localStorage + +Source: `src/components/IdeWorkspace.tsx` persists `ide_context_files` with full `content` fields. + +Impact: Entire-project or large custom context can exceed localStorage quota, slow startup, or silently drop context because storage failures are caught and ignored. It can also persist sensitive project text longer than the user expects. + +Status: fixed. Persisted context is now metadata-only; IDE sends rehydrate file content from disk immediately before execution. + +### P2 - Manual Context De-Dupes And Removes By File Name Only + +Source: `handleAddContext` prevents duplicates by `f.name === activeFile.name`; `handleRemoveContext` removes by name. + +Impact: A user cannot add both `src/config.json` and `tests/config.json`. Removing one duplicate name can remove the wrong context entry. + +Status: fixed. Context add/remove identity now uses normalized full paths. + +### P2 - Hard Stop Active-Run Path Needs Dedicated Long-Running Test + +Status: fixed. A new release-blocker test starts a long-running mocked agent route, verifies Hard Stop becomes enabled, clicks it, verifies the UI reports cancellation, and asserts `kernel_cancel_all_sessions` plus `cancel_agent_loop` were invoked. + +### P3 - Broad IDE Test Coverage Was Previously Behind The UI + +Before this pass, release-blocker tests covered important agent approval/write refresh flows, but there was no single broad IDE traversal touching model bar, command palette, editor toggles, terminal, inbox, Search/Replace, Git, Plugins, and layout together. + +Status: fixed. `e2e:ide-complete-audit` was added to `package.json`; local verification used the repo-local Playwright binary because the global npm shim on this machine failed to resolve `npm-cli.js`. + +## Areas Verified As Working In Mocked IDE + +- IDE shell loads with mocked Tauri bridge. +- Model band collapses and expands. +- Online API and OpenRouter model popovers open. +- Safe/free model selection is enforced in no-money mode. +- Command palette opens and command labels render. +- File tree opens markdown content. +- Markdown preview toggles. +- Monaco minimap and inline autocomplete toggles render. +- New file overlay creates a workspace file in the mocked bridge. +- Agent, Tools, Plugins, and Editor center tabs render current surfaces. +- Terminal command execution returns mocked output. +- Terminal transcript clear works. +- Inbox directive path appends mocked directive file. +- Workspace Search/Replace finds, previews, and renders replacement text. +- Git panel refreshes, shows branch status, and loads diff text. +- Agent permission toggles for write, run commands, and auto-approve commands render expected state. +- Conductor mode assigns no-money selectable workers. +- Command approval dialog appears, accepts, and resumes. +- Auto-approve command path suppresses the prompt and resumes. +- Agent write-file result refreshes the file explorer. +- Layout remains usable at 1366x768, 1600x900, 1920x1080, and 2560x1080 ultrawide. + +## Recommended Next Work + +1. Add an app-bound live-provider IDE smoke harness that uses the encrypted key store and records only pass/fail, model id, latency, and sanitized error class. +2. Run the DeepSeek V4 Flash/Pro live smoke once a DeepSeek key is available to the test boundary. +3. Repair or re-pull `bitnet-b1.58-2b:latest`, or keep it blocked from IDE placement until its generation probe passes. +4. Repair the local global npm shim (`npm run ...` currently fails looking for `npm-cli.js`); CI should still use its own clean Node/npm install. +5. Consider a future true multi-agent manual tool mode only if we want multiple independent tool agents without using Conductor; today that route is explicitly Conductor. diff --git a/INSTALL.md b/INSTALL.md index e16ef839..cb766249 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -9,7 +9,7 @@ Download the latest **NSIS EXE installer** from the [GitHub Releases](https://github.com/DougTrier/MoreAI/releases) page. ``` -More AI by Trier OS_1.0.8_x64-setup.exe +More AI by Trier OS_1.10.0_x64-setup.exe ``` Run the installer — no administrator rights required. Installs to your user profile. The installer bundles: @@ -19,15 +19,15 @@ Run the installer — no administrator rights required. Installs to your user pr - All pip packages for all servers pre-installed (torch CPU, TTS, fastapi, transformers, etc.) - Music generation runtime (`python_music_env.zip`) - FFmpeg binary -- BitNet engine binary +- llama.cpp / BitNet engine binary (CPU/Vulkan-ready by default; CUDA DLL pack can be added separately) - Governance pack files **No internet connection required after install**, except for: -- AI model weights (downloaded on first use — hardware-specific, too large to bundle) +- AI model weights such as Granite 4.1 8B (downloaded on first use — hardware-specific, too large to bundle) - PyTorch CUDA upgrade (offered during onboarding if an NVIDIA GPU is detected) - P2P Collaborate peer discovery (requires outbound WebSocket to Nostr relay servers) -> **MSI note:** The MSI is built alongside the NSIS EXE but is not the recommended installer for most users. Use the EXE. +> **MSI note:** The official release script currently packages the NSIS EXE only. MSI remains blocked until it has its own validation path. --- @@ -47,7 +47,6 @@ npm install # Installer output: # src-tauri/target/release/bundle/nsis/ ← use this -# src-tauri/target/release/bundle/msi/ ``` See [docs/RELEASE_BUILD_PROCESS.md](docs/RELEASE_BUILD_PROCESS.md) for full release build documentation. diff --git a/VERSION.md b/VERSION.md index 238d6e88..81c871de 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -1.0.7 +1.10.0 diff --git a/docs/OFFLINE_INSTALLER_EXPORT_SPEC.md b/docs/OFFLINE_INSTALLER_EXPORT_SPEC.md index c584953a..3d05a0f7 100644 --- a/docs/OFFLINE_INSTALLER_EXPORT_SPEC.md +++ b/docs/OFFLINE_INSTALLER_EXPORT_SPEC.md @@ -31,7 +31,7 @@ Each export writes `offline_manifest.json` with: ```json { "created_at": "2026-06-09T00:00:00Z", - "more_ai_version": "1.0.8", + "more_ai_version": "1.10.0", "platform": "windows-x64", "runtimes": [], "models": [], diff --git a/docs/RELEASE_BUILD_PROCESS.md b/docs/RELEASE_BUILD_PROCESS.md index 15bfa7fd..1f6f505d 100644 --- a/docs/RELEASE_BUILD_PROCESS.md +++ b/docs/RELEASE_BUILD_PROCESS.md @@ -4,9 +4,9 @@ Status: current release packaging runbook Owner: Doug Trier / Trier OS -Last updated: 2026-06-10 +Last updated: 2026-06-29 -Applies to: More AI by Trier OS v1.0.8 Windows MSI and NSIS EXE builds +Applies to: More AI by Trier OS v1.10.0 Windows Application NSIS builds, Supplemental optional runtime builds, diagnostic FullOffline MSI builds, and prior v1.0.9 Windows NSIS EXE security rebuilds ## Release Goal @@ -51,8 +51,8 @@ Optional resources are bundled when present: | Resource | Behavior | |---|---| -| `src-tauri\resources\bitnet_engine` | Bundled llama.cpp/BitNet server engine when the script can resolve a Windows binary. | -| `src-tauri\resources\playwright_browsers.zip` | Offline browser automation support for AI WebView/browser agent workflows. | +| `src-tauri\resources\bitnet_engine` | Source folder for the bundled llama.cpp/BitNet server engine. Application builds stage the non-CUDA runtime. Supplemental builds package the large NVIDIA CUDA DLLs separately. Diagnostic FullOffline builds stage the complete folder and package it as `bitnet_engine.zip`. | +| `src-tauri\resources\playwright_browsers.zip` | Optional offline browser automation support for AI WebView/browser agent workflows. Application builds skip this by default. Supplemental builds install it into the app-data browser runtime folder. Diagnostic FullOffline builds include it automatically. | | `src-tauri\resources\MicrosoftEdgeWebView2RuntimeInstallerX64.exe` | Optional explicit WebView2 installer resource. | | `src-tauri\resources\MicrosoftEdgeWebview2Setup.exe` | Optional explicit WebView2 setup resource. | | `src-tauri\resources\ffmpeg.exe` or `src-tauri\resources\ffmpeg` | Optional standalone FFmpeg binary. | @@ -75,7 +75,7 @@ In release builds, Music Studio should use `python_music_env.zip`. Debug builds ## What Is Not Bundled By Default The base installer does not bundle every large AI model weight. That is intentional. -Ollama is packaged as a managed external runtime, not as a bundled More AI engine. More AI can discover and guide Ollama setup, while llama.cpp / BitNet when `src-tauri\resources\bitnet_engine\llama-server.exe` is available is treated as the bundled CPU-safe local runtime path. +Ollama is packaged as a managed external runtime, not as a bundled More AI engine. More AI can discover and guide Ollama setup, while llama.cpp / BitNet when `src-tauri\resources\bitnet_engine\llama-server.exe` is available is treated as the bundled local runtime path. If CUDA llama.cpp DLLs are installed beside that engine, NVIDIA systems can use GPU offload. The Application NSIS profile keeps the CUDA payload out of the EXE; the Supplemental EXE installs NVIDIA CUDA DLLs into `%USERPROFILE%\.more_ai\bitnet_engine`. Not bundled by default: @@ -121,29 +121,112 @@ Set-Location "G:\More AI by Trier OS" This mode still requires both runtime zips and still injects the release resources into Tauri. It is faster because it does not reinstall the Python wheelhouses. +## Package Profiles + +The release script supports three shipping profiles plus one diagnostic profile. + +### Application Profile + +Application is the default shipping profile. `Standard` remains accepted as a backwards-compatible alias: + +```powershell +.\scripts\build-release.ps1 -UseExistingRuntimeZips +``` + +Application produces the normal NSIS installer. It includes the app shell, Python runtimes, music runtime, FFmpeg when present, the Tauri embedded WebView2 bootstrapper, governance/resources, and the default non-CUDA BitNet/llama.cpp runtime. It intentionally skips Playwright browsers and oversized NVIDIA CUDA BitNet DLLs. + +The release script also creates a clear alias beside Tauri's default installer output: + +```text +src-tauri\target\release\bundle\nsis\More AI Application__x64-setup.exe +``` + +This is the primary app installer users should download first. + +### Supplemental Profile + +Supplemental is the optional heavyweight local runtime package: + +```powershell +.\scripts\build-release.ps1 -PackageProfile Supplemental +``` + +Supplemental produces: + +```text +src-tauri\target\release\bundle\supplemental\More AI Supplemental__x64-setup.exe +``` + +It installs: + +- NVIDIA CUDA BitNet/llama.cpp DLLs into `%USERPROFILE%\.more_ai\bitnet_engine` +- Playwright browser automation runtime into `%APPDATA%\com.trieross.more-ai\playwright_browsers` + +Install order: + +1. Install **More AI Application**. +2. Install **More AI Supplemental** only if the user wants NVIDIA llama.cpp/BitNet GPU acceleration or offline Playwright browser automation. + +The app can still run without Supplemental. Users can add it later without reinstalling the application. + +### FullOffline Profile + +FullOffline is diagnostic only after v1.10 external-machine testing showed the ~2.3 GB embedded-CAB MSI can fail with Windows Installer errors such as "The system cannot open the device or file specified." + +```powershell +.\scripts\build-release.ps1 -UseExistingRuntimeZips -PackageProfile FullOffline +``` + +FullOffline defaults to the MSI bundle target and includes: + +- `python_env.zip` +- `python_music_env.zip` +- `playwright_browsers.zip` +- Full BitNet/llama.cpp runtime compressed as `bitnet_engine.zip`, including NVIDIA CUDA payload files when they are present in `src-tauri\resources\bitnet_engine` +- FFmpeg when present +- Tauri embedded WebView2 bootstrapper +- Governance, model registry, dependency manifest, DugBee, and shell skill resources + +FullOffline still does not bundle large AI model weights. Models such as Granite 4.1 8B remain guided downloads or user-imported assets after install. + +The FullOffline MSI is built as one installer with multiple embedded CAB streams. Tauri still generates the WiX source, but `build-release.ps1` rewrites the generated WiX media layout so the BitNet/CUDA zip, Python runtime, music runtime, and browser automation payloads live in separate embedded CABs. This avoids the single-CAB payload limit, but it did not pass external install validation for v1.10. + +Do not force FullOffline to NSIS. NSIS and single-CAB MSI packaging both fail on the complete offline payload. Use the default command above so the split-CAB MSI path runs. + +Do not publish FullOffline as the primary user download unless it has passed fresh external-machine install validation. The current v1.10 public/private tester path is **More AI Application + optional More AI Supplemental**. + ## Installer Output Locations -After a successful release build, the MSI and EXE are written under the Tauri release bundle folder: +After a successful release build, installers are written under the Tauri release bundle folder: ```text G:\More AI by Trier OS\src-tauri\target\release\bundle ``` -Current v1.0.8 installer locations: +Current v1.10.0 installer locations: ```text -G:\More AI by Trier OS\src-tauri\target\release\bundle\msi\More AI by Trier OS_1.0.8_x64_en-US.msi -G:\More AI by Trier OS\src-tauri\target\release\bundle\nsis\More AI by Trier OS_1.0.8_x64-setup.exe +G:\More AI by Trier OS\src-tauri\target\release\bundle\nsis\More AI by Trier OS_1.10.0_x64-setup.exe +G:\More AI by Trier OS\src-tauri\target\release\bundle\nsis\More AI Application_1.10.0_x64-setup.exe +G:\More AI by Trier OS\src-tauri\target\release\bundle\supplemental\More AI Supplemental_1.10.0_x64-setup.exe +``` + +FullOffline MSI candidate location: + +```text +G:\More AI by Trier OS\src-tauri\target\release\bundle\msi\More AI by Trier OS_1.10.0_x64_en-US.msi ``` Future versioned builds should follow the same pattern: ```text -src-tauri\target\release\bundle\msi\More AI by Trier OS__x64_en-US.msi src-tauri\target\release\bundle\nsis\More AI by Trier OS__x64-setup.exe +src-tauri\target\release\bundle\nsis\More AI Application__x64-setup.exe +src-tauri\target\release\bundle\supplemental\More AI Supplemental__x64-setup.exe +src-tauri\target\release\bundle\msi\More AI by Trier OS__x64_en-US.msi ``` -NSIS EXE is the recommended installer for public/private tester downloads. MSI is blocked as a primary release artifact until installed-build validation, branding review, and 100%, 125%, and 150% Windows scaling checks pass cleanly. +Application NSIS remains the primary public/private tester download. Supplemental is the optional local acceleration/browser automation package. FullOffline MSI is diagnostic-only until it passes external install validation. ## Installer Branding Requirement @@ -163,7 +246,8 @@ Before shipping a release: - The `.exe` installer file must show the More AI icon in Windows Explorer. - The `.msi` installer must use More AI branding where Windows Installer allows it. -- Opening either installer must show More AI by Trier OS branding and More AI logo artwork, not the default Tauri, NSIS, Wix, or Windows placeholder visuals. +- Opening the `.exe` installer must show More AI by Trier OS branding and More AI logo artwork, not the default Tauri, NSIS, or Windows placeholder visuals. +- Installer visuals must be checked at 100%, 125%, and 150% Windows scaling. - If the More AI logo changes, regenerate `src-tauri\icons\icon.ico`, the PNG icon set, and the installer BMP artwork before building the release. - The branding files must be checked as part of release verification before hashes are recorded. @@ -185,7 +269,7 @@ src-tauri\resources\python_music_env.zip scripts\python-release\manifests\music.installed.json ``` -After that, run the full release build or the faster `-UseExistingRuntimeZips` build to produce MSI/EXE installers. +After that, run the full release build or the faster `-UseExistingRuntimeZips` build to produce the NSIS EXE installer. ## Verification Commands @@ -206,6 +290,13 @@ Set-Location "G:\More AI by Trier OS" .\scripts\build-release.ps1 -UseExistingRuntimeZips ``` +FullOffline candidate verification should use: + +```powershell +Set-Location "G:\More AI by Trier OS" +.\scripts\build-release.ps1 -UseExistingRuntimeZips -PackageProfile FullOffline +``` + Additional release candidates should also run the broader app gates when time allows: ```powershell @@ -223,7 +314,32 @@ Installed voice acceptance must prove the packaged Bee TTS path, not a source ch Before running high-risk smoke commands, review `AV_SAFE_VERIFICATION_RUNBOOK.md`. The command-safety problem to avoid is malformed or insufficiently checked Codex-launched command lines. Do not count any antivirus-blocked, interrupted, quarantined, or malformed-command run as passing evidence. When automated smoke is unsafe, use `DUGBEE_TTS_LIVE_ACCEPTANCE_CHECKLIST.md` for manual DugBee/TTS acceptance evidence. -## Current v1.0.8 Release Artifacts +## Current v1.10.0 Local IDE Rebuild Target + +The v1.10.0 installer is the current target for the local-runtime and IDE-routing rebuild. It should be built with: + +```powershell +.\scripts\build-release.ps1 -UseExistingRuntimeZips +``` + +For the full self-contained installer candidate, use: + +```powershell +.\scripts\build-release.ps1 -UseExistingRuntimeZips -PackageProfile Application +.\scripts\build-release.ps1 -PackageProfile Supplemental +``` + +The Application NSIS EXE is the primary upload path. The Supplemental EXE is a custom self-extracting package that installs the heavyweight optional local runtimes: Playwright browser assets and CUDA-capable BitNet/llama.cpp DLLs. The diagnostic FullOffline MSI remains useful for packaging research, but it is not the v1.10 download path until it passes fresh external-machine install validation. Large model weights remain guided downloads or imports. + +## Previous v1.0.9 Security Rebuild Artifact + +Built on 2026-06-28 using `.\scripts\build-release.ps1 -UseExistingRuntimeZips` after the vendored `lopdf` nesting guard security remediation. Only the NSIS EXE is intended for GitHub release upload for this rebuild. + +| Artifact | Size | SHA-256 | +|---|---:|---| +| `src-tauri\target\release\bundle\nsis\More AI by Trier OS_1.0.9_x64-setup.exe` | 1532.10 MB | `19F92114BD47C8408AD725F040F08AAE618DDDE37C87EFE1B9830B489B192880` | + +## Previous v1.0.8 Release Artifacts Built on 2026-06-10 using `.\scripts\build-release.ps1 -UseExistingRuntimeZips` after rebuilding the ACE-Step music runtime and applying the installed-machine hardening pass for Ollama readiness, FFmpeg download recovery, and hidden Windows child processes. diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 178305a8..03604630 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -22,6 +22,11 @@ interface SetupAppOptions { beeTtsAudioDurationSeconds?: number; showStartupWelcome?: boolean; navigationWaitUntil?: 'load' | 'domcontentloaded' | 'networkidle' | 'commit'; + ideLocalModelIds?: string[]; + ideOnlineModelIds?: string[]; + ideMode?: 'manual' | 'conductor'; + ideAgentSubMode?: 'chat' | 'agent'; + ideConductorModelId?: string | null; } type TabId = @@ -164,7 +169,17 @@ export async function setupApp(page: Page, options: SetupAppOptions = {}) { }); }); await page.addInitScript(mockTauriIPC); - await page.addInitScript(({ firstRunProfile, forceOffline, initialTab, showStartupWelcome }) => { + await page.addInitScript(({ + firstRunProfile, + forceOffline, + initialTab, + showStartupWelcome, + ideLocalModelIds, + ideOnlineModelIds, + ideMode, + ideAgentSubMode, + ideConductorModelId, + }) => { if (firstRunProfile) { (window as any).__MORE_AI_E2E_FIRST_RUN_PROFILE__ = firstRunProfile; } @@ -212,15 +227,19 @@ export async function setupApp(page: Page, options: SetupAppOptions = {}) { localStorage.setItem('ide_first_run_complete', 'true'); localStorage.setItem('ide_first_agent_complete', 'true'); localStorage.setItem('ide_workspace_path', 'G:\\More AI by Trier OS'); + const ideLocalIds = Array.isArray(ideLocalModelIds) ? ideLocalModelIds : ['test-2']; + const ideOnlineIds = Array.isArray(ideOnlineModelIds) ? ideOnlineModelIds : []; if (firstRunProfile) { localStorage.removeItem('ide_local_model_ids'); localStorage.removeItem('ide_online_model_ids'); } else { - localStorage.setItem('ide_local_model_ids', JSON.stringify(['test-2'])); - localStorage.setItem('ide_online_model_ids', JSON.stringify([])); + localStorage.setItem('ide_local_model_ids', JSON.stringify(ideLocalIds)); + localStorage.setItem('ide_online_model_ids', JSON.stringify(ideOnlineIds)); } - localStorage.setItem('ide_mode', 'manual'); - localStorage.setItem('ide_agent_submode', 'chat'); + localStorage.setItem('ide_mode', ideMode ?? 'manual'); + localStorage.setItem('ide_agent_submode', ideAgentSubMode ?? 'chat'); + if (ideConductorModelId === null) localStorage.removeItem('ide_conductor_model_id'); + else if (ideConductorModelId) localStorage.setItem('ide_conductor_model_id', ideConductorModelId); localStorage.setItem('coe_active', 'true'); localStorage.setItem('coe_speak_replies', 'false'); if (showStartupWelcome) { @@ -253,6 +272,11 @@ export async function setupApp(page: Page, options: SetupAppOptions = {}) { forceOffline: options.forceOffline ?? false, initialTab: options.initialTab ?? null, showStartupWelcome: options.showStartupWelcome ?? false, + ideLocalModelIds: options.ideLocalModelIds ?? null, + ideOnlineModelIds: options.ideOnlineModelIds ?? null, + ideMode: options.ideMode ?? null, + ideAgentSubMode: options.ideAgentSubMode ?? null, + ideConductorModelId: options.ideConductorModelId, }); // The app is a Vite-powered SPA; document lifecycle events can lag or stall // while large lazy chunks/assets finish compiling. The setup contract is: @@ -270,7 +294,7 @@ export async function setupApp(page: Page, options: SetupAppOptions = {}) { timeout: options.expectStartupShellWithinMs, }).toBe(true); } - const appShellTimeoutMs = options.appShellTimeoutMs ?? 50000; + const appShellTimeoutMs = options.appShellTimeoutMs ?? 90000; await expect(page.locator('.app-shell')).toBeVisible({ timeout: appShellTimeoutMs }); await expect(page.locator('#btn-manual')).toBeVisible({ timeout: appShellTimeoutMs }); if (options.waitForWorkspace !== false) { diff --git a/e2e/ide-complete-audit.spec.ts b/e2e/ide-complete-audit.spec.ts new file mode 100644 index 00000000..7e16280d --- /dev/null +++ b/e2e/ide-complete-audit.spec.ts @@ -0,0 +1,140 @@ +// e2e/ide-complete-audit.spec.ts +// Broad IDE audit traversal. Uses the no-money Tauri bridge and mocked local +// commands so this never calls live providers or writes real project files. + +import { expect, test, type Page } from '@playwright/test'; +import { setupApp } from './helpers'; + +const SAFE_IDE_MODEL_IDS = [ + 'test-2', + 'deepseek-v4-pro-openrouter-free', + 'deepseek-v4-flash-openrouter-free', + 'qwen-code-free-openrouter', +]; + +async function setupIdeCompleteAudit(page: Page) { + page.on('dialog', dialog => dialog.accept()); + await page.addInitScript((safeIds) => { + (window as any).__MORE_AI_E2E_IDE_AUDIT__ = true; + (window as any).__MORE_AI_E2E_STRICT_NO_MONEY__ = true; + (window as any).__MORE_AI_E2E_SAFE_MODEL_IDS__ = safeIds; + (window as any).__MORE_AI_E2E_CREATED_FILES__ = []; + (window as any).__MORE_AI_E2E_CMD_APPROVALS__ = []; + localStorage.setItem('ide_mode', 'manual'); + localStorage.setItem('ide_agent_submode', 'agent'); + localStorage.setItem('ide_first_run_complete', 'true'); + localStorage.setItem('ide_first_agent_complete', 'true'); + localStorage.setItem('ide_workspace_path', 'G:\\More AI by Trier OS'); + localStorage.setItem('ide_local_model_ids', JSON.stringify([])); + localStorage.setItem('ide_online_model_ids', JSON.stringify(['deepseek-v4-flash-openrouter-free'])); + localStorage.setItem('ide_tool_inspector_open', 'true'); + localStorage.setItem('ide_workspace_health_open', 'true'); + }, SAFE_IDE_MODEL_IDS); + await setupApp(page, { waitForWorkspace: false, initialTab: 'ide', appShellTimeoutMs: 120000 }); + await expect(page.getByTestId('ide-workspace')).toBeVisible({ timeout: 120000 }); + await expect(page.locator('.workspace-loading')).toBeHidden({ timeout: 120000 }); +} + +async function closePopover(page: Page) { + const close = page.locator('.model-select-panel-close').last(); + if (await close.isVisible({ timeout: 1000 }).catch(() => false)) { + await close.click(); + } +} + +test.describe('IDE complete audit traversal', () => { + test('walks core IDE functions without live provider calls', async ({ page }) => { + test.setTimeout(180000); + await setupIdeCompleteAudit(page); + + await expect(page.locator('body')).not.toContainText(/ERR_CONNECTION_REFUSED|ReferenceError|Critical UI Fault/i); + await expect(page.getByTestId('ide-workspace')).toContainText('LLM Coding Model'); + + await page.locator('.ide-model-band-toggle').click(); + await expect(page.locator('.ide-model-bar')).toHaveCount(0); + await page.locator('.ide-model-band-toggle').click(); + await expect(page.locator('.ide-model-bar')).toBeVisible(); + + await page.getByRole('button', { name: /Online API/i }).click(); + await expect(page.locator('.ide-model-bar-popover')).toContainText('DeepSeek V4 Flash'); + await page.locator('.ide-online-row', { hasText: 'Qwen Code Free' }).click(); + await closePopover(page); + + await page.getByRole('button', { name: /OpenRouter/i }).click(); + await expect(page.locator('.ide-model-bar-popover')).toContainText('OpenRouter'); + await closePopover(page); + + await page.locator('.ide-model-band-command-btn').click(); + await expect(page.locator('.ide-command-palette')).toContainText('IDE Command Palette'); + await expect(page.locator('.ide-command-palette')).toContainText('Open Tools'); + await page.keyboard.press('Escape'); + await expect(page.locator('.ide-command-palette-overlay')).toHaveCount(0); + await page.locator('.ide-model-band-command-btn').click(); + await expect(page.locator('.ide-command-palette')).toContainText('IDE Command Palette'); + await page.getByLabel('Close command palette').click(); + await expect(page.locator('.ide-command-palette-overlay')).toHaveCount(0); + + await page.locator('.ide-tree').getByText('README.md').click(); + await expect(page.locator('.ide-editor-tabs')).toContainText('README.md'); + await page.locator('button[title="Toggle Markdown preview"]').click(); + await page.locator('button[title="Toggle Markdown preview"]').click(); + await page.locator('button[title="Toggle Monaco minimap"]').click(); + await page.locator('button[title="Toggle opt-in Monaco inline autocomplete ghost text"]').click(); + + await page.locator('button[title="New file at workspace root"]').click(); + await page.locator('.ide-overlay-input').fill('audit-created-from-playwright.md'); + await page.locator('.ide-overlay-actions button', { hasText: 'Create' }).click(); + await expect.poll(async () => page.evaluate(() => (window as any).__MORE_AI_E2E_CREATED_FILES__ ?? [])) + .toContain('audit-created-from-playwright.md'); + + await page.locator('.ide-center-view-tab', { hasText: 'Agent' }).click(); + await expect(page.locator('.ide-agent-console')).toContainText('Run Agent'); + const idleHardStop = page.locator('.ide-agent-console .ide-btn-hard-stop', { hasText: 'Hard Stop All' }); + await expect(idleHardStop).toBeVisible(); + await expect(idleHardStop).toBeDisabled(); + await page.locator('.ide-center-view-tab', { hasText: 'Tools' }).click(); + await expect(page.locator('.ide-world-panel-stack')).toContainText('Git'); + await expect(page.locator('.ide-world-panel-stack')).toContainText('Search / Replace'); + await page.locator('.ide-center-view-tab', { hasText: 'Plugins' }).click(); + await expect(page.locator('.ide-plugins-page')).toContainText(/Workspace Packages|Trust/); + await page.locator('.ide-center-view-tab', { hasText: 'Editor' }).click(); + + await page.locator('.ide-terminal-input').fill('echo IDE_AUDIT'); + await page.locator('.ide-terminal-run-btn').click(); + await expect(page.locator('.ide-terminal-log')).toContainText('mock terminal output for: echo IDE_AUDIT', { timeout: 5000 }); + await page.locator('.ide-terminal-mini-btn[title="Clear terminal transcript"]').click(); + await expect(page.locator('.ide-terminal-log')).toContainText('Terminal ready.'); + + await page.locator('.ide-terminal-tab', { hasText: 'Inbox' }).click(); + await page.locator('.ide-inbox-textarea').fill('Audit directive from Playwright'); + await page.locator('.ide-inbox-send-btn').click(); + await expect(page.locator('.ide-inbox-send-btn')).toContainText(/Sent/i); + + await page.locator('.ide-center-view-tab', { hasText: 'Tools' }).click(); + await page.getByPlaceholder('Find in workspace').fill('phase15Mock'); + await page.getByPlaceholder('Replace with').fill('phase15Covered'); + await page.locator('.ide-search-panel button', { hasText: 'Search' }).click(); + await expect(page.locator('.ide-search-results')).toContainText('IdeWorkspace.tsx'); + await page.locator('.ide-search-result-row button', { hasText: 'Preview' }).first().click(); + await expect(page.locator('.ide-replace-preview')).toContainText('phase15Covered'); + + await page.locator('.ide-world-panel', { hasText: 'Git' }).locator('button', { hasText: 'Refresh' }).click(); + await expect(page.locator('.ide-world-panel', { hasText: 'Git' })).toContainText('codex-remediation'); + await page.locator('.ide-world-panel', { hasText: 'Git' }).locator('button', { hasText: 'Diff' }).first().click(); + await expect(page.locator('.ide-world-panel', { hasText: 'Git' })).toContainText('Diff loaded'); + + await page.locator('.ide-agent-submode-bar .ide-mode-btn', { hasText: /Agent/i }).click(); + await page.locator('label.ide-agent-perm-label', { hasText: 'Write files' }).locator('input').check(); + await page.locator('label.ide-agent-perm-label', { hasText: 'Run commands' }).locator('input').check(); + await page.locator('label.ide-agent-perm-label', { hasText: 'Auto-approve cmds' }).locator('input').check(); + await expect(page.locator('.ide-agent-perms')).toContainText('WRITE MODE'); + + await page.getByRole('button', { name: /Conductor/i }).click(); + await expect(page.locator('body')).toContainText('Conductor'); + await page.getByTestId('ide-conductor-clear-project').click(); + await expect(page.locator('body')).toContainText(/Conductor|Manual/); + + const invokeLog = await page.evaluate(() => (window as any).__MORE_AI_E2E_INVOKE_LOG__ ?? []); + expect(JSON.stringify(invokeLog)).not.toMatch(/paid\/offline-deprecated|live_paid/i); + }); +}); diff --git a/e2e/ide-model-routing-matrix.spec.ts b/e2e/ide-model-routing-matrix.spec.ts new file mode 100644 index 00000000..f36bbb20 --- /dev/null +++ b/e2e/ide-model-routing-matrix.spec.ts @@ -0,0 +1,304 @@ +// e2e/ide-model-routing-matrix.spec.ts +// Proves how selected local/API coding models route through IDE Chat, Agent, +// and Conductor modes without live provider calls. + +import { expect, test, type Page } from '@playwright/test'; +import { setupApp } from './helpers'; + +const LOCAL_MODEL_ID = 'test-2'; +const API_WORKER_ID = 'deepseek-v4-flash-openrouter-free'; +const API_CONDUCTOR_ID = 'deepseek-v4-pro-openrouter-free'; +const CHAT_ONLY_MODEL_ID = 'chat-only-review-openrouter-free'; +const READ_ONLY_TOOL_ID = 'read-only-tool-openrouter-free'; +const MISSING_LOCAL_TOOL_ID = 'qwen3-function-pro-ollama-missing'; +const SAFE_IDE_MODEL_IDS = [LOCAL_MODEL_ID, API_WORKER_ID, API_CONDUCTOR_ID, CHAT_ONLY_MODEL_ID, READ_ONLY_TOOL_ID, MISSING_LOCAL_TOOL_ID, 'qwen-code-free-openrouter']; + +async function setupRoutingHarness( + page: Page, + options: { + mode: 'manual' | 'conductor'; + submode?: 'chat' | 'agent'; + localIds?: string[]; + onlineIds?: string[]; + conductorId?: string | null; + }, +) { + await page.addInitScript((safeIds) => { + (window as any).__MORE_AI_E2E_IDE_AUDIT__ = true; + (window as any).__MORE_AI_E2E_STRICT_NO_MONEY__ = true; + (window as any).__MORE_AI_E2E_SAFE_MODEL_IDS__ = safeIds; + (window as any).__MORE_AI_E2E_INVOKE_LOG__ = []; + }, SAFE_IDE_MODEL_IDS); + await setupApp(page, { + waitForWorkspace: false, + initialTab: 'ide', + appShellTimeoutMs: 120000, + ideMode: options.mode, + ideAgentSubMode: options.submode ?? 'chat', + ideLocalModelIds: options.localIds ?? [LOCAL_MODEL_ID], + ideOnlineModelIds: options.onlineIds ?? [API_WORKER_ID], + ideConductorModelId: options.conductorId, + }); + await expect(page.getByTestId('ide-workspace')).toBeVisible({ timeout: 120000 }); + await expect(page.locator('.workspace-loading')).toBeHidden({ timeout: 120000 }); +} + +async function continueGuardedFallbackIfShown(page: Page) { + const continueButton = page.getByRole('button', { name: /Continue With Guarded Fallback|Continue Read-Only|Test In Agent Mode/i }); + if (await continueButton.isVisible({ timeout: 1500 }).catch(() => false)) { + await continueButton.click(); + } +} + +async function latestInvoke(page: Page, command: string) { + return await expect.poll(async () => { + const log = await page.evaluate(() => (window as any).__MORE_AI_E2E_INVOKE_LOG__ ?? []); + return [...log].reverse().find((entry: any) => entry.cmd === command) ?? null; + }, { timeout: 10000 }).not.toBeNull(); +} + +test.describe('IDE local/API model routing matrix', () => { + test.describe.configure({ timeout: 120000 }); + + test('Manual Chat Coding routes selected local and API models together', async ({ page }) => { + await setupRoutingHarness(page, { + mode: 'manual', + submode: 'chat', + localIds: [LOCAL_MODEL_ID], + onlineIds: [API_WORKER_ID], + conductorId: null, + }); + + await expect(page.getByTestId('ide-routing-note')).toContainText(/Chat Coding will send this prompt to all 2 selected models/i); + await expect(page.getByTestId('ide-routing-note')).toContainText(/Local Llama/i); + await expect(page.getByTestId('ide-routing-note')).toContainText(/DeepSeek V4 Flash/i); + + await page.locator('.ide-prompt-textarea').fill('[routing-matrix-chat] answer with one tiny code suggestion'); + await page.locator('.ide-agent-console .ide-btn-primary', { hasText: /^Send$/ }).click(); + + await latestInvoke(page, 'execute_prompt'); + const call = await page.evaluate(() => { + const log = (window as any).__MORE_AI_E2E_INVOKE_LOG__ ?? []; + return [...log].reverse().find((entry: any) => entry.cmd === 'execute_prompt'); + }); + expect(call.modelIds).toEqual([LOCAL_MODEL_ID, API_WORKER_ID]); + expect(call.args.input.model_ids).toEqual([LOCAL_MODEL_ID, API_WORKER_ID]); + }); + + test('Manual Agent Coding runs one primary tool agent plus Chat Coding companions', async ({ page }) => { + await setupRoutingHarness(page, { + mode: 'manual', + submode: 'agent', + localIds: [LOCAL_MODEL_ID], + onlineIds: [API_WORKER_ID], + conductorId: null, + }); + + await expect(page.getByTestId('ide-routing-note')).toContainText(/Agent Mode uses one primary tool agent/i); + await expect(page.getByTestId('ide-routing-note')).toContainText(/Other selected models run as Chat Coding companions/i); + await expect(page.getByTestId('ide-routing-note')).toContainText(/Use Conductor to run a coordinated multi-tool-agent swarm/i); + + await page.locator('.ide-prompt-textarea').fill('[routing-matrix-agent] inspect one file without writing'); + await page.getByRole('button', { name: /Run Agent/i }).last().click(); + await continueGuardedFallbackIfShown(page); + + await latestInvoke(page, 'execute_agent_loop'); + await latestInvoke(page, 'execute_prompt'); + const call = await page.evaluate(() => { + const log = (window as any).__MORE_AI_E2E_INVOKE_LOG__ ?? []; + return [...log].reverse().find((entry: any) => entry.cmd === 'execute_agent_loop'); + }); + expect(call.modelIds).toEqual([LOCAL_MODEL_ID]); + expect(call.args.input.model_id).toBe(LOCAL_MODEL_ID); + const companionCall = await page.evaluate(() => { + const log = (window as any).__MORE_AI_E2E_INVOKE_LOG__ ?? []; + return [...log].reverse().find((entry: any) => + entry.cmd === 'execute_prompt' + && String(entry.args?.input?.prompt ?? '').includes('Chat Coding companion') + ); + }); + expect(companionCall.modelIds).toEqual([API_WORKER_ID]); + expect(companionCall.args.input.model_ids).toEqual([API_WORKER_ID]); + expect(companionCall.args.input.prompt).toContain('Primary tool agent: Local Llama'); + }); + + test('Manual Agent Coding saves plain-text file output when a local model skips tool calls', async ({ page }) => { + await setupRoutingHarness(page, { + mode: 'manual', + submode: 'agent', + localIds: [LOCAL_MODEL_ID], + onlineIds: [], + conductorId: null, + }); + + const writeFiles = page.getByLabel(/Write files/i); + if (!(await writeFiles.isChecked().catch(() => true))) { + await writeFiles.check(); + } + + await page.locator('.ide-prompt-textarea').fill('[routing-matrix-agent-plain-html] Make a Basketball.html file with a polished basketball rules page.'); + await page.getByRole('button', { name: /Run Agent/i }).last().click(); + await continueGuardedFallbackIfShown(page); + + await latestInvoke(page, 'execute_agent_loop'); + await expect.poll(async () => { + const files = await page.evaluate(() => (window as any).__MORE_AI_E2E_CREATED_FILES__ ?? []); + return files.includes('Basketball.html'); + }, { timeout: 10000 }).toBe(true); + + await expect(page.getByText(/saved 1 file from the response/i)).toBeVisible({ timeout: 10000 }); + }); + + test('Manual Agent Coding moves a non-tool model to Chat Coding companion', async ({ page }) => { + await setupRoutingHarness(page, { + mode: 'manual', + submode: 'agent', + localIds: [LOCAL_MODEL_ID], + onlineIds: [CHAT_ONLY_MODEL_ID], + conductorId: null, + }); + + await expect(page.getByTestId('ide-routing-note')).toContainText(/Chat Coding Companion/i); + await expect(page.getByTestId('ide-routing-note')).toContainText(/Chat Only Review Model/i); + + await page.locator('.ide-prompt-textarea').fill('[routing-matrix-chat-only-agent] inspect one file without writing'); + await page.getByRole('button', { name: /Run Agent/i }).last().click(); + await continueGuardedFallbackIfShown(page); + + await latestInvoke(page, 'execute_agent_loop'); + await latestInvoke(page, 'execute_prompt'); + const agentCall = await page.evaluate(() => { + const log = (window as any).__MORE_AI_E2E_INVOKE_LOG__ ?? []; + return [...log].reverse().find((entry: any) => entry.cmd === 'execute_agent_loop'); + }); + expect(agentCall.modelIds).toEqual([LOCAL_MODEL_ID]); + expect(agentCall.args.input.model_id).toBe(LOCAL_MODEL_ID); + const companionCall = await page.evaluate(() => { + const log = (window as any).__MORE_AI_E2E_INVOKE_LOG__ ?? []; + return [...log].reverse().find((entry: any) => + entry.cmd === 'execute_prompt' + && String(entry.args?.input?.prompt ?? '').includes('Chat Coding companion') + ); + }); + expect(companionCall.modelIds).toEqual([CHAT_ONLY_MODEL_ID]); + expect(companionCall.args.input.model_ids).toEqual([CHAT_ONLY_MODEL_ID]); + expect(companionCall.args.input.prompt).toContain('You cannot call IDE tools'); + }); + + test('Manual Agent Coding does not let a first-selected chat-only model block a later tool agent', async ({ page }) => { + await setupRoutingHarness(page, { + mode: 'manual', + submode: 'agent', + localIds: [CHAT_ONLY_MODEL_ID], + onlineIds: [API_WORKER_ID], + conductorId: null, + }); + + await expect(page.getByTestId('ide-routing-note')).toContainText(/DeepSeek V4 Flash.*Primary Tool Agent/i); + await expect(page.getByTestId('ide-routing-note')).toContainText(/Chat Only Review Model.*Chat Coding Companion/i); + + await page.locator('.ide-prompt-textarea').fill('[routing-matrix-chat-only-first] inspect one file without writing'); + await page.getByRole('button', { name: /Run Agent/i }).last().click(); + await expect(page.getByRole('button', { name: /Switch to Chat Coding/i })).toBeHidden(); + await continueGuardedFallbackIfShown(page); + + await latestInvoke(page, 'execute_agent_loop'); + await latestInvoke(page, 'execute_prompt'); + const agentCall = await page.evaluate(() => { + const log = (window as any).__MORE_AI_E2E_INVOKE_LOG__ ?? []; + return [...log].reverse().find((entry: any) => entry.cmd === 'execute_agent_loop'); + }); + expect(agentCall.modelIds).toEqual([API_WORKER_ID]); + expect(agentCall.args.input.model_id).toBe(API_WORKER_ID); + const companionCall = await page.evaluate(() => { + const log = (window as any).__MORE_AI_E2E_INVOKE_LOG__ ?? []; + return [...log].reverse().find((entry: any) => + entry.cmd === 'execute_prompt' + && String(entry.args?.input?.prompt ?? '').includes('Chat Coding companion') + ); + }); + expect(companionCall.modelIds).toEqual([CHAT_ONLY_MODEL_ID]); + }); + + test('Manual Agent Coding starts read-only tool primary with chat companions when Write Files is on', async ({ page }) => { + await setupRoutingHarness(page, { + mode: 'manual', + submode: 'agent', + localIds: [CHAT_ONLY_MODEL_ID], + onlineIds: [READ_ONLY_TOOL_ID], + conductorId: null, + }); + + await page.getByLabel(/Write files/i).check(); + await page.locator('.ide-prompt-textarea').fill('[routing-matrix-read-only-primary] inspect one file with chat review'); + await page.getByRole('button', { name: /Run Agent/i }).last().click(); + await expect(page.getByRole('button', { name: /Switch to Chat Coding/i })).toBeHidden(); + await expect(page.getByRole('button', { name: /Start Read-Only Tool Agent/i })).toBeHidden(); + + await latestInvoke(page, 'execute_agent_loop'); + await latestInvoke(page, 'execute_prompt'); + const agentCall = await page.evaluate(() => { + const log = (window as any).__MORE_AI_E2E_INVOKE_LOG__ ?? []; + return [...log].reverse().find((entry: any) => entry.cmd === 'execute_agent_loop'); + }); + expect(agentCall.modelIds).toEqual([READ_ONLY_TOOL_ID]); + expect(agentCall.args.input.model_id).toBe(READ_ONLY_TOOL_ID); + expect(agentCall.args.input.allow_write).toBe(false); + const companionCall = await page.evaluate(() => { + const log = (window as any).__MORE_AI_E2E_INVOKE_LOG__ ?? []; + return [...log].reverse().find((entry: any) => + entry.cmd === 'execute_prompt' + && String(entry.args?.input?.prompt ?? '').includes('Chat Coding companion') + ); + }); + expect(companionCall.modelIds).toEqual([CHAT_ONLY_MODEL_ID]); + }); + + test('Manual Agent Coding does not promote a missing local tag to primary tool agent', async ({ page }) => { + await setupRoutingHarness(page, { + mode: 'manual', + submode: 'agent', + localIds: [MISSING_LOCAL_TOOL_ID], + onlineIds: [CHAT_ONLY_MODEL_ID], + conductorId: null, + }); + + await expect(page.getByTestId('ide-routing-note')).toContainText(/Agent Mode needs one runnable tool agent/i); + await expect(page.getByTestId('ide-routing-note')).toContainText(/Qwen3 4B Function Calling Pro.*Chat Coding Companion/i); + + await page.locator('.ide-prompt-textarea').fill('[routing-matrix-missing-local] inspect one file without writing'); + await page.getByRole('button', { name: /Run Agent/i }).last().click(); + + await expect(page.getByText(/No selected model can start Agent Mode/i).first()).toBeVisible({ timeout: 10000 }); + const agentCallCount = await page.evaluate(() => { + const log = (window as any).__MORE_AI_E2E_INVOKE_LOG__ ?? []; + return log.filter((entry: any) => entry.cmd === 'execute_agent_loop').length; + }); + expect(agentCallCount).toBe(0); + }); + + test('Conductor routes selected local and API workers as a tool swarm', async ({ page }) => { + await setupRoutingHarness(page, { + mode: 'conductor', + localIds: [LOCAL_MODEL_ID], + onlineIds: [API_WORKER_ID], + conductorId: API_CONDUCTOR_ID, + }); + + await expect(page.getByTestId('ide-routing-note')).toContainText(/Conductor will coordinate 2 selected workers/i); + await expect(page.getByTestId('ide-routing-note')).toContainText(/Local Llama/i); + await expect(page.getByTestId('ide-routing-note')).toContainText(/DeepSeek V4 Flash/i); + + await page.locator('.ide-prompt-textarea').fill('[routing-matrix-conductor] coordinate local and API workers on a tiny audit'); + await page.locator('aside.ide-prompt-panel button', { hasText: /^Send$/ }).last().click(); + + await latestInvoke(page, 'execute_conductor_session'); + const call = await page.evaluate(() => { + const log = (window as any).__MORE_AI_E2E_INVOKE_LOG__ ?? []; + return [...log].reverse().find((entry: any) => entry.cmd === 'execute_conductor_session'); + }); + expect(call.args.conductorModelId).toBe(API_CONDUCTOR_ID); + expect(call.args.workerModelIds).toContain(LOCAL_MODEL_ID); + expect(call.args.workerModelIds).toContain(API_WORKER_ID); + }); +}); diff --git a/e2e/ide-release-blockers.spec.ts b/e2e/ide-release-blockers.spec.ts index 264efc3d..24509c52 100644 --- a/e2e/ide-release-blockers.spec.ts +++ b/e2e/ide-release-blockers.spec.ts @@ -6,6 +6,7 @@ import { test, expect, type Page } from '@playwright/test'; import { setupApp } from './helpers'; const SAFE_IDE_MODEL_IDS = [ + 'test-2', 'deepseek-v4-pro-openrouter-free', 'deepseek-v4-flash-openrouter-free', 'qwen-code-free-openrouter', @@ -19,26 +20,28 @@ async function setupIdeAudit(page: Page) { (window as any).__MORE_AI_E2E_CREATED_FILES__ = []; (window as any).__MORE_AI_E2E_CMD_APPROVALS__ = []; }, SAFE_IDE_MODEL_IDS); - await setupApp(page, { waitForWorkspace: false, initialTab: 'ide' }); + await setupApp(page, { waitForWorkspace: false, initialTab: 'ide', appShellTimeoutMs: 120000 }); } -async function openIde(page: Page, mode: 'manual' | 'conductor' = 'manual') { - await page.evaluate((ideMode) => { +async function openIde(page: Page, mode: 'manual' | 'conductor' = 'manual', selectedOnlineIds: string[] = [], autoApply = false) { + await page.evaluate(({ ideMode, onlineIds, autoApplyFiles }) => { localStorage.setItem('ide_mode', ideMode); localStorage.setItem('ide_first_run_complete', 'true'); localStorage.setItem('ide_first_agent_complete', 'true'); localStorage.setItem('ide_workspace_path', 'G:\\More AI by Trier OS'); localStorage.setItem('ide_local_model_ids', JSON.stringify([])); - localStorage.setItem('ide_online_model_ids', JSON.stringify([])); + localStorage.setItem('ide_online_model_ids', JSON.stringify(onlineIds)); + localStorage.setItem('ide_auto_apply', autoApplyFiles ? '1' : '0'); if (ideMode === 'conductor') { localStorage.setItem('ide_conductor_model_id', 'deepseek-v4-pro-openrouter-free'); } else { localStorage.removeItem('ide_conductor_model_id'); localStorage.setItem('ide_agent_submode', 'chat'); + localStorage.setItem('ide_center_view', 'agent'); } - }, mode); - await expect(page.getByTestId('ide-workspace')).toBeVisible({ timeout: 60000 }); - await expect(page.locator('.workspace-loading')).toBeHidden({ timeout: 60000 }); + }, { ideMode: mode, onlineIds: selectedOnlineIds, autoApplyFiles: autoApply }); + await expect(page.getByTestId('ide-workspace')).toBeVisible({ timeout: 120000 }); + await expect(page.locator('.workspace-loading')).toBeHidden({ timeout: 120000 }); } async function continueGuardedFallbackIfShown(page: Page) { @@ -49,7 +52,7 @@ async function continueGuardedFallbackIfShown(page: Page) { } test.describe('IDE release blockers', () => { - test.describe.configure({ mode: 'serial' }); + test.describe.configure({ mode: 'serial', timeout: 180000 }); test('Conductor assigns only selectable no-money workers and dispatches full-tooling session', async ({ page }) => { await setupIdeAudit(page); @@ -149,4 +152,65 @@ test.describe('IDE release blockers', () => { const createdFiles = await page.evaluate(() => (window as any).__MORE_AI_E2E_CREATED_FILES__); expect(createdFiles).toContain('cow-pacman.html'); }); + + test('Chat Coding auto-apply normalizes absolute workspace paths before marking applied', async ({ page }) => { + await setupIdeAudit(page); + await openIde(page, 'manual', ['deepseek-v4-flash-openrouter-free'], true); + + await page.locator('.ide-center-view-tab', { hasText: 'Agent' }).click(); + await page.locator('.ide-agent-console .ide-prompt-textarea').fill('[ide-audit-chat-auto-apply-absolute-path] create the baseball page'); + await page.locator('.ide-agent-console .ide-btn-primary', { hasText: /^Send$/ }).click(); + + await expect.poll(async () => { + const log = await page.evaluate(() => (window as any).__MORE_AI_E2E_INVOKE_LOG__ ?? []); + return log.filter((entry: any) => entry.cmd === 'execute_prompt').length; + }, { timeout: 5000 }).toBeGreaterThan(0); + + await expect(page.locator('.ide-tree')).toContainText('Baseball.html', { timeout: 5000 }); + await expect(page.locator('.ide-pending-writes--center')).toContainText('Baseball.html', { timeout: 5000 }); + await expect(page.locator('.ide-pending-writes--center')).toContainText(/Applied/i, { timeout: 5000 }); + await expect(page.locator('.ide-pending-writes--center')).not.toContainText(/Security violation|could not write/i); + + const createdFiles = await page.evaluate(() => (window as any).__MORE_AI_E2E_CREATED_FILES__); + expect(createdFiles).toContain('Baseball.html'); + }); + + test('Agent write_file success followed by model response error is recoverable, not failed', async ({ page }) => { + await setupIdeAudit(page); + await openIde(page, 'manual'); + + await page.locator('.ide-agent-submode-bar .ide-mode-btn', { hasText: /Agent/i }).click(); + await page.locator('.ide-center-view-tab', { hasText: 'Agent' }).click(); + await page.locator('label.ide-agent-perm-label', { hasText: 'Write files' }).locator('input').check(); + + await page.getByPlaceholder('Ask the agent to work on this project...').fill('[ide-audit-partial-error-after-write] create Golf.html then simulate a malformed final response'); + await page.locator('.ide-agent-console .ide-btn-primary', { hasText: /Run Agent/i }).click(); + await continueGuardedFallbackIfShown(page); + + await expect(page.locator('.ide-tree')).toContainText('Golf.html', { timeout: 5000 }); + await expect(page.locator('.ide-agent-run-notice')).toContainText(/Agent wrote 1 file/i, { timeout: 5000 }); + await expect(page.locator('.ide-agent-run-notice')).toContainText(/recoverable model response issue/i); + await expect(page.locator('.ide-agent-run-notice')).not.toContainText(/Agent failed/i); + await expect(page.getByRole('button', { name: /Use Chat Coding/i })).toBeHidden(); + }); + + test('Hard Stop cancels an active long-running agent run', async ({ page }) => { + await setupIdeAudit(page); + await openIde(page, 'manual'); + + await page.locator('.ide-agent-submode-bar .ide-mode-btn', { hasText: /Agent/i }).click(); + await page.locator('.ide-prompt-textarea').fill('[phase15 hold] start a long-running IDE agent route'); + await page.getByRole('button', { name: /Run Agent/i }).click(); + await continueGuardedFallbackIfShown(page); + + const hardStop = page.getByTestId('ide-hard-stop-all'); + await expect(hardStop).toBeEnabled({ timeout: 5000 }); + await hardStop.click(); + + await expect(page.locator('body')).toContainText(/Hard Stop sent to 1 active agent session/i, { timeout: 5000 }); + await expect(hardStop).toBeDisabled({ timeout: 5000 }); + const invokeLog = await page.evaluate(() => (window as any).__MORE_AI_E2E_INVOKE_LOG__ ?? []); + expect(JSON.stringify(invokeLog)).toContain('kernel_cancel_all_sessions'); + expect(JSON.stringify(invokeLog)).toContain('cancel_agent_loop'); + }); }); diff --git a/e2e/ide-world-class-layout.spec.ts b/e2e/ide-world-class-layout.spec.ts index 4e2c88d6..36d85aaa 100644 --- a/e2e/ide-world-class-layout.spec.ts +++ b/e2e/ide-world-class-layout.spec.ts @@ -1,10 +1,11 @@ -import { expect, test, type Page } from '@playwright/test'; +import { expect, test, type Locator, type Page } from '@playwright/test'; import { setupApp } from './helpers'; const SAFE_IDE_MODEL_IDS = [ 'deepseek-v4-pro-openrouter-free', 'deepseek-v4-flash-openrouter-free', 'qwen-code-free-openrouter', + 'qwen3-function-pro-ollama-missing', ]; const VIEWPORTS = [ @@ -28,26 +29,31 @@ async function setupIdeLayoutAudit(page: Page, viewport: { width: number; height localStorage.setItem('ide_tool_inspector_open', 'true'); localStorage.setItem('ide_workspace_health_open', 'true'); localStorage.setItem('ide_model_band_collapsed', 'false'); + localStorage.setItem('ide_center_view', 'agent'); localStorage.setItem('ide_local_model_ids', JSON.stringify([])); localStorage.setItem('ide_online_model_ids', JSON.stringify(['deepseek-v4-flash-openrouter-free'])); }, SAFE_IDE_MODEL_IDS); - await setupApp(page, { waitForWorkspace: false, initialTab: 'ide' }); - await expect(page.getByTestId('ide-workspace')).toBeVisible({ timeout: 60000 }); - await expect(page.locator('.workspace-loading')).toBeHidden({ timeout: 60000 }); + await setupApp(page, { waitForWorkspace: false, initialTab: 'ide', appShellTimeoutMs: 120000 }); + await expect(page.getByTestId('ide-workspace')).toBeVisible({ timeout: 120000 }); + await expect(page.locator('.workspace-loading')).toBeHidden({ timeout: 120000 }); + await page.locator('.ide-center-view-tab', { hasText: 'Agent' }).click(); await page.locator('.ide-agent-submode-bar .ide-mode-btn', { hasText: /Agent/i }).click(); } -async function expectBoxInsideViewport(page: Page, selector: string) { - const locator = page.locator(selector).first(); +async function expectLocatorInsideViewport(page: Page, locator: Locator, label: string) { await expect(locator).toBeVisible({ timeout: 10000 }); const box = await locator.boundingBox(); const viewport = page.viewportSize(); - expect(box, `${selector} should have a bounding box`).not.toBeNull(); + expect(box, `${label} should have a bounding box`).not.toBeNull(); expect(viewport, 'viewport should be available').not.toBeNull(); - expect(box!.x, `${selector} left edge`).toBeGreaterThanOrEqual(0); - expect(box!.y, `${selector} top edge`).toBeGreaterThanOrEqual(0); - expect(box!.x + box!.width, `${selector} right edge`).toBeLessThanOrEqual(viewport!.width + 1); - expect(box!.y + box!.height, `${selector} bottom edge`).toBeLessThanOrEqual(viewport!.height + 1); + expect(box!.x, `${label} left edge`).toBeGreaterThanOrEqual(0); + expect(box!.y, `${label} top edge`).toBeGreaterThanOrEqual(0); + expect(box!.x + box!.width, `${label} right edge`).toBeLessThanOrEqual(viewport!.width + 1); + expect(box!.y + box!.height, `${label} bottom edge`).toBeLessThanOrEqual(viewport!.height + 1); +} + +async function expectBoxInsideViewport(page: Page, selector: string) { + await expectLocatorInsideViewport(page, page.locator(selector).first(), selector); } test.describe('IDE world-class layout audit', () => { @@ -56,15 +62,16 @@ test.describe('IDE world-class layout audit', () => { await setupIdeLayoutAudit(page, viewport); await expect(page.locator('body')).not.toContainText(/Critical UI Fault|ReferenceError|ERR_CONNECTION_REFUSED/i); + await expect(page.locator('.ide-agent-console')).toContainText('Run Agent'); + await page.locator('.ide-center-view-tab', { hasText: 'Tools' }).click(); await expect(page.locator('.ide-world-panel-stack')).toContainText('Agent Tools'); await expect(page.locator('.ide-world-panel-stack')).toContainText('Workspace Health'); - await expect(page.locator('.ide-world-panel-stack')).toContainText('Agent Timeline'); await expect(page.locator('.ide-command-palette-overlay')).toHaveCount(0); for (const selector of [ '[data-testid="ide-workspace"]', - '.ide-model-band', - '.ide-world-panel-stack', + '.ide-model-band-shell', + '.ide-world-panel', '.ide-main-content', '.ide-prompt-panel', ]) { @@ -79,4 +86,72 @@ test.describe('IDE world-class layout audit', () => { }); }); } + + test('local model picker stays on-screen and treats failed probes as advisory', async ({ page }, testInfo) => { + await page.setViewportSize({ width: 1024, height: 768 }); + await page.addInitScript((safeIds) => { + (window as any).__MORE_AI_E2E_LOCAL_STRESS_MODELS__ = true; + (window as any).__MORE_AI_E2E_STRICT_NO_MONEY__ = true; + (window as any).__MORE_AI_E2E_SAFE_MODEL_IDS__ = safeIds; + localStorage.setItem('ide_mode', 'manual'); + localStorage.setItem('ide_agent_submode', 'agent'); + localStorage.setItem('ide_first_run_complete', 'true'); + localStorage.setItem('ide_first_agent_complete', 'true'); + localStorage.setItem('ide_workspace_path', 'G:\\More AI by Trier OS'); + localStorage.setItem('ide_model_band_collapsed', 'false'); + localStorage.setItem('ide_center_view', 'agent'); + localStorage.setItem('ide_local_model_ids', JSON.stringify([])); + localStorage.setItem('ide_online_model_ids', JSON.stringify([])); + }, SAFE_IDE_MODEL_IDS); + await setupApp(page, { + waitForWorkspace: false, + initialTab: 'ide', + appShellTimeoutMs: 120000, + ideLocalModelIds: [], + ideOnlineModelIds: [], + ideAgentSubMode: 'agent', + }); + + await expect(page.getByTestId('ide-workspace')).toBeVisible({ timeout: 120000 }); + await page.getByRole('button', { name: /^Local$/ }).click(); + + const panel = page.locator('.ide-model-bar-popover--fixed.ide-model-bar-popover--online').last(); + await expect(panel).toContainText('Qwen3 4B Function Calling Pro'); + await expectLocatorInsideViewport(page, panel, 'local model picker'); + await expectLocatorInsideViewport(page, panel.locator('.ide-online-popover-header'), 'local model picker header'); + await expectLocatorInsideViewport(page, panel.locator('.ide-model-bar-popover-footer'), 'local model picker footer'); + + const failedProbeRow = panel.locator('.ide-online-row', { hasText: 'Qwen3 4B Function Calling Pro' }).first(); + await failedProbeRow.click(); + await expect(panel.locator('.ide-model-bar-popover-footer')).toContainText('1 local model active', { timeout: 5000 }); + await expect(failedProbeRow).toContainText(/Ollama returned HTTP 404/i, { timeout: 5000 }); + await expectLocatorInsideViewport(page, failedProbeRow, 'failed local probe row'); + + await panel.locator('.model-select-panel-close').click(); + await expect(page.locator('.local-conflict-banner')).toContainText(/optional background IDE generation probe/i, { timeout: 5000 }); + await expect(page.locator('.ide-model-band-summary')).toContainText(/Qwen3 4B Function Calling Pro|1 selected/i); + + await page.locator('label.ide-agent-perm-label', { hasText: 'Write files' }).locator('input').check(); + await page.getByPlaceholder('Ask the agent to work on this project...').fill('[ide-local-missing-tag-trial] try the selected local model anyway'); + await page.locator('.ide-agent-console .ide-btn-primary', { hasText: /Run Agent/i }).click(); + + await expect(page.getByTestId('ide-agent-tool-readiness-warning')).toContainText(/More AI will try this selected local model/i, { timeout: 5000 }); + await expect.poll(async () => { + const log = await page.evaluate(() => (window as any).__MORE_AI_E2E_INVOKE_LOG__ ?? []); + return [...log].reverse().find((entry: any) => entry.cmd === 'execute_agent_loop') ?? null; + }, { timeout: 5000 }).not.toBeNull(); + const agentCall = await page.evaluate(() => { + const log = (window as any).__MORE_AI_E2E_INVOKE_LOG__ ?? []; + return [...log].reverse().find((entry: any) => entry.cmd === 'execute_agent_loop'); + }); + expect(agentCall.args.input.model_id).toBe('qwen3-function-pro-ollama-missing'); + expect(agentCall.args.input.allow_write).toBe(true); + + await page.screenshot({ + path: testInfo.outputPath('ide-local-model-picker-stress.png'), + fullPage: false, + animations: 'disabled', + caret: 'hide', + }); + }); }); diff --git a/e2e/mock-tauri.ts b/e2e/mock-tauri.ts index e0a27a64..9a8463ec 100644 --- a/e2e/mock-tauri.ts +++ b/e2e/mock-tauri.ts @@ -23,6 +23,7 @@ declare global { __MORE_AI_E2E_CMD_APPROVALS__?: Array<{ token: string; approved: boolean }>; __MORE_AI_E2E_PENDING_CMD_SESSION__?: { sessionId: string; command: string }; __MORE_AI_E2E_CONDUCTOR_CALL__?: any; + __MORE_AI_E2E_LOCAL_STRESS_MODELS__?: boolean; } } @@ -68,6 +69,29 @@ export function mockTauriIPC() { use_case_tags: overrides.use_case_tags ?? ['chat'], }); const ideAuditModels = () => [ + e2eModel({ + id: 'test-2', + display_name: 'Local Llama', + provider_type: 'ollama', + source_class: 'local_llm', + model_name: 'llama-3', + endpoint_url: 'http://127.0.0.1:11434', + cost_tier: 'free', + max_context_tokens: 8_192, + effective_context_tokens: 7_000, + role_badges: ['local', 'chat', 'code'], + use_case_tags: ['chat', 'code'], + }), + e2eModel({ + id: 'qwen3-function-pro-ollama-missing', + display_name: 'Qwen3 4B Function Calling Pro', + provider_type: 'ollama', + source_class: 'local_llm', + model_name: 'Qwen3-4B-Function-Calling-Pro', + endpoint_url: 'http://127.0.0.1:11434', + role_badges: ['local', 'code', 'tools'], + use_case_tags: ['chat', 'code', 'tools'], + }), e2eModel({ id: 'deepseek-v4-pro-openrouter-free', display_name: 'DeepSeek V4 Pro (OpenRouter free)', @@ -94,6 +118,32 @@ export function mockTauriIPC() { role_badges: ['chat', 'code'], use_case_tags: ['chat', 'code', 'tools'], }), + e2eModel({ + id: 'chat-only-review-openrouter-free', + display_name: 'Chat Only Review Model (OpenRouter free)', + provider_type: 'openrouter', + source_class: 'online_api', + model_name: 'mock/chat-only-review:free', + api_key_ref: 'openrouter_api_key', + cost_tier: 'free', + max_context_tokens: 32_000, + effective_context_tokens: 28_000, + role_badges: ['chat', 'review'], + use_case_tags: ['chat', 'code'], + }), + e2eModel({ + id: 'read-only-tool-openrouter-free', + display_name: 'Read-Only Tool Model (OpenRouter free)', + provider_type: 'openrouter', + source_class: 'online_api', + model_name: 'mock/read-only-tool:free', + api_key_ref: 'openrouter_api_key', + cost_tier: 'free', + max_context_tokens: 64_000, + effective_context_tokens: 58_000, + role_badges: ['tools', 'read-only'], + use_case_tags: ['chat', 'code', 'tools'], + }), e2eModel({ id: 'qwen-code-free-openrouter', display_name: 'Qwen Code Free (OpenRouter)', @@ -120,6 +170,84 @@ export function mockTauriIPC() { use_case_tags: ['chat', 'code'], }), ]; + const localStressModels = () => [ + e2eModel({ + id: 'qwen3-function-pro-ollama-missing', + display_name: 'Qwen3 4B Function Calling Pro', + provider_type: 'ollama', + source_class: 'local_llm', + model_name: 'Qwen3-4B-Function-Calling-Pro', + endpoint_url: 'http://127.0.0.1:11434', + role_badges: ['local', 'code', 'tools'], + use_case_tags: ['chat', 'code', 'tools'], + }), + e2eModel({ + id: 'qwen3-function-pro-llamacpp', + display_name: 'Qwen3 4B Function Calling Pro llama.cpp', + provider_type: 'llama_cpp', + source_class: 'local_llm', + model_name: 'Qwen3-4B-Function-Calling-Pro', + role_badges: ['local', 'code', 'tools'], + use_case_tags: ['chat', 'code', 'tools'], + }), + e2eModel({ + id: 'qwen3-empty-response', + display_name: 'Qwen3.5 9b', + provider_type: 'ollama', + source_class: 'local_llm', + model_name: 'qwen3.5:9b', + endpoint_url: 'http://127.0.0.1:11434', + role_badges: ['local', 'chat'], + use_case_tags: ['chat', 'code'], + }), + ...Array.from({ length: 18 }, (_, index) => e2eModel({ + id: `stress-local-${index + 1}`, + display_name: [ + 'Phi3 medium', + 'Qwen', + 'Qwen2.5 14b-instruct-q6_K', + 'Qwen2.5 7b-instruct-q6_K', + 'Qwen2.5 Coder 14b', + 'Qwen2.5 Coder 32b', + 'Qwen3 14b', + 'Qwopus3.6 27B Coder MTP Q3 K M', + 'Step3.7 flash mtp BF16', + 'llama 3.2 3b instruct', + 'mmpoj F32', + 'phi 3.5 mini instruct', + 'qwen2.5 coder 7b instruct', + 'qwen3.5 18b a3b reap coding heretic v0.i1 Q4_0', + 'trim 3b', + 'Mistral', + 'Nomic Embed Text', + 'Llava 13b', + ][index], + provider_type: index % 4 === 0 ? 'llama_cpp' : 'ollama', + source_class: 'local_llm', + model_name: [ + 'phi3:medium', + 'qwen:latest', + 'qwen2.5:14b-instruct-q6_K', + 'qwen2.5:7b-instruct-q6_K', + 'qwen2.5-coder:14b', + 'qwen2.5-coder:32b', + 'qwen3:14b', + 'Qwopus3.6-27B-Coder-MTP-Q3_K_M', + 'Step3.7-flash-mtp-BF16', + 'llama-3.2-3b-instruct', + 'mmpoj-F32', + 'phi-3.5-mini-instruct', + 'qwen2.5-coder-7b-instruct', + 'qwen3.5-18b-a3b-reap-coding-heretic-v0.i1-Q4_0', + 'trim:3b', + 'mistral:latest', + 'nomic-embed-text:latest', + 'llava:13b', + ][index], + role_badges: ['local', 'chat'], + use_case_tags: ['chat', 'code'], + })), + ]; const firstRunModels = (profile: FirstRunE2EProfile) => { if (profile === 'existing-ollama' || profile === 'returning-user') { return [e2eModel({ @@ -135,6 +263,7 @@ export function mockTauriIPC() { }; const modelRows = () => { if (Array.isArray(window.__MORE_AI_E2E_MODEL_MATRIX__)) return window.__MORE_AI_E2E_MODEL_MATRIX__!; + if (window.__MORE_AI_E2E_LOCAL_STRESS_MODELS__) return localStressModels(); if (window.__MORE_AI_E2E_IDE_AUDIT__) return ideAuditModels(); const profile = firstRunProfile(); if (profile) return firstRunModels(profile); @@ -362,7 +491,10 @@ export function mockTauriIPC() { }; const emitPromptStream = (sessionId: string, modelIds: string[], prompt: string) => { const wantsPlan = /Swarm Execution Planner|exactly \d+ branches|parallel branches/i.test(prompt); - const body = wantsPlan + const wantsAbsoluteFileApply = /ide-audit-chat-auto-apply-absolute-path/i.test(prompt); + const body = wantsAbsoluteFileApply + ? '[FILE: G:\\More AI by Trier OS\\Baseball.html]\n```html\nBaseball

Baseball Rules

\n```\n' + : wantsPlan ? JSON.stringify({ goal: 'Mock safe no-money swarm plan', branches: [ @@ -573,8 +705,123 @@ export function mockTauriIPC() { return modelRows().map(profileForModel); case 'get_model_cap_profile': return profileForModel(modelRows().find(m => m.id === args?.modelId) ?? modelRows()[0]); + case 'probe_ollama_model_generation': + if (/Qwen3-4B-Function-Calling-Pro/i.test(String(args?.modelName ?? ''))) { + return { + ok: false, + message: 'Ollama returned HTTP 404 Not Found: {"error":"model \\"Qwen3-4B-Function-Calling-Pro\\" not found"}', + response_preview: null, + latency_ms: 18, + }; + } + if (/qwen3\.5:9b/i.test(String(args?.modelName ?? ''))) { + return { + ok: false, + message: 'Ollama responded but returned no text.', + response_preview: '', + latency_ms: 22, + }; + } + return { + ok: true, + message: 'Mock local model generation probe passed.', + response_preview: 'IDE_OK', + latency_ms: 12, + }; case 'get_ide_model_tool_readiness': { const model = modelRows().find(m => m.id === args?.modelId) ?? modelRows()[0]; + if (model.id === 'qwen3-function-pro-ollama-missing') { + return { + model_id: model.id, + display_name: model.display_name, + model_name: model.model_name, + provider_type: model.provider_type, + source_class: model.source_class, + registry_profile_id: `local-profile:${model.id}`, + mode_label: 'Ollama tag missing', + ide_tool_mode: 'not_installed', + tool_calling_status: 'not_installed', + certification_level: 'not_installed', + can_start_agent: false, + can_write: false, + warning_required: true, + warning_title: 'Ollama model tag is not installed', + warning_message: 'More AI has this local model in the registry, but the running Ollama tag lookup cannot confirm the exact tag.', + evidence_label: 'Mock E2E live tags do not include Qwen3-4B-Function-Calling-Pro.', + evidence_notes: 'The frontend should keep this as an advisory local trial, not block Agent Mode.', + recommended_local_model: null, + recommended_online_model: { + model_id: 'deepseek-v4-pro-openrouter-free', + label: 'DeepSeek V4 Pro (OpenRouter free)', + reason: 'Free mocked online route for IDE Agent Mode coverage.', + }, + fallback_action_label: 'Switch to Chat Coding', + }; + } + if (model.id === 'chat-only-review-openrouter-free') { + return { + model_id: model.id, + display_name: model.display_name, + model_name: model.model_name, + provider_type: model.provider_type, + source_class: model.source_class, + registry_profile_id: null, + mode_label: 'Chat Coding Only', + ide_tool_mode: 'chat_only', + tool_calling_status: 'mock_chat_only', + certification_level: 'chat_only', + can_start_agent: false, + can_write: false, + warning_required: true, + warning_title: 'Chat Coding model selected', + warning_message: 'This model can draft and review code but is not certified for IDE tool calls.', + evidence_label: 'Mock E2E readiness marks this model as chat-only.', + evidence_notes: 'It should be routed as a companion when a primary tool agent is available.', + recommended_local_model: { + model_id: 'test-2', + label: 'Local Llama', + reason: 'Local mocked fallback model for primary Agent Mode coverage.', + }, + recommended_online_model: { + model_id: 'deepseek-v4-pro-openrouter-free', + label: 'DeepSeek V4 Pro (OpenRouter free)', + reason: 'Free mocked online route for IDE Agent Mode coverage.', + }, + fallback_action_label: 'Switch to Chat Coding', + }; + } + if (model.id === 'read-only-tool-openrouter-free') { + return { + model_id: model.id, + display_name: model.display_name, + model_name: model.model_name, + provider_type: model.provider_type, + source_class: model.source_class, + registry_profile_id: null, + mode_label: 'Read-Only Tool Agent', + ide_tool_mode: 'native_tool_calling', + tool_calling_status: 'mock_read_only_tools', + certification_level: 'read_only_tool_agent', + can_start_agent: true, + can_write: false, + warning_required: true, + warning_title: 'Read-only IDE tool route', + warning_message: 'This model can use IDE inspection tools but cannot write files in this route.', + evidence_label: 'Mock E2E readiness allows read-only Agent Mode.', + evidence_notes: 'Write Files should be downgraded for the primary agent without moving companions out of Chat Coding.', + recommended_local_model: { + model_id: 'test-2', + label: 'Local Llama', + reason: 'Local mocked fallback model for write-capable Agent Mode coverage.', + }, + recommended_online_model: { + model_id: 'deepseek-v4-pro-openrouter-free', + label: 'DeepSeek V4 Pro (OpenRouter free)', + reason: 'Free mocked online route for write-capable Agent Mode coverage.', + }, + fallback_action_label: 'Switch to Chat Coding', + }; + } return { model_id: model.id, display_name: model.display_name, @@ -1409,6 +1656,7 @@ export function mockTauriIPC() { const created = window.__MORE_AI_E2E_CREATED_FILES__ ?? []; return [ { name: 'src', path: `${args?.workspacePath ?? 'G:\\More AI by Trier OS'}\\src`, is_dir: true, size: null }, + { name: 'README.md', path: `${args?.workspacePath ?? 'G:\\More AI by Trier OS'}\\README.md`, is_dir: false, size: 1024 }, { name: 'package.json', path: `${args?.workspacePath ?? 'G:\\More AI by Trier OS'}\\package.json`, is_dir: false, size: 2048 }, ...created.map(name => ({ name, @@ -1418,10 +1666,43 @@ export function mockTauriIPC() { })), ]; } - case 'read_file_content': + case 'collect_workspace_context': + return [ + { + name: 'README.md', + path: `${args?.workspacePath ?? 'G:\\More AI by Trier OS'}\\README.md`, + size: 1024, + content: '# More AI IDE Audit\n\nThis markdown file exists so Playwright can verify the preview pane.\n', + }, + { + name: 'package.json', + path: `${args?.workspacePath ?? 'G:\\More AI by Trier OS'}\\package.json`, + size: 2048, + content: '{ "name": "tauri-app", "version": "1.0.9", "scripts": { "typecheck": "tsc --noEmit" } }', + }, + { + name: 'IdeWorkspace.tsx', + path: `${args?.workspacePath ?? 'G:\\More AI by Trier OS'}\\src\\components\\IdeWorkspace.tsx`, + size: 4096, + content: 'export const phase15Mock = true;\\nexport function IdeWorkspace() { return null; }\\n', + }, + ]; + case 'read_file_content': { + const targetPath = String(args?.path ?? args?.filePath ?? args?.relativePath ?? ''); + if (/README\.md$/i.test(targetPath)) { + return '# More AI IDE Audit\n\nThis markdown file exists so Playwright can verify the preview pane.\n'; + } + if (/package\.json$/i.test(targetPath)) { + return '{\n "name": "tauri-app",\n "version": "1.0.9"\n}\n'; + } return 'export const phase15Mock = true;\n'; + } case 'write_file_content': { - const rel = String(args?.relativePath ?? args?.targetPath ?? '').split(/[\\/]/).pop(); + const targetPath = String(args?.relativePath ?? args?.targetPath ?? ''); + if (/^[a-zA-Z]:[\\/]/.test(targetPath) || targetPath.startsWith('\\\\')) { + throw new Error(`Security violation: expected a workspace-relative file path, got ${targetPath}`); + } + const rel = targetPath.split(/[\\/]/).pop(); if (rel) { window.__MORE_AI_E2E_CREATED_FILES__ = Array.from(new Set([ ...(window.__MORE_AI_E2E_CREATED_FILES__ ?? []), @@ -1434,7 +1715,56 @@ export function mockTauriIPC() { case 'rename_file': return null; case 'list_workspace_tree': - return 'src/\npackage.json\n'; + return 'src/\nREADME.md\npackage.json\n'; + case 'execute_terminal_command_session': + return `mock terminal output for: ${args?.command ?? ''}`; + case 'cancel_terminal_command': + case 'kernel_cancel_all_sessions': + recordInvoke(cmd, args, []); + return cmd === 'kernel_cancel_all_sessions' ? 1 : null; + case 'get_git_status': + return { + is_repo: true, + branch: 'codex-remediation', + uncommitted_changes: true, + dirty_count: 1, + staged_count: 0, + unstaged_count: 1, + untracked_count: 0, + conflict_count: 0, + changed_files: [ + { + path: 'src/components/IdeWorkspace.tsx', + index_status: ' ', + worktree_status: 'M', + bucket: 'unstaged', + }, + ], + }; + case 'git_list_branches': + return [ + { name: 'codex-remediation', current: true }, + { name: 'main', current: false }, + ]; + case 'git_file_diff': + return 'diff --git a/src/components/IdeWorkspace.tsx b/src/components/IdeWorkspace.tsx\\n+export const phase15Mock = true;\\n'; + case 'git_stage_file': + return `Staged ${args?.path ?? 'file'}`; + case 'git_unstage_file': + return `Unstaged ${args?.path ?? 'file'}`; + case 'git_discard_file': + return `Discarded ${args?.path ?? 'file'}`; + case 'git_commit_staged': + return '70df27d39c50b12a23db4b05a4a076a8f23f0a73'; + case 'git_switch_branch': + return `Switched to ${args?.branch ?? 'branch'}`; + case 'git_create_branch': + return `Created ${args?.branch ?? 'branch'}`; + case 'list_session_tools': + return []; + case 'revoke_session_tool': + case 'test_session_tool': + return cmd === 'test_session_tool' ? 'mock session tool passed' : null; case 'auto_start_lsp': case 'index_workspace': case 'set_approved_orchestrate_workspace': @@ -1504,6 +1834,60 @@ export function mockTauriIPC() { }), 140); return null; } + if (/ide-audit-partial-error-after-write/i.test(prompt)) { + const callId = 'mock-write-file-before-error'; + const fileName = 'Golf.html'; + setTimeout(() => emitMockEvent('agent:text_chunk', { + session_id: sessionId, + text: 'Writing the requested file before validating the final response. ', + }), 20); + setTimeout(() => emitMockEvent('agent:tool_call', { + session_id: sessionId, + call_id: callId, + tool_name: 'write_file', + tool_input_json: JSON.stringify({ path: fileName, content: 'Golf' }), + }), 40); + setTimeout(() => { + window.__MORE_AI_E2E_CREATED_FILES__ = Array.from(new Set([ + ...(window.__MORE_AI_E2E_CREATED_FILES__ ?? []), + fileName, + ])); + emitMockEvent('agent:tool_result', { + session_id: sessionId, + call_id: callId, + result_preview: `Wrote ${fileName}`, + truncated: false, + is_error: false, + }); + }, 80); + setTimeout(() => emitMockEvent('agent:error', { + session_id: sessionId, + message: 'Ollama native chat HTTP 400 Bad Request: Value looks like object, but cannot find closing symbol', + }), 130); + return null; + } + if (/routing-matrix-agent-plain-html/i.test(prompt)) { + setTimeout(() => emitMockEvent('agent:text_chunk', { + session_id: sessionId, + text: + 'I will create the requested page.\n\n' + + '```html\n' + + '\n' + + '\n' + + 'Basketball\n' + + '

Basketball Rules

A polished basketball guide.

\n' + + '\n' + + '```\n', + }), 20); + setTimeout(() => emitMockEvent('agent:done', { + session_id: sessionId, + tool_calls_made: 0, + turns_made: 1, + total_input_tokens: 80, + total_output_tokens: 120, + }), 80); + return null; + } setTimeout(() => emitMockEvent('agent:text_chunk', { session_id: sessionId, text: 'Mock agent started and is waiting for a safe checkpoint. ', @@ -1557,6 +1941,9 @@ export function mockTauriIPC() { window.__MORE_AI_E2E_PENDING_CMD_SESSION__ = undefined; return null; } + case 'respond_to_write_confirm': + case 'respond_to_tool_approval': + return null; case 'execute_conductor_session': assertNoMoneySafeModels(cmd, args); window.__MORE_AI_E2E_CONDUCTOR_CALL__ = args; @@ -1592,6 +1979,8 @@ export function mockTauriIPC() { status: 'completed', }; case 'cancel_agent_loop': + recordInvoke(cmd, args, []); + return null; case 'inject_agent_message': return null; case 'get_model_domain_profile': diff --git a/e2e/regression.spec.ts b/e2e/regression.spec.ts index 7b17d544..2ca1cf0e 100644 --- a/e2e/regression.spec.ts +++ b/e2e/regression.spec.ts @@ -91,6 +91,18 @@ test.describe('Automated UI Regression Expansion', () => { await expect(page.getByTestId('model-setup-center')).toBeVisible(); const localLlmWidth = await page.getByTestId('settings-local-llm').evaluate(el => el.getBoundingClientRect().width); expect(localLlmWidth).toBeGreaterThan(900); + const runtimeGrid = page.getByTestId('local-llm-runtime-grid'); + await expect(runtimeGrid).toBeVisible(); + const runtimeColumnBoxes = await page + .locator('[data-testid="local-llm-runtime-grid"] > .local-llm-runtime-column') + .evaluateAll(els => els.map(el => { + const rect = el.getBoundingClientRect(); + return { y: rect.y, width: rect.width }; + })); + expect(runtimeColumnBoxes).toHaveLength(2); + expect(Math.abs(runtimeColumnBoxes[0].y - runtimeColumnBoxes[1].y)).toBeLessThan(4); + expect(runtimeColumnBoxes[0].width).toBeGreaterThan(300); + expect(runtimeColumnBoxes[1].width).toBeGreaterThan(300); await page.locator('.app-cat-pill', { hasText: 'AI Colosseum' }).first().click(); await expect(page.getByText('Put your AI agents to the test - see which ones think, adapt, and win under real pressure')).toBeVisible({ timeout: 5000 }); diff --git a/governance/HASHES.json b/governance/HASHES.json index 0f090168..cf334c4e 100644 --- a/governance/HASHES.json +++ b/governance/HASHES.json @@ -1,17 +1,17 @@ { "files": { - "MoreAI Governance.md": "sha256:2e36025b3bbfa8a9a9c3922988f1b634cb15739351a249c8dc012b4bc3dfc646", + "MoreAI Governance.md": "sha256:2a9edf1d6a7321627f4a5a5ddbf1fa430132ae03be6af06134734c8238c5c04f", "DETERMINISM.md": "sha256:7c121147aa6e84085a4f812de152c00ed0db6aecb68a26528429a4882f8e5470", "GATES.md": "sha256:5dfaccbea2ecb25eb7ee9a3083aae14cada5af2fbb23153b81baa0f3c0ed4454", "INCIDENT.md": "sha256:5377ddce918fbee0d5d900e6499070a232b1ac016501c47f9985eac0ba866413", "LICENSING.md": "sha256:1c335fc71a57e75bf8540b570abc5fe6e804229440ddf740eaad0519e47d29b5", - "PRIVACY.md": "sha256:51cb4ae3dd94421b16b976b8dc5ee1018355d6dcc85acf6a0f300a853dbc2b34", - "SECURITY.md": "sha256:572d0940e4845672ace687d9152eb15b5255ac2c4ad00bb52a3424ffd646e875", + "PRIVACY.md": "sha256:d7077a3f133494f754067c18d016d3582901adb2c8e02b109e2e847d26e15032", + "SECURITY.md": "sha256:09e489a07a24f7e83fa9852ed20527f0575d31bd318cf9b70dbf1105ebde3162", "STANDARDS.md": "sha256:cd2b723718d4498caad2f141af438ba6e07922a0fed57a2a233458dec8b70b81", - "CALIBRATION.md": "sha256:8aac03a115cbe3e9dcb3c3a607c1b93878fc47a74f07b5d403f221f81b74c8f6", - "calibration/pack_v1.json": "sha256:2d6587d698f60aebf30eecc5759d60e548c5a61b0e17dc2fc2c8776d5fd7053e" + "CALIBRATION.md": "sha256:8749c2c62e8f0edcea47af0de8fa3e46eecb2979882ca0e236a915ee90874cc7", + "calibration/pack_v1.json": "sha256:eb5f6ad194f46b6d8d25e7f03827ebd37e426232ff83b054e66e0b52b86d36d7" }, - "issued_at": "2026-06-06T00:00:00Z", + "issued_at": "2026-06-28T00:00:00Z", "issued_by": "Trier OS Governance — Doug Trier", - "pack_version": "2026.06.06-1" -} \ No newline at end of file + "pack_version": "2026.06.28-1" +} diff --git a/governance/PACK_VERSION b/governance/PACK_VERSION index 6b2eac84..2ea332db 100644 --- a/governance/PACK_VERSION +++ b/governance/PACK_VERSION @@ -1 +1 @@ -2026.05.17-2 +2026.06.28-1 diff --git a/package-lock.json b/package-lock.json index f1419896..318e50cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tauri-app", - "version": "1.0.8", + "version": "1.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tauri-app", - "version": "1.0.8", + "version": "1.10.0", "dependencies": { "@monaco-editor/react": "^4.7.0", "@tauri-apps/api": "^2.11.0", diff --git a/package.json b/package.json index cceb0a99..46a72639 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "tauri-app", "private": true, - "version": "1.0.8", + "version": "1.10.0", "type": "module", "scripts": { "dev": "node scripts/clear-dev-ports.cjs", @@ -56,6 +56,7 @@ "test:external-apps": "node scripts/test_external_apps_contract.cjs", "test:offline-export": "node scripts/test_offline_export_contract.cjs", "test:curated-model-manifest": "node scripts/test_curated_model_manifest_contract.cjs", + "test:governance-pack": "node scripts/test_governance_pack_contract.cjs", "test:runtime-dependencies": "node scripts/test_runtime_dependency_contract.cjs", "test:enterprise-secret-redaction": "node scripts/test_enterprise_secret_redaction_contract.cjs", "test:local-model-inventory": "node scripts/test_local_model_inventory_contract.cjs", @@ -77,6 +78,8 @@ "e2e:first-run-onboarding": "playwright test e2e/first-run-onboarding.spec.ts", "e2e:dugbee-first-experience": "playwright test --config=playwright.full-traversal.config.ts e2e/dugbee-first-experience.spec.ts", "e2e:model-selection": "playwright test e2e/model-selection-matrix.spec.ts", + "e2e:ide-complete-audit": "playwright test ide-complete-audit.spec.ts", + "e2e:ide-model-routing": "playwright test ide-model-routing-matrix.spec.ts", "e2e:ide-release-blockers": "playwright test e2e/ide-release-blockers.spec.ts", "e2e:ide-world-class": "playwright test e2e/ide-world-class-layout.spec.ts", "e2e:full-traversal": "playwright test --config=playwright.full-traversal.config.ts full-ui-traversal.spec.ts", diff --git a/playwright.config.ts b/playwright.config.ts index 26770ad0..2dd9ff53 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -40,7 +40,7 @@ export default defineConfig({ ...noMoneyEnv, }, url: 'http://localhost:1420', - reuseExistingServer: false, + reuseExistingServer: process.env.MORE_AI_REUSE_EXISTING_SERVER === '1', timeout: 120 * 1000, }, }); diff --git a/scripts/build-release.ps1 b/scripts/build-release.ps1 index 895519d0..bbc8a71c 100644 --- a/scripts/build-release.ps1 +++ b/scripts/build-release.ps1 @@ -10,19 +10,27 @@ # 4. Zips to src-tauri/resources/python_env.zip # 5. Verifies / refreshes optional managed runtimes # 6. Runs tauri build with release resources injected via --config +# and packages the selected Windows installer profile # -# The resulting MSI/EXE installer is self-contained: -# users need no internet after install except to download AI model weights. +# The Application NSIS installer is self-contained for core app runtimes. +# The Supplemental EXE carries optional heavyweight local acceleration/browser +# payloads: Playwright browser assets and NVIDIA CUDA BitNet/llama.cpp DLLs. +# The FullOffline profile is preserved for diagnostics only after v1.10 testing +# showed that a ~2.3 GB MSI can fail on another Windows machine. # -# PyTorch CUDA upgrade happens at onboarding if NVIDIA GPU detected - -# that's intentional (hardware-specific, 2GB+, not practical to bundle). +# Application keeps heavyweight GPU/browser payloads out of the main installer. # ============================================================ param( [switch]$CleanPythonCache, [switch]$PythonOnly, [switch]$MusicOnly, - [switch]$UseExistingRuntimeZips + [switch]$UseExistingRuntimeZips, + [switch]$IncludePlaywrightBrowsers, + [switch]$IncludeCudaBitnet, + [ValidateSet("Standard", "Application", "Supplemental", "FullOffline")] + [string]$PackageProfile = "Standard", + [string]$BundleTargets = "" ) # Use Continue so native exe stderr does not throw in PS5.1 @@ -149,6 +157,165 @@ function Ensure-EmbeddedPip { New-Item -ItemType Directory -Force -Path $Cache | Out-Null New-Item -ItemType Directory -Force -Path $ResourceDir | Out-Null +$IsApplicationProfile = $PackageProfile -eq "Application" -or $PackageProfile -eq "Standard" +$IsSupplementalProfile = $PackageProfile -eq "Supplemental" +$IsFullOfflineProfile = $PackageProfile -eq "FullOffline" +$EffectiveIncludePlaywrightBrowsers = $IncludePlaywrightBrowsers -or $IsFullOfflineProfile +$EffectiveIncludeCudaBitnet = $IncludeCudaBitnet -or $IsFullOfflineProfile + +if ($IsSupplementalProfile) { + Write-Host "Release package profile: Supplemental" -ForegroundColor Cyan + Write-Host "Building More AI Supplemental only: NVIDIA CUDA BitNet DLLs and Playwright browsers." -ForegroundColor Cyan + & "$PSScriptRoot\build-supplemental-installer.ps1" + exit $LASTEXITCODE +} + +if ([string]::IsNullOrWhiteSpace($BundleTargets)) { + $ResolvedBundleTargets = if ($IsFullOfflineProfile) { "msi" } else { "nsis" } +} else { + $ResolvedBundleTargets = $BundleTargets.Trim().ToLowerInvariant() +} + +$AllowedBundleTargets = @("nsis", "msi", "msi,nsis") +if ($AllowedBundleTargets -notcontains $ResolvedBundleTargets) { + Write-Host "Unsupported BundleTargets '$BundleTargets'. Allowed values: nsis, msi, msi,nsis." -ForegroundColor Red + exit 1 +} + +if ($IsFullOfflineProfile -and $ResolvedBundleTargets -ne "msi") { + Write-Host "FullOffline packages the oversized offline payload through split embedded CAB MSI packaging. Use BundleTargets 'msi' for this profile." -ForegroundColor Red + exit 1 +} + +Write-Host "Release package profile: $PackageProfile" -ForegroundColor Cyan +Write-Host "Bundle targets: $ResolvedBundleTargets" -ForegroundColor Cyan +if ($IsApplicationProfile) { + Write-Host "Application profile builds the main More AI Application installer without Playwright/CUDA supplemental payloads." -ForegroundColor Cyan +} +if ($IsFullOfflineProfile) { + Write-Host "FullOffline includes Playwright browsers and NVIDIA CUDA BitNet runtime files. This profile is diagnostic only; prefer Application + Supplemental." -ForegroundColor Yellow +} + +function Convert-FullOfflineWixSource { + param( + [string]$SourcePath, + [string]$DestinationPath + ) + + if (-not (Test-Path $SourcePath)) { + throw "Generated WiX source is missing: $SourcePath" + } + + $utf8NoBom = New-Object System.Text.UTF8Encoding $false + $wxs = [System.IO.File]::ReadAllText($SourcePath) + $singleCab = ' ' + if ($wxs.IndexOf($singleCab, [StringComparison]::Ordinal) -lt 0) { + throw "Generated WiX source does not contain the expected single-CAB media row." + } + + $splitCab = @( + ' ', + ' ', + ' ', + ' ', + ' ' + ) -join [Environment]::NewLine + $wxs = $wxs.Replace($singleCab, $splitCab) + + $diskAssignments = @( + @{ Name = "bitnet_engine.zip"; DiskId = "2" }, + @{ Name = "python_env.zip"; DiskId = "3" }, + @{ Name = "python_music_env.zip"; DiskId = "4" }, + @{ Name = "playwright_browsers.zip"; DiskId = "5" }, + @{ Name = "ffmpeg.exe"; DiskId = "5" } + ) + + foreach ($assignment in $diskAssignments) { + $fileName = [regex]::Escape($assignment.Name) + $pattern = "(]*Source=""[^""]*$fileName"")(?=[^>]*(?:/>|>)))([^>]*)(/>)" + $diskId = $assignment.DiskId + if (-not ([regex]::IsMatch($wxs, $pattern))) { + throw "Unable to find WiX File row for $($assignment.Name)." + } + $wxs = [regex]::Replace( + $wxs, + $pattern, + { + param($match) + if ($match.Groups[2].Value -match '\bDiskId=') { + return $match.Value + } + return $match.Groups[1].Value + $match.Groups[2].Value + ' DiskId="' + $diskId + '" ' + $match.Groups[3].Value + }, + 1 + ) + $verifyPattern = "]*Source=""[^""]*$fileName"")(?=[^>]*DiskId=""$diskId"")" + if (-not ([regex]::IsMatch($wxs, $verifyPattern))) { + throw "Unable to assign WiX DiskId $diskId to $($assignment.Name)." + } + } + + [System.IO.File]::WriteAllText($DestinationPath, $wxs, $utf8NoBom) +} + +function Invoke-FullOfflineSplitCabMsi { + param( + [DateTime]$BuildStartedAt + ) + + $tauriConfigPath = Join-Path $Root "src-tauri\tauri.conf.json" + $tauriConfig = Get-Content -LiteralPath $tauriConfigPath -Raw | ConvertFrom-Json + $productName = [string]$tauriConfig.productName + $productVersion = [string]$tauriConfig.version + + $wixDir = Join-Path $Root "src-tauri\target\release\wix\x64" + $sourceWxs = Join-Path $wixDir "main.wxs" + $splitWxs = Join-Path $wixDir "main.fulloffline-splitcab.wxs" + $splitObj = Join-Path $wixDir "main.fulloffline-splitcab.wixobj" + $localeWxl = Join-Path $wixDir "locale.wxl" + $bundleMsiDir = Join-Path $Root "src-tauri\target\release\bundle\msi" + $finalMsi = Join-Path $bundleMsiDir "${productName}_${productVersion}_x64_en-US.msi" + + if (-not (Test-Path $sourceWxs)) { + throw "Tauri did not generate the WiX source needed for FullOffline MSI packaging." + } + $sourceInfo = Get-Item -LiteralPath $sourceWxs + if ($sourceInfo.LastWriteTime -lt $BuildStartedAt.AddSeconds(-2)) { + throw "Generated WiX source is stale; refusing to package FullOffline MSI from an older build." + } + if (-not (Test-Path $localeWxl)) { + throw "Tauri did not generate the WiX locale file needed for FullOffline MSI packaging." + } + + $wixToolDir = Join-Path $env:LOCALAPPDATA "tauri\WixTools314" + $candleExe = Join-Path $wixToolDir "candle.exe" + $lightExe = Join-Path $wixToolDir "light.exe" + if (-not (Test-Path $candleExe)) { + throw "WiX candle.exe is missing: $candleExe" + } + if (-not (Test-Path $lightExe)) { + throw "WiX light.exe is missing: $lightExe" + } + + New-Item -ItemType Directory -Force -Path $bundleMsiDir | Out-Null + Convert-FullOfflineWixSource -SourcePath $sourceWxs -DestinationPath $splitWxs + + Write-Host " Building FullOffline split-CAB MSI..." -ForegroundColor Cyan + & $candleExe -nologo -arch x64 -ext WixUIExtension -out $splitObj $splitWxs + if ($LASTEXITCODE -ne 0) { + throw "WiX candle.exe failed while compiling FullOffline split-CAB MSI." + } + + & $lightExe -nologo -spdb -ext WixUIExtension -cultures:en-us -loc $localeWxl -out $finalMsi $splitObj + if ($LASTEXITCODE -ne 0) { + throw "WiX light.exe failed while linking FullOffline split-CAB MSI." + } + + $finalMB = [math]::Round((Get-Item -LiteralPath $finalMsi).Length / 1MB, 0) + Write-Host " FullOffline split-CAB MSI ready (${finalMB} MB)." -ForegroundColor Green + return $finalMsi +} + if ($CleanPythonCache -and $UseExistingRuntimeZips) { Write-Host "CleanPythonCache cannot be used with UseExistingRuntimeZips." -ForegroundColor Red exit 1 @@ -221,7 +388,12 @@ if ($UseExistingRuntimeZips) { Write-Host " python_env.zip present ($(Get-SizeMB $ZipOut) MB)." -ForegroundColor Green Write-Host " python_music_env.zip present ($(Get-SizeMB $MusicZipOut) MB)." -ForegroundColor Green if (Test-Path $PlaywrightZipOut) { - Write-Host " playwright_browsers.zip present ($(Get-SizeMB $PlaywrightZipOut) MB)." -ForegroundColor Green + $playwrightMode = if ($EffectiveIncludePlaywrightBrowsers) { + "will be bundled" + } else { + "available; skipped by default to keep the NSIS installer under payload limits" + } + Write-Host " playwright_browsers.zip present ($(Get-SizeMB $PlaywrightZipOut) MB) - $playwrightMode." -ForegroundColor Green } else { Write-Host " WARNING: playwright_browsers.zip is missing; browser automation will require guided setup." -ForegroundColor Yellow } @@ -505,6 +677,61 @@ if (-not (Test-Path $BitNetExe)) { Write-Host "[6/7] BitNet engine already cached (${SizeMB} MB)." -ForegroundColor Green } +$BitNetBundleSource = $null +$BitNetBundleDestination = "bitnet_engine" +$BitNetStagingDir = "$Root\src-tauri\target\release\release-resources\bitnet_engine" +$BitNetZipBundleOut = "$Root\src-tauri\target\release\release-resources\bitnet_engine.zip" +$CudaBitNetDlls = @("ggml-cuda.dll", "cublas64_12.dll", "cublasLt64_12.dll", "cudart64_12.dll") +if (Test-Path $BitNetExe) { + $stagingParent = Split-Path -Parent $BitNetStagingDir + New-Item -ItemType Directory -Force -Path $stagingParent | Out-Null + if (Test-Path $BitNetStagingDir) { + Remove-Item -LiteralPath $BitNetStagingDir -Recurse -Force + } + New-Item -ItemType Directory -Force -Path $BitNetStagingDir | Out-Null + + $bitnetRoot = (Resolve-Path -LiteralPath $BitNetDir).Path + $copiedBytes = [int64]0 + $skippedCudaBytes = [int64]0 + $copiedCount = 0 + $skippedCudaCount = 0 + $bitnetFiles = Get-ChildItem -LiteralPath $BitNetDir -Recurse -File + foreach ($bitnetFile in $bitnetFiles) { + $relative = $bitnetFile.FullName.Substring($bitnetRoot.Length) + while ($relative.StartsWith("\") -or $relative.StartsWith("/")) { + $relative = $relative.Substring(1) + } + if ((-not $EffectiveIncludeCudaBitnet) -and ($CudaBitNetDlls -contains $bitnetFile.Name)) { + $skippedCudaBytes += [int64]$bitnetFile.Length + $skippedCudaCount += 1 + } else { + $destPath = Join-Path $BitNetStagingDir $relative + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $destPath) | Out-Null + Copy-Item -LiteralPath $bitnetFile.FullName -Destination $destPath -Force + $copiedBytes += [int64]$bitnetFile.Length + $copiedCount += 1 + } + } + $BitNetBundleSource = "target/release/release-resources/bitnet_engine" + $copiedMB = [math]::Round($copiedBytes / 1MB, 1) + Write-Host " Staged BitNet release engine ($copiedCount files, ${copiedMB} MB)." -ForegroundColor Green + if ($skippedCudaCount -gt 0) { + $skippedMB = [math]::Round($skippedCudaBytes / 1MB, 1) + Write-Host " Skipped CUDA BitNet DLLs by default ($skippedCudaCount files, ${skippedMB} MB). Use -IncludeCudaBitnet only for a separate oversized CUDA package." -ForegroundColor Yellow + } + if ($IsFullOfflineProfile) { + if (Test-Path $BitNetZipBundleOut) { + Remove-Item -LiteralPath $BitNetZipBundleOut -Force + } + Write-Host " Compressing FullOffline BitNet/CUDA runtime payload..." -ForegroundColor Cyan + Compress-Archive -Path "$BitNetStagingDir\*" -DestinationPath $BitNetZipBundleOut -CompressionLevel Optimal + $zipMB = [math]::Round((Get-Item -LiteralPath $BitNetZipBundleOut).Length / 1MB, 1) + $BitNetBundleSource = "target/release/release-resources/bitnet_engine.zip" + $BitNetBundleDestination = "bitnet_engine.zip" + Write-Host " FullOffline BitNet runtime zip ready (${zipMB} MB)." -ForegroundColor Green + } +} + # -- Step 7: Run Tauri build with resources injected via --config ------------ # tauri.conf.json does NOT reference python_env.zip or bitnet_engine (so dev # mode never needs them). Write config to a temp file — PS5.1 strips quotes @@ -513,8 +740,8 @@ Write-Host "[7/7] Running tauri build (injecting bundled resources)..." -Foregro Set-Location $Root $TempConfig = "$env:TEMP\tauri-release-config.json" -$hasBitNet = Test-Path $BitNetExe -$hasPlaywright = Test-Path $PlaywrightZipOut +$hasBitNet = ($null -ne $BitNetBundleSource) +$hasPlaywright = $EffectiveIncludePlaywrightBrowsers -and (Test-Path $PlaywrightZipOut) $resources = @{} @@ -543,8 +770,11 @@ Add-BundleResource "resources/model_manifest.fallback.json" "model_manifest.fall Add-BundleResource "resources/dependency_manifest.json" "dependency_manifest.json" $true Add-BundleResource "resources/bee_templates.json" "bee_templates.json" $true Add-BundleResource "resources/shell_skills.json" "shell_skills.json" $true -if ($hasBitNet) { Add-BundleResource "resources/bitnet_engine" "bitnet_engine" $false } +if ($hasBitNet) { Add-BundleResource $BitNetBundleSource $BitNetBundleDestination $false } if ($hasPlaywright) { Add-BundleResource "resources/playwright_browsers.zip" "playwright_browsers.zip" $false } +if ((-not $EffectiveIncludePlaywrightBrowsers) -and (Test-Path $PlaywrightZipOut)) { + Write-Host " Optional release resource skipped by default: resources/playwright_browsers.zip. Use -IncludePlaywrightBrowsers for a separate offline browser-automation package." -ForegroundColor Yellow +} Add-BundleResource "resources/MicrosoftEdgeWebView2RuntimeInstallerX64.exe" "MicrosoftEdgeWebView2RuntimeInstallerX64.exe" $false Add-BundleResource "resources/MicrosoftEdgeWebview2Setup.exe" "MicrosoftEdgeWebview2Setup.exe" $false Add-BundleResource "resources/ffmpeg" "ffmpeg" $false @@ -560,15 +790,49 @@ $resJson = ($resources.Keys | ForEach-Object { "`"$_`":`"$($resources[$_])`"" }) $configJson = "{`"bundle`":{`"resources`":{$resJson}}}" [System.IO.File]::WriteAllText($TempConfig, $configJson, (New-Object System.Text.UTF8Encoding $false)) -npx tauri build --config $TempConfig +$BuildStartedAt = Get-Date +npx tauri build --bundles $ResolvedBundleTargets --config $TempConfig if ($LASTEXITCODE -ne 0) { - Write-Host "Tauri build failed" -ForegroundColor Red; exit 1 + if ($IsFullOfflineProfile -and $ResolvedBundleTargets -eq "msi") { + Write-Host " Tauri MSI packaging failed on the oversized FullOffline payload; attempting split-CAB MSI packaging from the generated WiX source." -ForegroundColor Yellow + } else { + Write-Host "Tauri build failed" -ForegroundColor Red; exit 1 + } +} + +if ($IsFullOfflineProfile -and $ResolvedBundleTargets -eq "msi") { + try { + Invoke-FullOfflineSplitCabMsi -BuildStartedAt $BuildStartedAt | Out-Null + } catch { + Write-Host "FullOffline split-CAB MSI packaging failed: $_" -ForegroundColor Red + exit 1 + } } Write-Host "" Write-Host "Build complete. Installer artifacts:" -ForegroundColor Green $BundleDir = Join-Path $Root "src-tauri\target\release\bundle" if (Test-Path $BundleDir) { + if ($IsApplicationProfile) { + $tauriConfigPath = Join-Path $Root "src-tauri\tauri.conf.json" + $tauriConfig = Get-Content -LiteralPath $tauriConfigPath -Raw | ConvertFrom-Json + $version = [string]$tauriConfig.version + $sourceInstaller = Join-Path $BundleDir "nsis\More AI by Trier OS_$version`_x64-setup.exe" + $applicationInstaller = Join-Path $BundleDir "nsis\More AI Application_$version`_x64-setup.exe" + if (Test-Path $sourceInstaller) { + if (Test-Path $applicationInstaller) { + Remove-Item -LiteralPath $applicationInstaller -Force + } + try { + New-Item -ItemType HardLink -Path $applicationInstaller -Target $sourceInstaller | Out-Null + Write-Host " Application installer alias: $applicationInstaller" -ForegroundColor Green + } catch { + Copy-Item -LiteralPath $sourceInstaller -Destination $applicationInstaller -Force + Write-Host " Application installer copy: $applicationInstaller" -ForegroundColor Green + } + } + } + Get-ChildItem $BundleDir -Recurse -Include "*.msi","*.exe" | ForEach-Object { Write-Host " $($_.FullName) ($([math]::Round($_.Length/1MB,0)) MB)" } @@ -609,6 +873,10 @@ if (Test-Path $BundleDir) { $resourceManifest = [ordered]@{ generated_at = $GeneratedAt bundle_dir = $BundleDir + package_profile = $PackageProfile + bundle_targets = $ResolvedBundleTargets + includes_playwright_browsers = [bool]$hasPlaywright + includes_cuda_bitnet = [bool]$EffectiveIncludeCudaBitnet resources = @($resources.Keys | Sort-Object | ForEach-Object { New-ResourceManifestEntry -Source $_ -Destination $resources[$_] }) @@ -634,8 +902,11 @@ if (Test-Path $BundleDir) { $artifactManifest = [ordered]@{ generated_at = $GeneratedAt bundle_dir = $BundleDir - recommended_installer = "nsis_exe" - msi_release_status = "blocked_pending_visual_and_launch_validation" + package_profile = $PackageProfile + bundle_targets = $ResolvedBundleTargets + recommended_installer = if ($IsFullOfflineProfile) { "msi_full_offline_diagnostic_only" } elseif ($IsApplicationProfile) { "more_ai_application_nsis_exe" } else { "nsis_exe" } + supplemental_installer = if ($IsApplicationProfile) { "build with .\scripts\build-release.ps1 -PackageProfile Supplemental" } else { $null } + msi_release_status = if ($IsFullOfflineProfile) { "diagnostic_only_failed_external_install_validation" } else { "not_primary_release_path" } artifacts = $artifactRows } $artifactManifestPath = Join-Path $BundleDir "release-artifacts.json" diff --git a/scripts/build-supplemental-installer.ps1 b/scripts/build-supplemental-installer.ps1 new file mode 100644 index 00000000..bb9b5aff --- /dev/null +++ b/scripts/build-supplemental-installer.ps1 @@ -0,0 +1,235 @@ +# scripts/build-supplemental-installer.ps1 +# ============================================================ +# Builds the optional More AI Supplemental installer. +# +# The primary More AI Application installer carries the app, Python runtimes, +# FFmpeg/WebView2 resources, and the non-CUDA BitNet/llama.cpp engine. +# This supplemental package carries the large optional local payloads that are +# useful but too large/risky for the main installer: +# - NVIDIA CUDA BitNet/llama.cpp DLLs +# - Playwright browser automation runtime +# ============================================================ + +param( + [string]$Version = "", + [switch]$SkipPackage +) + +$ErrorActionPreference = "Stop" + +$Root = Split-Path $PSScriptRoot -Parent +$ResourceDir = Join-Path $Root "src-tauri\resources" +$BitNetDir = Join-Path $ResourceDir "bitnet_engine" +$PlaywrightZip = Join-Path $ResourceDir "playwright_browsers.zip" +$BundleDir = Join-Path $Root "src-tauri\target\release\bundle\supplemental" +$TempRoot = [System.IO.Path]::GetTempPath() +$WorkDir = Join-Path $TempRoot "moreai-supplemental-build" +$PayloadDir = Join-Path $WorkDir "payload" +$CudaZip = Join-Path $PayloadDir "bitnet_cuda_payload.zip" +$InstallCmd = Join-Path $PayloadDir "install_supplemental.cmd" +$InstallPs1 = Join-Path $PayloadDir "install_supplemental.ps1" +$ReadmePath = Join-Path $PayloadDir "README.txt" +$PayloadZip = Join-Path $WorkDir "supplemental_payload.zip" +$StubSource = Join-Path $Root "scripts\supplemental_stub.rs" +$StubExe = Join-Path $WorkDir "more-ai-supplemental-stub.exe" + +if ([string]::IsNullOrWhiteSpace($Version)) { + $tauriConfigPath = Join-Path $Root "src-tauri\tauri.conf.json" + $tauriConfig = Get-Content -LiteralPath $tauriConfigPath -Raw | ConvertFrom-Json + $Version = [string]$tauriConfig.version +} + +New-Item -ItemType Directory -Force -Path $BundleDir | Out-Null +if (Test-Path $WorkDir) { + Remove-Item -LiteralPath $WorkDir -Recurse -Force +} +New-Item -ItemType Directory -Force -Path $PayloadDir | Out-Null + +if (-not (Test-Path $PlaywrightZip)) { + throw "Missing Playwright browser runtime zip: $PlaywrightZip" +} + +if (-not (Test-Path $BitNetDir)) { + throw "Missing BitNet engine directory: $BitNetDir" +} + +$CudaDllNames = @( + "ggml-cuda.dll", + "cublas64_12.dll", + "cublasLt64_12.dll", + "cudart64_12.dll" +) + +$CudaDlls = @() +foreach ($name in $CudaDllNames) { + $path = Join-Path $BitNetDir $name + if (-not (Test-Path $path)) { + throw "Missing NVIDIA BitNet runtime file: $path" + } + $CudaDlls += (Get-Item -LiteralPath $path) +} + +Copy-Item -LiteralPath $PlaywrightZip -Destination (Join-Path $PayloadDir "playwright_browsers.zip") -Force +Compress-Archive -Path ($CudaDlls | ForEach-Object { $_.FullName }) -DestinationPath $CudaZip -CompressionLevel Optimal + +$utf8NoBom = New-Object System.Text.UTF8Encoding $false + +$installCmdText = @' +@echo off +setlocal +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0install_supplemental.ps1" +exit /b %ERRORLEVEL% +'@ +[System.IO.File]::WriteAllText($InstallCmd, $installCmdText, $utf8NoBom) + +$installPs1Text = @' +$ErrorActionPreference = "Stop" + +$payloadRoot = Split-Path -Parent $PSCommandPath +$appDataRoot = Join-Path $env:APPDATA "com.trieross.more-ai" +$logsDir = Join-Path $appDataRoot "logs" +$moreAiHome = Join-Path $env:USERPROFILE ".more_ai" +$bitnetDir = Join-Path $moreAiHome "bitnet_engine" +$playwrightDir = Join-Path $appDataRoot "playwright_browsers" +$bitnetZip = Join-Path $payloadRoot "bitnet_cuda_payload.zip" +$playwrightZip = Join-Path $payloadRoot "playwright_browsers.zip" +$manifestPath = Join-Path $appDataRoot "supplemental-install.json" +$logPath = Join-Path $logsDir "supplemental-install.log" + +New-Item -ItemType Directory -Force -Path $logsDir | Out-Null +New-Item -ItemType Directory -Force -Path $bitnetDir | Out-Null +New-Item -ItemType Directory -Force -Path $appDataRoot | Out-Null + +"$(Get-Date -Format o) Starting More AI Supplemental install." | Out-File -LiteralPath $logPath -Encoding utf8 + +if (-not (Test-Path $bitnetZip)) { + throw "Missing payload file: $bitnetZip" +} +if (-not (Test-Path $playwrightZip)) { + throw "Missing payload file: $playwrightZip" +} + +Expand-Archive -LiteralPath $bitnetZip -DestinationPath $bitnetDir -Force + +if (Test-Path $playwrightDir) { + Remove-Item -LiteralPath $playwrightDir -Recurse -Force +} +New-Item -ItemType Directory -Force -Path $playwrightDir | Out-Null +Expand-Archive -LiteralPath $playwrightZip -DestinationPath $playwrightDir -Force + +$manifest = [ordered]@{ + installed_at = (Get-Date).ToUniversalTime().ToString("o") + bitnet_cuda_dir = $bitnetDir + playwright_browsers_dir = $playwrightDir + bitnet_cuda_payload_sha256 = (Get-FileHash -LiteralPath $bitnetZip -Algorithm SHA256).Hash + playwright_payload_sha256 = (Get-FileHash -LiteralPath $playwrightZip -Algorithm SHA256).Hash +} +$manifest | ConvertTo-Json -Depth 4 | Out-File -LiteralPath $manifestPath -Encoding utf8 + +"$(Get-Date -Format o) More AI Supplemental install complete." | Out-File -LiteralPath $logPath -Encoding utf8 -Append +'@ +[System.IO.File]::WriteAllText($InstallPs1, $installPs1Text, $utf8NoBom) + +$readmeText = @" +More AI Supplemental $Version + +This optional package installs: +- NVIDIA CUDA BitNet/llama.cpp runtime DLLs into %USERPROFILE%\.more_ai\bitnet_engine +- Playwright browser automation runtime into %APPDATA%\com.trieross.more-ai\playwright_browsers + +Install More AI Application first for the app, Python runtimes, FFmpeg/WebView2 resources, and the bundled non-CUDA BitNet engine. +"@ +[System.IO.File]::WriteAllText($ReadmePath, $readmeText, $utf8NoBom) + +$targetName = Join-Path $BundleDir "More AI Supplemental_$Version`_x64-setup.exe" +$magic = [System.Text.Encoding]::ASCII.GetBytes("MOREAI_SUPP_V1!!") + +if ($SkipPackage) { + Write-Host "Supplemental payload staged at $PayloadDir" -ForegroundColor Green + exit 0 +} + +if (-not (Test-Path $StubSource)) { + throw "Missing supplemental installer stub source: $StubSource" +} + +$rustc = (Get-Command rustc -ErrorAction SilentlyContinue) +if ($null -eq $rustc) { + throw "rustc was not found. Install Rust or run the normal release build environment before building More AI Supplemental." +} + +if (Test-Path $PayloadZip) { + Remove-Item -LiteralPath $PayloadZip -Force +} +Compress-Archive -Path (Join-Path $PayloadDir "*") -DestinationPath $PayloadZip -CompressionLevel Fastest + +& $rustc.Source $StubSource "-O" "-o" $StubExe +if ($LASTEXITCODE -ne 0) { + throw "rustc failed while building More AI Supplemental installer stub." +} + +$payloadLength = (Get-Item -LiteralPath $PayloadZip).Length +$lengthBytes = [System.BitConverter]::GetBytes([Int64]$payloadLength) + +if (Test-Path $targetName) { + Remove-Item -LiteralPath $targetName -Force +} + +$outStream = [System.IO.File]::Open($targetName, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None) +try { + $stubStream = [System.IO.File]::OpenRead($StubExe) + try { + $stubStream.CopyTo($outStream) + } finally { + $stubStream.Dispose() + } + + $payloadStream = [System.IO.File]::OpenRead($PayloadZip) + try { + $payloadStream.CopyTo($outStream) + } finally { + $payloadStream.Dispose() + } + + $outStream.Write($lengthBytes, 0, $lengthBytes.Length) + $outStream.Write($magic, 0, $magic.Length) +} finally { + $outStream.Dispose() +} + +$artifact = Get-Item -LiteralPath $targetName +$hash = (Get-FileHash -LiteralPath $artifact.FullName -Algorithm SHA256).Hash.ToUpperInvariant() +$manifest = [ordered]@{ + generated_at = (Get-Date).ToUniversalTime().ToString("o") + package = "More AI Supplemental" + version = $Version + artifact = $artifact.FullName + size_bytes = [int64]$artifact.Length + size_mb = [math]::Round($artifact.Length / 1MB, 2) + sha256 = $hash + package_format = "rust_sfx_with_appended_zip" + payloads = @( + [ordered]@{ + name = "bitnet_cuda_payload.zip" + size_bytes = [int64](Get-Item -LiteralPath $CudaZip).Length + sha256 = (Get-FileHash -LiteralPath $CudaZip -Algorithm SHA256).Hash.ToUpperInvariant() + }, + [ordered]@{ + name = "supplemental_payload.zip" + size_bytes = [int64](Get-Item -LiteralPath $PayloadZip).Length + sha256 = (Get-FileHash -LiteralPath $PayloadZip -Algorithm SHA256).Hash.ToUpperInvariant() + }, + [ordered]@{ + name = "playwright_browsers.zip" + size_bytes = [int64](Get-Item -LiteralPath (Join-Path $PayloadDir "playwright_browsers.zip")).Length + sha256 = (Get-FileHash -LiteralPath (Join-Path $PayloadDir "playwright_browsers.zip") -Algorithm SHA256).Hash.ToUpperInvariant() + } + ) +} +$manifestPath = Join-Path $BundleDir "more-ai-supplemental-artifact.json" +[System.IO.File]::WriteAllText($manifestPath, ($manifest | ConvertTo-Json -Depth 6), $utf8NoBom) + +Write-Host "More AI Supplemental ready:" -ForegroundColor Green +Write-Host " $($artifact.FullName) ($([math]::Round($artifact.Length / 1MB, 0)) MB)" -ForegroundColor Green +Write-Host " SHA-256: $hash" -ForegroundColor Green +Write-Host " Manifest: $manifestPath" -ForegroundColor Green diff --git a/scripts/download-bitnet-engine.ps1 b/scripts/download-bitnet-engine.ps1 index 40b56f6e..0783d7d9 100644 --- a/scripts/download-bitnet-engine.ps1 +++ b/scripts/download-bitnet-engine.ps1 @@ -1,37 +1,106 @@ # scripts/download-bitnet-engine.ps1 # ============================================================ -# DEV-MODE HELPER — downloads llama-server.exe (CPU build) -# from the latest ggerganov/llama.cpp release into -# src-tauri/resources/bitnet_engine/ so that dev builds -# (npm run tauri dev) detect the engine as installed without -# needing to build the full installer. +# DEV-MODE HELPER - downloads llama-server.exe from the latest +# ggerganov/llama.cpp release into src-tauri/resources/bitnet_engine. # -# llama.cpp mainline supports BitNet b1.58 (TQ1_0/TQ2_0) -# out of the box — no special fork required. -# -# Run this once after cloning or when you want to update the -# engine binary in your dev environment. +# Use -Force to refresh an existing engine. +# Use -InstallLocal to also update ~/.more_ai/bitnet_engine, which is the +# runtime path the installed/dev app prefers when it already exists. # ============================================================ +param( + [switch]$Force, + [switch]$InstallLocal +) + $ErrorActionPreference = "Continue" -$Root = Split-Path $PSScriptRoot -Parent -$ResourceDir = "$Root\src-tauri\resources" -$BitNetDir = "$ResourceDir\bitnet_engine" -$BitNetExe = "$BitNetDir\llama-server.exe" -$Cache = "$Root\.build-cache" -$BitNetZip = "$Cache\bitnet_engine.zip" +$Root = Split-Path $PSScriptRoot -Parent +$ResourceDir = "$Root\src-tauri\resources" +$BitNetDir = "$ResourceDir\bitnet_engine" +$BitNetExe = "$BitNetDir\llama-server.exe" +$LocalBitNetDir = "$env:USERPROFILE\.more_ai\bitnet_engine" +$LocalBitNetExe = "$LocalBitNetDir\llama-server.exe" +$Cache = "$Root\.build-cache" +$BitNetZip = "$Cache\bitnet_engine.zip" New-Item -ItemType Directory -Force -Path $Cache | Out-Null New-Item -ItemType Directory -Force -Path $ResourceDir | Out-Null -if (Test-Path $BitNetExe) { +if ((Test-Path $BitNetExe) -and -not $Force) { $SizeMB = [math]::Round((Get-Item $BitNetExe).Length / 1MB, 1) Write-Host "[BitNet] Engine already present: llama-server.exe (${SizeMB} MB)" -ForegroundColor Green - Write-Host "[BitNet] Delete '$BitNetDir' to force a fresh download." -ForegroundColor Gray + Write-Host "[BitNet] Re-run with -Force to download a fresh engine." -ForegroundColor Gray exit 0 } +function Test-WinX64Zip { + param([string]$Name) + return ($Name -match "win" -and $Name -match "x64|amd64" -and $Name -match "\.zip$") +} + +function Test-CudaAsset { + param([string]$Name) + return ((Test-WinX64Zip $Name) -and $Name -match "^llama-" -and $Name -match "cuda|cublas|cu12" -and $Name -notmatch "cudart|hip|rocm") +} + +function Test-CudaRuntimeAsset { + param([string]$Name) + return ((Test-WinX64Zip $Name) -and $Name -match "cudart|cuda-runtime|cublas") +} + +function Test-VulkanAsset { + param([string]$Name) + return ((Test-WinX64Zip $Name) -and $Name -match "vulkan") +} + +function Test-CpuAsset { + param([string]$Name) + return ((Test-WinX64Zip $Name) -and $Name -match "cpu|avx2|avx" -and $Name -notmatch "cuda|vulkan|hip|rocm|sycl|cudart") +} + +function Get-CudaMarker { + param([string]$Name) + if ($Name -match "(cu\d+(?:\.\d+)?)") { + return $Matches[1] + } + if ($Name -match "cuda-(\d+(?:\.\d+)?)") { + return "cuda-$($Matches[1])" + } + return $null +} + +function Expand-LlamaZip { + param( + [string]$ZipPath, + [string[]]$TargetDirs + ) + + $zip = [System.IO.Compression.ZipFile]::OpenRead($ZipPath) + $extracted = @() + try { + foreach ($entry in $zip.Entries) { + $name = $entry.Name.ToLower() + $isServer = ($name -eq "llama-server.exe" -or $name -eq "server.exe") -and $name -notmatch "rpc" + $isDll = $name -match "\.dll$" -and $entry.Name -ne "" + if (-not $isServer -and -not $isDll) { continue } + if ($entry.FullName -match "/$") { continue } + + $destName = if ($isServer) { "llama-server.exe" } else { $entry.Name } + foreach ($targetDir in $TargetDirs) { + New-Item -ItemType Directory -Force -Path $targetDir | Out-Null + $destPath = "$targetDir\$destName" + [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $destPath, $true) + } + $extracted += $destName + Write-Host "[BitNet] $destName" -ForegroundColor Gray + } + } finally { + $zip.Dispose() + } + return $extracted +} + Write-Host "[BitNet] Fetching latest llama.cpp release..." -ForegroundColor Cyan try { @@ -45,10 +114,36 @@ try { $tagName = $releaseInfo.tag_name Write-Host "[BitNet] Latest llama.cpp: $tagName" -ForegroundColor Gray - # Priority: win-cpu-x64 > win-avx2-x64 > any win x64 (no CUDA/Vulkan/HIP) - $winAsset = $releaseInfo.assets | - Where-Object { $_.name -match "win" -and $_.name -match "cpu" -and $_.name -match "x64" -and $_.name -match "\.zip$" } | - Select-Object -First 1 + $gpuName = $null + $hasNvidia = $false + try { + $gpuName = (& nvidia-smi --query-gpu=name --format=csv,noheader 2>$null | Select-Object -First 1) + if (-not [string]::IsNullOrWhiteSpace($gpuName)) { + $hasNvidia = $true + Write-Host "[BitNet] NVIDIA GPU detected: $gpuName" -ForegroundColor Green + } + } catch { + $hasNvidia = $false + } + + $winAsset = $null + if ($hasNvidia) { + $winAsset = $releaseInfo.assets | + Where-Object { Test-CudaAsset $_.name } | + Select-Object -First 1 + } + + if (-not $winAsset) { + $winAsset = $releaseInfo.assets | + Where-Object { Test-VulkanAsset $_.name } | + Select-Object -First 1 + } + + if (-not $winAsset) { + $winAsset = $releaseInfo.assets | + Where-Object { $_.name -match "win" -and $_.name -match "cpu" -and $_.name -match "x64" -and $_.name -match "\.zip$" } | + Select-Object -First 1 + } if (-not $winAsset) { $winAsset = $releaseInfo.assets | @@ -58,53 +153,60 @@ try { if (-not $winAsset) { $winAsset = $releaseInfo.assets | - Where-Object { $_.name -match "win" -and $_.name -match "x64" -and $_.name -match "\.zip$" -and $_.name -notmatch "cuda|vulkan|hip|sycl|cudart" } | + Where-Object { Test-CpuAsset $_.name } | Select-Object -First 1 } if (-not $winAsset) { $names = ($releaseInfo.assets | ForEach-Object { $_.name }) -join ", " - Write-Host "[BitNet] No Windows CPU zip found. Available: $names" -ForegroundColor Red + Write-Host "[BitNet] No Windows llama.cpp zip found. Available: $names" -ForegroundColor Red exit 1 } - Write-Host "[BitNet] Downloading $($winAsset.name)..." -ForegroundColor Cyan - Invoke-WebRequest -Uri $winAsset.browser_download_url -OutFile $BitNetZip -UseBasicParsing + $targetDirs = @($BitNetDir) + if ($InstallLocal) { + $targetDirs += $LocalBitNetDir + } - New-Item -ItemType Directory -Force -Path $BitNetDir | Out-Null + $selectedAssets = @($winAsset) + if (Test-CudaAsset $winAsset.name) { + $marker = Get-CudaMarker $winAsset.name + $runtimeAssets = $releaseInfo.assets | + Where-Object { + $_.name -ne $winAsset.name -and + (Test-CudaRuntimeAsset $_.name) -and + ((-not $marker) -or $_.name -match [regex]::Escape($marker)) + } + foreach ($runtimeAsset in $runtimeAssets) { + $selectedAssets += $runtimeAsset + } + } - $zip = [System.IO.Compression.ZipFile]::OpenRead($BitNetZip) $extracted = @() - foreach ($entry in $zip.Entries) { - $name = $entry.Name.ToLower() - # Extract llama-server.exe and required DLLs, skip subdirectories - # Only match the main inference server (llama-server.exe / server.exe). - # Explicitly exclude llama-rpc-server.exe — it has a different CLI and - # would overwrite the real binary if both are in the zip. - $isServer = ($name -eq "llama-server.exe" -or $name -eq "server.exe") -and $name -notmatch "rpc" - $isDll = $name -match "\.dll$" -and $entry.Name -ne "" - if (-not $isServer -and -not $isDll) { continue } - if ($entry.FullName -match "/$") { continue } # skip directory entries - - $destName = if ($isServer) { "llama-server.exe" } else { $entry.Name } - $destPath = "$BitNetDir\$destName" - [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $destPath, $true) - $extracted += $destName - Write-Host "[BitNet] $destName" -ForegroundColor Gray + foreach ($asset in $selectedAssets) { + Write-Host "[BitNet] Downloading $($asset.name)..." -ForegroundColor Cyan + Invoke-WebRequest -Uri $asset.browser_download_url -OutFile $BitNetZip -UseBasicParsing + $extracted += Expand-LlamaZip -ZipPath $BitNetZip -TargetDirs $targetDirs + Remove-Item $BitNetZip -Force -ErrorAction SilentlyContinue } - $zip.Dispose() - Remove-Item $BitNetZip -Force -ErrorAction SilentlyContinue if (Test-Path $BitNetExe) { - # Write version.json so the in-app update checker has a baseline $utf8NoBom = New-Object System.Text.UTF8Encoding $false [System.IO.File]::WriteAllText("$BitNetDir\version.json", "{`"version`":`"$tagName`",`"installed_at`":`"`"}", $utf8NoBom) + if ($InstallLocal -and (Test-Path $LocalBitNetDir)) { + [System.IO.File]::WriteAllText("$LocalBitNetDir\version.json", + "{`"version`":`"$tagName`",`"installed_at`":`"`"}", $utf8NoBom) + } $SizeMB = [math]::Round((Get-Item $BitNetExe).Length / 1MB, 1) Write-Host "" Write-Host "[BitNet] Engine ready: llama-server.exe ${SizeMB} MB" -ForegroundColor Green Write-Host "[BitNet] Source: $($winAsset.name)" -ForegroundColor Gray + if ($InstallLocal -and (Test-Path $LocalBitNetExe)) { + $LocalSizeMB = [math]::Round((Get-Item $LocalBitNetExe).Length / 1MB, 1) + Write-Host "[BitNet] Local app engine updated: $LocalBitNetExe (${LocalSizeMB} MB)" -ForegroundColor Green + } Write-Host "[BitNet] Dev builds will now detect the engine automatically." -ForegroundColor Green Write-Host "[BitNet] Restart 'npm run tauri dev' to pick up the change." -ForegroundColor Yellow } else { diff --git a/scripts/live_ide_model_matrix_smoke.cjs b/scripts/live_ide_model_matrix_smoke.cjs new file mode 100644 index 00000000..e9d34226 --- /dev/null +++ b/scripts/live_ide_model_matrix_smoke.cjs @@ -0,0 +1,344 @@ +#!/usr/bin/env node +// Tiny live IDE model matrix smoke test. +// - Never prints API keys. +// - Writes only small proof artifacts to the requested output directory. +// - Exercises provider-boundary behavior that the mocked UI tests cannot prove. + +const fs = require('node:fs/promises'); +const path = require('node:path'); + +const args = process.argv.slice(2); +const outArgIndex = args.findIndex(arg => arg === '--out'); +const outputDir = outArgIndex >= 0 && args[outArgIndex + 1] + ? args[outArgIndex + 1] + : path.join(process.cwd(), 'test-results', 'live-ide-model-matrix'); + +const OLLAMA_BASE = process.env.OLLAMA_BASE_URL || 'http://127.0.0.1:11434'; +const LOCAL_MODEL_CANDIDATES = [ + 'granite4.1:8b', + 'qwen3.5:9b', + 'qwen3:14b', + 'qwen:latest', +]; +const OPENAI_MODEL_CANDIDATES = [ + process.env.OPENAI_IDE_TEST_MODEL, + 'gpt-5.5', + 'gpt-5.4-mini', + 'gpt-4.1-mini', + 'gpt-4o-mini', +].filter(Boolean); +const DEEPSEEK_MODEL_CANDIDATES = [ + process.env.DEEPSEEK_IDE_TEST_MODEL, + 'deepseek-v4-flash', + 'deepseek-chat', +].filter(Boolean); + +async function ensureDir(dir) { + await fs.mkdir(dir, { recursive: true }); +} + +async function writeArtifact(fileName, content) { + const safeName = path.basename(fileName); + const fullPath = path.join(outputDir, safeName); + await fs.writeFile(fullPath, content, 'utf8'); + return fullPath; +} + +async function fetchJson(url, options, timeoutMs = 120000) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { ...options, signal: controller.signal }); + const text = await response.text(); + let json = null; + try { json = text ? JSON.parse(text) : null; } catch {} + if (!response.ok) { + const preview = text.slice(0, 320).replace(/[A-Za-z0-9_-]{32,}/g, '[redacted-token-like-value]'); + throw new Error(`HTTP ${response.status}: ${preview}`); + } + return json; + } finally { + clearTimeout(timeout); + } +} + +function extractFileBlock(text) { + const match = String(text || '').match(/\[FILE:\s*([^\]\r\n]+)\]\s*```[a-zA-Z0-9_-]*\s*([\s\S]*?)```/); + if (!match) return null; + return { + path: match[1].trim(), + content: match[2].trimEnd() + '\n', + }; +} + +function parseToolCall(message) { + const call = message?.tool_calls?.[0]; + if (!call?.function) return null; + const rawArgs = call.function.arguments; + const parsedArgs = typeof rawArgs === 'string' ? JSON.parse(rawArgs) : rawArgs; + return { + name: call.function.name, + path: String(parsedArgs.path || ''), + content: String(parsedArgs.content || ''), + }; +} + +async function getOllamaModels() { + const json = await fetchJson(`${OLLAMA_BASE}/api/tags`, { method: 'GET' }, 20000); + return new Set((json?.models || []).map(model => model.name)); +} + +async function pickLocalModel() { + const installed = await getOllamaModels(); + return LOCAL_MODEL_CANDIDATES.find(name => installed.has(name)) || null; +} + +async function runLocalChat(localModel) { + const prompt = [ + 'You are in More AI IDE Chat Coding mode.', + 'Return exactly one complete file block and no extra commentary:', + '[FILE: local_chat_probe.js]', + '```js', + 'export const localChatProbe = "LOCAL_CHAT_OK";', + '```', + ].join('\n'); + const json = await fetchJson(`${OLLAMA_BASE}/api/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: localModel, + prompt, + stream: false, + keep_alive: '5m', + options: { temperature: 0, num_predict: 220 }, + }), + }); + const block = extractFileBlock(json?.response); + if (!block || !block.content.includes('LOCAL_CHAT_OK')) { + throw new Error('Local chat model did not return the expected [FILE] block.'); + } + const artifact = await writeArtifact('local_chat_probe_response.md', json.response); + return { ok: true, model: localModel, artifact }; +} + +async function runLocalTool(localModel) { + const json = await fetchJson(`${OLLAMA_BASE}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: localModel, + stream: false, + messages: [ + { role: 'user', content: 'Use the available tool to write local_tool_probe.txt containing exactly LOCAL_TOOL_OK.' }, + ], + tools: [{ + type: 'function', + function: { + name: 'write_file', + description: 'Write UTF-8 text to a workspace-relative file.', + parameters: { + type: 'object', + properties: { + path: { type: 'string' }, + content: { type: 'string' }, + }, + required: ['path', 'content'], + }, + }, + }], + options: { temperature: 0, num_predict: 220 }, + }), + }); + const tool = parseToolCall(json?.message); + if (!tool || tool.name !== 'write_file' || !tool.content.includes('LOCAL_TOOL_OK')) { + throw new Error('Local model did not return the expected write_file tool call.'); + } + const artifact = await writeArtifact(tool.path || 'local_tool_probe.txt', tool.content); + return { ok: true, model: localModel, artifact }; +} + +async function callOpenAiCompatible({ provider, baseUrl, apiKey, model, messages, tools }) { + const payload = { + model, + messages, + max_completion_tokens: provider === 'openai' ? 220 : undefined, + max_tokens: provider === 'openai' ? undefined : 220, + temperature: provider === 'openai' ? undefined : 0, + tools, + tool_choice: tools ? 'auto' : undefined, + }; + Object.keys(payload).forEach(key => payload[key] === undefined && delete payload[key]); + return await fetchJson(`${baseUrl.replace(/\/$/, '')}/chat/completions`, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); +} + +async function runApiChat(provider, baseUrl, apiKey, modelCandidates) { + let lastError = null; + for (const model of modelCandidates) { + try { + const json = await callOpenAiCompatible({ + provider, + baseUrl, + apiKey, + model, + messages: [{ + role: 'user', + content: [ + 'You are in More AI IDE Chat Coding mode.', + 'Return exactly one complete file block and no extra commentary:', + '[FILE: api_chat_probe.js]', + '```js', + 'export const apiChatProbe = "API_CHAT_OK";', + '```', + ].join('\n'), + }], + }); + const text = json?.choices?.[0]?.message?.content || ''; + const block = extractFileBlock(text); + if (!block || !block.content.includes('API_CHAT_OK')) { + throw new Error('API chat model did not return the expected [FILE] block.'); + } + const artifact = await writeArtifact(`${provider}_chat_probe_response.md`, text); + return { ok: true, provider, model, artifact }; + } catch (error) { + lastError = error; + } + } + throw lastError || new Error(`${provider} chat smoke failed.`); +} + +async function runApiTool(provider, baseUrl, apiKey, modelCandidates) { + let lastError = null; + for (const model of modelCandidates) { + try { + const json = await callOpenAiCompatible({ + provider, + baseUrl, + apiKey, + model, + messages: [{ + role: 'user', + content: `Use the available tool to write ${provider}_tool_probe.txt containing exactly API_TOOL_OK.`, + }], + tools: [{ + type: 'function', + function: { + name: 'write_file', + description: 'Write UTF-8 text to a workspace-relative file.', + parameters: { + type: 'object', + properties: { + path: { type: 'string' }, + content: { type: 'string' }, + }, + required: ['path', 'content'], + additionalProperties: false, + }, + }, + }], + }); + const message = json?.choices?.[0]?.message; + const tool = parseToolCall(message); + if (!tool || tool.name !== 'write_file' || !tool.content.includes('API_TOOL_OK')) { + throw new Error('API model did not return the expected write_file tool call.'); + } + const artifact = await writeArtifact(tool.path || `${provider}_tool_probe.txt`, tool.content); + return { ok: true, provider, model, artifact }; + } catch (error) { + lastError = error; + } + } + throw lastError || new Error(`${provider} tool smoke failed.`); +} + +async function record(label, fn) { + try { + const result = await fn(); + return { label, status: 'pass', ...result }; + } catch (error) { + return { label, status: 'fail', error: String(error.message || error) }; + } +} + +async function main() { + await ensureDir(outputDir); + const results = []; + const localModel = await pickLocalModel().catch(() => null); + + if (localModel) { + results.push(await record('local_chat_coding', () => runLocalChat(localModel))); + results.push(await record('local_tool_call_coding', () => runLocalTool(localModel))); + } else { + results.push({ label: 'local_chat_coding', status: 'skip', error: 'No preferred local Ollama coding model installed.' }); + results.push({ label: 'local_tool_call_coding', status: 'skip', error: 'No preferred local Ollama coding model installed.' }); + } + + const openAiKey = process.env.OPENAI_API_KEY; + let openAiChat = null; + let openAiTool = null; + if (openAiKey) { + openAiChat = await record('api_openai_chat_coding', () => + runApiChat('openai', 'https://api.openai.com/v1', openAiKey, OPENAI_MODEL_CANDIDATES)); + openAiTool = await record('api_openai_tool_call_coding', () => + runApiTool('openai', 'https://api.openai.com/v1', openAiKey, OPENAI_MODEL_CANDIDATES)); + results.push(openAiChat, openAiTool); + } else { + results.push({ label: 'api_openai_chat_coding', status: 'skip', error: 'OPENAI_API_KEY not available.' }); + results.push({ label: 'api_openai_tool_call_coding', status: 'skip', error: 'OPENAI_API_KEY not available.' }); + } + + const deepSeekKey = process.env.DEEPSEEK_API_KEY || process.env.MORE_AI_DEEPSEEK_API_KEY; + if (deepSeekKey) { + results.push(await record('api_deepseek_chat_coding', () => + runApiChat('deepseek', 'https://api.deepseek.com/v1', deepSeekKey, DEEPSEEK_MODEL_CANDIDATES))); + results.push(await record('api_deepseek_tool_call_coding', () => + runApiTool('deepseek', 'https://api.deepseek.com/v1', deepSeekKey, DEEPSEEK_MODEL_CANDIDATES))); + } else { + results.push({ label: 'api_deepseek_chat_coding', status: 'skip', error: 'No DeepSeek key environment name available.' }); + results.push({ label: 'api_deepseek_tool_call_coding', status: 'skip', error: 'No DeepSeek key environment name available.' }); + } + + const localChat = results.find(r => r.label === 'local_chat_coding'); + const localTool = results.find(r => r.label === 'local_tool_call_coding'); + results.push({ + label: 'mixed_manual_chat_local_plus_api', + status: localChat?.status === 'pass' && openAiChat?.status === 'pass' ? 'pass' : 'skip', + local_status: localChat?.status, + api_status: openAiChat?.status, + note: 'Provider-boundary proof for More AI manual Chat Coding parallel local+API route.', + }); + results.push({ + label: 'mixed_tool_swarm_local_plus_api', + status: localTool?.status === 'pass' && openAiTool?.status === 'pass' ? 'pass' : 'skip', + local_status: localTool?.status, + api_status: openAiTool?.status, + note: 'Provider-boundary proof for Conductor-style local+API tool worker mix.', + }); + + const summaryPath = await writeArtifact('live_ide_model_matrix_summary.json', JSON.stringify({ + generated_at: new Date().toISOString(), + output_dir: outputDir, + local_model: localModel, + results, + }, null, 2)); + + for (const item of results) { + const extra = item.model ? ` (${item.model})` : ''; + console.log(`${item.status.toUpperCase()} ${item.label}${extra}`); + } + console.log(`SUMMARY ${summaryPath}`); + + const hardFailures = results.filter(r => r.status === 'fail'); + if (hardFailures.length > 0) process.exitCode = 1; +} + +main().catch(error => { + console.error(String(error.message || error)); + process.exit(1); +}); diff --git a/scripts/start-devmode-isolated.ps1 b/scripts/start-devmode-isolated.ps1 new file mode 100644 index 00000000..90e3be15 --- /dev/null +++ b/scripts/start-devmode-isolated.ps1 @@ -0,0 +1,9 @@ +param( + [string]$AppDataDir = "G:\More AI by Trier OS\.dev-app-data" +) + +$ErrorActionPreference = "Stop" +Set-Location -LiteralPath "G:\More AI by Trier OS" +New-Item -ItemType Directory -Force -Path $AppDataDir | Out-Null +$env:MOREAI_DB_APP_DATA_DIR = $AppDataDir +npm run tauri -- dev diff --git a/scripts/supplemental_stub.rs b/scripts/supplemental_stub.rs new file mode 100644 index 00000000..873a2c94 --- /dev/null +++ b/scripts/supplemental_stub.rs @@ -0,0 +1,189 @@ +use std::env; +use std::fs::{self, File}; +use std::io::{self, Read, Seek, SeekFrom, Write}; +use std::path::Path; +use std::process::{self, Command}; + +const MAGIC: &[u8; 16] = b"MOREAI_SUPP_V1!!"; +const TRAILER_LEN: u64 = 24; + +fn main() { + if let Err(err) = run() { + eprintln!("More AI Supplemental install failed: {err}"); + message_box("More AI Supplemental", &format!("Install failed:\n\n{err}")); + process::exit(1); + } + + message_box( + "More AI Supplemental", + "More AI Supplemental installed successfully.", + ); +} + +fn run() -> Result<(), String> { + let current_exe = + env::current_exe().map_err(|err| format!("Could not locate installer EXE: {err}"))?; + let temp_root = env::temp_dir().join(format!("MoreAISupplemental-{}", process::id())); + let payload_zip = temp_root.join("supplemental_payload.zip"); + let payload_dir = temp_root.join("payload"); + + if temp_root.exists() { + fs::remove_dir_all(&temp_root) + .map_err(|err| format!("Could not clear temporary folder {temp_root:?}: {err}"))?; + } + fs::create_dir_all(&payload_dir) + .map_err(|err| format!("Could not create temporary folder {payload_dir:?}: {err}"))?; + + extract_appended_payload(¤t_exe, &payload_zip)?; + expand_archive(&payload_zip, &payload_dir)?; + + let install_script = payload_dir.join("install_supplemental.ps1"); + if !install_script.exists() { + return Err(format!( + "Payload did not contain installer script: {}", + install_script.display() + )); + } + + let status = Command::new("powershell.exe") + .arg("-NoProfile") + .arg("-ExecutionPolicy") + .arg("Bypass") + .arg("-File") + .arg(&install_script) + .status() + .map_err(|err| format!("Could not start PowerShell installer: {err}"))?; + + if !status.success() { + return Err(format!( + "PowerShell installer exited with code {:?}", + status.code() + )); + } + + Ok(()) +} + +fn extract_appended_payload(exe_path: &Path, output_path: &Path) -> Result<(), String> { + let mut exe = File::open(exe_path) + .map_err(|err| format!("Could not open installer EXE {}: {err}", exe_path.display()))?; + let exe_len = exe + .metadata() + .map_err(|err| format!("Could not read installer metadata: {err}"))? + .len(); + + if exe_len < TRAILER_LEN { + return Err("Installer payload trailer is missing.".to_string()); + } + + exe.seek(SeekFrom::End(-(MAGIC.len() as i64))) + .map_err(|err| format!("Could not seek to payload marker: {err}"))?; + let mut marker = [0u8; 16]; + exe.read_exact(&mut marker) + .map_err(|err| format!("Could not read payload marker: {err}"))?; + if &marker != MAGIC { + return Err("Installer payload marker is invalid.".to_string()); + } + + exe.seek(SeekFrom::End(-(TRAILER_LEN as i64))) + .map_err(|err| format!("Could not seek to payload length: {err}"))?; + let mut len_bytes = [0u8; 8]; + exe.read_exact(&mut len_bytes) + .map_err(|err| format!("Could not read payload length: {err}"))?; + let payload_len = u64::from_le_bytes(len_bytes); + let payload_start = exe_len + .checked_sub(TRAILER_LEN) + .and_then(|offset| offset.checked_sub(payload_len)) + .ok_or_else(|| "Installer payload length is invalid.".to_string())?; + + exe.seek(SeekFrom::Start(payload_start)) + .map_err(|err| format!("Could not seek to payload start: {err}"))?; + let mut output = File::create(output_path).map_err(|err| { + format!( + "Could not create temporary payload {}: {err}", + output_path.display() + ) + })?; + + copy_exact(&mut exe, &mut output, payload_len) + .map_err(|err| format!("Could not extract payload: {err}"))?; + + Ok(()) +} + +fn copy_exact(input: &mut File, output: &mut File, mut remaining: u64) -> io::Result<()> { + let mut buffer = vec![0u8; 1024 * 1024]; + while remaining > 0 { + let read_len = remaining.min(buffer.len() as u64) as usize; + input.read_exact(&mut buffer[..read_len])?; + output.write_all(&buffer[..read_len])?; + remaining -= read_len as u64; + } + output.flush()?; + Ok(()) +} + +fn expand_archive(zip_path: &Path, destination: &Path) -> Result<(), String> { + if !zip_path.exists() { + return Err(format!( + "Temporary supplemental payload zip was not created: {}", + zip_path.display() + )); + } + + let command = format!( + "Expand-Archive -LiteralPath {} -DestinationPath {} -Force", + powershell_single_quoted_path(zip_path), + powershell_single_quoted_path(destination) + ); + + let status = Command::new("powershell.exe") + .arg("-NoProfile") + .arg("-ExecutionPolicy") + .arg("Bypass") + .arg("-Command") + .arg(&command) + .status() + .map_err(|err| format!("Could not start PowerShell extraction: {err}"))?; + + if !status.success() { + return Err(format!( + "PowerShell extraction exited with code {:?} while expanding {} to {}", + status.code(), + zip_path.display(), + destination.display() + )); + } + + Ok(()) +} + +fn powershell_single_quoted_path(path: &Path) -> String { + let escaped = path.as_os_str().to_string_lossy().replace('\'', "''"); + format!("'{escaped}'") +} + +#[cfg(windows)] +fn message_box(title: &str, text: &str) { + use std::ffi::OsStr; + use std::os::windows::ffi::OsStrExt; + + #[link(name = "user32")] + extern "system" { + fn MessageBoxW( + hwnd: *mut std::ffi::c_void, + lp_text: *const u16, + lp_caption: *const u16, + u_type: u32, + ) -> i32; + } + + let title: Vec = OsStr::new(title).encode_wide().chain(Some(0)).collect(); + let text: Vec = OsStr::new(text).encode_wide().chain(Some(0)).collect(); + unsafe { + MessageBoxW(std::ptr::null_mut(), text.as_ptr(), title.as_ptr(), 0x0000_0040); + } +} + +#[cfg(not(windows))] +fn message_box(_title: &str, _text: &str) {} diff --git a/scripts/test_bee_voice_contract.cjs b/scripts/test_bee_voice_contract.cjs index 61f4b88d..207ca087 100644 --- a/scripts/test_bee_voice_contract.cjs +++ b/scripts/test_bee_voice_contract.cjs @@ -14,6 +14,14 @@ const talkToSelector = fs.readFileSync(path.join(root, 'src', 'components', 'Tal const talkToSelectorCss = fs.readFileSync(path.join(root, 'src', 'components', 'TalkToSelector.css'), 'utf8'); const tauriLib = fs.readFileSync(path.join(root, 'src-tauri', 'src', 'lib.rs'), 'utf8'); const beeTts = fs.readFileSync(path.join(root, 'src-tauri', 'src', 'commands', 'bee_tts.rs'), 'utf8'); +const runtimeDependencies = fs.readFileSync( + path.join(root, 'src-tauri', 'src', 'commands', 'runtime_dependencies.rs'), + 'utf8', +); +const dependencyManifest = fs.readFileSync( + path.join(root, 'src-tauri', 'resources', 'dependency_manifest.json'), + 'utf8', +); const voiceControlCenter = fs.readFileSync( path.join(root, 'src', 'components', 'VoiceControlCenter.tsx'), 'utf8', @@ -61,6 +69,10 @@ assert.match(beeStrip, /markTtsStartupStatus/); assert.match(beeStrip, /dugbee:tts-ready/); assert.match(beeStrip, /dugbee:tts-fallback/); assert.match(beeStrip, /dugbee:tts-retry-local/); +assert.match(beeStrip, /LOCAL_TTS_UNAVAILABLE_ENGINE_LABEL/); +assert.doesNotMatch(beeStrip, /SpeechSynthesisUtterance|speechSynthesis\.speak|WEB_SPEECH_TTS_ENGINE_LABEL|falling back to Web Speech/); +assert.doesNotMatch(dependencyManifest, /web_speech_fallback|Browser Speech Fallback|emergency TTS fallback/); +assert.doesNotMatch(runtimeDependencies, /web_speech_fallback|Browser\/OS speech fallback/); assert.match(beeStrip, /__moreAiDugBeeTtsStatus/); assert.match(beeStrip, /ttsEngineLabel=\{ttsEngineLabel\}/); assert.match(beeStrip, /startPremiumTtsWarmup/); @@ -90,6 +102,24 @@ assert.match(beeStrip, /rec\.interimResults = true/); assert.match(beeStrip, /rec\.maxAlternatives = 5/); assert.match(beeStrip, /function normalizeDugBeeVoiceTranscript/); assert.match(beeStrip, /getBestSpeechRecognitionText/); +assert.match(beeStrip, /function buildSpeechRecognitionMissMessage/); +assert.match(beeStrip, /function isZeroConfidenceSpeechResult/); +assert.match(beeStrip, /Browser speech recognition guessed/); +assert.match(beeStrip, /Recognizer guessed at 0%/); +assert.match(beeStrip, /Conversation mode was paused because the recognizer keeps returning zero-confidence guesses/); +assert.match(beeStrip, /voiceInputDeviceLabelRef/); +assert.match(beeStrip, /zeroConfidenceSpeechMissesRef/); +assert.match(beeStrip, /VOICE_ASR_MIC_RESET_MISS_THRESHOLD/); +assert.match(beeStrip, /resetBrowserSpeechRecognitionMic/); +assert.match(beeStrip, /More AI is resetting the browser microphone stream now/); +assert.match(beeStrip, /Browser ASR mic stream reset/); +assert.match(beeStrip, /function isLikelyUsefulManualVoiceTranscript/); +assert.match(beeStrip, /reportSpeechRecognitionMiss/); +assert.match(beeStrip, /voice:not understood/); +assert.match(beeStrip, /manual_unclear_asr/); +assert.match(beeStrip, /data-testid="bee-asr-status"/); +assert.match(beeStrip, /data-testid="bee-asr-last-issue"/); +assert.match(beeStripCss, /\.bee-vex--system/); assert.match(beeStrip, /voice:hearing/); assert.match(beeStrip, /const shouldRouteModelTurnToChat = effectiveBrainId !== 'dugbee_local' && !isCoeTurn/); assert.match(beeStrip, /COE-active turns stay in the DugBee\/COE brain lane below instead of the Chat bridge/); diff --git a/scripts/test_coe_documentation_contract.cjs b/scripts/test_coe_documentation_contract.cjs index 5481b25b..2dc31d3a 100644 --- a/scripts/test_coe_documentation_contract.cjs +++ b/scripts/test_coe_documentation_contract.cjs @@ -17,7 +17,10 @@ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); assert.ok(manual.coe, 'English manual must include a dedicated COE chapter'); assert.ok(manualWorkspaceSource.includes("id: 'coe'"), 'Manual navigation must include the COE chapter'); -assert.ok(manualWorkspaceSource.includes('manualEn[c.id as keyof typeof manualEn]'), 'Manual must fall back to English for untranslated COE sections'); +assert.ok( + manualWorkspaceSource.includes("{ ...(await loadManualBody('en')), ...body }"), + 'Manual must fall back to English for untranslated COE sections', +); const coe = manual.coe; const requiredPhrases = [ diff --git a/scripts/test_coe_webview_contract.cjs b/scripts/test_coe_webview_contract.cjs index eea558f3..75e7e13a 100644 --- a/scripts/test_coe_webview_contract.cjs +++ b/scripts/test_coe_webview_contract.cjs @@ -112,8 +112,15 @@ assert.ok(workspaceSource.includes('webview_execute_script'), 'AIWebViewWorkspac assert.ok(workspaceSource.includes('AI_WEBVIEW_OPEN_EVENT'), 'AIWebViewWorkspace must listen for DugBee web shortcut open events'); assert.ok(workspaceSource.includes('consumeQueuedAiWebViewOpen'), 'AIWebViewWorkspace must consume queued DugBee web shortcut requests after tab switching'); assert.ok(workspaceSource.includes('webview_navigate'), 'AIWebViewWorkspace must route DugBee web shortcuts through the WebView2 navigation bridge'); +assert.ok(workspaceSource.includes('tabId: targetTabId'), 'AIWebViewWorkspace must navigate the selected UI tab, not every tab'); +assert.ok(workspaceSource.includes('webview_show_browser'), 'AIWebViewWorkspace must reveal the matching WebView2 window when a tab is selected'); +assert.ok(workspaceSource.includes('payload.tab_id ?? activeTabId'), 'AIWebViewWorkspace must apply browser events to the event tab id'); assert.ok(rustSource.includes('more-ai-coe-webview-dock'), 'Rust WebView must inject the mini COE dock into the pop-out'); +assert.ok(rustSource.includes('.on_new_window'), 'Rust WebView must intercept target=_blank/window.open requests from the pop-out'); +assert.ok(rustSource.includes('NewWindowResponse::Deny'), 'Rust WebView must deny unmanaged extra browser windows after redirecting allowed links'); +assert.ok(rustSource.includes('WEBVIEW_TAB_LABEL_PREFIX'), 'Rust WebView must create stable per-tab browser labels'); +assert.ok(rustSource.includes('webview_label_for_tab'), 'Rust WebView commands must resolve a window label from the tab id'); assert.ok(rustSource.includes('createTreeWalker'), 'Rust page text extraction must walk visible text nodes'); assert.ok(rustSource.includes('input,textarea,select'), 'Rust page text extraction must exclude form controls'); assert.ok(rustSource.includes('password|passcode|card|cc-|credit|payment|billing|token|secret'), 'Rust inspection must filter sensitive fields'); diff --git a/scripts/test_curated_model_manifest_contract.cjs b/scripts/test_curated_model_manifest_contract.cjs index 789991de..ef257e50 100644 --- a/scripts/test_curated_model_manifest_contract.cjs +++ b/scripts/test_curated_model_manifest_contract.cjs @@ -112,6 +112,16 @@ assert.equal( 'supported', 'DeepSeek Coder V2 should remain visible as fallback-capable for IDE work', ); +assert.equal( + byId.get('granite4.1-8b').tool_calling_status, + 'more_ai_tested', + 'Granite 4.1 8B must be surfaced as the tested local IDE Agent recommendation', +); +assert.equal( + byId.get('granite4.1-8b').capability_matrix.native_tool_calling.confidence, + 'more_ai_tested', + 'Granite 4.1 8B native tool calling must carry More AI tested confidence', +); assert.equal( byId.get('flux1-schnell').capability_matrix.image_generation.status, 'supported', @@ -122,6 +132,11 @@ assert.equal( 'supported', 'CogVideoX must remain classified as video generation', ); +assert.equal( + byId.get('wan2.2-ti2v-5b').capability_matrix.video_generation.status, + 'supported', + 'Wan 2.2 TI2V 5B must be classified as the modern beginner video recommendation', +); assert.equal( byId.get('musicgen-small').capability_matrix.music_generation.status, 'supported', @@ -134,6 +149,10 @@ assert.match(tauriConf, /model_manifest\.fallback\.json/, 'Tauri config must bun const releaseScript = read('scripts/build-release.ps1'); assert.match(releaseScript, /model_manifest\.fallback\.json/, 'release build must include the fallback manifest'); +const beginnerSeedMigration = read('src-tauri/migrations/0318_seed_beginner_local_model_recommendations.sql'); +assert.match(beginnerSeedMigration, /granite4\.1-8b/, 'beginner seed migration must insert Granite 4.1 8B'); +assert.match(beginnerSeedMigration, /wan2\.2-ti2v-5b/, 'beginner seed migration must insert Wan 2.2 TI2V 5B'); + const rust = read('src-tauri/src/commands/catalogue.rs'); assert.match(rust, /struct ManifestModelMeta/, 'catalogue backend must define manifest metadata'); assert.match(rust, /load_curated_model_manifest/, 'catalogue backend must load the curated manifest'); diff --git a/scripts/test_governance_pack_contract.cjs b/scripts/test_governance_pack_contract.cjs new file mode 100644 index 00000000..384df843 --- /dev/null +++ b/scripts/test_governance_pack_contract.cjs @@ -0,0 +1,45 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const assert = require('node:assert/strict'); + +const root = path.resolve(__dirname, '..'); +const read = (...parts) => fs.readFileSync(path.join(root, ...parts)); +const readText = (...parts) => read(...parts).toString('utf8'); + +const manifest = JSON.parse(readText('governance', 'HASHES.json')); +const packVersion = readText('governance', 'PACK_VERSION').trim(); + +assert.equal(manifest.pack_version, packVersion, 'PACK_VERSION must match HASHES.json pack_version'); + +const packFiles = { + 'MoreAI Governance.md': ['governance', 'MoreAI Governance.md'], + 'SECURITY.md': ['governance', 'SECURITY.md'], + 'STANDARDS.md': ['governance', 'STANDARDS.md'], + 'DETERMINISM.md': ['governance', 'DETERMINISM.md'], + 'GATES.md': ['governance', 'GATES.md'], + 'LICENSING.md': ['governance', 'LICENSING.md'], + 'PRIVACY.md': ['governance', 'PRIVACY.md'], + 'INCIDENT.md': ['governance', 'INCIDENT.md'], + 'CALIBRATION.md': ['governance', 'CALIBRATION.md'], + 'calibration/pack_v1.json': ['src-tauri', 'calibration', 'pack_v1.json'], +}; + +for (const [manifestName, filePath] of Object.entries(packFiles)) { + const actual = 'sha256:' + crypto.createHash('sha256').update(read(...filePath)).digest('hex'); + assert.equal( + actual, + manifest.files[manifestName], + `${manifestName} hash drifted from governance/HASHES.json`, + ); +} + +const governancePack = readText('src-tauri', 'src', 'commands', 'governance_pack.rs'); +assert.match(governancePack, /Canonical pack files must move together with HASHES\.json/); +assert.doesNotMatch(governancePack, /const ALWAYS_OVERWRITE/); + +const governancePanel = readText('src', 'components', 'Governance', 'GovernancePanel.tsx'); +assert.doesNotMatch(governancePanel, /all 8 pack files/); +assert.match(governancePanel, /all pack files verified/); + +console.log('Governance pack contract passed'); diff --git a/scripts/test_ide_release_blockers_contract.cjs b/scripts/test_ide_release_blockers_contract.cjs index 292e35e8..34ede9c4 100644 --- a/scripts/test_ide_release_blockers_contract.cjs +++ b/scripts/test_ide_release_blockers_contract.cjs @@ -51,6 +51,42 @@ if (!ide.includes('autoApproveCommandsRef.current') || !ide.includes('Auto-appro fail('IDE auto-approve command state must be live-synced and must bypass command approval prompts.'); } +if (!ide.includes('const [agentAllowWrite, setAgentAllowWrite] = useState(true);') + || !ide.includes("type AgentPersona = 'none' | 'auditor';") + || !ide.includes("localStorage.removeItem('agent_persona')") + || !ide.includes("if (agentPersona === 'auditor')") + || !ide.includes('setAgentAllowWrite(false)') + || !ide.includes('setAgentAllowWrite(true)') + || !ide.includes('Audit (read-only)')) { + fail('IDE Agent coding must default to write-enabled none mode, with Audit as the only explicit read-only focus.'); +} + +if (!ide.includes('buildAgentPlainTextFileWrites') + || !ide.includes('inferRequestedFilePathFromText') + || !ide.includes('agent plain-text file fallback') + || !mock.includes('routing-matrix-agent-plain-html')) { + fail('IDE Agent Mode must recover when local models return full file content as plain text instead of calling write_file.'); +} + +if (!ide.includes('resolveOllaGpuLaneCounts') + || !ide.includes('activeIdeAssistantCount') + || !ide.includes('/ ${activeIdeAssistantCount} assistants') + || !ide.includes('localLaneCounts.forEach((count, id)')) { + fail('IDE local swarm activation must preserve assistant lane counts as model instance counts.'); +} + +for (const removedPersona of ['value="architect"', 'value="refactor"', 'value="explainer"']) { + if (ide.includes(removedPersona)) { + fail(`IDE Agent rail must not expose extra persona option ${removedPersona}; coding should stay simple.`); + } +} + +if (!css.includes('.ide-persona-select option') + || !css.includes('.ide-agent-budget-select option') + || !css.includes('color-scheme: dark')) { + fail('IDE Agent select controls must force dark option colors so native dropdowns remain readable.'); +} + if (!ide.includes('allowWrite: agentAllowWrite') || !ide.includes('allowTerminal: agentAllowTerminal') || !ide.includes('maxDepth: 5')) { fail('IDE Orchestra must pass governed full-tooling permission flags into execute_conductor_session.'); } @@ -373,6 +409,10 @@ for (const required of [ 'Tool-calling uncertain model requested', 'Autopilot detected a noisy local-model tool dump', 'modelHasUncertainIdeToolCalling', + "new Set(['not_installed', 'missing', 'unavailable', 'unsupported', 'not_applicable'])", + 'This Agent swarm has no runnable tool agent', + 'Agent Mode needs one runnable tool agent', + 'buildCurrentSwarmMembers', 'Tool-calling uncertain', 'isExplicitAgentContinuationPrompt', 'const shouldUseAgentContinuation', @@ -397,7 +437,7 @@ for (const required of [ "setCenterView('tools')", "setCenterView('plugins')", "format: 'more-ai.workspace-tool.v1'", - "centerView === 'editor' &&
fs.readFileSync(path.join(root, ...parts), 'utf8'); const modelCompletion = read('src-tauri', 'src', 'commands', 'model_completion.rs'); const agentTools = read('src-tauri', 'src', 'commands', 'agent_tools.rs'); +function assertSafeOllamaDefault(source, label) { + const directDefault = /"ollama" => Ok\("http:\/\/127\.0\.0\.1:11434"\.to_string\(\)\)/; + const normalizedDefault = /"ollama" => Ok\(normalize_ollama_native_base_url\(\s*"http:\/\/127\.0\.0\.1:11434",?\s*\)\)/; + assert.ok( + directDefault.test(source) || normalizedDefault.test(source), + `${label} must keep the safe Ollama default`, + ); + + if (normalizedDefault.test(source)) { + assert.match( + source, + /fn normalize_ollama_native_base_url\(endpoint_url: &str\) -> String[\s\S]*"http:\/\/127\.0\.0\.1:11434"\.to_string\(\)/, + `${label} normalized Ollama default must resolve blank endpoints to loopback`, + ); + } +} + for (const [label, source] of [ ['model_completion.rs', modelCompletion], ['agent_tools.rs', agentTools], ]) { assert.match(source, /fn resolve_base_url\(provider_type: &str, endpoint_url: &str\) -> Result/, `${label} must fail closed from its route resolver`); - assert.match(source, /"ollama" => Ok\("http:\/\/127\.0\.0\.1:11434"\.to_string\(\)\)/, `${label} must keep the safe Ollama default`); + assertSafeOllamaDefault(source, label); assert.match(source, /"lm_studio" => Ok\("http:\/\/127\.0\.0\.1:1234\/v1"\.to_string\(\)\)/, `${label} must use the LM Studio local OpenAI-compatible default`); assert.match(source, /"vllm" => Ok\("http:\/\/127\.0\.0\.1:8000\/v1"\.to_string\(\)\)/, `${label} must use the vLLM local OpenAI-compatible default`); assert.match(source, /"llama_cpp" \| "bitnet" => Err\(format!\(/, `${label} must require explicit llama.cpp/BitNet endpoints`); diff --git a/scripts/test_model_management_contract.cjs b/scripts/test_model_management_contract.cjs index c3f9c517..83bf012f 100644 --- a/scripts/test_model_management_contract.cjs +++ b/scripts/test_model_management_contract.cjs @@ -40,6 +40,7 @@ const modelsCommands = read('src-tauri', 'src', 'commands', 'models.rs'); const firstRunBootstrap = read('src-tauri', 'src', 'commands', 'first_run_bootstrap.rs'); const ollamaCommands = read('src-tauri', 'src', 'commands', 'ollama.rs'); const hfDownloadCommands = read('src-tauri', 'src', 'commands', 'hf_download.rs'); +const bitnetServerCommands = read('src-tauri', 'src', 'commands', 'bitnet_server.rs'); const hardwareScanCommands = read('src-tauri', 'src', 'commands', 'hardware_scan.rs'); const tauriLib = read('src-tauri', 'src', 'lib.rs'); const tauriCommandsMod = read('src-tauri', 'src', 'commands', 'mod.rs'); @@ -243,6 +244,16 @@ assert.match(ollamaCommands, /pub async fn cancel_ollama_pull/); assert.match(ollamaCommands, /OLLAMA_PULL_CANCEL_REQUESTS/); assert.match(hfDownloadCommands, /pub async fn cancel_bitnet_gguf_download/); assert.match(hfDownloadCommands, /BITNET_GGUF_CANCEL_REQUESTS/); +assert.match(bitnetServerCommands, /LARGE_CPP_GPU_THRESHOLD_B:\s*f32\s*=\s*8\.0/); +assert.match(bitnetServerCommands, /build_llama_cpp_launch_profile/); +assert.match(bitnetServerCommands, /append_llama_cpp_offload_args/); +assert.match(bitnetServerCommands, /--n-gpu-layers/); +assert.match(bitnetServerCommands, /LLAMA_CPP_GPU_OFFLOAD_LAYERS/); +assert.match(bitnetServerCommands, /read_gpu_info\(\)/); +assert.match(bitnetServerCommands, /is_cuda_llama_cpp_asset/); +assert.match(bitnetServerCommands, /is_cuda_runtime_llama_cpp_asset/); +assert.match(bitnetServerCommands, /Downloading CUDA runtime/); +assert.match(bitnetServerCommands, /Installing CUDA-enabled llama\.cpp engine/); assert.match(hardwareScanCommands, /pub struct ModelHardwareFitRequest/); assert.match(hardwareScanCommands, /pub struct ModelHardwareFitResult/); assert.match(hardwareScanCommands, /pub struct SharedHardwareProfile/); diff --git a/scripts/test_model_setup_center_contract.cjs b/scripts/test_model_setup_center_contract.cjs index 7af929b5..8115c309 100644 --- a/scripts/test_model_setup_center_contract.cjs +++ b/scripts/test_model_setup_center_contract.cjs @@ -8,6 +8,7 @@ const read = (...parts) => fs.readFileSync(path.join(root, ...parts), 'utf8'); const readJson = (...parts) => JSON.parse(read(...parts)); const settings = read('src', 'components', 'SettingsWorkspace.tsx'); +const settingsCss = read('src', 'components', 'SettingsWorkspace.css'); const packageJson = readJson('package.json'); assert.equal( @@ -78,6 +79,31 @@ assert.match(settings, /data-testid="model-setup-offline-fallback"/, 'Offline se assert.match(settings, /data-testid="model-setup-import-gguf"/, 'Existing local GGUF import must be exposed from inventory'); assert.match(settings, /data-testid="model-setup-cancel-action"/, 'Cancelable model setup actions must be represented'); assert.match(settings, /data-testid="model-setup-retry-action"/, 'Retry model setup actions must be represented'); +assert.match(settings, /data-testid="model-setup-busy-detail"/, 'Busy setup cards must explain the active step near the clicked action'); +assert.match(settings, /data-testid="model-setup-progress"/, 'Model setup downloads must show visible progress immediately after the user starts one'); +assert.match(settings, /downloadProgressIsKnown/, 'Model setup progress must distinguish known percent from backend-started downloads'); +assert.match(settings, /Starting Hugging Face download/, 'Hugging Face downloads must show a started state before file-level progress arrives'); +assert.match(settings, /Starting local model download/, 'Ollama/local downloads must show a started state before percent progress arrives'); +assert.match(settings, /data-testid="model-setup-error-detail"/, 'Failed downloads must leave a clear visible error on the recommendation card'); +assert.match(settings, /Download did not finish/, 'Failed download messages must use direct user-facing language'); +assert.match(settingsCss, /pull-progress-bar-fill--indeterminate/, 'Progress bars must support an indeterminate active-download state'); +assert.match(settings, /setupBeginnerRecommendationLabel/, 'Beginner setup cards must use job-focused labels instead of raw catalogue names'); +assert.match(settings, /IDE Agent recommendation/, 'IDE setup must label the primary beginner recommendation as an IDE Agent recommendation'); +assert.match(settings, /setupBusyActionLabel\(entry, isBusy, progress\)/, 'Setup buttons must use specific busy labels instead of a generic Working state'); +assert.match(settings, /markCatalogueSetupComplete\(category, entry, modelId\)/, 'Successful setup must clear card-level pull state even if final progress events are missed'); +assert.match(settings, /BEGINNER_RECOMMENDATION_PRIORITY/, 'Beginner setup must keep an explicit curated model priority list'); +assert.match(settings, /granite4\.1-8b/, 'Beginner IDE/code setup must prioritize Granite 4.1 8B for local Agent coding'); +assert.match(settings, /sortCatalogueForBeginnerRecommendations/, 'Catalogue rows must be sorted for beginner hardware recommendations before setup cards select defaults'); +assert.match(settings, /more_ai_tested/, 'Beginner ranking must account for More AI tested local tool-call evidence'); +assert.match(settings, /catalogueRecommendationAvailability/, 'Recommendation cards must detect installed or registered local models before offering setup'); +assert.match(settings, /data-testid="model-setup-already-installed"/, 'Recommendation cards must label already-owned models beside the hardware fit badge'); +assert.match(settings, /You have this model already/, 'Already-owned recommendations must use plain user-facing language'); +assert.match(settings, /!recommendationAvailability\.available && \(/, 'Setup buttons must be hidden when the recommended model is already available locally'); +assert.match(settings, /existingAvailability\.available/, 'The setup handler must guard against stale clicks on already-owned recommendations'); +assert.match(settings, /data-testid="local-llm-runtime-grid"/, 'Local LLM runtime cards must be grouped in a stable resource grid'); +assert.match(settingsCss, /\.local-llm-runtime-grid[\s\S]*grid-template-columns: minmax\(360px, 1fr\) minmax\(360px, 1fr\)/, 'Local LLM lower cards must use two independent columns on desktop'); +assert.match(settingsCss, /\.local-llm-runtime-column[\s\S]*flex-direction: column/, 'Local LLM lower cards must stack independently inside each column'); +assert.doesNotMatch(settings, /setup command finished/, 'Beginner setup status must not expose implementation-shaped command language'); assert.match(settings, /does not expose a safe pause\/cancel control yet/, 'Unsupported pause/cancel must be truthful, not simulated'); assert.match(settings, /No download, model import, runtime repair, or hosted provider setup starts/, 'Setup Center must require explicit user action'); diff --git a/scripts/test_picture_generation_contract.cjs b/scripts/test_picture_generation_contract.cjs index f0cde3f4..de3c5eed 100644 --- a/scripts/test_picture_generation_contract.cjs +++ b/scripts/test_picture_generation_contract.cjs @@ -98,6 +98,10 @@ assert.match(imageEditorCommands, /image_bytes_from_payload/); assert.match(imageEditorCommands, /resolve_model_api_key/); assert.match(imageEditorCommands, /hosted_api/); assert.match(hfDownloadCommands, /hf:download_progress/); +assert.match(hfDownloadCommands, /hf_auth_error_message/); +assert.match(hfDownloadCommands, /Hugging Face rejected the saved token/); +assert.match(hfDownloadCommands, /same account/); +assert.match(hfDownloadCommands, /read access/); assert.match(hfDownloadCommands, /models:updated/); assert.match(tauriLib, /commands::image_editor::generate_image/); assert.match(tauriLib, /commands::image_editor::generate_online_image/); diff --git a/scripts/test_release_packaging_contract.cjs b/scripts/test_release_packaging_contract.cjs index 240e0b62..4cbee27d 100644 --- a/scripts/test_release_packaging_contract.cjs +++ b/scripts/test_release_packaging_contract.cjs @@ -11,6 +11,8 @@ const exists = (...parts) => fs.existsSync(path.join(root, ...parts)); const tauriConf = readJson('src-tauri', 'tauri.conf.json'); const cargoToml = read('src-tauri', 'Cargo.toml'); const releaseScript = read('scripts', 'build-release.ps1'); +const supplementalScript = read('scripts', 'build-supplemental-installer.ps1'); +const supplementalStub = read('scripts', 'supplemental_stub.rs'); const releaseProcess = read('docs', 'RELEASE_BUILD_PROCESS.md'); const buildMetrics = read('docs', 'BUILD_METRICS.md'); const engineeringSpec = read('docs', 'ENGINEERING_SPEC.md'); @@ -19,6 +21,8 @@ const startupRs = read('src-tauri', 'src', 'startup.rs'); const dbRs = read('src-tauri', 'src', 'db.rs'); const dbHealthRs = read('src-tauri', 'src', 'commands', 'db_health.rs'); const voiceRs = read('src-tauri', 'src', 'commands', 'voice.rs'); +const runtimeDependenciesRs = read('src-tauri', 'src', 'commands', 'runtime_dependencies.rs'); +const bitnetServerRs = read('src-tauri', 'src', 'commands', 'bitnet_server.rs'); const voiceWorkspace = read('src', 'components', 'VoiceWorkspace.tsx'); const voiceServer = read('voice_server.py'); const installedStartupSmoke = read('scripts', 'test_installed_db_startup_smoke.ps1'); @@ -114,8 +118,46 @@ for (const optional of [ assert.match(releaseScript, /release-resources-manifest\.json/, 'build-release must generate a release resource manifest'); assert.match(releaseScript, /release-artifacts\.json/, 'build-release must generate release artifact hash/size manifest'); assert.match(releaseScript, /Get-FileHash -LiteralPath \$_.FullName -Algorithm SHA256/, 'installer artifacts must get SHA-256 hashes after build'); -assert.match(releaseScript, /recommended_installer = "nsis_exe"/, 'release artifact manifest must mark NSIS EXE as the current recommended installer'); -assert.match(releaseScript, /msi_release_status = "blocked_pending_visual_and_launch_validation"/, 'MSI status must stay blocked until validated'); +assert.match(releaseScript, /\[ValidateSet\("Standard", "Application", "Supplemental", "FullOffline"\)\]/, 'build-release must expose application, supplemental, and diagnostic full-offline package profiles'); +assert.match(releaseScript, /build-supplemental-installer\.ps1/, 'build-release must route the supplemental profile through the supplemental installer builder'); +assert.match(releaseScript, /\$ResolvedBundleTargets = if \(\$IsFullOfflineProfile\) \{ "msi" \} else \{ "nsis" \}/, 'FullOffline must default to MSI while Standard stays NSIS'); +assert.match(releaseScript, /FullOffline packages the oversized offline payload through split embedded CAB MSI packaging/, 'FullOffline must reject non-MSI targets so the oversized payload does not silently fall back to NSIS'); +assert.match(releaseScript, /\$EffectiveIncludePlaywrightBrowsers = \$IncludePlaywrightBrowsers -or \$IsFullOfflineProfile/, 'FullOffline must include Playwright browsers automatically'); +assert.match(releaseScript, /\$EffectiveIncludeCudaBitnet = \$IncludeCudaBitnet -or \$IsFullOfflineProfile/, 'FullOffline must include CUDA BitNet runtime files automatically'); +assert.match(releaseScript, /More AI Application_\$version`_x64-setup\.exe/, 'Application profile must create a clear More AI Application installer alias'); +assert.match(releaseScript, /bitnet_engine\.zip/, 'FullOffline must compress the BitNet/CUDA runtime as a single bundled resource'); +assert.match(releaseScript, /Compress-Archive -Path "\$BitNetStagingDir\\\*" -DestinationPath \$BitNetZipBundleOut -CompressionLevel Optimal/, 'FullOffline BitNet runtime zip must be compressed before bundling'); +assert.match(releaseScript, /tauri build --bundles \$ResolvedBundleTargets --config \$TempConfig/, 'official release build must use the selected bundle target profile'); +assert.match(releaseScript, /function Convert-FullOfflineWixSource/, 'FullOffline must rewrite generated WiX source for split embedded CAB packaging'); +assert.match(releaseScript, //, 'FullOffline WiX rewrite must put BitNet/CUDA payload in its own embedded CAB'); +assert.match(releaseScript, //, 'FullOffline WiX rewrite must put the main Python payload in its own embedded CAB'); +assert.match(releaseScript, //, 'FullOffline WiX rewrite must put browser automation payloads in their own embedded CAB'); +assert.match(releaseScript, /Invoke-FullOfflineSplitCabMsi/, 'FullOffline build must link the split-CAB MSI after Tauri generates WiX source'); +assert.match(releaseScript, /Tauri MSI packaging failed on the oversized FullOffline payload; attempting split-CAB MSI packaging/, 'FullOffline must recover from Tauri single-CAB MSI payload failures'); +assert.match(releaseScript, /package_profile = \$PackageProfile/, 'release resource manifest must record the package profile'); +assert.match(releaseScript, /includes_playwright_browsers = \[bool\]\$hasPlaywright/, 'release resource manifest must record Playwright inclusion'); +assert.match(releaseScript, /includes_cuda_bitnet = \[bool\]\$EffectiveIncludeCudaBitnet/, 'release resource manifest must record CUDA BitNet inclusion'); +assert.match(releaseScript, /recommended_installer = if \(\$IsFullOfflineProfile\) \{ "msi_full_offline_diagnostic_only" \} elseif \(\$IsApplicationProfile\) \{ "more_ai_application_nsis_exe" \} else \{ "nsis_exe" \}/, 'release artifact manifest must make Application + Supplemental the primary release path'); +assert.match(releaseScript, /diagnostic_only_failed_external_install_validation/, 'MSI status must record that FullOffline failed external install validation'); +assert.match(supplementalScript, /More AI Supplemental_\$Version`_x64-setup\.exe/, 'supplemental builder must produce a named More AI Supplemental EXE'); +assert.match(supplementalScript, /bitnet_cuda_payload\.zip/, 'supplemental builder must package NVIDIA BitNet CUDA DLLs separately'); +assert.match(supplementalScript, /playwright_browsers\.zip/, 'supplemental builder must package Playwright browser automation separately'); +assert.match(supplementalScript, /supplemental_stub\.rs/, 'supplemental builder must use the custom self-extracting EXE stub'); +assert.match(supplementalScript, /supplemental_payload\.zip/, 'supplemental builder must append a single payload zip to the installer stub'); +assert.match(supplementalScript, /MOREAI_SUPP_V1!!/, 'supplemental builder must append a trailer marker for payload extraction'); +assert.match(supplementalStub, /env::current_exe\(\)/, 'supplemental stub must extract the payload from its own EXE'); +assert.doesNotMatch(supplementalStub, /LiteralPath \$args\[0\]/, 'supplemental stub must not rely on PowerShell $args for payload extraction paths'); +assert.match(supplementalStub, /powershell_single_quoted_path/, 'supplemental stub must quote extraction paths before passing them to PowerShell'); +assert.match(supplementalStub, /Expand-Archive -LiteralPath \{\} -DestinationPath \{\} -Force/, 'supplemental stub must expand the appended payload zip without internet access'); +assert.match(supplementalStub, /install_supplemental\.ps1/, 'supplemental stub must run the staged supplemental installer script'); +assert.match(supplementalScript, /%USERPROFILE%\\\.more_ai\\bitnet_engine/, 'supplemental README must document the BitNet install location'); +assert.match(supplementalScript, /%APPDATA%\\com\.trieross\.more-ai\\playwright_browsers/, 'supplemental README must document the Playwright install location'); +assert.match(runtimeDependenciesRs, /Supplemental browser automation runtime is installed/, 'runtime dependency status must recognize supplemental-installed Playwright browsers'); +assert.match(bitnetServerRs, /BITNET_ENGINE_ZIP_NAME/, 'BitNet server must know about the full-offline engine zip resource'); +assert.match(bitnetServerRs, /fn bundled_engine_zip_path/, 'BitNet server must locate the full-offline engine zip resource'); +assert.match(bitnetServerRs, /zip::ZipArchive/, 'BitNet server must extract the bundled engine zip without external tools'); +assert.match(bitnetServerRs, /entry\.enclosed_name\(\)/, 'BitNet zip extraction must reject unsafe archive paths'); +assert.match(bitnetServerRs, /extracted_engine_matches_bundle/, 'BitNet server must refresh extracted engines when packaged runtime resources change'); assert.match(startupRs, /append_native_startup_log/, 'native startup log helper missing'); assert.match(startupRs, /show_native_startup_recovery_dialog/, 'native startup recovery dialog helper missing'); @@ -162,8 +204,11 @@ assert.match(installedStartupSmoke, /Write-SmokeProgress/, 'installed startup sm assert.match(installedStartupSmoke, /Start-Process -FilePath \$ExePath -PassThru -WindowStyle Hidden/, 'installed startup smoke should launch the packaged app hidden because it collects marker evidence'); assert.doesNotMatch(installedStartupSmoke, /WindowStyle Normal/, 'installed startup smoke must not force a visible app window from automation'); -assert.match(releaseProcess, /NSIS EXE is the recommended installer/i, 'release process must document NSIS as the recommended installer'); -assert.match(releaseProcess, /MSI .*blocked/i, 'release process must document MSI as blocked pending validation'); +assert.match(releaseProcess, /Application profile[\s\S]*NSIS/i, 'release process must document the application NSIS profile'); +assert.match(releaseProcess, /Supplemental profile[\s\S]*NVIDIA CUDA[\s\S]*Playwright/i, 'release process must document the supplemental optional runtime package'); +assert.match(releaseProcess, /FullOffline profile[\s\S]*diagnostic/i, 'release process must document the full-offline MSI as diagnostic-only'); +assert.match(releaseProcess, /bitnet_engine\.zip/i, 'release process must document the compressed full-offline BitNet runtime resource'); +assert.match(releaseProcess, /playwright_browsers\.zip/i, 'release process must document Playwright browser inclusion for full-offline builds'); assert.match(releaseProcess, /src-tauri\\resources\\bee_server\.py/, 'release process must document Bee TTS packaged resource'); assert.match(releaseProcess, /%USERPROFILE%\\\.more_ai\\bee/, 'release process must document Bee TTS writable voice asset root'); assert.match(releaseProcess, /frontend-startup-ready\.json/, 'release process must document frontend startup readiness evidence'); @@ -193,4 +238,4 @@ for (const dir of ['src', path.join('src-tauri', 'src')]) { assert.match(engineeringSpec, /compiled EXE\/MSI builds must call the real Tauri commands/i, 'engineering spec must keep the no-mock compiled-build rule'); assert.match(releaseProcess, /No mock or fake release paths/i, 'release process must document the no-mock/no-fake release rule'); -console.log('PASS: release packaging contract covers installer branding, NSIS recommendation/MSI block, WebView2 bootstrapper mode, resource injection, artifact manifests, and native startup recovery logging.'); +console.log('PASS: release packaging contract covers installer branding, standard/full-offline package profiles, WebView2 bootstrapper mode, resource injection, artifact manifests, and native startup recovery logging.'); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 149b8528..1792a1b1 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3351,9 +3351,7 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lopdf" -version = "0.34.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5c8ecfc6c72051981c0459f75ccc585e7ff67c70829560cda8e647882a9abff" +version = "0.42.0" dependencies = [ "chrono", "encoding_rs", @@ -6697,7 +6695,7 @@ dependencies = [ [[package]] name = "tauri-app" -version = "1.0.8" +version = "1.10.0" dependencies = [ "aes-gcm", "argon2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d210d222..197a9edd 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tauri-app" -version = "1.0.8" +version = "1.10.0" description = "More AI by Trier OS — Local-first multi-model AI orchestration IDE" authors = ["Doug Trier"] edition = "2021" @@ -44,7 +44,7 @@ rustls-pki-types = "1.14.1" hex = "0.4.3" sysinfo = "0.38.4" zip = { version = "2", default-features = false, features = ["deflate"] } -lopdf = "0.34" +lopdf = { path = "../vendor/lopdf-0.42.0-security" } regex = "1.12.3" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/src-tauri/migrations/0318_seed_beginner_local_model_recommendations.sql b/src-tauri/migrations/0318_seed_beginner_local_model_recommendations.sql new file mode 100644 index 00000000..0909de51 --- /dev/null +++ b/src-tauri/migrations/0318_seed_beginner_local_model_recommendations.sql @@ -0,0 +1,16 @@ +-- 0318_seed_beginner_local_model_recommendations.sql +-- Adds current beginner-facing local recommendations without mutating older seeds. + +INSERT OR IGNORE INTO model_catalogue + (id, category, label, description, size_label, source, ollama_name, downloads, pinned) +VALUES + ('granite4.1-8b', 'Code Creation', 'Granite 4.1 8B', + 'IBM Granite 4.1 8B - recommended local IDE Agent model for 16 GB VRAM systems after More AI tool-calling validation.', + '~5 GB', 'ollama', 'granite4.1:8b', 0, 1); + +INSERT OR IGNORE INTO model_catalogue + (id, category, label, description, size_label, source, hf_repo, downloads, pinned) +VALUES + ('wan2.2-ti2v-5b', 'Video Generation', 'Wan 2.2 TI2V 5B', + 'Wan 2.2 lightweight text/image-to-video model for consumer GPUs and beginner video tests.', + '~10 GB', 'huggingface', 'Wan-AI/Wan2.2-TI2V-5B', 0, 1); diff --git a/src-tauri/resources/bitnet_engine/ggml-base.dll b/src-tauri/resources/bitnet_engine/ggml-base.dll index 06b90435..30041664 100644 Binary files a/src-tauri/resources/bitnet_engine/ggml-base.dll and b/src-tauri/resources/bitnet_engine/ggml-base.dll differ diff --git a/src-tauri/resources/bitnet_engine/ggml-cpu-alderlake.dll b/src-tauri/resources/bitnet_engine/ggml-cpu-alderlake.dll index 235484fa..05e75667 100644 Binary files a/src-tauri/resources/bitnet_engine/ggml-cpu-alderlake.dll and b/src-tauri/resources/bitnet_engine/ggml-cpu-alderlake.dll differ diff --git a/src-tauri/resources/bitnet_engine/ggml-cpu-cannonlake.dll b/src-tauri/resources/bitnet_engine/ggml-cpu-cannonlake.dll index 0ba5a467..e008dc22 100644 Binary files a/src-tauri/resources/bitnet_engine/ggml-cpu-cannonlake.dll and b/src-tauri/resources/bitnet_engine/ggml-cpu-cannonlake.dll differ diff --git a/src-tauri/resources/bitnet_engine/ggml-cpu-cascadelake.dll b/src-tauri/resources/bitnet_engine/ggml-cpu-cascadelake.dll index 7fe22f49..3679ead9 100644 Binary files a/src-tauri/resources/bitnet_engine/ggml-cpu-cascadelake.dll and b/src-tauri/resources/bitnet_engine/ggml-cpu-cascadelake.dll differ diff --git a/src-tauri/resources/bitnet_engine/ggml-cpu-cooperlake.dll b/src-tauri/resources/bitnet_engine/ggml-cpu-cooperlake.dll index f5578ba9..fda9e65a 100644 Binary files a/src-tauri/resources/bitnet_engine/ggml-cpu-cooperlake.dll and b/src-tauri/resources/bitnet_engine/ggml-cpu-cooperlake.dll differ diff --git a/src-tauri/resources/bitnet_engine/ggml-cpu-haswell.dll b/src-tauri/resources/bitnet_engine/ggml-cpu-haswell.dll index 4a7706a5..a93cbc88 100644 Binary files a/src-tauri/resources/bitnet_engine/ggml-cpu-haswell.dll and b/src-tauri/resources/bitnet_engine/ggml-cpu-haswell.dll differ diff --git a/src-tauri/resources/bitnet_engine/ggml-cpu-icelake.dll b/src-tauri/resources/bitnet_engine/ggml-cpu-icelake.dll index b3b2812b..3bf1190b 100644 Binary files a/src-tauri/resources/bitnet_engine/ggml-cpu-icelake.dll and b/src-tauri/resources/bitnet_engine/ggml-cpu-icelake.dll differ diff --git a/src-tauri/resources/bitnet_engine/ggml-cpu-ivybridge.dll b/src-tauri/resources/bitnet_engine/ggml-cpu-ivybridge.dll index 7b3ff702..e8cdbdda 100644 Binary files a/src-tauri/resources/bitnet_engine/ggml-cpu-ivybridge.dll and b/src-tauri/resources/bitnet_engine/ggml-cpu-ivybridge.dll differ diff --git a/src-tauri/resources/bitnet_engine/ggml-cpu-piledriver.dll b/src-tauri/resources/bitnet_engine/ggml-cpu-piledriver.dll index afb0cbf6..ee57279c 100644 Binary files a/src-tauri/resources/bitnet_engine/ggml-cpu-piledriver.dll and b/src-tauri/resources/bitnet_engine/ggml-cpu-piledriver.dll differ diff --git a/src-tauri/resources/bitnet_engine/ggml-cpu-sandybridge.dll b/src-tauri/resources/bitnet_engine/ggml-cpu-sandybridge.dll index 194ffa66..bd913190 100644 Binary files a/src-tauri/resources/bitnet_engine/ggml-cpu-sandybridge.dll and b/src-tauri/resources/bitnet_engine/ggml-cpu-sandybridge.dll differ diff --git a/src-tauri/resources/bitnet_engine/ggml-cpu-sapphirerapids.dll b/src-tauri/resources/bitnet_engine/ggml-cpu-sapphirerapids.dll index fa886b35..4fb55210 100644 Binary files a/src-tauri/resources/bitnet_engine/ggml-cpu-sapphirerapids.dll and b/src-tauri/resources/bitnet_engine/ggml-cpu-sapphirerapids.dll differ diff --git a/src-tauri/resources/bitnet_engine/ggml-cpu-skylakex.dll b/src-tauri/resources/bitnet_engine/ggml-cpu-skylakex.dll index e9d26fe5..8eeab278 100644 Binary files a/src-tauri/resources/bitnet_engine/ggml-cpu-skylakex.dll and b/src-tauri/resources/bitnet_engine/ggml-cpu-skylakex.dll differ diff --git a/src-tauri/resources/bitnet_engine/ggml-cpu-sse42.dll b/src-tauri/resources/bitnet_engine/ggml-cpu-sse42.dll index c152af69..7c961f29 100644 Binary files a/src-tauri/resources/bitnet_engine/ggml-cpu-sse42.dll and b/src-tauri/resources/bitnet_engine/ggml-cpu-sse42.dll differ diff --git a/src-tauri/resources/bitnet_engine/ggml-cpu-x64.dll b/src-tauri/resources/bitnet_engine/ggml-cpu-x64.dll index 4ec1fffe..a924894f 100644 Binary files a/src-tauri/resources/bitnet_engine/ggml-cpu-x64.dll and b/src-tauri/resources/bitnet_engine/ggml-cpu-x64.dll differ diff --git a/src-tauri/resources/bitnet_engine/ggml-cpu-zen4.dll b/src-tauri/resources/bitnet_engine/ggml-cpu-zen4.dll index 067113df..6fd137ba 100644 Binary files a/src-tauri/resources/bitnet_engine/ggml-cpu-zen4.dll and b/src-tauri/resources/bitnet_engine/ggml-cpu-zen4.dll differ diff --git a/src-tauri/resources/bitnet_engine/ggml-rpc.dll b/src-tauri/resources/bitnet_engine/ggml-rpc.dll index 24b0274f..66d0857a 100644 Binary files a/src-tauri/resources/bitnet_engine/ggml-rpc.dll and b/src-tauri/resources/bitnet_engine/ggml-rpc.dll differ diff --git a/src-tauri/resources/bitnet_engine/ggml-vulkan.dll b/src-tauri/resources/bitnet_engine/ggml-vulkan.dll new file mode 100644 index 00000000..6251079a Binary files /dev/null and b/src-tauri/resources/bitnet_engine/ggml-vulkan.dll differ diff --git a/src-tauri/resources/bitnet_engine/ggml.dll b/src-tauri/resources/bitnet_engine/ggml.dll index a01fdefa..fe5db579 100644 Binary files a/src-tauri/resources/bitnet_engine/ggml.dll and b/src-tauri/resources/bitnet_engine/ggml.dll differ diff --git a/src-tauri/resources/bitnet_engine/libomp140.x86_64.dll b/src-tauri/resources/bitnet_engine/libomp140.x86_64.dll index 7c0dd14f..e45a4213 100644 Binary files a/src-tauri/resources/bitnet_engine/libomp140.x86_64.dll and b/src-tauri/resources/bitnet_engine/libomp140.x86_64.dll differ diff --git a/src-tauri/resources/bitnet_engine/llama-batched-bench-impl.dll b/src-tauri/resources/bitnet_engine/llama-batched-bench-impl.dll new file mode 100644 index 00000000..1c17b1a0 Binary files /dev/null and b/src-tauri/resources/bitnet_engine/llama-batched-bench-impl.dll differ diff --git a/src-tauri/resources/bitnet_engine/llama-bench-impl.dll b/src-tauri/resources/bitnet_engine/llama-bench-impl.dll new file mode 100644 index 00000000..0922dc3f Binary files /dev/null and b/src-tauri/resources/bitnet_engine/llama-bench-impl.dll differ diff --git a/src-tauri/resources/bitnet_engine/llama-cli-impl.dll b/src-tauri/resources/bitnet_engine/llama-cli-impl.dll new file mode 100644 index 00000000..7e84125b Binary files /dev/null and b/src-tauri/resources/bitnet_engine/llama-cli-impl.dll differ diff --git a/src-tauri/resources/bitnet_engine/llama-common.dll b/src-tauri/resources/bitnet_engine/llama-common.dll index 431ce26f..859067db 100644 Binary files a/src-tauri/resources/bitnet_engine/llama-common.dll and b/src-tauri/resources/bitnet_engine/llama-common.dll differ diff --git a/src-tauri/resources/bitnet_engine/llama-completion-impl.dll b/src-tauri/resources/bitnet_engine/llama-completion-impl.dll new file mode 100644 index 00000000..2139f9fd Binary files /dev/null and b/src-tauri/resources/bitnet_engine/llama-completion-impl.dll differ diff --git a/src-tauri/resources/bitnet_engine/llama-fit-params-impl.dll b/src-tauri/resources/bitnet_engine/llama-fit-params-impl.dll new file mode 100644 index 00000000..a706b43a Binary files /dev/null and b/src-tauri/resources/bitnet_engine/llama-fit-params-impl.dll differ diff --git a/src-tauri/resources/bitnet_engine/llama-perplexity-impl.dll b/src-tauri/resources/bitnet_engine/llama-perplexity-impl.dll new file mode 100644 index 00000000..ab3e65da Binary files /dev/null and b/src-tauri/resources/bitnet_engine/llama-perplexity-impl.dll differ diff --git a/src-tauri/resources/bitnet_engine/llama-quantize-impl.dll b/src-tauri/resources/bitnet_engine/llama-quantize-impl.dll new file mode 100644 index 00000000..74d15f88 Binary files /dev/null and b/src-tauri/resources/bitnet_engine/llama-quantize-impl.dll differ diff --git a/src-tauri/resources/bitnet_engine/llama-server-impl.dll b/src-tauri/resources/bitnet_engine/llama-server-impl.dll new file mode 100644 index 00000000..8e807c17 Binary files /dev/null and b/src-tauri/resources/bitnet_engine/llama-server-impl.dll differ diff --git a/src-tauri/resources/bitnet_engine/llama-server.exe b/src-tauri/resources/bitnet_engine/llama-server.exe index b02dd1f1..67e2d737 100644 Binary files a/src-tauri/resources/bitnet_engine/llama-server.exe and b/src-tauri/resources/bitnet_engine/llama-server.exe differ diff --git a/src-tauri/resources/bitnet_engine/llama.dll b/src-tauri/resources/bitnet_engine/llama.dll index c658fedd..409712a7 100644 Binary files a/src-tauri/resources/bitnet_engine/llama.dll and b/src-tauri/resources/bitnet_engine/llama.dll differ diff --git a/src-tauri/resources/bitnet_engine/mtmd.dll b/src-tauri/resources/bitnet_engine/mtmd.dll index b551b995..558a6ef4 100644 Binary files a/src-tauri/resources/bitnet_engine/mtmd.dll and b/src-tauri/resources/bitnet_engine/mtmd.dll differ diff --git a/src-tauri/resources/bitnet_engine/version.json b/src-tauri/resources/bitnet_engine/version.json index 44db55f5..64e37156 100644 --- a/src-tauri/resources/bitnet_engine/version.json +++ b/src-tauri/resources/bitnet_engine/version.json @@ -1 +1 @@ -{"version":"b9165","installed_at":""} \ No newline at end of file +{"version":"b9837","installed_at":""} \ No newline at end of file diff --git a/src-tauri/resources/dependency_manifest.json b/src-tauri/resources/dependency_manifest.json index 591f18f6..fc545f8f 100644 --- a/src-tauri/resources/dependency_manifest.json +++ b/src-tauri/resources/dependency_manifest.json @@ -79,22 +79,7 @@ "setup_action": "start_bee_tts_server", "repair_action": "start_bee_tts_server", "license_notes": "Voice engine and model licenses vary by selected voice asset.", - "missing_user_message": "Start or repair DugBee Local TTS so the polished voice is available before fallback speech." - }, - { - "id": "web_speech_fallback", - "label": "Browser Speech Fallback", - "kind": "voice_fallback", - "category": "voice", - "ownership_mode": "host_platform", - "ownership_label": "Host platform fallback", - "bundled": true, - "managed": false, - "required_for": ["emergency TTS fallback"], - "setup_action": null, - "repair_action": null, - "license_notes": "Uses the OS/browser speech synthesis voices available to the WebView.", - "missing_user_message": "No setup action is needed; this fallback is only used when local TTS is unavailable." + "missing_user_message": "Start or repair DugBee Local TTS so polished spoken replies are available." }, { "id": "ffmpeg_media_tools", diff --git a/src-tauri/resources/model_manifest.fallback.json b/src-tauri/resources/model_manifest.fallback.json index adcf59b2..6f39e1a5 100644 --- a/src-tauri/resources/model_manifest.fallback.json +++ b/src-tauri/resources/model_manifest.fallback.json @@ -345,6 +345,42 @@ }, "license_notes": "DeepSeek model license; review the upstream terms before redistribution." }, + { + "id": "granite4.1-8b", + "runtime_target": "ollama", + "source_url": "https://ollama.com/library/granite4.1", + "download_size_bytes": 5000000000, + "installed_size_bytes": 5400000000, + "quality_tier": "advanced", + "best_for": ["IDE Agent tool calling", "code generation", "structured outputs", "enterprise coding"], + "minimum_ram_mb": 8192, + "recommended_ram_mb": 16384, + "minimum_vram_mb": 8192, + "recommended_vram_mb": 12288, + "minimum_disk_gb": 6, + "creator": "IBM", + "model_family": "Granite 4.1", + "release_date": "2026", + "registry_summary": "Doug-tested 8B local coding and tool-calling model. Recommended as the default beginner IDE Agent model on 16 GB VRAM systems.", + "training_notes": "Granite 4.1 is an IBM open model family tuned for enterprise language, code, structured output, and tool-oriented workflows.", + "ide_tool_mode": "native_tool_calling", + "tool_calling_status": "more_ai_tested", + "tool_calling_notes": "More AI local testing observed Granite 4.1 8B successfully writing files through IDE Agent Mode. Keep exact-route certification evidence attached per install.", + "capability_source_url": "https://ollama.com/library/granite4.1", + "capability_verified_at": "2026-06-28T00:00:00Z", + "capability_matrix": { + "chat": { "status": "supported", "confidence": "operator_observed", "notes": "Can answer local chat and coding prompts." }, + "code_chat": { "status": "supported", "confidence": "operator_observed", "notes": "Observed producing useful small coding artifacts in More AI." }, + "document_chat": { "status": "limited", "confidence": "curated", "notes": "Can draft technical notes, but the recommendation is primarily for IDE coding." }, + "native_tool_calling": { "status": "supported", "confidence": "more_ai_tested", "source_url": "https://ollama.com/library/granite4.1", "verified_at": "2026-06-28T00:00:00Z", "notes": "Observed in More AI IDE Agent Mode writing files through tool calls on Doug's RTX 4080-class system." }, + "printed_tool_fallback": { "status": "supported", "confidence": "operator_observed", "notes": "Fallback remains available if a runtime route drops native tool-call formatting." }, + "vision": { "status": "unsupported", "confidence": "curated" }, + "image_generation": { "status": "unsupported", "confidence": "curated" }, + "video_generation": { "status": "unsupported", "confidence": "curated" }, + "music_generation": { "status": "unsupported", "confidence": "curated" } + }, + "license_notes": "Review the IBM Granite model card and Ollama library page before redistribution." + }, { "id": "dreamshaper-xl", "runtime_target": "comfyui_or_diffusers", @@ -452,6 +488,42 @@ }, "license_notes": "Review the Hugging Face model card before redistribution." }, + { + "id": "wan2.2-ti2v-5b", + "runtime_target": "comfyui_video", + "source_url": "https://huggingface.co/Wan-AI/Wan2.2-TI2V-5B", + "download_size_bytes": 10737418240, + "installed_size_bytes": 12000000000, + "quality_tier": "advanced", + "best_for": ["short text-to-video clips", "image-to-video tests", "consumer GPU video generation"], + "minimum_ram_mb": 32768, + "recommended_ram_mb": 65536, + "minimum_vram_mb": 12288, + "recommended_vram_mb": 16384, + "minimum_disk_gb": 16, + "creator": "Wan-AI", + "model_family": "Wan 2.2", + "release_date": "2026", + "registry_summary": "Lightweight Wan 2.2 text/image-to-video model for practical local video tests on 16 GB VRAM systems.", + "training_notes": "Wan 2.2 TI2V targets text-to-video and image-to-video generation with a smaller 5B model footprint.", + "ide_tool_mode": "not_applicable", + "tool_calling_status": "unsupported", + "tool_calling_notes": "Video generation checkpoints do not drive IDE tools or chat tools.", + "capability_source_url": "https://huggingface.co/Wan-AI/Wan2.2-TI2V-5B", + "capability_verified_at": "2026-06-28T00:00:00Z", + "capability_matrix": { + "chat": { "status": "unsupported", "confidence": "curated" }, + "code_chat": { "status": "unsupported", "confidence": "curated" }, + "document_chat": { "status": "unsupported", "confidence": "curated" }, + "native_tool_calling": { "status": "unsupported", "confidence": "curated" }, + "printed_tool_fallback": { "status": "unsupported", "confidence": "curated" }, + "vision": { "status": "limited", "confidence": "curated", "notes": "Image-to-video conditioning is supported by compatible pipelines, but this is not visual question answering." }, + "image_generation": { "status": "unsupported", "confidence": "curated" }, + "video_generation": { "status": "supported", "confidence": "curated", "notes": "Text/image-to-video generation through a compatible local video pipeline." }, + "music_generation": { "status": "unsupported", "confidence": "curated" } + }, + "license_notes": "Review the Hugging Face model card and any gated-use terms before download or redistribution." + }, { "id": "musicgen-small", "runtime_target": "audiocraft_or_audio_backend", diff --git a/src-tauri/src/commands/agent_tools.rs b/src-tauri/src/commands/agent_tools.rs index e12b9f15..218c432b 100644 --- a/src-tauri/src/commands/agent_tools.rs +++ b/src-tauri/src/commands/agent_tools.rs @@ -2290,7 +2290,11 @@ pub async fn execute_agent_loop( let is_anthropic = provider_type == "anthropic"; // Build base URL - let base_url = resolve_base_url(&provider_type, &endpoint_url)?; + let mut base_url = if matches!(provider_type.as_str(), "llama_cpp" | "bitnet") { + String::new() + } else { + resolve_base_url(&provider_type, &endpoint_url)? + }; if provider_type == "ollama" { crate::commands::ollama_server::ensure_ollama_running(&app).await?; } @@ -2299,6 +2303,21 @@ pub async fn execute_agent_loop( // Only a connect timeout — no total timeout. Streaming responses keep bytes // flowing for as long as the model is generating; a total timeout would abort // mid-stream on long audit / code-generation turns. + if matches!(provider_type.as_str(), "llama_cpp" | "bitnet") { + crate::commands::bitnet_server::start_bitnet_server( + app.clone(), + Some(model_name.clone()), + None, + None, + ) + .await + .map_err(|e| format!("llama.cpp local runtime could not start for '{model_name}': {e}"))?; + base_url = format!( + "http://127.0.0.1:{}/v1", + crate::commands::bitnet_server::actual_port() + ); + } + let client = reqwest::Client::builder() .connect_timeout(std::time::Duration::from_secs(30)) .build() diff --git a/src-tauri/src/commands/ai_webview.rs b/src-tauri/src/commands/ai_webview.rs index debca714..f773cf30 100644 --- a/src-tauri/src/commands/ai_webview.rs +++ b/src-tauri/src/commands/ai_webview.rs @@ -11,13 +11,14 @@ use serde::{Deserialize, Serialize}; use std::sync::Arc; -use tauri::webview::PageLoadEvent; +use tauri::webview::{NewWindowResponse, PageLoadEvent}; use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder}; use tokio::sync::Mutex; use url::Url; use uuid::Uuid; const WEBVIEW_LABEL: &str = "ai-webview-browser"; +const WEBVIEW_TAB_LABEL_PREFIX: &str = "ai-webview-browser-tab-"; const COE_DOCK_ELEMENT_ID: &str = "more-ai-coe-webview-dock"; const COE_DOCK_SCRIPT: &str = r#"(function() { try { @@ -71,6 +72,7 @@ pub enum ControlState { #[derive(Debug, Clone, Serialize)] pub struct WebviewStatus { + pub tab_id: Option, pub current_url: String, pub title: String, pub is_loading: bool, @@ -82,6 +84,7 @@ pub struct WebviewStatus { #[derive(Debug, Clone, Serialize)] pub struct WebviewNavigationEvent { + pub tab_id: Option, pub url: String, pub title: String, pub is_https: bool, @@ -89,11 +92,13 @@ pub struct WebviewNavigationEvent { #[derive(Debug, Clone, Serialize)] pub struct WebviewTitleEvent { + pub tab_id: Option, pub title: String, } #[derive(Debug, Clone, Serialize)] pub struct WebviewPageLoadedEvent { + pub tab_id: Option, pub url: String, pub title: String, pub is_https: bool, @@ -124,7 +129,12 @@ impl AiWebViewState { } fn snapshot(&self) -> WebviewStatus { + self.snapshot_for_tab(None) + } + + fn snapshot_for_tab(&self, tab_id: Option) -> WebviewStatus { WebviewStatus { + tab_id, current_url: self.current_url.clone(), title: self.title.clone(), is_loading: self.is_loading, @@ -142,6 +152,58 @@ pub fn new_shared_state() -> SharedAiWebViewState { Arc::new(Mutex::new(AiWebViewState::new())) } +fn normalize_tab_id(tab_id: Option<&str>) -> Option { + let raw = tab_id?.trim(); + if raw.is_empty() { + return None; + } + + let mut sanitized = String::with_capacity(raw.len().min(64)); + for ch in raw.chars().take(64) { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + sanitized.push(ch); + } else { + sanitized.push('-'); + } + } + + if sanitized.is_empty() { + None + } else { + Some(sanitized) + } +} + +fn webview_label_for_tab(tab_id: Option<&str>) -> String { + normalize_tab_id(tab_id) + .map(|id| format!("{WEBVIEW_TAB_LABEL_PREFIX}{id}")) + .unwrap_or_else(|| WEBVIEW_LABEL.to_string()) +} + +fn hide_inactive_browser_windows(app: &AppHandle, active_label: &str) { + for (label, window) in app.webview_windows() { + let is_ai_browser = label == WEBVIEW_LABEL || label.starts_with(WEBVIEW_TAB_LABEL_PREFIX); + if is_ai_browser && label != active_label { + let _ = window.hide(); + } + } +} + +fn show_active_browser_window( + app: &AppHandle, + label: &str, + browser: &tauri::WebviewWindow, +) -> Result<(), String> { + hide_inactive_browser_windows(app, label); + browser + .show() + .map_err(|e| format!("Failed to show AI WebView window: {e}"))?; + browser + .set_focus() + .map_err(|e| format!("Failed to focus AI WebView window: {e}"))?; + Ok(()) +} + pub fn normalize_url(raw: &str) -> Result { let trimmed = raw.trim(); if trimmed.is_empty() { @@ -188,11 +250,12 @@ fn inject_coe_dock(window: &tauri::WebviewWindow) { let _ = window.eval(COE_DOCK_SCRIPT); } -fn emit_navigation(app: &AppHandle, url: &Url, title: impl Into) { +fn emit_navigation(app: &AppHandle, url: &Url, title: impl Into, tab_id: Option<&str>) { let title = title.into(); let _ = app.emit( "webview:navigation_committed", WebviewNavigationEvent { + tab_id: normalize_tab_id(tab_id), url: url.to_string(), title, is_https: url.scheme() == "https", @@ -214,9 +277,13 @@ fn emit_navigation(app: &AppHandle, url: &Url, title: impl Into) { pub fn ensure_browser_window( app: &AppHandle, initial_url: Url, + tab_id: Option<&str>, ) -> Result { + let tab_id = normalize_tab_id(tab_id); + let label = webview_label_for_tab(tab_id.as_deref()); + // Return existing window without touching visibility. - if let Some(existing) = app.get_webview_window(WEBVIEW_LABEL) { + if let Some(existing) = app.get_webview_window(&label) { return Ok(existing); } @@ -235,57 +302,113 @@ pub fn ensure_browser_window( .unwrap_or((120.0, 60.0)); let app_for_nav = app.clone(); + let app_for_new_window = app.clone(); let app_for_title = app.clone(); let app_for_load = app.clone(); + let nav_tab_id = tab_id.clone(); + let new_window_tab_id = tab_id.clone(); + let title_tab_id = tab_id.clone(); + let load_tab_id = tab_id.clone(); + let label_for_new_window = label.clone(); + + WebviewWindowBuilder::new(app, &label, WebviewUrl::External(initial_url.clone())) + .title("AI Web — More AI") + .inner_size(1120.0, 760.0) + .min_inner_size(900.0, 620.0) + .resizable(true) + .position(pop_x, pop_y) // beside the main window, not centered on top of it + .visible(false) // shown explicitly by webview_navigate + .on_navigation(move |url| { + if validate_url_policy(url).is_ok() { + emit_navigation(&app_for_nav, url, "Loading...", nav_tab_id.as_deref()); + true + } else { + let _ = app_for_nav.emit( + "webview:navigation_blocked", + WebviewNavigationEvent { + tab_id: nav_tab_id.clone(), + url: url.to_string(), + title: "Navigation blocked".to_string(), + is_https: url.scheme() == "https", + }, + ); + false + } + }) + .on_new_window(move |url, _features| { + if validate_url_policy(&url).is_err() { + let _ = app_for_new_window.emit( + "webview:navigation_blocked", + WebviewNavigationEvent { + url: url.to_string(), + tab_id: new_window_tab_id.clone(), + title: "Navigation blocked".to_string(), + is_https: url.scheme() == "https", + }, + ); + return NewWindowResponse::Deny; + } - WebviewWindowBuilder::new( - app, - WEBVIEW_LABEL, - WebviewUrl::External(initial_url.clone()), - ) - .title("AI Web — More AI") - .inner_size(1120.0, 760.0) - .min_inner_size(900.0, 620.0) - .resizable(true) - .position(pop_x, pop_y) // beside the main window, not centered on top of it - .visible(false) // shown explicitly by webview_navigate - .on_navigation(move |url| { - if validate_url_policy(url).is_ok() { - emit_navigation(&app_for_nav, url, "Loading…"); - true - } else { - let _ = app_for_nav.emit( - "webview:navigation_blocked", - WebviewNavigationEvent { - url: url.to_string(), - title: "Navigation blocked".to_string(), - is_https: url.scheme() == "https", + emit_navigation( + &app_for_new_window, + &url, + "Loading...", + new_window_tab_id.as_deref(), + ); + if let Some(browser) = app_for_new_window.get_webview_window(&label_for_new_window) { + if let Err(err) = browser.navigate(url.clone()) { + let _ = app_for_new_window.emit( + "webview:navigation_blocked", + WebviewNavigationEvent { + url: url.to_string(), + tab_id: new_window_tab_id.clone(), + title: format!("New-window redirect failed: {err}"), + is_https: url.scheme() == "https", + }, + ); + } + } else { + let _ = app_for_new_window.emit( + "webview:navigation_blocked", + WebviewNavigationEvent { + url: url.to_string(), + tab_id: new_window_tab_id.clone(), + title: "Browser window missing".to_string(), + is_https: url.scheme() == "https", + }, + ); + } + + NewWindowResponse::Deny + }) + .on_document_title_changed(move |_window, title| { + let _ = app_for_title.emit( + "webview:title_changed", + WebviewTitleEvent { + tab_id: title_tab_id.clone(), + title, }, ); - false - } - }) - .on_document_title_changed(move |_window, title| { - let _ = app_for_title.emit("webview:title_changed", WebviewTitleEvent { title }); - }) - .on_page_load(move |window, payload| { - if matches!(payload.event(), PageLoadEvent::Finished) { - let url = window.url().unwrap_or_else(|_| payload.url().clone()); - let title = window - .title() - .unwrap_or_else(|_| "Untitled page".to_string()); - inject_coe_dock(&window); - let event = WebviewPageLoadedEvent { - url: url.to_string(), - title: title.clone(), - is_https: url.scheme() == "https", - }; - let _ = app_for_load.emit("webview:page_loaded", event); - emit_navigation(&app_for_load, &url, title); - } - }) - .build() - .map_err(|e| format!("Failed to create AI WebView window: {e}")) + }) + .on_page_load(move |window, payload| { + if matches!(payload.event(), PageLoadEvent::Finished) { + let url = window.url().unwrap_or_else(|_| payload.url().clone()); + let title = window + .title() + .unwrap_or_else(|_| "Untitled page".to_string()); + inject_coe_dock(&window); + let event = WebviewPageLoadedEvent { + tab_id: load_tab_id.clone(), + url: url.to_string(), + title: title.clone(), + is_https: url.scheme() == "https", + }; + let _ = app_for_load.emit("webview:page_loaded", event); + emit_navigation(&app_for_load, &url, title, load_tab_id.as_deref()); + } + }) + .build() + .map_err(|e| format!("Failed to create AI WebView window: {e}")) } #[tauri::command] @@ -299,17 +422,19 @@ pub async fn webview_get_status( pub async fn webview_navigate( app: AppHandle, url: String, + tab_id: Option, state: tauri::State<'_, SharedAiWebViewState>, ) -> Result { let parsed = normalize_url(&url)?; - let browser = ensure_browser_window(&app, parsed.clone())?; + let normalized_tab_id = normalize_tab_id(tab_id.as_deref()); + let label = webview_label_for_tab(normalized_tab_id.as_deref()); + let browser = ensure_browser_window(&app, parsed.clone(), normalized_tab_id.as_deref())?; browser .navigate(parsed.clone()) .map_err(|e| format!("Navigation failed: {e}"))?; // Show and focus the window here — webview_navigate is only called when the user // is actively on the AI Web tab, so showing is always correct at this point. - let _ = browser.show(); - let _ = browser.set_focus(); + show_active_browser_window(&app, &label, &browser)?; let mut guard = state.lock().await; guard.current_url = parsed.to_string(); @@ -317,57 +442,69 @@ pub async fn webview_navigate( guard.is_loading = true; guard.is_https = parsed.scheme() == "https"; guard.browser_window_created = true; - Ok(guard.snapshot()) + Ok(guard.snapshot_for_tab(normalized_tab_id)) } #[tauri::command] pub async fn webview_go_back( app: AppHandle, + tab_id: Option, state: tauri::State<'_, SharedAiWebViewState>, ) -> Result { + let normalized_tab_id = normalize_tab_id(tab_id.as_deref()); + let label = webview_label_for_tab(normalized_tab_id.as_deref()); let browser = app - .get_webview_window(WEBVIEW_LABEL) + .get_webview_window(&label) .ok_or_else(|| "AI WebView is not open yet.".to_string())?; + show_active_browser_window(&app, &label, &browser)?; browser // Injects a static browser-history command; no user or AI input. .eval("history.back();") .map_err(|e| format!("Back navigation failed: {e}"))?; let mut guard = state.lock().await; guard.is_loading = true; - Ok(guard.snapshot()) + Ok(guard.snapshot_for_tab(normalized_tab_id)) } #[tauri::command] pub async fn webview_go_forward( app: AppHandle, + tab_id: Option, state: tauri::State<'_, SharedAiWebViewState>, ) -> Result { + let normalized_tab_id = normalize_tab_id(tab_id.as_deref()); + let label = webview_label_for_tab(normalized_tab_id.as_deref()); let browser = app - .get_webview_window(WEBVIEW_LABEL) + .get_webview_window(&label) .ok_or_else(|| "AI WebView is not open yet.".to_string())?; + show_active_browser_window(&app, &label, &browser)?; browser // Injects a static browser-history command; no user or AI input. .eval("history.forward();") .map_err(|e| format!("Forward navigation failed: {e}"))?; let mut guard = state.lock().await; guard.is_loading = true; - Ok(guard.snapshot()) + Ok(guard.snapshot_for_tab(normalized_tab_id)) } #[tauri::command] pub async fn webview_reload( app: AppHandle, + tab_id: Option, state: tauri::State<'_, SharedAiWebViewState>, ) -> Result { + let normalized_tab_id = normalize_tab_id(tab_id.as_deref()); + let label = webview_label_for_tab(normalized_tab_id.as_deref()); let browser = app - .get_webview_window(WEBVIEW_LABEL) + .get_webview_window(&label) .ok_or_else(|| "AI WebView is not open yet.".to_string())?; + show_active_browser_window(&app, &label, &browser)?; browser .reload() .map_err(|e| format!("Reload failed: {e}"))?; let mut guard = state.lock().await; guard.is_loading = true; - Ok(guard.snapshot()) + Ok(guard.snapshot_for_tab(normalized_tab_id)) } /// Programmatically hide the AI WebView pop-out window. @@ -376,8 +513,19 @@ pub async fn webview_reload( /// /// Always returns Ok — callers must not rely on errors to detect visibility state. #[tauri::command] -pub async fn webview_hide_browser(app: AppHandle) -> Result<(), String> { - if let Some(w) = app.get_webview_window(WEBVIEW_LABEL) { +pub async fn webview_hide_browser(app: AppHandle, tab_id: Option) -> Result<(), String> { + if tab_id.as_deref().is_none() { + for (label, window) in app.webview_windows() { + if label == WEBVIEW_LABEL || label.starts_with(WEBVIEW_TAB_LABEL_PREFIX) { + let _ = window.hide(); + } + } + return Ok(()); + } + + let normalized_tab_id = normalize_tab_id(tab_id.as_deref()); + let label = webview_label_for_tab(normalized_tab_id.as_deref()); + if let Some(w) = app.get_webview_window(&label) { let _ = w.hide(); // ignore error — window may already be hidden } Ok(()) @@ -386,13 +534,13 @@ pub async fn webview_hide_browser(app: AppHandle) -> Result<(), String> { /// Programmatically show and focus the AI WebView pop-out window. /// No-op if the window was never opened. #[tauri::command] -pub async fn webview_show_browser(app: AppHandle) -> Result<(), String> { - if let Some(w) = app.get_webview_window(WEBVIEW_LABEL) { - w.show() - .map_err(|e| format!("Failed to show AI WebView window: {e}"))?; - w.set_focus() - .map_err(|e| format!("Failed to focus AI WebView window: {e}"))?; - } +pub async fn webview_show_browser(app: AppHandle, tab_id: Option) -> Result<(), String> { + let normalized_tab_id = normalize_tab_id(tab_id.as_deref()); + let label = webview_label_for_tab(normalized_tab_id.as_deref()); + let Some(w) = app.get_webview_window(&label) else { + return Err("AI WebView is not open yet.".to_string()); + }; + show_active_browser_window(&app, &label, &w)?; Ok(()) } @@ -658,9 +806,12 @@ pub struct InteractiveElementsResult { pub async fn webview_find_elements( app: AppHandle, selector: String, + tab_id: Option, ) -> Result, String> { + let normalized_tab_id = normalize_tab_id(tab_id.as_deref()); + let label = webview_label_for_tab(normalized_tab_id.as_deref()); let browser = app - .get_webview_window(WEBVIEW_LABEL) + .get_webview_window(&label) .ok_or_else(|| "AI WebView is not open yet.".to_string())?; let selector_json = serde_json::to_string(&selector).unwrap_or_default(); @@ -707,9 +858,14 @@ pub async fn webview_find_elements( /// Get all visible text content from the page, stripped of scripts and styles. #[tauri::command] -pub async fn webview_get_page_text(app: AppHandle) -> Result { +pub async fn webview_get_page_text( + app: AppHandle, + tab_id: Option, +) -> Result { + let normalized_tab_id = normalize_tab_id(tab_id.as_deref()); + let label = webview_label_for_tab(normalized_tab_id.as_deref()); let browser = app - .get_webview_window(WEBVIEW_LABEL) + .get_webview_window(&label) .ok_or_else(|| "AI WebView is not open yet.".to_string())?; let js = r#"(function() { @@ -780,7 +936,7 @@ pub async fn webview_get_page_text(app: AppHandle) -> Result Result { let parsed = normalize_url(url)?; - let browser = ensure_browser_window(app, parsed.clone())?; + let browser = ensure_browser_window(app, parsed.clone(), None)?; browser .navigate(parsed.clone()) .map_err(|e| format!("Navigation failed: {e}"))?; @@ -900,9 +1056,14 @@ pub async fn webview_screenshot(_app: AppHandle) -> Result { /// Return all interactive elements visible in the viewport. #[tauri::command] -pub async fn webview_get_interactive_elements(app: AppHandle) -> Result { +pub async fn webview_get_interactive_elements( + app: AppHandle, + tab_id: Option, +) -> Result { + let normalized_tab_id = normalize_tab_id(tab_id.as_deref()); + let label = webview_label_for_tab(normalized_tab_id.as_deref()); let browser = app - .get_webview_window(WEBVIEW_LABEL) + .get_webview_window(&label) .ok_or_else(|| "AI WebView is not open yet.".to_string())?; let js = r#"(function() { @@ -950,12 +1111,15 @@ pub async fn webview_execute_script( app: AppHandle, script: String, developer_mode: bool, + tab_id: Option, ) -> Result { if !developer_mode { return Err("webview_execute_script requires developer_mode=true. This prevents agents from injecting arbitrary scripts without user awareness.".to_string()); } + let normalized_tab_id = normalize_tab_id(tab_id.as_deref()); + let label = webview_label_for_tab(normalized_tab_id.as_deref()); let browser = app - .get_webview_window(WEBVIEW_LABEL) + .get_webview_window(&label) .ok_or_else(|| "AI WebView is not open yet.".to_string())?; // script is user/developer supplied and not sanitized; developer_mode=true is the explicit allowlist gate. eval_get_result(&app, &browser, &script).await diff --git a/src-tauri/src/commands/bee.rs b/src-tauri/src/commands/bee.rs index f5d64703..f536d94a 100644 --- a/src-tauri/src/commands/bee.rs +++ b/src-tauri/src/commands/bee.rs @@ -3,8 +3,9 @@ // bee.rs — The Bee Bot: Phase B1 + OS Skills command layer // ========================================================= // Provides keyword-matching canned response lookup (template tier), conversation -// logging, and event logging. Phase B1 ships with Web Speech API TTS on the frontend -// and template-matching in Rust. Phase B3 replaces the matching layer with BitNet — +// logging, and event logging. DugBee speech output is local Bee TTS only; the +// browser/WebView speech-synthesis fallback is intentionally disabled so host +// voices cannot impersonate DugBee. Phase B3 replaces the matching layer with BitNet — // the command signatures and return types here are stable throughout all phases. // // DugBee OS & Info Skills (Phase D1) run BEFORE template matching: diff --git a/src-tauri/src/commands/bitnet_server.rs b/src-tauri/src/commands/bitnet_server.rs index 3736d53c..fa069c5d 100644 --- a/src-tauri/src/commands/bitnet_server.rs +++ b/src-tauri/src/commands/bitnet_server.rs @@ -24,8 +24,9 @@ //! bitnet:install_progress { stage: String, pct: u8 } use serde::Serialize; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU16, AtomicU32, Ordering}; +use std::time::{Duration, Instant, UNIX_EPOCH}; use tauri::{AppHandle, Emitter, Manager}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::process::Command as TokioCmd; @@ -40,6 +41,16 @@ const SERVER_EXE_NAME: &str = "llama-server.exe"; #[cfg(not(target_os = "windows"))] const SERVER_EXE_NAME: &str = "llama-server"; +const CPP_GPU_PREFERRED_THRESHOLD_B: f32 = 5.0; +const LARGE_CPP_FILE_FALLBACK_MB: u64 = 5_500; +const LLAMA_CPP_GPU_OFFLOAD_LAYERS: u32 = 999; +const LLAMA_CPP_CHAT_READY_TIMEOUT_SECS: u64 = 180; +const LLAMA_CPP_LOAD_PREFERENCE_KEY: &str = "llama_cpp_load_preference"; +const BITNET_ENGINE_ZIP_NAME: &str = "bitnet_engine.zip"; +const MAX_ENGINE_ARCHIVE_ENTRIES: usize = 1_000; +const MAX_ENGINE_ENTRY_UNCOMPRESSED_BYTES: u64 = 1024 * 1024 * 1024; +const MAX_ENGINE_ARCHIVE_UNCOMPRESSED_BYTES: u64 = 4 * 1024 * 1024 * 1024; + // AUDIT-CRIT-017 fix: replaced std::sync::Mutex statics with lock-free atomics // and std::sync::RwLock to eliminate the risk of blocking Tokio worker threads. // @@ -51,11 +62,16 @@ static USER_STOPPED: AtomicBool = AtomicBool::new(true); static ACTUAL_PORT: AtomicU16 = AtomicU16::new(BITNET_PORT); static CURRENT_MODEL: std::sync::OnceLock>> = std::sync::OnceLock::new(); +static START_LOCK: std::sync::OnceLock> = std::sync::OnceLock::new(); fn model_lock() -> &'static std::sync::RwLock> { CURRENT_MODEL.get_or_init(|| std::sync::RwLock::new(None)) } +fn start_lock() -> &'static tokio::sync::Mutex<()> { + START_LOCK.get_or_init(|| tokio::sync::Mutex::new(())) +} + // ── Path helpers ────────────────────────────────────────────────────────────── fn engine_dir() -> PathBuf { @@ -69,6 +85,10 @@ fn server_exe() -> PathBuf { engine_dir().join(SERVER_EXE_NAME) } +fn engine_resource_stamp_file() -> PathBuf { + engine_dir().join(".more_ai_bitnet_engine_resource_stamp") +} + /// Locates the source directory holding the bundled llama-server binary. /// /// Lookup order: @@ -100,15 +120,85 @@ fn bundled_engine_dir(app: &AppHandle) -> Option { None } -/// Returns the path to the llama-server binary in the bundle/resources directory. -fn bundled_engine_path(app: &AppHandle) -> Option { - bundled_engine_dir(app).map(|d| d.join(SERVER_EXE_NAME)) +fn bundled_engine_zip_path(app: &AppHandle) -> Option { + if let Ok(res) = app.path().resource_dir() { + let p = res.join(BITNET_ENGINE_ZIP_NAME); + if p.exists() { + return Some(p); + } + } + #[cfg(debug_assertions)] + { + let p = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("resources") + .join(BITNET_ENGINE_ZIP_NAME); + if p.exists() { + return Some(p); + } + } + None +} + +fn bundled_engine_available(app: &AppHandle) -> bool { + bundled_engine_dir(app).is_some() || bundled_engine_zip_path(app).is_some() +} + +fn resource_stamp(path: &Path, prefix: &str) -> Option { + let meta = std::fs::metadata(path).ok()?; + let modified = meta + .modified() + .ok() + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_secs()) + .unwrap_or_default(); + Some(format!("{prefix}:{}:{modified}", meta.len())) +} + +fn bundled_engine_stamp(app: &AppHandle) -> Option { + if let Some(zip_path) = bundled_engine_zip_path(app) { + return resource_stamp(&zip_path, "zip"); + } + let dir = bundled_engine_dir(app)?; + let mut file_count = 0_u64; + let mut total_bytes = 0_u64; + for entry in std::fs::read_dir(dir).ok()?.flatten() { + if let Ok(meta) = entry.metadata() { + if meta.is_file() { + file_count += 1; + total_bytes = total_bytes.saturating_add(meta.len()); + } + } + } + Some(format!("dir:{file_count}:{total_bytes}")) +} + +fn extracted_engine_matches_bundle(app: &AppHandle) -> bool { + if !server_exe().exists() { + return false; + } + let Some(expected) = bundled_engine_stamp(app) else { + return true; + }; + std::fs::read_to_string(engine_resource_stamp_file()) + .map(|stored| stored.trim() == expected) + .unwrap_or(false) } -/// Copy all files from the bundled `bitnet_engine/` resource into the local -/// install directory (`~/.more_ai/bitnet_engine/`). Called automatically when -/// the user first starts the engine in a production build. +/// Copy the bundled BitNet engine resource into the local install directory +/// (`~/.more_ai/bitnet_engine/`). Full offline installers use a compressed +/// `bitnet_engine.zip`; standard builds may still ship the loose resource dir. fn extract_bundled_engine(app: &AppHandle) -> Result<(), String> { + if let Some(zip_path) = bundled_engine_zip_path(app) { + if zip_path.exists() { + let dest_dir = engine_dir(); + std::fs::create_dir_all(&dest_dir) + .map_err(|e| format!("Cannot create engine directory: {e}"))?; + extract_bundled_engine_zip(&zip_path, &dest_dir)?; + finalize_extracted_engine(app)?; + return Ok(()); + } + } + let src_dir = bundled_engine_dir(app) .ok_or_else(|| "No bundled engine found in resources. Run scripts/build-release.ps1 (or scripts/download-bitnet-engine.ps1 for dev) to download the engine binary.".to_string())?; if !src_dir.exists() { @@ -132,6 +222,84 @@ fn extract_bundled_engine(app: &AppHandle) -> Result<(), String> { })?; } + finalize_extracted_engine(app) +} + +fn extract_bundled_engine_zip(zip_path: &Path, dest_dir: &Path) -> Result<(), String> { + let archive_file = std::fs::File::open(zip_path) + .map_err(|e| format!("Cannot open bundled BitNet engine archive: {e}"))?; + let mut archive = zip::ZipArchive::new(archive_file) + .map_err(|e| format!("Cannot read bundled BitNet engine archive: {e}"))?; + + if archive.len() > MAX_ENGINE_ARCHIVE_ENTRIES { + return Err(format!( + "Bundled BitNet engine archive has too many files: {}", + archive.len() + )); + } + + let mut total_uncompressed = 0u64; + for idx in 0..archive.len() { + let mut entry = archive + .by_index(idx) + .map_err(|e| format!("Cannot read bundled BitNet engine archive entry {idx}: {e}"))?; + let entry_size = entry.size(); + if entry_size > MAX_ENGINE_ENTRY_UNCOMPRESSED_BYTES { + return Err(format!( + "Bundled BitNet engine archive entry is too large: {}", + entry.name() + )); + } + total_uncompressed = total_uncompressed + .checked_add(entry_size) + .ok_or_else(|| "Bundled BitNet engine archive is too large.".to_string())?; + if total_uncompressed > MAX_ENGINE_ARCHIVE_UNCOMPRESSED_BYTES { + return Err( + "Bundled BitNet engine archive exceeds the release size guard.".to_string(), + ); + } + + let Some(enclosed_name) = entry.enclosed_name() else { + return Err(format!( + "Bundled BitNet engine archive contains an unsafe path: {}", + entry.name() + )); + }; + let out_path = dest_dir.join(enclosed_name); + + if entry.is_dir() { + std::fs::create_dir_all(&out_path) + .map_err(|e| format!("Cannot create BitNet engine directory: {e}"))?; + continue; + } + + if let Some(parent) = out_path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("Cannot create BitNet engine directory: {e}"))?; + } + let mut out_file = std::fs::File::create(&out_path) + .map_err(|e| format!("Cannot extract {}: {e}", entry.name()))?; + let copied = std::io::copy(&mut entry, &mut out_file) + .map_err(|e| format!("Cannot extract {}: {e}", entry.name()))?; + if copied > MAX_ENGINE_ENTRY_UNCOMPRESSED_BYTES { + return Err(format!( + "Bundled BitNet engine archive entry expanded too large: {}", + entry.name() + )); + } + } + + Ok(()) +} + +fn finalize_extracted_engine(app: &AppHandle) -> Result<(), String> { + if !server_exe().exists() { + return Err(format!( + "Bundled BitNet engine extraction did not create {}.", + SERVER_EXE_NAME + )); + } + #[cfg(not(target_os = "windows"))] { use std::os::unix::fs::PermissionsExt; @@ -151,6 +319,10 @@ fn extract_bundled_engine(app: &AppHandle) -> Result<(), String> { let _ = write_version_file("bundled"); } + if let Some(stamp) = bundled_engine_stamp(app) { + let _ = std::fs::write(engine_resource_stamp_file(), stamp); + } + Ok(()) } @@ -167,6 +339,295 @@ fn find_available_port() -> u16 { BITNET_PORT } +#[derive(Debug, Clone)] +struct LlamaCppLaunchProfile { + params_b: Option, + model_size_mb: u64, + load_preference: String, + should_gpu_offload: bool, + has_gpu: bool, + gpu_name: Option, + vram_total_mb: u32, + vram_available_mb: u32, + engine_has_gpu_backend: bool, +} + +fn normalize_llama_cpp_load_preference(raw: Option<&str>) -> String { + match raw.unwrap_or("auto").trim().to_lowercase().as_str() { + "gpu" | "vram" | "gpu_vram" => "gpu".to_string(), + "cpu" | "ram" | "cpu_ram" => "cpu".to_string(), + "hybrid" | "gpu_ram" | "gpu+ram" => "hybrid".to_string(), + "low_memory" | "low-memory" | "lowmemory" => "low_memory".to_string(), + _ => "auto".to_string(), + } +} + +async fn read_llama_cpp_load_preference(app: &AppHandle, override_pref: Option) -> String { + let override_value = normalize_llama_cpp_load_preference(override_pref.as_deref()); + if override_value != "auto" { + return override_value; + } + + let Some(db_state) = app.try_state::() else { + return "auto".to_string(); + }; + let stored = + sqlx::query_scalar::<_, String>("SELECT value FROM app_config WHERE key = ? LIMIT 1") + .bind(LLAMA_CPP_LOAD_PREFERENCE_KEY) + .fetch_optional(&*db_state.pool) + .await + .ok() + .flatten(); + normalize_llama_cpp_load_preference(stored.as_deref()) +} + +fn estimate_params_b_from_text(text: &str) -> Option { + let re = regex::Regex::new(r"(?i)(?:^|[^a-z0-9])(\d+(?:\.\d+)?)\s*b(?:[^a-z0-9]|$)").ok()?; + let mut best: Option = None; + for cap in re.captures_iter(text) { + let Some(raw) = cap.get(1).map(|m| m.as_str()) else { + continue; + }; + let Ok(value) = raw.parse::() else { + continue; + }; + if !(0.5..=1000.0).contains(&value) { + continue; + } + best = Some(match best { + Some(current) => current.max(value), + None => value, + }); + } + best +} + +fn estimate_params_b(local_name: Option<&str>, model_path: &Path) -> Option { + local_name + .and_then(estimate_params_b_from_text) + .or_else(|| { + model_path + .file_stem() + .and_then(|s| s.to_str()) + .and_then(estimate_params_b_from_text) + }) + .or_else(|| { + model_path + .file_name() + .and_then(|s| s.to_str()) + .and_then(estimate_params_b_from_text) + }) +} + +fn model_file_size_mb(model_path: &Path) -> u64 { + std::fs::metadata(model_path) + .map(|m| m.len() / 1024 / 1024) + .unwrap_or(0) +} + +fn engine_has_gpu_backend() -> bool { + let Ok(entries) = std::fs::read_dir(engine_dir()) else { + return false; + }; + + entries.flatten().any(|entry| { + let name = entry.file_name().to_string_lossy().to_lowercase(); + name.contains("cuda") + || name.contains("cublas") + || name.contains("cudart") + || name.contains("vulkan") + || name.contains("kompute") + || name.contains("hipblas") + || name.contains("rocm") + || name.contains("sycl") + }) +} + +fn build_llama_cpp_launch_profile( + local_name: Option<&str>, + model_path: &Path, + load_preference: &str, +) -> LlamaCppLaunchProfile { + let params_b = estimate_params_b(local_name, model_path); + let model_size_mb = model_file_size_mb(model_path); + let auto_gpu_offload = params_b + .map(|params| params >= CPP_GPU_PREFERRED_THRESHOLD_B) + .unwrap_or(model_size_mb >= LARGE_CPP_FILE_FALLBACK_MB); + let should_gpu_offload = + match normalize_llama_cpp_load_preference(Some(load_preference)).as_str() { + "gpu" | "hybrid" => true, + "cpu" | "low_memory" => false, + _ => auto_gpu_offload, + }; + let (vram_total_mb, vram_available_mb, has_gpu, gpu_name) = + crate::commands::hardware_scan::read_gpu_info(); + + LlamaCppLaunchProfile { + params_b, + model_size_mb, + load_preference: normalize_llama_cpp_load_preference(Some(load_preference)), + should_gpu_offload, + has_gpu, + gpu_name, + vram_total_mb, + vram_available_mb, + engine_has_gpu_backend: engine_has_gpu_backend(), + } +} + +fn append_llama_cpp_offload_args(args: &mut Vec, profile: &LlamaCppLaunchProfile) { + if profile.should_gpu_offload && profile.has_gpu { + args.push("--n-gpu-layers".to_string()); + args.push(LLAMA_CPP_GPU_OFFLOAD_LAYERS.to_string()); + args.push("--main-gpu".to_string()); + args.push("0".to_string()); + } +} + +fn emit_llama_cpp_launch_profile( + app: &AppHandle, + local_name: Option<&str>, + model_path: &Path, + profile: &LlamaCppLaunchProfile, +) { + let model_size = if let Some(params) = profile.params_b { + format!("{params:.1}B") + } else if profile.model_size_mb > 0 { + format!("{} MB GGUF", profile.model_size_mb) + } else { + "unknown size".to_string() + }; + let gpu_requested = profile.should_gpu_offload && profile.has_gpu; + let offload_layers = if gpu_requested { + LLAMA_CPP_GPU_OFFLOAD_LAYERS + } else { + 0 + }; + let load_lane = + if profile.load_preference == "hybrid" && gpu_requested && profile.engine_has_gpu_backend { + "hybrid" + } else if gpu_requested && profile.engine_has_gpu_backend { + "gpu" + } else { + "cpu" + }; + let warning = if profile.should_gpu_offload && !profile.has_gpu { + Some("Large llama.cpp model requested, but no discrete GPU was detected. This will run on CPU/RAM and may be slow.") + } else if gpu_requested && !profile.engine_has_gpu_backend { + Some("GPU offload was requested, but the installed llama.cpp engine does not expose a GPU backend. This may still run on CPU.") + } else if profile.should_gpu_offload { + None + } else { + Some("Small llama.cpp model selected. Auto mode keeps it on CPU/RAM to preserve GPU headroom.") + }; + let model_stem = model_path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("local-llama-cpp") + .to_string(); + + let _ = app.emit( + "bitnet:launch_profile", + serde_json::json!({ + "runtime": "llama.cpp", + "load_preference": profile.load_preference, + "load_lane": load_lane, + "gpu_requested": gpu_requested, + "offload_layers": offload_layers, + "params_b": profile.params_b, + "model_size_mb": profile.model_size_mb, + "model_name": local_name, + "model_stem": model_stem, + "model_path": model_path.to_string_lossy(), + "gpu_name": profile.gpu_name, + "vram_total_mb": profile.vram_total_mb, + "vram_available_mb": profile.vram_available_mb, + "engine_has_gpu_backend": profile.engine_has_gpu_backend, + "warning": warning, + }), + ); + + if profile.should_gpu_offload && profile.has_gpu { + emit_log( + app, + format!( + "[BitNet] Large llama.cpp model detected ({model_size}). Requesting GPU/VRAM offload with --n-gpu-layers {LLAMA_CPP_GPU_OFFLOAD_LAYERS} on {} ({} MB total VRAM, {} MB free).", + profile.gpu_name.as_deref().unwrap_or("GPU"), + profile.vram_total_mb, + profile.vram_available_mb + ), + ); + if !profile.engine_has_gpu_backend { + emit_log( + app, + "[BitNet] Current llama.cpp engine does not expose CUDA/Vulkan/HIP backend files. Offload flags will be passed, but this engine may still run on CPU until the GPU-enabled engine is installed.", + ); + } + } else if profile.should_gpu_offload { + emit_log( + app, + format!( + "[BitNet] Large llama.cpp model detected ({model_size}), but no discrete GPU was detected. This run will use the CPU path and may be slow." + ), + ); + } else { + emit_log( + app, + format!( + "[BitNet] Small llama.cpp model detected ({model_size}). Keeping it on the CPU/RAM lane so Ollama can keep GPU headroom." + ), + ); + } +} + +fn asset_name_lower(asset: &serde_json::Value) -> String { + asset["name"].as_str().unwrap_or("").to_lowercase() +} + +fn is_windows_x64_zip(name: &str) -> bool { + name.ends_with(".zip") + && name.contains("win") + && (name.contains("x64") || name.contains("amd64")) +} + +fn is_cuda_llama_cpp_asset(name: &str) -> bool { + name.starts_with("llama-") + && is_windows_x64_zip(name) + && (name.contains("cuda") + || name.contains("cublas") + || name.contains("cudart") + || name.contains("cu12")) + && !name.contains("cudart") + && !name.contains("hip") + && !name.contains("rocm") +} + +fn is_cuda_runtime_llama_cpp_asset(name: &str) -> bool { + is_windows_x64_zip(name) + && !name.starts_with("llama-") + && (name.contains("cudart") || name.contains("cuda-runtime") || name.contains("cublas")) +} + +fn cuda_runtime_marker(name: &str) -> Option { + let re = regex::Regex::new(r"cuda-\d+(?:\.\d+)?|cu\d+(?:\.\d+)?").ok()?; + re.find(name).map(|m| m.as_str().to_string()) +} + +fn is_vulkan_llama_cpp_asset(name: &str) -> bool { + is_windows_x64_zip(name) && name.contains("vulkan") +} + +fn is_cpu_llama_cpp_asset(name: &str) -> bool { + is_windows_x64_zip(name) + && (name.contains("cpu") || name.contains("avx2") || name.contains("avx")) + && !name.contains("cuda") + && !name.contains("vulkan") + && !name.contains("hip") + && !name.contains("rocm") + && !name.contains("sycl") + && !name.contains("cudart") +} + /// Returns the port the BitNet engine is currently listening on. /// Exposed publicly so other modules (dugbee_brain, etc.) can route to it. pub fn actual_port() -> u16 { @@ -177,6 +638,110 @@ fn bitnet_base() -> String { format!("http://127.0.0.1:{}", actual_port()) } +fn llama_cpp_request_model_name(model_path: &Path) -> String { + model_path + .file_stem() + .and_then(|s| s.to_str()) + .filter(|s| !s.trim().is_empty()) + .unwrap_or("local-llama-cpp") + .to_string() +} + +fn is_llama_cpp_loading_response(status: reqwest::StatusCode, body: &str) -> bool { + if status != reqwest::StatusCode::SERVICE_UNAVAILABLE { + return false; + } + let lower = body.to_lowercase(); + lower.contains("loading model") || lower.contains("unavailable_error") +} + +async fn wait_for_llama_cpp_chat_ready( + app: &AppHandle, + client: &reqwest::Client, + base: &str, + port: u16, + request_model_name: &str, +) -> Result<(), String> { + let health_url = format!("{base}/health"); + let chat_url = format!("{base}/v1/chat/completions"); + let deadline = Instant::now() + Duration::from_secs(LLAMA_CPP_CHAT_READY_TIMEOUT_SECS); + let mut last_notice = Instant::now() - Duration::from_secs(30); + + loop { + if Instant::now() >= deadline { + return Err(format!( + "llama.cpp engine is running on port {port}, but the model did not become chat-ready within {LLAMA_CPP_CHAT_READY_TIMEOUT_SECS}s. Check VRAM/RAM pressure or choose a smaller quant." + )); + } + + tokio::time::sleep(Duration::from_secs(1)).await; + + let health_ok = client + .get(&health_url) + .send() + .await + .map(|r| r.status().is_success()) + .unwrap_or(false); + if !health_ok { + if last_notice.elapsed() >= Duration::from_secs(10) { + emit_log( + app, + format!("[BitNet] Waiting for llama.cpp health on port {port}..."), + ); + last_notice = Instant::now(); + } + continue; + } + + let body = serde_json::json!({ + "model": request_model_name, + "max_tokens": 1, + "temperature": 0, + "stream": false, + "messages": [ + { "role": "user", "content": "Reply OK." } + ], + }); + + match client.post(&chat_url).json(&body).send().await { + Ok(resp) => { + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + if status.is_success() { + emit_log(app, format!("[BitNet] Engine chat-ready on port {port}.")); + return Ok(()); + } + + if is_llama_cpp_loading_response(status, &text) { + if last_notice.elapsed() >= Duration::from_secs(10) { + emit_log( + app, + format!( + "[BitNet] Model is still loading into memory/VRAM on port {port}; waiting..." + ), + ); + last_notice = Instant::now(); + } + continue; + } + + return Err(format!( + "llama.cpp readiness probe failed: HTTP {status}: {text}" + )); + } + Err(e) => { + if last_notice.elapsed() >= Duration::from_secs(10) { + emit_log( + app, + format!("[BitNet] Waiting for llama.cpp chat endpoint on port {port}: {e}"), + ); + last_notice = Instant::now(); + } + } + } + } +} + fn models_dir() -> PathBuf { dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) @@ -270,7 +835,7 @@ pub struct BitNetEngineStatus { #[tauri::command] pub fn get_bitnet_engine_status(app: AppHandle) -> BitNetEngineStatus { - let installed = server_exe().exists() || bundled_engine_path(&app).is_some(); + let installed = server_exe().exists() || bundled_engine_available(&app); let running = ENGINE_PID.load(Ordering::SeqCst) != 0; BitNetEngineStatus { installed, @@ -298,10 +863,10 @@ pub fn get_bitnet_engine_status(app: AppHandle) -> BitNetEngineStatus { /// Called automatically on app startup and on BitNet panel mount. /// The Tauri command variant is also callable from the frontend. pub async fn ensure_engine_available(app: &AppHandle) -> Result<(), String> { - if server_exe().exists() { + if server_exe().exists() && extracted_engine_matches_bundle(app) { return Ok(()); } - if bundled_engine_path(app).is_some() { + if bundled_engine_available(app) { emit_log(app, "[BitNet] Extracting bundled engine on first launch…"); extract_bundled_engine(app)?; emit_log(app, "[BitNet] Engine ready."); @@ -607,42 +1172,61 @@ async fn do_download_engine(app: &AppHandle, backup_first: bool) -> Result win-avx2 > win-avx > any win x64 zip - let win_asset = assets - .iter() - .find(|a| { - let name = a["name"].as_str().unwrap_or("").to_lowercase(); - name.ends_with(".zip") - && name.contains("win") - && name.contains("cpu") - && name.contains("x64") + // ── 2. Find a suitable Windows llama.cpp zip asset ─────────────────────── + // Prefer a GPU backend when this machine can use one; fall back to the + // portable CPU assets so CPU-only users still get a working local runtime. + let (_, _, has_gpu, gpu_name) = crate::commands::hardware_scan::read_gpu_info(); + let gpu_name_lower = gpu_name.as_deref().unwrap_or("").to_lowercase(); + let prefer_cuda = has_gpu && gpu_name_lower.contains("nvidia"); + let prefer_vulkan = has_gpu && !prefer_cuda; + + let gpu_asset = if prefer_cuda { + assets + .iter() + .find(|a| is_cuda_llama_cpp_asset(&asset_name_lower(a))) + } else if prefer_vulkan { + assets + .iter() + .find(|a| is_vulkan_llama_cpp_asset(&asset_name_lower(a))) + } else { + None + }; + + let win_asset = gpu_asset + .or_else(|| { + assets.iter().find(|a| { + let name = asset_name_lower(a); + is_windows_x64_zip(&name) && name.contains("cpu") + }) + }) + .or_else(|| { + assets.iter().find(|a| { + let name = asset_name_lower(a); + is_windows_x64_zip(&name) && name.contains("avx2") + }) }) .or_else(|| { assets.iter().find(|a| { - let name = a["name"].as_str().unwrap_or("").to_lowercase(); - name.ends_with(".zip") - && name.contains("win") - && name.contains("avx2") - && name.contains("x64") + let name = asset_name_lower(a); + is_cpu_llama_cpp_asset(&name) }) }) .or_else(|| { assets.iter().find(|a| { - let name = a["name"].as_str().unwrap_or("").to_lowercase(); - name.ends_with(".zip") - && name.contains("win") - && (name.contains("x64") || name.contains("amd64")) + let name = asset_name_lower(a); + is_windows_x64_zip(&name) && !name.contains("cuda") && !name.contains("vulkan") && !name.contains("hip") + && !name.contains("rocm") + && !name.contains("sycl") }) }); let asset = win_asset.ok_or_else(|| { let names: Vec<&str> = assets.iter().filter_map(|a| a["name"].as_str()).collect(); format!( - "No Windows CPU binary found in the latest release. Available assets: {}.", + "No Windows llama.cpp binary found in the latest release. Available assets: {}.", names.join(", ") ) })?; @@ -654,6 +1238,27 @@ async fn do_download_engine(app: &AppHandle, backup_first: bool) -> Result Result = assets + .iter() + .filter_map(|a| { + let name = asset_name_lower(a); + if !is_cuda_runtime_llama_cpp_asset(&name) { + return None; + } + if let Some(marker) = marker.as_deref() { + if !name.contains(marker) { + return None; + } + } + let url = a["browser_download_url"].as_str().unwrap_or(""); + if url.is_empty() { + return None; + } + Some(( + a["name"].as_str().unwrap_or("cuda-runtime.zip").to_string(), + url.to_string(), + )) + }) + .collect(); + + for (runtime_name, runtime_url) in runtime_downloads { + emit_progress(&format!("Downloading CUDA runtime {runtime_name}…"), 88); + emit_log( + app, + format!("[BitNet] Downloading CUDA runtime {runtime_name}"), + ); + + let mut resp = client + .get(&runtime_url) + .send() + .await + .map_err(|e| format!("CUDA runtime download failed: {e}"))?; + + if !resp.status().is_success() { + return Err(format!( + "HTTP {} downloading CUDA runtime {runtime_name}", + resp.status() + )); + } + + let runtime_zip = std::env::temp_dir().join(format!( + "bitnet_cuda_runtime_{}_{}.zip", + std::process::id(), + runtime_name.replace(['\\', '/', ':'], "_") + )); + { + let mut file = tokio::fs::File::create(&runtime_zip) + .await + .map_err(|e| format!("Cannot write CUDA runtime temp file: {e}"))?; + while let Some(chunk) = resp + .chunk() + .await + .map_err(|e| format!("CUDA runtime download error: {e}"))? + { + file.write_all(&chunk) + .await + .map_err(|e| format!("CUDA runtime write error: {e}"))?; + } + file.flush().await.ok(); + } + + let runtime_zip_data = std::fs::read(&runtime_zip) + .map_err(|e| format!("Cannot read CUDA runtime zip: {e}"))?; + let _ = std::fs::remove_file(&runtime_zip); + let cursor = std::io::Cursor::new(runtime_zip_data); + let mut runtime_archive = zip::ZipArchive::new(cursor) + .map_err(|e| format!("Cannot open CUDA runtime zip archive: {e}"))?; + + for i in 0..runtime_archive.len() { + let mut entry = runtime_archive + .by_index(i) + .map_err(|e| format!("CUDA runtime zip entry error: {e}"))?; + let raw_name = entry.name().to_string(); + let lower = raw_name.to_lowercase(); + if !lower.ends_with(".dll") { + continue; + } + let file_name = std::path::Path::new(&raw_name) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(&raw_name) + .to_string(); + let dest = install_dir.join(&file_name); + let mut out = std::fs::File::create(&dest) + .map_err(|e| format!("Cannot write CUDA runtime {file_name}: {e}"))?; + std::io::copy(&mut entry, &mut out) + .map_err(|e| format!("Cannot extract CUDA runtime {file_name}: {e}"))?; + emit_log(app, format!("[BitNet] Extracted CUDA runtime {file_name}")); + } + } + } + // ── 5. Set execute permission on Unix ───────────────────────────────────── #[cfg(not(target_os = "windows"))] { @@ -939,16 +1641,14 @@ pub async fn start_bitnet_server( app: AppHandle, local_name: Option, ctx_size: Option, + load_preference: Option, ) -> Result<(), String> { USER_STOPPED.store(false, Ordering::Relaxed); + let _start_guard = start_lock().lock().await; - if ENGINE_PID.load(Ordering::SeqCst) != 0 { - emit_log(&app, "[BitNet] Engine is already running."); - return Ok(()); - } - - // Auto-extract from bundled resources if the local binary isn't present yet - if !server_exe().exists() { + // Auto-extract from bundled resources if the local binary is missing or + // the packaged engine changed since the last extraction. + if !server_exe().exists() || !extracted_engine_matches_bundle(&app) { // ── Auto-heal: engine landed in models/bitnet/ instead of bitnet_engine/ ── // This happens when a previous download step put everything in one dir. // Silently relocate the binary + all non-GGUF files to the correct path. @@ -999,8 +1699,8 @@ pub async fn start_bitnet_server( } } - if !server_exe().exists() { - if bundled_engine_path(&app).is_some() { + if !server_exe().exists() || !extracted_engine_matches_bundle(&app) { + if bundled_engine_available(&app) { emit_log(&app, "[BitNet] Extracting bundled engine on first use…"); extract_bundled_engine(&app) .map_err(|e| format!("Failed to extract bundled engine: {e}"))?; @@ -1017,23 +1717,74 @@ pub async fn start_bitnet_server( let exe = server_exe(); // Resolve GGUF path - let model_path = if let Some(ref name) = local_name { - let p = models_dir().join(format!("{}.gguf", name)); - if p.exists() { - Some(p) - } else { - // Fallback: scan models dir - find_first_gguf() + let model = match local_name + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + Some(name) => { + let base_name = name.split(':').next().unwrap_or(name); + let exact = models_dir().join(format!("{}.gguf", name)); + let without_tag = models_dir().join(format!("{}.gguf", base_name)); + if exact.exists() { + exact + } else if without_tag.exists() { + without_tag + } else { + return Err(format!( + "GGUF model file not found for '{name}'. Expected {}", + without_tag.display() + )); + } } - } else { - find_first_gguf() + None => find_first_gguf().ok_or_else(|| { + "No BitNet GGUF model found. Download a model first using the browser above." + .to_string() + })?, }; - let model = model_path.ok_or_else(|| { - "No BitNet GGUF model found. Download a model first using the browser above.".to_string() - })?; - let model_str = model.to_string_lossy().into_owned(); + let request_model_name = llama_cpp_request_model_name(&model); + + if ENGINE_PID.load(Ordering::SeqCst) != 0 { + let current_model = model_lock() + .read() + .unwrap_or_else(|e| { + tracing::error!(target: "bitnet_server", "Mutex poisoned at {}:{}", file!(), line!()); + e.into_inner() + }) + .clone(); + if current_model.as_deref() == Some(model_str.as_str()) { + emit_log( + &app, + "[BitNet] Engine is already running with the requested model; verifying chat readiness.", + ); + let ready_client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .map_err(|e| e.to_string())?; + let port = actual_port(); + let base = format!("http://127.0.0.1:{port}"); + wait_for_llama_cpp_chat_ready(&app, &ready_client, &base, port, &request_model_name) + .await?; + emit_status(&app, true, Some(model_str)); + return Ok(()); + } + let pid = ENGINE_PID.load(Ordering::SeqCst); + emit_log( + &app, + format!("[BitNet] Switching engine model - stopping PID {pid}."), + ); + kill_pid(pid); + ENGINE_PID.store(0, Ordering::SeqCst); + ACTUAL_PORT.store(BITNET_PORT, Ordering::SeqCst); + *model_lock().write().unwrap_or_else(|e| { + tracing::error!(target: "bitnet_server", "Mutex poisoned at {}:{}", file!(), line!()); + e.into_inner() + }) = None; + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + } + *model_lock().write().unwrap_or_else(|e| { tracing::error!(target: "bitnet_server", "Mutex poisoned at {}:{}", file!(), line!()); e.into_inner() @@ -1058,22 +1809,31 @@ pub async fn start_bitnet_server( let resolved_ctx = ctx_size.unwrap_or(8192).clamp(512, 65536); let ctx_str = resolved_ctx.to_string(); + let resolved_load_preference = + read_llama_cpp_load_preference(&app, load_preference.clone()).await; + let launch_profile = + build_llama_cpp_launch_profile(local_name.as_deref(), &model, &resolved_load_preference); + emit_llama_cpp_launch_profile(&app, local_name.as_deref(), &model, &launch_profile); + + let mut args = vec![ + "--model".to_string(), + model_str.clone(), + "--port".to_string(), + port.to_string(), + "--host".to_string(), + "127.0.0.1".to_string(), + "--ctx-size".to_string(), + ctx_str, + // Do NOT force --chat-template: each GGUF embeds its own template + // and llama-server detects it automatically. Forcing "chatml" breaks + // models that use a different template (Phi-3.5, Llama 3.x, etc.) + ]; + append_llama_cpp_offload_args(&mut args, &launch_profile); + let mut cmd = TokioCmd::new(&exe); crate::commands::process_utils::hide_window_tokio(&mut cmd); let mut child = cmd - .args([ - "--model", - &model_str, - "--port", - &port.to_string(), - "--host", - "127.0.0.1", - "--ctx-size", - &ctx_str, - // Do NOT force --chat-template: each GGUF embeds its own template - // and llama-server detects it automatically. Forcing "chatml" breaks - // models that use a different template (Phi-3.5, Llama 3.x, etc.) - ]) + .args(&args) .current_dir(engine_dir()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) @@ -1113,75 +1873,67 @@ pub async fn start_bitnet_server( let app_exit = app.clone(); tokio::spawn(async move { let status = child.wait().await; - ENGINE_PID.store(0, Ordering::SeqCst); - *model_lock().write().unwrap_or_else(|e| { - tracing::error!(target: "bitnet_server", "Mutex poisoned at {}:{}", file!(), line!()); - e.into_inner() - }) = None; - ACTUAL_PORT.store(BITNET_PORT, Ordering::SeqCst); match status { Ok(s) => emit_log(&app_exit, format!("[BitNet] Engine exited (code: {s})")), Err(e) => emit_log(&app_exit, format!("[BitNet] Engine wait error: {e}")), } - emit_status(&app_exit, false, None); + if ENGINE_PID + .compare_exchange(pid, 0, Ordering::SeqCst, Ordering::SeqCst) + .is_ok() + { + *model_lock().write().unwrap_or_else(|e| { + tracing::error!(target: "bitnet_server", "Mutex poisoned at {}:{}", file!(), line!()); + e.into_inner() + }) = None; + ACTUAL_PORT.store(BITNET_PORT, Ordering::SeqCst); + emit_status(&app_exit, false, None); + } else { + emit_log( + &app_exit, + format!( + "[BitNet] Ignored exit from old PID {pid}; a newer engine process is active." + ), + ); + } }); - // Health poll — up to 30 s + // Chat-readiness poll. `/health` can become true while llama.cpp is still + // loading a large GGUF into RAM/VRAM; wait until the chat endpoint accepts + // a tiny request so the real user prompt does not receive a false 503. let health_client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(3)) + .timeout(Duration::from_secs(30)) .build() .map_err(|e| e.to_string())?; let base = bitnet_base(); - for _ in 0..30 { - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - let ok = health_client - .get(format!("{base}/health")) - .send() - .await - .map(|r| r.status().is_success()) - .unwrap_or(false); - if ok { - emit_log(&app, format!("[BitNet] Engine ready on port {port}.")); - - // Update any registered BitNet models in the DB so chat routes to - // this engine instead of Ollama. Broad match covers both fresh - // registrations (llama_cpp) and Ollama-imported models (model_name - // contains "bitnet" and endpoint previously pointed at Ollama). - let endpoint = format!("http://127.0.0.1:{port}/v1"); - let local_stem = model - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("") - .to_string(); - let tagged = format!("{}:latest", local_stem); - let now_ts = chrono::Utc::now().to_rfc3339(); - if let Some(db_state) = app.try_state::() { - let _ = sqlx::query!( - "UPDATE models - SET endpoint_url = ?, provider_type = 'llama_cpp', updated_at = ? - WHERE model_name = ? OR model_name = ? - OR model_name LIKE '%bitnet%'", - endpoint, - now_ts, - local_stem, - tagged - ) - .execute(&*db_state.pool) - .await; - let _ = app.emit("models:updated", ()); - emit_log(&app, format!("[BitNet] Chat routing updated → {endpoint}")); - } - - emit_status(&app, true, Some(model_str)); - return Ok(()); - } + wait_for_llama_cpp_chat_ready(&app, &health_client, &base, port, &request_model_name).await?; + emit_log(&app, format!("[BitNet] Engine ready on port {port}.")); + + // Update any registered BitNet models in the DB so chat routes to this + // engine instead of Ollama. Broad match covers both fresh registrations + // (llama_cpp) and Ollama-imported models (model_name contains "bitnet" and + // endpoint previously pointed at Ollama). + let endpoint = format!("http://127.0.0.1:{port}/v1"); + let local_stem = request_model_name.clone(); + let tagged = format!("{}:latest", local_stem); + let now_ts = chrono::Utc::now().to_rfc3339(); + if let Some(db_state) = app.try_state::() { + let _ = sqlx::query!( + "UPDATE models + SET endpoint_url = ?, provider_type = 'llama_cpp', updated_at = ? + WHERE model_name = ? OR model_name = ? + OR model_name LIKE '%bitnet%'", + endpoint, + now_ts, + local_stem, + tagged + ) + .execute(&*db_state.pool) + .await; + let _ = app.emit("models:updated", ()); + emit_log(&app, format!("[BitNet] Chat routing updated → {endpoint}")); } - emit_log( - &app, - "[BitNet] Warning: engine started but did not respond within 30 s.", - ); emit_status(&app, true, Some(model_str)); Ok(()) } @@ -1265,7 +2017,7 @@ pub fn start_bitnet_watchdog(app: AppHandle) { .and_then(|s| s.to_str()) .map(String::from) }); - let _ = start_bitnet_server(app.clone(), local_name, None).await; + let _ = start_bitnet_server(app.clone(), local_name, None, None).await; } } }); @@ -1453,3 +2205,45 @@ fn kill_pid(pid: u32) { .output(); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_model_size_from_common_gguf_names() { + assert_eq!(estimate_params_b_from_text("Qwen3.5 9B Q5 K M"), Some(9.0)); + assert_eq!( + estimate_params_b_from_text("Meta-Llama-3-8B-Instruct-Q4_K_M"), + Some(8.0) + ); + assert_eq!( + estimate_params_b_from_text("qwen2.5-coder:14b-instruct-q6_K"), + Some(14.0) + ); + } + + #[test] + fn classifies_llama_cpp_gpu_and_cpu_assets() { + assert!(is_cuda_llama_cpp_asset( + "llama-b999-bin-win-cuda-cu12.4-x64.zip" + )); + assert!(!is_cuda_llama_cpp_asset( + "cudart-llama-bin-win-cuda-12.4-x64.zip" + )); + assert!(is_cuda_runtime_llama_cpp_asset( + "cudart-llama-bin-win-cuda-12.4-x64.zip" + )); + assert_eq!( + cuda_runtime_marker("llama-b999-bin-win-cuda-12.4-x64.zip"), + Some("cuda-12.4".to_string()) + ); + assert!(is_vulkan_llama_cpp_asset( + "llama-b999-bin-win-vulkan-x64.zip" + )); + assert!(is_cpu_llama_cpp_asset("llama-b999-bin-win-cpu-x64.zip")); + assert!(!is_cpu_llama_cpp_asset( + "llama-b999-bin-win-cuda-cu12.4-x64.zip" + )); + } +} diff --git a/src-tauri/src/commands/colosseum_engine.rs b/src-tauri/src/commands/colosseum_engine.rs index 232626f7..f1384238 100644 --- a/src-tauri/src/commands/colosseum_engine.rs +++ b/src-tauri/src/commands/colosseum_engine.rs @@ -725,7 +725,7 @@ async fn ensure_bitnet_for_colosseum( Some(local_name) }; - crate::commands::bitnet_server::start_bitnet_server(app.clone(), local_name, None).await + crate::commands::bitnet_server::start_bitnet_server(app.clone(), local_name, None, None).await } async fn call_ollama(endpoint_url: &str, model_name: &str, prompt: &str) -> Result { diff --git a/src-tauri/src/commands/dugbee_brain.rs b/src-tauri/src/commands/dugbee_brain.rs index 4169ccd7..ce04fd45 100644 --- a/src-tauri/src/commands/dugbee_brain.rs +++ b/src-tauri/src/commands/dugbee_brain.rs @@ -3705,7 +3705,7 @@ pub async fn dugbee_start_local_brain( match brain_id.as_str() { "bitnet" => { // Reuse the existing BitNet server command — idempotent if already running - crate::commands::bitnet_server::start_bitnet_server(app, None, None).await + crate::commands::bitnet_server::start_bitnet_server(app, None, None, None).await } id if is_ollama_brain_id(id) => { // Ollama manages its own process; if the model appeared in /api/tags it's diff --git a/src-tauri/src/commands/engine.rs b/src-tauri/src/commands/engine.rs index 65fb68ce..149b23c0 100644 --- a/src-tauri/src/commands/engine.rs +++ b/src-tauri/src/commands/engine.rs @@ -1192,6 +1192,42 @@ pub async fn execute_prompt( // A cancel_model(session_id:effective_id) call between the session token // registration and the per-model token registration found no per-model token // and silently no-op'd. Fix: all token registrations happen in ONE lock scope. + let llama_cpp_model_names: Vec = models_to_run + .iter() + .filter(|m| matches!(m.provider_type.as_str(), "llama_cpp" | "bitnet")) + .map(|m| m.model_name.clone()) + .collect(); + if let Some(local_name) = llama_cpp_model_names.first() { + if llama_cpp_model_names.len() > 1 { + emit_exec_trace( + &app, + "Managed llama.cpp startup requested for multiple local GGUF models; More AI will start the first selected model for this run.", + ); + } + if let Err(e) = crate::commands::bitnet_server::start_bitnet_server( + app.clone(), + Some(local_name.clone()), + None, + None, + ) + .await + { + let msg = format!("llama.cpp local runtime could not start for '{local_name}': {e}"); + emit_exec_trace(&app, &msg); + return Err(msg); + } + let actual_endpoint = format!( + "http://127.0.0.1:{}/v1", + crate::commands::bitnet_server::actual_port() + ); + for model in models_to_run + .iter_mut() + .filter(|m| matches!(m.provider_type.as_str(), "llama_cpp" | "bitnet")) + { + model.endpoint_url = actual_endpoint.clone(); + } + } + let cancel_token = CancellationToken::new(); // 4. Spawn independent Tokio tasks diff --git a/src-tauri/src/commands/enterprise_diagnostics.rs b/src-tauri/src/commands/enterprise_diagnostics.rs index 92665d7c..fe590b44 100644 --- a/src-tauri/src/commands/enterprise_diagnostics.rs +++ b/src-tauri/src/commands/enterprise_diagnostics.rs @@ -98,7 +98,7 @@ pub async fn export_enterprise_diagnostic_bundle(app: tauri::AppHandle) -> Resul "category": "sidecars", "status": sidecar_status, "whatFailed": sidecar_failure, - "whatStillWorks": "Text responses and unrelated sidecars can continue; Web Speech may be used as TTS fallback when available.", + "whatStillWorks": "Text responses and unrelated sidecars can continue; DugBee speech stays silent until local Bee TTS is repaired.", "nextStep": "Review runtimeDependencies, Bee TTS diagnostics, Python runtime trust rules, and sidecar auth/token status." } ], diff --git a/src-tauri/src/commands/governance_pack.rs b/src-tauri/src/commands/governance_pack.rs index e293809e..dd14491a 100644 --- a/src-tauri/src/commands/governance_pack.rs +++ b/src-tauri/src/commands/governance_pack.rs @@ -2,7 +2,7 @@ //! governance_pack.rs — Universal Trier OS Governance Pack verification //! ===================================================================== -//! Reads the eight pack files from ./governance/, recomputes SHA-256 of +//! Reads the governance pack files from ./governance/, recomputes SHA-256 of //! each file's body, compares to the canonical hashes in HASHES.json, //! and writes per-file verification state to governance_state. //! @@ -497,10 +497,12 @@ const ALL_GOVERNANCE_FILES: &[&str] = &[ "PACK_VERSION", ]; -/// Deploys all governance files to ~/.more_ai/ on first install. +/// Deploys all canonical governance pack files to ~/.more_ai/. /// Also ensures ~/.more_ai/governance/ exists with at least one rule file — /// this subdirectory is what get_governance_state() checks to allow execution. -/// Skips any file that is already present — preserves user edits. +/// User-authored execution rules live under ~/.more_ai/governance/ and are not +/// part of ALL_GOVERNANCE_FILES, so canonical pack updates can be redeployed +/// without touching user rule files. /// Called once at app startup. pub fn ensure_governance_file_deployed(app: &tauri::AppHandle) { let dest_dir = match dirs::home_dir() { @@ -515,16 +517,11 @@ pub fn ensure_governance_file_deployed(app: &tauri::AppHandle) { let src_dir = bundle_governance_dir(app); - // Files that are authoritative pack data — always overwrite to keep in sync with the bundle. - // User-authored execution rules live in ~/.more_ai/governance/ (not listed here). - const ALWAYS_OVERWRITE: &[&str] = &["HASHES.json", "PACK_VERSION", "MoreAI Governance.md"]; - for fname in ALL_GOVERNANCE_FILES { let dest = dest_dir.join(fname); - let always_overwrite = ALWAYS_OVERWRITE.contains(fname); - if dest.exists() && !always_overwrite { - continue; // Already present — do not overwrite user edits - } + // Canonical pack files must move together with HASHES.json, otherwise + // old user copies can be compared against a new manifest and falsely + // degrade the trust posture. // Ensure parent directory exists (handles calibration/pack_v1.json subdir) if let Some(parent) = dest.parent() { let _ = std::fs::create_dir_all(parent); @@ -659,7 +656,7 @@ pub async fn restore_governance_to_default( r#"INSERT INTO governance_audit (id, file_name, event_kind, new_status, actor, occurred_at, detail) VALUES (?, 'all', 'restored_to_default', 'verified', 'user:restore', ?, - 'Full restore — all 8 governance files redeployed from bundle')"#, + 'Full restore — all governance pack files redeployed from bundle')"#, ) .bind(uuid::Uuid::new_v4().to_string()) .bind(&now) diff --git a/src-tauri/src/commands/hf_download.rs b/src-tauri/src/commands/hf_download.rs index 6d5384eb..a1c08bc1 100644 --- a/src-tauri/src/commands/hf_download.rs +++ b/src-tauri/src/commands/hf_download.rs @@ -148,6 +148,10 @@ pub async fn download_bitnet_gguf( .await .unwrap_or_default(); let hf_token: Option = if raw.is_empty() { None } else { Some(raw) }; + let has_hf_token = hf_token + .as_ref() + .map(|token| !token.trim().is_empty()) + .unwrap_or(false); let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(7200)) @@ -171,10 +175,7 @@ pub async fn download_bitnet_gguf( // 401/403 = invalid/missing token; 404 = valid token but license not yet accepted // HF returns 404 (not 403) for gated repos to mask their existence from non-authorised callers if matches!(resp.status().as_u16(), 401 | 403 | 404) { - let msg = format!( - "Gated model — accept the license at huggingface.co/{} then add your HF token in Settings → Services → HuggingFace Token.", - hf_repo - ); + let msg = hf_auth_error_message(&hf_repo, None, has_hf_token); emit(&msg, 0.0, false, Some(msg.clone())); return Err(msg); } @@ -253,10 +254,7 @@ pub async fn download_bitnet_gguf( })?; if matches!(dl_resp.status().as_u16(), 401 | 403 | 404) { - let msg = format!( - "Gated model — accept the license at huggingface.co/{} then add your HF token in Settings → Services → HuggingFace Token.", - hf_repo - ); + let msg = hf_auth_error_message(&hf_repo, Some(&target.rfilename), has_hf_token); emit(&msg, 0.0, false, Some(msg.clone())); return Err(msg); } @@ -422,6 +420,27 @@ fn emit_error(app: &AppHandle, model_id: &str, msg: String) { ); } +fn hf_auth_error_message(repo_id: &str, filename: Option<&str>, has_token: bool) -> String { + if has_token { + if let Some(file) = filename { + format!( + "Hugging Face rejected the saved token for '{}' while downloading '{}'. Open https://huggingface.co/{} with the same account, accept any model terms, confirm the token has read access, then retry.", + repo_id, file, repo_id + ) + } else { + format!( + "Hugging Face rejected the saved token for '{}'. Open https://huggingface.co/{} with the same account, accept any model terms, confirm the token has read access, then retry.", + repo_id, repo_id + ) + } + } else { + format!( + "'{}' requires a Hugging Face token. Save it in Settings -> Services -> HuggingFace Access Token, then retry.", + repo_id + ) + } +} + // ── Command ─────────────────────────────────────────────────────────────────── #[tauri::command] @@ -436,6 +455,10 @@ pub async fn download_hf_model( .await .unwrap_or_default(); let hf_token: Option = if raw.is_empty() { None } else { Some(raw) }; + let has_hf_token = hf_token + .as_ref() + .map(|token| !token.trim().is_empty()) + .unwrap_or(false); let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(7200)) .user_agent("MoreAI/1.0 (HuggingFace downloader)") @@ -455,10 +478,7 @@ pub async fn download_hf_model( .map_err(|e| format!("Cannot reach Hugging Face: {}", e))?; if resp.status() == 401 || resp.status() == 403 { - let msg = format!( - "'{}' is a gated model. Accept the license on huggingface.co and add your HF token in Settings → Services.", - repo_id - ); + let msg = hf_auth_error_message(&repo_id, None, has_hf_token); emit_error(&app, &model_id, msg.clone()); return Err(msg); } @@ -557,10 +577,7 @@ pub async fn download_hf_model( })?; if dl_resp.status() == 401 || dl_resp.status() == 403 { - let msg = format!( - "'{}' requires a Hugging Face token. Add it in Settings → Services.", - repo_id - ); + let msg = hf_auth_error_message(&repo_id, Some(&file.rfilename), has_hf_token); emit_error(&app, &model_id, msg.clone()); return Err(msg); } diff --git a/src-tauri/src/commands/ollama.rs b/src-tauri/src/commands/ollama.rs index c9a8a671..55bba29a 100644 --- a/src-tauri/src/commands/ollama.rs +++ b/src-tauri/src/commands/ollama.rs @@ -614,6 +614,90 @@ struct OllamaGenerateChunk { done: Option, } +#[derive(Deserialize)] +struct OllamaGenerateProbeResponse { + response: Option, + done: Option, + error: Option, +} + +#[derive(Serialize, Clone)] +pub struct OllamaGenerationProbe { + pub ok: bool, + pub message: String, + pub response_preview: Option, + pub latency_ms: u128, +} + +#[tauri::command] +pub async fn probe_ollama_model_generation( + model_name: String, +) -> Result { + let started = std::time::Instant::now(); + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| format!("HTTP client error: {e}"))?; + + let response = client + .post(format!( + "{}/api/generate", + super::ollama_server::OLLAMA_BASE + )) + .json(&serde_json::json!({ + "model": model_name, + "prompt": "Return exactly IDE_OK", + "stream": false, + "keep_alive": "5m", + "options": { + "num_predict": 8, + "temperature": 0 + } + })) + .send() + .await + .map_err(|e| format!("Ollama unreachable: {e}"))?; + + let status = response.status(); + let body = response.text().await.unwrap_or_else(|_| String::new()); + let latency_ms = started.elapsed().as_millis(); + + if !status.is_success() { + return Ok(OllamaGenerationProbe { + ok: false, + message: format!( + "Ollama returned HTTP {status}: {}", + body.chars().take(220).collect::() + ), + response_preview: None, + latency_ms, + }); + } + + let parsed: OllamaGenerateProbeResponse = serde_json::from_str(&body) + .map_err(|e| format!("Could not parse Ollama probe response: {e}"))?; + if let Some(error) = parsed.error { + return Ok(OllamaGenerationProbe { + ok: false, + message: error.chars().take(220).collect(), + response_preview: None, + latency_ms, + }); + } + + let preview = parsed.response.unwrap_or_default(); + Ok(OllamaGenerationProbe { + ok: parsed.done.unwrap_or(true) && !preview.trim().is_empty(), + message: if preview.trim().is_empty() { + "Ollama responded but returned no text.".to_string() + } else { + "Generation probe passed.".to_string() + }, + response_preview: Some(preview.chars().take(80).collect()), + latency_ms, + }) +} + /// Stream an Ollama chat completion for the Music Studio lyrics sidebar. /// Emits `ollama:lyrics:chunk:{session_id}` with the accumulated text on each /// token, then `ollama:lyrics:done:{session_id}` with the final full text. diff --git a/src-tauri/src/commands/runtime_dependencies.rs b/src-tauri/src/commands/runtime_dependencies.rs index fc7a2b61..905ff458 100644 --- a/src-tauri/src/commands/runtime_dependencies.rs +++ b/src-tauri/src/commands/runtime_dependencies.rs @@ -275,12 +275,6 @@ async fn resolve_dependency_status( entry.missing_user_message.clone() }; } - "web_speech_fallback" => { - installed = true; - status = "ready".into(); - details = - "Browser/OS speech fallback is available when local TTS cannot be used.".into(); - } "ffmpeg_media_tools" => { detected_path = find_resource_path(app, "ffmpeg") .or_else(|| find_resource_path(app, "ffmpeg.exe")) @@ -336,15 +330,25 @@ async fn resolve_dependency_status( }; } "playwright_browsers" => { - detected_path = find_resource_path(app, "playwright_browsers.zip"); + let bundled_zip = find_resource_path(app, "playwright_browsers.zip"); + let supplemental_dir = app + .path() + .app_data_dir() + .ok() + .map(|p| p.join("playwright_browsers")) + .filter(|p| p.exists()); + detected_path = bundled_zip.clone().or(supplemental_dir.clone()); installed = detected_path.is_some(); status = if installed { "ready".into() } else { "missing".into() }; - details = if installed { + details = if bundled_zip.is_some() { "Bundled browser automation runtime is available for offline bridge checks.".into() + } else if supplemental_dir.is_some() { + "Supplemental browser automation runtime is installed in the More AI app data folder." + .into() } else { entry.missing_user_message.clone() }; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7a2972c4..7dd45044 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -124,6 +124,8 @@ pub mod commands; mod db; pub mod error; mod key_store; +#[cfg(test)] +mod lopdf_security_tests; pub mod maintenance; pub mod memory; pub mod model_health; @@ -1150,6 +1152,7 @@ pub fn run() { commands::ollama::launch_ollama, commands::ollama::list_local_ollama_models, commands::ollama::chat_ollama_model, + commands::ollama::probe_ollama_model_generation, commands::ollama::get_ollama_ps, commands::ollama::open_ollama_models_folder, commands::ollama::open_external_url, diff --git a/src-tauri/src/lopdf_security_tests.rs b/src-tauri/src/lopdf_security_tests.rs new file mode 100644 index 00000000..fabd9314 --- /dev/null +++ b/src-tauri/src/lopdf_security_tests.rs @@ -0,0 +1,38 @@ +fn pdf_with_catalog_payload(payload: &str) -> Vec { + let header = "%PDF-1.4\n"; + let catalog = format!( + "1 0 obj\n<< /Type /Catalog /Pages 2 0 R /X {} >>\nendobj\n", + payload + ); + let pages = "2 0 obj\n<< /Type /Pages /Count 0 >>\nendobj\n"; + + let catalog_offset = header.len(); + let pages_offset = catalog_offset + catalog.len(); + let xref_offset = pages_offset + pages.len(); + + format!( + "{header}{catalog}{pages}xref\n0 3\n0000000000 65535 f \n{catalog_offset:010} 00000 n \n{pages_offset:010} 00000 n \ntrailer\n<< /Size 3 /Root 1 0 R >>\nstartxref\n{xref_offset}\n%%EOF\n", + ) + .into_bytes() +} + +#[test] +fn lopdf_rejects_deeply_nested_direct_objects() { + let shallow_payload = format!("{}{}", "[".repeat(4), "]".repeat(4)); + let shallow_pdf = pdf_with_catalog_payload(&shallow_payload); + let shallow_document = lopdf::Document::load_mem(&shallow_pdf) + .unwrap_or_else(|error| panic!("shallow nested PDF should remain readable: {error}")); + assert!( + shallow_document.get_object((1, 0)).is_ok(), + "shallow catalog object should be loaded" + ); + + let deep_payload = format!("{}{}", "[".repeat(150), "]".repeat(150)); + let deep_pdf = pdf_with_catalog_payload(&deep_payload); + let deep_document = lopdf::Document::load_mem(&deep_pdf) + .expect("deeply nested PDF should fail the object, not abort the process"); + assert!( + deep_document.get_object((1, 0)).is_err(), + "deeply nested PDF object should be rejected before recursive parsing can overflow the stack" + ); +} diff --git a/src-tauri/src/swarm_builder.rs b/src-tauri/src/swarm_builder.rs index c448a617..0c5efd23 100644 --- a/src-tauri/src/swarm_builder.rs +++ b/src-tauri/src/swarm_builder.rs @@ -1308,6 +1308,7 @@ pub async fn activate_easy_swarm( app.clone(), Some(row.model_name), Some(computed_ctx), + None, ) .await; } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index f78e971e..5c5e622a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "More AI by Trier OS", - "version": "1.0.8", + "version": "1.10.0", "identifier": "com.trieross.more-ai", "mainBinaryName": "MoreAI", "build": { diff --git a/src/App.css b/src/App.css index 857c6ef4..4cf5feb7 100644 --- a/src/App.css +++ b/src/App.css @@ -320,7 +320,8 @@ body::before { background: rgba(0, 0, 0, 0.72); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); - z-index: 9800; + z-index: 2147483400; + isolation: isolate; display: flex; align-items: flex-start; justify-content: center; @@ -328,6 +329,8 @@ body::before { } .savings-modal-panel { + position: relative; + z-index: 1; background: #0d1117; border: 1px solid #1e293b; border-radius: 0.75rem; diff --git a/src/App.tsx b/src/App.tsx index 3e746aa5..38a0c77a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3188,13 +3188,17 @@ function AppInner() { {/* ── Savings proof popup (opens from SavingsMeter badge click) ── */} - {savingsModalOpen && ( + {savingsModalOpen && typeof document !== 'undefined' && createPortal(
setSavingsModalOpen(false)} >
e.stopPropagation()} >
@@ -3213,7 +3217,8 @@ function AppInner() {
-
+
, + document.body )} {/* ── COE info modal — portalled to body so it clears all panel stacking contexts ── */} diff --git a/src/components/AIWebViewWorkspace.tsx b/src/components/AIWebViewWorkspace.tsx index a00963ba..8c3e87d8 100644 --- a/src/components/AIWebViewWorkspace.tsx +++ b/src/components/AIWebViewWorkspace.tsx @@ -40,6 +40,7 @@ type ActionTier = 'read_only' | 'interactive' | 'form_fill' | 'transactional'; type ControlState = 'user_control' | 'agent_queued' | 'agent_executing' | 'transferring'; interface WebviewStatus { + tab_id?: string | null; current_url: string; title: string; is_loading: boolean; @@ -50,11 +51,17 @@ interface WebviewStatus { } interface WebviewNavigationEvent { + tab_id?: string | null; url: string; title: string; is_https: boolean; } +interface WebviewTitleEvent { + tab_id?: string | null; + title: string; +} + interface BrowserTab { id: string; title: string; @@ -201,9 +208,9 @@ export default function AIWebViewWorkspace() { setBrowserWindowCreated(status.browser_window_created); }, []); - const updateActiveTab = useCallback((patch: Partial) => { - setTabs(prev => prev.map(tab => tab.id === activeTabId ? { ...tab, ...patch, lastActiveAt: Date.now() } : tab)); - }, [activeTabId]); + const updateTabById = useCallback((id: string, patch: Partial) => { + setTabs(prev => prev.map(tab => tab.id === id ? { ...tab, ...patch, lastActiveAt: Date.now() } : tab)); + }, []); const resetPageScopedCoePermissions = useCallback((reason: string) => { setInspectPermission('not_requested'); @@ -268,7 +275,7 @@ export default function AIWebViewWorkspace() { setCoeBusy(true); try { - const page = await invoke('webview_get_page_text'); + const page = await invoke('webview_get_page_text', { tabId: activeTabId }); const prompt = buildCoeWebViewSummaryPrompt(intent, coeWebSnapshot, page.text); const summary = [ `Prepared COE page-summary prompt from ${page.word_count} approved visible words.`, @@ -291,7 +298,7 @@ export default function AIWebViewWorkspace() { } finally { setCoeBusy(false); } - }, [coeWebSnapshot, pushFeed]); + }, [activeTabId, coeWebSnapshot, pushFeed]); const showWebViewGuideOverlay = useCallback(async () => { const inspectPolicy = canCoeInspectWebView(coeWebSnapshot); @@ -314,7 +321,7 @@ export default function AIWebViewWorkspace() { return 'COE guide overlay shown'; })()`; try { - await invoke('webview_execute_script', { script, developerMode: true }); + await invoke('webview_execute_script', { script, developerMode: true, tabId: activeTabId }); const message = 'COE guide overlay shown in the pop-out WebView. No page action was performed.'; setCoeWebNotice(message); pushFeed('success', message); @@ -323,7 +330,7 @@ export default function AIWebViewWorkspace() { setCoeWebNotice(message); pushFeed('error', message); } - }, [coeWebSnapshot, pushFeed]); + }, [activeTabId, coeWebSnapshot, pushFeed]); const prepareFormDraft = useCallback((intent = 'Draft form text for the current page.') => { const actionPolicy = canCoeActOnWebView(coeWebSnapshot); @@ -337,21 +344,21 @@ export default function AIWebViewWorkspace() { return prompt; }, [coeWebSnapshot, pushFeed]); - const navigate = useCallback(async (target: string) => { + const navigate = useCallback(async (target: string, targetTabId = activeTabId) => { const trimmed = target.trim(); if (!trimmed) return; resetPageScopedCoePermissions('COE WebView page permissions reset for the new navigation.'); setLoading(true); pushFeed('info', `Navigate requested: ${trimmed}`); try { - const status = await invoke('webview_navigate', { url: trimmed }); + const status = await invoke('webview_navigate', { url: trimmed, tabId: targetTabId }); applyStatus(status); - updateActiveTab({ url: status.current_url || trimmed, title: status.title || trimmed }); + updateTabById(targetTabId, { url: status.current_url || trimmed, title: status.title || trimmed }); } catch (err) { setLoading(false); pushFeed('error', err instanceof Error ? err.message : String(err)); } - }, [applyStatus, pushFeed, resetPageScopedCoePermissions, updateActiveTab]); + }, [activeTabId, applyStatus, pushFeed, resetPageScopedCoePermissions, updateTabById]); const openExternalRequest = useCallback((detail: AiWebViewOpenDetail) => { const target = detail.url?.trim(); @@ -381,38 +388,38 @@ export default function AIWebViewWorkspace() { const goBack = useCallback(async () => { setLoading(true); try { - const status = await invoke('webview_go_back'); + const status = await invoke('webview_go_back', { tabId: activeTabId }); applyStatus(status); pushFeed('info', 'Back navigation requested.'); } catch (err) { setLoading(false); pushFeed('error', err instanceof Error ? err.message : String(err)); } - }, [applyStatus, pushFeed]); + }, [activeTabId, applyStatus, pushFeed]); const goForward = useCallback(async () => { setLoading(true); try { - const status = await invoke('webview_go_forward'); + const status = await invoke('webview_go_forward', { tabId: activeTabId }); applyStatus(status); pushFeed('info', 'Forward navigation requested.'); } catch (err) { setLoading(false); pushFeed('error', err instanceof Error ? err.message : String(err)); } - }, [applyStatus, pushFeed]); + }, [activeTabId, applyStatus, pushFeed]); const reload = useCallback(async () => { setLoading(true); try { - const status = await invoke('webview_reload'); + const status = await invoke('webview_reload', { tabId: activeTabId }); applyStatus(status); pushFeed('info', 'Reload requested.'); } catch (err) { setLoading(false); pushFeed('error', err instanceof Error ? err.message : String(err)); } - }, [applyStatus, pushFeed]); + }, [activeTabId, applyStatus, pushFeed]); const createTab = useCallback(() => { setTabs(prev => { @@ -427,6 +434,7 @@ export default function AIWebViewWorkspace() { setTitle('AI WebView Home'); setIsHttps(false); setLoading(false); + void invoke('webview_hide_browser', { tabId: null }).catch(() => undefined); pushFeed('info', 'Opened a new AI WebView tab.'); return capped; }); @@ -435,6 +443,7 @@ export default function AIWebViewWorkspace() { const closeTab = useCallback((id: string) => { setTabs(prev => { if (prev.length === 1) return prev; + void invoke('webview_hide_browser', { tabId: id }).catch(() => undefined); const idx = prev.findIndex(t => t.id === id); const next = prev.filter(t => t.id !== id); if (activeTabId === id) { @@ -444,7 +453,12 @@ export default function AIWebViewWorkspace() { setCurrentUrl(replacement.url); setTitle(replacement.title || 'AI WebView Home'); setIsHttps(replacement.url.startsWith('https://')); - if (replacement.url) void navigate(replacement.url); + if (replacement.url) { + void invoke('webview_show_browser', { tabId: replacement.id }) + .catch(() => navigate(replacement.url, replacement.id)); + } else { + void invoke('webview_hide_browser', { tabId: null }).catch(() => undefined); + } } return next; }); @@ -457,7 +471,12 @@ export default function AIWebViewWorkspace() { setCurrentUrl(tab.url); setTitle(tab.title || 'AI WebView Home'); setIsHttps(tab.url.startsWith('https://')); - if (tab.url) void navigate(tab.url); + if (tab.url) { + void invoke('webview_show_browser', { tabId: tab.id }) + .catch(() => navigate(tab.url, tab.id)); + } else { + void invoke('webview_hide_browser', { tabId: null }).catch(() => undefined); + } }, [navigate]); useEffect(() => { @@ -610,38 +629,53 @@ export default function AIWebViewWorkspace() { Promise.all([ listen('webview:navigation_committed', event => { const payload = event.payload; - setCurrentUrl(payload.url); - setAddressDraft(payload.url); - setTitle(payload.title || payload.url); - setIsHttps(payload.is_https); - setLoading(true); - setBrowserWindowCreated(true); - updateActiveTab({ url: payload.url, title: payload.title || payload.url }); + const eventTabId = payload.tab_id ?? activeTabId; + const nextTitle = payload.title || payload.url; + updateTabById(eventTabId, { url: payload.url, title: nextTitle }); + if (!payload.tab_id || eventTabId === activeTabId) { + setCurrentUrl(payload.url); + setAddressDraft(payload.url); + setTitle(nextTitle); + setIsHttps(payload.is_https); + setLoading(true); + setBrowserWindowCreated(true); + } }), listen('webview:page_loaded', event => { const payload = event.payload; - resetPageScopedCoePermissions('COE WebView page permissions reset because the pop-out loaded a page.'); - setCurrentUrl(payload.url); - setAddressDraft(payload.url); - setTitle(payload.title || payload.url); - setIsHttps(payload.is_https); - setLoading(false); - setBrowserWindowCreated(true); - updateActiveTab({ url: payload.url, title: payload.title || payload.url }); - pushFeed('success', `Loaded: ${payload.title || payload.url}`); + const eventTabId = payload.tab_id ?? activeTabId; + const nextTitle = payload.title || payload.url; + updateTabById(eventTabId, { url: payload.url, title: nextTitle }); + if (!payload.tab_id || eventTabId === activeTabId) { + resetPageScopedCoePermissions('COE WebView page permissions reset because the pop-out loaded a page.'); + setCurrentUrl(payload.url); + setAddressDraft(payload.url); + setTitle(nextTitle); + setIsHttps(payload.is_https); + setLoading(false); + setBrowserWindowCreated(true); + pushFeed('success', `Loaded: ${nextTitle}`); + } // COE-6-3: announce page load to DugBee - if (localStorage.getItem('coe_active') === 'true') { + if ((!payload.tab_id || eventTabId === activeTabId) && localStorage.getItem('coe_active') === 'true') { window.dispatchEvent(new CustomEvent('coe:web_navigated', { detail: { url: payload.url, title: payload.title || '', bridgeContext: COE_WEBVIEW_POP_OUT_CONTRACT.context } })); } }), - listen<{ title: string }>('webview:title_changed', event => { - setTitle(event.payload.title || 'Untitled page'); - updateActiveTab({ title: event.payload.title || 'Untitled page' }); + listen('webview:title_changed', event => { + const eventTabId = event.payload.tab_id ?? activeTabId; + const nextTitle = event.payload.title || 'Untitled page'; + updateTabById(eventTabId, { title: nextTitle }); + if (!event.payload.tab_id || eventTabId === activeTabId) { + setTitle(nextTitle); + } }), listen('webview:navigation_blocked', event => { - setLoading(false); + const eventTabId = event.payload.tab_id ?? activeTabId; + if (!event.payload.tab_id || eventTabId === activeTabId) { + setLoading(false); + } pushFeed('warning', `Blocked navigation: ${event.payload.url}`); }), ]).then(listeners => { @@ -653,7 +687,7 @@ export default function AIWebViewWorkspace() { disposed = true; unlisteners.forEach(fn => fn()); }; - }, [applyStatus, pushFeed, resetPageScopedCoePermissions, updateActiveTab]); + }, [activeTabId, applyStatus, pushFeed, resetPageScopedCoePermissions, updateTabById]); const inspectPolicy = canCoeInspectWebView(coeWebSnapshot); const actionPolicy = canCoeActOnWebView(coeWebSnapshot); diff --git a/src/components/AboutModal.tsx b/src/components/AboutModal.tsx index 4b3b024a..36bbc8df 100644 --- a/src/components/AboutModal.tsx +++ b/src/components/AboutModal.tsx @@ -54,7 +54,7 @@ export default function AboutModal({ isOpen, onClose }: AboutModalProps) {

{t('about.title', 'More AI by Trier OS')}

-
v1.0.8
+
v1.10.0

{t('about.tagline', 'Free, open-source AI — 30+ workspaces in one app. Local, cloud, or both.')} diff --git a/src/components/BeeStrip.css b/src/components/BeeStrip.css index 5f70761d..0df8080a 100644 --- a/src/components/BeeStrip.css +++ b/src/components/BeeStrip.css @@ -1712,6 +1712,13 @@ } .bee-vex__q { background: rgba(255,255,255,0.02); } .bee-vex__a { background: rgba(249,115,22,0.04); } +.bee-vex--system .bee-vex__q, +.bee-vex--system .bee-vex__a { + background: rgba(246,173,85,0.08); +} +.bee-vex--system .bee-vex__a { + border-left: 2px solid rgba(246,173,85,0.55); +} .bee-vex__role { font-size: 0.60rem; diff --git a/src/components/BeeStrip.tsx b/src/components/BeeStrip.tsx index 93108989..d5c8a584 100644 --- a/src/components/BeeStrip.tsx +++ b/src/components/BeeStrip.tsx @@ -12,13 +12,15 @@ // controls panel's left edge (hard boundary measured live from controlsRef). // Spring physics + sinusoidal wander keep motion organic and dreamy. // -// TTS priority: +// TTS boundary: // 1. Microsoft Edge Neural TTS via bee_server.py on port 7874 (premium, ~200ms) // 2. Piper TTS via bee_server.py on port 7874 (offline neural fallback) -// 3. Web Speech API (browser fallback if server unreachable) +// Browser/WebView speech synthesis is never allowed to impersonate DugBee. +// If local TTS is unavailable, the UI reports the local voice failure and retries +// the DugBee TTS service instead of handing speech to WebView. // // NOTE: tauri.conf.json must have http://127.0.0.1:* in connect-src and -// blob: in media-src or the fetch silently fails and falls to Web Speech. +// blob: in media-src so local DugBee TTS audio can be fetched and decoded. import { useState, useRef, useCallback, useEffect, type CSSProperties } from 'react'; import { createPortal } from 'react-dom'; @@ -68,7 +70,6 @@ import { classifyCoeVoiceDocumentRepeatRequest, coeSpeechModeForText, coeSpeechPauseMs, - coeSpeechRate, formatSpellingForSpeech, formatSpellingForTts, parseCoeSpeechMode, @@ -1094,9 +1095,12 @@ const TTS_ECHO_SUPPRESSION_MS = 1400; const TTS_WARMUP_TEXT = 'DugBee voice warmup.'; const TTS_ENGINE_RESPONSE_HEADER = 'X-More-AI-TTS-Engine'; const DEFAULT_BEE_TTS_ENGINE_LABEL = 'DugBee Local TTS'; -const WEB_SPEECH_TTS_ENGINE_LABEL = 'Web Speech API'; +const LOCAL_TTS_UNAVAILABLE_ENGINE_LABEL = 'DugBee Local TTS unavailable'; const BEE_TOP_LAYER_Z_INDEX = 2147483647; const MIN_TTS_AUDIO_BYTES = 64; +const VOICE_ASR_MIC_RESET_MISS_THRESHOLD = 2; +const VOICE_ASR_MIC_RESET_WINDOW_MS = 30_000; +const VOICE_ASR_MIC_RESET_COOLDOWN_MS = 5_000; type TtsStartupStatus = 'warming' | 'ready' | 'fallback'; type TtsWarmupOptions = { background?: boolean }; @@ -2099,6 +2103,39 @@ function shouldAcceptAutomaticVoiceTranscript(text: string): boolean { || isExplicitDugBeeVoiceCommand(text); } +function isLikelyUsefulManualVoiceTranscript(text: string): boolean { + const normalized = normalizeAssistantDirectedVoiceText(text); + if (!normalized) return false; + if (shouldAcceptAutomaticVoiceTranscript(normalized)) return true; + if (/^(?:who|what|when|where|why|how|tell me|explain|summari[sz]e|help|can you|could you|would you|please|i want|i need|write|create|make|build|open|go|navigate|switch|turn|run|download|install|setup|show|find|search|read|say|use|try|check|give me)\b/.test(normalized)) { + return true; + } + return normalized.split(/\s+/).length >= 6; +} + +function isZeroConfidenceSpeechResult(confidence: number | null): boolean { + return confidence != null && confidence <= 0.01; +} + +function buildSpeechRecognitionMissMessage( + rawTranscript: string, + normalizedTranscript: string, + confidence: number | null, + deviceLabel?: string, +): string { + const heard = (rawTranscript || normalizedTranscript).trim(); + const confidenceText = confidence != null ? ` Confidence ${Math.round(confidence * 100)}%.` : ''; + const deviceText = deviceLabel ? ` Active input: ${deviceLabel}.` : ''; + if (isZeroConfidenceSpeechResult(confidence)) { + return heard + ? `Browser speech recognition guessed "${heard}" with 0% confidence, so More AI ignored it instead of treating it as what you said.${deviceText} Check the selected Windows input device or try push-to-talk once after switching microphones.` + : `Browser speech recognition returned a 0% confidence result, so More AI ignored it.${deviceText} Check the selected Windows input device or try push-to-talk once after switching microphones.`; + } + return heard + ? `Voice input heard "${heard}", but not a clear DugBee command. No action was run.${confidenceText}` + : 'Voice input heard speech, but not enough clear words to run a DugBee command. No action was run.'; +} + function isLocalBrainOfflineNotice(text: string): boolean { return /\b(?:isn't|is not|isnt)\s+running\s+right\s+now\b/i.test(text) || /\b(?:bitnet|ollama|local brain|selected dugbee brain)\b[\s\S]{0,120}\b(?:not available|request failed|offline|connection refused|running right now)\b/i.test(text); @@ -2378,10 +2415,6 @@ function generateSessionId(): string { return `bee-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; } -function isSpeechAvailable(): boolean { - return typeof window !== 'undefined' && 'speechSynthesis' in window; -} - const TTS_PRONUNCIATION_LEXICON_KEY = 'more_ai_tts_pronunciation_lexicon_v1'; interface TtsPronunciationRule { @@ -2562,6 +2595,7 @@ export default function BeeStrip() { // Track it directly so unavailable or denied mic input never looks like a dead button. const [voiceInputHealth, setVoiceInputHealth] = useState('unknown'); const [voiceInputIssue, setVoiceInputIssue] = useState(''); + const lastVoiceAsrMissNoticeAtRef = useRef(0); const [showHistory, setShowHistory] = useState(false); const [history, setHistory] = useState([]); const [historyDrawerStyle, setHistoryDrawerStyle] = useState({}); @@ -2694,6 +2728,10 @@ export default function BeeStrip() { // null = mic hasn't fired no-speech yet this session. // Set when first no-speech fires; reset when the user actually speaks. const silenceStartRef = useRef(null); + const voiceInputDeviceLabelRef = useRef(''); + const zeroConfidenceSpeechMissesRef = useRef({ count: 0, lastAt: 0 }); + const voiceInputResetInFlightRef = useRef(false); + const voiceInputLastResetAtRef = useRef(0); // ── Conversation session state machine ──────────────────────────────────── // Counts how many agent responses have arrived since the user last spoke. // Conversation mode itself stays enabled until the user turns it off. @@ -3546,7 +3584,7 @@ export default function BeeStrip() { const markTtsStartupStatus = useCallback((status: TtsStartupStatus, engineLabel?: string) => { const nextEngineLabel = (engineLabel || '').trim() - || (status === 'fallback' ? WEB_SPEECH_TTS_ENGINE_LABEL : ttsEngineLabelRef.current) + || (status === 'fallback' ? LOCAL_TTS_UNAVAILABLE_ENGINE_LABEL : ttsEngineLabelRef.current) || DEFAULT_BEE_TTS_ENGINE_LABEL; if (status === 'ready') { ttsFallbackRetryStartedAtRef.current = null; @@ -3650,7 +3688,7 @@ export default function BeeStrip() { } ttsReadyRef.current = false; if (!background) { - markTtsStartupStatus('fallback', WEB_SPEECH_TTS_ENGINE_LABEL); + markTtsStartupStatus('fallback', LOCAL_TTS_UNAVAILABLE_ENGINE_LABEL); } return false; })(); @@ -3719,14 +3757,14 @@ export default function BeeStrip() { return () => window.removeEventListener('dugbee:tts-retry-local', handleRetryLocalTts); }, [markTtsStartupStatus, startPremiumTtsWarmup]); - // ── TTS — Edge Neural (via bee_server) → Web Speech fallback ─────────────── + // ── TTS — local Bee audio only (Edge Neural or Piper via bee_server) ──────── // // SPEAKER ARBITRATION: this function is the single gateway for all TTS output. // It ALWAYS aborts any active speech recognizer before playing audio, so the // mic can never capture TTS speaker output and create a feedback loop. // - // AWAITABLE: both the Edge TTS and Web Speech paths now resolve a Promise only - // when audio playback is fully complete (not when it starts). This means + // AWAITABLE: the local Bee TTS audio path resolves a Promise only when audio + // playback is fully complete (not when it starts). This means // callers that do `await speak(...)` block until the voice finishes speaking — // the mic reopen logic in bee:chat-response-ready therefore fires AFTER TTS ends, // with an additional 500 ms debounce from the speaking→idle useEffect. @@ -4096,7 +4134,7 @@ export default function BeeStrip() { closeAudioContextQuietly(ctx); ttsStopRef.current = null; resolvePlayback(); - throw decodeErr; // fall through to Web Speech + throw decodeErr; } await playbackDone; // wait until audio finishes or is stopped @@ -4105,93 +4143,29 @@ export default function BeeStrip() { console.warn( '[BeeStrip] bee_server /speak did not return all requested audio buffers:', serverFailure || serverStatus || 'unknown failure', - '-- falling back to Web Speech', + '-- DugBee Local TTS is unavailable', ); - emitTtsPlaybackStatus('fallback'); + emitTtsPlaybackStatus('failed'); } } catch (err) { if (speakTimeout !== null) window.clearTimeout(speakTimeout); const isAbortError = err instanceof DOMException && err.name === 'AbortError'; if (isAbortError && !speakTimedOut) return; if (speakSeq !== ttsSpeakSeqRef.current) return; - console.warn('[BeeStrip] bee_server /speak failed:', err, '— falling back to Web Speech'); - emitTtsPlaybackStatus('fallback'); + console.warn('[BeeStrip] bee_server /speak failed:', err, '-- DugBee Local TTS is unavailable'); + emitTtsPlaybackStatus('failed'); } - // Web Speech API — last-resort fallback (only fires if server is unreachable) + // DugBee TTS is local-only. Browser/WebView speech synthesis must never + // impersonate DugBee, so stop here and surface the local voice failure. if (speakSeq !== ttsSpeakSeqRef.current) return; ttsReadyRef.current = false; - markTtsStartupStatus('fallback', WEB_SPEECH_TTS_ENGINE_LABEL); - if (!isSpeechAvailable()) { - setBeeState('happy'); - setTimeout(() => setBeeState(isMuted ? 'muted' : 'idle'), 1200); - emitTtsPlaybackStatus('failed'); - return; - } - window.speechSynthesis.cancel(); - const utter = new SpeechSynthesisUtterance(text); - utter.rate = coeSpeechRate(activeSpeechMode); - utter.pitch = 1.0; - utter.volume = 0.9; - utter.onstart = () => { - if (speakSeq !== ttsSpeakSeqRef.current) return; - const estimatedSpeechMs = Math.min(30000, Math.max(1000, text.split(/\s+/).length * 360)); - voiceOutputGuardUntilRef.current = Math.max( - voiceOutputGuardUntilRef.current, - Date.now() + estimatedSpeechMs + TTS_ECHO_SUPPRESSION_MS, - ); - setBeeState('speaking'); - voiceConversationStateRef.current = nextVoiceBargeInState(voiceConversationStateRef.current, 'tts_started'); - markCoeFirstAudio(); - if (allowBargeIn) startVoiceBargeInMonitor(text); - }; - // Await completion so the mic never reopens while the utterance is playing - await new Promise(resolve => { - ttsStopRef.current = () => { - stopVoiceBargeInMonitor(); - window.speechSynthesis.cancel(); - if (speakSeq === ttsSpeakSeqRef.current) { - setBeeState('idle'); - voiceConversationStateRef.current = nextVoiceBargeInState(voiceConversationStateRef.current, 'tts_finished'); - voiceOutputGuardUntilRef.current = Math.max( - voiceOutputGuardUntilRef.current, - Date.now() + TTS_ECHO_SUPPRESSION_MS, - ); - } - emitTtsPlaybackStatus('stopped'); - resolve(); - }; - utter.onend = () => { - stopVoiceBargeInMonitor(); - ttsStopRef.current = null; - if (speakSeq === ttsSpeakSeqRef.current) { - recordEnterpriseLastKnownGood(MORE_AI_LKG_TTS_KEY); - setBeeState('idle'); - voiceConversationStateRef.current = nextVoiceBargeInState(voiceConversationStateRef.current, 'tts_finished'); - voiceOutputGuardUntilRef.current = Math.max( - voiceOutputGuardUntilRef.current, - Date.now() + TTS_ECHO_SUPPRESSION_MS, - ); - emitTtsPlaybackStatus('completed'); - } - resolve(); - }; - utter.onerror = () => { - stopVoiceBargeInMonitor(); - ttsStopRef.current = null; - if (speakSeq === ttsSpeakSeqRef.current) { - setBeeState('idle'); - voiceConversationStateRef.current = nextVoiceBargeInState(voiceConversationStateRef.current, 'error'); - voiceOutputGuardUntilRef.current = Math.max( - voiceOutputGuardUntilRef.current, - Date.now() + TTS_ECHO_SUPPRESSION_MS, - ); - } - emitTtsPlaybackStatus('failed'); - resolve(); - }; - window.speechSynthesis.speak(utter); - }); + markTtsStartupStatus('fallback', LOCAL_TTS_UNAVAILABLE_ENGINE_LABEL); + stopVoiceBargeInMonitor(); + ttsStopRef.current = null; + voiceConversationStateRef.current = nextVoiceBargeInState(voiceConversationStateRef.current, 'error'); + setBeeState('alert'); + setTimeout(() => setBeeState(isMuted ? 'muted' : 'idle'), 1200); }, [isMuted, beePort, markTtsStartupStatus, startVoiceBargeInMonitor, stopVoiceBargeInMonitor, waitForPremiumTtsReady]); // Keep speakRef always pointing at the latest speak closure so that async @@ -5514,7 +5488,7 @@ export default function BeeStrip() { } } else { ttsReadyRef.current = false; - markTtsStartupStatus('fallback', WEB_SPEECH_TTS_ENGINE_LABEL); + markTtsStartupStatus('fallback', LOCAL_TTS_UNAVAILABLE_ENGINE_LABEL); const cacheKey = buildLiveContextCacheKey(liveContextIntent, liveLocationContext); const cached = liveContextCacheRef.current.get(cacheKey); if (cached && cached.expiresAt > Date.now()) { @@ -6377,7 +6351,7 @@ export default function BeeStrip() { } catch (err) { if (!isCurrentBeeRequest()) return; ttsReadyRef.current = false; - markTtsStartupStatus('fallback', WEB_SPEECH_TTS_ENGINE_LABEL); + markTtsStartupStatus('fallback', LOCAL_TTS_UNAVAILABLE_ENGINE_LABEL); console.error('[BeeStrip] bee_get_response error:', err); logBeeEvent('skill.failed', 'bee_get_response'); setResponseText(t('bee.error', 'I hit a snag — try again in a moment.')); @@ -6446,7 +6420,7 @@ export default function BeeStrip() { } } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); - setDiagData({ server: 'offline', last_error: msg || 'Connection refused — server not running', last_fallback_reason: 'bee_server.py not running — Web Speech fallback active' }); + setDiagData({ server: 'offline', last_error: msg || 'Connection refused — server not running', last_fallback_reason: 'bee_server.py not running — DugBee Local TTS unavailable' }); } finally { setDiagLoading(false); } @@ -6777,6 +6751,138 @@ export default function BeeStrip() { setBeeState(prev => prev === 'listening' ? 'idle' : prev); }, [stopDiscoSynth]); + const resetBrowserSpeechRecognitionMic = useCallback(async (reason: string): Promise => { + const now = Date.now(); + if (voiceInputResetInFlightRef.current) return false; + if (now - voiceInputLastResetAtRef.current < VOICE_ASR_MIC_RESET_COOLDOWN_MS) return false; + + voiceInputResetInFlightRef.current = true; + voiceInputLastResetAtRef.current = now; + stopListening(); + await wait(150); + + try { + if (!navigator.mediaDevices?.getUserMedia) { + throw new Error('navigator.mediaDevices.getUserMedia is unavailable'); + } + + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const audioTrack = stream.getAudioTracks()[0] ?? stream.getTracks()[0]; + const deviceLabel = audioTrack?.label?.trim() || ''; + if (deviceLabel) voiceInputDeviceLabelRef.current = deviceLabel; + stream.getTracks().forEach(track => track.stop()); + + if (navigator.mediaDevices.enumerateDevices) { + try { await navigator.mediaDevices.enumerateDevices(); } catch { /* device refresh is best-effort */ } + } + + zeroConfidenceSpeechMissesRef.current = { count: 0, lastAt: 0 }; + silenceStartRef.current = null; + const resetMessage = deviceLabel + ? `More AI reset the browser microphone stream for ${deviceLabel}. Try DugBee again.` + : 'More AI reset the browser microphone stream. Try DugBee again.'; + setVoiceInputHealth(getBrowserSpeechRecognitionCtor() ? 'ready' : 'unavailable'); + setVoiceInputIssue(resetMessage); + appendVoiceLogEntry({ + userText: 'Browser ASR mic stream reset', + answer: `${resetMessage} Trigger: ${reason}.`, + brainName: 'Voice input', + brainIcon: 'MIC', + kind: 'system', + }); + window.setTimeout(() => { + setVoiceInputIssue(current => current === resetMessage ? '' : current); + }, 6500); + + if (conversationModeRef.current) { + window.setTimeout(() => { + if ( + conversationModeRef.current && + !isListeningRef.current && + !recognitionRef.current && + Date.now() >= voiceOutputGuardUntilRef.current + ) { + startPTTRef.current?.(); + } + }, 300); + } + return true; + } catch (error) { + const message = `More AI tried to reset the browser microphone stream but Windows/WebView did not reopen it: ${String(error)}`; + setVoiceInputHealth('degraded'); + setVoiceInputIssue(message); + appendVoiceLogEntry({ + userText: 'Browser ASR mic stream reset failed', + answer: message, + brainName: 'Voice input', + brainIcon: 'MIC', + kind: 'system', + }); + return false; + } finally { + voiceInputResetInFlightRef.current = false; + } + }, [appendVoiceLogEntry, stopListening]); + + const reportSpeechRecognitionMiss = useCallback(( + rawTranscript: string, + normalizedTranscript: string, + confidence: number | null, + ) => { + const now = Date.now(); + if (now - lastVoiceAsrMissNoticeAtRef.current < 2500) return; + lastVoiceAsrMissNoticeAtRef.current = now; + const heard = (rawTranscript || normalizedTranscript).trim(); + const zeroConfidence = isZeroConfidenceSpeechResult(confidence); + const deviceLabel = voiceInputDeviceLabelRef.current.trim(); + let message = buildSpeechRecognitionMissMessage(rawTranscript, normalizedTranscript, confidence, deviceLabel); + let pausedConversationMode = false; + let scheduledMicReset = false; + if (zeroConfidence) { + const streak = zeroConfidenceSpeechMissesRef.current; + const withinSameRun = now - streak.lastAt < VOICE_ASR_MIC_RESET_WINDOW_MS; + zeroConfidenceSpeechMissesRef.current = { + count: withinSameRun ? streak.count + 1 : 1, + lastAt: now, + }; + if (zeroConfidenceSpeechMissesRef.current.count >= VOICE_ASR_MIC_RESET_MISS_THRESHOLD) { + scheduledMicReset = true; + message += ' More AI is resetting the browser microphone stream now, the same way toggling the Windows mic can clear a stale input path.'; + window.setTimeout(() => { + void resetBrowserSpeechRecognitionMic('repeated zero-confidence SpeechRecognition guesses'); + }, 250); + } + if (zeroConfidenceSpeechMissesRef.current.count >= 3 && conversationModeRef.current) { + pausedConversationMode = true; + setConversationMode(false); + message += ' Conversation mode was paused because the recognizer keeps returning zero-confidence guesses.'; + } + } else { + zeroConfidenceSpeechMissesRef.current = { count: 0, lastAt: 0 }; + } + setVoiceInputHealth('degraded'); + setVoiceInputIssue(message); + setBeeState('alert'); + setIntentLabel('voice:not understood'); + setResponseText(message); + appendVoiceLogEntry({ + userText: zeroConfidence + ? (heard ? `Recognizer guessed at 0%: ${heard}` : 'Recognizer returned 0% confidence') + : (heard ? `Heard: ${heard}` : 'Heard unclear speech'), + answer: zeroConfidence + ? `${message}${pausedConversationMode || scheduledMicReset ? '' : ' If this repeats, turn conversation mode off, check Windows Sound Settings input, then try one push-to-talk recording.'}` + : `${message} Try saying "DugBee, what time is it" again with the microphone closer, or check the selected Windows input device.`, + brainName: 'Voice input', + brainIcon: 'MIC', + kind: 'system', + }); + window.setTimeout(() => { + setVoiceInputHealth(getBrowserSpeechRecognitionCtor() ? 'ready' : 'unavailable'); + setVoiceInputIssue(current => current === message ? '' : current); + setBeeState(prev => prev === 'alert' ? (isMuted ? 'muted' : 'idle') : prev); + }, 6500); + }, [appendVoiceLogEntry, isMuted, resetBrowserSpeechRecognitionMic]); + const startPTT = useCallback((opts?: { manual?: boolean }) => { const manual = opts?.manual === true; if (!manual && Date.now() < voiceOutputGuardUntilRef.current) return; @@ -6807,7 +6913,7 @@ export default function BeeStrip() { stopListening(); // ── Bluetooth microphone wake-up ────────────────────────────────────────── - // Web Speech API does NOT automatically switch a Bluetooth headset into its + // Browser SpeechRecognition does NOT automatically switch a Bluetooth headset into its // microphone profile (HFP/AG Audio). We call getUserMedia first — this forces // the OS/Bluetooth stack to activate the mic pipeline before SpeechRecognition // tries to open it. Without this, AirPods (and other BT headsets) correctly @@ -6911,12 +7017,31 @@ export default function BeeStrip() { decisionReason: 'ambient_not_directed', confidence: finalConfidence, }); + preserveVoiceInputIssue = true; + reportSpeechRecognitionMiss(rawTranscript, text, finalConfidence); silenceStartRef.current = Date.now(); noSpeechRestart = true; try { rec.stop(); } catch { stopListening(); } return; } + if (manual && !isLikelyUsefulManualVoiceTranscript(text)) { + setIntentLabel('voice:not understood'); + logDugBeeVoiceEvent({ + eventType: 'speech_ignored', + rawTranscript, + normalizedTranscript: text, + alternatives: finalAlternatives, + accepted: false, + decisionReason: 'manual_unclear_asr', + confidence: finalConfidence, + }); + preserveVoiceInputIssue = true; + reportSpeechRecognitionMiss(rawTranscript, text, finalConfidence); + try { rec.stop(); } catch { stopListening(); } + return; + } if (text) { + zeroConfidenceSpeechMissesRef.current = { count: 0, lastAt: 0 }; voiceConversationStateRef.current = nextVoiceBargeInState(voiceConversationStateRef.current, 'speech_final'); logDugBeeVoiceEvent({ eventType: 'speech_accepted', @@ -7006,7 +7131,7 @@ export default function BeeStrip() { recognitionRef.current = null; isListeningRef.current = false; setIsListening(false); - if (!endedWithBlockingError) { + if (!endedWithBlockingError && !preserveVoiceInputIssue) { setVoiceInputHealth(getBrowserSpeechRecognitionCtor() ? 'ready' : 'unavailable'); } setBeeState(prev => prev === 'listening' ? 'idle' : prev); @@ -7037,6 +7162,8 @@ export default function BeeStrip() { decisionReason: 'ambient_not_directed_interim_fallback', confidence: null, }); + preserveVoiceInputIssue = true; + reportSpeechRecognitionMiss(fallbackRawText, fallbackText, null); silenceStartRef.current = Date.now(); noSpeechRestart = true; return; @@ -7098,6 +7225,8 @@ export default function BeeStrip() { if (navigator.mediaDevices?.getUserMedia) { navigator.mediaDevices.getUserMedia({ audio: true }) .then(stream => { + const audioTrack = stream.getAudioTracks()[0] ?? stream.getTracks()[0]; + if (audioTrack?.label) voiceInputDeviceLabelRef.current = audioTrack.label; // Stop all tracks immediately — we only needed the device activation stream.getTracks().forEach(t => t.stop()); doStartRecognition(); @@ -7110,7 +7239,7 @@ export default function BeeStrip() { } else { doStartRecognition(); } - }, [isMuted, stopListening, handleSubmit, hasActivePendingDugBeeReply, logDugBeeVoiceEvent, t]); + }, [isMuted, stopListening, handleSubmit, hasActivePendingDugBeeReply, logDugBeeVoiceEvent, reportSpeechRecognitionMiss, t]); // Keep ref current so the conv-mode auto-reopen can always call latest startPTT startPTTRef.current = startPTT; @@ -7348,6 +7477,10 @@ export default function BeeStrip() { voiceInputHealth === 'degraded' ? t('bee.voiceInput.degraded', 'Voice issue') : voiceInputHealth === 'unknown' ? t('bee.voiceInput.checking', 'Checking mic') : ''; + const voiceInputDiagTone = + voiceInputHealth === 'ready' || voiceInputHealth === 'listening' ? 'ok' : + voiceInputHealth === 'unknown' || voiceInputHealth === 'degraded' ? 'warn' : + 'err'; const voiceInputStatusVisible = isListening || voiceInputHealth === 'unavailable' || @@ -7823,6 +7956,22 @@ export default function BeeStrip() { )} +

+ Speech input + + {beeVoiceContract.asr.statusLabel}: {voiceInputStatusLabel || voiceInputHealth} + +
+ {voiceInputIssue && ( +
+ Last heard + {voiceInputIssue} +
+ )} {diagLoading ? (
Loading…
@@ -8135,7 +8284,7 @@ export default function BeeStrip() {
{voiceLog.map(ex => ( -
+
You {ex.userText} diff --git a/src/components/ChatWorkspace.css b/src/components/ChatWorkspace.css index 1b92953d..a9162e9c 100644 --- a/src/components/ChatWorkspace.css +++ b/src/components/ChatWorkspace.css @@ -766,6 +766,70 @@ align-self: center; } +.model-pill-load { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 34px; + padding: 1px 6px; + border-radius: 999px; + font-size: 9px; + font-weight: 800; + letter-spacing: 0.04em; + line-height: 1.35; + border: 1px solid rgba(148, 163, 184, 0.35); + background: rgba(148, 163, 184, 0.10); + color: #cbd5e1; +} + +.model-pill-load--gpu { + border-color: rgba(52, 211, 153, 0.45); + background: rgba(52, 211, 153, 0.14); + color: #34d399; +} + +.model-pill-load--hybrid { + border-color: rgba(251, 191, 36, 0.50); + background: rgba(251, 191, 36, 0.14); + color: #fbbf24; +} + +.model-pill-load--cpu { + border-color: rgba(248, 113, 113, 0.48); + background: rgba(248, 113, 113, 0.13); + color: #fca5a5; +} + +.model-pill-load-pref { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 8px; + border-radius: 18px; + border: 1px solid rgba(99, 102, 241, 0.32); + background: rgba(99, 102, 241, 0.10); + color: #c7d2fe; + font-size: 11px; + font-weight: 700; +} + +.model-pill-load-pref select { + min-width: 92px; + background: rgba(5, 8, 22, 0.88); + border: 1px solid rgba(99, 102, 241, 0.45); + border-radius: 8px; + color: #e0e7ff; + font-size: 11px; + font-weight: 700; + padding: 2px 20px 2px 6px; + outline: none; +} + +.model-pill-load-pref select:disabled { + opacity: 0.55; + cursor: not-allowed; +} + .model-pill-remove { background: rgba(248, 113, 113, 0.10); border: 1px solid rgba(248, 113, 113, 0.30); diff --git a/src/components/ChatWorkspace.tsx b/src/components/ChatWorkspace.tsx index b5b47272..42d9d538 100644 --- a/src/components/ChatWorkspace.tsx +++ b/src/components/ChatWorkspace.tsx @@ -320,6 +320,120 @@ interface Attachment { dataUrl?: string; // preview URL for images } +type LlamaCppLoadLane = 'gpu' | 'cpu' | 'hybrid' | 'unknown'; +type LlamaCppLoadPreference = 'auto' | 'gpu' | 'cpu' | 'hybrid' | 'low_memory'; + +interface LlamaCppLaunchProfileEvent { + runtime: string; + load_preference: string; + load_lane: LlamaCppLoadLane; + gpu_requested: boolean; + offload_layers: number; + params_b?: number | null; + model_size_mb: number; + model_name?: string | null; + model_stem?: string | null; + model_path?: string | null; + gpu_name?: string | null; + vram_total_mb: number; + vram_available_mb: number; + engine_has_gpu_backend: boolean; + warning?: string | null; +} + +function normalizeModelIdentity(value?: string | null): string { + return (value ?? '') + .toLowerCase() + .replace(/\.gguf$/, '') + .replace(/[^a-z0-9]+/g, ''); +} + +function basenameWithoutExtension(path?: string | null): string { + if (!path) return ''; + const normalized = path.replace(/\\/g, '/'); + const file = normalized.split('/').pop() ?? normalized; + return file.replace(/\.gguf$/i, ''); +} + +function llamaCppProfileKeys(profile: LlamaCppLaunchProfileEvent): string[] { + return [ + profile.model_name, + profile.model_stem, + basenameWithoutExtension(profile.model_path), + profile.model_path, + ] + .map(normalizeModelIdentity) + .filter(Boolean); +} + +function modelIdentityKeys(model: ModelRecord): string[] { + return [ + model.model_name, + model.display_name, + basenameWithoutExtension(model.model_name), + ] + .map(normalizeModelIdentity) + .filter(Boolean); +} + +function llamaCppModelSizeLabel(profile: LlamaCppLaunchProfileEvent): string { + if (typeof profile.params_b === 'number') return `${profile.params_b.toFixed(1)}B`; + if (profile.model_size_mb > 0) return `${(profile.model_size_mb / 1024).toFixed(1)} GB`; + return 'unknown size'; +} + +function llamaCppLaneLabel(profile: LlamaCppLaunchProfileEvent): string { + if (profile.load_lane === 'gpu') return 'GPU'; + if (profile.load_lane === 'hybrid') return 'HYBRID'; + if (profile.load_lane === 'cpu') return 'CPU'; + return 'LANE?'; +} + +function llamaCppLaneTitle(profile: LlamaCppLaunchProfileEvent): string { + const vram = profile.vram_total_mb > 0 + ? `${(profile.vram_available_mb / 1024).toFixed(1)} / ${(profile.vram_total_mb / 1024).toFixed(1)} GB VRAM available` + : 'VRAM unknown'; + const gpu = profile.gpu_name ? `GPU: ${profile.gpu_name}` : 'GPU: not detected'; + const offload = profile.offload_layers > 0 + ? `Offload: ${profile.offload_layers} layers requested` + : 'Offload: none'; + const warning = profile.warning ? `\n${profile.warning}` : ''; + return `Runtime: ${profile.runtime}\nLoad lane: ${llamaCppLaneLabel(profile)} (${profile.load_preference})\nModel size: ${llamaCppModelSizeLabel(profile)}\n${gpu}\n${vram}\n${offload}${warning}`; +} + +const LLAMA_CPP_LOAD_PREFERENCE_KEY = 'llama_cpp_load_preference'; + +const LLAMA_CPP_LOAD_PREFERENCE_OPTIONS: Array<{ value: LlamaCppLoadPreference; label: string; title: string }> = [ + { value: 'auto', label: 'Auto', title: 'More AI chooses the safest lane for this model and hardware.' }, + { value: 'gpu', label: 'GPU / VRAM', title: 'Request GPU offload for llama.cpp models.' }, + { value: 'cpu', label: 'CPU / RAM', title: 'Force CPU/RAM mode. Slower, but preserves VRAM for other workloads.' }, + { value: 'hybrid', label: 'Hybrid', title: 'Prefer GPU offload while allowing system RAM fallback.' }, + { value: 'low_memory', label: 'Low memory', title: 'Avoid GPU offload and use the most conservative local lane.' }, +]; + +function normalizeLlamaCppLoadPreference(value?: string | null): LlamaCppLoadPreference { + switch ((value ?? '').trim().toLowerCase()) { + case 'gpu': + case 'vram': + case 'gpu_vram': + return 'gpu'; + case 'cpu': + case 'ram': + case 'cpu_ram': + return 'cpu'; + case 'hybrid': + case 'gpu_ram': + case 'gpu+ram': + return 'hybrid'; + case 'low_memory': + case 'low-memory': + case 'lowmemory': + return 'low_memory'; + default: + return 'auto'; + } +} + // Replaces inline base64 images with a short placeholder before storing in // conversation history. Prevents multi-MB data URIs from being re-sent to // models on every subsequent turn, which causes Ollama/local models to hang. @@ -666,6 +780,8 @@ export default function ChatWorkspace({ pct_used?: number; } const [cppContextAlerts, setCppContextAlerts] = useState([]); + const [llamaCppLaunchProfiles, setLlamaCppLaunchProfiles] = useState>({}); + const [llamaCppLoadPreference, setLlamaCppLoadPreference] = useState('auto'); // ── Model bar state ── const [selectedLocalIds, setSelectedLocalIds] = useState>(() => { @@ -739,6 +855,21 @@ export default function ChatWorkspace({ closeAudioContextQuietly(beeAudioCtx.current); }; }, []); + + useEffect(() => { + invoke('get_app_config_value', { key: LLAMA_CPP_LOAD_PREFERENCE_KEY }) + .then(value => setLlamaCppLoadPreference(normalizeLlamaCppLoadPreference(value))) + .catch(() => setLlamaCppLoadPreference('auto')); + }, []); + + const handleLlamaCppLoadPreferenceChange = (next: LlamaCppLoadPreference) => { + setLlamaCppLoadPreference(next); + invoke('set_app_config_value', { key: LLAMA_CPP_LOAD_PREFERENCE_KEY, value: next }) + .catch(err => { + console.error('Failed to save llama.cpp load preference', err); + }); + }; + const onlinePopoverRef = useRef(null); const openRouterPanelRef = useRef(null); const peerPopoverRef = useRef(null); @@ -933,6 +1064,15 @@ export default function ChatWorkspace({ title: badge.tip, })); + const getLlamaCppLaunchProfile = (model: ModelRecord): LlamaCppLaunchProfileEvent | null => { + if (model.provider_type !== 'llama_cpp' && !isBitNet(model.model_name)) return null; + for (const key of modelIdentityKeys(model)) { + const profile = llamaCppLaunchProfiles[key]; + if (profile) return profile; + } + return null; + }; + // `activeModelIds` is the deduped union of selected local, online API, and peer models. const activeModelIds: string[] = (() => { const ids: string[] = []; @@ -1341,6 +1481,20 @@ export default function ChatWorkspace({ } ); + const launchProfileP = listen( + 'bitnet:launch_profile', + (e) => { + const profile = e.payload; + const keys = llamaCppProfileKeys(profile); + if (keys.length === 0) return; + setLlamaCppLaunchProfiles(prev => { + const next = { ...prev }; + for (const key of keys) next[key] = profile; + return next; + }); + } + ); + return () => { activatedP.then(fn => fn()); downloadsCompleteP.then(fn => fn()); @@ -1348,6 +1502,7 @@ export default function ChatWorkspace({ replacedP.then(fn => fn()); cppWarnP.then(fn => fn()); cppExceedP.then(fn => fn()); + launchProfileP.then(fn => fn()); }; }, []); @@ -3914,6 +4069,7 @@ export default function ChatWorkspace({ const isVideo = m.source_class === 'local_video'; const isQualityOpen = videoQualityOpenId === m.id; const role = chainMode && !isVideo ? (chainRoles[m.id] ?? 'respond') : null; + const llamaCppProfile = getLlamaCppLaunchProfile(m); return ( {srcLabel} @@ -3921,6 +4077,14 @@ export default function ChatWorkspace({ 🎤 Spokesperson )} {m.display_name} + {llamaCppProfile && ( + + {llamaCppLaneLabel(llamaCppProfile)} + + )} {role && ( diff --git a/src/components/Governance/GovernancePanel.tsx b/src/components/Governance/GovernancePanel.tsx index d3d0562e..d734422d 100644 --- a/src/components/Governance/GovernancePanel.tsx +++ b/src/components/Governance/GovernancePanel.tsx @@ -132,7 +132,7 @@ export default function GovernancePanel() { }; const POSTURE_LABELS: Record = { - high: t('govpanel.posture_high', 'Full governance — all 8 pack files verified'), + high: t('govpanel.posture_high', 'Full governance — all pack files verified'), medium: t('govpanel.posture_medium', 'Reduced — some files modified or opted out'), low: t('govpanel.posture_low', 'Minimal — multiple files missing or modified'), unknown: t('govpanel.posture_unknown', 'Not yet verified'), @@ -177,7 +177,7 @@ export default function GovernancePanel() {

{t('govpanel.governance_file_desc', - 'This file governs all AI execution in More AI. You can replace it with your own governance file. The folder is yours — open it to view, edit, or swap the file.' + 'This canonical pack file is hash-verified at startup. Custom execution rules belong in the governance subfolder; restoring defaults returns this pack to full trust.' )}

diff --git a/src/components/IdeWorkspace.css b/src/components/IdeWorkspace.css index 27ca2072..43ab7959 100644 --- a/src/components/IdeWorkspace.css +++ b/src/components/IdeWorkspace.css @@ -239,6 +239,70 @@ text-align: center; } +.model-pill-load { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 34px; + padding: 1px 6px; + border-radius: 999px; + font-size: 9px; + font-weight: 800; + letter-spacing: 0.04em; + line-height: 1.35; + border: 1px solid rgba(148, 163, 184, 0.35); + background: rgba(148, 163, 184, 0.10); + color: #cbd5e1; +} + +.model-pill-load--gpu { + border-color: rgba(52, 211, 153, 0.45); + background: rgba(52, 211, 153, 0.14); + color: #34d399; +} + +.model-pill-load--hybrid { + border-color: rgba(251, 191, 36, 0.50); + background: rgba(251, 191, 36, 0.14); + color: #fbbf24; +} + +.model-pill-load--cpu { + border-color: rgba(248, 113, 113, 0.48); + background: rgba(248, 113, 113, 0.13); + color: #fca5a5; +} + +.model-pill-load-pref { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 8px; + border-radius: 18px; + border: 1px solid rgba(99, 102, 241, 0.32); + background: rgba(99, 102, 241, 0.10); + color: #c7d2fe; + font-size: 11px; + font-weight: 700; +} + +.model-pill-load-pref select { + min-width: 92px; + background: rgba(5, 8, 22, 0.88); + border: 1px solid rgba(99, 102, 241, 0.45); + border-radius: 8px; + color: #e0e7ff; + font-size: 11px; + font-weight: 700; + padding: 2px 20px 2px 6px; + outline: none; +} + +.model-pill-load-pref select:disabled { + opacity: 0.55; + cursor: not-allowed; +} + /* Local model instance counter in the IDE model bar */ .ide-local-instance-counter { margin-right: 0; @@ -519,11 +583,53 @@ line-height: 1; } +.ide-routing-note { + padding: 6px 8px; + border: 1px solid rgba(34, 211, 238, 0.18); + border-radius: 6px; + background: rgba(15, 23, 42, 0.32); + color: var(--color-text-secondary, #cbd5e1); + font-size: 11px; + line-height: 1.35; +} + +.ide-routing-note__roles { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin-top: 6px; +} + +.ide-routing-role-chip { + display: inline-flex; + align-items: center; + max-width: 100%; + padding: 2px 6px; + border-radius: 4px; + border: 1px solid rgba(148, 163, 184, 0.22); + background: rgba(15, 23, 42, 0.45); + color: var(--color-text-secondary, #cbd5e1); + font-size: 10.5px; + line-height: 1.25; +} + +.ide-routing-role-chip--tool { + border-color: rgba(52, 211, 153, 0.34); + color: #bbf7d0; +} + +.ide-routing-role-chip--chat { + border-color: rgba(96, 165, 250, 0.3); + color: #bfdbfe; +} + .ide-tool-readiness-banner { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 14px; align-items: start; + max-height: min(44vh, 380px); + overflow-y: auto; padding: 12px 14px; background: rgba(245, 158, 11, 0.1); border-bottom: 1px solid rgba(245, 158, 11, 0.32); @@ -532,6 +638,16 @@ line-height: 1.45; } +.ide-tool-readiness-banner--advisory { + background: rgba(14, 165, 233, 0.08); + border-bottom-color: rgba(14, 165, 233, 0.24); +} + +.ide-tool-readiness-banner--advisory .ide-tool-readiness-banner__title span { + border-color: rgba(56, 189, 248, 0.38); + color: #7dd3fc; +} + .ide-tool-readiness-banner__body { min-width: 0; } @@ -571,6 +687,23 @@ color: var(--color-text-secondary, #a7b1c2); } +.ide-tool-readiness-banner__details { + margin-top: 6px; + color: var(--color-text-secondary, #a7b1c2); +} + +.ide-tool-readiness-banner__details summary { + color: #bfdbfe; + cursor: pointer; + font-size: 12px; + font-weight: 800; +} + +.ide-tool-readiness-banner__details p { + margin-top: 6px; + overflow-wrap: anywhere; +} + .ide-tool-readiness-banner__actions { display: flex; gap: 8px; @@ -599,6 +732,43 @@ background: rgba(139, 92, 246, 0.24); } +.ide-prompt-panel .ide-tool-readiness-banner { + display: block; + max-height: 220px; + margin: 8px; + padding: 10px; + border: 1px solid rgba(245, 158, 11, 0.26); + border-radius: 7px; + font-size: 12px; + line-height: 1.35; +} + +.ide-prompt-panel .ide-tool-readiness-banner__title { + align-items: flex-start; + gap: 6px; + margin-bottom: 6px; + font-size: 13px; + line-height: 1.25; +} + +.ide-prompt-panel .ide-tool-readiness-banner__title span { + border-radius: 5px; + font-size: 10px; + line-height: 1.1; + white-space: nowrap; +} + +.ide-prompt-panel .ide-tool-readiness-banner__actions { + justify-content: flex-start; + margin-top: 8px; +} + +.ide-prompt-panel .ide-tool-readiness-banner__actions button { + min-height: 28px; + padding: 5px 8px; + font-size: 11px; +} + @media (max-width: 860px) { .ide-tool-readiness-banner { grid-template-columns: 1fr; @@ -621,6 +791,15 @@ appearance: none; -webkit-appearance: none; min-width: 70px; + color-scheme: dark; +} +.ide-agent-budget-select option { + background-color: #0f172a; + color: #e5e7eb; +} +.ide-agent-budget-select option:checked { + background-color: #312e81; + color: #fff; } .ide-agent-budget-select:hover, .ide-agent-budget-select:focus { @@ -1230,6 +1409,8 @@ display: flex; flex-direction: column; min-width: 0; + min-height: 0; + overflow: hidden; background-color: var(--color-bg-surface); } @@ -2164,6 +2345,7 @@ display: flex; align-items: center; justify-content: space-between; + flex-wrap: wrap; gap: 0.5rem; padding: 0.3rem 0.6rem; border-bottom: 1px solid rgba(255,255,255,0.04); @@ -2194,6 +2376,20 @@ color: #6ee7b7; } +.ide-pending-write-row.error { + border-left: 2px solid rgba(248, 113, 113, 0.8); + background: rgba(127, 29, 29, 0.16); +} + +.ide-pending-write-error { + flex: 0 0 100%; + min-width: 0; + font-size: 0.68rem; + line-height: 1.3; + color: #fca5a5; + overflow-wrap: anywhere; +} + /* ── Terminal tab bar ── */ .ide-terminal-tabs { display: flex; @@ -4867,16 +5063,24 @@ white-space: nowrap; } .ide-persona-select { - background: none; + background: rgba(15, 23, 42, 0.82); border: none; color: var(--color-text); font-size: 0.78rem; font-weight: 600; cursor: pointer; outline: none; - padding: 0 2px; - appearance: none; - -webkit-appearance: none; + padding: 1px 6px; + border-radius: 4px; + color-scheme: dark; +} +.ide-persona-select option { + background-color: #0f172a; + color: #e5e7eb; +} +.ide-persona-select option:checked { + background-color: #312e81; + color: #fff; } .ide-persona-select:focus { outline: none; } /* Active persona indicator */ @@ -5066,10 +5270,11 @@ .ide-agent-console { min-height: 0; - height: 100%; + flex: 1; display: flex; flex-direction: column; gap: 12px; + overflow: hidden; padding: 14px; background: var(--color-bg-surface, #080c18); } @@ -5130,6 +5335,27 @@ display: grid; grid-template-rows: minmax(220px, 3fr) minmax(170px, 2fr); gap: 10px; + overflow: hidden; +} + +.ide-agent-split--chat { + grid-template-rows: minmax(260px, 1fr); +} + +.ide-agent-console--chat .ide-agent-message-list--conversation { + padding: 12px; +} + +.ide-chat-console-error { + margin: 8px 0 0; + color: #fca5a5; + font-size: 12px; +} + +.ide-chat-console-actions { + display: flex; + flex-direction: column; + gap: 8px; } .ide-agent-pane { @@ -5242,6 +5468,13 @@ user-select: text; } +.ide-agent-file-queued { + margin-top: 8px; + color: #6ee7b7; + font-size: 12px; + font-weight: 700; +} + .ide-agent-message-tools { display: flex; flex-wrap: wrap; @@ -5324,6 +5557,10 @@ padding: 10px 12px; } +.ide-agent-prompt-textarea { + margin-bottom: 0; +} + .ide-agent-console-run-row { display: flex; flex-direction: column; @@ -5331,6 +5568,35 @@ gap: 8px; } +.ide-pending-writes--center { + flex-shrink: 0; + max-height: 170px; + overflow: auto; +} + +.ide-pending-writes-header-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.ide-pending-writes-auto { + color: #6ee7b7; + font-size: 11px; + font-weight: 800; +} + +.ide-pending-writes--center .ide-pending-write-path-input { + flex: 1; + min-width: 0; + border: 1px dashed rgba(125, 140, 255, 0.5); + border-radius: 4px; + background: rgba(255, 255, 255, 0.04); + color: var(--color-text-primary, #f4f7ff); + padding: 3px 6px; + font-size: 12px; +} + .ide-tooling-warning { display: flex; flex-direction: column; @@ -6167,7 +6433,7 @@ .ide-command-palette-overlay { position: fixed; inset: 0; - z-index: 12000; + z-index: 2147483600; display: flex; align-items: flex-start; justify-content: center; @@ -6223,6 +6489,31 @@ color: var(--color-text-muted, #8b92bd); } +.ide-local-probe { + display: block; + margin-top: 3px; + font-size: 10px; + line-height: 1.3; + color: var(--color-text-muted, #8b92bd); +} + +.ide-local-probe--checking { + color: #facc15; +} + +.ide-local-probe--ready { + color: #6ee7b7; +} + +.ide-local-probe--failed { + color: #fca5a5; +} + +.ide-online-row--warn { + outline: 1px solid rgba(248, 113, 113, 0.28); + background: rgba(127, 29, 29, 0.12); +} + .cap-badge--tool-uncertain { background: rgba(251, 191, 36, 0.16); color: #facc15; diff --git a/src/components/IdeWorkspace.tsx b/src/components/IdeWorkspace.tsx index 4cdcea74..23a83479 100644 --- a/src/components/IdeWorkspace.tsx +++ b/src/components/IdeWorkspace.tsx @@ -124,6 +124,19 @@ interface ChatOnlyModelPrompt { message: string; } +interface OllamaGenerationProbe { + ok: boolean; + message: string; + response_preview?: string | null; + latency_ms: number; +} + +interface LocalModelProbeState { + status: 'checking' | 'ready' | 'failed'; + message: string; + latencyMs?: number; +} + const IDE_CHAT_ONLY_MODEL_STORAGE_KEY = 'ide_chat_only_model_ids'; function readChatOnlyModelIds(): string[] { @@ -275,11 +288,133 @@ function listen(event: string, handler: (event: TauriEventPayload = [ + { value: 'auto', label: 'Auto', title: 'More AI chooses the safest lane for this model and hardware.' }, + { value: 'gpu', label: 'GPU / VRAM', title: 'Request GPU offload for llama.cpp models.' }, + { value: 'cpu', label: 'CPU / RAM', title: 'Force CPU/RAM mode. Slower, but preserves VRAM for other workloads.' }, + { value: 'hybrid', label: 'Hybrid', title: 'Prefer GPU offload while allowing system RAM fallback.' }, + { value: 'low_memory', label: 'Low memory', title: 'Avoid GPU offload and use the most conservative local lane.' }, +]; + +function normalizeLlamaCppLoadPreference(value?: string | null): LlamaCppLoadPreference { + switch ((value ?? '').trim().toLowerCase()) { + case 'gpu': + case 'vram': + case 'gpu_vram': + return 'gpu'; + case 'cpu': + case 'ram': + case 'cpu_ram': + return 'cpu'; + case 'hybrid': + case 'gpu_ram': + case 'gpu+ram': + return 'hybrid'; + case 'low_memory': + case 'low-memory': + case 'lowmemory': + return 'low_memory'; + default: + return 'auto'; + } +} + +function normalizeModelIdentity(value?: string | null): string { + return (value ?? '') + .toLowerCase() + .replace(/\.gguf$/, '') + .replace(/[^a-z0-9]+/g, ''); +} + +function basenameWithoutExtension(path?: string | null): string { + if (!path) return ''; + const normalized = path.replace(/\\/g, '/'); + const file = normalized.split('/').pop() ?? normalized; + return file.replace(/\.gguf$/i, ''); +} + +function llamaCppProfileKeys(profile: LlamaCppLaunchProfileEvent): string[] { + return [ + profile.model_name, + profile.model_stem, + basenameWithoutExtension(profile.model_path), + profile.model_path, + ] + .map(normalizeModelIdentity) + .filter(Boolean); +} + +function modelIdentityKeys(model: ModelRecord): string[] { + return [ + model.model_name, + model.display_name, + basenameWithoutExtension(model.model_name), + ] + .map(normalizeModelIdentity) + .filter(Boolean); +} + +function llamaCppModelSizeLabel(profile: LlamaCppLaunchProfileEvent): string { + if (typeof profile.params_b === 'number') return `${profile.params_b.toFixed(1)}B`; + if (profile.model_size_mb > 0) return `${(profile.model_size_mb / 1024).toFixed(1)} GB`; + return 'unknown size'; +} + +function llamaCppLaneLabel(profile: LlamaCppLaunchProfileEvent): string { + if (profile.load_lane === 'gpu') return 'GPU'; + if (profile.load_lane === 'hybrid') return 'HYBRID'; + if (profile.load_lane === 'cpu') return 'CPU'; + return 'LANE?'; +} + +function llamaCppLaneTitle(profile: LlamaCppLaunchProfileEvent): string { + const vram = profile.vram_total_mb > 0 + ? `${(profile.vram_available_mb / 1024).toFixed(1)} / ${(profile.vram_total_mb / 1024).toFixed(1)} GB VRAM available` + : 'VRAM unknown'; + const gpu = profile.gpu_name ? `GPU: ${profile.gpu_name}` : 'GPU: not detected'; + const offload = profile.offload_layers > 0 + ? `Offload: ${profile.offload_layers} layers requested` + : 'Offload: none'; + const warning = profile.warning ? `\n${profile.warning}` : ''; + return `Runtime: ${profile.runtime}\nLoad lane: ${llamaCppLaneLabel(profile)} (${profile.load_preference})\nModel size: ${llamaCppModelSizeLabel(profile)}\n${gpu}\n${vram}\n${offload}${warning}`; +} + interface ContextFile { name: string; path: string; size: number; - content: string; + content?: string; +} + +function normalizeContextPath(pathValue: string): string { + return pathValue.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase(); +} + +function contextFileStorageSnapshot(files: ContextFile[]): ContextFile[] { + return files.map(({ name, path, size }) => ({ name, path, size })); } /** A plan / roadmap file the Conductor reads privately — invisible to sub-agents. */ @@ -400,6 +535,7 @@ export interface ParsedFileWrite { content: string; applied: boolean; inferred?: boolean; // true = path was guessed from language/active file, not explicit [FILE:] + applyError?: string; } export interface IdeMessage { @@ -428,7 +564,19 @@ interface AgentMessage { id: string; role: 'user' | 'assistant'; text: string; + modelId?: string; + modelName?: string; + agentRole?: string; toolCalls?: AgentToolCall[]; + fileWrites?: ParsedFileWrite[]; +} + +type FileWriteSource = 'ide' | 'agent'; + +interface PendingFileWriteItem extends ParsedFileWrite { + msgId: string; + fileIdx: number; + source: FileWriteSource; } type CenterView = 'editor' | 'agent' | 'tools' | 'plugins'; @@ -602,6 +750,53 @@ function modelHasUncertainIdeToolCalling(modelName: string): boolean { return getModelToolCallingWarning(modelName) !== null; } +function shouldRunOllamaGenerationProbe(model: ModelRecord): boolean { + return model.provider_type === 'ollama'; +} + +function localModelInstallProofMessage(model: ModelRecord): string { + if (model.provider_type === 'llama_cpp' || isBitNet(model.model_name)) { + return 'Ready for IDE selection (local llama.cpp model record is installed).'; + } + return 'Ready for IDE selection (local model record is installed).'; +} + +function ideReadinessCanTrialLocalAgent(readiness: IdeModelToolReadiness | undefined, model: ModelRecord | undefined): boolean { + const nonRunnableStates = new Set(['not_installed', 'missing', 'unavailable', 'unsupported', 'not_applicable']); + const mode = readiness?.ide_tool_mode ?? ''; + const status = readiness?.tool_calling_status ?? ''; + const level = readiness?.certification_level ?? ''; + return Boolean( + readiness + && model?.source_class === 'local_llm' + && mode !== 'chat_only' + && level !== 'chat_only' + && !nonRunnableStates.has(mode) + && !nonRunnableStates.has(status) + && !nonRunnableStates.has(level), + ); +} + +function ideReadinessCanAttemptAgent(readiness: IdeModelToolReadiness | undefined, model: ModelRecord | undefined): boolean { + return Boolean(readiness?.can_start_agent || ideReadinessCanTrialLocalAgent(readiness, model)); +} + +function ideReadinessAllowsWrite(readiness: IdeModelToolReadiness | undefined, model: ModelRecord | undefined): boolean { + if (!readiness) return true; + return Boolean(readiness.can_write || ideReadinessCanTrialLocalAgent(readiness, model)); +} + +function summarizeAgentToolProgress(messages: AgentMessage[]) { + const toolCalls = messages.flatMap(msg => msg.toolCalls ?? []); + const successfulToolCalls = toolCalls.filter(tc => !tc.is_error && tc.result !== undefined); + const successfulWrites = successfulToolCalls.filter(tc => tc.tool_name === 'write_file'); + return { + toolCalls: toolCalls.length, + successfulToolCalls: successfulToolCalls.length, + successfulWrites: successfulWrites.length, + }; +} + function renderMarkdownPreviewLine(line: string, index: number): React.ReactNode { const heading = /^(#{1,4})\s+(.+)$/.exec(line); if (heading) { @@ -627,7 +822,9 @@ function renderMarkdownPreviewLine(line: string, index: number): React.ReactNode } function formatAgentMessageForCopy(msg: AgentMessage): string { - const name = msg.role === 'user' ? 'You' : 'Agent'; + const name = msg.role === 'user' + ? 'You' + : `${msg.modelName ?? msg.modelId ?? 'Agent'}${msg.agentRole ? ` - ${msg.agentRole}` : ''}`; const toolDetails = (msg.toolCalls ?? []).length > 0 ? `\n\nTool Calls:\n\n${(msg.toolCalls ?? []).map((tc, index) => formatAgentToolCallForCopy(tc, index)).join('\n\n')}` : ''; @@ -936,6 +1133,84 @@ function parseFileWrites(text: string, activeFilePath?: string): Omit[], + requestedFilePath: string | null, +): Omit[] { + if (!requestedFilePath) return writes; + let used = false; + return writes.map((fw) => { + if (used) return fw; + const inferredOutputName = /^output(?:-\d+)?\.[a-z0-9]+$/i.test(fw.path); + if (!fw.inferred && !inferredOutputName) return fw; + used = true; + return { ...fw, path: requestedFilePath, inferred: true }; + }); +} + +function normalizeWorkspaceRelativeFilePath(filePath: string, workspacePath: string): string { + const rawPath = filePath.trim().replace(/^["']|["']$/g, ''); + if (!rawPath) throw new Error('File path is empty.'); + if (rawPath.includes('\0')) throw new Error('File path contains an invalid character.'); + + const workspace = workspacePath.replace(/\\/g, '/').replace(/\/+$/, ''); + let normalizedPath = rawPath.replace(/\\/g, '/'); + if (/^file:\/+/i.test(normalizedPath)) { + normalizedPath = normalizedPath.replace(/^file:\/+/i, ''); + if (/^[a-zA-Z]\//.test(normalizedPath)) { + normalizedPath = `${normalizedPath[0]}:/${normalizedPath.slice(2)}`; + } + } + + const isWindowsAbsolute = /^[a-zA-Z]:\//.test(normalizedPath); + const isUncPath = normalizedPath.startsWith('//'); + const isUnixAbsolute = normalizedPath.startsWith('/'); + let relativePath = normalizedPath; + + if (isWindowsAbsolute) { + if (!normalizedPath.toLowerCase().startsWith(`${workspace.toLowerCase()}/`)) { + throw new Error(`Absolute path is outside the active workspace: ${rawPath}`); + } + relativePath = normalizedPath.slice(workspace.length + 1); + } else if (isUncPath || isUnixAbsolute) { + throw new Error(`Absolute paths are not allowed for IDE file writes: ${rawPath}`); + } + + relativePath = relativePath.replace(/^\.\/+/, '').replace(/^\/+/, ''); + const parts = relativePath.split('/').filter(Boolean); + if (parts.length === 0) throw new Error('File path is empty.'); + if (parts.some(part => part === '..')) { + throw new Error(`Parent-directory segments are not allowed: ${rawPath}`); + } + if (/^[a-zA-Z]:/.test(parts[0])) { + throw new Error(`Drive-qualified paths must be inside the active workspace: ${rawPath}`); + } + + return parts.join('/'); +} + function stripFileBlocks(text: string): string { return text.replace(/\[FILE:\s*[^\]\n]+\]\s*\n```\w*\n[\s\S]*?```\n?/g, '').trim(); } @@ -1336,12 +1611,90 @@ export default function IdeWorkspace({ onNavigateToSettings, isActiveTab = false try { return new Set(JSON.parse(localStorage.getItem('ide_online_model_ids') ?? '[]')); } catch { return new Set(); } }); + const [llamaCppLaunchProfiles, setLlamaCppLaunchProfiles] = useState>({}); + const [llamaCppLoadPreference, setLlamaCppLoadPreference] = useState('auto'); const [localConflictMsg, setLocalConflictMsg] = useState(null); + const [localModelProbeStatus, setLocalModelProbeStatus] = useState>({}); + + useEffect(() => { + invoke('get_app_config_value', { key: LLAMA_CPP_LOAD_PREFERENCE_KEY }) + .then(value => setLlamaCppLoadPreference(normalizeLlamaCppLoadPreference(value))) + .catch(() => setLlamaCppLoadPreference('auto')); + }, []); + + useEffect(() => { + const launchProfileP = listen('bitnet:launch_profile', (event) => { + const profile = event.payload; + const keys = llamaCppProfileKeys(profile); + if (keys.length === 0) return; + setLlamaCppLaunchProfiles(prev => { + const next = { ...prev }; + for (const key of keys) next[key] = profile; + return next; + }); + }); + return () => { launchProfileP.then(fn => fn()); }; + }, []); + + const handleLlamaCppLoadPreferenceChange = (next: LlamaCppLoadPreference) => { + setLlamaCppLoadPreference(next); + invoke('set_app_config_value', { key: LLAMA_CPP_LOAD_PREFERENCE_KEY, value: next }) + .catch(error => console.error('Failed to save llama.cpp load preference', error)); + }; + + const probeLocalModelForIde = useCallback(async (model: ModelRecord): Promise => { + if (!shouldRunOllamaGenerationProbe(model)) { + setLocalModelProbeStatus(prev => ({ + ...prev, + [model.id]: { + status: 'ready', + message: localModelInstallProofMessage(model), + }, + })); + return true; + } + setLocalModelProbeStatus(prev => ({ + ...prev, + [model.id]: { status: 'checking', message: 'Checking local generation...' }, + })); + try { + const result = await invoke('probe_ollama_model_generation', { + modelName: model.model_name, + }); + setLocalModelProbeStatus(prev => ({ + ...prev, + [model.id]: { + status: result.ok ? 'ready' : 'failed', + message: result.ok + ? `Ready for IDE chat (${result.latency_ms} ms).` + : result.message, + latencyMs: result.latency_ms, + }, + })); + if (!result.ok) { + setLocalConflictMsg(`${model.display_name} failed an optional background IDE generation probe. ${result.message} More AI will keep it selected and report a runtime failure only if the actual job cannot use it.`); + } + return result.ok; + } catch (error) { + const message = String(error); + setLocalModelProbeStatus(prev => ({ + ...prev, + [model.id]: { status: 'failed', message }, + })); + setLocalConflictMsg(`${model.display_name} could not be checked by the optional background IDE generation probe. ${message} More AI will keep it selected and report a runtime failure only if the actual job cannot use it.`); + return false; + } + }, []); - const toggleLocalIdeModel = (id: string) => { + const toggleLocalIdeModel = async (id: string) => { if (!selectedLocalIdeIds.has(id)) { const conflict = findLocalModelConflict(id, 'ide'); if (conflict) { setLocalConflictMsg(localConflictMessage(conflict)); return; } + const model = modelsRef.current.find(m => m.id === id); + const existingProbe = localModelProbeStatus[id]; + if (model && existingProbe?.status !== 'ready' && existingProbe?.status !== 'checking') { + void probeLocalModelForIde(model); + } } setSelectedLocalIdeIds(prev => { const next = new Set(prev); @@ -1489,6 +1842,7 @@ export default function IdeWorkspace({ onNavigateToSettings, isActiveTab = false const [swarmCreateOpen, setSwarmCreateOpen] = useState(false); const [swarmNameInput, setSwarmNameInput] = useState(''); const [swarmSaveError, setSwarmSaveError] = useState(null); + const [swarmSavePreview, setSwarmSavePreview] = useState(null); const swarmBtnRef = useRef(null); const swarmDropdownRef = useRef(null); @@ -1777,11 +2131,90 @@ export default function IdeWorkspace({ onNavigateToSettings, isActiveTab = false } catch (e) { console.error('loadSwarm failed', e); } }; + const buildCurrentSwarmMembers = async (): Promise<{ + members: { model_id: string; instance_count: number; sort_order: number }[]; + preview: string; + }> => { + const selectedIds = activeIdeModelIds.filter(id => allModels.some(model => model.id === id)); + if (selectedIds.length === 0) { + throw new Error('Select at least one model before saving a swarm.'); + } + + if (ideMode !== 'manual' || agentSubMode !== 'agent') { + return { + members: selectedIds.map((id, i) => ({ model_id: id, instance_count: ideInstanceCounts[id] ?? 1, sort_order: i })), + preview: `Chat Coding swarm: all ${selectedIds.length} selected model${selectedIds.length === 1 ? '' : 's'} will answer in parallel.`, + }; + } + + let readinessById: Record = {}; + try { + const readinessEntries = await Promise.all(selectedIds.map(async id => { + const readiness = selectedIdeReadiness[id] + ?? await invoke('get_ide_model_tool_readiness', { modelId: id }); + return [id, readiness] as const; + })); + readinessById = Object.fromEntries(readinessEntries); + setSelectedIdeReadiness(prev => ({ ...prev, ...readinessById })); + } catch (e: any) { + throw new Error(`Could not verify selected model tool readiness before saving this Agent swarm: ${String(e)}`); + } + + const primaryId = selectedIds.find(id => { + const model = allModels.find(m => m.id === id); + return !chatOnlyModelIdsForRouting.has(id) && ideReadinessCanAttemptAgent(readinessById[id], model); + }); + if (!primaryId) { + const reasons = selectedIds.map(id => { + const model = allModels.find(m => m.id === id); + const readiness = readinessById[id]; + const label = model?.display_name ?? id; + const detail = readiness + ? readiness.mode_label || readiness.warning_title || readiness.certification_level + : 'readiness unknown'; + return `${label}: ${detail}`; + }).join('; '); + throw new Error(`This Agent swarm has no runnable tool agent. Add a verified/local tool agent such as Granite 4.1 8B, or switch to Chat Coding before saving. ${reasons}`); + } + + const companionIds = selectedIds.filter(id => id !== primaryId); + const primaryName = allModels.find(m => m.id === primaryId)?.display_name ?? primaryId; + const companionNames = companionIds.map(id => allModels.find(m => m.id === id)?.display_name ?? id); + const orderedIds = [primaryId, ...companionIds]; + return { + members: orderedIds.map((id, i) => ({ model_id: id, instance_count: ideInstanceCounts[id] ?? 1, sort_order: i })), + preview: companionIds.length > 0 + ? `Agent swarm: ${primaryName} is the primary tool agent. ${companionNames.join(', ')} ${companionIds.length === 1 ? 'runs' : 'run'} as Chat Coding companion${companionIds.length === 1 ? '' : 's'}.` + : `Agent swarm: ${primaryName} is the primary tool agent.`, + }; + }; + + const openCreateSwarmDialog = async () => { + setSwarmSaveError(null); + setSwarmSavePreview(null); + setSwarmNameInput(''); + try { + const { preview } = await buildCurrentSwarmMembers(); + setSwarmSavePreview(preview); + } catch (e: any) { + setSwarmSaveError(String(e?.message ?? e)); + } + setSwarmCreateOpen(true); + }; + const saveNewSwarm = async () => { setSwarmSaveError(null); const name = swarmNameInput.trim(); if (!name) { setSwarmSaveError('Name is required'); return; } - const members = activeIdeModels.map((m, i) => ({ model_id: m.id, instance_count: ideInstanceCounts[m.id] ?? 1, sort_order: i })); + let members: { model_id: string; instance_count: number; sort_order: number }[] = []; + try { + const result = await buildCurrentSwarmMembers(); + members = result.members; + setSwarmSavePreview(result.preview); + } catch (e: any) { + setSwarmSaveError(String(e?.message ?? e)); + return; + } try { const saved = await invoke('save_swarm_preset', { name, members }); setActiveSwarmId(saved.id); @@ -1789,18 +2222,30 @@ export default function IdeWorkspace({ onNavigateToSettings, isActiveTab = false setLoadedSwarmSnapshot(members.map(m => ({ modelId: m.model_id, instanceCount: m.instance_count }))); setSwarmCreateOpen(false); setSwarmNameInput(''); + setSwarmSavePreview(null); fetchSwarms(); } catch (e: any) { setSwarmSaveError(String(e)); } }; const updateCurrentSwarm = async () => { if (!activeSwarmId) return; - const members = activeIdeModels.map((m, i) => ({ model_id: m.id, instance_count: ideInstanceCounts[m.id] ?? 1, sort_order: i })); + let members: { model_id: string; instance_count: number; sort_order: number }[] = []; + try { + const result = await buildCurrentSwarmMembers(); + members = result.members; + setSwarmSavePreview(result.preview); + } catch (e: any) { + setAgentRunNotice({ kind: 'error', text: String(e?.message ?? e) }); + return; + } try { await invoke('update_swarm_preset', { id: activeSwarmId, members }); setLoadedSwarmSnapshot(members.map(m => ({ modelId: m.model_id, instanceCount: m.instance_count }))); fetchSwarms(); - } catch (e) { console.error('updateCurrentSwarm failed', e); } + } catch (e) { + console.error('updateCurrentSwarm failed', e); + setAgentRunNotice({ kind: 'error', text: `Could not update swarm preset: ${String(e)}` }); + } }; const deleteSwarm = async (presetId: string) => { @@ -1900,6 +2345,18 @@ export default function IdeWorkspace({ onNavigateToSettings, isActiveTab = false const [workspaceSearchStatus, setWorkspaceSearchStatus] = useState('Not searched'); const [workspaceReplacePreview, setWorkspaceReplacePreview] = useState(null); const [ideTaskBoard, setIdeTaskBoard] = useState([]); + + useEffect(() => { + if (!commandPaletteOpen) return; + const handleCommandPaletteKey = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + setCommandPaletteOpen(false); + } + }; + window.addEventListener('keydown', handleCommandPaletteKey); + return () => window.removeEventListener('keydown', handleCommandPaletteKey); + }, [commandPaletteOpen]); const [governanceOverrideDraft, setGovernanceOverrideDraft] = useState(''); const [governanceOverrideStatus, setGovernanceOverrideStatus] = useState('Not saved'); const [visualEvidenceItems, setVisualEvidenceItems] = useState([]); @@ -1943,9 +2400,8 @@ export default function IdeWorkspace({ onNavigateToSettings, isActiveTab = false const [agentSubMode, setAgentSubMode] = useState<'chat' | 'agent'>( () => (localStorage.getItem('ide_agent_submode') as 'chat' | 'agent' | null) ?? 'chat', ); - // B4-3: Write-mode always starts disabled — must be explicitly enabled each session. - // Never restored from localStorage; safety-first default. - const [agentAllowWrite, setAgentAllowWrite] = useState(false); + // Coding is the default IDE path. Audit mode is the explicit read-only path. + const [agentAllowWrite, setAgentAllowWrite] = useState(true); const [agentAllowTerminal, setAgentAllowTerminal] = useState(() => localStorage.getItem('agent_allow_terminal') === 'true'); // 0 = unlimited (Rust clamps to HARD_MAX_TURNS = 5000) const [agentMaxToolCalls, setAgentMaxToolCalls] = useState(() => { @@ -1957,10 +2413,13 @@ export default function IdeWorkspace({ onNavigateToSettings, isActiveTab = false }); const agentMessagesRef = useRef(agentMessages); const agentToolNamesByCallIdRef = useRef>(new Map()); + const agentSuccessfulToolCallCountRef = useRef(0); + const agentSuccessfulWriteCountRef = useRef(0); const [agentRunning, setAgentRunning] = useState(false); const [agentRunNotice, setAgentRunNotice] = useState<{ kind: 'done' | 'error'; text: string } | null>(null); const [chatOnlyModelPrompt, setChatOnlyModelPrompt] = useState(null); const [ideToolReadinessWarning, setIdeToolReadinessWarning] = useState(null); + const [selectedIdeReadiness, setSelectedIdeReadiness] = useState>({}); const ideToolReadinessOverrideRef = useRef(null); const [agentInterruptCheckpoint, setAgentInterruptCheckpoint] = useState(null); const agentInterruptCheckpointRef = useRef(agentInterruptCheckpoint); @@ -2102,9 +2561,9 @@ export default function IdeWorkspace({ onNavigateToSettings, isActiveTab = false const [tooltipStep, setTooltipStep] = useState(0); // 0=hidden; 1/2/3=steps const firstAgentDoneRef = useRef(false); - // ── AI-and-Me Phase 4: Agent personas ──────────────────────────────────────── - type AgentPersona = 'none' | 'auditor' | 'architect' | 'refactor' | 'explainer'; - const [agentPersona, setAgentPersona] = useState(() => (localStorage.getItem('agent_persona') as AgentPersona) || 'none'); + // ── Agent focus ────────────────────────────────────────────────────────────── + type AgentPersona = 'none' | 'auditor'; + const [agentPersona, setAgentPersona] = useState('none'); // ── AI-and-Me Phase 4D: Mouse hover context ────────────────────────────────── const [hoverContextEnabled, setHoverContextEnabled] = useState(() => localStorage.getItem('hover_context_enabled') === 'true'); @@ -2135,6 +2594,7 @@ export default function IdeWorkspace({ onNavigateToSettings, isActiveTab = false useEffect(() => { localStorage.setItem('ide_center_view', centerView); }, [centerView]); useEffect(() => { localStorage.setItem('ide_tool_inspector_open', String(toolInspectorOpen)); }, [toolInspectorOpen]); useEffect(() => { localStorage.setItem('ide_workspace_health_open', String(workspaceHealthOpen)); }, [workspaceHealthOpen]); + useEffect(() => { localStorage.removeItem('agent_persona'); }, []); const toggleToolCallExpanded = (msgId: string, callId: string) => { setAgentMessages(prev => prev.map(m => @@ -2256,6 +2716,12 @@ export default function IdeWorkspace({ onNavigateToSettings, isActiveTab = false const [isLoadingFile, setIsLoadingFile] = useState(false); const [isLoadingFolder, setIsLoadingFolder] = useState(false); + useEffect(() => { + if (ideMode === 'manual' && centerView === 'editor' && !activeFile) { + setCenterView('agent'); + } + }, [activeFile, agentSubMode, centerView, ideMode]); + const IMAGE_EXTENSIONS = new Set(['png','jpg','jpeg','gif','webp','bmp','svg','ico','tiff','tif','avif']); const isImageFile = (name: string) => IMAGE_EXTENSIONS.has(name.split('.').pop()?.toLowerCase() ?? ''); const activeFileRelativePath = useMemo(() => { @@ -2556,13 +3022,17 @@ export default function IdeWorkspace({ onNavigateToSettings, isActiveTab = false const [promptText, setPromptText] = useState(''); const promptTextareaRef = useRef(null); const [contextFiles, setContextFiles] = useState(() => { - try { return JSON.parse(localStorage.getItem('ide_context_files') ?? '[]'); } catch { return []; } + try { + const parsed = JSON.parse(localStorage.getItem('ide_context_files') ?? '[]') as ContextFile[]; + return Array.isArray(parsed) ? contextFileStorageSnapshot(parsed) : []; + } catch { return []; } }); const [isSending, setIsSending] = useState(false); const [isExecuting, setIsExecuting] = useState(false); const [hardStopBusy, setHardStopBusy] = useState(false); const [activeSessionId, setActiveSessionId] = useState(null); const activeSessionIdRef = useRef(null); + const noAutoApplySessionIdsRef = useRef>(new Set()); const [allModels, setAllModels] = useState([]); const modelsRef = useRef([]); const [ideOllamaPullStatus, setIdeOllamaPullStatus] = useState(null); @@ -2585,6 +3055,7 @@ export default function IdeWorkspace({ onNavigateToSettings, isActiveTab = false const [projectFileCount, setProjectFileCount] = useState(0); const [ideSaved, setIdeSaved] = useState(false); const [agentSaved, setAgentSaved] = useState(false); + const [newSessionConfirmArmed, setNewSessionConfirmArmed] = useState(false); // Persistent conversation ID — links all turns in swarm_messages DB (survives restarts) const [ideConversationId, setIdeConversationId] = useState(() => { @@ -2598,6 +3069,7 @@ export default function IdeWorkspace({ onNavigateToSettings, isActiveTab = false ideConversationIdRef.current = ideConversationId; const startNewIdeConversation = () => { + setNewSessionConfirmArmed(false); const fresh = crypto.randomUUID(); localStorage.setItem('ide_conversation_id', fresh); setIdeConversationId(fresh); @@ -2612,6 +3084,12 @@ export default function IdeWorkspace({ onNavigateToSettings, isActiveTab = false setAgentInterruptCheckpoint(null); }; + useEffect(() => { + if (!newSessionConfirmArmed) return; + const timer = window.setTimeout(() => setNewSessionConfirmArmed(false), 5000); + return () => window.clearTimeout(timer); + }, [newSessionConfirmArmed]); + // Conversation history for multi-turn context (kept as fallback for first turn) const ideHistoryRef = useRef<{ role: string; content: string }[]>([]); // Accumulated response text per model for the current turn (keyed by modelId) @@ -3677,7 +4155,7 @@ export default function IdeWorkspace({ onNavigateToSettings, isActiveTab = false if (prev.has(id)) return; const model = allModels.find(m => m.id === id); if (!model) return; - if (model.provider_type === 'ollama' || model.source_class === 'local_llm') { + if (shouldRunOllamaGenerationProbe(model)) { if (model.model_name) { invoke('prewarm_ollama_model', { modelName: model.model_name }).catch(() => {}); } @@ -3806,6 +4284,15 @@ export default function IdeWorkspace({ onNavigateToSettings, isActiveTab = false useEffect(() => { localStorage.setItem('agent_allow_write', String(agentAllowWrite)); }, [agentAllowWrite]); useEffect(() => { localStorage.setItem('agent_allow_terminal', String(agentAllowTerminal)); }, [agentAllowTerminal]); useEffect(() => { localStorage.setItem('agent_max_tool_calls', String(agentMaxToolCalls)); }, [agentMaxToolCalls]); + useEffect(() => { + if (agentPersona === 'auditor') { + setAgentAllowWrite(false); + setAgentPerFileConfirm(true); + setPlanFirst(true); + } else { + setAgentAllowWrite(true); + } + }, [agentPersona]); useEffect(() => { localStorage.setItem('ide_inline_autocomplete_enabled', String(inlineAutocompleteEnabled)); }, [inlineAutocompleteEnabled]); useEffect(() => { autoApproveCommandsRef.current = autoApproveCommands; @@ -3826,7 +4313,7 @@ export default function IdeWorkspace({ onNavigateToSettings, isActiveTab = false } catch {} }, [orchestraLiveAssignments]); useEffect(() => { try { localStorage.setItem('ide_messages', JSON.stringify(ideMessages.slice(-100))); } catch {} }, [ideMessages]); - useEffect(() => { try { localStorage.setItem('ide_context_files', JSON.stringify(contextFiles)); } catch {} }, [contextFiles]); + useEffect(() => { try { localStorage.setItem('ide_context_files', JSON.stringify(contextFileStorageSnapshot(contextFiles))); } catch {} }, [contextFiles]); useEffect(() => { localStorage.setItem('ide_context_scope', contextScope); }, [contextScope]); useEffect(() => { try { localStorage.setItem('ide_instance_counts', JSON.stringify(ideInstanceCounts)); } catch {} }, [ideInstanceCounts]); useEffect(() => { localStorage.setItem('ide_orch_tier', orchTier); }, [orchTier]); @@ -4033,18 +4520,29 @@ export default function IdeWorkspace({ onNavigateToSettings, isActiveTab = false // IMPORTANT: only process when IDE tab is the active tab. // Chat also fires swarm:activated; without this guard it would pollute the IDE model bar. useEffect(() => { - const resolveOllaGpuTags = (tags: Set, current: ModelRecord[]) => { - const localIds = new Set(); - const seenNames = new Set(); + const resolveOllaGpuLaneCounts = (tags: string[], current: ModelRecord[]) => { + const counts = new Map(); + const byName = new Map(); for (const m of current) { - if (m.source_class === 'local_llm' && tags.has(m.model_name) && !seenNames.has(m.model_name)) { - seenNames.add(m.model_name); - localIds.add(m.id); + if (m.source_class === 'local_llm' && !byName.has(m.model_name)) { + byName.set(m.model_name, m); } } - return localIds; + for (const tag of tags) { + const model = byName.get(tag); + if (model) counts.set(model.id, (counts.get(model.id) ?? 0) + 1); + } + return counts; }; - const pendingGpuTagsRef = { current: new Set() }; + const countModelIdLanes = (ids: string[] = []) => { + const counts = new Map(); + ids.filter(Boolean).forEach(id => counts.set(id, (counts.get(id) ?? 0) + 1)); + return counts; + }; + const mergeLaneCounts = (target: Map, source: Map) => { + source.forEach((count, id) => target.set(id, (target.get(id) ?? 0) + count)); + }; + const pendingGpuTagsRef = { current: [] as string[] }; const activatedP = listen<{ gpu_lanes: string[]; ram_lanes: string[]; llama_cpp_lane_model_ids?: string[]; api_lane_model_ids: string[] }>('swarm:activated', async (e) => { // Only apply swarm to IDE model bar when the IDE tab is currently active. @@ -4058,36 +4556,50 @@ export default function IdeWorkspace({ onNavigateToSettings, isActiveTab = false setAllModels(enabled); } catch { /* fall back to current list */ } const current = modelsRef.current; - const ollaTags = new Set([...conf.gpu_lanes, ...conf.ram_lanes]); - pendingGpuTagsRef.current = ollaTags; - const localIds = resolveOllaGpuTags(ollaTags, current); - for (const id of conf.llama_cpp_lane_model_ids ?? []) localIds.add(id); - const onlineIds = new Set(conf.api_lane_model_ids ?? []); + const ollaLaneTags = [...conf.gpu_lanes, ...conf.ram_lanes].filter(Boolean); + pendingGpuTagsRef.current = ollaLaneTags; + const localLaneCounts = resolveOllaGpuLaneCounts(ollaLaneTags, current); + mergeLaneCounts(localLaneCounts, countModelIdLanes(conf.llama_cpp_lane_model_ids ?? [])); + const onlineLaneCounts = countModelIdLanes(conf.api_lane_model_ids ?? []); + const localIds = new Set(localLaneCounts.keys()); + const onlineIds = new Set(onlineLaneCounts.keys()); swarmModelIdsRef.current = { local: localIds, online: onlineIds }; setSwarmActiveModelIds(new Set([...localIds, ...onlineIds])); setSelectedLocalIdeIds(prev => new Set([...prev, ...localIds])); setSelectedIdeOnlineIds(prev => new Set([...prev, ...onlineIds])); + setIdeInstanceCounts(prev => { + const next = { ...prev }; + localLaneCounts.forEach((count, id) => { next[id] = Math.max(1, Math.min(3, count)); }); + onlineLaneCounts.forEach((count, id) => { next[id] = Math.max(1, Math.min(8, count)); }); + return next; + }); }); const downloadsCompleteP = listen('swarm:downloads_complete', async () => { if (!isActiveTabRef.current) return; - if (pendingGpuTagsRef.current.size === 0) return; + if (pendingGpuTagsRef.current.length === 0) return; try { const fresh = await invoke('list_models'); const enabled = filterSelectableModels(fresh); modelsRef.current = enabled; setAllModels(enabled); - const newIds = resolveOllaGpuTags(pendingGpuTagsRef.current, enabled); + const newLaneCounts = resolveOllaGpuLaneCounts(pendingGpuTagsRef.current, enabled); + const newIds = new Set(newLaneCounts.keys()); if (newIds.size > 0) { swarmModelIdsRef.current.local = new Set([...swarmModelIdsRef.current.local, ...newIds]); setSwarmActiveModelIds(prev => new Set([...prev, ...newIds])); setSelectedLocalIdeIds(prev => new Set([...prev, ...newIds])); + setIdeInstanceCounts(prev => { + const next = { ...prev }; + newLaneCounts.forEach((count, id) => { next[id] = Math.max(1, Math.min(3, count)); }); + return next; + }); } } catch { /* ignore */ } }); const stoppedP = listen('swarm:stopped', () => { - pendingGpuTagsRef.current = new Set(); + pendingGpuTagsRef.current = []; const { local, online } = swarmModelIdsRef.current; setSelectedLocalIdeIds(prev => { const n = new Set(prev); local.forEach(id => n.delete(id)); return n; }); setSelectedIdeOnlineIds(prev => { const n = new Set(prev); online.forEach(id => n.delete(id)); return n; }); @@ -4182,6 +4694,42 @@ export default function IdeWorkspace({ onNavigateToSettings, isActiveTab = false ...allModels.filter(m => selectedIdeOnlineIds.has(m.id)), ...(selectedIdePeerId ? allModels.filter(m => m.id === selectedIdePeerId) : []), ]; + const activeIdeAssistantCount = activeIdeModels.reduce( + (sum, model) => sum + Math.max(1, ideInstanceCounts[model.id] ?? 1), + 0, + ); + const getLlamaCppLaunchProfile = (model: ModelRecord): LlamaCppLaunchProfileEvent | null => { + if (model.provider_type !== 'llama_cpp' && !isBitNet(model.model_name)) return null; + const keys = modelIdentityKeys(model); + for (const key of keys) { + const profile = llamaCppLaunchProfiles[key]; + if (profile) return profile; + } + return null; + }; + + useEffect(() => { + if (activeIdeModelIds.length === 0 || (ideMode === 'manual' && agentSubMode === 'chat')) return; + let cancelled = false; + const ids = activeIdeModelIds; + Promise.all( + ids.map(id => + invoke('get_ide_model_tool_readiness', { modelId: id }) + .then(readiness => ({ id, readiness })) + .catch(() => null) + ), + ).then(results => { + if (cancelled) return; + setSelectedIdeReadiness(prev => { + const next = { ...prev }; + results.forEach(result => { + if (result) next[result.id] = result.readiness; + }); + return next; + }); + }); + return () => { cancelled = true; }; + }, [activeIdeModelIds.join(','), agentSubMode, ideMode]); // ── Orchestra / Conductor split ── // conductorModelId is EXPLICITLY chosen by the user via the conductor selector. @@ -4199,6 +4747,15 @@ export default function IdeWorkspace({ onNavigateToSettings, isActiveTab = false const isConductorMode = conductorModelId !== null; const orchestraIds = activeIdeModelIds.filter(id => id !== conductorModelId); const isOrchestra = isConductorMode && orchestraIds.length > 0; + const chatOnlyModelIdsForRouting = new Set(readChatOnlyModelIds()); + const primaryAgentModelId = ideMode === 'manual' && agentSubMode === 'agent' + ? activeIdeModelIds.find(id => + !chatOnlyModelIdsForRouting.has(id) + && (selectedIdeReadiness[id] + ? ideReadinessCanAttemptAgent(selectedIdeReadiness[id], allModels.find(m => m.id === id)) + : true) + ) ?? null + : null; // Expand orchestra model IDs to include per-instance variants const orchestraModels = isOrchestra @@ -4327,9 +4884,10 @@ export default function IdeWorkspace({ onNavigateToSettings, isActiveTab = false const handleAddContext = () => { if (!activeFile || fileError || !fileContent) return; + const activePathKey = normalizeContextPath(activeFile.path); - // Prevent duplicates - if (!contextFiles.some(f => f.name === activeFile.name)) { + // Prevent duplicates by path, not basename. + if (!contextFiles.some(f => normalizeContextPath(f.path) === activePathKey)) { setContextFiles([...contextFiles, { name: activeFile.name, path: activeFile.path, @@ -4339,6 +4897,20 @@ export default function IdeWorkspace({ onNavigateToSettings, isActiveTab = false } }; + const hydrateContextFilesForSend = useCallback(async (files: ContextFile[]): Promise => { + const wp = workspacePathRef.current; + if (!wp || files.length === 0) return files; + return Promise.all(files.map(async file => { + if (typeof file.content === 'string') return file; + const content = await invoke('read_file_content', { workspacePath: wp, targetPath: file.path }); + return { + ...file, + size: file.size || content.length, + content, + }; + })); + }, []); + // ── Context Scope Handlers (Phase 2 — ROADMAP) ── /** Switch context scope mode. Manages context files accordingly. */ @@ -4422,6 +4994,29 @@ export default function IdeWorkspace({ onNavigateToSettings, isActiveTab = false }, [p2pSandboxPath]); const [sendError, setSendError] = useState(null); + const ensureSelectedLocalModelsReady = useCallback(async (): Promise => { + for (const id of selectedLocalIdeIds) { + const model = allModels.find(m => m.id === id); + if (!model || model.source_class !== 'local_llm') { + continue; + } + const current = localModelProbeStatus[id]; + if (!shouldRunOllamaGenerationProbe(model)) { + if (current?.status !== 'ready') { + void probeLocalModelForIde(model); + } + continue; + } + if (current?.status === 'failed') { + setLocalConflictMsg(`${model.display_name} has a failed background readiness probe. ${current.message} More AI will still start this job and only stop if the actual runtime call fails.`); + continue; + } + if (current?.status !== 'ready' && current?.status !== 'checking') { + void probeLocalModelForIde(model); + } + } + return true; + }, [allModels, localModelProbeStatus, probeLocalModelForIde, selectedLocalIdeIds]); // ── Terminal state ── const [terminalOpen, setTerminalOpen] = useState(true); @@ -4721,8 +5316,9 @@ export default function IdeWorkspace({ onNavigateToSettings, isActiveTab = false const [newFileParent, setNewFileParent] = useState(null); const [newFileName, setNewFileName] = useState(''); - const handleRemoveContext = (name: string) => { - setContextFiles(contextFiles.filter(f => f.name !== name)); + const handleRemoveContext = (pathValue: string) => { + const removeKey = normalizeContextPath(pathValue); + setContextFiles(contextFiles.filter(f => normalizeContextPath(f.path) !== removeKey)); }; // Auto-scroll IDE conversation to bottom when messages update @@ -4748,25 +5344,63 @@ export default function IdeWorkspace({ onNavigateToSettings, isActiveTab = false : undefined; const parsed = parseFileWrites(responseText, activeRelative); const wp = workspacePathRef.current; + const pendingWrites: ParsedFileWrite[] = parsed.map(fw => { + if (!wp) return { ...fw, applied: false }; + try { + return { + ...fw, + path: normalizeWorkspaceRelativeFilePath(fw.path, wp), + applied: false, + applyError: undefined, + }; + } catch (e) { + return { + ...fw, + applied: false, + applyError: e instanceof Error ? e.message : String(e), + }; + } + }); - if (parsed.length > 0 && autoApplyRef.current && wp) { - // Auto-apply: write all files to disk immediately, no clicking needed - Promise.all( - parsed.map(fw => - invoke('write_file_content', { workspacePath: wp, relativePath: fw.path, content: fw.content }) - .catch(e => console.error('Auto-apply failed:', fw.path, e)) - ) - ).then(() => refreshWorkspaceTree('auto-applied IDE file block')) - .catch(console.error); + if (pendingWrites.length > 0 && autoApplyRef.current && !noAutoApplySessionIdsRef.current.has(sid) && wp) { setIdeMessages(prev => prev.map(m => m.id === `${sid}:${mid}` - ? { ...m, streaming: false, fileWrites: parsed.map(fw => ({ ...fw, applied: true })) } + ? { ...m, streaming: false, fileWrites: pendingWrites } : m )); + void (async () => { + const updated = [...pendingWrites]; + let successCount = 0; + + for (let i = 0; i < updated.length; i += 1) { + const fw = updated[i]; + if (fw.applyError) continue; + try { + await invoke('write_file_content', { workspacePath: wp, relativePath: fw.path, content: fw.content }); + updated[i] = { ...fw, applied: true, applyError: undefined }; + successCount += 1; + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + updated[i] = { ...fw, applied: false, applyError: message }; + console.error('Auto-apply failed:', fw.path, e); + } + } + + setIdeMessages(prev => prev.map(m => + m.id === `${sid}:${mid}` ? { ...m, fileWrites: updated } : m + )); + if (successCount > 0) { + await refreshWorkspaceTree('auto-applied IDE file block'); + } + const failed = updated.find(fw => fw.applyError && !fw.applied); + if (failed) { + setSendError(`Auto Apply could not write ${failed.path}: ${failed.applyError}`); + } + })(); } else { setIdeMessages(prev => prev.map(m => m.id === `${sid}:${mid}` - ? { ...m, streaming: false, ...(parsed.length > 0 ? { fileWrites: parsed.map(fw => ({ ...fw, applied: false })) } : {}) } + ? { ...m, streaming: false, ...(pendingWrites.length > 0 ? { fileWrites: pendingWrites } : {}) } : m )); } @@ -4791,6 +5425,7 @@ export default function IdeWorkspace({ onNavigateToSettings, isActiveTab = false setIsExecuting(false); setActiveSessionId(null); activeSessionIdRef.current = null; + noAutoApplySessionIdsRef.current.delete(sid); // Auto-continue if toggled on and multi-model if (autoContinueRef.current && modelIds.length > 1) { setTimeout(() => handleContinueIde(), 800); @@ -4837,6 +5472,7 @@ export default function IdeWorkspace({ onNavigateToSettings, isActiveTab = false setIsExecuting(false); setActiveSessionId(null); activeSessionIdRef.current = null; + noAutoApplySessionIdsRef.current.delete(sid); }; const handleHardStopAgents = async () => { @@ -4886,6 +5522,7 @@ export default function IdeWorkspace({ onNavigateToSettings, isActiveTab = false setActiveSessionId(null); activeSessionIdRef.current = null; activeSessionModelIds.current = []; + noAutoApplySessionIdsRef.current.clear(); setOrchestraLiveAssignments(prev => prev.map(a => ({ ...a, status: a.status === 'done' ? a.status : 'error', @@ -4988,38 +5625,56 @@ export default function IdeWorkspace({ onNavigateToSettings, isActiveTab = false const handleSendPrompt = async () => handleSendIdePrompt(promptText); - const handleApplyFile = async (msgId: string, fileIdx: number, filePath: string, content: string) => { + const collectPendingFileWrites = (): PendingFileWriteItem[] => [ + ...ideMessages.flatMap(m => + (m.fileWrites ?? []).map((fw, i) => ({ source: 'ide' as const, msgId: m.id, fileIdx: i, ...fw })) + ), + ...agentMessages.flatMap(m => + (m.fileWrites ?? []).map((fw, i) => ({ source: 'agent' as const, msgId: m.id, fileIdx: i, ...fw })) + ), + ]; + + const handleApplyFile = async ( + msgId: string, + fileIdx: number, + filePath: string, + content: string, + source: FileWriteSource = 'ide', + ) => { if (!workspacePath) return; - try { - // If the agent gave an absolute path inside the workspace, make it relative - let relativePath = filePath; - const normalizedWorkspace = workspacePath.replace(/\\/g, '/').replace(/\/$/, ''); - const normalizedFile = filePath.replace(/\\/g, '/'); - if (normalizedFile.toLowerCase().startsWith(normalizedWorkspace.toLowerCase() + '/')) { - relativePath = normalizedFile.slice(normalizedWorkspace.length + 1); - } - await invoke('write_file_content', { workspacePath, relativePath, content }); - setIdeMessages(prev => prev.map(m => { + const updateFileWrite = (next: Partial) => { + const updateMessage = (m: { id: string; fileWrites?: ParsedFileWrite[] }) => { if (m.id !== msgId || !m.fileWrites) return m; const updated = [...m.fileWrites]; - updated[fileIdx] = { ...updated[fileIdx], applied: true }; + updated[fileIdx] = { ...updated[fileIdx], ...next }; return { ...m, fileWrites: updated }; - })); + }; + if (source === 'agent') { + setAgentMessages(prev => prev.map(m => updateMessage(m) as AgentMessage)); + } else { + setIdeMessages(prev => prev.map(m => updateMessage(m) as IdeMessage)); + } + }; + let relativePath = filePath; + try { + relativePath = normalizeWorkspaceRelativeFilePath(filePath, workspacePath); + await invoke('write_file_content', { workspacePath, relativePath, content }); + updateFileWrite({ path: relativePath, applied: true, applyError: undefined }); await refreshWorkspaceTree('manual file apply'); - if (activeFile && activeFile.path.replace(/\\/g, '/').endsWith(filePath.replace(/\\/g, '/'))) { + if (activeFile && activeFile.path.replace(/\\/g, '/').endsWith(relativePath.replace(/\\/g, '/'))) { handleFileSelect(activeFile); } } catch (e) { - alert('Failed to write file: ' + e); + const message = e instanceof Error ? e.message : String(e); + setSendError(`Failed to write ${relativePath}: ${message}`); + updateFileWrite({ path: relativePath, applied: false, applyError: message }); } }; const handleApplyAllPending = async () => { - const pending = ideMessages.flatMap(m => - (m.fileWrites ?? []).map((fw, i) => ({ msgId: m.id, fileIdx: i, ...fw })) - ).filter(fw => !fw.applied); + const pending = collectPendingFileWrites().filter(fw => !fw.applied); for (const fw of pending) { - await handleApplyFile(fw.msgId, fw.fileIdx, fw.path, fw.content); + await handleApplyFile(fw.msgId, fw.fileIdx, fw.path, fw.content, fw.source); } }; @@ -5110,6 +5765,10 @@ Rules: setIsSending(false); return; } + if (!(await ensureSelectedLocalModelsReady())) { + setIsSending(false); + return; + } let runtimeOrchestraIds = orchestraIds; @@ -5492,12 +6151,16 @@ Rules: })); setIdeMessages(prev => [...prev, userMsg, ...aiPlaceholders]); + const hydratedContextFiles = contextFiles.length > 0 + ? await hydrateContextFilesForSend(contextFiles) + : null; + await invoke('execute_prompt', { input: { prompt: executionPrompt, model_ids: activeIdeModelIds, model_instances: modelInstances, - context_files: contextFiles.length > 0 ? contextFiles : null, + context_files: hydratedContextFiles, images: null, session_id: sessionId, compliance_block: complianceBlock, @@ -5740,24 +6403,62 @@ Rules: if (!promptText.trim()) return; if (!workspacePath) { setSendError('Open a workspace folder first.'); return; } if (activeIdeModelIds.length === 0) { setSendError('Select at least one model in the model bar.'); return; } - const modelId = activeIdeModelIds[0]; - const runModelName = allModels.find(m => m.id === modelId)?.model_name ?? ''; - const runModelLabel = runModelName || allModels.find(m => m.id === modelId)?.display_name || modelId; + if (!(await ensureSelectedLocalModelsReady())) return; + const chatOnlyModelIds = new Set(readChatOnlyModelIds()); + let readinessById: Record = {}; + if (!agentRunning) { + try { + const readinessEntries = await Promise.all(activeIdeModelIds.map(async id => { + const readiness = selectedIdeReadiness[id] + ?? await invoke('get_ide_model_tool_readiness', { modelId: id }); + return [id, readiness] as const; + })); + readinessById = Object.fromEntries(readinessEntries); + setSelectedIdeReadiness(prev => ({ ...prev, ...readinessById })); + } catch (e: any) { + setSendError(`Could not verify IDE tool readiness for the selected models: ${String(e)}`); + return; + } + } - if (!agentRunning && readChatOnlyModelIds().includes(modelId)) { - setChatOnlyModelPrompt({ - modelId, - modelName: runModelLabel, - reason: 'marked', - message: `${runModelLabel} is marked as Chat Coding only from a previous failed Agent Mode test.`, - }); + const modelId = agentRunning + ? activeIdeModelIds[0] + : activeIdeModelIds.find(id => + !chatOnlyModelIds.has(id) && ideReadinessCanAttemptAgent(readinessById[id], allModels.find(m => m.id === id)) + ) ?? null; + if (!modelId) { + const fallbackId = activeIdeModelIds[0]; + const fallbackModel = allModels.find(m => m.id === fallbackId); + const fallbackLabel = fallbackModel?.model_name || fallbackModel?.display_name || fallbackId; + const fallbackReadiness = fallbackId ? readinessById[fallbackId] : null; + if (fallbackReadiness) { + setIdeToolReadinessWarning(fallbackReadiness); + } + if (fallbackId && chatOnlyModelIds.has(fallbackId)) { + setChatOnlyModelPrompt({ + modelId: fallbackId, + modelName: fallbackLabel, + reason: 'marked', + message: `${fallbackLabel} is marked as Chat Coding only from a previous failed Agent Mode test.`, + }); + } setAgentRunNotice({ kind: 'error', - text: `${runModelLabel} is marked Chat Coding only. You can switch to Chat Coding or clear the marker and test Agent Mode again.`, + text: 'No selected model can start Agent Mode. Add a tool-capable model or switch to Chat Coding for this team.', }); - setSendError(null); + setSendError('No selected model can start Agent Mode. Add a tool-capable model or switch to Chat Coding for this team.'); return; } + const agentCompanionModelIds = agentRunning ? [] : activeIdeModelIds.filter(id => id !== modelId); + const runModel = allModels.find(m => m.id === modelId); + const runModelName = allModels.find(m => m.id === modelId)?.model_name ?? ''; + const runModelLabel = runModelName || runModel?.display_name || modelId; + const primaryReadiness = !agentRunning ? readinessById[modelId] : undefined; + const primaryReadinessAllowsWrite = ideReadinessAllowsWrite(primaryReadiness, runModel); + const effectiveAgentAllowWrite = agentRunning || !primaryReadiness + ? agentAllowWrite + : agentAllowWrite && primaryReadinessAllowsWrite; + const primaryWriteDowngraded = !agentRunning && agentAllowWrite && primaryReadiness ? !primaryReadinessAllowsWrite : false; // ── B1-3: Large context confirmation (> 50K estimated tokens) ───────── if (!skipLargeContextCheck && !agentRunning) { @@ -5771,24 +6472,21 @@ Rules: const userText = promptText.trim(); if (!agentRunning) { - let readiness: IdeModelToolReadiness; - try { - readiness = await invoke('get_ide_model_tool_readiness', { modelId }); - } catch (e: any) { - setSendError(`Could not verify IDE tool readiness for this model: ${String(e)}`); + const readiness = primaryReadiness; + if (!readiness) { + setSendError('Could not verify IDE tool readiness for the primary tool agent.'); return; } const warningKey = buildIdeToolReadinessWarningKey(readiness); - const canContinueWithCurrentMode = readiness.can_start_agent && (!agentAllowWrite || readiness.can_write); - const mustStopForReadiness = readiness.warning_required || !readiness.can_start_agent || (agentAllowWrite && !readiness.can_write); - if (mustStopForReadiness && (ideToolReadinessOverrideRef.current !== warningKey || !canContinueWithCurrentMode)) { + const mustStopForReadiness = !ideReadinessCanAttemptAgent(readiness, runModel); + if (mustStopForReadiness && ideToolReadinessOverrideRef.current !== warningKey) { ideToolReadinessOverrideRef.current = null; setIdeToolReadinessWarning(readiness); setSendError(null); return; } ideToolReadinessOverrideRef.current = null; - setIdeToolReadinessWarning(null); + setIdeToolReadinessWarning(readiness.warning_required ? readiness : null); } const interruptCheckpointForRun = agentInterruptCheckpoint?.status === 'checkpointed' ? agentInterruptCheckpoint @@ -5803,9 +6501,125 @@ Rules: } setPromptText(''); setSendError(null); - setAgentRunNotice(null); + setAgentRunNotice(primaryWriteDowngraded + ? { + kind: 'done', + text: `${runModel?.display_name ?? runModelLabel} can use IDE tools but not file writes in this route, so the primary tool agent will run read-only while companions stay in Chat Coding.`, + } + : null); setChatOnlyModelPrompt(null); + const startAgentChatCompanions = async () => { + if (agentCompanionModelIds.length === 0) return; + const companionSessionId = 'agent_companion_' + crypto.randomUUID(); + const primaryName = runModel?.display_name ?? runModelLabel; + const companionModels = agentCompanionModelIds + .map(id => allModels.find(m => m.id === id)) + .filter((m): m is ModelRecord => Boolean(m)); + const companionNames = companionModels.map(m => m.display_name).join(', '); + const companionPrompt = + `You are a Chat Coding companion in More AI IDE Agent Mode.\n` + + `Primary tool agent: ${primaryName}.\n` + + `Chat Coding companions: ${companionNames || 'none'}.\n\n` + + `You cannot call IDE tools or directly edit files in this companion run. ` + + `Review, draft, point out risks, and write concise Coordination Notes so the user can reconcile your output with the primary tool agent. ` + + `Do not claim you changed files. Avoid [FILE:] blocks unless the user explicitly asked for a draft file.\n\n` + + `Task:\n${agentExecutionText}`; + try { + const [workspaceSysPrompt, challenge, hydratedContextFiles] = await Promise.all([ + buildWorkspaceSystemPrompt(), + invoke<{ nonce: string; antigravity_hash: string; expires_at: string }>( + 'issue_antigravity_challenge', { sessionId: companionSessionId } + ), + contextFiles.length > 0 ? hydrateContextFilesForSend(contextFiles) : Promise.resolve(null), + ]); + const complianceBlock = 'ANTIGRAVITY_COMPLIANCE:' + JSON.stringify({ + acknowledged: true, + nonce: challenge.nonce, + antigravity_hash: challenge.antigravity_hash, + session_id: companionSessionId, + timestamp: new Date().toISOString(), + }); + const companionInstanceEntries = agentCompanionModelIds + .map(id => ({ id, count: ideInstanceCounts[id] ?? 1 })) + .filter(entry => entry.count > 1); + const companionModelInstances = companionInstanceEntries.length > 0 ? companionInstanceEntries : null; + const companionExpandedModelIds = agentCompanionModelIds.flatMap(mid => { + const count = ideInstanceCounts[mid] ?? 1; + const base = allModels.find(m => m.id === mid); + if (count > 1) { + return Array.from({ length: count }, (_, i) => ({ + effectiveId: `${mid}:${i + 1}`, + displayName: `${base?.display_name ?? mid} #${i + 1}`, + })); + } + return [{ effectiveId: mid, displayName: base?.display_name ?? mid }]; + }); + const historySnapshot = ideHistoryRef.current.slice(-6); + ideHistoryRef.current.push({ role: 'user', content: `Agent Mode companion review:\n${agentExecutionText}` }); + ideCurrentResponsesRef.current = new Map(); + activeSessionModelIds.current = companionExpandedModelIds.map(e => e.effectiveId); + noAutoApplySessionIdsRef.current.add(companionSessionId); + setIdeMessages(prev => [ + ...prev, + { + id: `${companionSessionId}:user`, + role: 'user', + content: `Agent Mode companion review:\n${userText}`, + streaming: false, + }, + ...companionExpandedModelIds.map(({ effectiveId, displayName }) => ({ + id: `${companionSessionId}:${effectiveId}`, + role: 'assistant' as const, + modelId: effectiveId, + modelName: displayName, + content: '', + streaming: true, + })), + ]); + setActiveSessionId(companionSessionId); + activeSessionIdRef.current = companionSessionId; + setIsExecuting(true); + await invoke('execute_prompt', { + input: { + prompt: companionPrompt, + model_ids: agentCompanionModelIds, + model_instances: companionModelInstances, + context_files: hydratedContextFiles, + images: null, + session_id: companionSessionId, + compliance_block: complianceBlock, + history: historySnapshot.length > 0 ? historySnapshot : null, + conversation_id: ideConversationIdRef.current, + system_prompt_override: workspaceSysPrompt ?? null, + per_model_system_prompts: null, + history_depth_override: companionModels.some(m => m.source_class === 'local_llm') ? 20 : null, + } + }); + setAgentRunNotice({ + kind: 'done', + text: `${primaryName} is the primary tool agent. ${companionNames} ${agentCompanionModelIds.length === 1 ? 'is' : 'are'} running as Chat Coding companion${agentCompanionModelIds.length === 1 ? '' : 's'}.`, + }); + } catch (e) { + noAutoApplySessionIdsRef.current.delete(companionSessionId); + if (activeSessionIdRef.current === companionSessionId) { + setIsExecuting(false); + setActiveSessionId(null); + activeSessionIdRef.current = null; + activeSessionModelIds.current = []; + } + setIdeMessages(prev => prev.map(m => + m.id.startsWith(`${companionSessionId}:`) + ? { ...m, streaming: false, error: `Companion Chat Coding failed: ${String(e)}` } + : m + )); + setAgentRunNotice({ + kind: 'error', + text: `Companion Chat Coding failed, but the primary tool agent can still run: ${String(e)}`, + }); + } + }; + // B2-2: Auto-detect audit/review prompts → enable Plan First for this send only // FAIL-04: Do NOT call setPlanFirst(true) — that persists to localStorage and sticks // permanently. Instead, use a local flag so planFirst resets after agent:done. @@ -5849,6 +6663,8 @@ Rules: } // ── New run (or continuation) path ──────────────────────────────────── + void startAgentChatCompanions(); + const sessionId = 'agent_' + crypto.randomUUID(); agentSessionIdRef.current = sessionId; setLastAgentSessionId(sessionId); @@ -5869,6 +6685,8 @@ Rules: setPlanAwaitingApproval(false); setAgentRunning(true); agentToolNamesByCallIdRef.current.clear(); + agentSuccessfulToolCallCountRef.current = 0; + agentSuccessfulWriteCountRef.current = 0; // Add a visual run-separator if continuing a prior session const shouldUseAgentContinuation = agentContinuationRef.current !== null && isExplicitAgentContinuationPrompt(userText); @@ -5887,7 +6705,15 @@ Rules: ? [{ id: crypto.randomUUID(), role: 'user' as const, text: '─── continuing ───', toolCalls: [] }] : []), { id: crypto.randomUUID(), role: 'user' as const, text: userText, toolCalls: [] }, - { id: asstMsgId, role: 'assistant' as const, text: '', toolCalls: [] }, + { + id: asstMsgId, + role: 'assistant' as const, + text: '', + modelId, + modelName: runModel?.display_name ?? runModelName ?? modelId, + agentRole: agentCompanionModelIds.length > 0 ? 'Primary Tool Agent' : 'Tool Agent', + toolCalls: [], + }, ]); // B1-1: Capture model name for cost calculation @@ -5908,9 +6734,45 @@ Rules: // Subscribe to events BEFORE invoke so no events are missed let cleanup: () => void = () => {}; + let assistantTextForRun = ''; + const buildAgentPlainTextFileWrites = (): ParsedFileWrite[] => { + const assistantText = assistantTextForRun || agentMessagesRef.current.find(m => m.id === asstMsgId)?.text || ''; + if (!assistantText.trim()) return []; + const activeRelative = activeFile + ? activeFile.path.replace(/\\/g, '/').replace((workspacePathRef.current ?? '').replace(/\\/g, '/') + '/', '') + : undefined; + const requestedPath = inferRequestedFilePathFromText(userText) ?? inferRequestedFilePathFromText(assistantText); + let parsed = applyRequestedPathToInferredWrites(parseFileWrites(assistantText, activeRelative), requestedPath); + if (parsed.length === 0 && requestedPath) { + const bareHtml = assistantText.match(//i) + ?? assistantText.match(//i); + if (bareHtml?.[0]) { + parsed = [{ path: requestedPath, content: bareHtml[0].trim(), inferred: true }]; + } + } + const wp = workspacePathRef.current ?? workspacePath; + return parsed.map(fw => { + if (!wp) return { ...fw, applied: false }; + try { + return { + ...fw, + path: normalizeWorkspaceRelativeFilePath(fw.path, wp), + applied: false, + applyError: undefined, + }; + } catch (e) { + return { + ...fw, + applied: false, + applyError: e instanceof Error ? e.message : String(e), + }; + } + }); + }; const [onText, onCall, onResult, onDone, onError, onStats, onSnapshot, onInjected, onBacktrack, onDivergence, onRetro, onWriteConfirm, onThinking, onCmdApproval] = await Promise.all([ listen<{ session_id: string; text: string }>('agent:text_chunk', ev => { if (ev.payload.session_id !== sessionId) return; + assistantTextForRun += ev.payload.text; // 2-A: Accumulate text for intent narration (last sentence before a tool call) lastTextBeforeToolRef.current += ev.payload.text; setAgentMessages(prev => prev.map(m => @@ -5967,6 +6829,12 @@ Rules: ), } : m )); + if (!ev.payload.is_error) { + agentSuccessfulToolCallCountRef.current += 1; + if (toolName === 'write_file') { + agentSuccessfulWriteCountRef.current += 1; + } + } if (!ev.payload.is_error && toolName === 'write_file') { void refreshWorkspaceTree('agent write_file'); } @@ -6001,19 +6869,74 @@ Rules: { const completedToolCalls = agentMessagesRef.current.flatMap(m => m.toolCalls ?? []); const editCount = completedToolCalls.filter(tc => tc.tool_name === 'write_file' && !tc.is_error).length; + const plainTextFallbackWrites = ev.payload.tool_calls_made === 0 ? buildAgentPlainTextFileWrites() : []; + const validPlainTextFallbackWrites = plainTextFallbackWrites.filter(fw => !fw.applyError); recordModelCompetency(runModelName || agentModelName || modelId, { toolCalls: ev.payload.tool_calls_made, - edits: editCount, + edits: editCount + validPlainTextFallbackWrites.length, error: false, - stoppedEarly: ev.payload.tool_calls_made === 0 && agentAllowWrite, + stoppedEarly: ev.payload.tool_calls_made === 0 && effectiveAgentAllowWrite && validPlainTextFallbackWrites.length === 0, }); + if (plainTextFallbackWrites.length > 0) { + setAgentMessages(prev => prev.map(m => + m.id === asstMsgId ? { ...m, fileWrites: plainTextFallbackWrites } : m + )); + const fileText = plainTextFallbackWrites.length === 1 ? '1 file' : `${plainTextFallbackWrites.length} files`; + if (effectiveAgentAllowWrite && !agentPerFileConfirm && workspacePath) { + setAgentRunNotice({ + kind: 'done', + text: `Agent returned ${fileText} as text instead of calling write_file. More AI is saving it now.`, + }); + void (async () => { + const updated = [...plainTextFallbackWrites]; + let successCount = 0; + for (let i = 0; i < updated.length; i += 1) { + const fw = updated[i]; + if (fw.applyError) continue; + try { + await invoke('write_file_content', { workspacePath, relativePath: fw.path, content: fw.content }); + updated[i] = { ...fw, applied: true, applyError: undefined }; + successCount += 1; + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + updated[i] = { ...fw, applied: false, applyError: message }; + } + } + setAgentMessages(prev => prev.map(m => + m.id === asstMsgId ? { ...m, fileWrites: updated } : m + )); + if (successCount > 0) { + await refreshWorkspaceTree('agent plain-text file fallback'); + } + const failed = updated.find(fw => fw.applyError && !fw.applied); + if (failed) { + setSendError(`Agent text fallback could not write ${failed.path}: ${failed.applyError}`); + } + setAgentRunNotice({ + kind: successCount > 0 ? 'done' : 'error', + text: successCount > 0 + ? `Agent returned code without tool calls, so More AI saved ${successCount === 1 ? '1 file' : `${successCount} files`} from the response.` + : `Agent returned file content without tool calls, but More AI could not save it. Review the queued file below.`, + }); + })(); + } else { + setAgentRunNotice({ + kind: 'done', + text: effectiveAgentAllowWrite + ? `Agent returned ${fileText} as text instead of calling write_file. Confirm writes is on, so it is queued for review below.` + : `Agent returned ${fileText} as text instead of calling write_file. Write files is off, so it is queued for review below.`, + }); + } + } else { + setAgentRunNotice({ kind: 'done', text: `Agent finished: ${ev.payload.tool_calls_made} tool calls across ${ev.payload.turns_made} turns.` }); + } + if (shouldPlanFirst && ev.payload.tool_calls_made === 0 && plainTextFallbackWrites.length === 0) { + setPlanAwaitingApproval(true); + } } // B2-1: If this was a plan-first run with no tool calls → show Proceed/Edit buttons - setAgentRunNotice({ kind: 'done', text: `Agent finished: ${ev.payload.tool_calls_made} tool calls across ${ev.payload.turns_made} turns.` }); + setSendError(null); setChatOnlyModelPrompt(null); - if (shouldPlanFirst && ev.payload.tool_calls_made === 0) { - setPlanAwaitingApproval(true); - } // 1-B: Refresh sync history after session ends if (workspacePath) { void refreshWorkspaceTree('agent done'); @@ -6069,22 +6992,45 @@ Rules: return; } const parsedAgentError = parseTrierError(ev.payload.message); - setSendError(ev.payload.message); + const messageProgress = summarizeAgentToolProgress(agentMessagesRef.current); + const progress = { + toolCalls: Math.max(messageProgress.toolCalls, agentToolCallCountRef.current), + successfulToolCalls: Math.max(messageProgress.successfulToolCalls, agentSuccessfulToolCallCountRef.current), + successfulWrites: Math.max(messageProgress.successfulWrites, agentSuccessfulWriteCountRef.current), + }; setAgentRunning(false); - setAgentRunNotice({ kind: 'error', text: `Agent failed: ${parsedAgentError.message}` }); - setChatOnlyModelPrompt({ - modelId, - modelName: runModelName || agentModelName || modelId, - reason: 'failure', - message: parsedAgentError.message, - }); + if (progress.successfulToolCalls > 0) { + const writeText = progress.successfulWrites === 1 + ? '1 file' + : `${progress.successfulWrites} files`; + const toolText = progress.successfulToolCalls === 1 + ? '1 tool call' + : `${progress.successfulToolCalls} tool calls`; + setSendError(null); + setAgentRunNotice({ + kind: 'done', + text: progress.successfulWrites > 0 + ? `Agent wrote ${writeText}, then hit a recoverable model response issue. The workspace was refreshed. Detail: ${parsedAgentError.message}` + : `Agent completed ${toolText}, then hit a recoverable model response issue. Detail: ${parsedAgentError.message}`, + }); + setChatOnlyModelPrompt(null); + } else { + setSendError(ev.payload.message); + setAgentRunNotice({ kind: 'error', text: `Agent failed: ${parsedAgentError.message}` }); + setChatOnlyModelPrompt({ + modelId, + modelName: runModelName || agentModelName || modelId, + reason: 'failure', + message: parsedAgentError.message, + }); + } recordModelCompetency(runModelName || agentModelName || modelId, { toolCalls: agentToolCallCountRef.current, - edits: agentMessagesRef.current.flatMap(m => m.toolCalls ?? []).filter(tc => tc.tool_name === 'write_file' && !tc.is_error).length, - error: true, - stoppedEarly: true, + edits: progress.successfulWrites, + error: progress.successfulToolCalls === 0, + stoppedEarly: progress.successfulToolCalls === 0, }); - void refreshWorkspaceTree('agent error'); + void refreshWorkspaceTree(progress.successfulToolCalls > 0 ? 'agent recoverable error' : 'agent error'); appendAgentRunTrace({ sessionId, kind: 'error', @@ -6195,12 +7141,9 @@ Rules: // O1-3: Build user_context_hint from current cognitive load level const userContextHint = saturationLevel !== 'normal' ? saturationLevel : null; - // 4-C-1: Build persona system prompt prefix when persona is active + // Build the optional read-only audit prompt when the user explicitly chooses Audit. const personaHints: Record = { - auditor: '[PERSONA: Auditor] You are in Auditor mode. Prioritize security bugs and correctness issues. Always call get_diagnostics and git_diff early. Produce a structured report with ## 🔴 Critical / ## 🟡 Warnings / ## 💡 Suggestions sections.', - architect: '[PERSONA: Architect] You are in Architect mode. Focus on structure, patterns, coupling, and scalability. Produce architecture diagrams in ASCII/text. Identify architectural risks.', - refactor: '[PERSONA: Refactor] You are in Refactor mode. Hunt for dead code, redundancy, and cleanup opportunities. Every suggestion must show a before/after diff. Do not change logic, only improve clarity and structure.', - explainer: '[PERSONA: Explainer] You are in Explainer mode. Explain the code in plain language suitable for onboarding. Prioritize readability over technical depth. Avoid jargon. No destructive tool calls.', + auditor: '[MODE: Audit] You are in read-only IDE audit mode. Prioritize security bugs and correctness issues. Call diagnostics and diff tools early. Do not write files. Produce a structured report with Critical, Warnings, and Suggestions sections.', }; const personaPrefix = agentPersona !== 'none' ? (personaHints[agentPersona] ?? null) : null; @@ -6236,6 +7179,17 @@ Rules: ? `${effectiveSystemPrompt}\n\n${memoryNote}` : memoryNote; } + if (agentCompanionModelIds.length > 0) { + const companionNames = agentCompanionModelIds + .map(id => allModels.find(m => m.id === id)?.display_name ?? id) + .join(', '); + const companionNote = + `[Team routing: You are the primary IDE tool agent. Chat Coding companion${agentCompanionModelIds.length === 1 ? '' : 's'} (${companionNames}) ` + + `are reviewing the same task in parallel without tool access. Keep your changes grounded, summarize what you changed, and leave enough detail for the user to reconcile companion notes.]`; + effectiveSystemPrompt = effectiveSystemPrompt + ? `${effectiveSystemPrompt}\n\n${companionNote}` + : companionNote; + } const recentVisualEvidence = visualEvidenceItems.slice(-3); if (recentVisualEvidence.length > 0) { const evidenceNote = [ @@ -6259,14 +7213,14 @@ Rules: system_prompt: effectiveSystemPrompt, history: continuationMessages ? null : history, continuation_messages: continuationMessages, - allow_write: agentAllowWrite, + allow_write: effectiveAgentAllowWrite, allow_terminal: agentAllowTerminal, max_tool_calls: scaledBudget, coe_active: coeActive, plan_first: shouldPlanFirst, user_context_hint: userContextHint, domain_trust_hint: domainTrustHint, - per_file_confirm: agentAllowWrite && agentPerFileConfirm, + per_file_confirm: effectiveAgentAllowWrite && agentPerFileConfirm, }, }); } catch (e: any) { @@ -6754,7 +7708,10 @@ Rules: '', ].filter(Boolean); for (const [msgIndex, msg] of agentMessages.entries()) { - lines.push(`## ${msgIndex + 1}. ${msg.role === 'user' ? 'You' : 'Agent'}`); + const speaker = msg.role === 'user' + ? 'You' + : `${msg.modelName ?? msg.modelId ?? 'Agent'}${msg.agentRole ? ` - ${msg.agentRole}` : ''}`; + lines.push(`## ${msgIndex + 1}. ${speaker}`); lines.push(''); lines.push(msg.text || '(no text)'); if ((msg.toolCalls ?? []).length > 0) { @@ -7464,6 +8421,22 @@ Rules: ); }; + const canLoadAgentRepairPrompt = + agentRunNotice?.kind === 'error' + && (agentMessages.length > 0 || agentToolCallCount > 0 || agentRunTrace.length > 0); + + const loadAgentRepairPrompt = () => { + const failure = agentRunNotice?.text || sendError || 'Agent run failed.'; + setPromptText(`Repair the last Agent Mode run and continue from the last safe checkpoint.\n\nFailure:\n${failure}\n\nPlease inspect the current workspace state, preserve any completed file writes, and continue with the smallest safe next step.`); + setCenterView('agent'); + setAgentSubMode('agent'); + setAgentRunNotice({ + kind: 'done', + text: 'Repair prompt loaded in the Agent Console. Review it, then press Run Agent.', + }); + setTimeout(() => promptTextareaRef.current?.focus(), 0); + }; + const renderAgentConsole = (compact = false) => (
@@ -7507,14 +8480,11 @@ Rules: )}
- {agentRunNotice.kind === 'error' && ( + {canLoadAgentRepairPrompt && ( )} {chatOnlyModelPrompt && agentRunNotice.kind === 'error' && ( @@ -7551,6 +8521,17 @@ Rules:
)} + {sendError && ( +
+
+ {sendError} +
+
+ +
+
+ )} +
@@ -7563,10 +8544,13 @@ Rules: ) : ( agentMessages.map(msg => { const noisyToolDump = msg.role === 'assistant' && looksLikeNoisyPrintedToolDump(msg.text); + const speakerLabel = msg.role === 'user' + ? 'YOU' + : `${msg.modelName ?? msg.modelId ?? 'AGENT'}${msg.agentRole ? ` - ${msg.agentRole}` : ''}`; return (
- {msg.role === 'user' ? 'YOU' : 'AGENT'} + {speakerLabel}
{(msg.toolCalls ?? []).length > 0 && {(msg.toolCalls ?? []).length} tool calls below} @@ -7578,6 +8562,11 @@ Rules:
)}
{msg.text || (agentRunning && msg.role === 'assistant' ? 'Working...' : '(no text)')}
+ {!agentRunning && msg.fileWrites && msg.fileWrites.length > 0 && ( +
+ {msg.fileWrites.length} file{msg.fileWrites.length === 1 ? '' : 's'} queued below +
+ )}
); }) @@ -7607,7 +8596,11 @@ Rules: agentMessages.map(msg => ( (msg.toolCalls ?? []).length === 0 ? null : (
-
{msg.role === 'user' ? 'User turn' : 'Agent turn'}
+
+ {msg.role === 'user' + ? 'User turn' + : `${msg.modelName ?? msg.modelId ?? 'Agent'}${msg.agentRole ? ` - ${msg.agentRole}` : ''}`} +
{groupToolCalls(msg.toolCalls ?? [], msg.id).map((item, index) => renderAgentToolItem(msg, item, index))}
) @@ -7627,6 +8620,8 @@ Rules: ))}