diff --git a/.github/actions/cache-macos-mlx/action.yml b/.github/actions/cache-macos-mlx/action.yml deleted file mode 100644 index 3fcc8fc52..000000000 --- a/.github/actions/cache-macos-mlx/action.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: Cache macOS MLX native build -description: Preserve vendored MLX CMake output before rust-cache removes in-tree path dependencies. -inputs: - profile: - description: Cargo output profile directory (debug or release) - required: true -runs: - using: composite - steps: - - name: Fingerprint MLX native toolchain - id: native - shell: bash - working-directory: openless-all/app - env: - OPENLESS_MLX_PROFILE: ${{ inputs.profile }} - run: | - set -euo pipefail - case "$OPENLESS_MLX_PROFILE" in - debug|release) ;; - *) echo "::error::Unsupported MLX cache profile"; exit 1 ;; - esac - fingerprint=$({ - rustc -vV - xcrun clang --version - # Downloadable Metal toolchains can change independently of Clang. - # Their InstalledDir may contain a per-boot mount identifier. - xcrun --sdk macosx metal --version | sed '/^InstalledDir:/d' - xcrun --sdk macosx --show-sdk-version - cmake --version - git -C src-tauri/vendor/qwen3-asr-rs rev-parse HEAD - git -C src-tauri/vendor/qwen3-asr-rs/mlx-c rev-parse HEAD - for name in CARGO_PROFILE_DEV_DEBUG CARGO_PROFILE_TEST_DEBUG CARGO_PROFILE_RELEASE_CODEGEN_UNITS CARGO_PROFILE_RELEASE_STRIP RUSTFLAGS CARGO_ENCODED_RUSTFLAGS CC CXX CFLAGS CXXFLAGS SDKROOT DEVELOPER_DIR MACOSX_DEPLOYMENT_TARGET CMAKE_GENERATOR CMAKE_TOOLCHAIN_FILE; do - printf '%s=%s\n' "$name" "${!name-}" - done - } | shasum -a 256 | awk '{print $1}') - echo "fingerprint=$fingerprint" >> "$GITHUB_OUTPUT" - - # Call this action AFTER rust-cache: post actions run in reverse order, - # so MLX is saved before rust-cache prunes src-tauri/vendor path packages. - # Cache only CMake output, not Cargo fingerprints: Cargo still reruns the - # build script and CMake validates its native inputs on every clean checkout. - - uses: actions/cache@v4 - with: - path: openless-all/app/src-tauri/target/${{ inputs.profile }}/build/qwen3-asr-rs-*/out - key: macos-mlx-v1-${{ runner.arch }}-${{ inputs.profile }}-${{ steps.native.outputs.fingerprint }}-${{ hashFiles('openless-all/app/src-tauri/Cargo.toml', 'openless-all/app/src-tauri/Cargo.lock', 'openless-all/app/src-tauri/tauri*.json', '.cargo/config.toml', '.github/actions/cache-macos-mlx/action.yml') }} diff --git a/.github/workflows/android-apk.yml b/.github/workflows/android-apk.yml index 0030092b4..3146b63d1 100644 --- a/.github/workflows/android-apk.yml +++ b/.github/workflows/android-apk.yml @@ -164,9 +164,6 @@ jobs: # Android does not use the local Qwen3/Whisper C submodules. submodules: false - - name: Disable macOS-only Qwen3 MLX dependency - run: node openless-all/app/scripts/ci-disable-macos-qwen3.mjs - - name: Check updater signing availability (tag release) if: needs.plan.outputs.is_tag_release == 'true' shell: bash @@ -548,7 +545,8 @@ jobs: name: OpenLess ${{ github.ref_name }} # The desktop and Android jobs share one Beta draft. Publish it only # after all jobs and package verification have finished. - draft: ${{ env.OPENLESS_RELEASE_CHANNEL == 'beta' }} + # Publication is administrator-controlled after both asset workflows pass. + draft: true prerelease: ${{ env.OPENLESS_RELEASE_CHANNEL == 'beta' }} append_body: true body_path: ${{ runner.temp }}/android-release-body.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 728b4145f..2e6296e55 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -95,15 +95,9 @@ jobs: steps: - uses: actions/checkout@v4 with: - # Android uses no desktop local-ASR submodules; the macOS MLX dependency stays out of - # this job. + # Optional local runtimes are not source submodules of the main application. submodules: false - # Android compiles no local-ASR C code: build.rs excludes the Android triple by TARGET - # (does not call build_qwen_asr). qwen3-asr-rs is a path dependency, and the Cargo - # resolver must read its manifest for all targets — so the ci-disable-macos-qwen3.mjs - # step below removes that dependency line, otherwise cargo check hard-fails. - # Dropping recursive fetch saves one network fetch and one failure point. - name: Setup Android SDK + NDK uses: android-actions/setup-android@v3 with: @@ -175,9 +169,6 @@ jobs: - name: Check Android updater pubkey matches tauri.conf.json run: npm run check:android-updater-pubkey - - name: Disable macOS-only Qwen3 MLX dependency - run: node scripts/ci-disable-macos-qwen3.mjs - - name: Check Tauri backend (Android target) run: cargo check --locked --manifest-path src-tauri/Cargo.toml --target aarch64-linux-android @@ -264,8 +255,8 @@ jobs: runs-on: ${{ matrix.os }} timeout-minutes: 60 env: - # With the shared Core in place, macOS's first build compiles the MLX C++ dependencies and - # the full Tauri test binary together; limit concurrency so the arm64 runner is not OOM-killed + # The shared Core and full Tauri test binary compile together; limit concurrency + # so the arm64 runner is not OOM-killed # at peak memory. CARGO_BUILD_JOBS: 2 CARGO_PROFILE_TEST_DEBUG: 0 @@ -276,12 +267,8 @@ jobs: steps: - uses: actions/checkout@v4 with: - # Initialize the MLX submodule on macOS only; Windows does not resolve that dependency. - submodules: ${{ matrix.os == 'macos-latest' && 'recursive' || 'false' }} - - name: Disable macOS-only Qwen3 MLX dependency - if: runner.os != 'macOS' - run: node scripts/ci-disable-macos-qwen3.mjs - + # No legacy native-ASR submodules remain in this application. + submodules: false - uses: actions/setup-node@v4 with: node-version: "22" @@ -319,12 +306,6 @@ jobs: openless-all/app -> target openless-all/app/src-tauri -> target - - name: Cache macOS MLX native build - if: runner.os == 'macOS' && runner.arch == 'ARM64' - uses: ./.github/actions/cache-macos-mlx - with: - profile: debug - - name: Prepare Windows Sherpa static libraries if: runner.os == 'Windows' shell: pwsh @@ -460,7 +441,7 @@ jobs: steps: - uses: actions/checkout@v4 with: - submodules: recursive + submodules: false - uses: actions/setup-node@v4 with: node-version: "22" @@ -474,11 +455,6 @@ jobs: workspaces: | openless-all/app/src-tauri -> target openless-all/app/src-tauri/backend-tests -> target - - name: Cache macOS MLX native build - if: runner.arch == 'ARM64' - uses: ./.github/actions/cache-macos-mlx - with: - profile: debug - run: npm ci - run: npm run build - name: Check Tauri backend with Rust 1.88 MSRV diff --git a/.github/workflows/release-tauri.yml b/.github/workflows/release-tauri.yml index fc3963acf..045b0dad8 100644 --- a/.github/workflows/release-tauri.yml +++ b/.github/workflows/release-tauri.yml @@ -62,12 +62,8 @@ jobs: steps: - uses: actions/checkout@v4 with: - # The MLX submodule is only needed for macOS release builds. - submodules: ${{ startsWith(matrix.platform, 'macos') && 'recursive' || 'false' }} - - - name: Disable macOS-only Qwen3 MLX dependency - if: ${{ !startsWith(matrix.platform, 'macos') }} - run: node openless-all/app/scripts/ci-disable-macos-qwen3.mjs + # Optional inference dependencies are installed only by explicit user opt-in. + submodules: false - uses: actions/setup-node@v4 with: @@ -99,12 +95,6 @@ jobs: key: macos-release-v1 workspaces: 'openless-all/app/src-tauri -> target' - - name: Cache macOS MLX native build - if: matrix.updater-arch == 'aarch64' - uses: ./.github/actions/cache-macos-mlx - with: - profile: release - - name: Prepare Windows Sherpa static libraries if: matrix.platform == 'windows-latest' working-directory: 'openless-all/app' @@ -536,9 +526,9 @@ jobs: with: tag_name: ${{ github.ref_name }} name: 'OpenLess ${{ github.ref_name }}' - # Keep Beta assets private until all platforms have built and the - # administrator has verified the downloadable packages. - draft: ${{ env.OPENLESS_RELEASE_CHANNEL == 'beta' }} + # Both channels stay draft until all desktop/Android assets are verified. + # The administrator explicitly publishes the shared release afterwards. + draft: true # Beta releases must be marked prerelease=true: GitHub UI collapses them and regular # users do not see them; only latest-*-beta.json is uploaded, so stable users' # endpoint (latest-*.json) is never overwritten and beta never leaks into stable. @@ -560,100 +550,3 @@ jobs: openless-all/app/src-tauri/target/release/bundle/msi/*.msi openless-all/app/src-tauri/target/release/bundle/msi/*.msi.sig openless-all/app/src-tauri/target/release/bundle/latest-*.json - - # ── After a stable release, update the Homebrew cask automatically ── - # Why in this pipeline instead of a separate `release: published` workflow: a Release created - # by softprops with the default GITHUB_TOKEN does not trigger other workflows on the `release` - # event (GitHub's anti-recursion rule), so a standalone update-cask workflow would never fire - # automatically. Chaining it with needs: build in the same pipeline is the only way to update - # the cask on every stable release. - # - # Stable only: v*-tauri and not -beta-tauri. Beta does not touch Homebrew, avoiding pushing a - # prerelease to `brew install --cask openless` users. The cask lives on the default branch - # (beta); commits go back to that branch. - update-homebrew-cask: - name: Update Homebrew cask (stable only) - needs: build - runs-on: ubuntu-latest - timeout-minutes: 30 - permissions: - contents: write - if: >- - startsWith(github.ref, 'refs/tags/v') - && endsWith(github.ref, '-tauri') - && !endsWith(github.ref, '-beta-tauri') - && !contains(github.ref_name, '-Beta.') - steps: - - name: Checkout default branch (cask 住在这里) - uses: actions/checkout@v4 - with: - ref: ${{ github.event.repository.default_branch }} - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Resolve version + DMG sha256 from the release - id: meta - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAG: ${{ github.ref_name }} - run: | - set -euo pipefail - version="${TAG#v}" - version="${version%-tauri}" - - # Download the DMGs directly and compute sha256 ourselves; do not depend on the - # timing/availability of GitHub's asset digest fields. - mkdir -p "$RUNNER_TEMP/dmg" - gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \ - --dir "$RUNNER_TEMP/dmg" \ - --pattern '*aarch64.dmg' --pattern '*_x64.dmg' - - arm_sha="$(sha256sum "$RUNNER_TEMP"/dmg/*aarch64.dmg | cut -d' ' -f1)" - intel_sha="$(sha256sum "$RUNNER_TEMP"/dmg/*_x64.dmg | cut -d' ' -f1)" - - { - echo "version=$version" - echo "arm_sha=$arm_sha" - echo "intel_sha=$intel_sha" - } >> "$GITHUB_OUTPUT" - echo "cask → version=$version arm=$arm_sha intel=$intel_sha" - - - name: Update Casks/openless.rb - env: - VERSION: ${{ steps.meta.outputs.version }} - ARM_SHA: ${{ steps.meta.outputs.arm_sha }} - INTEL_SHA: ${{ steps.meta.outputs.intel_sha }} - run: | - set -euo pipefail - cask="Casks/openless.rb" - # Warning: line 2 of the cask, `arch arm: "aarch64", intel: "x64"`, also contains - # `intel: "..."`. A bare `s/intel: "..."/` would rewrite the arch instruction with a - # hash too -> brew installs 404 for Intel users. So the intel sed anchors the sha256 - # line with `^[[:space:]]*intel:`, while the arm sed naturally matches only the sha256 - # line via the `sha256 arm: ` prefix. Capture group \1 preserves the original indent. - sed -i "s/version \"[^\"]*\"/version \"$VERSION\"/" "$cask" - sed -i "s/sha256 arm: \"[^\"]*\"/sha256 arm: \"$ARM_SHA\"/" "$cask" - sed -i "s/^\([[:space:]]*\)intel: \"[^\"]*\"/\1intel: \"$INTEL_SHA\"/" "$cask" - # Guard: the arch instruction must remain untouched and both new sha256 values must - # actually be written, otherwise fail the job. - grep -q 'arch arm: "aarch64", intel: "x64"' "$cask" \ - || { echo "::error::arch 指令被 sed 误伤"; exit 1; } - grep -q "\"$ARM_SHA\"" "$cask" && grep -q "\"$INTEL_SHA\"" "$cask" \ - || { echo "::error::sha256 未正确写入 cask"; exit 1; } - echo "----- updated $cask -----" - cat "$cask" - - - name: Commit & push cask bump - env: - VERSION: ${{ steps.meta.outputs.version }} - run: | - set -euo pipefail - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add Casks/openless.rb - if git diff --cached --quiet; then - echo "cask 已是 $VERSION,无需提交" - exit 0 - fi - # [skip ci]: cask text changes do not need another cross-platform ci.yml run. - git commit -m "[cask] openless $VERSION (auto from release) [skip ci]" - git push diff --git a/.github/workflows/sync-homebrew-cask.yml b/.github/workflows/sync-homebrew-cask.yml new file mode 100644 index 000000000..73b98f875 --- /dev/null +++ b/.github/workflows/sync-homebrew-cask.yml @@ -0,0 +1,128 @@ +name: Sync Homebrew cask + +# The administrator publishes verified drafts with a personal/admin credential. +# Unlike the asset workflows' GITHUB_TOKEN, that emits release:published normally. +# workflow_dispatch is an explicit repair path; both paths reject drafts/Beta/old tags. +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: Published latest Stable Tauri tag + required: true + type: string + +concurrency: + group: homebrew-cask-sync + cancel-in-progress: false + +jobs: + update-homebrew-cask: + name: Update Homebrew cask (stable only) + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: write + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.release.prerelease == false && github.event.release.draft == false && + startsWith(github.event.release.tag_name, 'v') && endsWith(github.event.release.tag_name, '-tauri') && + !contains(github.event.release.tag_name, '-Beta.') && !endsWith(github.event.release.tag_name, '-beta-tauri')) + steps: + - name: Checkout default branch (cask 住在这里) + uses: actions/checkout@v4 + with: + ref: ${{ github.event.repository.default_branch }} + token: ${{ secrets.GITHUB_TOKEN }} + + - uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Check stable release publication + id: publication + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ inputs.tag || github.event.release.tag_name }} + run: | + set -euo pipefail + [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-tauri$ ]] || { echo "::error::Expected a stable Tauri tag"; exit 1; } + private=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft,isPrerelease --jq '(.isDraft or .isPrerelease)') + latest=$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq .tag_name) + if [ "$private" != false ] || [ "$latest" != "$TAG" ]; then + echo "ready=false" >> "$GITHUB_OUTPUT" + echo "::notice::Only the published latest Stable release may update Homebrew." + else + echo "ready=true" >> "$GITHUB_OUTPUT" + fi + + - name: Resolve version + DMG sha256 from the release + if: steps.publication.outputs.ready == 'true' + id: meta + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ inputs.tag || github.event.release.tag_name }} + run: | + set -euo pipefail + # Download the DMGs directly and compute sha256 ourselves; do not depend on the + # timing/availability of GitHub's asset digest fields. + mkdir -p "$RUNNER_TEMP/dmg" + gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \ + --dir "$RUNNER_TEMP/dmg" \ + --pattern '*aarch64.dmg' --pattern '*_x64.dmg' + + # Tags omit +build metadata; filenames and the cask must preserve it. + version="$(node openless-all/app/scripts/resolve-release-cask-version.mjs "$TAG" "$RUNNER_TEMP/dmg")" + arm_sha="$(sha256sum "$RUNNER_TEMP"/dmg/*aarch64.dmg | cut -d' ' -f1)" + intel_sha="$(sha256sum "$RUNNER_TEMP"/dmg/*_x64.dmg | cut -d' ' -f1)" + + { + echo "version=$version" + echo "arm_sha=$arm_sha" + echo "intel_sha=$intel_sha" + } >> "$GITHUB_OUTPUT" + echo "cask → version=$version arm=$arm_sha intel=$intel_sha" + + - name: Update Casks/openless.rb + if: steps.publication.outputs.ready == 'true' + env: + VERSION: ${{ steps.meta.outputs.version }} + ARM_SHA: ${{ steps.meta.outputs.arm_sha }} + INTEL_SHA: ${{ steps.meta.outputs.intel_sha }} + run: | + set -euo pipefail + cask="Casks/openless.rb" + # Warning: line 2 of the cask, `arch arm: "aarch64", intel: "x64"`, also contains + # `intel: "..."`. A bare `s/intel: "..."/` would rewrite the arch instruction with a + # hash too -> brew installs 404 for Intel users. So the intel sed anchors the sha256 + # line with `^[[:space:]]*intel:`, while the arm sed naturally matches only the sha256 + # line via the `sha256 arm: ` prefix. Capture group \1 preserves the original indent. + sed -i "s/version \"[^\"]*\"/version \"$VERSION\"/" "$cask" + sed -i "s/sha256 arm: \"[^\"]*\"/sha256 arm: \"$ARM_SHA\"/" "$cask" + sed -i "s/^\([[:space:]]*\)intel: \"[^\"]*\"/\1intel: \"$INTEL_SHA\"/" "$cask" + # Guard: the arch instruction must remain untouched and both new sha256 values must + # actually be written, otherwise fail the job. + grep -q 'arch arm: "aarch64", intel: "x64"' "$cask" \ + || { echo "::error::arch 指令被 sed 误伤"; exit 1; } + grep -q "\"$ARM_SHA\"" "$cask" && grep -q "\"$INTEL_SHA\"" "$cask" \ + || { echo "::error::sha256 未正确写入 cask"; exit 1; } + echo "----- updated $cask -----" + cat "$cask" + + - name: Commit & push cask bump + if: steps.publication.outputs.ready == 'true' + env: + VERSION: ${{ steps.meta.outputs.version }} + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add Casks/openless.rb + if git diff --cached --quiet; then + echo "cask 已是 $VERSION,无需提交" + exit 0 + fi + # [skip ci]: cask text changes do not need another cross-platform ci.yml run. + git commit -m "[cask] openless $VERSION (auto from release) [skip ci]" + git push diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 18862cfed..000000000 --- a/.gitmodules +++ /dev/null @@ -1,6 +0,0 @@ -[submodule "openless-all/app/src-tauri/vendor/qwen-asr"] - path = openless-all/app/src-tauri/vendor/qwen-asr - url = https://github.com/Open-Less/qwen-asr.git -[submodule "openless-all/app/src-tauri/vendor/qwen3-asr-rs"] - path = openless-all/app/src-tauri/vendor/qwen3-asr-rs - url = https://github.com/Open-Less/qwen3_asr_rs.git diff --git a/Casks/openless.rb b/Casks/openless.rb index 37a45b2a1..41eb92fa1 100644 --- a/Casks/openless.rb +++ b/Casks/openless.rb @@ -5,7 +5,7 @@ sha256 arm: "746dde4d4ffa8b4464b2e52b8a73f8e18092215d871e59c6bbf766bfed53fa75", intel: "36b9ba5f7926b5bb3ac7f6af3b1f5a75096b9b0f3aebe12581c91876522f2cf4" - url "https://github.com/Open-Less/openless/releases/download/v#{version}-tauri/OpenLess_#{version}_#{arch}.dmg" + url "https://github.com/Open-Less/openless/releases/download/v#{version.to_s.split("+", 2).first}-tauri/OpenLess_#{version}_#{arch}.dmg" name "OpenLess" desc "Menu-bar voice input layer" homepage "https://github.com/Open-Less/openless" @@ -16,7 +16,7 @@ end auto_updates true - depends_on macos: :monterey + depends_on macos: (version.major.to_i >= 2 ? :sonoma : :monterey) app "OpenLess.app" diff --git a/RELEASING.md b/RELEASING.md index a35ab60b7..cb5888589 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -86,10 +86,18 @@ The script takes a plain `X.Y.Z`; for a prerelease version such as 3. CI is green on the commit being tagged. 4. The applicable [desktop feature and device acceptance](docs/2.0-desktop-acceptance.md), signing, and distribution requirements are met; green builds alone do not establish product readiness. Linux acceptance below is required before including Linux assets. 5. Then, and only then, push the release tag. -6. Beta tag workflows upload Tauri and Android assets to a shared draft. - Wait for **both** workflows to succeed, verify the packages and Beta updater - manifests, then publish that draft as a prerelease. +6. Both Beta and Stable tag workflows upload Tauri and Android assets to a shared + draft. Wait for **both** workflows to succeed, verify the packages and channel + updater manifests, then publish that draft (Beta as a prerelease; Stable as latest). Do not rerun an asset workflow after publication without first returning the release to draft. +7. Publishing Stable with the administrator's CLI/UI credential automatically + triggers `sync-homebrew-cask.yml`. It accepts only the published latest Stable + tag, preserves application build metadata in DMG filenames, verifies matching + ARM/Intel versions, computes their checksums and commits the cask update. + Drafts, Beta and older releases never update Homebrew. If repair is needed, run + `gh workflow run sync-homebrew-cask.yml -f tag=v-tauri`; no asset rebuild + or manual cask edit is needed. Do not publish via an asset workflow's + `GITHUB_TOKEN`, which does not emit the downstream release event. Before independently attaching Linux assets, additionally require all of the following: diff --git a/docs/architecture.md b/docs/architecture.md index 04306b686..2569dab71 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # OpenLess 2.0 架构 -状态:canonical,当前实现说明;更新:2026-09-30。平台范围见 [2.0 需求](2.0-requirements.md),文件定位见 [目录结构](structure.md)。 +状态:canonical,当前实现说明;更新:2026-10-15。平台范围见 [2.0 需求](2.0-requirements.md),文件定位见 [目录结构](structure.md)。 ## 1. 分层与工作区 @@ -41,7 +41,7 @@ flowchart TB 启动时,`src/App.tsx` 经 `src/lib/ipc/shared.ts` 请求 `get_startup_snapshot`,校验合同版本和 backend 运行状态后进入业务界面。Core 事件定义在 `events.rs`,Tauri 的转译入口为 `src-tauri/src/tauri_events.rs`,Linux 直接订阅类型化事件。 -听写主链由 `dictation_engine.rs` 管理:触发会话 → 录音/ASR → 清理与润色 → Host 插入 → 历史与事件。Tauri 在 `coordinator/dictation_core.rs` 接入该链路;本地 ASR 的模型管理归 Core,原生执行实现分别位于 Host。取消、失败和旧会话事件处理也属于该业务链,而不是页面各自实现。 +听写主链由 `dictation_engine.rs` 管理:触发会话 → 录音/ASR → 清理与润色 → Host 插入 → 历史与事件。Host 音频处理 worker 在原生采样停止后通过 FIFO Shutdown 排空并退出,不能依赖 CPAL 回调中所有 sender 都析构才结束,否则指定麦克风时可能堵住停止与后续多模态请求。Tauri 在 `coordinator/dictation_core.rs` 接入该链路;本地 ASR 的模型管理归 Core,原生执行实现分别位于 Host。取消、失败和旧会话事件处理也属于该业务链,而不是页面各自实现。 Android 悬浮窗在录音中转入追问时,Host 先捕获原选区,Core 的 `stop_dictation_for_qa` 按原会话 ID 完成转写并释放听写资源,跳过听写润色与文字插入,再由 `QaApi::submit_captured_text` 接收已录问题和选区。重复手势由 Host 的异步锁合并;QA 不重新抓取已经变化的前台选区。 @@ -75,10 +75,20 @@ Tauri 在 `src-tauri/src/coordinator.rs` 构造 Core,`core_adapters.rs` 组装 主窗口默认逻辑尺寸为 1300×835,允许用户调整;macOS 原生窗口按钮左侧和顶部均留出 16px,前端保留 44px 拖动区。桌面侧栏宽 226px,主内容从版本行下方开始,设置面板单独限制高度并在内部滚动。 +macOS 全局热键在后台 CGEventTap 线程读取按键与修饰键边沿。只有配置的 Fn 正处于按住状态时,监听器才解析系统功能键负载;`NSEvent eventWithCGEvent:` 的转换同步交给主线程,完成分类后再处理 Fn 松键。此转换会进入 HIToolbox 的输入源逻辑,不能在热键线程执行;普通 Caps Lock 中英文切换不需要该转换。Esc 与组合键取消仍由原有独立通道处理。 + +Linux 主窗口和 QA 面板由常驻宿主拉起的独立进程承载。QA 空态没有 Core 会话,宿主与面板统一使用空态标识校验操作;文字发送通过 `submit_text_in_context` 在 Core 中检查当前上下文并创建新轮次。早期调用失败回传到对应面板,迟到的失败不能覆盖新轮次。弹窗消息使用宿主递增的序号,不依赖 Core 是否发布了新事件;显式收起 QA 结束窗口进程,隐藏时的 QA 快照只更新宿主状态,下一次打开再创建窗口。没有托盘时,正常关闭主窗口仍会退出应用,托盘注册失败的原因写入启动日志。 + +Linux 的 Siri 波形包络在分数次幂运算前限制为非负值,避免浮点端点误差生成 NaN。设置和胶囊绘制回归须经过 egui tessellation 并检查生成顶点的有限性,文字快照不能单独证明绘制成功。 + +QA 与润色预览共用浮窗时,宿主记录实际发送的上下文,只允许当前上下文的隐藏请求结束窗口;切回 QA 会创建新的窗口协议状态,后台 QA 更新不能覆盖正在显示的润色预览。异步 QA 调用结果还绑定窗口代次,空态关闭后重新打开仍没有 Core 会话 ID,旧代次的结果也不能显示到新面板。 + Siri、Classic、Typeless 三种胶囊共用 Core 的 `CapsuleStyle`,窗口尺寸与点击范围在保存偏好时同步。胶囊按显示器工作区底部定位,避开未自动隐藏的 Dock/任务栏;可见期间重新检查工作区。带正文的浮窗使用不透明底色,聊天面板另叠加细噪点纹理,圆角外部仍保留透明区域。 思考动画覆盖转写、润色和原生文字写入,输入完成后才收尾。macOS 流式键盘输入由会话内串行 worker 维护原控件和累计 UTF-16 终点;每批发送后不再等待 AX 长确认,finish/cancel 在已接收写入之后等待最终屏障,再恢复输入源。AX 仅读选区范围,不读正文;采用 250 ms 无进展预算和 10 秒总预算。不可读、提交型 Return 或预算耗尽时明确降级到按键已发送语义,不重新粘贴已发送文字。目标应用的实际输入表现仍需设备验收。 +Less Computer 与 Claude 控制台收拢到「实验与扩展 → 正在测试中的功能(Beta)」。本设备的 `testingFeaturesEnabled` 默认关闭,旧版已开启 Less Computer 的配置也不会自动打开总开关。开启后按真实平台能力显示入口;React 的 Less Computer 仅 Windows/macOS、Claude 控制台仅 macOS,Linux egui 保留自己的 Less Computer 接入入口,Android 当前显示无可用测试功能。关闭总开关会隐藏入口、快捷键和独立窗口,Core 停止当前捕获与 Agent/控制台任务并拒绝新的运行请求;已有 Agent、模型、权限与路径配置保留。总开关不进入云同步,恢复保留接收设备的本机选择。 + Less Computer 面板将听写与直接语音提交分开:麦克风把转写填入草稿供编辑,语音模式和快捷键可直接提交给 Agent。Core 的 `voice_state` 事件携带会话 ID、模式、实时转写及收尾结果;波形使用实际音量采样。停止和取消均绑定指定会话,延迟请求不能结束下一段录音;开麦与文字发送互斥。工具过程默认折叠,运行状态只在真实活动步骤显示动效,右侧工作台汇总当前轮次;历史和多会话仍标为暂不可用。登录弹窗打开时,听写结果只更新草稿,不抢走弹窗或授权浏览器的焦点。 选区直接润色在捕获文字和原输入目标后显示处理中提示,重复快捷键的 Busy 返回不覆盖该提示。已有语音选区入口在松开快捷键后继续显示思考动画,直到处理/替换完成,或交给确认和预览面板。录音提示音的 Web Audio context 在恢复超时或音频时钟冻结时丢弃并最多重试一次,重试沿用原请求的取消和迟到边界。 @@ -87,7 +97,9 @@ Selection Voice 在有效输入目标中允许空选区:Core 将无草稿编 AI 服务设置在顶部选择传统或多模态模式,各配置入口等宽排布并完整显示,不依赖横向滑动。传统模式禁用多模态模型入口;多模态模式禁用语言模型、语音识别和本地模型入口。禁用入口名称置灰,名称下方附醒目的黄/琥珀色“当前模式未使用”提示,鼠标和键盘均不可进入;连接与扩展保持可用。多模态表单将供应商/模型及自定义高级字段并排,拉取模型与验证集中在页尾;桌面常用窗口内单页显示完整表单,窄屏或放大字体时保留内容的可访问性。切换模式时若当前页面已禁用,则转到当前模式可用的配置页;本地模型的内部跳转入口受相同限制。切换模式保留两组渠道和凭据。Omni 连接测试只验证文本请求,实际音频可用性需录音验证;DashScope 音频使用 `data:;base64,` 和独立 `format` 字段。API Route 作为可手动添加的 OpenAI 兼容润色渠道,不改变默认提供方。 -界面动效由 `src/lib/motion.ts` 统一管理:设置面板以 360ms 淡入、上移并缩放到位,背景以 220ms 淡入;先绘制入场起点,再启动动画时钟。其他弹窗入场和配置内容切换 240ms、弹窗退出 180ms,主页面进入 160ms、退出 90ms;缓动读取 `tokens.css` 中的入场/退出曲线,设置入场使用 soft 曲线。动画使用透明度、位移及缩放,不动画布局尺寸。快速关闭、重开或切回原页从当前绘制状态续播,旧切页任务不能覆盖新选择。`useExitMount` 共用退出时长;系统开启减少动态效果时跳过动画等待。服务设置的模式高亮和分类指示条在选中项间移动,供应商配置切换淡入;加载图标与保存中状态保持真实反馈,临时合成层在完成或取消后释放。 +界面动效由 `src/lib/motion.ts` 统一管理:全窗设置对整个不透明 workspace 做 220ms 淡入、180ms 淡出,不再缩放或平移文字;先绘制入场起点,再启动动画时钟。其他弹窗入场 240ms、退出 180ms。主页面、设置分类及服务配置先保留旧内容做 120ms 透明度退出,再以 160ms 淡入最新目标;快速切回当前页会取消旧任务,不遗留透明或 inert 页面。缓动读取 `tokens.css` 中的统一曲线。`useExitMount`、`ExitPresence`/`PresenceModal` 维持退出生命周期;系统减少动态效果时跳过等待。动态状态/错误使用 Base UI Collapsible 的局部高度过渡平滑推动后续内容,其他过渡主要使用透明度与 transform。服务高亮立即跟随请求目标,不等待内容退出,临时合成层在完成或取消后释放。Select、Combobox、Popover、Tooltip、Switch 和 Collapsible 复用 Base UI;可锚定弹层优先向右并进行碰撞避让。实验模型状态由 Settings 与全局下载指示器共享,取消与模型开关使用同一路径。 + +主窗口无显式偏好时统一默认 110% 界面大小,首帧 bootstrap 与 React 使用同一默认,保留已有主动选择。快捷键直接点击按键进入右侧录制弹层,不另设重复录制菜单。macOS 主窗口和 Less Computer 使用原生窗口红绿灯;设置顶部拖动条独立于被遮盖的应用内容并按 zoom 换算原生高度。 界面启动等待所选语言资源就绪;语言选择持久化到 `ol.locale`,其他 WebView 通过存储事件同步,日期、数字和默认风格展示随语言变化。用户修改的风格名称、说明和内容保持原文。旧版两种强制排版字段只保留数据兼容,界面清除其布局效果,窄屏改由响应式布局处理。 diff --git a/docs/beta5-regression-repair.md b/docs/beta5-regression-repair.md new file mode 100644 index 000000000..250aacd27 --- /dev/null +++ b/docs/beta5-regression-repair.md @@ -0,0 +1,93 @@ +# beta.5 用户反馈修复与审查结果 + +范围:`0454dc56..0947c05a`(PR #1197),以及本次工作树修复。 + +**当前交接状态(2026-10-15):** 已构建并安装 `2.0.0-beta-a.beta.6+build.20261015-be` 到 `/Applications/OpenLess.app`,包含最后的实验下载状态 CSS 隔离修复。已安装主程序与最终构建产物 SHA-256 一致:`7f0918b3012d273fb1ec61b3a041089c85acf9fcb04f73e5ce933bbf6076bba4`。源码未提交、未推送。按用户最新要求,后续测试由用户负责;Agent 停止继续测试,不回滚、不撤回当前安装,不继续操作 App。其余审查 Agent 已关闭,本轮编排记录在备份后清空,自动任务列表为空。 + +## 结论 + +首录音停止后一直 Processing 的高可信根因不是转圈动画:**指定名称的麦克风 + CPAL 0.15.3 保留回调 sender + 音频处理线程只等队列 EOF**,使停止录音永久卡在 `join`,后续多模态请求尚未发出。该等待由 **`44972015` / PR #1194(麦克风降噪)** 引入,已经存在于此次整合基线之前。 + +已改为显式 FIFO `Shutdown`:停止接受原生采样、暂停/释放外层输入流,再排空已接收音频并结束处理线程,不再等待第三方回调析构。没有通过超时隐藏转圈、丢弃 join 或提前返回来掩盖阻塞。 + +本机日志 `~/Library/Logs/OpenLess/openless.log` 中,2026-10-15 20:26 与 20:38(+0800)都能看到输入流启动/释放,但没有后续 Omni POST;非秘密偏好为 DashScope 多模态、指定内建麦克风、降噪开启、低延迟关闭,与上述分支吻合。麦克风权限为 granted。旧 cb# 日志已随新 worker 停用,不能把缺少 cb# 当成没有采样的证据。 + +CPAL 自身的原生引用环没有被篡改或通过升级依赖绕过;本轮修复的是应用等待其析构造成的死锁。真实设备资源、拔插和完整输入仍需后述实机验收。 + +## ToDo 状态 + +- [x] P0:检查录音/停止/转写/多模态/输出调用链、本地日志与偏好,追溯引入提交;修复音频处理线程退出协议,并验证保留原生 sender 时已接收 PCM 不丢失、停止正常完成。 +- [x] 独立审查全部整合差异:语音/快捷键、核心、多模态、前端、egui 与构建边界;原始问题和本轮修复状态分开记录。 +- [x] 所有平台无显式偏好时默认界面大小 100%;首帧 bootstrap 同步应用。用户主动保存的 90%/100%/110% 不被覆盖。 +- [x] 100% 返回录音输入页:复用缓存平台能力、滚动在 layout effect 归零、修正条件 return 后调用 hooks、避免无变化的 ResizeObserver setState。双浏览器反复往返采样确认控件位置稳定。 +- [x] 共享主页面/设置内容保留短退出淡化,快速 A→B→A 取消旧任务;页面只淡化,不缩放/平移文字。设置对整个不透明 workspace 淡出,不再只淡出被实色外层遮挡的子节点。 +- [x] 桌面设置仅保留“返回应用”;顶部专用拖动条高于全窗设置,并按界面缩放换算原生尺寸。主设置原先就是原生红绿灯;真正的仿制灯位于 Less Computer,已移除并接入 NSWindow 装饰和原有 dismiss 清理流程。 +- [x] 实验模型采用 Settings/全局指示器共用的状态与取消路径;取消调用对应实验开关,而不是已退役的 Qwen 下载接口。首字节前为不定进度,少量字节不再伪装成固定 0%;错误可见,关闭设置不丢失任务状态。 +- [x] 实验模型三个条目预留位置,状态/进度/操作放右侧,开关位置稳定。浏览器回归确认开始下载不推动后续条目;错误使用平滑展开。 +- [x] 快捷键只有点击当前按键一个录制入口,删除重复展开菜单。录制提示使用右侧 Base UI Popover,不顶开下面内容;重置、停用和独立的 Mac 听写键动作保留。键盘捕获不依赖 Portal 焦点到达的时序。 +- [x] 普通/可搜索下拉统一 Base UI Select/Combobox;开关、展开、提示与录制弹层复用 Base UI Switch/Collapsible/Tooltip/Popover。长列表跨越 120ms 动态内容刷新后保持用户滚动;右优先并保留碰撞避让、键盘操作和 zoom 补偿。 +- [x] 云同步状态通过 Collapsible 平滑推动后续元素;实验完成提示、云同步设置/自动提示、市场登录使用共享退出保留。共享 Modal 处理顶层 Escape,保留已被子层消费的按键和 busy 保护。 +- [x] 110% 下多模态表单紧凑布局、禁用状态提示、快速模式往返后的高亮跟随及窄屏可访问性回归。 +- [ ] 本轮构建的原生 App:真实 Fn 首次/连续/取消录音,设置麦克风预览→听写交接,实际 DashScope/其他服务请求与目标应用上屏;没有用浏览器 mock 冒充通过。 +- [ ] 原生标题栏拖动/三色灯、真实下载零字节取消/断网/重启、Windows Foundry SDK 与设备拔插验收。 +- [ ] Linux 新视觉基线:本机仅能生成/检查 macOS 的八张图片;Linux 对应旧图尚未刷新,不能宣称 Linux UI CI 已绿。 + +## 根因与附带修复 + +| 问题 | 引入记录 | 修复与证据 | +| --- | --- | --- | +| 停止音频线程永久等待 EOF;还可堵塞麦克风预览操作锁,使下一段录音无电平 | `44972015` / #1194,基线前 | 显式 Shutdown;直接编译生产 recorder 的 22 项无硬件回归通过,含保留 sender、排空 8 帧并有界结束 | +| 普通页面删掉 exit;全窗设置不透明外层遮住淡出、覆盖拖动区;快捷键重复录制入口 | `8c32db9e` | 恢复保留/取消生命周期,整体淡化,专用拖动条及单一录制入口 | +| 长列表更新时重置高亮、抢回选中项;新实验轮询扩大重绘范围 | 原手写菜单为 `66915405`;放大因素 `16a73b9d` | Base UI 统一组件、稳定条目 identity、共享快照去重;双浏览器长列表动态更新验证 | +| Qwen/Stepfun 启动失败后持锁再 cancel,重入同一非重入锁 | `9ed34dab` / #775;`17fe67c5` / #835,均为历史缺陷 | 先释放状态锁再清理;两个错误分支回归通过。不是本机当前多模态故障的直接候选 | +| TextStream stdout EOF 后取走 Child 并 wait,取消/超时看不到仍运行的进程 | `16a73b9d` | 短锁 try_wait 保留取消句柄;生产 Process 模块的 EOF-before-exit 子进程回归通过 | +| 编辑非活动同 provider 渠道误释放活动 runtime;迟到状态刷新可能清理新实例 | `16a73b9d` | 按活动 owner 清理、只串行 owner 交接、清理完成后才做异步状态刷新;Core 合同 34 项通过 | +| Foundry 缺失/截断缓存无法冷删除,反而忘记 receipt | `16a73b9d` | 删除完整性与加载完整性分离,验证受管路径并保留失败凭据;6 项临时文件系统回归通过 | + +## 独立审查 + +Orca Run:`run_102ebb62f9c8`。审查与实现文件所有权分离:主 Agent 负责语音与前端;一个后续 Pi 任务仅修改 `api.rs`、`local_asr_contract.rs`、`native_model_receipt.rs`,其余审查只读。 + +| Agent / 结果 | 范围 | 固定报告 | +| --- | --- | --- | +| Codex,完成 | 首录音链路、日志、偏好白名单与提交追溯 | [语音审查](reviews/2026-10-15/voice.md) | +| Pi,完成 | Core、多模态、本地运行时、egui/构建差异 | [核心审查](reviews/2026-10-15/core.md) | +| Pi,完成后续独占修复 | 渠道 owner 与 Foundry 缓存删除 | [修复与回归](reviews/2026-10-15/core-fixes.md) | +| Pi,完成;替代失败的 Cursor/Codex UI 审查 | 固定 HEAD 的双浏览器动态对照与前端/测试审查 | [UI 审查](reviews/2026-10-15/ui.md) | + +Cursor 因额度耗尽退出;第一次替代 Codex 因 workspace routing discovery 连续失败未完成审查,没有把它们计为成功。最终三个审查范围均有独立报告。报告中的“当前 worktree”是各自交付时的快照;例如 UI 报告写作之后又将快捷键录制提示移入 Popover,并补了真实键盘输入回归,当前源码/本清单优先。 + +## 本轮验证 + +以下表格主要记录安装前代码修复阶段的检查,命令在 `openless-all/app/` 执行;没有清除用户配置或读出 Keychain 秘密。随后已完成 beta.6 的真实 Release 构建与安装;后续设备测试现交由用户负责。 + +| 检查 | 结果 | +| --- | --- | +| `npm run build` | TypeScript/Vite 通过;原有大 chunk 提示仍在,不把提高告警阈值当作优化 | +| `node scripts/frontend-test-runner.mjs` | 121 个前端/契约测试文件通过 | +| `CARGO_TARGET_DIR="$PWD/src-tauri/backend-tests/target" cargo test --offline --locked -p openless-core --lib` | 1204 通过,0 失败,1 个显式外部 Codex 用例忽略 | +| Core `local_asr_contract` | 34 通过,0 失败 | +| `CARGO_TARGET_DIR="$PWD/src-tauri/backend-tests/target" python3 scripts/test-recorder-module.py --offline` | 项目既有隔离脚本直接编译生产 recorder 与音频处理器:37 通过,0 失败,其中 recorder 22 项含本次退出回归 | +| `cargo test --offline --locked --manifest-path src-tauri/backend-tests/Cargo.toml` | 46 通过,0 失败;包括生产 Process、receipt 和已有 Host 合同 | +| Chromium/WebKit UI | 全量 96 项中首次 90 通过,6 项暴露真实布局/输入或旧断言问题;修复后 6 项及 4 个视觉用例无更新参数重跑全通过;另补共享 Modal/Guide Escape 4 项通过。共 100 个独立 UI 用例覆盖;没有把首轮失败记成一次全绿执行 | +| 八张 macOS 深/浅色截图 | 已更新并检查;不带 `--update-snapshots` 的独立像素比较通过,未提高容差;Linux 旧图未冒充已更新 | +| 原生 Rust 类型检查 | `DOCS_RS=1 CARGO_BUILD_JOBS=2 cargo check --offline --locked --manifest-path src-tauri/Cargo.toml` 通过;该模式跳过 Sherpa 本机库下载/链接,不是最终生产链接验证 | +| 格式/补丁 | 变更前端文件 Prettier、目标 Rust 文件 rustfmt、`git diff --check` 通过 | + +第一次完整 native `cargo test --lib` 曾受 Sherpa 1.13.2 预编译库下载的 TLS EOF 阻塞;直接 curl 同一公共下载地址当时也超时。后续已通过断点续传取得完整官方依赖,真实 Release `.app` / DMG 构建、签名与包校验通过,并安装到 `/Applications/OpenLess.app`;该阻塞现已解除。没有把构建成功当作完整 native 单元测试已执行。因此本轮复用项目既有 `scripts/test-recorder-module.py` 直接编译生产 recorder/音频处理器,并将生产 Process 模块挂入独立后端测试 crate,完成实际队列与子进程回归。隔离脚本仅替换 Core 的接口桥,不打开设备;没有用空壳实现或 docs.rs 模式冒充 native 测试成功。生产依赖版本不变,后端 lockfile 仅增加测试所需的既有 libc 直接依赖,没有顺带升级/降级其他包。 + +主要本轮日志:`/tmp/openless-regression-core.log`、`openless-regression-recorder-final.log`、`openless-regression-backend-final.log`、`openless-regression-frontend-final.log`、`openless-regression-full-ui.log`、`openless-regression-final-validation.log`、`openless-regression-presence-final.log`、`openless-regression-delivery-ui.log`、`openless-regression-downloads-final.log`、`openless-regression-native-check.log`。临时目录可能被系统清理;本文件和报告保留结论及复现入口。 + +## 后续发版接手(2026-10-16) + +用户随后确认本机修复版无明显问题,并授权全面 Review、PR/CI/合并和 beta6 → 2.0.0 正式发布;前述“不提交/停止测试”为上一阶段安排,已由本次授权更新。当前本机 App 不替换;同构建哈希、运行状态和最新真实录音完成日志已核验。发版前 Review、新发现的发布链路修复及本机门禁详见 [release-readiness.md](reviews/2026-10-16/release-readiness.md)。 + +## 实机验收入口 + +beta.6 已按项目构建流程生成并安装;[原 beta.5 安装包](desktop-beta5-integration.md) 不包含这些修复。按用户最新安排,以下剩余设备验收由用户接手,Agent 不再自动执行: + +1. 指定内建麦克风,保持用户的 DashScope 多模态与降噪设置,冷启动后 Fn 开始→结束;确认日志出现新增 `audio processor stopped (accepted frames drained)`,随后有 Omni 请求及最终上屏。 +2. 连续录音/取消,再在设置中打开、滚动、关闭麦克风列表后录音;不应残留预览锁、橙点或 Processing。 +3. 100%/110% 来回切页、拖动标题栏、使用原生窗口按钮;实验模型从零字节下载取消、关闭开关、离开设置和重启后复查。 + +这些步骤保留为用户接手清单,不是全部通过声明。此前已观察到真实录音完成处理线程退出、多模态请求和 `Inserted` 终态;其余界面与完整设备行为由用户继续确认。 diff --git a/docs/desktop-beta5-integration.md b/docs/desktop-beta5-integration.md new file mode 100644 index 000000000..120a545bb --- /dev/null +++ b/docs/desktop-beta5-integration.md @@ -0,0 +1,121 @@ +# 桌面 beta.5 整合与本地验收 + +> 本文记录最初整合与当时安装包。后续用户反馈发现的首录音阻塞、交互问题及本轮工作树修复见 [回归修复记录](beta5-regression-repair.md);下列旧安装包与哈希不包含这些后续修复。 + +- 版本:`2.0.0-beta-a.beta.5+build.20261015-bd`(按请求保留完整大小写与后缀)。 +- 合并分支:`integration/desktop-beta-a.beta.5-20261015-bd`;PR 目标:`beta`。 +- 基线:`origin/beta` 的 `0454dc56`。 +- 初次产品代码检查点:`5637abee8c92d1dbff6afecfdd0877771bcb8223`;后续 CI 修复限定于桌面模块编译边界、测试布局/时序及跨平台截图,没有修改三模型推理实现。 +- 最终产品代码与安装包检查点:`edff8913c48afa79609cbb29eacc47a00620e89d`;本节下方校验值来自该检查点重新构建的 Release 包。 +- 验收环境:2026-10-15,Apple Silicon / macOS 27.0.1。 + +## 合并来源与边界 + +| 来源 | 整合内容 | +| --- | --- | +| 当前三模型工作区 | macOS 旧引擎下架、定向清理、延迟加载、TextStream / SenseVoice / Embedding Gemma 独立启停与真实推理 | +| `appergb/合并两个分支` 的未提交桌面快照 | 首帧与启动视频移除、按需加载、扁平设置、字体与提示组件、快捷键命令列表、渠道弹簧重排、卡片删除与 WebGL 生命周期 | +| `origin/fix/macos-input-switch-crash`,PR #1142 | 输入源相关 NSEvent 转换转交主线程,避免切换输入法时在事件监听线程调用 AppKit | +| `origin/fix/linux-egui-ui`,PR #1136 | Linux 设置、QA 面板、窗口代次与迟到响应保护 | +| `origin/feature/testing-features-gate`,PR #1141 | Less Computer / Claude Console 的设备本地 Beta opt-in,撤回后的入口、热键与窗口可见性限制 | + +桌面 UI 未提交快照通过独立临时 index 生成提交,再三方合并,不直接覆盖已完成的三模型实现。旧的两模型实验实现没有被重新引入。原始 UI 工作区和原始实验模型工作区的 tracked diff SHA-256 在整合前后相同。 + +没有合入第三方尚未审核的 PR、未提交 iOS 目录或 Android 专属功能。相对当前 `origin/beta` 无 Android / iOS 源码改动;Android workflow 仅移除随旧 macOS 模型依赖退役而失效的处理步骤。根目录原有的未跟踪 `package.json`、`pnpm-lock.yaml` 未提交。 + +### 冲突处理保留的语义 + +- Mac 的旧模型入口仍禁用,提示为「该功能已下架,请去实验性与扩展里面」;新三模型入口仍在“实验与扩展”首项。 +- 三模型各自 opt-in;通用 Beta 开关只控制其原本负责的 Less Computer / Claude Console,不成为下载或加载模型的隐式授权。 +- 已访问的高级页面保留草稿;未访问页面不挂载;撤回 Beta 授权后相关测试页面卸载。 +- 新 UI 的紧凑模型行保留三模型、独立开关、内存/规格提示、实时 SSE 地址、进度、错误、重试、删除及渠道选择引导。说明通过支持鼠标、键盘、触屏的共享提示展示。 +- 退休入口与整行提示使用非按钮外层触发器,避免 Base UI 提示按钮包住已有按钮造成无效嵌套。 +- Windows Less Computer 的创建仍离开同步 IPC 线程;显示与 opt-in 复查在 UI 线程串行,结果回传调用方,不把“任务已排队”当作“窗口已成功打开”。 + +## 本地测试结果 + +| 检查 | 最终结果 | +| --- | --- | +| Core 完整测试 | 1,399 通过(1,202 单元 + 197 集成),0 失败,1 个需要固定 Codex CLI 的无关测试忽略 | +| Tauri 原生默认测试 | 533 通过,0 失败,4 个按需模型/安装用例默认忽略;真实模型另行验收 | +| 独立后端测试 | 40 通过 | +| `npm test` | 生产前端构建及 121 个前端/契约测试文件通过 | +| Chromium / WebKit 全量 UI | 84 通过,无重试;包含浅/深色截图、Beta opt-in 撤回、三模型提示、重排、取消、删除与撤销、窄屏和缩放 | +| Python worker 测试 | 7 通过 | +| 指定版本 debug `.app` 真实模型验收 | 通过,退出码 0,不带 `--download` | +| Release `.app` / DMG | 构建及签名、资源、版本、DMG 校验通过 | +| 变更组件 Prettier / `git diff --check` | 通过 | + +Ubuntu CI 执行完整交互、几何、生命周期和像素比较,不跳过视觉用例。针对系统字体栅格化差异,从首轮 CI 的实际产物逐张检查并增加了八张 Linux / 浏览器独立基线,不复用 macOS 像素,也不提高容差。macOS / Windows 模型入口测试显式声明产品 OS,不依赖 CI 物理平台。 + +合并后的初次 UI 回归暴露了旧版断言与新边界的冲突,已修复并重跑完整套件:退休模型入口不再作为可点击选项;导航按钮的统计包含合法外层提示;并行模型下载的测试等待三个完成项再确认;拖动用例先滚动到目标并验证真实位移再松手。仅更新了经截图检查确认的四张快捷键基线,变化为 Beta 未授权时隐藏 Less Computer 后的计数/行位移;没有提高截图容差,字体截图保持原样,最后一轮不带更新快照参数通过。 + +### 真实模型与生命周期 + +使用既有隔离模型缓存与 16 kHz 单声道固定英文 WAV,没有补下载、麦克风调用或当前安装应用凭据库访问。三模型均真实输出了包含 `hello`、`test`、`system` 的识别文本,并验证: + +1. 冷构造、设置/状态查询不初始化推理。 +2. 安装/启用后无已加载模型;已启用且已选中的重启恢复也不加载推理。 +3. 实际会话加载并识别;TextStream SSE 最终文本与原生返回一致,终态无未提交草稿。 +4. 公共渠道停用释放推理;取消会话不提交草稿。 +5. 关闭实验保留模型文件,停用后重启不能获得推理租约。 +6. 临时旧模型升级清理定向且幂等,实验包与无关文件保留。 +7. 结束后无残留 TextStream worker 或 TCP 7890 监听。 + +主要日志与机器可读结果:`/tmp/openless-desktop-beta5-20261015-bd/` 下的 `core.log`、`final-native-tests.log`、`final-frontend.log`、`backend.log`、`final-e2e.log`、`native-acceptance.log`、`final-release-package.log`、`final-artifacts.log`、`verified.json`。该目录可能被系统清理,本文保留主要结果。 + +## 首轮 CI 发现与修复 + +首轮 macOS checks 与 Rust 1.88 MSRV 已通过。Windows 的版本契约在 CRLF checkout 上未匹配 Cargo.lock,已统一文本换行;Android 发现 Foundry 桌面命令模块被错误编译进移动端,已像其余本地 ASR 模块一样以 `not(mobile)` 限定声明和导出,没有添加移动端空壳。Linux Core / egui 测试已通过,但运行时边界脚本未识别通过 `#[path]` 命名的独立测试模块;现改为标准 `experimental_asr/tests.rs` 测试布局,保留 TaskSpawner 门禁原有强度。 + +UI 的两个 Linux WebKit 时序问题改为测量确定的动画时间轴及浏览器真实滚动停稳,而非假设 CI 必须在固定毫秒内提供足够帧数。文档提交后的复核又发现旧探针可能漏掉短动画,且会暂停、回拨应用自己的动画;现于动画创建时捕获,先验证其自然结束和图层释放,再用独立副本测量实际 DOM 中间状态,不再干预业务动画的时钟/finished 生命周期。该探针在本机双浏览器连续 20 次通过,业务代码与安装包未改。八张 Linux 基线来自 [首轮 CI](https://github.com/Open-Less/openless/actions/runs/38675183147) 的实际渲染,已检查字体、表单、计数、布局和深浅主题。 + +最终产品代码 `edff8913` 的 [完整 CI](https://github.com/Open-Less/openless/actions/runs/38677199261) 已全部通过:Windows checks、macOS checks、macOS Rust 1.88 MSRV、Linux Core/egui 与 deb/rpm 检查、Android cargo check、Chromium/WebKit UI,以及改动范围检查。它们是 GitHub runner 的编译/自动测试证据,不等同于真实设备、安装升级或公开公证分发验收。 + +## 本地安装包 + +相对仓库根目录: + +- 应用:`openless-all/app/src-tauri/target/release/bundle/macos/OpenLess.app` +- 安装镜像:`openless-all/app/src-tauri/target/release/bundle/dmg/OpenLess_2.0.0-beta-a.beta.5+build.20261015-bd_aarch64.dmg` + +构建命令在 `openless-all/app` 执行,显式使用 `INSTALL=0`,未覆盖 `/Applications/OpenLess.app` 或重置现有权限: + +```sh +APPLE_SIGNING_IDENTITY=- INSTALL=0 CARGO_BUILD_JOBS=2 ./scripts/build-mac.sh +``` + +检查结果: + +- Release Info.plist 的 `CFBundleShortVersionString`、`CFBundleVersion` 均精确等于指定版本;npm、Tauri、Rust 与两个 lockfile 由版本契约共同核对。 +- Info.plist / Mach-O 最低系统版本均为 macOS 14.0;Gemma 自身仍要求 macOS 15+。 +- `codesign --verify --deep --strict` 通过,含麦克风 entitlement / 说明,不包含旧系统语音识别权限声明。 +- 主程序不链接旧 MLX、Whisper、Speech.framework;bundle 无旧 Metal 资源或模型 `.runtime`。 +- 旧 worker 参数仅返回下架信息。 +- DMG 的 CRC、Retina 背景、图标、窗口布局和 Applications 链接验证通过。 + +Release 主程序:74,479,344 字节,SHA-256: + +```text +d73474fbc663d072b9810d58a5e7a4f1d760a3465ce9d048f696ad28564f1a5b +``` + +DMG:28,887,027 字节,SHA-256: + +```text +765bf5bb13b3e5e2e408d4f564b660f14764474e8d73cbf7abcc13b70adbb762 +``` + +debug 验收主程序 SHA-256: + +```text +2014860f896169c6a402e52b924b41a3d9f49e9b2f571617ba6ebeb469674930 +``` + +## 未宣称通过的部分 + +- 本地包为 **ad-hoc 签名,未做 Apple 公证**,不等同于已获 Gatekeeper 认可的公开发行包。 +- 真实推理来自指定版本 debug `.app` 的隔离验收入口;Release 完成构建和产物验证,但没有用真实用户配置启动正式 UI 或进行真实麦克风/其他应用输入验收。 +- 本机没有 Windows 环境;Windows 编译、后端自动测试和 MSRV 已由 CI 通过,仍未进行真实 Windows 设备上的麦克风、SDK 推理或目标应用输入验收。 +- Linux host 依赖仅在 Linux target 中声明,直接在 macOS 上运行其测试会因 CPAL / Wayland 等缺失而失败;Docker daemon 未运行。Linux 原生构建、Core/egui 自动测试和 deb/rpm 检查已由 CI 通过,不记为本机或 Linux 桌面实机验收。 +- 已有 FFI 声明、弃用和 window-vibrancy 链接警告仍存在,不把消除这些无关警告当成本次发布的完成条件。 diff --git a/docs/encrypted-cloud-sync.md b/docs/encrypted-cloud-sync.md index 1caa901eb..05aa90a91 100644 --- a/docs/encrypted-cloud-sync.md +++ b/docs/encrypted-cloud-sync.md @@ -33,6 +33,8 @@ 短暂的本机写入竞争最多触发 4 次有界延后重试;网络失败、结果未知、版本冲突不进入该重试。后续手动任务、锁定或停用会淘汰旧重试链。同步连接遵循当前系统代理偏好,策略变化后重建连接,回环测试始终直连。 +测试功能授权 `testingFeaturesEnabled` 不进入远端文档或设备档案;正常恢复和失败回滚均保留接收设备的本机值,不能通过同步打开 Less Computer 或 Claude 控制台。 + 系统自启动 `launchAtLogin` 不进入远端文档或设备档案;保留接收设备本机值,实际 OS 状态仍由现有 UI 自启动插件管理,恢复不宣称替用户重建自启动授权。 恢复必须绑定 `RestoreRuntimeEffects` 的真实 Host 适配器。所有仓库写入后,在同一加密 journal、独占写入门和运行态屏障内等待绝对目标设置生效;副作用失败按 before 镜像回滚。只保存在本地 journal 的 Agent 开关、provider 镜像和录音标记恢复后,再应用最终旧设置;若回滚副作用也失败,保留 recovery_required,不能提前放行录音或 Agent。未绑定适配器明确失败,不使用空实现代替平台确认。 diff --git a/docs/experimental-local-asr.md b/docs/experimental-local-asr.md new file mode 100644 index 000000000..909155ca6 --- /dev/null +++ b/docs/experimental-local-asr.md @@ -0,0 +1,80 @@ +# macOS 实验性本地语音模型 + +macOS 的旧本地模型管理功能已下架。「设置 → AI 服务与模型 → 本地模型」保留灰色、不可点击的占位入口;鼠标悬浮或键盘聚焦时显示 **「该功能已下架,请去实验性与扩展里面」**。旧本地渠道也停用并置灰。Windows 原有模型管理入口和云服务渠道不受这次下架影响。 + +新功能位于 **设置 → 实验与扩展 → 实验性本地语音模型**,排在扩展列表第一位。三个实验单独开启,不会自动替换已有首选渠道。 + +## 三种方案 + +| 模型 | 下载与平台 | 运行方式 | +| --- | --- | --- | +| **TextStream(推荐)** | 模型约 1.9 GB,另需独立 Python/MLX 运行组件;Apple Silicon,macOS 14+ | OpenLess 采集 PCM;仅录音时启动独立 worker,输出可修订草稿和最终文本;结束或取消后退出 | +| **SenseVoice Small** | 约 240 MB;macOS | CPU;普通话、粤语、英语、日语、韩语;录音停止后返回全文 | +| **Embedding Gemma** | 约 715 MB;Apple Silicon,macOS 15+ | Core ML 英语语音转文字;录音停止后返回全文;不是语言模型或多模态生成服务 | + +TextStream 本身使用 Qwen3-ASR/MLX。移除的是主程序中的旧 Qwen/Whisper/Apple Speech 原生实现、MLX 链接和 Metal 资源,不是声称 TextStream 不再依赖 Qwen/MLX。它的依赖只进入所选模型目录中的 `.runtime`,不安装到系统 Python,也不打开自己的麦克风。 + +每个模型都有独立开关、下载/安装状态、错误与重试入口,以及明确的内存占用警告。网络失败不会初始化推理,也不会把失败的模型标成可用。 + +## 开关、渠道与生命周期 + +1. 开启开关:显式下载固定版本的文件并校验大小/SHA-256;TextStream 还安装固定版本、哈希锁定的运行组件。此阶段不导入 MLX 或加载模型。 +2. 完成后注册独立语音渠道并提示用户选择。下载不会启用一个原本没有启用渠道的语音流程,也不会替换已有首选渠道。 +3. 实际使用:在传统语音识别流程中,开启需要的渠道并拖到最上方。录音开始前检查实验开关、文件完整性和会话代次,之后才加载引擎。 +4. 关闭实验开关:取消未完成任务、移除该实验拥有的自动渠道、释放推理,**保留模型文件**。停用后可以单独删除。 +5. 停用渠道或切换到其他渠道:释放对应运行状态,不更改实验开关或删除文件。 +6. 重启/打开设置:文件和元数据查询,不加载模型。只有持久化标记为中断的、先前由用户明确启动的下载/安装/删除操作可以恢复。单独的 `enabled=true` 不允许补下载;文件丢失时须手动重试。 + +状态文件:`experimental-asr.json`。旧的两模型状态按原有 SenseVoice/Gemma 索引迁移,TextStream 默认关闭,不会错位。文件目录分别为 `experimental-textstream-qwen3`、`experimental-sensevoice-small`、`experimental-embedding-gemma-en`。 + +## TextStream 的流式约定 + +- OpenLess 始终拥有录音;worker 只接收 16 kHz、单声道、16-bit PCM,不调用录音设备。 +- 草稿是整体替换快照,可缩短、修订;不能按追加字符串处理。 +- 完成时以 decoder 的 finalization 结果为准。取消、EOF、协议错误和超时都不能把最后一段草稿当最终结果写入历史。 +- 录音期间,本机应用可以订阅 `http://localhost:7890/stream`。接口仅绑定 loopback,检查 Host/Origin,并限制订阅数和排队长度。 +- SSE 的终态仍是 `type: stream`、`done: true`,且 `draft` 为空。录音结束后关闭 worker 和接口。 +- worker 有加载/完成超时、父进程死亡监控、进程组清理和十分钟音频上限。端口占用会在推理导入前失败。 +- TextStream 强制实时送入音频,但不修改用户保存的“录音后识别”偏好;其他渠道仍遵循该偏好。 + +## 旧版升级清理 + +macOS 启动时,在配置的模型根目录和默认根目录中清除已知旧 Qwen/Whisper 安装与中断下载目录,并退休旧渠道。操作幂等;失败记日志,下次启动重试。旧同步设置也不能绕过后端重新下载、启用或推理。 + +不删除根目录、实验包、Windows 模型、录音、无关文件、其他应用数据或 macOS 系统语音资源;不沿模型路径、父目录或模型根目录的符号链接删除外部文件。**只有运行新版应用才执行真实升级清理,编译/测试不清理当前安装应用的数据。** + +## 模块与来源 + +- Core:`crates/openless-core/src/experimental_asr.rs` 管理状态、文件、渠道和租约,不依赖 Tauri 或原生推理库。 +- 原生:`src-tauri/src/asr/local/experimental/` 中分别实现 Gemma FFI 和 TextStream 的安装、协议、worker;共享适配器只负责选择模块和会话生命周期。 +- TextStream:MIT;实现基于固定上游修订的流式设置,保留许可。Qwen 权重固定 revision;Python 3.12.15、uv 0.13.1、mlx-qwen3-asr 0.4.4、MLX 0.32.3、silero-vad-lite 0.4.0;依赖由 `requirements.lock` 固定版本与 wheel 哈希。 +- SenseVoice:固定仓库 revision 和两个文件的 SHA-256。 +- Gemma:固定发布文件、大小和 SHA-256,保留 NOTICE、Apache-2.0 许可;部署还需遵守 NOTICE 指向的 Gemma 使用政策。适配器不调用其他应用的可执行文件或私有 Swift ABI。 + +## 测试与隔离验收 + +```sh +cd openless-all/app +cargo test --locked -p openless-core +cargo test --locked --manifest-path src-tauri/Cargo.toml --lib -- --test-threads=1 +cargo test --locked --manifest-path src-tauri/backend-tests/Cargo.toml +npm test +npx playwright test e2e/macos-experimental-asr.spec.ts +python3 -B -m unittest discover -s src-tauri/src/asr/local/experimental/textstream -p 'test_*.py' +``` + +debug 原生二进制及 debug `.app` 支持专用验收入口(发布构建不包含): + +```sh +src-tauri/target/debug/openless --openless-local-asr-check \ + /tmp/openless-asr-acceptance /path/to/english-16k-mono.wav --download +# 已安装并校验后去掉 --download,禁止测试暗中补下载: +src-tauri/target/debug/bundle/macos/OpenLess.app/Contents/MacOS/openless \ + --openless-local-asr-check /tmp/openless-asr-acceptance /path/to/english-16k-mono.wav +``` + +目录必须是显式指定的 `openless-asr-acceptance*` 隔离目录,不能是符号链接。验收使用真实权重/原生引擎和包含 hello、test、system 的固定 WAV,不用麦克风,不访问系统凭据库。覆盖三模型推理、TextStream SSE/最终文本一致性、取消不提交、停用保留文件、禁用后重启不加载、已启用重启/设置查询不加载,以及隔离旧模型的升级清理。通过后写入该目录的 `passed.json`。 + +浏览器回归使用 IPC mock,只证明界面行为,不能替代真实原生推理或签名发布验收。debug bundle 也不能替代 notarization 或真实 Windows 设备测试。 + +本次已完成的真实模型、进程生命周期、Release 打包与验证边界见 [验收记录](local-asr-verification.md)。 diff --git a/docs/homebrew-openless-updates.md b/docs/homebrew-openless-updates.md new file mode 100644 index 000000000..448eb52b1 --- /dev/null +++ b/docs/homebrew-openless-updates.md @@ -0,0 +1,34 @@ +# OpenLess 的 Homebrew 同步与本机提醒 + +## 公开版本自动同步 + +两个发版流水线先把桌面和 Android 资产上传到共享 draft。管理员确认两条流水线和安装包后,通过 GitHub UI 或管理员 `gh release edit` 发布。`release:published` 自动触发 `.github/workflows/sync-homebrew-cask.yml`,仅处理已公开、latest、非 Beta 的 `vX.Y.Z-tauri`。 + +脚本从 ARM/Intel DMG 的真实文件名读取完整版本,检查两个架构一致且与 tag 对应,再计算 SHA-256 并更新默认分支的 `Casks/openless.rb`。tag 不带 `+build`,DMG 文件名保留日期构建号;1.x 保持 Monterey,2.x 使用与 Host 对应的 Sonoma。 + +手动恢复同步(不重建包): + +```sh +gh workflow run sync-homebrew-cask.yml --repo Open-Less/openless -f tag=v2.0.0-tauri +``` + +## 本机更新提示(zsh + Node.js) + +```sh +brew tap Open-Less/openless https://github.com/Open-Less/openless +node openless-all/app/scripts/install-homebrew-openless-reminder.mjs +``` + +安装器保留已有 `.zshrc` 内容,首次修改先备份,重复执行不会重复插入。新终端会加载 zsh 的 `preexec` / `precmd` 事件钩子:成功执行 `brew update`、`brew update-reset` 或 `brew upgrade` 后检查 OpenLess。它不覆盖 brew、不自动替换 App、不建立 cron 或 LaunchAgent。 + +每次检查都会在终端显示结果;发现新版时另发 macOS 通知,同一个可用版本只通知一次。系统关闭通知时仍能看到终端结果。优先比较实际 `/Applications/OpenLess.app` 的版本,避免旧公开版覆盖手动安装的修复版;正式版高于同版本 Beta。Homebrew 命令失败时不冒称已成功检查。 + +也可单次检查: + +```sh +node ~/.local/share/openless/homebrew-openless-notify.mjs --notify +``` + +确认公开版更新后执行 `brew upgrade --cask open-less/openless/openless`。尚未由 Homebrew 管理的手动安装需先通过 cask 安装接管;不要为了接管而降级当前修复版。 + +卸载提醒只需删除 `.zshrc` 中 `OpenLess Homebrew update reminders` 标记块和 `~/.local/share/openless/homebrew-openless-{hook.zsh,notify.mjs}`;没有后台 Agent 需要停止。 diff --git a/docs/index.md b/docs/index.md index 0516a04ea..f90aadef7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -33,7 +33,11 @@ - [讯飞(iflytek)ASR 配置](xfyun-asr.md) - [百炼(DashScope)ASR 模型](bailian-asr-models.md) - [Tauri CSP 边界](tauri-csp.md) -- [qwen-asr 子模块升级清单](qwen-asr-submodule-upgrade-checklist.md) +- [macOS 实验性本地语音模型](experimental-local-asr.md):三模型、旧版下架、升级清理和隔离验收。 +- [本地 ASR 生命周期](local-asr-lifecycle.md):下载、选择和推理状态的边界。 +- [本地 ASR 验收记录](local-asr-verification.md):整合前的真实三模型与生命周期验证。 +- [桌面 beta.5 整合验收](desktop-beta5-integration.md):合并来源、指定版本、本地测试及最终安装包。 +- [旧 qwen-asr 子模块说明(已下架)](qwen-asr-submodule-upgrade-checklist.md) ## 验证入口(在 `openless-all/app` 执行) diff --git a/docs/linux-egui-backend-contract.md b/docs/linux-egui-backend-contract.md index 83d60420e..56832a77e 100644 --- a/docs/linux-egui-backend-contract.md +++ b/docs/linux-egui-backend-contract.md @@ -1,6 +1,6 @@ # Linux egui 后端接口契约(2.0.0) -状态:canonical(2026-09-07 以源码为准重写);更新:2026-09-27。范围以[2.0 需求](2.0-requirements.md)为准;本文是长接口与实现参考,交接材料以[交接目录](linux-egui-handoff/README.md)为准。 +状态:canonical(2026-09-07 以源码为准重写);更新:2026-10-03。范围以[2.0 需求](2.0-requirements.md)为准;本文是长接口与实现参考,交接材料以[交接目录](linux-egui-handoff/README.md)为准。 ## 1. 合同文件 @@ -10,6 +10,7 @@ | --- | --- | | `startupSnapshot` | 启动快照结构与版本校验规则;UI 必须先消费快照再渲染 | | `backendEvent` | 语义事件清单、顺序与重放规则 | +| `testingFeatures` | 本设备 Beta 总开关、默认关闭、运行门禁与取消合同 | | `lessComputerVoice` | Less Computer 语音事件面 | | `androidJni` | Android JNI 合同(src-tauri android 桥接共用) | | `linuxFacade` | Linux 专用 facade 面(`LinuxHost` 公开方法对应) | @@ -33,6 +34,8 @@ ## 5. 行为约定(合同级) +- `UserPreferences.testingFeaturesEnabled` 默认 `false`,设备独立选择,不进入云同步。实验与扩展中的 Less Computer 移到 Beta 测试功能子页,开启总开关后才显示,运行还须开启原有 `codingAgentEnabled`。Core 在会话预约、录音与文字提交时验证有效开关,关闭返回 `PermissionDenied`;关闭总开关取消当前任务、注销热键并关闭独立弹窗,保留功能配置。 + - fcitx5 为生产启动硬依赖:持有单实例锁后检查插件和会话 D-Bus,按需刷新并在 15 秒就绪预算内等待,再启动监听器、注册必需热键、启动 Core 和 Remote Input。失败清理已启动资源并显示错误窗口;关闭后以非零状态退出,外层锁覆盖完整生命周期。 - 设置保存状态与动作处理位于 `linux_app/settings_save.rs`:按序列化字段生成补丁、串行提交、只重试明确 revision 冲突(最多三次),失败保留草稿并显式重试;Core 合同与持久化格式不变。 - 窗口生命周期与 client 位于 `linux_app/window.rs`,协议门禁位于 `ui/bridge.rs`。显式显示窗口请求创建窗口或发送 FocusMain;Wayland 是否激活由合成器决定。 diff --git a/docs/local-asr-lifecycle.md b/docs/local-asr-lifecycle.md new file mode 100644 index 000000000..8fa792ec4 --- /dev/null +++ b/docs/local-asr-lifecycle.md @@ -0,0 +1,37 @@ +# Local ASR lifecycle + +Model files, channel selection and a live inference engine are separate states. + +**macOS:** the legacy Qwen/Whisper/Apple Speech implementation and model manager are retired. The disabled placeholder redirects users to the three opt-in [experimental models](experimental-local-asr.md). Old IDs are metadata compatibility only; they cannot start an engine or download old models. Windows/cloud routes are not retired. + +| Action | Files / metadata | Inference | +| --- | --- | --- | +| Start/restart or open settings | Inspect files; migrate retired metadata; restore saved channel state | Unloaded, even when an experimental model was selected | +| Explicitly enable an experiment | Download and validate; install optional runtime components; register a channel | Not loaded; no inference-library imports during setup | +| Select/enable a downloaded channel | Metadata only; existing channel priorities are not overwritten by downloads | Deferred | +| Record or permitted explicit test | Require enabled/selected channel, installed files and current generation | Load only on this explicit action | +| Disable/delete a channel or switch away | Persist the choice; keep files | Release the corresponding engine/worker | +| Turn off an experiment | Cancel pending work; remove only its owned channel; keep files | Release | +| Explicitly delete its package | Require disabled experiment; remove its managed files after release | Never load merely to delete | + +Persisted interrupted `downloadPending`/`installPending`/`deletePending` operations may resume because they represent a prior explicit action. `enabled=true` alone is not permission to download or reinstall missing files. Downloads/status/settings never invoke inference preparation. + +TextStream's worker exists only during recording and receives PCM from OpenLess. Its independent Python/MLX environment is optional, hash locked and kept below the managed package. Finalized text, not the most recent draft, is committed. Cancellation, EOF, disabled leases and timeout cannot promote a draft. SenseVoice/Gemma are offline adapters and do not claim native streaming. + +`LocalAsrActivationResult.preparedModel` remains for wire compatibility: it identifies a selected model, not a successful native load. + +## Windows native setup exception: Foundry + +The Foundry SDK's explicit download/setup path can initialize native components. It therefore sets a process-local restart-required flag, acquires/verifies the package without enabling a channel, writes a filesystem-only receipt, attempts unloading on success or failure, and blocks activation/inference until OpenLess restarts. UI restart guidance remains visible if deferred. + +Catalog/status/cold deletion use receipts rather than starting the SDK manager. Existing installations without a receipt require one explicit Download/verify and restart; settings must not silently initialize the SDK for migration. + +## Retirement safety + +macOS startup removes only catalog-known legacy Qwen/Whisper installation/staging paths in configured/default managed roots. It keeps renamed channel identities as disabled entries. Root/model/parent symlinks must not grant deletion authority outside the managed tree. No macOS system speech assets, recordings, experimental packages or another application's files are removed. + +## Verification + +Run the Core, native, backend, frontend and browser checks documented in [experimental-local-asr.md](experimental-local-asr.md). Native acceptance has an explicit isolated debug-bundle entry point; it never uses the installed application's data, OS credential vault or microphone. + +Checks govern OpenLess's own engine handles and child processes. They do not promise unrelated macOS/Windows system services disappear. Browser mocks and a debug bundle are not evidence of signed/notarized release acceptance or Windows hardware behavior. diff --git a/docs/local-asr-verification.md b/docs/local-asr-verification.md new file mode 100644 index 000000000..5857e1bd7 --- /dev/null +++ b/docs/local-asr-verification.md @@ -0,0 +1,102 @@ +# macOS 本地 ASR 下架与实验模型验收 + +> 这是桌面分支整合前的历史验收记录,产物版本为 `2.0.0-Beta.4+build.20260930`。后续构建会覆盖相同的 `.app` 路径;当前 `2.0.0-beta-a.beta.5+build.20261015-bd` 的结果与校验值以 [桌面整合验收](desktop-beta5-integration.md) 为准。 + +验收时间:2026-10-15(Apple Silicon,macOS 27.0.1)。代码位于当前未提交工作区;不是已发布版本的验收声明。功能与复现入口见 [实验性本地语音模型](experimental-local-asr.md)。 + +## 结果 + +| 检查 | 结果 | +| --- | --- | +| `cargo test --locked -p openless-core` | 1,376 项通过(1,179 单元 + 197 集成),0 失败;另有 1 项依赖固定 Codex CLI 的无关测试默认忽略 | +| Tauri `cargo test --locked … --lib -- --test-threads=1` | 529 项通过,0 失败;4 个按需运行的模型/安装测试默认忽略,相关真实推理与安装另行验证 | +| 独立 `src-tauri/backend-tests` | 40 项通过,0 失败 | +| `npm test` | 生产前端构建及 117 个前端/契约测试文件通过 | +| Chromium / WebKit 设置页验收 | 共 6 项通过,覆盖旧入口禁用与指定悬浮提示、三模型入口、后台下载提示、Windows 原入口不变 | +| Python worker 协议/SSE 单元测试 | 7 项通过,无模型加载或麦克风调用 | +| SenseVoice 真实推理 | 英文、普通话固定 WAV 均通过 | +| Embedding Gemma 真实推理 | 英文固定 WAV 通过 | +| TextStream 真实推理 | 独立 Python/MLX worker、可修订预览、SSE 和最终文本验收通过 | +| 最新 debug `.app`,不补下载的隔离验收 | 通过,退出码 0 | +| Release `.app` / DMG | 构建、ad-hoc 签名验证、DMG 校验和及布局验证通过 | +| `git diff --check` / 新实验模块 Rust 格式检查 | 通过 | + +完整 Core、原生、前端测试中的初次失败已修复,以上统计来自最后一次成功结果,不把早期失败日志当作通过证据。旧引擎的空壳缓存、兼容 facade 和不可达调用路径也已删除,不再通过空实现维持旧运行接口。 + +## 真实模型、进程与文件验证 + +测试使用 `/tmp/openless-asr-acceptance` 隔离目录以及 6.925 秒、16 kHz 单声道 PCM WAV,由系统 TTS 合成后交给 OpenLess 的真实原生适配器。**没有调用麦克风、系统凭据库,或安装/清理当前用户的 OpenLess 应用数据。** + +- SenseVoice、TextStream 使用固定版本下载,并通过大小和 SHA-256 校验。下载中断后从断点恢复,没有重下已完成的模型。 +- Gemma 测试包从已有文件只读复制到隔离目录,全部 26 项文件通过固定清单校验;源应用数据未修改。 +- 实验开启、文件安装和渠道注册结束后,原生模型句柄仍为空,TextStream worker 未启动。 +- 已开启且已选中模型的重启恢复、反复状态查询不会加载模型。 +- 实际录音会话才加载模型;三模型识别结果均包含 `hello`、`test`、`system`。 +- TextStream 的 SSE 终态为 `type: stream`、`done: true`,`draft` 为空,`finalized` 与 OpenLess 返回的最终文本一致;录音中确实产生替换型预览,不靠最后一个草稿充当最终结果。 +- 通过真实 Core 渠道开关验证释放,不只调用测试用的释放函数;实验开关和模型文件不因停用渠道被删除。 +- 再次启动会话后取消,完成接口返回错误,未提交草稿;关闭实验开关后保留文件,重新启动仍为停用状态,无法获取推理租约。 +- 在真实 `.app` 内对临时旧模型/中断目录执行清理;重复执行仍安全,实验包和无关文件保留。符号链接、渠道退休与元数据边界另外由 Core 回归覆盖。 +- 验收结束后,没有残留 TextStream worker 或 TCP 7890 监听。 + +最新 debug bundle 验收记录:`/tmp/openless-asr-acceptance/deliverable-acceptance.log`、`deliverable-acceptance.exit`(0)。结果收据为同目录的 `passed.json`;汇总为 `verified-tests.json`。临时目录可能被系统清理,本文保留主要结果及产物校验值。 + +固定英文 WAV SHA-256: + +```text +d41257872b65e283ad4b54809e2cdda7160bd733b4cfc9a7dc13150669710843 +``` + +三模型输出(标点差异正常,不代表任意真实语音都能达到同样效果): + +```text +SenseVoice: Hello, this is a test of the speech recognition system; the local model should recognize this sentence. +Gemma: Hello, this is a test of the speech recognition system. The local model should recognize this sentence. +TextStream: Hello. This is a test of the speech recognition system. The local model should recognize this sentence. +``` + +最新 debug bundle 主程序 SHA-256: + +```text +4a6f0acf28e7e90cf1fe1037b050fe63daf252975e50d19b82c59e2ac8183855 +``` + +debug 模式的整包哈希校验开销显著,以上测试不作为发布版首字延迟/实时率基准。它证明真实模型与生命周期可用,不承诺所有语言、长录音、设备或口音的识别质量。 + +## 可交付产物 + +从仓库根目录查看: + +- Release 应用:`openless-all/app/src-tauri/target/release/bundle/macos/OpenLess.app` +- DMG:`openless-all/app/src-tauri/target/release/bundle/dmg/OpenLess_2.0.0-Beta.4+build.20260930_aarch64.dmg` +- debug 验收应用:`openless-all/app/src-tauri/target/debug/bundle/macos/OpenLess.app` + +Release 使用 `INSTALL=0` 的正式打包脚本生成,没有覆盖 `/Applications`,没有重置当前应用权限。Release `.app` 约 78 MiB,DMG 35,168,578 字节。 + +验证项目: + +- `codesign --verify --deep --strict` 通过,含麦克风 entitlement。 +- Info.plist 仍有麦克风说明,不再声明旧系统语音识别权限。 +- Info.plist 与 Mach-O `LC_BUILD_VERSION` 均要求 macOS 14.0;Gemma 自身另行限制 macOS 15+。 +- 不包含旧 `mlx.metallib`,主程序未链接旧 MLX/Whisper/Speech.framework。 +- 旧 `--openless-mlx-worker` 启动参数仅返回下架提示,不创建推理 worker。 +- DMG CRC 校验、Retina 背景、窗口/图标布局及 Applications 链接验证通过。 + +Release 主程序 SHA-256: + +```text +c4802ab6dd64842ecd1209751ab990df5a31650e6a8facf792ec16cccea412c0 +``` + +DMG SHA-256: + +```text +0a1de42b40f2cd6583321edee77821e1605ea38ea788e3d1760ab4ca2f43b43c +``` + +## 验证范围之外 + +- 本机没有 Developer ID / Apple 公证凭据。本次产物为 **ad-hoc 签名的本地测试包,不是已公证的公开分发包**;浏览器下载仍可能触发 Gatekeeper。 +- 三模型的进程级推理验收使用 debug `.app` 的隔离入口;Release 另行完成构建、签名、资源和安装镜像检查,未对已安装用户配置启动正式 UI。 +- Windows 入口由浏览器回归覆盖,相关共享业务逻辑由 Core 回归覆盖;本机没有 Windows 运行环境。尝试交叉编译时缺少 `x86_64-w64-mingw32-gcc`,因此没有把 Windows 原生构建、SDK 或真实设备行为标成通过。 +- 未进行真实麦克风、其他应用文本插入、旧版用户真实安装升级,或所有 macOS 版本/Intel 设备的验收。升级自动清理逻辑已实现并用隔离旧数据验证,但当前安装应用的数据未被清除。 +- 构建仍有现有的弃用/FFI 声明和 window-vibrancy 重复符号警告;未为这次 ASR 改动顺便重构无关窗口/热键代码。 diff --git a/docs/qwen-asr-submodule-upgrade-checklist.md b/docs/qwen-asr-submodule-upgrade-checklist.md index d13ab3cfa..727e1543d 100644 --- a/docs/qwen-asr-submodule-upgrade-checklist.md +++ b/docs/qwen-asr-submodule-upgrade-checklist.md @@ -1,26 +1,7 @@ -# qwen-asr 子模块升级清单 +# 旧 Qwen-ASR 子模块清单(已下架) -状态:canonical(2026-09-07 以源码为准重写);更新:2026-09-07。 +这份清单已被 [macOS 实验性本地语音模型](experimental-local-asr.md) 取代,不再是当前构建指南。 -## 1. 子模块(`.gitmodules`,仓库根) +旧 `qwen-asr`、`qwen3-asr-rs` 子模块、原生 C/MLX 引擎、Metal 资源打包与对应 CI 启用/禁用脚本均已从应用移除。**不要为解决当前编译问题恢复旧子模块或旧 MLX 打包步骤。** -| 子模块 | 路径 | 来源 | -| --- | --- | --- | -| qwen-asr | `openless-all/app/src-tauri/vendor/qwen-asr` | (组织 fork;`antirez/qwen-asr` 只作上游同步来源,不直接进构建) | -| qwen3-asr-rs | `openless-all/app/src-tauri/vendor/qwen3-asr-rs` | | - -qwen-asr 是 macOS 本地 Qwen3-ASR 的 **C 引擎**,由 src-tauri 构建链接(vendored);qwen3-asr-rs 为 Rust 运行时来源。Linux workspace(openless-core + linux-egui)不包含 src-tauri,不依赖这两个子模块。 - -## 2. 升级步骤 - -1. `git submodule update --init --recursive`(先在干净工作区)。 -2. 在子模块目录 `git fetch && git checkout <目标提交>`(只用 Open-Less fork 的提交;记录提交号)。 -3. 主仓 `git add` 子模块指针并提交(提交信息注明引擎版本/目的)。 -4. macOS 构建验证:`src-tauri` 按平台构建通过;如涉及 Metal/工具链,先跑 `npm run check:macos-metal-toolchain`;相关契约:`scripts/macos-compiler-runtime-contract.test.mjs`、`stage-macos-mlx-metallib.mjs`(MLX 路径)。 -5. CI 注意:`scripts/ci-disable-macos-qwen3.mjs` 控制 macOS CI 中 qwen3 的启用范围,升级后核对其仍符合当前 CI 策略。 -6. 行为验证:本地 ASR 下载/激活/取消与听写回归(见[桌面验收](2.0-desktop-acceptance.md)本地模型域)。 - -## 3. 纪律 - -- 不修改 vendored 引擎源码本身的问题;修复走 fork 上游,再 bump 指针。 -- 子模块指针升级必须与构建验证同批提交,不留"指针先行"状态。 +TextStream 使用独立、按需安装的 Python/MLX worker。升级它时核对固定 revision、模型清单、wheel 版本/哈希及许可,并运行流式协议、真实推理、取消/退出和安装包验收;不再更新原生子模块指针。 diff --git a/docs/reviews/2026-10-15/core-fixes.md b/docs/reviews/2026-10-15/core-fixes.md new file mode 100644 index 000000000..34e326d70 --- /dev/null +++ b/docs/reviews/2026-10-15/core-fixes.md @@ -0,0 +1,73 @@ +# R1 / R3 修复交付 + +基线 `0947c05a`,工作目录 `/Users/trip/orca/workspaces/openless/修复-Matel存在的部分问题`。R1、R3 已完成修复和语义回归;只改授权的 3 个文件,没有提交,没有修改 R2 TextStream、前端、其他 Core 文件或 backend-tests manifest/lock。 + +## 修改内容 + +### R1:只清理退出的活动 owner,不让清理落后于异步状态刷新 + +文件:`openless-all/app/crates/openless-core/src/api.rs`。 + +- `:2332` 新增局部 `local_asr_channel_gate`,只协调 ASR 渠道的 owner 变更与 runtime 清理;LLM 渠道不使用该锁。 +- `:4860` 的渠道 mutation 捕获活动 owner,而不是按“正在编辑的任意渠道”的 provider 无条件释放。`set_channel_provider_type` (`:4636`) 与 reorder (`:4771`) 复用此路径,`set_active_provider` (`:4815`) 和显式本地 activation 也参与同一 owner 临界区。 +- `:4897` 解析 channel ID / provider type,并兼容 legacy provider-only selection;`active_provider` 不受支持的 persistence-only store 不会因为新检查而失去原有 mutation 能力。 +- `:4930` 比较 mutation 前后的活动 owner。非活动同 provider 渠道被禁用、删除、更换 provider 或清理空白草稿时,不会释放活动 runtime;活动本地 owner 离开、且没有同 runtime 新 owner 接管时,仍执行 release。 +- 如果规范化后的新活动渠道继续拥有同一个共享 runtime,则保留该实例,避免把“旧 owner 退出”变成“杀掉新 owner”;模型差异仍由下一次显式使用/既有 activation lease 处理。 +- **runtime release 在 credential status refresh 之前完成**,之后释放 owner gate。状态查询被阻塞时,新的选择、activation 和 preload 不被 gate 卡住;旧请求恢复时不会再执行任何 runtime 清理,因此不能卸载刚加载的新实例。 +- native release 返回错误时,仍发布已经提交的 credential metadata,再向调用方返回 release 错误,避免新增“设置已提交但状态不再更新”的回归。 + +该修改集中在现有渠道 facade,没有更改 runtime adapter / TextStream ABI,也没有重构全局 settings 或录音链路。 + +### R3:删除完整性与加载完整性分离 + +文件:`openless-all/app/src-tauri/src/asr/local/native_model_receipt.rs`。 + +- `:127` 冷删除直接解析 receipt 的 `model_dir`,不再依赖只接受完整文件的 `installed_dir()`;模型缺文件或文件截断,不会阻止清理受管目录。 +- `:93` 通过真实存在的祖先解析目录,即使模型目录及其部分父目录已经不存在,仍可验证归属并完成幂等 cleanup。 +- 保留 canonical root containment 检查,拒绝删除缓存根本身、根外现有/缺失路径及指向外部的 symlink。 +- 只有目录成功删除或确认不存在后才删除 receipt;JSON 损坏、越界或删除失败会保留 receipt 并报错,后续仍能追踪/重试。 +- `installed_dir()` 的加载完整性标准未放宽,状态页也不会为了清理而初始化 Foundry SDK。 + +## 新增语义回归 + +`openless-all/app/crates/openless-core/tests/local_asr_contract.rs` 新增 5 项: + +1. `non_active_same_provider_channel_edits_do_not_release_the_active_runtime` (`:1287`):分别禁用、删除、更换 provider、删除空白的非活动同 provider 渠道;断言活动渠道未变、release 不发生、已加载模型保留。 +2. `active_local_channel_edits_release_its_runtime_when_no_local_owner_remains` (`:1310`):对当前 owner 做上述四种操作,断言恰好一次 release 且模型清空。 +3. `committed_channel_changes_are_published_even_when_runtime_release_fails` (`:1330`):release 失败仍传播明确错误,但已提交的渠道变化发出 `CredentialsChanged`。 +4. `a_same_runtime_channel_takeover_preserves_the_shared_loaded_instance` (`:1355`):当前 owner 关闭后由同 runtime 渠道接管,断言不误释放新 owner 的共享实例。 +5. `late_credentials_refresh_never_releases_a_reselected_or_reloaded_local_owner` (`:1380`):分别阻塞 disable/delete/provider type/select/reorder 的 credential refresh,期间重新激活并加载本地模型,然后恢复旧请求;断言新实例保留、不会出现第二次 stale release,且 replacement 不会被 owner gate 锁在状态刷新之后。 + +`native_model_receipt.rs` 新增 5 项(原有 1 项保留): + +- `:202` 缺文件和截断两种情形都删除整个模型目录,而不只是删除 receipt。 +- `:227` 模型目录及其父目录已经缺失时,安全忘记 receipt。 +- `:235` 缓存根、根外现有目录、根外缺失目录均拒绝删除,并保留 receipt / 外部文件。 +- `:252` Unix symlink 指向根外目录时,即使 receipt 文件列表不完整也拒绝删除。 +- `:269` receipt JSON 损坏时报错,不偷偷丢弃追踪信息。 + +## 测试结果 + +从 `openless-all/app` 执行: + +```sh +CARGO_TARGET_DIR="$PWD/src-tauri/backend-tests/target" \ + cargo test --offline --locked -p openless-core --test local_asr_contract + +cargo test --offline --locked \ + --manifest-path src-tauri/backend-tests/Cargo.toml \ + --test native_model_receipt +``` + +最终结果: + +```text +local_asr_contract: 34 passed; 0 failed; 0 ignored +native_model_receipt: 6 passed; 0 failed; 0 ignored +``` + +`git diff --check` 对 3 个授权文件通过。只对新写的局部代码块使用 rustfmt,没有格式化整个工程;生成局部格式化替换的 `/tmp` 文件已经删除,测试使用的临时模型目录由测试回收。 + +## 验证边界与交接 + +这轮 Cargo 仅构建 Core 和独立 backend-tests 的指定目标,全程 `--offline --locked`,没有触发完整 native 构建或 sherpa 库下载,也没有模型下载、录音、调用收费 API、读取用户秘密或安装程序。Core 回归使用内存 credential store / fake runtime,receipt 回归直接编译生产文件、操作临时目录;Windows Foundry SDK 与真实 Windows 文件系统、真实 TextStream/MLX 推理和 GUI 未在本轮执行。R2 文件及主 Agent 并行修改的 manifest、lock、recorder、前端与其他 ASR 文件均未由本 worker 修改。 diff --git a/docs/reviews/2026-10-15/core.md b/docs/reviews/2026-10-15/core.md new file mode 100644 index 000000000..325933b29 --- /dev/null +++ b/docs/reviews/2026-10-15/core.md @@ -0,0 +1,151 @@ +# PR #1197:Core / native / egui 独立只读审查 + +审查基线:`0454dc56..0947c05a`,仓库 `/Users/trip/orca/workspaces/openless/修复-Matel存在的部分问题`。下文行号对应 `0947c05a`;本次没有修改仓库源码、配置或凭据,没有安装程序、下载模型、主动录音、调用收费 API、启动应用或创建子 Agent,也没有执行 Cargo / 完整构建。 + +## 结论与优先级 + +发现 3 个生命周期问题,均由 **`16a73b9d`** 引入。优先修复 R1 的渠道所有权判断,其次修复 R2 的子进程取消句柄和 R3 的损坏缓存清理;未发现能够在本次约束内确认的永久互锁,也没有把“多模态无输出”未经实测地归因于本分支。 + +| 编号 | 优先级 | 问题 | 核验强度 | +|---|---|---|---| +| R1 | P2 | 禁用/删除非活动同类型 ASR 渠道,会释放活动渠道共享的 runtime | 原函数提取后的 spy-port 探针已复现 release 决策;native 后果沿源码追踪 | +| R2 | P2 | TextStream 在 EOF 后等待进程退出时丢失取消句柄,可留下仍存活的 worker | 提取原 `Process` 实现、以自有 shell/sleep 子进程确定性复现;不是 MLX 真机复现 | +| R3 | P2 | Windows Foundry 的损坏/缺文件缓存无法冷删除,且删除成功后丢失 receipt | 静态路径确认;未运行 Windows SDK / 原生 UI | + +这里的 P2 表示应修复的条件性回归,不表示所有普通录音都会失败。R2 的成立条件特意保留为“stdout 已 EOF,但进程仍存活”,不能把该边界探针写成真实 MLX teardown 已发生挂起。 + +## R1 — 非活动渠道编辑也会释放活动渠道的共享 runtime + +**定位**:`openless-all/app/crates/openless-core/src/api.rs:4901-4915`,尤其 `4914-4915`。引入提交:`16a73b9d`。 + +`apply_channel_mutation()` 按被修改渠道的 `provider_type` 获取 `retiring_provider`,但不检查该渠道是否是活动渠道、是否持有该 runtime 的录音/加载 lease;持久化与状态刷新后,直接调用 `release_local_asr_provider()`。因此渠道 A 与 B 共用一个本地 provider,A 活动、B 非活动时,禁用或删除 B 仍会释放 A 的 runtime。重复 provider 的渠道是 Core 支持的状态,Windows 的 Foundry/Sherpa 添加渠道也允许重复;实验 provider 不在当前 UI 的新增列表中,但 Core 创建/导入契约仍可形成该状态。 + +**触发条件**:存在两个同 provider 的 ASR 渠道,其中只有 A 是活动渠道;编辑 B 的启用状态或删除 B。若活动 provider 是 TextStream,且已有运行中的 worker,该无关编辑会走 `api.rs:4921-4923` → `experimental_asr.rs:510-516` 的 `release_runtime()` → `textstream.rs:112-133` 的 `stop()`,取消活动 worker;Foundry/Sherpa 则发生不必要的共享实例释放,不能据此一概断言它们丢失转写,因为其部分 session 能保留 Arc 或重新加载。 + +**可复核证据**:探针 `/tmp/openless-review-core-channel-probe.py` 直接从当前 `api.rs` 提取 `apply_channel_mutation()` 函数体,使用只在内存工作的 credential ports 与 release spy,未执行 native 推理。运行: + +```sh +PYTHONDONTWRITEBYTECODE=1 python3 /tmp/openless-review-core-channel-probe.py +``` + +输出: + +```text +inactive_channel_action=disable; active_channel=active; runtime_releases=["experimental-textstream"] +inactive_channel_action=delete; active_channel=active; runtime_releases=["experimental-textstream"] +``` + +这证明“活动渠道未变化,却执行了同 provider release”,不是完整产品录音验证。 + +**修复建议**:区分渠道配置变更与 runtime 所有权退出。至少仅在被修改渠道持有活动 runtime 时执行释放;更稳妥的是捕获原 owner / generation,在异步刷新之后只释放该旧 lease,避免 A→B→A 快速切换时旧清理误杀新实例。不要仅在 await 前读取活动 provider,await 后无条件释放同类型的当前实例。 + +**现有测试缺口**:`crates/openless-core/tests/local_asr_contract.rs` 覆盖活动渠道的 disable/reorder、无隐式 preload、重启冷启动等;`experimental/acceptance.rs` 也只禁用当前正在测试的渠道。缺少“两个同 provider 渠道,编辑非活动的一个,活动 runtime / session 保持不变”的断言,也缺少延迟 credential status 期间切换回来后的 owner/generation 场景。 + +## R2 — EOF 后 `completed()` 取走 Child,之后 cancel/timeout 不再能杀它 + +**定位**:`openless-all/app/src-tauri/src/asr/local/experimental/textstream.rs:57-64`;关联 EOF 路径 `:366-368`,取消/超时清理 `:440-459`。引入提交:`16a73b9d`。 + +`Process::completed()` 在 `child.wait()` 前用 `.take()` 将唯一 `Child` 从共享 `Mutex>` 中拿走。若 stdout 已关闭但 worker 仍停留在退出/原生析构阶段,reader 线程会在局部 `Child::wait()` 中阻塞;此时 `stop()`、Session 超时清理、关闭实验开关和 `Drop` 看到的都是 `None`,不能再杀掉该仍存活的进程。`running()` 同时错误返回 false,下一轮可尝试启动另一个使用固定 7890 端口的 worker。 + +**触发条件**:worker stdout 到达 EOF,但 worker 进程尚未退出;随后调用 cancel / runtime release,或等到 finish 的 180 秒超时。正常 Python/MLX 退出若很快完成,不会暴露这个问题。本次不声称真实 MLX 已复现退出挂起。 + +**可复核证据**:`/tmp/openless-review-core-process-probe.py` 从 `textstream.rs` 提取原 `Process` struct/impl,只用 std 和 libc 声明编译一个小型 rustc 探针,不经过 Cargo。自有子进程关闭 stdout 后执行 `sleep`,模拟同一退出边界;探针调用原 `completed()` 和 `stop()`,最后明确 SIGKILL 并回收自己的进程组。 + +```sh +PYTHONDONTWRITEBYTECODE=1 python3 /tmp/openless-review-core-process-probe.py +``` + +输出: + +```text +EOF observed; completed owns Child; running()=false; child_alive_after_stop=true +waiter_after_explicit_cleanup=Err("TextStream worker exited: signal: 9 (SIGKILL)") +``` + +**修复建议**:等待退出与取消必须共享仍有效的进程所有权。可由一个专用进程监督任务独占 Child、通过消息接受 kill/wait;或使用短周期 `try_wait`,仅在确认已退出后从共享 slot 移除。不要把 mutex 持有到无界 `wait()` 结束,否则只是把“丢失 kill 句柄”换成 stop 无法获得锁。 + +**现有测试缺口**:`textstream.rs` 已测试 startup cancellation、空闲 writer 唤醒和 generation preflight;`protocol.rs` 与 `test_worker.py` 也覆盖 EOF 不提交 draft。但它们没有测试“EOF 不等于进程退出”,也没有断言 finish timeout / disable / cancel 后 owned PID 确实消失。应新增先发 ready/final、再关闭 stdout 而不退出的假 worker,验证取消与超时都能回收。 + +## R3 — 损坏的 Foundry 缓存删除只移除 receipt,留下大文件 + +**定位**:`openless-all/app/src-tauri/src/asr/local/native_model_receipt.rs:92-106`,完整性判定 `:70-80`;调用方 `foundry_runtime.rs:1131-1134`。引入提交:`16a73b9d`。 + +冷删除 `remove()` 通过 `installed_dir()` 获取删除路径,而 `installed_dir()` 只有 receipt 中每个文件都存在且大小完全一致时才返回目录。成功下载后,只要其中一个文件被截断/移走/清理,或者一次后续替换使 receipt 不再完整,`installed_dir()` 就返回 None;`remove()` 随后跳过目录删除,仍调用 `forget()` 并返回成功。其余数百 MB / GB 缓存因此残留,而且唯一指向缓存目录的 receipt 被删掉,之后无法再通过该冷删除路径找到它。 + +**触发条件**:Windows Foundry 已有 receipt,模型缓存后来不完整;重启后 SDK manager 尚未初始化,调用模型删除。相同问题不要求 SDK 推理发生,也不应靠先启动 SDK 才能清理。 + +**验证边界**:已逐行追踪 `installed_dir → remove → forget` 以及 manager 为 None 的实际调用分支;这是静态确定的文件状态分支。本次未执行 `native_model_receipt` Rust 测试、未运行 Windows SDK,也没有修改用户缓存来复现。 + +**修复建议**:把“可用于加载的完整模型”和“应用可安全删除的受管路径”分开。删除时先读取 receipt 的 model_dir,独立做 canonical / root containment 与 symlink 边界检查,不要求内容完整;只有删除完成或目录确实不存在后才忘记 receipt。receipt 损坏或目录无法安全归属时应返回明确错误,而不是成功且失去追踪。 + +**现有测试缺口**:`native_model_receipt.rs:113-128` 只覆盖完整权重的 record/lookup/remove 和非法 alias。应覆盖两个文件中缺一个、一个文件截断、receipt 存在而目录不存在、拒绝 root/outside 路径,并在成功删除后断言剩余大文件也消失。 + +## 本次执行的验证 + +以下检查均通过,没有使用 Cargo 锁、模型下载或录音: + +- `PYTHONDONTWRITEBYTECODE=1 python3 openless-all/app/src-tauri/src/asr/local/experimental/textstream/test_worker.py -v`:7/7,通过;native 模块全部 fake,SSE 仅临时 loopback server。 +- `node scripts/local-asr-lifecycle-contract.test.mjs`。 +- `node scripts/macos-local-asr-retirement-contract.test.mjs`。 +- `node scripts/macos-audio-usage-description-contract.test.mjs`:只对 `/tmp` plist fixtures 运行检查脚本。 +- `node scripts/macos-optional-runtime-bundle-contract.test.mjs`。 +- `node scripts/macos-runtime-minimum-version-contract.test.mjs`:静态契约确认 14.0。 +- `node scripts/less-computer-opencode-contract.test.mjs`。 +- 上述两个原函数提取探针均编译、执行成功,临时 rustc 源码/可执行文件自动清理,自有子进程也已回收。 + +这些 Node 检查主要是源码/结构契约,不能证明 worker 生命周期、Windows 原生推理、打包/签名或真实 GUI 成功。`git diff --exit-code HEAD -- openless-all/app/crates/openless-core openless-all/app/linux-egui openless-all/app/src-tauri .github` 在核验时为空;工作树里主 Agent 的前端修改未被本次更改或回滚。 + +## 审查覆盖清单 + +“完整”指读取新增文件或相关实现全部;“差异+追踪”指覆盖本范围所有变更块,并读取必要未修改调用方,不表示整个大型文件每一行都重新审计。 + +### Core(差异+追踪;实验 service/tests 完整) + +路径前缀 `openless-all/app/crates/openless-core/`: + +- `src/api.rs`:Beta admission / opt-out cleanup、本地渠道 mutation、ASR activation/release/settings 事务;追踪活动所有权与 credential await。 +- `src/experimental_asr.rs`、`src/experimental_asr/tests.rs`:下载、取消、独立 worker 锁、开关 generation、重启恢复、install、删除、channel 注册与 lease。 +- `src/experimental-gemma-manifest.json`、`src/experimental-textstream-manifest.json`:读取 manifest 入口与 shape/哈希校验路径;未联网重验全部上游对象内容。许可证文件为新增数据,不作法律审查。 +- `src/local_asr_service.rs`、`src/model_store.rs`、`src/local_asr_catalog.rs`:本地 activation、prepared/installed 边界、文件下载取消、迁移、旧模型清理、storage relocation、native adapter 分流。 +- `src/coding_agent.rs`、`src/less_computer.rs`:Beta gate、settings admission 锁序、run/capture/test ownership、取消与 cleanup;没有执行外部 Coding Agent CLI。 +- `src/credentials.rs`、`src/provider_rules.rs`、`src/provider_service.rs`:retired channel 投影、experimental channel 注册、本地验证 probe 不启动推理、provider defaults。 +- `src/settings.rs`、`src/shared_types.rs`、`src/domains.rs`、`src/dictation_context.rs`、`src/lib.rs`:新开关默认值与反序列化、Host 能力、接口及 TextStream batch preference 例外;未扩展审查首录音链路。 +- `src/cloud_sync_e2ee_documents/{registry.rs,tests.rs}`、`src/cloud_sync_e2ee_store/{native.rs,tests.rs}`:device-local opt-in 排除同步及 rollback 状态保留。 +- `examples/export_provider_descriptors.rs`。 +- `tests/{local_asr_contract.rs,macos_asr_retirement.rs,auxiliary_voice_lifecycle.rs}`:已有生命周期断言及上述缺口;Rust 测试没有运行。 + +### Tauri / native(差异+追踪;新增运行时/installer/protocol 完整) + +路径前缀 `openless-all/app/src-tauri/`: + +- `src/asr/local/experimental.rs`、`experimental/{gemma.rs,gemma.swift,textstream.rs,acceptance.rs}`。 +- `src/asr/local/experimental/textstream/{install.rs,install-guardian.sh,protocol.rs,worker.py,test_worker.py}`;`requirements.lock` 仅核对被 `--require-hashes` / `--only-binary` 使用的边界,未联网审计全部 wheels。 +- `src/asr/local/native_model_receipt.rs`、`src/asr/local/foundry_runtime.rs`、`src/core_adapters.rs`:Foundry download/restart、receipt inspection、冷删除、runtime release 与实验 service 绑定。 +- `src/asr/local/{mod.rs,models.rs,test_run.rs}`;旧 native 文件删除及其 router/build 引用清理:`apple_speech_provider.rs`、`cache.rs`、`local_provider.rs`、`mlx_qwen_engine.rs`、`mlx_set_metallib_path.cpp`、`mlx_worker.rs`、`qwen_engine.rs`、`qwen_ffi.rs`、`whisper_provider.rs`;未重新审计被删除代码内部全部算法。 +- `src/commands/{credentials.rs,experimental_asr.rs,foundry_asr.rs,mod.rs,qa.rs}`:host/窗口边界、provider retirement、IPC 注册与 file-only 状态。 +- `src/{lib.rs,tauri_coordinator_host.rs,tauri_events.rs,hotkey.rs}`、`src/coordinator/hotkey_loops.rs`、`src/persistence/credentials.rs`:window gate/main-thread 调度、隐藏、AppKit 系统事件转换、权限与设备级迁移;未触发系统权限弹窗。 +- `backend-tests/tests/{native_model_receipt.rs,textstream_protocol.rs}`:测试文件挂接检查。 +- `Cargo.toml`、`Cargo.lock`、`build.rs`、`Info.plist`、`tauri.conf.json`、`tauri.macos.conf.json`、删除的 `tauri.macos-mlx.conf.json` 与 vendor gitlinks。 + +### egui / Linux(全部变更块+必要调用方;不运行原生窗口) + +路径前缀 `openless-all/app/linux-egui/`: + +- `src/main.rs`:QA popup generation、queued event 隔离、状态 delta、失败投递、窗口重开/关闭、选区预览与 QA 共用窗口、tray 生命周期;逐项对照新增回归测试。 +- `src/{backend.rs,i18n.rs,lib.rs,popup.rs,settings.rs}`、`src/linux_app/settings_save.rs`。 +- `src/ui/frontend/{mod.rs,settings.rs,siri_gl.rs,view_model.rs}`:Beta 显隐/撤销、快捷键显示、CPU glow 非有限值修复及 tessellation 测试。 +- `examples/headless_host.rs`、`tests/host_contract.rs`。 + +本轮未找到可确认由这些变更造成的 QA/选区“无输出”回归;不能将这一结果解释成已完成真实选区捕获、fcitx5、X11/Wayland 或付费多模态服务验收。Less Computer popup 未在 supervisor polling 列表中是基线已存在现象,未将它作为本分支新问题报告。 + +### 构建 / 合同 / 脚本 + +- `.gitmodules`、`.github/actions/cache-macos-mlx/action.yml` 删除;`.github/workflows/{android-apk.yml,ci.yml,release-tauri.yml}` 差异。 +- `openless-all/app/contract/backend-2.0.json`。 +- `openless-all/app/scripts/build-mac.sh`、`check-macos-audio-usage-description.sh`、`macos-dmg-layout.py`、`sync-egui-i18n.mjs`、`measure-startup.mjs`;后者仅为 browser benchmark,未当作 native 启动性能证据。 +- 新增/变更的 `desktop-version-contract.test.mjs`、`local-asr-lifecycle-contract.test.mjs`、`macos-local-asr-retirement-contract.test.mjs`、`macos-audio-usage-description-contract.test.mjs`、`macos-optional-runtime-bundle-contract.test.mjs`、`macos-runtime-minimum-version-contract.test.mjs`、`macos-build-cache.test.mjs`、`shared-backend-wire-contract.test.mjs`、`less-computer-opencode-contract.test.mjs`、`encrypted-sync-ui-bridge.test.mjs`、`frontend-test-runner.test.mjs` 的本次变更;删除的 macOS MLX staging/toolchain/cache 脚本及其引用。 +- 对照 `docs/local-asr-lifecycle.md`、`docs/experimental-local-asr.md`、`docs/local-asr-verification.md` 的边界说明。Foundry 老安装无 receipt 必须一次显式 Download/verify + 重启是已有明确设计,未将该行为本身误报为 bug。 + +## 未验证与交接边界 + +前端视觉/交互和首录音链路由其他审查负责;本轮仅为论证上面的生命周期结论读取了少量相关前端/录音适配调用。未运行 Cargo 测试或构建、Windows/Linux 原生程序、真实实验模型、安装 Python/wheels、实际选区/麦克风/Accessibility 操作、DMG/签名/公证与任何收费服务;历史文档中“绿 CI/真实模型验收”没有被复述为本次独立执行结果。主 Agent 若修复,建议先补上述 owner / EOF-live-child / corrupt-receipt 三组小测试,再按对应平台做必要验证,不需要为本报告重新完整构建全部平台。 diff --git a/docs/reviews/2026-10-15/ui.md b/docs/reviews/2026-10-15/ui.md new file mode 100644 index 000000000..8fc2fb5e5 --- /dev/null +++ b/docs/reviews/2026-10-15/ui.md @@ -0,0 +1,129 @@ +# PR #1197 UI 只读审查 + +## 结论与审查边界 + +原始 HEAD `0947c05a5b91` 存在能解释用户反馈的具体实现问题:普通页面的 exit 被删掉,Settings 只淡出内层却保留不透明外层,新的全窗 Settings 覆盖原生拖动条,实验 ASR 的每秒轮询会触发旧 SelectLite 抢回选中项,ShortcutRecorder 直接替换内容造成高度跳变。主 Agent 当前 worktree 已处理这些主路径的大部分;本报告末尾另列追加检查发现的裸 Modal 与交付前复核状态,不能把原始分支问题当成尚未修复的当前状态。 + +审查基线为 `0454dc56..0947c05a`,覆盖 `openless-all/app/src` 与 `e2e` 的变更清单、交互实现和新增测试;着重检查 UI 提交 `8c32db9e`、实验 ASR 提交 `16a73b9d` 及后续测试修正。行号未注明“当前 worktree”的均指固定 HEAD,可用 `git show 0947c05a:` 核实。仓库一开始就有主 Agent 的未提交修复,因此动态验证使用 `git archive 0947c05a` 导出的临时副本,没有改仓库源码、设置、凭据或安装依赖。 + +验证使用已安装 Playwright 的 headless Chromium/WebKit、1300×835、zh-CN、macOS UA;部分场景显式将临时浏览器的界面大小设为 100%。Vite 仅按需转换源文件,没有完整构建或运行全套 e2e;浏览器请求限制在本机临时服务器,`isTauri=false`,没有实际录音、麦克风捕获、OAuth、云同步或收费 API。新增 PNG 是静态视觉参考,不能证明动态过渡和原生标题栏行为。 + +## 可核实问题与最小修复边界 + +### 1. P2:普通页退出没有淡出,直接拆掉旧节点 + +- **位置**:`openless-all/app/src/lib/motion.ts:246-253`;调用端 `src/components/FloatingShell.tsx:166,533-534`。 +- **触发**:鼠标从 History 切回 Overview,或切换其他主导航页。 +- **原因**:`usePageTransition` 直接 `return requested`,只调用 `useContentMotion`;外层又是 `key={displayTab}`,旧页在本次 React commit 立即卸载,不存在旧内容的 exit 阶段。 +- **引入提交**:`8c32db9e` 明确删去基线的 displayed 状态和 PAGE_EXIT_MS 流程。 +- **已验证**:两个浏览器从 History→Overview 均只创建新页的 `[opacity:0, translateY(6px)]→[opacity:1, none]`、240ms 动画,没有旧页 exit;没有 pageerror。 +- **最小建议**:在共享 hook 保留 outgoing displayed,先对旧页做短 opacity exit,再切最新请求页并 opacity enter;用代次/取消处理快速 A→B→A,保留 reduced-motion 分支,不需要引入路由库或重写全部页面。当前 worktree 已恢复这种 hook,但本审查未对新实现重新跑浏览器。 + +### 2. P2:Settings“关闭”看似没淡出,是外层不透明遮挡 + +- **位置**:`src/components/SettingsModal.tsx:94-95,332-344`。 +- **触发**:Settings 已打开,用返回或右上角关闭退出。 +- **原因**:`overlayRef` 的 `useOverlayMotion(..., enabled=false)` 被关闭,但 workspace 被改成全窗不透明 `background:var(--ol-surface)`;只有 inner surface 在淡出。底下主页面一直被外层实色挡住,180ms 到时整层卸载才突然露出。 +- **引入提交**:`8c32db9e`。 +- **已验证**:Chromium/WebKit 中 inner opacity 已降到约 0.001,outer 仍是 `opacity:1`、`rgb(28,28,31)`;随后 outer/inner 同时消失。这是合成结果问题,不是没有调用 exit,也不是需延长退出时间。 +- **最小建议**:只对整个不透明 workspace 做 opacity enter/exit,消除内外层重复动画;继续复用 `useExitMount` 维持生命周期。当前 worktree 已改为整体淡出。 + +### 3. P2:Settings 顶部覆盖拖动条;重复关闭入口确实存在,但自绘红绿灯归因不对 + +- **位置**:`src/components/SettingsModal.tsx:332-344,411-428,601-612`;`src/components/WindowChrome.tsx:67-77`。 +- **触发**:macOS 主窗口打开 Settings 后拖顶部空白;观察 sidebar“返回”和右上角“关闭”。 +- **原因**:全窗 Settings 的 z-index 50 覆盖同为 50、但先绘制的 WindowChrome drag region;sidebar 顶部 padding 仅留出视觉位置,并未赋予拖动能力。新增 sidebar 返回与原右上角关闭都调用 `closeSettings`,属于重复出口。 +- **引入提交**:覆盖与重复入口由 `8c32db9e` 的全窗设置/新增返回引入;WindowChrome 旧的 z-index 本身早已存在。 +- **已验证**:两个浏览器 `elementFromPoint(150,12)` 在打开前命中 drag region,打开后命中 Settings `ASIDE`,`closest('[data-tauri-drag-region]')` 从 true 变 false。 +- **最小建议**:保留 WindowChrome/原生标题栏,将专用拖动条置于全窗设置之上并按 zoom 换算固定原生高度,避开搜索/按钮;桌面保留一个 Settings 返回出口,子页返回不是关闭设置,应单独保留。当前 worktree 已提高拖动条并删掉右上角重复关闭。 +- **重要纠正**:HEAD 的 SettingsModal/WindowChrome **没有自绘三色灯**,其顶部也没有三色灯 DOM。自绘灯在 `src/pages/LessComputerPanel.tsx:598-626`、`src/pages/less-computer-panel.css:227-262`,源于基线前的 `51f6f4cf`,不应报成 `8c32db9e` 新增的 Settings 红绿灯。替换 LessComputer 时应仅收口该窗口的 native decorations/CloseRequested→既有 dismiss 路径,不能只删前端灯造成无关闭入口;这部分 native 实际行为未验证。 + +### 4. P2:长菜单被强行滚回顶部,真正问题在旧 SelectLite 的高亮初始化 + +- **位置**:`src/components/ui/SelectLite.tsx:184-202,209-217`;`src/pages/settings/MicrophoneSelect.tsx:65-79,100-119`;新放大因素为 `src/pages/settings/ExperimentalAsrSection.tsx:33-58` 和 `src/components/SettingsModal.tsx:76`。 +- **触发**:打开超过视口高度的列表,把鼠标移到较下方选项并继续浏览;父组件有 ASR 状态轮询或麦克风电平更新。 +- **原因**:`filteredOptions` 随 options identity 更新,effect 每次都把 highlight 改回 selectedIndex;另一个 effect 又把 highlight 对应项滚进视口。麦克风电平每 120ms 改 level 并重新生成 options。分支又把无条件每秒 `setModels(next)` 的实验 ASR hook 放在整个 SettingsModal 中,即使实验未打开也会触发子树 render,扩大旧故障范围。 +- **引入提交**:高亮重置的原始代码为基线前 `66915405`;电平刷新也是旧实现,**不是新 Base UI Select 的 bug**。全 Settings 的每秒刷新由 `16a73b9d` 新增。 +- **已验证**:临时 fixture 使用原始真实组件、50 个选项、真实 `useExperimentalAsr(true)` mock 轮询。两个浏览器 Legacy Select 均从 scrollTop 700 在约一秒内回到 4;相同 owner/相同重建 options 下的新 Base UI Select 稳定保持 696。WebKit 的 mock 麦克风列表从 700 下一次 120ms 采样变 4;Chromium 在首采样前就已经回到 4。三种场景都没有 pageerror。 +- **最小建议**:普通 selects 复用已有 `@base-ui/react/select` 包装;需要搜索的复用同依赖的 Combobox,补齐现有 disabled/trailing/alwaysVisible/onOpenChange 契约即可。不要再造 wheel 拦截或二次惯性滚动;至少必须把“打开/筛选/值改变初始化”与“选项内容刷新”分离,不要在 hover 或电平更新时 scrollIntoView。当前 worktree 已用 Select/Combobox facade 统一,方向与上述对照验证一致;新 facade 的全部调用契约未逐一动态验证。 + +### 5. P2:100% 返回录音页的“抽搐”有两个机制,不应笼统称为 zoom 错误 + +- **位置**:`src/components/SettingsModal.tsx:121,206-208,731-743`;`src/lib/motion.ts:165-192`;`src/pages/settings/RecordingInputSection.tsx:57-64,150-163`;`src/pages/settings/tabs.tsx:54-74`。 +- **触发**:100% 界面大小,Settings 从其他分类返回“录音与输入”,尤其返回时原滚动位置较深或 native capabilities 返回较慢。 +- **机制**:分类 `key` 使表单重新挂载;caps 每次从 null 起步,桌面热键/启动/远程输入部分要等 effect 补挂;scrollTop 在 passive effect 才归零;整个新内容又先归零透明度再沿 6px 平移,产生分数字素重采样。100% 时 zoom=1,仍会经历上述过程。 +- **归因边界**:caps 从 null、passive scroll reset 和 settings 的 useContentMotion 在基线已有(例如 `1ea467a0`、`30e580fc`、`6f9af895`);本分支加入新的内容/新快捷键结构,但没有证据能把用户机器的全部重复闪烁单独归因给本次 fontScale 改动。 +- **已验证**:两浏览器 100% 返回页会重建一个 240ms opacity/translateY 动画,采样到 `translateY(4.29px)` 等分数位移;采样开始后页高分别稳定在 1426/1418,没有复现持续多次抖动或异常。因此这里只确认跳入机制,**未宣称已复现真机 WKWebView 的全部闪烁**。 +- **最小建议**:初始化复用 `getCachedPlatformCapabilities`,scroll reset 放 layout effect,页面内容只做 opacity 过渡;不要用调整默认缩放来掩盖。当前 worktree 已做这些主路径修复。native 首帧、macOS 系统字体栅格化及真机100%往返仍需主 Agent 的运行验收。 + +### 6. P2:快捷键出现重复录制入口,提示/错误内容直接顶开布局 + +- **位置**:`src/components/ShortcutRecorder.tsx:343-365,386-410,429-433`;`src/styles/refinements.css:261-272`。 +- **触发**:点击 Fn 等按键显示进入录制,或展开箭头菜单;录制失败出现错误文案。 +- **原因**:按键按钮和展开后的“录制快捷键”都绑定 `startRecording`;录制提示使用 `recording ? ... : ...` 直接替换整个 idle 区域,并没有用新增 Collapse 包住。error 也是裸条件内容。 +- **引入提交**:`8c32db9e` 把只显示的按键改成可点击,同时保留菜单同一动作,并移除了旧 AnimatePresence 过渡。 +- **已验证**:原始 HEAD 两浏览器的单个 recorder 高度均从 38px 直接变为 59px;内容是“请按下快捷键组合… / Esc · 取消”。这是快捷键捕获的 mock,不是音频录制。 +- **最小建议**:保留按键本体为单一录制入口,reset/disable/native dictation key 是独立动作;仅为提示/错误加稳定容器和高度插值/复用 Base Collapsible,保留现有 Fn 原生事件、左右修饰键、校验与保存流程,不重写热键协议。`Fn` 和 `MacDictationKey` 是不同 binding,不能把原生听写键选项当作同义动作删除。 +- **当前 worktree 余项**:展开箭头/重复录制已删;但 `src/components/ShortcutRecorder.tsx:302-316` 仍直接条件替换录制区,错误也仍直接条件挂载。未发现其外层已有高度插值,建议作为剩余“提示平滑撑开”复核点;当前新布局未重新测高度。 + +### 7. P2:实验下载生命周期与窗口生命周期混在一起,0% 取消要区分前后端 + +- **位置**:`src/components/SettingsModal.tsx:76,764-770`;`src/pages/settings/ExperimentalAsrSection.tsx:20-59,60-76,155-174,208-222`;`src/components/GlobalDownloadProgress.tsx:33,51-153`。 +- **触发**:实验模型下载尚未有首字节、关闭 Settings、再打开或想取消。 +- **原始实现**:controller 只由 SettingsModal 持有,关闭后轮询和可见控制一同停止;GlobalDownloadProgress 只支持 qwen3/sherpa/foundry,不支持三个 experimental provider。因此下载继续时主窗口没有统一进度和取消入口,恢复后只能等下一次 status 读。下载进度/错误段落裸条件插入,状态和 Toggle 放进 `SettingRow` 默认 `justifyContent:flex-start` 的同一行,状态不是稳定靠右。 +- **引入提交**:`16a73b9d` 新增实验控制器/页面,却没有接入已有 global download surface。 +- **0% 的证据边界**:`downloadPercent` 会 floor,收到少量字节也可能显示 0%。前端 `toggle(false)` 并不检查 downloadedBytes/percent,也没有把 downloading 状态永久禁用;只有 pending/removing 等状态禁用。不能仅凭此 UI 代码断言 native 0% 请求“不允许取消”,或保证禁用已终止下载/释放进程;这些属于主 Agent 的后端审核。 +- **最小建议**:一个共享 controller/store 同时供 Settings 与全局下载区域读取;取消复用 `setExperimentalAsrEnabled(model,false)`,区分等待首字节与已开始的确定进度,显示 pending/error,不把“关闭设置”隐式等同于取消。复用现有全局区域即可,不新增独立下载框架。状态组靠右、Toggle 最右,进度保留固定槽,错误复用 Collapse。 +- **当前 worktree**:已看到 `src/lib/useExperimentalAsr.ts` 的共享 useSyncExternalStore、去重更新与 generation 清理;GlobalDownloadProgress 与 Settings 已共同使用,状态右对齐、首字节 indeterminate、错误 Collapse 亦已落地。只做静态复核,未验证真实模型下载/取消、应用重启、native worker 释放或取消残留。 + +### 8. 要求差距而非新回归:默认 110% 与可用弹层右优先 + +- **位置**:`src/lib/fontScale.ts:15-28`;`src/components/Tooltip.tsx:120`;`src/components/ui/Select.tsx:30-35`。 +- **原始行为**:没保存设置的 Windows 用 large/110%,macOS 用 medium/100%;`Hint` 显式强制 `placement="top"`,覆盖 Tooltip 本身默认 right;新 Base Select 未设 side,因此也不是右优先。 +- **归因**:字体默认早于范围(Windows 分支 `9e62dfe2`、medium fallback `48f21341`);Hint 的 top 和新 Select 为 `8c32db9e`,且原 e2e 故意验证 top。这两项应按用户新要求修改,不应包装成比例计算回归。 +- **最小建议**:仅无效/不存在存储值时默认 large,尊重显式90/100%;Hint/Select/Combobox 用 Base UI Positioner `side="right"`,继续依赖 collision handling 在无空间时翻转,保留 portal 的 zoom compensation。当前 worktree 已做。 + +## 补充:所有主要退出生命周期调用点(协调者追加要求) + +共享 `Modal` 的 closing 默认 false,只画 exit 并不会替调用者延迟卸载。以下缺口已通过协调消息发送,主 Agent在本报告写作期间继续修复;表中同时记录原始证据和交付前的最新静态状态。 + +| 原始调用点(HEAD) | 触发/实际卸载原因 | 引入归因 | 交付前 worktree 复核 | +|---|---|---|---| +| `src/pages/settings/ExperimentalAsrSection.tsx:242,257` | acknowledge 后 ready=[],直接 return null | 新增 `16a73b9d` | 已改 `:170-171` 为无条件 PresenceModal + open=ready.length>0,保留最后 render closure;未动态复测。 | +| `src/components/CloudSyncSetupPrompt.tsx:114-116` | 用户点稍后/背景/打开设置后 open=false,直接 return null | 基线前 `9731a7f0` | 已改 `:116` 为 PresenceModal。原注释的 Core eligibility revoke 即时撤销语义应保留(动画中不可交互),未验证 native revoke。 | +| `src/pages/settings/CloudSyncSection.tsx:873-879,1435` | `dialog && `,closeDialog 置空;换 kind 也重建 | 基线前 `9731a7f0`/`62e09f1f` | 已在当前 `:878` 用 ExitPresence 保留 SyncDialog,`:1442` 透传 closing;`:865` 的 Github 登录亦有 ExitPresence。业务 busy/恢复流程没有动态验证。 | +| `src/pages/Marketplace.tsx:1600-1601` | showLogin=false 立即卸载 GithubLoginModal,未传 closing | 基线前 `52c49fcf`/`4519f19c` | 已在相同调用点改 ExitPresence 并传 closing;不重挂 OAuth flow 的运行行为仍需验证。 | +| `src/pages/settings/MarketplaceSection.tsx:126-128` | 同上 | 基线前 `52c49fcf` | 当前 `:127` 已 ExitPresence + closing。 | +| `src/pages/LessComputerPanel.tsx:235,796-799` | login.mounted 保留,传 login.closing | 已有实现 | **原本已覆盖**;不要把 Github 所有调用点一概列成缺陷。 | +| `src/components/CloudSyncSetupPrompt.tsx:198` 的 CloudSyncWelcome、`src/components/AutoUpdate.tsx:340`、`src/pages/Marketplace.tsx:923,1125,1277` | PresenceModal 缓存退出内容 | 已有实现 | **原本已覆盖**。 | +| Settings、MobileMoreSheet/MobileStyleSheet、Vocab ModalShell | useExitMount + closing | 已有实现/本轮修复 | 有生命周期,应检查真正合成/内容保留,不是再包一层动画组件。 | +| `src/pages/settings/ChannelList.tsx:1191` 的无 host fallback Modal | ChannelDialog 原主路径在 Settings 使用 right-pane host;fallback 为裸 Modal | 基线前 | Settings 当前主路径非此 fallback,不作为本轮主修边界;若独立复用 fallback,需要与 caller 一起保留 closing。 | + +另一个已反馈遗漏:原始 `ExperimentalAsrGuidance` 没有 Escape handler,共享 Modal 原来只处理 Tab,Settings 又对内层 dialog 提前返回,因此 Guide 原先不支持 Escape 关闭。**交付前复核已看到** `src/components/ui/Modal.tsx:66-73` 新增 top-most、defaultPrevented、isComposing、inert 判断并通过 closeRef 调 onClose,此静态缺口已补;子 Select/Combobox 消费 Escape 后不能同时关闭 Guide/Settings 的行为未动态复测。当前 ExitPresence/PresenceModal 只做保留最后 render closure 和透传 closing,改动边界较小。 + +## e2e 能证明什么、缺什么 + +- `e2e/motion.spec.ts:140-181` 验证的是 inner Settings surface 存在 exit、起始 opacity 连续,未检查 opaque workspace 是否跟着淡出,所以能漏掉问题2。`:126-140` 的快速返回只检查最终可见/inert,而且按键 Enter 使原始 interaction mode 跳过动画,不能证明鼠标导航有完整退出。 +- `e2e/settings-feedback.spec.ts:99-135` 只打开 7 个字体选项,验证键盘 End/Enter 与锚点,没有长列表 wheel + 高频 options refresh。新增回归应使用50项,先 hover下部并滚动,跨越120ms电平或1000ms轮询,确认 scrollTop 不被 selectedItem 抢回。 +- `e2e/macos-experimental-asr.spec.ts:94-111` 的关闭再打开测试验证 mock 状态/指导恢复,但 mock 仅在 `getExperimentalAsrStatus` 中按 Date.now 推导进度;它不能证明 native 下载在关闭期间继续、0字节可取消或 worker 退出。 +- `e2e/refinements.spec.ts:266-285` 只验证 ExpandableText 最终高度,没有检查 recorder 条件切换或提示关闭时每帧高度,不能证明“平滑撑开”。 +- 原测试中有按旧行为刻意写死的断言:`motion.spec.ts:118-119` 要求 Settings y>16/scale<.97;`settings-feedback.spec.ts:53-55,132-134` 要求提示在上方;多文件桌面设置按名称“关闭”退出。用户已要求纯淡出/右优先/单一返回入口,修复后应同步这些 oracle,不应为了绿测恢复旧交互。 +- `0947c05a` 自然观察入口完成、`edff8913` 跨平台 UI oracle 修正是测试可靠性改进;它们没有解决以上生命周期/父层遮挡问题。 + +## 动态证据摘要与未验证项 + +独立固定 HEAD 临时浏览器结果: + +| 探针 | Chromium | WebKit | +|---|---|---| +| Settings 顶部命中 | drag=true → false/ASIDE | 相同 | +| Settings 关闭 | inner≈0.001,outer=1且不透明,然后一起卸载 | 相同 | +| 100% 分类返回 | 240ms 透明+6px平移;观察后页高1426稳定 | 同类动画;页高1418稳定 | +| Shortcut idle→recording | 38px→59px,无高度过渡 | 相同 | +| 主导航 History→Overview | 只有 enter,无 outgoing exit | 相同 | +| 50项旧 SelectLite + 实验轮询 | 700→4 | 700→4 | +| 50项 Base UI Select + 同样轮询 | 696保持不变 | 696保持不变 | +| 50项 mock 麦克风 | 首采样已回4 | 700→4,下个120ms采样发生 | +| pageerror | 无 | 无 | + +当前 worktree 的修复只做静态比对;没有把旧 HEAD 的探针结果冒充成修复后验证结果。未验证:Tauri 真机拖动/三色灯位置和 CloseRequested 行为、100%/110%系统字体反复栅格化、真实实验模型首字节等待/取消与子进程回收、OAuth 和云同步完整生命周期、所有搜索 Select 契约以及新 PNG 基线。临时服务器、快照和探针文件已清理;可复现方法是将 HEAD 导出到独立临时目录,用既有 Vite/Playwright 启动 mock 浏览器,记录 Element.animate 的目标/关键帧、elementFromPoint 和 listbox.scrollTop;复核不依赖任何真实服务。 diff --git a/docs/reviews/2026-10-15/voice.md b/docs/reviews/2026-10-15/voice.md new file mode 100644 index 000000000..8542e5740 --- /dev/null +++ b/docs/reviews/2026-10-15/voice.md @@ -0,0 +1,92 @@ +# macOS 语音链路只读审查 + +审查仓库:`/Users/trip/orca/workspaces/openless/修复-Matel存在的部分问题`。原始审查 HEAD:`0947c05a5b911aebce7a750c3045160859428814`;PR 范围 `0454dc56..0947c05a`。以下原始问题行号以该 HEAD 为准;末尾另评主 Agent 尚未提交的修复。时间均明确标注 UTC 或 +0800。 + +## 结论 + +**P1:已定位与本机配置及最新日志吻合的首录音永久 Processing 根因。** 显式指定麦克风名称时,CPAL 0.15.3 的 macOS disconnect listener 形成强引用环,音频回调继续持有队列 sender;OpenLess 新增音频处理 worker 却依赖所有 sender 析构才退出,停止录音会永久卡在 join。此缺陷由 `44972015`(#1194)引入,已经存在于审查基线,不能归入本次 UI/实验 ASR 的新增回归。 + +另确认两个独立缺陷:Qwen/Stepfun Realtime 启动错误清理自锁(历史问题),以及 TextStream 等待进程退出时丢失可取消句柄(`16a73b9d` 新增)。当前本机偏好使用 DashScope 多模态,实验模型全部关闭,后两者不解释最新故障窗口。 + +## 1. P1:指定麦克风的回调 sender 被 CPAL 引用环保留,停止时永远等待 EOF + +**主定位**:`openless-all/app/src-tauri/src/recorder.rs:1515`、`:1871`、`:1906`。 + +**触发条件**:macOS,选择一个非空名称的麦克风(即使名称恰好是当前系统默认内建麦克风),开始并停止普通听写、设置页电平预览或其他共用 Recorder 的会话。无需 ASR 服务报错,分步与多模态均受影响。 + +可复核因果链: + +1. `recorder.rs:1676-1682` 按名称从 `host.input_devices()` 返回设备。锁定依赖为 `openless-all/app/src-tauri/Cargo.lock:1065-1066` 的 CPAL 0.15.3。 +2. 本机 CPAL 源码 `~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/cpal-0.15.3/src/host/coreaudio/macos/enumerate.rs:84-86` 将枚举设备标记为 `is_default=false`;只有 `default_input_device` 创建的 handle 标记为 true(同文件 `:114-116`)。 +3. 该依赖 `src/host/coreaudio/macos/mod.rs:616-617` 为非 default handle 安装 disconnect listener;`:456-468` 的 listener closure 捕获 `stream.clone()`,而 StreamInner 又拥有 listener,形成 `StreamInner -> listener -> Stream(Arc)` 强引用环。 +4. OpenLess 回调在 `recorder.rs:1780-1789` 捕获 `frame_tx`。`drop(stream)` 不能解开依赖的环,因此回调及该 sender 并未析构。`:1512` 的 `cpal Stream dropped (mic released)` 只说明外部 stream handle 被 drop,不能证明所有回调资源或处理线程已结束。 +5. `:1513-1514` 只释放 OpenLess 自己的 sender;worker 在 `:1871` 等待 `receiver.recv()` 返回 disconnected,`:1904-1906` 无退出指令地 join,永远无法返回。 +6. `core_adapters.rs:2502-2514` 的停止任务因此无法返回;`crates/openless-core/src/cloud_providers.rs:2191-2198` 的 Omni finish 要先等待录音 stop,后面才调用多模态请求。用户能看到 Processing,但请求尚未发出。 + +**无电平的相关扩散路径**:`src-tauri/src/commands/misc.rs:199-205` 停止预览时持有 `MicrophoneMonitorState.operation` 并同步执行 `recorder.stop()`;卡住后再次预览和普通听写都会等这把锁。`src-tauri/src/core_adapters.rs:2648-2653` 在异步任务中同步取得该锁,或直接 `preview.stop()`,此时新 Recorder 尚未启动,因而可能表现为无电平并占住 Tokio worker。该路径是源码确认的后续影响;未通过真人操作确认用户具体经历过哪次预览交接。 + +**引入记录**:`git blame` 将 `processor.finish()`、worker 的 `recv`/`join` 指向 `44972015`,提交时间 `2026-10-14 17:51:39 +0800`,`feat(audio): add lightweight microphone noise suppression (#1194)`。`git merge-base --is-ancestor 44972015 0454dc56` 成功;基线提交时间是 17:51:53,故它在基线前。按名称选设备代码更早来自 `ec773f08`(#306);引用环本身位于锁定第三方依赖。 + +**修复建议**:worker 应拥有显式、FIFO 的 Shutdown 结束协议,在停止接收原生帧后排空已入队帧并退出,不能把 native callback 析构当作 worker 结束条件。覆盖“仍保留一份 native sender、队列已有 PCM 时,stop 有界完成且已入队帧不丢”的纯内存回归;同时覆盖设置预览停止再开始听写。不要靠丢弃 join 或提前释放 voice gate 绕过清理。 + +**验证**:已读锁定 CPAL 实际源码,并用标准库 Rust 2021 探针模拟相同的 Arc/closure/sender 所有权结构。drop 外部 Stream 和 sender 后 worker 100ms 内仍等待;断开模型中的引用环后立即结束。另探针确认保留 native sender 时 FIFO Shutdown 仍能排空帧并退出。未实际打开麦克风或调用 CoreAudio;因此是源码、隔离复现和本机条件吻合的高可信根因,非真实设备端到端实测结论。 + +## 2. P1(历史):Qwen/Stepfun Realtime 启动失败时重入同一非重入锁 + +**定位**:`crates/openless-core/src/asr/qwen_realtime.rs:307-308`,其 `cancel()` 再锁于 `:375`;同类代码 `asr/stepfun_realtime.rs:375-376`,再锁于 `:530`。这些路径均位于 `openless-all/app/` 下。 + +**触发条件**:对应 provider 在 `session.updated` 前收到服务端 error/连接关闭等启动失败。`if let Some(error) = self.state.lock().session_start_error.clone()` 的临时 guard 在成功匹配分支仍存活;分支中 `self.cancel()` 再次锁定 state,自锁。网络超时不能解除一个同步 Mutex 阻塞。稳定转写在停止后启动 ASR,因此可表现为永久 Processing;非稳定模式卡 Starting,stop 等待启动完成。 + +**引入记录**:Qwen 首次为 `9ed34dab`(2026-07-12,#775);Stepfun 为 `17fe67c5`(2026-07-18,#835)。二者均早于本审查基线,当前本机多模态路径不经过它们。 + +**修复建议**:先在独立语句/作用域复制 `session_start_error`,释放 guard 后再判断并 cancel;用假 WebSocket/无网络状态机回归覆盖启动前错误和关闭,确认返回错误、释放会话而非挂住。 + +**验证**:无依赖 Rust 2021 探针在 if-let 分支中 `try_lock` 得到 WouldBlock,独立 snapshot 后能够取得锁;与仓库 `edition=2021` 一致。未运行真实 provider 请求。最新日志无该 provider 的直接命中证据,不能当作本次用户故障原因。 + +## 3. P2(本分支新增):TextStream 完成等待取走 Child,超时/取消无法终止仍存活进程 + +**定位**:`openless-all/app/src-tauri/src/asr/local/experimental/textstream.rs:57-64`,调用于 `:367-368`,取消路径 `:45-55`、`:443-455`。 + +**触发条件**:worker stdout EOF,但 worker 进程尚未退出(例如原生库或解释器退出阶段仍运行)。`completed()` 先从 `Mutex>` take 句柄,再在局部变量中 `wait()`;并发 `stop()` 见 None 即返回。180 秒 finalization timeout、Esc、切换/关闭渠道均可能报告清理结束,而本地进程仍存活;`running()` 也会误认无进程。不是当前全部关闭的实验模型所致。 + +**引入记录**:`16a73b9d`(2026-10-15,实验 ASR 提交)首次新增,属于 `0454dc56..HEAD`。 + +**修复建议**:在完成等待期间保留统一可终止的进程所有权,例如短锁 `try_wait` 轮询,由 stop 独占取走并 kill/wait;不要在持全局 mutex 的阻塞 wait 中等待退出,也不要先取走 Child 使取消失去目标。测试 EOF-before-exit 与取消/超时并发,断言子进程已真正被回收。 + +**验证**:从仓库提取原始 `Process` 实现放入临时独立 Rust 探针,以仅关闭 stdout 后等待的 shell 子进程模拟 worker。已验证 completed 取走句柄后 stop 返回但 child 仍存活;探针自行 kill 并 reap 其唯一测试进程。未运行 Python 模型、SSE 服务、下载或真实推理。 + +## 本机证据与排除项 + +协调 Agent 追加授权后,只对白名单非秘密偏好字段做只读检查,未读 Keychain。`preferences.json` mtime 为 `2026-10-15 20:25:53 +0800`:`pipelineMode=multimodal`、`multimodalPipelineEnabled=true`、`activeOmniProvider=dashscope-omni`、`stableTranscriptionEnabled=false`、`microphoneDeviceName=MacBook Air麦克风`、Fn toggle、`lowLatencyDictationEnabled=false`、`audioEnhancementEnabled=true`、静音自动停止和录音静音均关闭。`experimental-asr.json` 的三个 enabled 均 false。偏好快照不是每一历史会话的完整运行态证明,但其修改时间先于以下最新问题窗口。 + +只读检查 `/Users/trip/Library/Logs/OpenLess/openless.log`(当时末行 13439、mtime 20:38:43 +0800)与滚动日志;未保存正文、转写、请求内容或任何秘密。 + +| 原日志位置 | UTC 时间 | 可用事实 | +|---|---|---| +| `openless.log:13264-13273` | 12:25–12:26 | 麦克风权限探针均 granted | +| `openless.log:13274,13277,13278` | 12:26:44 → 12:26:49 | 首次 recording 胶囊、输入设备启动、Stream dropped;之后未见 Omni POST | +| `openless.log:13396,13399` | 12:37:00 | recording 胶囊与输入设备启动;退出前未见对应 stream drop | +| `openless.log:13434,13435` | 12:38:36 → 12:38:38 | 再次输入设备启动与 Stream dropped;仍无后续 Omni POST | +| `openless.log:12328,12329` | 00:07:47 | 一次明确 watchdog 回调停止 4 秒,随后释放原生流;此记录更早,不能证明最新会话也是硬件回调停顿 | + +`recorder.rs:1655-1660` 在 `stream.play()` 成功后才打印 inputDevice,因此有此日志可证明 play 返回成功。`omni.rs:320-326` 在发送多模态请求前固定打印 `[omni] POST`,最新故障窗口中没有该标签,与 stop 卡在 processor join 相容。 + +旧 `cb#` 日志来自 `recorder.rs:1987` 一带的旧 `process_callback`;`44972015` 新 worker 在 `:1870-1884` 不打印它。**缺少 cb# 不是无电平或未采集的证据。** 同样,native Stream dropped 不等于 audio processor 已退出。 + +最新两份 `DiagnosticReports/OpenLess-2026-10-14-193439.ips`、`...193133.ips` 为旧 `0.1.0 (1)` 的 AppleSpeechService.permissions / Swift executor SIGTRAP,均在本次 10 月 15 日窗口之前;不应拿来解释当前多模态 Processing。没有导出诊断报告全文。 + +## 已检查但未发现本分支直接新增的阻塞 + +- 普通 hotkey bridge、PipelineDictationEngine、SharedOmniDictationEngine、Recorder、cloud ASR/Omni 主体在 `0454dc56..HEAD` 未改动;hotkey_loops 的差异仅测试开关。8c32db9e 对 Capsule 的相关改动为字体样式,没有改听写状态流转。 +- macOS Fn system-defined 事件新增主线程同步分类,但 listener shutdown 不 join 主线程;本次未发现足以解释上述 stop 日志的新增循环等待。 +- Core stop 在读取 Starting 前登记 phase_changed waiter,并绑定具体 session ID;现有实现能防止 stop-before-ready 的普通丢唤醒/停错会话。启动未完成时 stop 等待是明确行为;provider 或原生资源清理挂住时该等待不会自行解决。 +- TauriTaskSpawner 明确使用 Tauri 共享 runtime;常规录音 start/stop 的原生重操作位于 spawn_blocking,未发现本分支引入新的私有 runtime 生命周期问题。预览交接中仍有同步 stop/锁进入异步任务的边界,已作为问题 1 的扩散路径说明。 +- 凭据退役投影、实验注册和通道释放的变更已读;当前实验全部关闭且多模态不经过本地 ASR 启动。本次未发现这些新增操作能解释 recorder 已 stop 却无 Omni 请求的确定锁链。 + +## 主 Agent 修复 diff 的独立复核 + +已只读复核主 Agent 在本次审查期间对 `recorder.rs` 的未提交修改:增加 `AudioProcessingMessage::Shutdown`、worker 自持 shutdown sender、finish 发 FIFO Shutdown 后 join,以及 `audio_processing_shutdown_drains_frames_with_a_retained_native_sender`(8 帧、2 秒、保留 native sender)回归。该退出协议与问题 1 的确定阻塞条件吻合,未发现此 diff 新增问题;可以使 preview->dictation 的资源交接返回。依赖自身的 AudioUnit/closure 引用环仍是另一个原生资源释放边界,显式 Shutdown 修复的是等待这个环析构导致的应用死锁。 + +我未运行主 Agent 的仓库回归,避免重复构建;其编译/测试结果由主 Agent 汇总。本审查运行的三个隔离探针全部通过,临时源码、可执行文件与探针子进程已清理。没有改仓库源码、配置、凭据,未安装应用、创建子 Agent、主动录音、调用收费 API 或完整构建;仅新增本报告。 + +仍未验证:真实设备开始/停止及设置预览交接的端到端表现、固定后的请求和最终输入、其他麦克风设备/拔插、CPAL 原生资源环是否另需依赖修复、实验模型真实退出阶段。报告中所有根因与修复结论均限定于上述证据。 diff --git a/docs/reviews/2026-10-16/release-readiness.md b/docs/reviews/2026-10-16/release-readiness.md new file mode 100644 index 000000000..86dd71cfa --- /dev/null +++ b/docs/reviews/2026-10-16/release-readiness.md @@ -0,0 +1,37 @@ +# beta6 / 2.0.0 发版前 Review + +## 范围与结论 + +复核 PR #1197 的 `0454dc56..0947c05a` 整合范围、此前语音/Core/UI 独立报告,以及当前工作树全部生产代码、回归用例、版本和发布链路改动。主 Agent 完成最终复核,没有重新创建 Agent 或定时提醒。 + +语音 worker 显式 FIFO Shutdown、Qwen/Stepfun 启动错误解锁、TextStream 可取消进程等待、ASR 活动 owner 退役与冷删除边界均已复核。设置页/主页面退出保留、Base UI Select/Combobox/Switch/Collapsible/Popover、快捷键保存/取消、共享实验下载快照与取消、云同步/登录弹窗的惰性退出,以及 macOS 原生窗口按钮/关闭事件均在本轮范围内。没有发现尚未修复的合并阻塞项;仍须以当前 PR HEAD 的云端 CI 作为合并门禁。 + +## 本轮发现并修复的发布问题 + +1. **日期构建号会使 Homebrew 下载 404。** 原脚本从 `v2.0.0-tauri` 推导 `2.0.0`,但实际文件名含 `2.0.0+build.YYYYMMDD`。新增 `resolve-release-cask-version.mjs`,从 ARM/Intel 实际文件名读取完整且一致的应用版本,并核对 metadata-free tag;cask URL 的 tag 部分去除 build metadata,文件名保留完整版本。拒绝缺失、多余、错版本、跨架构不一致的 DMG。 +2. **cask 最低系统要求与 2.0 Host 不符。** 2.0 的 Tauri 配置要求 macOS 14.0;cask 原来仍标记 Monterey。现保留 1.x 的 Monterey,2.x 使用 Sonoma,并增加与 Host minimumSystemVersion 的契约检查。 +3. **Stable 资产可能未齐就对外可见。** Tauri 与 Android 原来只让 Beta 保持 draft,Stable 第一个平台完成就发布。现两个渠道都上传到共享 draft,管理员在两条资产工作流全部通过并核验后显式发布。Homebrew 对 draft 不更新;Stable 由管理员公开后自动触发独立 `sync-homebrew-cask.yml`,不依赖人工记得更新 cask,也不重跑资产流水线。仅 published latest Stable 可修改 cask,旧 tag 的手动重放不会降级。流程同步到 `RELEASING.md`。 +4. **快速 Escape 可能跳过退出保留。** 全量本地浏览器回归发现 `useExitMount` 的 passive effect 尚未记住打开状态时即可关闭。改在 layout effect 中提交保留状态;Chromium/WebKit 各重复 5 次,10/10 通过。截图基线按已验收的 100% 默认值更新,不增加容差。 +5. **本机 Homebrew 未管理 OpenLess。** 已接入 README 指定的官方 tap,当前公开 cask 仍是 1.3.18,不用它覆盖已安装修复版。新增并安装 zsh 事件提醒,成功执行 brew update/upgrade 后只读比较实际 App 与 cask,终端提示及同版本去重通知;无 cron/LaunchAgent,安装器幂等且备份已有 `.zshrc`。正式版公开后再由 Homebrew 接管更新。 + +## 本机发布前门禁 + +用户确认当前修复版无明显问题。只读核验 `/Applications/OpenLess.app`: + +- 版本 `2.0.0-beta-a.beta.6+build.20261015-be`,签名校验通过,主程序 SHA-256 为 `7f0918b3012d273fb1ec61b3a041089c85acf9fcb04f73e5ce933bbf6076bba4`,与最终本机构建一致。 +- 本机核验时生产源文件没有晚于该构建的功能改动。后续 Review 额外补充共享退出保留的 layout-effect 竞态修复及发布/提醒脚本;该 UI 修复经浏览器重复验证,没有替换正在使用的 App,不能冒称它已经进入本机旧二进制。 +- 进程持续运行。日志中 2026-10-15 23:46 +0800 的两次录音均依次出现音频处理线程退出、Omni stream 完成、`phase=completed outcome=Inserted`,分别 227 与 24 字符;此前也有连续开始/停止和完整转写成功记录,没有再出现已停止录音却卡住 worker 的原故障。 +- 日志曾出现启动凭据迁移重试及更新检查网络失败,不把它们解释为已消失的全局错误;后续真实录音/请求/插入已经成功。未导出转写正文或 Keychain 秘密,未重置用户配置。 + +结合用户验收,本机修复版运行门禁已满足。此结论不冒充 Windows 真机、Linux 产品验收或云端安装包验收;不额外挂载/发布未经独立验收的 Linux 安装包。 + +## 验证及发布次序 + +- `npm test`(含 build 与前端/契约)通过;新增日期 cask 与双渠道 draft 契约分别通过,Ruby cask 语法检查通过。 +- Core lib:1204 passed、1 条显式 external Codex ignored;`local_asr_contract`:34 passed;独立 backend-tests:46 passed。 +- 本机当前完整 Chromium/WebKit 用例已一次执行 100/100 通过(不带更新截图参数)。首轮 PR CI 中 macOS、MSRV、Android、Linux Core/egui/deb/rpm 已通过;UI 仅四个 Linux 快捷键旧截图失配,功能用例通过。随后排版页的四张旧 Linux 参考也被完整收集;已逐张审查 Ubuntu 实际图并更新快捷键、排版共八张对应参考;两张独立页面使用 soft 截图断言收集完整失败证据,任一失配仍让 CI 失败。后续以最终 HEAD 全量 CI 为合并门禁,不提高容差。 +- 本机 `brew update` 已成功;官方 tap 已接入,提醒脚本已安装。当前 Homebrew 公开版仍为 1.3.18,未用旧版覆盖已验收修复版。 +- Beta 应用版本规范为 `2.0.0-Beta.6+build.20261015`,tag 为 `v2.0.0-Beta.6-tauri`。日期为该候选准备日期,不改已安装 App。 +- PR 合并、被标记提交的 CI 通过后,先构建/验证/公开 beta6;之后通过 `beta → main` PR 发布 `2.0.0+build.<正式版准备当天 YYYYMMDD>`,tag 保持 `v2.0.0-tauri`,Stable 标记 latest。 + +本报告记录 Review 与本机门禁结论;PR、CI、tag、资产及公开状态以实际后续交付记录为准,不将尚未执行事项写成通过。 diff --git a/docs/structure.md b/docs/structure.md index 26569f11d..746edf583 100644 --- a/docs/structure.md +++ b/docs/structure.md @@ -56,11 +56,11 @@ Core 其余模块按领域列于 [架构模块地图](architecture.md)。平台 | --- | --- | | `package.json` / `package-lock.json` | npm 命令、前端依赖与锁定版本;脚本从应用目录执行 | | `Cargo.toml` / `Cargo.lock` | Core 与 Linux workspace;不覆盖 `src-tauri` | -| `src-tauri/Cargo.toml` / `Cargo.lock` | Tauri Host 的独立依赖图;本地 path 子模块须在解析前就绪 | +| `src-tauri/Cargo.toml` / `Cargo.lock` | Tauri Host 的独立依赖图;不再依赖旧 Qwen/MLX/Whisper 子模块 | | `src-tauri/backend-tests/Cargo.toml` | 独立 Rust 回归 crate,按 CI 选择平台执行 | | `vite.config.ts` / `tsconfig.json` | WebView 构建、TypeScript 与 Android 别名 | | `src-tauri/tauri.conf.json` / `src-tauri/capabilities/` | 应用元数据、初始窗口、打包与 Tauri 能力权限 | -| `src-tauri/vendor/` | 原生 ASR 引擎与子模块;升级按 [qwen-asr 清单](qwen-asr-submodule-upgrade-checklist.md) | +| `src-tauri/src/asr/local/experimental/` | 按需原生模块和独立 TextStream worker;生命周期见 [实验模型](experimental-local-asr.md) | | `src/lib/ipc/provider-descriptors.generated.json` | Core 导出的公开 provider 目录;生成命令见 [架构](architecture.md) | | `assets/remote-input/` / `assets/vocab-presets.json` | Tauri 与 Linux 共用的手机输入页面及内置词表;测试和格式化均读取这份资源 | | `contract/language-catalog.json` | 工作语言的原生保存值、显示代码、ASR 代码与 Apple locale;前端及 Core 直接共用,新增语种不分别修改三份映射 | diff --git a/docs/ui-ux-refinement.md b/docs/ui-ux-refinement.md new file mode 100644 index 000000000..dd1d459f2 --- /dev/null +++ b/docs/ui-ux-refinement.md @@ -0,0 +1,105 @@ +# 桌面 UI 整合来源与历史校验 + +> 本文记录此前 UI 工作区的实现与验证。文中的两模型、测试数量及未打包说明属于该阶段;三模型、Beta 功能开关、最终版本和本地包验收以 [桌面整合验收](desktop-beta5-integration.md) 为准。 + +## 来源与边界 + +共同基线:`e4727697`。两边均为未提交改动,不能只合并分支指针。 + +- `/Users/trip/orca/openless`:本地实验语音识别,45 个变更文件。 +- `/Users/trip/orca/workspaces/openless/UI-UX-细节增强`:54 个变更文件。 +- 将这两份快照做三方整合到 `appergb/合并两个分支`,没有修改原工作区,没有提交或推送。完成时再次比对,原工作区内容与快照一致。 +- 原始补丁、文件与清单保存在 `/var/folders/lf/zll5whg118q4yd9jnq61mxgm0000gn/T/openless-merge-5irykbgr`。临时目录不是长期备份。 +- 根目录原有的未跟踪 `package.json`、`pnpm-lock.yaml` 保持不变;真正应用位于 `openless-all/app/`。 + +## 设计与实现 + +遵循工具型软件的即时响应,不把展示性动画塞到高频键盘操作中。 + +| Before | After | Why | +| --- | --- | --- | +| 6.1 MB 启动视频,25 秒 watchdog,另加 1.2 秒退出 | 删除视频与播放器;代码生成的三条线标记,装饰运动 2.5 秒内结束,就绪即进入 | 动画不再延迟进入;真正的后端契约失败仍阻止业务操作 | +| 首帧等待 UI 同步/Keychain | 本地主题与字体预先应用、HTML 首帧、React 就绪后渐进加载;同步后台初始化 | 避免空白等待;提前操作的偏好仍进入原有 CAS 队列,不被旧镜像覆盖 | +| 所有主页面和设置随主壳加载 | 非首页页面与设置按需加载,设置高级页面首次访问才挂载 | 降低初始解析和未访问功能的常驻开销;已打开的实验页仍保留草稿 | +| 设置浮窗、双层边框 | 设置占用窗口本身,统一左栏与右侧内容,返回应用入口 | 减少嵌套;保留焦点恢复、弹出子层与待保存表单保护 | +| 快捷键长卡片和层层展开的录制控件 | 右侧内嵌命令搜索、分类、分配状态、独立 keycaps,直接点击录制 | 高频动作即时生效;冲突校验、取消、禁用、重置继续复用真实 IPC | +| 渠道拖动时直接交换整行 | 临时脱离文档流的列表、逐帧计算连续插槽、每行独立临界阻尼弹簧 | 半程拖动也有部分让位;拖动帧不写 React state;取消不保存 | +| 无键盘重排 | 拖动手柄可聚焦,方向键移动,结果播报 | 不依赖鼠标/触控;持久化失败恢复后端状态 | +| 开关用 `left` 移动 | `transform` 驱动滑块;组内邻近开关有最多 1.5px 的视觉反馈 | 不改变其他开关状态,键盘与减少动态效果时不传播运动 | +| 描述文本截断 | 有明确入口的高度展开、180° 箭头 | 仅内容变化和尺寸改变时测量,200ms 局部高度过渡 | +| 删除卡片只有淡出 | 向右下删除方向的短弧线、缩小/旋转/透明度变化;支持水平划走 | 240ms 的定向反馈,保留 6 秒撤销及删除失败恢复 | +| 标签仅变色 | 选中项轻微放大,实际增加布局占位,邻项弹簧让位 | 不覆盖相邻选项;用于风格切换 | +| 滚动页面整面 mask/纹理合成 | 移除整页滚动遮罩与装饰噪点;稳定滚动条槽位 | 降低合成开销,避免内容顶部淡隐和滚动条出现时宽度抖动 | +| WebGL 预览离开可视区域仍绘制 | 离屏和隐藏时停钟;尺寸只在 resize 时测量;减少动态效果使用静态帧 | 自动测试确认离屏绘制计数不增长,重新可见后恢复 | +| 字体与排版占满一屏 | 仅保留字体、界面大小、阅读宽度三行,使用 Base UI Select | 删除文字用途、平铺字体、预设、最近使用、预览及导入导出面板;只用本机字体 | +| 问号按钮和内嵌长说明 | 共用 `Hint`,文字虚线下划线,Base UI Tooltip 优先向上弹出 | Portal 不撑开设置容器;支持悬浮、键盘、触屏、Esc 与碰撞避让 | +| 实验模型默认显示多段说明 | 默认两个模型名称和开关,规格及启停影响放入名称提示 | 下载进度、错误、重试和完成后的渠道引导仍然可见 | + +### 滚动策略 + +不接管 `wheel`,不对滚动事件做防抖,不安装第二套模拟惯性。macOS 触控板惯性由系统/浏览器处理,用户反向输入、触碰或停止时不会再被 JS 速度累加器拖走。保留原有历史/市场搜索的 300ms 防抖,以及流式内容的 rAF 合并。动画主要使用 transform/opacity;高度动画仅用于小型展开容器。真实触控板的加速度与制动感不能用合成 wheel 事件完全验证。 + +### 字体与数据范围 + +字体配置保存在设备本地 `ol.typography.v1`;不包含账号、密钥或任意 CSS,旧数据仍可读取。新的单一字体选择同时应用于界面、正文、标题和数字,代码保持原来的等宽字体设置。界面整体缩放继续使用原有 `ol-font-scale` 偏好和同步机制;Base UI Portal 抵消根节点缩放,再在弹层恢复字号,避免 90%/110% 下重复缩放坐标。新增排版资源提供中英文,其余语言的新增文案暂用英文。 + +风格卡片通过 `elementRef` 传递页面测量引用,内部与 `forwardRef` 合并,避免 Framer Motion 的 `PopChild` 在 React 18 中访问特殊属性 `props.ref`。保留 `popLayout`、滑动删除、退出动画和撤销,不修改依赖包、不屏蔽控制台警告。 + +## 可复现验证 + +设计反馈的新增回归在 `e2e/settings-feedback.spec.ts`:开发模式控制台、文字提示、触屏持久显示、键盘/Esc、窄屏、三档缩放下的定位;排版持久化用例位于 `e2e/refinements.spec.ts`。实测 1429×930 视口下,字体区域由约 739px 收至 251px,实验模型区域由约 649px 收至 127px。本轮生产构建、前端测试集及 Chromium/WebKit 全部 80 项 UI 回归通过;4 张排版视觉基线已更新,并在不更新快照的全量测试中复核通过。 + +在 `openless-all/app/` 执行: + +```sh +npm ci +npm run build +node scripts/frontend-test-runner.mjs +npm run test:ui-motion +cargo test -p openless-core --lib +node scripts/measure-startup.mjs 5 +``` + +以下数量为最初两分支整合的验证记录(不包含后续设计反馈用例): + +- TypeScript + Vite 生产构建通过。 +- 118 个前端测试文件通过,包括新的排版输入校验、连续插槽计算、启动阶段偏好写入、删除生命周期。 +- Chromium / WebKit:64 项 UI 测试通过,包含主页面和设置入口、7 类辅助窗口、移动窄屏、查询、导入导出、取消重排、视觉反馈不改状态、离屏 WebGL 停止、减少动态效果。 +- 8 张深浅主题视觉基线位于 `openless-all/app/e2e/visual-refinements.spec.ts-snapshots/`,按浏览器和 OS 区分;比较阈值 100 像素。更新后已独立比较通过,而非只生成图片。 +- Core:1,163 通过、1 跳过。其中本地实验 ASR 的 7 项生命周期/渠道隔离测试全部通过。 +- 修复了 Less Computer 的跨 chunk `getSettings` 重导出警告;主入口约 616KB 的大 chunk 提示仍存在,不将调高告警阈值当作优化。 + +### 启动测量的含义 + +`startupMetrics.ts` 记录导航相对时间:HTML 引导、前端代码、React 帧估计、后端就绪、壳与偏好就绪、同步桥。首屏与可交互不是同一个指标,不用假进度或动画时长抵扣真正工作。设置 → 关于与更新 → 启动诊断可查看当次数据。 + +一次本机 Chromium headless、生产资源、5 个新浏览器 context、模拟后端的结果: + +| 指标 | 结果 | +| --- | --- | +| FCP | 32–92ms,中位数 32ms | +| 壳与偏好就绪的双 rAF 估计 | 254–350ms,中位数 256ms | +| 首屏资源请求数 | 每次 9 个 | +| 主壳 chunk(未压缩) | 整合基线 369.92KB → 约 50.46KB;设置与其余页面改为按需加载 | + +这些是浏览器样本,不是原生进程冷启动,也不是优化前后速度对照;系统文件缓存、CPU/GPU 负载会影响结果。未声称 CPU 或 GPU 百分比下降。 + +## 预览与人工验收 + +Orca 当前工作区的内置浏览器:`http://127.0.0.1:5337/`。对应托管终端为 `OpenLess UI Preview`,支持源码修改后热更新。浏览器用模拟后端,不能据此验证真实麦克风、模型推理、系统级快捷键或系统权限。 + +建议实机检查: + +1. 快捷键搜索、录制、Esc 取消、冲突提示、分类切换;检查浅色/深色、90%/110% 缩放。 +2. AI 服务中的渠道上拖、半程停留、反向拖、边缘自动滚动、Esc 取消和方向键重排。 +3. 设置中的开关反馈,确认只有当前开关改变;开启系统“减少动态效果”再试。 +4. 风格卡片展开、水平划走、撤销、快速连续删除和切换页面。 +5. 在真实 macOS 打包 App 中用触控板检验原生惯性与快速制动;测原生启动及真实 ASR。此次没有构建或验证新的原生 App 安装包。 + +参考: + +- https://developer.apple.com/design/human-interface-guidelines/motion +- https://developer.apple.com/design/human-interface-guidelines/launching +- https://developer.apple.com/design/human-interface-guidelines/loading +- https://developer.apple.com/design/human-interface-guidelines/typography +- https://developer.apple.com/videos/play/wwdc2018/803/ diff --git a/openless-all/app/contract/backend-2.0.json b/openless-all/app/contract/backend-2.0.json index d9c176a9d..02a281f4e 100644 --- a/openless-all/app/contract/backend-2.0.json +++ b/openless-all/app/contract/backend-2.0.json @@ -1,5 +1,14 @@ { "contractVersion": "2.0.0", + "testingFeatures": { + "preferenceField": "testingFeaturesEnabled", + "default": false, + "scope": "device_local", + "features": ["lessComputer", "claudeConsole"], + "disabledBehavior": ["hide_entries_and_windows", "unregister_less_computer_hotkeys", "reject_runtime_admission", "cancel_active_tasks", "preserve_feature_settings"], + "disabledErrorCode": "permission_denied", + "cloudSync": "excluded" + }, "startupSnapshot": { "fields": ["contractVersion", "backend"], "sample": { @@ -351,6 +360,7 @@ "available": true, "loaded": false, "activeModel": "qwen3-asr-0.6b", + "restartRequired": false, "keepLoadedSecs": 0 } } diff --git a/openless-all/app/crates/openless-core/examples/export_provider_descriptors.rs b/openless-all/app/crates/openless-core/examples/export_provider_descriptors.rs index ee90a44c5..ba2ba8400 100644 --- a/openless-all/app/crates/openless-core/examples/export_provider_descriptors.rs +++ b/openless-all/app/crates/openless-core/examples/export_provider_descriptors.rs @@ -16,6 +16,17 @@ fn main() -> anyhow::Result<()> { let mut bytes = serde_json::to_vec_pretty(&catalog)?; bytes.push(b'\n'); fs::write(&destination, bytes)?; + // Both browser adapters consume the same canonical metadata. Keep the + // flattened contract fixture in sync instead of requiring a second edit. + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../src/lib/ipc/mock-provider-descriptors.json"); + let providers = [ProviderKind::Llm, ProviderKind::Asr, ProviderKind::Omni] + .into_iter() + .flat_map(provider_descriptors) + .collect::>(); + let mut bytes = serde_json::to_vec_pretty(&providers)?; + bytes.push(b'\n'); + fs::write(fixture, bytes)?; println!( "Provider preview catalog exported to {}", destination.display() diff --git a/openless-all/app/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index 3152b222d..ee9124023 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -1,4 +1,5 @@ mod cloud_note; +use futures_util::future::BoxFuture; use std::collections::HashMap; use std::sync::atomic::{AtomicU64, AtomicU8, Ordering}; use std::sync::{Arc, Mutex, RwLock}; @@ -2327,6 +2328,8 @@ pub struct OpenLessBackend { preferences: Arc, preferences_revision: Arc, settings_write_gate: Arc>, + // Serialize channel ownership changes through runtime cleanup, not status reads. + local_asr_channel_gate: tokio::sync::Mutex<()>, cloud_sync: Option, encrypted_sync: Option, encrypted_sync_store: Option>, @@ -2623,6 +2626,10 @@ impl OpenLessBackend { let history_revision = Arc::new(AtomicU64::new(0)); let vocabulary_revision = Arc::new(AtomicU64::new(0)); let settings_write_gate = Arc::new(Mutex::new(())); + deps.services.less_computer.bind_testing_features( + Arc::clone(&repositories.preferences), + Arc::clone(&settings_write_gate), + ); let voice_sessions = Arc::clone(&deps.services.voice_sessions); let runtime_start_work = Arc::new(crate::voice_session::RuntimeActivityGate::default()); deps.services.selection_voice = @@ -2651,6 +2658,8 @@ impl OpenLessBackend { process, Arc::clone(&deps.services.less_computer), BackendEventPublisher::new(Arc::clone(&events)), + Arc::clone(&repositories.preferences), + Arc::clone(&settings_write_gate), )); } if let Some(runtime) = deps.local_asr_runtime.take() { @@ -2926,6 +2935,7 @@ impl OpenLessBackend { preferences: repositories.preferences, preferences_revision, settings_write_gate, + local_asr_channel_gate: tokio::sync::Mutex::new(()), cloud_sync, encrypted_sync, encrypted_sync_store, @@ -3026,7 +3036,7 @@ impl OpenLessBackend { /// [`Self::abort_less_computer_capture`]. pub fn begin_less_computer_capture(&self, session_id: SessionId) -> Result<(), BackendError> { let _runtime = self.runtime_start_work.acquire()?; - if !self.get_preferences().coding_agent_enabled { + if !self.get_preferences().less_computer_available() { return Err(BackendError::new( BackendErrorCode::PermissionDenied, "Less Computer is disabled", @@ -3043,6 +3053,11 @@ impl OpenLessBackend { self.deps.services.less_computer.begin_capture(session_id) } + /// Require this device's explicit opt-in for unfinished features. + pub fn ensure_testing_features_enabled(&self) -> Result<(), BackendError> { + crate::settings::ensure_testing_features_enabled(&self.get_preferences()) + } + /// Return the current Less Computer capture/run session, if any. pub fn less_computer_active_session(&self) -> Option { self.deps.services.less_computer.active_session() @@ -3087,7 +3102,7 @@ impl OpenLessBackend { ) -> Result { let _runtime = self.runtime_start_work.acquire()?; let preferences = self.get_preferences(); - if !preferences.coding_agent_enabled { + if !preferences.less_computer_available() { return Err(BackendError::new( BackendErrorCode::PermissionDenied, "Less Computer is disabled", @@ -3661,7 +3676,7 @@ impl OpenLessBackend { ) -> Result { let _runtime = self.runtime_start_work.acquire()?; let preferences = self.get_preferences(); - if !preferences.coding_agent_enabled { + if !preferences.less_computer_available() { return Err(BackendError::new( BackendErrorCode::PermissionDenied, "Less Computer is disabled", @@ -3713,6 +3728,17 @@ impl OpenLessBackend { let Some(session_id) = session_id.or_else(|| self.less_computer_active_session()) else { return Ok(()); }; + own_voice_effect( + &self.deps.task_spawner, + self.less_computer_cancellation(session_id), + ) + .await + } + + fn less_computer_cancellation( + &self, + session_id: SessionId, + ) -> BoxFuture<'static, Result<(), BackendError>> { let control = self .less_computer_voice_controls .lock() @@ -3721,26 +3747,22 @@ impl OpenLessBackend { .cloned(); let controls = Arc::clone(&self.less_computer_voice_controls); let less_computer = Arc::clone(&self.deps.services.less_computer); - own_voice_effect( - &self.deps.task_spawner, - Box::pin(async move { - let service_result = less_computer.cancel(Some(session_id)).await; - let resource_result = match control { - Some(control) => { - let _guard = VoiceControlGuard { - session_id, - control: Arc::clone(&control), - controls, - }; - control.cancel_resources().await - } - None => Ok(()), - }; - let _ = less_computer.abort_capture(session_id); - resource_result.and(service_result) - }), - ) - .await + Box::pin(async move { + let service_result = less_computer.cancel(Some(session_id)).await; + let resource_result = match control { + Some(control) => { + let _guard = VoiceControlGuard { + session_id, + control: Arc::clone(&control), + controls, + }; + control.cancel_resources().await + } + None => Ok(()), + }; + let _ = less_computer.abort_capture(session_id); + resource_result.and(service_result) + }) } pub async fn cancel_active_voice_session( @@ -4624,6 +4646,16 @@ impl OpenLessBackend { "provider type must not be blank", )); } + if kind == ChannelKind::Asr { + return self + .apply_channel_mutation(ChannelMutation::SetProviderType { + kind, + id, + provider_type, + }) + .await + .map(|_| ()); + } let previous = self .list_channels(kind) .await? @@ -4791,18 +4823,36 @@ impl OpenLessBackend { "provider id must not be blank", )); } + let owner_guard = if slot == ProviderSlot::Asr { + Some(self.local_asr_channel_gate.lock().await) + } else { + None + }; + let previous = if slot == ProviderSlot::Asr { + self.asr_channel_owner().await? + } else { + None + }; self.deps .credential_store .set_active_provider(slot, provider_id) .await?; - self.refresh_and_publish_credentials().await + let retirement = self.release_previous_asr_owner(previous).await; + // No runtime cleanup may trail an asynchronous credentials refresh: a + // replacement channel/recording can already own the same runtime then. + drop(owner_guard); + let status = self.refresh_and_publish_credentials().await?; + retirement?; + Ok(status) } pub async fn activate_local_asr( &self, request: crate::domains::LocalAsrActivationRequest, ) -> Result { + let owner_guard = self.local_asr_channel_gate.lock().await; let result = self.deps.services.local_asr.activate(request).await?; + drop(owner_guard); self.refresh_and_publish_credentials().await?; Ok(result) } @@ -4811,11 +4861,123 @@ impl OpenLessBackend { &self, mutation: ChannelMutation, ) -> Result { + let kind = match &mutation { + ChannelMutation::ActivateLocalAsr { .. } + | ChannelMutation::RegisterExperimentalAsr { .. } => ChannelKind::Asr, + ChannelMutation::Create { kind, .. } + | ChannelMutation::SetProviderType { kind, .. } + | ChannelMutation::InvalidateTest { kind, .. } + | ChannelMutation::InvalidateTests { kind } + | ChannelMutation::DeleteIfBlank { kind, .. } + | ChannelMutation::Rename { kind, .. } + | ChannelMutation::Delete { kind, .. } + | ChannelMutation::SetEnabled { kind, .. } + | ChannelMutation::Reorder { kind, .. } + | ChannelMutation::RecordTest { kind, .. } => *kind, + }; + let owner_guard = if kind == ChannelKind::Asr { + Some(self.local_asr_channel_gate.lock().await) + } else { + None + }; + let previous = if kind == ChannelKind::Asr { + self.asr_channel_owner().await? + } else { + None + }; let result = self.deps.credential_store.mutate_channel(mutation).await?; + let retirement = self.release_previous_asr_owner(previous).await; + drop(owner_guard); + // The metadata commit still needs publishing when native cleanup fails. self.refresh_and_publish_credentials().await?; + retirement?; Ok(result) } + async fn asr_channel_owner(&self) -> Result, BackendError> { + let id = match self + .deps + .credential_store + .active_provider(ProviderSlot::Asr) + .await + { + Ok(id) => id, + Err(error) if error.code == BackendErrorCode::Unsupported => return Ok(None), + Err(error) => return Err(error), + }; + if id.is_empty() { + return Ok(None); + } + let channels = match self + .deps + .credential_store + .list_channels(ChannelKind::Asr) + .await + { + Ok(channels) => channels, + Err(error) if error.code == BackendErrorCode::Unsupported => Vec::new(), + Err(error) => return Err(error), + }; + Ok( + match channels.into_iter().find(|channel| channel.id == id) { + Some(channel) if channel.enabled => Some((id, channel.provider_type)), + Some(_) => None, + None => Some((id.clone(), id)), // Legacy provider-only selection. + }, + ) + } + + async fn release_previous_asr_owner( + &self, + previous: Option<(String, String)>, + ) -> Result<(), BackendError> { + let Some(previous) = previous else { + return Ok(()); + }; + if let Some(current) = self.asr_channel_owner().await? { + // Editing an unrelated channel is not runtime retirement. A new + // channel using the same shared runtime also keeps ownership; its + // model can be replaced by the next explicit use, not a stale unload. + if current == previous + || current.1 == previous.1 + || Self::local_asr_runtime(&previous.1) + .is_some_and(|runtime| Self::local_asr_runtime(¤t.1) == Some(runtime)) + { + return Ok(()); + } + } + self.release_local_asr_provider(&previous.1).await + } + + fn local_asr_runtime(provider: &str) -> Option { + match provider { + "local-qwen3" | "local-qwen3-mlx" | "local-qwen3-c" | "local-whisper" + | "apple-whisper" => Some(crate::LocalAsrRuntime::Generic), + "foundry-local" | "foundry-whisper" | "foundry-local-whisper" => { + Some(crate::LocalAsrRuntime::Foundry) + } + "sherpa-onnx" | "sherpa-onnx-local" => Some(crate::LocalAsrRuntime::SherpaOnnx), + _ => None, + } + } + + async fn release_local_asr_provider(&self, provider: &str) -> Result<(), BackendError> { + if let Some(model) = crate::experimental_asr::ExperimentalAsrModel::from_provider(provider) + { + if let Some(service) = &self.deps.services.experimental_asr { + service.release_runtime(model).await?; + } + return Ok(()); + } + let Some(runtime) = Self::local_asr_runtime(provider) else { + return Ok(()); + }; + match self.deps.services.local_asr.release(runtime).await { + Err(error) if error.code == BackendErrorCode::Unsupported => Ok(()), + result => result, + } + } + pub async fn refresh_and_publish_credentials(&self) -> Result { let status = self .deps @@ -5039,6 +5201,21 @@ impl OpenLessBackend { self.disarm_edit_observation(); self.dismiss_pending_corrections(); } + if previous.testing_features_enabled && !preferences.testing_features_enabled { + // Capture the old owners while settings admission is still locked. + // A later opt-in must not let cleanup cancel a replacement task. + let cancellation = self + .less_computer_active_session() + .map(|session_id| self.less_computer_cancellation(session_id)); + self.deps.services.less_computer.dismiss(); + let cancel_test = self.deps.services.coding_agent.cancel_test(); + self.deps.task_spawner.spawn(Box::pin(async move { + let _ = cancel_test.await; + if let Some(cancellation) = cancellation { + let _ = cancellation.await; + } + })); + } self.publish_preferences_changed(); Ok(crate::SettingsUpdateOutcome { preferences, @@ -7999,6 +8176,7 @@ mod tests { async fn voice_workflows_share_one_busy_lease_and_release_it_on_terminal_paths() { let (backend, _) = backend(); let mut preferences = backend.get_preferences(); + preferences.testing_features_enabled = true; preferences.coding_agent_enabled = true; backend .update_settings( @@ -8106,6 +8284,7 @@ mod tests { ) .unwrap(); let mut preferences = backend.get_preferences(); + preferences.testing_features_enabled = true; preferences.coding_agent_enabled = true; preferences.coding_agent_provider = "dsh-cli".into(); preferences.coding_agent_permission_mode = "bypassPermissions".into(); @@ -8158,6 +8337,24 @@ mod tests { .await .unwrap_err(); assert_eq!(error.code, BackendErrorCode::PermissionDenied); + let mut legacy = backend.get_preferences(); + legacy.coding_agent_enabled = true; + backend.set_preferences(legacy).unwrap(); + assert_eq!( + backend + .submit_less_computer("legacy".into()) + .await + .unwrap_err() + .code, + BackendErrorCode::PermissionDenied + ); + assert_eq!( + backend + .begin_less_computer_capture(SessionId::new()) + .unwrap_err() + .code, + BackendErrorCode::PermissionDenied + ); assert!(runtime.request.lock().unwrap().is_none()); } @@ -8165,6 +8362,7 @@ mod tests { async fn less_computer_capture_facade_is_session_scoped_and_cancel_releases_it() { let (backend, _) = backend(); let mut preferences = backend.get_preferences(); + preferences.testing_features_enabled = true; preferences.coding_agent_enabled = true; backend.set_preferences(preferences).unwrap(); @@ -8185,10 +8383,74 @@ mod tests { assert_eq!(backend.less_computer_active_session(), None); } + #[tokio::test] + async fn disabling_testing_features_cancels_capture_and_preserves_configuration() { + let (backend, _) = backend(); + backend.start().await.unwrap(); + let mut preferences = backend.get_preferences(); + preferences.testing_features_enabled = true; + preferences.coding_agent_enabled = true; + preferences.coding_agent_model = Some("saved-model".into()); + backend + .update_settings( + preferences, + crate::SettingsUpdateOptions::STRICT, + &crate::NoopSettingsRuntime, + ) + .unwrap(); + let owner = SessionId::new(); + backend.begin_less_computer_capture(owner).unwrap(); + let mut preferences = backend.get_preferences(); + preferences.testing_features_enabled = false; + let outcome = backend + .update_settings( + preferences, + crate::SettingsUpdateOptions::STRICT, + &crate::NoopSettingsRuntime, + ) + .unwrap(); + let hotkeys = outcome.effects.hotkeys.unwrap(); + assert!(hotkeys.previous.coding_agent_enabled); + assert!(!hotkeys.next.coding_agent_enabled); + assert!(outcome.preferences.coding_agent_enabled); + assert_eq!( + outcome.preferences.coding_agent_model.as_deref(), + Some("saved-model") + ); + assert!(backend.less_computer_capture_cancelled(owner)); + assert!(backend.ensure_testing_features_enabled().is_err()); + tokio::time::timeout(std::time::Duration::from_secs(2), async { + while backend.less_computer_active_session().is_some() { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert_eq!( + backend + .begin_less_computer_capture(SessionId::new()) + .unwrap_err() + .code, + BackendErrorCode::PermissionDenied + ); + let mut preferences = backend.get_preferences(); + preferences.testing_features_enabled = true; + backend + .update_settings( + preferences, + crate::SettingsUpdateOptions::STRICT, + &crate::NoopSettingsRuntime, + ) + .unwrap(); + backend.begin_less_computer_capture(owner).unwrap(); + backend.cancel_less_computer(Some(owner)).await.unwrap(); + } + #[test] fn less_computer_hotkey_modes_share_hold_toggle_auto_and_combined_rules() { let (backend, _) = backend(); let mut preferences = backend.get_preferences(); + preferences.testing_features_enabled = true; preferences.coding_agent_enabled = true; preferences.hotkey.mode = crate::HotkeyMode::Hold; @@ -8445,6 +8707,7 @@ mod tests { .unwrap(), ); let mut preferences = backend.get_preferences(); + preferences.testing_features_enabled = true; preferences.coding_agent_enabled = true; backend.set_preferences(preferences).unwrap(); let session_id = SessionId::new(); @@ -8513,6 +8776,7 @@ mod tests { ) .unwrap(); let mut preferences = backend.get_preferences(); + preferences.testing_features_enabled = true; preferences.coding_agent_enabled = true; preferences.stable_transcription_enabled = true; backend.set_preferences(preferences).unwrap(); @@ -8673,6 +8937,7 @@ mod tests { ) .unwrap(); let mut preferences = backend.get_preferences(); + preferences.testing_features_enabled = true; preferences.coding_agent_enabled = true; backend.set_preferences(preferences).unwrap(); (data_dir, backend, transcription, runtime) @@ -9288,6 +9553,10 @@ mod tests { BackendDependencies::unsupported(), ) .unwrap(); + let mut preferences = backend.get_preferences(); + preferences.testing_features_enabled = true; + preferences.coding_agent_enabled = true; + backend.set_preferences(preferences).unwrap(); let control = Arc::new(FakeRecordingControl::default()); let stop_session = SessionId::new(); @@ -15569,6 +15838,7 @@ mod tests { let backend = runtime_restore_backend(); backend.ensure_runtime_ready().unwrap(); let mut preferences = backend.get_preferences(); + preferences.testing_features_enabled = true; preferences.coding_agent_enabled = true; backend .update_settings( diff --git a/openless-all/app/crates/openless-core/src/asr/qwen_realtime.rs b/openless-all/app/crates/openless-core/src/asr/qwen_realtime.rs index 342eab31a..d0c66816d 100644 --- a/openless-all/app/crates/openless-core/src/asr/qwen_realtime.rs +++ b/openless-all/app/crates/openless-core/src/asr/qwen_realtime.rs @@ -304,11 +304,17 @@ impl Qwen3RealtimeASR { self.cancel(); return Err(error); } - if let Some(error) = self.state.lock().session_start_error.clone() { + self.finish_session_start() + } + + fn finish_session_start(&self) -> Result<(), Qwen3ASRError> { + // Rust 2021 keeps an if-let scrutinee's temporary guard through its + // body. Release it before cancel() acquires the same non-reentrant lock. + let start_error = self.state.lock().session_start_error.clone(); + if let Some(error) = start_error { self.cancel(); return Err(Qwen3ASRError::TaskFailed(error)); } - Ok(()) } @@ -733,6 +739,25 @@ async fn close_writer(writer: &SharedWriter) -> Result<(), Qwen3ASRError> { mod tests { use super::*; + #[test] + fn session_start_failure_cancels_without_reentering_the_state_lock() { + let asr = create_test_asr(); + asr.state.lock().session_start_error = Some("startup rejected".into()); + let (tx, rx) = std::sync::mpsc::channel(); + let worker = std::thread::spawn(move || { + let result = asr.finish_session_start(); + assert!(asr.state.lock().session_finished); + tx.send(result).unwrap(); + }); + let result = rx + .recv_timeout(Duration::from_secs(2)) + .expect("provider startup failure must not deadlock cleanup"); + assert!( + matches!(result, Err(Qwen3ASRError::TaskFailed(message)) if message == "startup rejected") + ); + worker.join().unwrap(); + } + #[test] fn transcript_snapshots_keep_prefix_and_recognition_corrections() { let asr = create_test_asr(); diff --git a/openless-all/app/crates/openless-core/src/asr/stepfun_realtime.rs b/openless-all/app/crates/openless-core/src/asr/stepfun_realtime.rs index f50a5a81f..1d7b71ecc 100644 --- a/openless-all/app/crates/openless-core/src/asr/stepfun_realtime.rs +++ b/openless-all/app/crates/openless-core/src/asr/stepfun_realtime.rs @@ -372,11 +372,17 @@ impl StepfunRealtimeASR { self.cancel(); return Err(error); } - if let Some(error) = self.state.lock().session_start_error.clone() { + self.finish_session_start() + } + + fn finish_session_start(&self) -> Result<(), StepfunASRError> { + // Rust 2021 keeps an if-let scrutinee's temporary guard through its + // body. Release it before cancel() acquires the same non-reentrant lock. + let start_error = self.state.lock().session_start_error.clone(); + if let Some(error) = start_error { self.cancel(); return Err(StepfunASRError::TaskFailed(error)); } - Ok(()) } @@ -949,6 +955,25 @@ async fn close_writer(writer: &SharedWriter) -> Result<(), StepfunASRError> { mod tests { use super::*; + #[test] + fn session_start_failure_cancels_without_reentering_the_state_lock() { + let asr = create_test_asr(); + asr.state.lock().session_start_error = Some("startup rejected".into()); + let (tx, rx) = std::sync::mpsc::channel(); + let worker = std::thread::spawn(move || { + let result = asr.finish_session_start(); + assert!(asr.state.lock().session_finished); + tx.send(result).unwrap(); + }); + let result = rx + .recv_timeout(Duration::from_secs(2)) + .expect("provider startup failure must not deadlock cleanup"); + assert!( + matches!(result, Err(StepfunASRError::TaskFailed(message)) if message == "startup rejected") + ); + worker.join().unwrap(); + } + #[test] fn transcript_snapshots_keep_prefix_and_recognition_corrections() { let asr = create_test_asr(); diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs index b1f7c6d8d..bb38496d4 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs @@ -438,6 +438,13 @@ pub const PREFERENCE_FIELDS: &[PreferenceField] = &[ shape: PreferenceShape::ObjectList, reason: "device_bound", }, + PreferenceField { + rust_name: "testing_features_enabled", + key: "testingFeaturesEnabled", + class: PreferenceClass::Excluded, + shape: PreferenceShape::Boolean, + reason: "local_consent_or_secret", + }, PreferenceField { rust_name: "coding_agent_enabled", key: "codingAgentEnabled", diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/tests.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/tests.rs index 072dc2fba..c5212b120 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/tests.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/tests.rs @@ -118,6 +118,27 @@ fn all_current_preference_fields_have_an_explicit_registration() { assert_eq!(registered.len(), registry::PREFERENCE_FIELDS.len()); } +#[test] +fn testing_features_opt_in_is_never_exported_or_accepted_from_another_device() { + let mut data = snapshot(); + data.preferences = SecretJson::new(json!({"testingFeaturesEnabled": true})); + let exported = export_snapshot(data).unwrap(); + assert!(!serde_json::to_string(exported.documents.documents()) + .unwrap() + .contains("testingFeaturesEnabled")); + let mut invalid = sample().documents().clone(); + invalid.documents.push(LogicalDocument { + id: "testingFeaturesEnabled".into(), + kind: DocumentKind::Preferences, + schema_version: 1, + value: json!(true), + }); + assert_eq!( + validate_sync_documents(invalid, Revision::new(3)).unwrap_err(), + DocumentError::ExcludedField + ); +} + #[test] fn controlled_export_keeps_real_service_keys_but_excludes_local_secrets_and_grants() { let mut data = channel_snapshot(); diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/native.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/native.rs index 4a30c0940..bdfc466fd 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/native.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/native.rs @@ -89,6 +89,8 @@ pub(super) fn canonicalize_native_documents( #[serde(rename_all = "camelCase", deny_unknown_fields)] struct LocalRollbackState { schema_version: u32, + #[serde(default)] + testing_features_enabled: bool, coding_agent_enabled: bool, active_asr_provider: String, active_llm_provider: String, @@ -126,6 +128,7 @@ impl CoreSyncStore { } SecretJson::from_serializable(&LocalRollbackState { schema_version: 1, + testing_features_enabled: prefs.testing_features_enabled, coding_agent_enabled: prefs.coding_agent_enabled, active_asr_provider: prefs.active_asr_provider, active_llm_provider: prefs.active_llm_provider, @@ -177,6 +180,10 @@ impl CoreSyncStore { let object = current .as_object_mut() .ok_or(DocumentError::InvalidDocument)?; + object.insert( + "testingFeaturesEnabled".into(), + Value::Bool(local.testing_features_enabled), + ); object.insert( "codingAgentEnabled".into(), Value::Bool(local.coding_agent_enabled), diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/tests.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/tests.rs index 4cad49904..6cb137ff8 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/tests.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/tests.rs @@ -1553,7 +1553,10 @@ async fn launch_at_login_is_excluded_and_preserves_the_receivers_local_setting() fixture .repo .preferences - .update(|prefs| prefs.launch_at_login = true) + .update(|prefs| { + prefs.launch_at_login = true; + prefs.testing_features_enabled = true; + }) .unwrap(); let before = fixture .store @@ -1562,6 +1565,7 @@ async fn launch_at_login_is_excluded_and_preserves_the_receivers_local_setting() .unwrap(); let encoded = serde_json::to_string(before.documents.documents()).unwrap(); assert!(!encoded.contains("launchAtLogin")); + assert!(!encoded.contains("testingFeaturesEnabled")); let mut desired = before.documents.documents().clone(); set_doc( &mut desired, @@ -1575,6 +1579,7 @@ async fn launch_at_login_is_excluded_and_preserves_the_receivers_local_setting() .await .unwrap(); assert!(fixture.repo.preferences.get().launch_at_login); + assert!(fixture.repo.preferences.get().testing_features_enabled); assert!( fixture .effects diff --git a/openless-all/app/crates/openless-core/src/coding_agent.rs b/openless-all/app/crates/openless-core/src/coding_agent.rs index 77ff713e0..b1283e8ff 100644 --- a/openless-all/app/crates/openless-core/src/coding_agent.rs +++ b/openless-all/app/crates/openless-core/src/coding_agent.rs @@ -1644,6 +1644,8 @@ pub struct CodingAgentService { events: BackendEventPublisher, active_test: Arc>>>, request_counter: AtomicU64, + preferences: Arc, + settings_write_gate: Arc>, } impl CodingAgentService { @@ -1652,6 +1654,8 @@ impl CodingAgentService { process: Arc, less_computer: Arc, events: BackendEventPublisher, + preferences: Arc, + settings_write_gate: Arc>, ) -> Self { Self { runner, @@ -1660,6 +1664,8 @@ impl CodingAgentService { events, active_test: Arc::new(Mutex::new(None)), request_counter: AtomicU64::new(0), + preferences, + settings_write_gate, } } @@ -1757,7 +1763,9 @@ impl crate::domains::CodingAgentApi for CodingAgentService { request: CodingAgentDetectRequest, ) -> BoxFuture<'static, Result> { let process = Arc::clone(&self.process); + let preferences = Arc::clone(&self.preferences); Box::pin(async move { + crate::settings::ensure_testing_features_enabled(&preferences.get())?; let executable = normalize_coding_agent_executable(request.provider, request.executable)?; let probe = execute_capture( @@ -1809,7 +1817,9 @@ impl crate::domains::CodingAgentApi for CodingAgentService { request: CodingAgentModelsRequest, ) -> BoxFuture<'static, Result, BackendError>> { let process = Arc::clone(&self.process); + let preferences = Arc::clone(&self.preferences); Box::pin(async move { + crate::settings::ensure_testing_features_enabled(&preferences.get())?; if request.provider != CodingAgentProvider::OpenCodeCli { return Err(BackendError::new( BackendErrorCode::Unsupported, @@ -1864,10 +1874,16 @@ impl crate::domains::CodingAgentApi for CodingAgentService { let active_test = Arc::clone(&self.active_test); let events = self.events.clone(); let request_id = self.next_request_id(); + let preferences = Arc::clone(&self.preferences); + let settings_write_gate = Arc::clone(&self.settings_write_gate); Box::pin(async move { let normalized = normalized?; let cancel = Arc::new(AtomicBool::new(false)); { + let _admission = settings_write_gate + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + crate::settings::ensure_testing_features_enabled(&preferences.get())?; let mut active = active_test.lock().expect("coding agent test lock poisoned"); if active.is_some() { return Err(BackendError::new( @@ -1918,17 +1934,15 @@ impl crate::domains::CodingAgentApi for CodingAgentService { } fn cancel_test(&self) -> BoxFuture<'static, Result<(), BackendError>> { - let active_test = Arc::clone(&self.active_test); - Box::pin(async move { - if let Some(cancel) = active_test - .lock() - .expect("coding agent test lock poisoned") - .clone() - { - cancel.store(true, Ordering::Release); - } - Ok(()) - }) + if let Some(cancel) = self + .active_test + .lock() + .expect("coding agent test lock poisoned") + .as_ref() + { + cancel.store(true, Ordering::Release); + } + Box::pin(async move { Ok(()) }) } fn approve( @@ -1944,6 +1958,100 @@ impl crate::domains::CodingAgentApi for CodingAgentService { mod tests { use super::*; + #[tokio::test] + async fn console_requires_device_opt_in_and_revocation_cancels_only_the_current_test() { + use crate::domains::CodingAgentApi; + let process = Arc::new(CancelAwareProcess); + let path = std::env::temp_dir().join(format!( + "openless-testing-console-{}.json", + uuid::Uuid::new_v4() + )); + let preferences = Arc::new(crate::PreferencesStore::fallback(path.clone())); + let events = BackendEventPublisher::new(Arc::new(crate::events::EventBus::new(32))); + let service = CodingAgentService::new( + Arc::new(CodingAgentRunner::new(process.clone())), + process, + Arc::new(crate::less_computer::LessComputerService::new()), + events, + preferences.clone(), + Arc::new(Mutex::new(())), + ); + assert_eq!( + service + .detect(CodingAgentDetectRequest { + provider: CodingAgentProvider::ClaudeCodeCli, + executable: None, + }) + .await + .unwrap_err() + .code, + BackendErrorCode::PermissionDenied + ); + assert_eq!( + service + .list_models(CodingAgentModelsRequest { + provider: CodingAgentProvider::OpenCodeCli, + executable: None, + refresh: false, + }) + .await + .unwrap_err() + .code, + BackendErrorCode::PermissionDenied + ); + let request = CodingAgentTestRequest { + provider: CodingAgentProvider::ClaudeCodeCli, + executable: None, + prompt: "probe".into(), + permission_mode: CodingAgentPermissionMode::default(), + workdir: None, + model: None, + max_budget_usd: None, + timeout_secs: 5, + }; + let mut queued = service.run_test(request.clone()); + preferences + .update(|prefs| prefs.testing_features_enabled = true) + .unwrap(); + assert!(futures_util::poll!(queued.as_mut()).is_pending()); + preferences + .update(|prefs| prefs.testing_features_enabled = false) + .unwrap(); + let cleanup = service.cancel_test(); + assert_eq!( + tokio::time::timeout(std::time::Duration::from_secs(2), queued) + .await + .unwrap() + .unwrap_err() + .code, + BackendErrorCode::Cancelled + ); + assert_eq!( + service.run_test(request.clone()).await.unwrap_err().code, + BackendErrorCode::PermissionDenied + ); + + preferences + .update(|prefs| prefs.testing_features_enabled = true) + .unwrap(); + let mut replacement = service.run_test(request); + assert!(futures_util::poll!(replacement.as_mut()).is_pending()); + cleanup.await.unwrap(); + assert!(!service + .active_test + .lock() + .unwrap() + .as_ref() + .unwrap() + .load(Ordering::Acquire)); + service.cancel_test().await.unwrap(); + assert_eq!( + replacement.await.unwrap_err().code, + BackendErrorCode::Cancelled + ); + std::fs::remove_file(path).unwrap(); + } + struct ScriptedProcess(Vec, Result); impl CodingAgentProcessAdapter for ScriptedProcess { diff --git a/openless-all/app/crates/openless-core/src/credentials.rs b/openless-all/app/crates/openless-core/src/credentials.rs index d68630281..6f885d098 100644 --- a/openless-all/app/crates/openless-core/src/credentials.rs +++ b/openless-all/app/crates/openless-core/src/credentials.rs @@ -185,6 +185,12 @@ pub enum ProviderSlot { Omni, } +/// Legacy macOS engines retained only as disabled channel metadata. +pub fn is_retired_macos_asr_provider(provider: &str) -> bool { + matches!(provider, "local-qwen3" | "local-qwen3-mlx" | "local-qwen3-c" + | "local-whisper" | "apple-whisper" | "apple-speech") +} + #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct ChannelSummary { @@ -207,6 +213,9 @@ pub struct ChannelTestSummary { #[derive(Debug, Clone, PartialEq, Eq)] pub enum ChannelMutation { + RegisterExperimentalAsr { + model: crate::experimental_asr::ExperimentalAsrModel, + }, InvalidateTest { kind: ChannelKind, id: String, @@ -214,7 +223,8 @@ pub enum ChannelMutation { InvalidateTests { kind: ChannelKind, }, - /// Commit a prepared local runtime and its channel in one metadata revision. + /// Enable/select a downloaded local model's channel in one metadata revision. + /// This mutation never prepares or loads the runtime. ActivateLocalAsr { id: Option, provider_type: String, @@ -568,6 +578,42 @@ impl CredentialMetadata { } } + /// Host-invoked macOS migration. Preserve channel identities/names for a + /// grey retired card, but never leave a retired local channel selected. + /// This is deliberately not a global rule: Windows hosts keep their catalog. + pub fn retire_macos_local_asr(&mut self) -> bool { + let active = self.active_provider(ProviderSlot::Asr); + let channels = self.channels.entry(ChannelKind::Asr).or_default(); + let retiring_active = is_retired_macos_asr_provider(&active) + || channels.iter().any(|c| c.id == active && is_retired_macos_asr_provider(&c.provider_type)); + let mut changed = false; + for channel in channels.iter_mut().filter(|c| is_retired_macos_asr_provider(&c.provider_type)) { + changed |= channel.enabled || channel.last_test.is_some(); + channel.enabled = false; + channel.last_test = None; + } + normalize_channel_order(channels); + if retiring_active { + let next = channels.iter().find(|c| c.enabled) + .map(|c| c.id.clone()).unwrap_or_default(); + self.active_providers.insert(ProviderSlot::Asr, next); + changed = true; + } + if changed { + self.revision = self.revision.saturating_add(1); + } + changed + } + + /// Read-only host projection after sync/import, keeping the persisted revision + /// so the next ordinary metadata transaction can still commit atomically. + pub fn retired_macos_view(mut self) -> Self { + let revision = self.revision; + self.retire_macos_local_asr(); + self.revision = revision; + self + } + pub fn revision(&self) -> u64 { self.revision } @@ -657,7 +703,8 @@ impl CredentialMetadata { has_credentials: impl Fn(&str) -> bool, ) -> Result { let mutation_kind = match &mutation { - ChannelMutation::ActivateLocalAsr { .. } => ChannelKind::Asr, + ChannelMutation::ActivateLocalAsr { .. } + | ChannelMutation::RegisterExperimentalAsr { .. } => ChannelKind::Asr, ChannelMutation::Create { kind, .. } | ChannelMutation::SetProviderType { kind, .. } | ChannelMutation::InvalidateTest { kind, .. } @@ -678,6 +725,37 @@ impl CredentialMetadata { .get(&mutation_kind) .is_some_and(|channels| channels.iter().any(|channel| channel.id == active)); let (kind, result) = match mutation { + ChannelMutation::RegisterExperimentalAsr { model } => { + let channels = self.channels.entry(ChannelKind::Asr).or_default(); + if let Some(existing) = channels.iter().find(|c| c.id == model.channel_id()) { + if existing.provider_type != model.provider_type() { + return Err(BackendError::new( + BackendErrorCode::InvalidState, + "Experimental channel ID is already in use", + )); + } + } else { + let enabled = channels.iter().any(|c| c.enabled && c.id == active); + let order = channels + .iter() + .map(|c| c.order) + .max() + .map(|v| v.saturating_add(1)) + .unwrap_or(0); + channels.push(ChannelSummary { + id: model.channel_id().into(), + name: model.name().into(), + provider_type: model.provider_type().into(), + enabled, + order, + last_test: None, + }); + } + ( + ChannelKind::Asr, + ChannelMutationResult::Created(model.channel_id().into()), + ) + } ChannelMutation::ActivateLocalAsr { id, provider_type } => { if provider_type.trim().is_empty() { return Err(BackendError::new( diff --git a/openless-all/app/crates/openless-core/src/dictation_context.rs b/openless-all/app/crates/openless-core/src/dictation_context.rs index cff439885..93a6cd75b 100644 --- a/openless-all/app/crates/openless-core/src/dictation_context.rs +++ b/openless-all/app/crates/openless-core/src/dictation_context.rs @@ -301,7 +301,10 @@ impl DictationContext { microphone_device_name: non_blank(&preferences.microphone_device_name), capture_generation: None, mute_during_recording: preferences.mute_during_recording, - transcribe_after_stop: preferences.stable_transcription_enabled, + // TextStream is explicitly a live ASR module; do not let an old + // batch-mode preference silently buffer its entire recording. + transcribe_after_stop: preferences.stable_transcription_enabled + && asr.provider_type != "experimental-textstream", archive_enabled: true, archive_successful_recording: matches!( options.output_target, @@ -706,6 +709,24 @@ mod tests { assert!(!user.contains("raw_transcript")); } + #[test] + fn textstream_stays_live_without_changing_the_users_batch_preference() { + let preferences = UserPreferences { stable_transcription_enabled: true, ..UserPreferences::default() }; + let pack = builtin_style_pack_for_mode(PolishMode::Structured); + for (provider, buffered) in [("experimental-textstream", false), ("volcengine", true)] { + let context = DictationContext::capture( + &preferences, &pack, + DictationProviderInvocations::new( + ProviderInvocation::for_provider(provider), + ProviderInvocation::for_provider("openai"), + ProviderInvocation::for_provider("omni"), + ), Vec::new(), Vec::new(), &DictationStartOptions::default(), + ); + assert_eq!(context.recording.transcribe_after_stop, buffered); + } + assert!(preferences.stable_transcription_enabled); + } + #[test] fn context_is_an_owned_snapshot_of_session_preferences() { let mut preferences = UserPreferences { diff --git a/openless-all/app/crates/openless-core/src/domains.rs b/openless-all/app/crates/openless-core/src/domains.rs index 4cef8a3c8..40af1fb12 100644 --- a/openless-all/app/crates/openless-core/src/domains.rs +++ b/openless-all/app/crates/openless-core/src/domains.rs @@ -128,6 +128,9 @@ pub struct LocalAsrModel { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct LocalAsrRuntimeStatus { + /// An explicit vendor download initialized native code in this process. + #[serde(default)] + pub restart_required: bool, pub runtime: LocalAsrRuntime, pub provider_id: String, pub available: bool, @@ -209,6 +212,7 @@ pub struct LocalAsrActivationResult { pub target: LocalAsrTarget, pub provider_id: String, pub generation: u64, + /// Legacy wire field: the selected model ID, not a runtime-load receipt. pub prepared_model: String, } @@ -1495,6 +1499,13 @@ pub struct LessComputerRunResult { /// the typed event and return the user's decision; they must not maintain a /// second token registry or conversation flag. pub trait LessComputerApi: Send + Sync { + /// Bind admission to this backend's device-local settings transaction. + fn bind_testing_features( + &self, + _preferences: Arc, + _settings_write_gate: Arc>, + ) { + } #[doc(hidden)] fn bind_event_publisher(&self, _publisher: crate::events::BackendEventPublisher) {} #[doc(hidden)] @@ -2092,6 +2103,7 @@ pub struct BackendServices { pub auxiliary: Arc, pub provider: Arc, pub local_asr: Arc, + pub experimental_asr: Option>, pub selection: Arc, pub selection_voice: Arc, pub qa: Arc, @@ -2116,6 +2128,7 @@ impl BackendServices { auxiliary: Arc::new(crate::auxiliary::UnsupportedAuxiliaryApi), provider: Arc::new(UnsupportedDomainServices), local_asr: Arc::new(UnsupportedDomainServices), + experimental_asr: None, selection: Arc::new(UnsupportedDomainServices), selection_voice: Arc::new(UnsupportedDomainServices), qa: Arc::new(UnsupportedDomainServices), diff --git a/openless-all/app/crates/openless-core/src/experimental-gemma-LICENSE.txt b/openless-all/app/crates/openless-core/src/experimental-gemma-LICENSE.txt new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/experimental-gemma-LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/openless-all/app/crates/openless-core/src/experimental-gemma-manifest.json b/openless-all/app/crates/openless-core/src/experimental-gemma-manifest.json new file mode 100644 index 000000000..9b35bc62b --- /dev/null +++ b/openless-all/app/crates/openless-core/src/experimental-gemma-manifest.json @@ -0,0 +1,158 @@ +[ + { + "path": "NOTICE", + "url": "https://haku-models.tslfiles.org/eg2demo/v5/files/NOTICE", + "sizeBytes": 891, + "sha256": "d7721bec34cdc40474d48e1c3454915881c1b11b7ab2d58b35e2097107067415" + }, + { + "path": "eg2_audio_l11.mlmodelc/analytics/coremldata.bin", + "url": "https://haku-models.tslfiles.org/eg2demo/v4/files/eg2_audio_l11.mlmodelc/analytics/coremldata.bin", + "sizeBytes": 243, + "sha256": "26ef07ea49872366f2cb07143b65c2ee1b27a193f7765b2203130e6505356d93" + }, + { + "path": "eg2_audio_l11.mlmodelc/coremldata.bin", + "url": "https://haku-models.tslfiles.org/eg2demo/v4/files/eg2_audio_l11.mlmodelc/coremldata.bin", + "sizeBytes": 413, + "sha256": "6a2eb779f98459182f75eb411dd9fc7331ce499f8d9fa8ef22bdf03ed8b09a7b" + }, + { + "path": "eg2_audio_l11.mlmodelc/metadata.json", + "url": "https://haku-models.tslfiles.org/eg2demo/v4/files/eg2_audio_l11.mlmodelc/metadata.json", + "sizeBytes": 2616, + "sha256": "474eb132d5b5b4172e419cb35050070bfdf8ec26d35d74ccc0dbdc21182abca0" + }, + { + "path": "eg2_audio_l11.mlmodelc/model.mil", + "url": "https://haku-models.tslfiles.org/eg2demo/v4/files/eg2_audio_l11.mlmodelc/model.mil", + "sizeBytes": 549319, + "sha256": "94a4f965ff6821b9112bdc3afd236774dbea3bac958311e2f9caefa9173dd0fe" + }, + { + "path": "eg2_audio_l11.mlmodelc/weights/weight.bin", + "url": "https://haku-models.tslfiles.org/eg2demo/v4/files/eg2_audio_l11.mlmodelc/weights/weight.bin", + "sizeBytes": 227280704, + "sha256": "1781b3491deef798302ff0594cf89af84512dca0baaee093f71dada0985f0aab" + }, + { + "path": "hann_320.f32", + "url": "https://haku-models.tslfiles.org/eg2demo/v1/files/hann_320.f32", + "sizeBytes": 1280, + "sha256": "8ac4fd109da552d6d3ef7a90083306384c9ee2bc32a60dd9f1ecfe931d8caf44" + }, + { + "path": "mel_filters_257x128.f32", + "url": "https://haku-models.tslfiles.org/eg2demo/v1/files/mel_filters_257x128.f32", + "sizeBytes": 131584, + "sha256": "669a99684b146cc243809c72bb0330261a56918ac30bdc0876fc112a8881a6b9" + }, + { + "path": "punct_case.json", + "url": "https://haku-models.tslfiles.org/eg2demo/v3/files/punct_case.json", + "sizeBytes": 275841, + "sha256": "ff6df24c8be41942a1e5cf22be81c8a2ac3a1be19cce720f59703ed8703aeffb" + }, + { + "path": "punct_headonly.mlmodelc/analytics/coremldata.bin", + "url": "https://haku-models.tslfiles.org/eg2demo/v3/files/punct_headonly.mlmodelc/analytics/coremldata.bin", + "sizeBytes": 243, + "sha256": "c678160cb94050a1f8adcbfe552f950b03a80ff440ba04596caa134f5c32426a" + }, + { + "path": "punct_headonly.mlmodelc/coremldata.bin", + "url": "https://haku-models.tslfiles.org/eg2demo/v3/files/punct_headonly.mlmodelc/coremldata.bin", + "sizeBytes": 490, + "sha256": "03b8cb7e1b78d47e54fb1e9b4b05b20a6e44800d5284405a31894e22ab3a650a" + }, + { + "path": "punct_headonly.mlmodelc/metadata.json", + "url": "https://haku-models.tslfiles.org/eg2demo/v3/files/punct_headonly.mlmodelc/metadata.json", + "sizeBytes": 2582, + "sha256": "4f0b7d6727a7bb4de550eb5b3ccf8a175e22717d01a7c6bea1bbc115ef4c388b" + }, + { + "path": "punct_headonly.mlmodelc/model.mil", + "url": "https://haku-models.tslfiles.org/eg2demo/v3/files/punct_headonly.mlmodelc/model.mil", + "sizeBytes": 53561, + "sha256": "975d6d1427cf9a2f362935de5c9c2398a186d83db56d38009af0371e87319eb9" + }, + { + "path": "punct_headonly.mlmodelc/weights/weight.bin", + "url": "https://haku-models.tslfiles.org/eg2demo/v3/files/punct_headonly.mlmodelc/weights/weight.bin", + "sizeBytes": 6772352, + "sha256": "c84e2dc00eb63ad6c800c1f25fb2250a8929553d10565725fb5eb67cf20fbfcc" + }, + { + "path": "reader_P6.json", + "url": "https://haku-models.tslfiles.org/eg2demo/v1/files/reader_G795_L11_big.json", + "sizeBytes": 294, + "sha256": "7a43e6557e6a202b59761ea0720fad9b807796e24776b2b331b0f03d67a6f700" + }, + { + "path": "reader_P6.mlmodelc/analytics/coremldata.bin", + "url": "https://haku-models.tslfiles.org/eg2demo/v4/files/reader_P6.mlmodelc/analytics/coremldata.bin", + "sizeBytes": 243, + "sha256": "d458d53c489dbb0915d37bbb91e5362396d83701486b34590b81826254da7f7e" + }, + { + "path": "reader_P6.mlmodelc/coremldata.bin", + "url": "https://haku-models.tslfiles.org/eg2demo/v4/files/reader_P6.mlmodelc/coremldata.bin", + "sizeBytes": 417, + "sha256": "8abedbc1f0c1ff01c8d131411479ae73096f83b700bcb832ee446fe0f5aead1d" + }, + { + "path": "reader_P6.mlmodelc/metadata.json", + "url": "https://haku-models.tslfiles.org/eg2demo/v4/files/reader_P6.mlmodelc/metadata.json", + "sizeBytes": 2728, + "sha256": "644f948514442720ac78040c1a21254b55317868c217b63a34101950597ce6b8" + }, + { + "path": "reader_P6.mlmodelc/model.mil", + "url": "https://haku-models.tslfiles.org/eg2demo/v4/files/reader_P6.mlmodelc/model.mil", + "sizeBytes": 133206, + "sha256": "c2401a5f0faeebb8b5618923d6513c1aeb486f821a7bfa0d00338007cb8bd22f" + }, + { + "path": "reader_P6.mlmodelc/weights/weight.bin", + "url": "https://haku-models.tslfiles.org/eg2demo/v4/files/reader_P6.mlmodelc/weights/weight.bin", + "sizeBytes": 65689984, + "sha256": "b0697e6d084048918dc2ddde822da156e1b2400ff585bc63d4929d8e9721adbf" + }, + { + "path": "reader_words.json", + "url": "https://haku-models.tslfiles.org/eg2demo/v1/files/reader_words.json", + "sizeBytes": 2688088, + "sha256": "d651e2ec1ebd55de2d31c70f64803894580eb6f5e85239edcd0bbec99c918b53" + }, + { + "path": "scorer_blk_d128_k16_b4_t500.mlmodelc/analytics/coremldata.bin", + "url": "https://haku-models.tslfiles.org/eg2demo/v1/files/scorer_blk_d128_k16_b4_t500.mlmodelc/analytics/coremldata.bin", + "sizeBytes": 243, + "sha256": "2422632913713b3f624d65ac7e7a1020464782bb45826ef2be96e11f970e0e22" + }, + { + "path": "scorer_blk_d128_k16_b4_t500.mlmodelc/coremldata.bin", + "url": "https://haku-models.tslfiles.org/eg2demo/v1/files/scorer_blk_d128_k16_b4_t500.mlmodelc/coremldata.bin", + "sizeBytes": 307, + "sha256": "25f5845dc7db1215842add5f51d78d9179d8ed940707a448b57828bddeb20b59" + }, + { + "path": "scorer_blk_d128_k16_b4_t500.mlmodelc/model.mil", + "url": "https://haku-models.tslfiles.org/eg2demo/v1/files/scorer_blk_d128_k16_b4_t500.mlmodelc/model.mil", + "sizeBytes": 3583, + "sha256": "3992faf39a83111a1664a8cbe35bee0a1427b79eadc33d85f83b49e888a59790" + }, + { + "path": "scorer_blk_d128_k16_b4_t500.mlmodelc/weights/weight.bin", + "url": "https://haku-models.tslfiles.org/eg2demo/v1/files/scorer_blk_d128_k16_b4_t500.mlmodelc/weights/weight.bin", + "sizeBytes": 58938560, + "sha256": "823e7cace9dfc1d8c2c6575dc802814f15879c2b124b9da6693e5cf1f6f7034a" + }, + { + "path": "word_unit_f16.bin", + "url": "https://haku-models.tslfiles.org/eg2demo/v1/files/word_unit_f16.bin", + "sizeBytes": 352445952, + "sha256": "36edb3cae02101c49ad3f3289ce5c526b1698916eb6b9faf116f4df227a25c9b" + } +] diff --git a/openless-all/app/crates/openless-core/src/experimental-textstream-manifest.json b/openless-all/app/crates/openless-core/src/experimental-textstream-manifest.json new file mode 100644 index 000000000..2ba13b909 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/experimental-textstream-manifest.json @@ -0,0 +1,50 @@ +[ + { + "path": "chat_template.json", + "url": "https://huggingface.co/Qwen/Qwen3-ASR-0.6B/resolve/5eb144179a02acc5e5ba31e748d22b0cf3e303b0/chat_template.json", + "sizeBytes": 1161, + "sha256": "75a8cfca24f00de72d796fbfed6858fc9614ef3dabd8696684cc3bc03a9c58ff" + }, + { + "path": "config.json", + "url": "https://huggingface.co/Qwen/Qwen3-ASR-0.6B/resolve/5eb144179a02acc5e5ba31e748d22b0cf3e303b0/config.json", + "sizeBytes": 6193, + "sha256": "76d3ae4601ce939830b2517f4a6cadb86cc51316c3900af6b020b051c21a478c" + }, + { + "path": "generation_config.json", + "url": "https://huggingface.co/Qwen/Qwen3-ASR-0.6B/resolve/5eb144179a02acc5e5ba31e748d22b0cf3e303b0/generation_config.json", + "sizeBytes": 142, + "sha256": "1da527824d81e07118facff437e03f2e24a23311e3bdeb2368973fe77e5f275c" + }, + { + "path": "merges.txt", + "url": "https://huggingface.co/Qwen/Qwen3-ASR-0.6B/resolve/5eb144179a02acc5e5ba31e748d22b0cf3e303b0/merges.txt", + "sizeBytes": 1671853, + "sha256": "8831e4f1a044471340f7c0a83d7bd71306a5b867e95fd870f74d0c5308a904d5" + }, + { + "path": "model.safetensors", + "url": "https://huggingface.co/Qwen/Qwen3-ASR-0.6B/resolve/5eb144179a02acc5e5ba31e748d22b0cf3e303b0/model.safetensors", + "sizeBytes": 1876091704, + "sha256": "79d6cbd4c98c7bbffe9db2edac07f56cd6637d0d5944b27f6c2b8353840323ea" + }, + { + "path": "preprocessor_config.json", + "url": "https://huggingface.co/Qwen/Qwen3-ASR-0.6B/resolve/5eb144179a02acc5e5ba31e748d22b0cf3e303b0/preprocessor_config.json", + "sizeBytes": 330, + "sha256": "45e120a4eda2c20c5d7f2ea9354e63536bf35e27aa573fb7cdf78017b378770d" + }, + { + "path": "tokenizer_config.json", + "url": "https://huggingface.co/Qwen/Qwen3-ASR-0.6B/resolve/5eb144179a02acc5e5ba31e748d22b0cf3e303b0/tokenizer_config.json", + "sizeBytes": 12487, + "sha256": "4942d005604266809309cabc9f4e9cb89ce855d59b14681fdc0e1cc62ea26c4c" + }, + { + "path": "vocab.json", + "url": "https://huggingface.co/Qwen/Qwen3-ASR-0.6B/resolve/5eb144179a02acc5e5ba31e748d22b0cf3e303b0/vocab.json", + "sizeBytes": 2776833, + "sha256": "ca10d7e9fb3ed18575dd1e277a2579c16d108e32f27439684afa0e10b1440910" + } +] diff --git a/openless-all/app/crates/openless-core/src/experimental_asr.rs b/openless-all/app/crates/openless-core/src/experimental_asr.rs new file mode 100644 index 000000000..5190ae0b7 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/experimental_asr.rs @@ -0,0 +1,621 @@ +//! Opt-in model packages. Separate IDs/directories/channels deliberately protect +//! the existing local-ASR catalog and user-created channels from switch-off cleanup. +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use futures_util::future::BoxFuture; +use serde::{Deserialize, Serialize}; +use tokio::sync::{watch, Mutex as AsyncMutex}; + +use crate::credentials::{ChannelKind, ChannelMutation, CredentialStore}; +use crate::errors::{BackendError, BackendErrorCode}; +use crate::model_store::{ModelCatalogEntry, ModelFile, ModelFileSelector, ModelStore}; +use crate::{LocalAsrMirror, LocalAsrRuntime, LocalAsrTarget, TaskSpawner}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ExperimentalAsrModel { + SenseVoice, + EmbeddingGemma, + TextStream, +} +impl ExperimentalAsrModel { + pub const ALL: [Self; 3] = [Self::SenseVoice, Self::EmbeddingGemma, Self::TextStream]; + fn index(self) -> usize { + match self { + Self::SenseVoice => 0, + Self::EmbeddingGemma => 1, + Self::TextStream => 2, + } + } + pub fn model_id(self) -> &'static str { + match self { + Self::SenseVoice => "experimental-sensevoice-small", + Self::EmbeddingGemma => "experimental-embedding-gemma-en", + Self::TextStream => "experimental-textstream-qwen3", + } + } + pub fn provider_type(self) -> &'static str { + match self { + Self::SenseVoice => "experimental-sensevoice", + Self::EmbeddingGemma => "experimental-embedding-gemma", + Self::TextStream => "experimental-textstream", + } + } + pub fn channel_id(self) -> &'static str { + self.provider_type() + } + pub fn name(self) -> &'static str { + match self { + Self::SenseVoice => "SenseVoice Small (Local)", + Self::EmbeddingGemma => "Embedding Gemma (Local · English)", + Self::TextStream => "TextStream (Local · Streaming)", + } + } + pub fn from_model_id(id: &str) -> Option { + Self::ALL.into_iter().find(|m| m.model_id() == id) + } + pub fn from_provider(id: &str) -> Option { + Self::ALL.into_iter().find(|m| m.provider_type() == id) + } + pub fn target(self) -> LocalAsrTarget { + LocalAsrTarget::parse(LocalAsrRuntime::Generic, self.model_id()) + .expect("built-in experimental model") + } + pub fn files(self) -> Vec { + match self { + Self::TextStream => { + serde_json::from_str(include_str!("experimental-textstream-manifest.json")) + .expect("bundled TextStream artifact manifest") + } + Self::EmbeddingGemma => { + serde_json::from_str(include_str!("experimental-gemma-manifest.json")) + .expect("bundled Gemma file manifest") + } + Self::SenseVoice => { + let base = "https://huggingface.co/csukuangfj/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17/resolve/2365baeacb507f821a0c8120fcee3d484dba7a07"; + [ + ( + "model.int8.onnx", + 239_233_841, + "c71f0ce00bec95b07744e116345e33d8cbbe08cef896382cf907bf4b51a2cd51", + ), + ( + "tokens.txt", + 315_894, + "f449eb28dc567533d7fa59be34e2abca8784f771850c78a47fb731a31429a1dc", + ), + ] + .into_iter() + .map(|(path, size_bytes, hash)| ModelFile { + path: path.into(), + url: format!("{base}/{path}"), + size_bytes, + sha256: Some(hash.into()), + }) + .collect() + } + } + } +} + +pub(crate) fn catalog_entries() -> Vec { + ExperimentalAsrModel::ALL + .into_iter() + .map(|model| ModelCatalogEntry { + target: model.target(), + repository: model.provider_type().into(), + modelscope_repository: None, + display_name: model.name().into(), + family: "experimental_asr".into(), + mode: if model == ExperimentalAsrModel::TextStream { + "streaming" + } else { + "offline" + } + .into(), + languages: match model { + ExperimentalAsrModel::SenseVoice => vec!["zh", "en", "yue", "ja", "ko"], + ExperimentalAsrModel::EmbeddingGemma => vec!["en"], + ExperimentalAsrModel::TextStream => vec!["multi"], + } + .into_iter() + .map(String::from) + .collect(), + selector: ModelFileSelector::Pinned(model.files()), + }) + .collect() +} + +/// Host implements real availability/loading. A successful download alone must +/// never create a seemingly usable channel for a missing runtime. +pub trait ExperimentalAsrRuntime: Send + Sync { + /// Stable reason code when unsupported, None when the runtime can be tried. + fn unavailable_reason(&self, model: ExperimentalAsrModel) -> Option; + /// Optional module setup downloads code/dependencies, but must not load models. + fn install( + &self, + _model: ExperimentalAsrModel, + _directory: PathBuf, + ) -> BoxFuture<'static, Result<(), BackendError>> { + Box::pin(async { Ok(()) }) + } + /// Filesystem-only check, safe on startup/settings/status paths. + fn installed(&self, _model: ExperimentalAsrModel, _directory: &Path) -> bool { + true + } + fn prepare( + &self, + model: ExperimentalAsrModel, + directory: PathBuf, + ) -> BoxFuture<'static, Result<(), BackendError>>; + fn release(&self, model: ExperimentalAsrModel) -> BoxFuture<'static, Result<(), BackendError>>; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ExperimentalAsrPhase { + Disabled, + Downloading, + Preparing, + Ready, + Removing, + Failed, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExperimentalAsrStatus { + pub model: ExperimentalAsrModel, + pub enabled: bool, + pub phase: ExperimentalAsrPhase, + pub available: bool, + pub unavailable_reason: Option, + pub downloaded_bytes: u64, + pub total_bytes: u64, + pub channel_id: Option, + pub needs_guidance: bool, + pub error: Option, +} +#[derive(Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +struct Saved { + enabled: bool, + needs_guidance: bool, + cleanup_pending: bool, + download_pending: bool, + install_pending: bool, + delete_pending: bool, +} +struct Slot { + saved: Saved, + phase: ExperimentalAsrPhase, + error: Option, + generation: u64, + cancel: Option>, +} + +type ChannelChangeHandler = Arc BoxFuture<'static, ()> + Send + Sync>; + +pub struct ExperimentalAsrService { + store: Arc, + credentials: Arc, + runtime: Arc, + spawner: Arc, + path: PathBuf, + slots: Mutex>, + workers: Vec>, + channel_change: Mutex>, +} +impl ExperimentalAsrService { + pub fn new( + store: Arc, + credentials: Arc, + runtime: Arc, + spawner: Arc, + path: PathBuf, + ) -> Result, BackendError> { + let mut saved: Vec = crate::persistence::read_or_default(&path)?; + if saved.len() > ExperimentalAsrModel::ALL.len() { + return Err(error("Unsupported newer experimental settings")); + } + saved.resize_with(ExperimentalAsrModel::ALL.len(), Saved::default); + Ok(Arc::new(Self { + store, + credentials, + runtime, + spawner, + path, + slots: Mutex::new( + saved + .into_iter() + .map(|saved| Slot { + phase: if saved.enabled { + ExperimentalAsrPhase::Preparing + } else if saved.cleanup_pending { + ExperimentalAsrPhase::Removing + } else { + ExperimentalAsrPhase::Disabled + }, + saved, + error: None, + generation: 0, + cancel: None, + }) + .collect(), + ), + workers: ExperimentalAsrModel::ALL + .iter() + .map(|_| AsyncMutex::new(())) + .collect(), + channel_change: Mutex::new(None), + })) + } + pub fn set_channel_change_handler(&self, handler: ChannelChangeHandler) { + *self.channel_change.lock().unwrap() = Some(handler); + } + async fn channels_changed(&self) { + let handler = self.channel_change.lock().unwrap().clone(); + if let Some(handler) = handler { + handler().await; + } + } + fn save(&self, slots: &[Slot]) -> Result<(), BackendError> { + let bytes = + serde_json::to_vec_pretty(&slots.iter().map(|slot| &slot.saved).collect::>()) + .map_err(|e| error(e.to_string()))?; + crate::persistence::atomic_write(&self.path, &bytes) + } + /// Called once by the Host after construction. Interrupted enable/disable + /// operations recover without tying their lifetime to a settings WebView. + pub fn resume(self: &Arc) { + for model in ExperimentalAsrModel::ALL { + let needed = { + let mut slots = self.slots.lock().unwrap(); + let slot = &mut slots[model.index()]; + if slot.saved.enabled + && !slot.saved.download_pending + && !slot.saved.install_pending + && (!self.store.is_installed(&model.target()).unwrap_or(false) + || !self + .store + .runtime_model_dir(&model.target()) + .is_ok_and(|dir| self.runtime.installed(model, &dir))) + { + slot.phase = ExperimentalAsrPhase::Failed; + slot.error = + Some("Model files are missing; explicitly enable/retry to download".into()); + false + } else { + slot.saved.enabled || slot.saved.cleanup_pending + } + }; + // Restoring an installed enabled item only reconciles channel metadata. + // Only an explicitly persisted interrupted download may fetch files. + if needed { + self.launch(model); + } + } + } + pub fn set_enabled( + self: &Arc, + model: ExperimentalAsrModel, + enabled: bool, + ) -> Result<(), BackendError> { + if enabled { + if let Some(reason) = self.runtime.unavailable_reason(model) { + return Err(BackendError::new(BackendErrorCode::Unsupported, reason)); + } + } + { + let mut slots = self.slots.lock().unwrap(); + let slot = &mut slots[model.index()]; + if slot.saved.enabled == enabled && !matches!(slot.phase, ExperimentalAsrPhase::Failed) + { + return Ok(()); + } + let previous = slot.saved.clone(); + slot.saved.enabled = enabled; + slot.saved.cleanup_pending = !enabled; + slot.saved.download_pending = enabled && !self.store.is_installed(&model.target())?; + slot.saved.install_pending = enabled; + slot.saved.delete_pending = false; + slot.saved.needs_guidance = enabled; + if let Err(e) = self.save(&slots) { + slots[model.index()].saved = previous; + return Err(e); + } + } + self.launch(model); + Ok(()) + } + pub fn delete_model(self: &Arc, model: ExperimentalAsrModel) -> Result<(), BackendError> { + { + let mut slots = self.slots.lock().unwrap(); + let slot = &mut slots[model.index()]; + if slot.saved.enabled { + return Err(BackendError::new( + BackendErrorCode::InvalidState, + "Disable the model before deleting its files", + )); + } + let previous = slot.saved.clone(); + slot.saved.cleanup_pending = true; + slot.saved.delete_pending = true; + if let Err(error) = self.save(&slots) { + slots[model.index()].saved = previous; + return Err(error); + } + } + self.launch(model); + Ok(()) + } + + fn launch(self: &Arc, model: ExperimentalAsrModel) { + let (generation, enabled, rx) = { + let mut slots = self.slots.lock().unwrap(); + let slot = &mut slots[model.index()]; + if let Some(tx) = slot.cancel.take() { + let _ = tx.send(true); + } + slot.generation += 1; + let (tx, rx) = watch::channel(false); + slot.cancel = Some(tx); + slot.error = None; + slot.phase = if slot.saved.enabled { + ExperimentalAsrPhase::Downloading + } else { + ExperimentalAsrPhase::Removing + }; + (slot.generation, slot.saved.enabled, rx) + }; + let service = Arc::clone(self); + self.spawner.spawn(Box::pin(async move { + let _worker = service.workers[model.index()].lock().await; + if !service.current(model, generation) { + return; + } + let outcome = service.run(model, generation, enabled, rx).await; + let mut slots = service.slots.lock().unwrap(); + let slot = &mut slots[model.index()]; + if slot.generation != generation { + return; + } + if let Err(e) = outcome { + slot.phase = ExperimentalAsrPhase::Failed; + slot.error = Some(e.to_string()); + } + })); + } + fn current(&self, model: ExperimentalAsrModel, generation: u64) -> bool { + self.slots.lock().unwrap()[model.index()].generation == generation + } + async fn run( + &self, + model: ExperimentalAsrModel, + generation: u64, + enabled: bool, + mut cancel: watch::Receiver, + ) -> Result<(), BackendError> { + if !enabled { + // Only the extension-owned, type-matching channel may be removed. + let channels = self.credentials.list_channels(ChannelKind::Asr).await?; + if channels + .iter() + .any(|c| c.id == model.channel_id() && c.provider_type == model.provider_type()) + { + self.credentials + .mutate_channel(ChannelMutation::Delete { + kind: ChannelKind::Asr, + id: model.channel_id().into(), + }) + .await?; + self.channels_changed().await; + } + self.runtime.release(model).await?; + if !self.current(model, generation) { + return Ok(()); + } + if self.slots.lock().unwrap()[model.index()] + .saved + .delete_pending + { + self.store.delete_model(&model.target())?; + } + let mut slots = self.slots.lock().unwrap(); + let slot = &mut slots[model.index()]; + if slot.generation == generation { + slot.saved.cleanup_pending = false; + slot.saved.delete_pending = false; + slot.phase = ExperimentalAsrPhase::Disabled; + self.save(&slots)?; + } + return Ok(()); + } + if let Some(reason) = self.runtime.unavailable_reason(model) { + return Err(BackendError::new(BackendErrorCode::Unsupported, reason)); + } + // Dropping a cancelled download also drops ModelStore's active-download + // guard; switch-off waits for this worker before deleting staging files. + tokio::select! { + biased; + _ = cancel.wait_for(|cancelled| *cancelled) => return Ok(()), + result = async { + if !self.store.is_installed(&model.target())? { + if !self.slots.lock().unwrap()[model.index()].saved.download_pending { + return Err(error("Model files are missing; explicit download is required")); + } + self.store.download_target(model.target(), LocalAsrMirror::Huggingface).await?; + } + Ok::<(), BackendError>(()) + } => result?, + } + if !self.current(model, generation) { + return Ok(()); + } + if model == ExperimentalAsrModel::EmbeddingGemma { + crate::persistence::atomic_write( + &self + .store + .runtime_model_dir(&model.target())? + .join("LICENSE-Apache-2.0.txt"), + include_bytes!("experimental-gemma-LICENSE.txt"), + )?; + } + if self.slots.lock().unwrap()[model.index()] + .saved + .install_pending + { + self.slots.lock().unwrap()[model.index()].phase = ExperimentalAsrPhase::Preparing; + tokio::select! { + biased; + _ = cancel.wait_for(|cancelled| *cancelled) => return Ok(()), + result = self.runtime.install(model, self.store.runtime_model_dir(&model.target())?) => result?, + } + if !self.current(model, generation) { + return Ok(()); + } + } + if !self + .runtime + .installed(model, &self.store.runtime_model_dir(&model.target())?) + { + return Err(error( + "Runtime files are missing; explicitly retry to install", + )); + } + { + let mut slots = self.slots.lock().unwrap(); + slots[model.index()].saved.install_pending = false; + slots[model.index()].saved.download_pending = false; + self.save(&slots)?; + } + // Download/enable NEVER prepares the runtime. The transcription start + // path checks the opt-in lease and loads on the first actual recording. + // Idempotent and append-only: never activates/reorders an existing route. + self.credentials + .mutate_channel(ChannelMutation::RegisterExperimentalAsr { model }) + .await?; + self.channels_changed().await; + let mut slots = self.slots.lock().unwrap(); + let slot = &mut slots[model.index()]; + if slot.generation == generation { + slot.phase = ExperimentalAsrPhase::Ready; + // Guidance was persisted at opt-in; restarting must not re-arm it. + self.save(&slots)?; + } + Ok(()) + } + /// Changing/turning off an ASR channel releases native state without + /// uninstalling its package or changing the extension opt-in preference. + pub fn release_runtime( + &self, + model: ExperimentalAsrModel, + ) -> BoxFuture<'static, Result<(), BackendError>> { + self.runtime.release(model) + } + + pub fn acknowledge(&self, model: ExperimentalAsrModel) -> Result<(), BackendError> { + let mut slots = self.slots.lock().unwrap(); + let previous = slots[model.index()].saved.needs_guidance; + slots[model.index()].saved.needs_guidance = false; + if let Err(e) = self.save(&slots) { + slots[model.index()].saved.needs_guidance = previous; + return Err(e); + } + Ok(()) + } + pub fn status(&self) -> Result, BackendError> { + let slots = self.slots.lock().unwrap(); + ExperimentalAsrModel::ALL + .into_iter() + .map(|model| { + let slot = &slots[model.index()]; + let reason = self.runtime.unavailable_reason(model); + let cache = self.store.pinned_model_status(&model.target())?; + Ok(ExperimentalAsrStatus { + model, + enabled: slot.saved.enabled, + phase: slot.phase, + available: reason.is_none(), + unavailable_reason: reason, + downloaded_bytes: cache.downloaded_bytes.min(cache.expected_bytes), + total_bytes: cache.expected_bytes, + channel_id: (slot.phase == ExperimentalAsrPhase::Ready) + .then(|| model.channel_id().into()), + needs_guidance: slot.saved.needs_guidance + && slot.phase == ExperimentalAsrPhase::Ready, + error: slot.error.clone(), + }) + }) + .collect() + } + /// Freeze the opt-in generation for a recording; a disable/re-enable cycle + /// must not revive a session that was using the previous model instance. + pub fn lease(&self, model: ExperimentalAsrModel) -> Result<(PathBuf, u64), BackendError> { + let slots = self.slots.lock().unwrap(); + let slot = &slots[model.index()]; + if !slot.saved.enabled + || slot.phase != ExperimentalAsrPhase::Ready + || !self.store.is_installed(&model.target())? + || !self + .runtime + .installed(model, &self.store.runtime_model_dir(&model.target())?) + { + return Err(BackendError::new( + BackendErrorCode::InvalidState, + "Experimental model is not enabled and ready", + )); + } + Ok(( + self.store.runtime_model_dir(&model.target())?, + slot.generation, + )) + } + pub fn check_lease( + &self, + model: ExperimentalAsrModel, + generation: u64, + ) -> Result<(), BackendError> { + let (_, current) = self.lease(model)?; + if current != generation { + return Err(BackendError::new( + BackendErrorCode::Cancelled, + "Experimental model changed during recording", + )); + } + Ok(()) + } +} +#[cfg(test)] +mod tests; + +fn error(message: impl Into) -> BackendError { + BackendError::new(BackendErrorCode::Platform, message) +} + +/// Verify every pinned byte before native loaders see an existing cache. The +/// installer already checks hashes; this check detects modified installed files. +pub fn verify_package(model: ExperimentalAsrModel, directory: &Path) -> Result<(), BackendError> { + use sha2::{Digest, Sha256}; + use std::io::Read; + for file in model.files() { + let mut input = + std::fs::File::open(directory.join(&file.path)).map_err(|e| error(e.to_string()))?; + if input.metadata().map_err(|e| error(e.to_string()))?.len() != file.size_bytes { + return Err(error(format!("Invalid size: {}", file.path))); + } + let mut hash = Sha256::new(); + let mut buffer = vec![0; 1024 * 1024]; + loop { + let n = input.read(&mut buffer).map_err(|e| error(e.to_string()))?; + if n == 0 { + break; + } + hash.update(&buffer[..n]); + } + if Some(format!("{:x}", hash.finalize())) != file.sha256 { + return Err(error(format!("Invalid checksum: {}", file.path))); + } + } + Ok(()) +} diff --git a/openless-all/app/crates/openless-core/src/experimental_asr/tests.rs b/openless-all/app/crates/openless-core/src/experimental_asr/tests.rs new file mode 100644 index 000000000..c294f1f38 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/experimental_asr/tests.rs @@ -0,0 +1,616 @@ +use super::*; +use crate::model_store::{ + ModelContentRange, ModelHttpMetadata, ModelStoreConfig, ModelTransport, ModelTransportRequest, + ModelTransportResponse, +}; +use crate::{InMemoryCredentialStore, ProviderSlot}; +use sha2::{Digest, Sha256}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::time::Duration; + +struct Spawner; +impl TaskSpawner for Spawner { + fn spawn(&self, task: BoxFuture<'static, ()>) { + tokio::spawn(task); + } +} +struct Transport { + gate: Arc, + calls: Arc, + corrupt: bool, +} +impl ModelTransport for Transport { + fn request( + &self, + req: ModelTransportRequest, + ) -> BoxFuture<'static, Result> { + let gate = self.gate.clone(); + let calls = self.calls.clone(); + let corrupt = self.corrupt; + Box::pin(async move { + calls.fetch_add(1, Ordering::SeqCst); + let _permit = gate.acquire().await.unwrap(); + let body = if corrupt { b"oops" } else { b"test" }; + let (status, bytes, content_range) = if let Some((start, end)) = req.range { + ( + 206, + body[start as usize..=end as usize].to_vec(), + Some(ModelContentRange { + start, + end, + total: 4, + }), + ) + } else { + (200, body.to_vec(), None) + }; + Ok(ModelTransportResponse { + status, + metadata: ModelHttpMetadata { + content_length: Some(bytes.len() as u64), + content_range, + link: None, + }, + bytes, + }) + }) + } +} +#[derive(Default)] +struct Runtime { + unsupported: AtomicBool, + fail_prepare: AtomicBool, + releases: AtomicUsize, + prepares: AtomicUsize, + installs: AtomicUsize, +} +impl ExperimentalAsrRuntime for Runtime { + fn unavailable_reason(&self, _: Model) -> Option { + self.unsupported + .load(Ordering::SeqCst) + .then(|| "unsupported".into()) + } + fn install(&self, _: Model, _: PathBuf) -> BoxFuture<'static, Result<(), BackendError>> { + self.installs.fetch_add(1, Ordering::SeqCst); + Box::pin(async { Ok(()) }) + } + fn prepare(&self, _: Model, _: PathBuf) -> BoxFuture<'static, Result<(), BackendError>> { + self.prepares.fetch_add(1, Ordering::SeqCst); + let fail = self.fail_prepare.load(Ordering::SeqCst); + Box::pin(async move { + if fail { + Err(error("load failed")) + } else { + Ok(()) + } + }) + } + fn release(&self, _: Model) -> BoxFuture<'static, Result<(), BackendError>> { + self.releases.fetch_add(1, Ordering::SeqCst); + Box::pin(async { Ok(()) }) + } +} +type Model = ExperimentalAsrModel; +struct TempDir(PathBuf); +impl TempDir { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "openless-experimental-asr-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&path).unwrap(); + Self(path) + } + fn path(&self) -> &Path { + &self.0 + } +} +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} +struct Fixture { + _temp: TempDir, + store: Arc, + credentials: Arc, + runtime: Arc, + service: Arc, + gate: Arc, + calls: Arc, + path: PathBuf, +} +impl Fixture { + fn new(permits: usize, corrupt: bool) -> Self { + let temp = TempDir::new(); + let path = temp.path().join("state.json"); + let gate = Arc::new(tokio::sync::Semaphore::new(permits)); + let calls = Arc::new(AtomicUsize::new(0)); + let mut entries = catalog_entries(); + for entry in &mut entries { + entry.selector = ModelFileSelector::Pinned(vec![ModelFile { + path: "model.bin".into(), + url: "https://example.com/pinned/model.bin".into(), + size_bytes: 4, + sha256: Some(format!("{:x}", Sha256::digest(b"test"))), + }]); + } + let store = Arc::new( + ModelStore::with_transport( + ModelStoreConfig::new(temp.path().join("models")).unwrap(), + Arc::new(Transport { + gate: gate.clone(), + calls: calls.clone(), + corrupt, + }), + ) + .with_test_catalog(entries), + ); + let credentials = Arc::new(InMemoryCredentialStore::default()); + let runtime = Arc::new(Runtime::default()); + let service = ExperimentalAsrService::new( + store.clone(), + credentials.clone(), + runtime.clone(), + Arc::new(Spawner), + path.clone(), + ) + .unwrap(); + Self { + _temp: temp, + store, + credentials, + runtime, + service, + gate, + calls, + path, + } + } + async fn phase(&self, model: Model, phase: ExperimentalAsrPhase) { + tokio::time::timeout(Duration::from_secs(3), async { + loop { + let status = self.service.status().unwrap(); + let status = &status[model.index()]; + if status.phase == phase { + break; + } + assert_ne!( + status.phase, + ExperimentalAsrPhase::Failed, + "{:?}", + status.error + ); + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("model operation did not settle"); + } + async fn wait_failed(&self, model: Model) { + tokio::time::timeout(Duration::from_secs(3), async { + while self.service.status().unwrap()[model.index()].phase + != ExperimentalAsrPhase::Failed + { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .unwrap(); + } +} +#[test] +fn pinned_catalog_is_scoped_and_complete() { + for model in Model::ALL { + let files = model.files(); + crate::ModelManifest::new(model.target(), model.provider_type(), files.clone()).unwrap(); + assert!(files + .iter() + .all(|f| f.sha256.as_ref().is_some_and(|s| s.len() == 64))); + assert_ne!(model.target().model_id(), "sense-voice-small-zh"); + } + assert_eq!( + Model::EmbeddingGemma + .files() + .iter() + .map(|f| f.size_bytes) + .sum::(), + 714_975_724 + ); + assert_eq!(Model::EmbeddingGemma.files().len(), 26); + let catalog = crate::ModelCatalog::standard(); + assert!(catalog + .find(LocalAsrRuntime::SherpaOnnx, "sense-voice-small-zh") + .is_some()); + for model in Model::ALL { + let d = crate::provider_rules::provider_descriptor( + crate::ProviderKind::Asr, + model.provider_type(), + ) + .unwrap(); + assert_eq!( + d.auth_requirement, + crate::provider_rules::AuthRequirement::None + ); + assert!( + !crate::provider_rules::provider_descriptors(crate::ProviderKind::Llm) + .iter() + .any(|d| d.provider_type.as_str() == model.provider_type()) + ); + } +} +#[tokio::test] +async fn register_is_idempotent_append_only_and_keeps_existing_routes() { + let f = Fixture::new(4, false); + f.credentials + .mutate_channel(ChannelMutation::Create { + kind: ChannelKind::Asr, + provider_type: "volcengine".into(), + name: "Existing".into(), + }) + .await + .unwrap(); + let previous = f + .credentials + .active_provider(ProviderSlot::Asr) + .await + .unwrap(); + f.service.set_enabled(Model::SenseVoice, true).unwrap(); + f.service.set_enabled(Model::EmbeddingGemma, true).unwrap(); + f.phase(Model::SenseVoice, ExperimentalAsrPhase::Ready) + .await; + f.phase(Model::EmbeddingGemma, ExperimentalAsrPhase::Ready) + .await; + assert_eq!( + f.credentials + .active_provider(ProviderSlot::Asr) + .await + .unwrap(), + previous + ); + let channels = f.credentials.list_channels(ChannelKind::Asr).await.unwrap(); + assert_eq!(channels.len(), 3); + assert_eq!(channels[0].id, previous); + f.service.set_enabled(Model::SenseVoice, true).unwrap(); + assert_eq!( + f.credentials + .list_channels(ChannelKind::Asr) + .await + .unwrap() + .len(), + 3 + ); + assert!(f.service.status().unwrap()[0].needs_guidance); + f.service.acknowledge(Model::SenseVoice).unwrap(); + let resumed = ExperimentalAsrService::new( + f.store.clone(), + f.credentials.clone(), + f.runtime.clone(), + Arc::new(Spawner), + f.path.clone(), + ) + .unwrap(); + resumed.resume(); + tokio::time::sleep(Duration::from_millis(30)).await; + assert!( + !resumed.status().unwrap()[0].needs_guidance, + "restart must not re-arm acknowledged guidance" + ); + assert!( + resumed.status().unwrap()[1].needs_guidance, + "unacknowledged guidance survives restart" + ); + assert_eq!( + f.credentials + .list_channels(ChannelKind::Asr) + .await + .unwrap() + .len(), + 3 + ); + let legacy = f + .store + .models_root_dir() + .join("sherpa-onnx/sense-voice-small-zh/model.int8.onnx"); + std::fs::create_dir_all(legacy.parent().unwrap()).unwrap(); + std::fs::write(&legacy, b"existing legacy model").unwrap(); + f.credentials + .mutate_channel(ChannelMutation::Reorder { + kind: ChannelKind::Asr, + ids: vec![ + Model::SenseVoice.channel_id().into(), + previous.clone(), + Model::EmbeddingGemma.channel_id().into(), + ], + }) + .await + .unwrap(); + assert_eq!( + f.credentials + .active_provider(ProviderSlot::Asr) + .await + .unwrap(), + Model::SenseVoice.channel_id() + ); + f.service.set_enabled(Model::SenseVoice, false).unwrap(); + f.phase(Model::SenseVoice, ExperimentalAsrPhase::Disabled) + .await; + assert!(f.store.is_installed(&Model::SenseVoice.target()).unwrap()); + f.service.delete_model(Model::SenseVoice).unwrap(); + f.phase(Model::SenseVoice, ExperimentalAsrPhase::Disabled) + .await; + assert!(!f + .store + .model_dir(&Model::SenseVoice.target()) + .unwrap() + .exists()); + assert_eq!(std::fs::read(&legacy).unwrap(), b"existing legacy model"); + assert_eq!( + f.runtime.prepares.load(Ordering::SeqCst), + 0, + "downloads and restored flags never prepare inference" + ); + assert!(f + .store + .is_installed(&Model::EmbeddingGemma.target()) + .unwrap()); + assert_eq!( + f.credentials + .active_provider(ProviderSlot::Asr) + .await + .unwrap(), + previous + ); + assert_eq!( + f.credentials + .list_channels(ChannelKind::Asr) + .await + .unwrap() + .len(), + 2 + ); +} +#[tokio::test] +async fn empty_channel_list_does_not_activate_a_downloaded_model() { + let f = Fixture::new(1, false); + f.service.set_enabled(Model::SenseVoice, true).unwrap(); + f.phase(Model::SenseVoice, ExperimentalAsrPhase::Ready) + .await; + let channels = f.credentials.list_channels(ChannelKind::Asr).await.unwrap(); + assert_eq!(channels.len(), 1); + assert!(!channels[0].enabled); + assert_ne!( + f.credentials + .active_provider(ProviderSlot::Asr) + .await + .unwrap(), + Model::SenseVoice.channel_id() + ); +} +#[tokio::test] +async fn cancel_waits_for_download_cleanup_and_never_registers_a_stale_channel() { + let f = Fixture::new(0, false); + f.service.set_enabled(Model::SenseVoice, true).unwrap(); + f.service.set_enabled(Model::EmbeddingGemma, true).unwrap(); + tokio::time::timeout(Duration::from_secs(2), async { + while f.calls.load(Ordering::SeqCst) < 2 { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + f.service.set_enabled(Model::SenseVoice, false).unwrap(); + f.phase(Model::SenseVoice, ExperimentalAsrPhase::Disabled) + .await; + f.gate.add_permits(2); + f.phase(Model::EmbeddingGemma, ExperimentalAsrPhase::Ready) + .await; + assert!(!f + .store + .model_dir(&Model::SenseVoice.target()) + .unwrap() + .exists()); + assert!(f + .credentials + .list_channels(ChannelKind::Asr) + .await + .unwrap() + .iter() + .all(|c| c.id != Model::SenseVoice.channel_id())); + f.service.set_enabled(Model::SenseVoice, true).unwrap(); + f.phase(Model::SenseVoice, ExperimentalAsrPhase::Ready) + .await; +} +#[tokio::test] +async fn failed_download_never_creates_ready_channels() { + for corrupt in [true] { + let f = Fixture::new(1, corrupt); + f.runtime.fail_prepare.store(!corrupt, Ordering::SeqCst); + f.service.set_enabled(Model::SenseVoice, true).unwrap(); + f.wait_failed(Model::SenseVoice).await; + assert!(f + .credentials + .list_channels(ChannelKind::Asr) + .await + .unwrap() + .is_empty()); + assert!(!f.service.status().unwrap()[0].needs_guidance); + f.service.set_enabled(Model::SenseVoice, false).unwrap(); + f.phase(Model::SenseVoice, ExperimentalAsrPhase::Disabled) + .await; + } +} +#[tokio::test] +async fn recording_lease_is_invalid_after_disable_and_reenable() { + let f = Fixture::new(1, false); + f.service.set_enabled(Model::SenseVoice, true).unwrap(); + f.phase(Model::SenseVoice, ExperimentalAsrPhase::Ready) + .await; + let (_, generation) = f.service.lease(Model::SenseVoice).unwrap(); + f.service.set_enabled(Model::SenseVoice, false).unwrap(); + f.service.set_enabled(Model::SenseVoice, true).unwrap(); + f.phase(Model::SenseVoice, ExperimentalAsrPhase::Ready) + .await; + assert!(f + .service + .check_lease(Model::SenseVoice, generation) + .is_err()); + let (_, current) = f.service.lease(Model::SenseVoice).unwrap(); + assert!(f.service.check_lease(Model::SenseVoice, current).is_ok()); +} + +#[tokio::test] +async fn enabled_saved_model_is_metadata_only_on_resume_and_loader_failures_are_deferred() { + let f = Fixture::new(1, false); + f.runtime.fail_prepare.store(true, Ordering::SeqCst); + f.service.set_enabled(Model::SenseVoice, true).unwrap(); + f.phase(Model::SenseVoice, ExperimentalAsrPhase::Ready) + .await; + let downloads = f.calls.load(Ordering::SeqCst); + let resumed = ExperimentalAsrService::new( + f.store.clone(), + f.credentials.clone(), + f.runtime.clone(), + Arc::new(Spawner), + f.path.clone(), + ) + .unwrap(); + resumed.resume(); + tokio::time::sleep(Duration::from_millis(40)).await; + assert_eq!( + resumed.status().unwrap()[0].phase, + ExperimentalAsrPhase::Ready + ); + assert_eq!(f.calls.load(Ordering::SeqCst), downloads); + assert_eq!(f.runtime.prepares.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn saved_enabled_without_files_never_silently_downloads_on_startup() { + let f = Fixture::new(1, false); + std::fs::write(&f.path, br#"[{"enabled":true},{}]"#).unwrap(); + let resumed = ExperimentalAsrService::new( + f.store.clone(), + f.credentials.clone(), + f.runtime.clone(), + Arc::new(Spawner), + f.path.clone(), + ) + .unwrap(); + resumed.resume(); + tokio::time::sleep(Duration::from_millis(40)).await; + assert_eq!( + resumed.status().unwrap()[0].phase, + ExperimentalAsrPhase::Failed + ); + assert_eq!(f.calls.load(Ordering::SeqCst), 0); + assert_eq!(f.runtime.prepares.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn only_an_explicit_interrupted_download_resumes() { + let f = Fixture::new(1, false); + std::fs::write(&f.path, br#"[{"enabled":true,"downloadPending":true},{}]"#).unwrap(); + let resumed = ExperimentalAsrService::new( + f.store.clone(), + f.credentials.clone(), + f.runtime.clone(), + Arc::new(Spawner), + f.path.clone(), + ) + .unwrap(); + resumed.resume(); + tokio::time::timeout(Duration::from_secs(2), async { + while resumed.status().unwrap()[0].phase != ExperimentalAsrPhase::Ready { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .unwrap(); + assert_eq!(f.calls.load(Ordering::SeqCst), 1); + assert_eq!(f.runtime.prepares.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn all_three_packages_install_independently_without_loading_and_migrate_two_slot_settings() { + let f = Fixture::new(4, false); + for model in Model::ALL { + f.service.set_enabled(model, true).unwrap(); + } + for model in Model::ALL { + f.phase(model, ExperimentalAsrPhase::Ready).await; + } + assert_eq!(f.runtime.installs.load(Ordering::SeqCst), 3); + assert_eq!(f.runtime.prepares.load(Ordering::SeqCst), 0); + let downloads = f.calls.load(Ordering::SeqCst); + let resumed = ExperimentalAsrService::new( + f.store.clone(), + f.credentials.clone(), + f.runtime.clone(), + Arc::new(Spawner), + f.path.clone(), + ) + .unwrap(); + resumed.resume(); + tokio::time::sleep(Duration::from_millis(40)).await; + assert_eq!(f.calls.load(Ordering::SeqCst), downloads); + assert_eq!( + f.runtime.installs.load(Ordering::SeqCst), + 3, + "resume cannot install or execute a module again" + ); + assert_eq!(f.runtime.prepares.load(Ordering::SeqCst), 0); + // The legacy two-slot file must not shift SenseVoice/Gemma into TextStream. + std::fs::write(&f.path, br#"[{"enabled":true},{"enabled":false}]"#).unwrap(); + let upgraded = ExperimentalAsrService::new( + f.store.clone(), + f.credentials.clone(), + f.runtime.clone(), + Arc::new(Spawner), + f.path.clone(), + ) + .unwrap(); + let statuses = upgraded.status().unwrap(); + assert!( + statuses + .iter() + .find(|s| s.model == Model::SenseVoice) + .unwrap() + .enabled + ); + assert!( + !statuses + .iter() + .find(|s| s.model == Model::TextStream) + .unwrap() + .enabled + ); +} + +#[tokio::test] +async fn optional_runtime_files_are_not_download_progress() { + let f = Fixture::new(4, false); + let directory = f.store.model_dir(&Model::TextStream.target()).unwrap(); + std::fs::create_dir_all(directory.join(".runtime/cache")).unwrap(); + let unrelated = std::fs::File::create(directory.join(".runtime/cache/wheels")).unwrap(); + unrelated.set_len(5 * 1024 * 1024 * 1024).unwrap(); + let status = f.service.status().unwrap(); + assert_eq!(status[Model::TextStream.index()].downloaded_bytes, 0); + f.service.set_enabled(Model::TextStream, true).unwrap(); + f.phase(Model::TextStream, ExperimentalAsrPhase::Ready) + .await; + std::fs::create_dir_all(directory.join(".runtime/cache")).unwrap(); + std::fs::write(directory.join(".runtime/cache/extra"), b"not model weights").unwrap(); + let status = f.service.status().unwrap(); + assert_eq!(status[Model::TextStream.index()].downloaded_bytes, 4); + assert_eq!(status[Model::TextStream.index()].total_bytes, 4); +} + +#[tokio::test] +async fn unsupported_models_are_not_downloaded() { + let f = Fixture::new(1, false); + f.runtime.unsupported.store(true, Ordering::SeqCst); + assert!(f.service.set_enabled(Model::EmbeddingGemma, true).is_err()); + assert_eq!(f.calls.load(Ordering::SeqCst), 0); + assert!(!f.service.status().unwrap()[1].enabled); +} diff --git a/openless-all/app/crates/openless-core/src/less_computer.rs b/openless-all/app/crates/openless-core/src/less_computer.rs index 157528278..dedf04a1c 100644 --- a/openless-all/app/crates/openless-core/src/less_computer.rs +++ b/openless-all/app/crates/openless-core/src/less_computer.rs @@ -23,6 +23,7 @@ const MAX_DSH_CONTINUATION_TURNS: usize = 2; pub(crate) const VOICE_CAPTURE_FAILED: &str = "Less Computer voice input failed. Please try again."; struct LessComputerState { + testing_gate: Mutex>, conversation_active: AtomicBool, approvals: Mutex>>, events: Mutex>, @@ -33,6 +34,12 @@ struct LessComputerState { voice_sessions: Arc, } +#[derive(Clone)] +struct TestingFeaturesGate { + preferences: Arc, + settings_write_gate: Arc>, +} + enum ActiveLease { Capture(ActiveCapture), Run(ActiveRun), @@ -85,6 +92,7 @@ impl LessComputerService { ) -> Self { Self { state: Arc::new(LessComputerState { + testing_gate: Mutex::new(None), conversation_active: AtomicBool::new(false), approvals: Mutex::new(HashMap::new()), events: Mutex::new(None), @@ -134,6 +142,26 @@ impl LessComputerService { } fn begin_capture_inner(&self, session_id: SessionId) -> Result<(), BackendError> { + let gate = self + .state + .testing_gate + .lock() + .expect("testing feature gate lock poisoned") + .clone(); + let _admission = gate.as_ref().map(|gate| { + gate.settings_write_gate + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + }); + if gate + .as_ref() + .is_some_and(|gate| !gate.preferences.get().less_computer_available()) + { + return Err(BackendError::new( + BackendErrorCode::PermissionDenied, + "Less Computer is disabled", + )); + } let mut active = self .state .active_lease @@ -160,6 +188,26 @@ impl LessComputerService { &self, session_id: SessionId, ) -> Result, BackendError> { + let gate = self + .state + .testing_gate + .lock() + .expect("testing feature gate lock poisoned") + .clone(); + let _admission = gate.as_ref().map(|gate| { + gate.settings_write_gate + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + }); + if gate + .as_ref() + .is_some_and(|gate| !gate.preferences.get().less_computer_available()) + { + return Err(BackendError::new( + BackendErrorCode::PermissionDenied, + "Less Computer is disabled", + )); + } let mut active = self .state .active_lease @@ -382,6 +430,20 @@ impl Drop for ApprovalLease { } impl LessComputerApi for LessComputerService { + fn bind_testing_features( + &self, + preferences: Arc, + settings_write_gate: Arc>, + ) { + *self + .state + .testing_gate + .lock() + .expect("testing feature gate lock poisoned") = Some(TestingFeaturesGate { + preferences, + settings_write_gate, + }); + } fn bind_event_publisher(&self, publisher: BackendEventPublisher) { *self .state diff --git a/openless-all/app/crates/openless-core/src/lib.rs b/openless-all/app/crates/openless-core/src/lib.rs index a909be005..acbbd2432 100644 --- a/openless-all/app/crates/openless-core/src/lib.rs +++ b/openless-all/app/crates/openless-core/src/lib.rs @@ -33,6 +33,7 @@ pub mod edit_plan; pub mod endpoint_security; pub mod errors; pub mod events; +pub mod experimental_asr; pub mod external_audio; pub mod history; pub mod host_document; diff --git a/openless-all/app/crates/openless-core/src/local_asr_catalog.rs b/openless-all/app/crates/openless-core/src/local_asr_catalog.rs index ee90f913f..1289475fb 100644 --- a/openless-all/app/crates/openless-core/src/local_asr_catalog.rs +++ b/openless-all/app/crates/openless-core/src/local_asr_catalog.rs @@ -205,7 +205,11 @@ impl LocalAsrTarget { ) -> Result { let model_id = model_id.into(); let known = match runtime { - LocalAsrRuntime::Generic => LocalAsrModelId::from_wire_id(&model_id).is_some(), + LocalAsrRuntime::Generic => { + LocalAsrModelId::from_wire_id(&model_id).is_some() + || crate::experimental_asr::ExperimentalAsrModel::from_model_id(&model_id) + .is_some() + } LocalAsrRuntime::Foundry => FOUNDRY_MODEL_ALIASES.contains(&model_id.as_str()), LocalAsrRuntime::SherpaOnnx => SHERPA_MODEL_ALIASES.contains(&model_id.as_str()), }; diff --git a/openless-all/app/crates/openless-core/src/local_asr_service.rs b/openless-all/app/crates/openless-core/src/local_asr_service.rs index 074834fb1..06251efaf 100644 --- a/openless-all/app/crates/openless-core/src/local_asr_service.rs +++ b/openless-all/app/crates/openless-core/src/local_asr_service.rs @@ -69,6 +69,7 @@ pub trait ModelRuntimeAdapter: Send + Sync { self.engine_available(target.runtime) } + /// Pure inspection: must not create a native manager, worker or device. fn inspect_native_models( &self, _targets: Vec, @@ -76,6 +77,21 @@ pub trait ModelRuntimeAdapter: Send + Sync { Box::pin(async { Ok(Vec::new()) }) } + /// Some vendor SDKs initialize their runtime while downloading. After that + /// explicit operation, block further use until a fresh application process. + fn restart_required(&self, _runtime: LocalAsrRuntime) -> bool { + false + } + + fn download_native_model( + &self, + _target: LocalAsrTarget, + _source: FoundryRuntimeSource, + _progress: ModelPrepareProgressSink, + ) -> BoxFuture<'static, Result<(), BackendError>> { + unsupported("native model download") + } + fn native_model_dir( &self, _target: LocalAsrTarget, @@ -193,7 +209,6 @@ pub(crate) struct LocalAsrService { credentials: Arc, activation_generation: Arc, activation_lock: Arc>, - active_lease: Arc>>, } impl LocalAsrService { @@ -216,7 +231,6 @@ impl LocalAsrService { credentials, activation_generation: Arc::new(AtomicU64::new(0)), activation_lock: Arc::new(tokio::sync::Mutex::new(())), - active_lease: Arc::new(std::sync::Mutex::new(None)), } } @@ -276,7 +290,8 @@ impl LocalAsrService { let active_model = Self::active_model(&preferences, runtime); let target = LocalAsrTarget::parse(runtime, active_model.clone())?; let model_dir = model_store.runtime_model_dir(&target)?; - adapter + let restart_required = adapter.restart_required(runtime); + let mut status = adapter .runtime_status( LocalAsrSettings { runtime, @@ -304,7 +319,9 @@ impl LocalAsrService { }, model_dir, ) - .await + .await?; + status.restart_required = restart_required; + Ok(status) } async fn publish_runtime_status( @@ -344,7 +361,18 @@ impl LocalAsrService { }) } + fn validate_target(&self, target: &LocalAsrTarget) -> Result<(), BackendError> { + if crate::experimental_asr::ExperimentalAsrModel::from_model_id(target.model_id()).is_some() + || !self.runtime.supports_model(target) + { + return Err(BackendError::new(BackendErrorCode::Unsupported, + "This local model is unavailable here; use Experimental local speech models")); + } + Ok(()) + } + fn prepared_model_dir(&self, target: &LocalAsrTarget) -> Result { + self.validate_target(target)?; if !self.model_store.is_native(target)? && !self.model_store.is_installed(target)? { return Err(BackendError::new( BackendErrorCode::InvalidState, @@ -405,7 +433,7 @@ impl LocalAsrService { let provider_matches = match target.runtime { LocalAsrRuntime::Generic => { let model = crate::LocalAsrModelId::from_wire_id(target.model_id()) - .expect("validated local ASR target"); + .ok_or_else(|| BackendError::new(BackendErrorCode::InvalidArgument, "Experimental models have their own activation switch"))?; if model.is_qwen() { matches!( provider_type, @@ -434,74 +462,37 @@ impl LocalAsrService { } } -async fn release_activation_lease( - adapter: &Arc, - generation_clock: &Arc, - operation_generation: u64, - lease: LocalAsrRuntimeLease, -) -> Result<(), BackendError> { - if generation_clock.load(Ordering::Acquire) != operation_generation { - return Ok(()); - } - adapter.release_lease(lease).await +fn restart_required_error() -> BackendError { + BackendError::new( + BackendErrorCode::InvalidState, + "Restart OpenLess after downloading native runtime components before using local ASR", + ) } -async fn restore_activation_runtime( +async fn ensure_model_installed( adapter: &Arc, - model_store: &Arc, - operation_generation: u64, - requested: &LocalAsrTarget, - previous: Option<&LocalAsrRuntimeLease>, - previous_preferences: &UserPreferences, - progress: ModelPrepareProgressSink, -) -> Vec { - // Native Windows runtimes already replace their own model during prepare; - // preserve their existing same-target rollback behavior. Generic cache - // ownership must be restored even when only the activation owner changed. - if requested.runtime != LocalAsrRuntime::Generic - && previous.map(|lease| &lease.target) == Some(requested) - { - return Vec::new(); - } - let mut errors = Vec::new(); - if let Err(error) = adapter - .release_lease(LocalAsrRuntimeLease { - target: requested.clone(), - generation: operation_generation, - }) - .await - { - errors.push(error); + store: &Arc, + target: &LocalAsrTarget, +) -> Result<(), BackendError> { + if !adapter.supports_model(target) || crate::experimental_asr::ExperimentalAsrModel::from_model_id(target.model_id()).is_some() { + return Err(BackendError::new(BackendErrorCode::Unsupported, "Use Experimental local speech models")); } - if let Some(previous) = previous { - match model_store.runtime_model_dir(&previous.target) { - Ok(model_dir) => { - let source = FoundryRuntimeSource::from_legacy( - &previous_preferences.foundry_local_runtime_source, - ); - match adapter - .prepare(previous.target.clone(), source, model_dir.clone(), progress) - .await - { - Ok(_) => { - if let Err(error) = adapter - .preload_lease( - previous.clone(), - model_dir, - previous_preferences.active_asr_provider.clone(), - ) - .await - { - errors.push(error); - } - } - Err(error) => errors.push(error), - } - } - Err(error) => errors.push(error), - } + let installed = if store.is_native(target)? { + adapter + .inspect_native_models(vec![target.clone()]) + .await? + .iter() + .any(|state| &state.target == target && state.installed) + } else { + store.is_installed(target)? + }; + if !installed { + return Err(BackendError::new( + BackendErrorCode::InvalidState, + "local ASR model is not downloaded", + )); } - errors + Ok(()) } fn activation_error(primary: BackendError, rollback: Vec) -> BackendError { @@ -600,7 +591,6 @@ impl LocalAsrApi for LocalAsrService { let preferences_revision = Arc::clone(&self.preferences_revision); let generation_clock = Arc::clone(&self.activation_generation); let activation_lock = Arc::clone(&self.activation_lock); - let active_lease = Arc::clone(&self.active_lease); Box::pin(async move { let _guard = activation_lock.lock().await; let previous_preferences = preferences.get(); @@ -629,140 +619,31 @@ impl LocalAsrApi for LocalAsrService { .unwrap_or_else(|| request.provider_id.clone()); Self::validate_activation_provider(&request.target, &provider_type)?; let generation = generation_clock.fetch_add(1, Ordering::AcqRel) + 1; - let previous_target = Self::target_for_active_preferences(&previous_preferences); - let previous_lease = active_lease - .lock() - .expect("local ASR activation lease lock poisoned") - .clone() - .filter(|lease| previous_target.as_ref() == Some(&lease.target)) - .or_else(|| { - previous_target.map(|target| LocalAsrRuntimeLease { - target, - generation: generation.saturating_sub(1), - }) - }); - if !model_store.is_native(&request.target)? - && !model_store.is_installed(&request.target)? - { - return Err(BackendError::new( - BackendErrorCode::InvalidState, - "local ASR model is not downloaded", - )); + // Activation is a settings transaction, not a runtime operation. In + // particular, restoring saved settings must never download or load. + ensure_model_installed(&adapter, &model_store, &request.target).await?; + if adapter.restart_required(request.target.runtime) { + return Err(restart_required_error()); } - let model_dir = model_store.runtime_model_dir(&request.target)?; - if let Some(previous) = previous_lease.as_ref() { - adapter.claim_lease(previous.clone()); - } - let progress_events = events.clone(); - let progress: ModelPrepareProgressSink = Arc::new(move |progress| { - progress_events.publish(None, BackendEventKind::LocalAsrPrepareProgress(progress)); - }); - let source = FoundryRuntimeSource::from_legacy( - &previous_preferences.foundry_local_runtime_source, - ); - let prepared_model = match adapter - .prepare( - request.target.clone(), - source, - model_dir.clone(), - Arc::clone(&progress), - ) - .await - { - Ok(model) => model, - Err(error) => { - let rollback = restore_activation_runtime( - &adapter, - &model_store, - generation, - &request.target, - previous_lease.as_ref(), - &previous_preferences, - progress, - ) - .await; - return Err(activation_error(error, rollback)); - } - }; - if let Err(error) = adapter - .preload_lease( - LocalAsrRuntimeLease { - target: request.target.clone(), - generation, - }, - model_dir.clone(), - provider_type.clone(), - ) - .await - { - let rollback = restore_activation_runtime( - &adapter, - &model_store, + let previous_target = Self::target_for_active_preferences(&previous_preferences); + if let Some(previous) = previous_target.filter(|target| { + target != &request.target + || previous_preferences.active_asr_provider != provider_type + }) { + // Evict the old cache without aborting a recording that already + // owns its engine. Never reload it if metadata commit later fails. + let lease = LocalAsrRuntimeLease { + target: previous, generation, - &request.target, - previous_lease.as_ref(), - &previous_preferences, - progress, - ) - .await; - return Err(activation_error(error, rollback)); + }; + adapter.claim_lease(lease.clone()); + adapter.release_lease(lease).await?; } - - // Finish every fallible native operation before committing the - // selected channel. A user may choose another channel while native - // preparation awaits; rolling back an old channel snapshot would - // erase that choice. Channel persistence is the final commit point. - if let Some(previous_lease) = previous_lease.as_ref() { - // Generic is a catalog group, not a single native cache: macOS - // Qwen and Whisper have independent engines. Retire the old - // model lease even when the replacement has the same runtime. - if previous_lease.target.runtime != request.target.runtime - || (request.target.runtime == LocalAsrRuntime::Generic - && previous_lease.target != request.target) - { - if let Err(error) = release_activation_lease( - &adapter, - &generation_clock, - generation, - previous_lease.clone(), - ) - .await - { - let rollback = restore_activation_runtime( - &adapter, - &model_store, - generation, - &request.target, - Some(previous_lease), - &previous_preferences, - progress, - ) - .await; - return Err(activation_error(error, rollback)); - } - } - } - - let committed_previous = match preferences.update(|current| { + let committed_previous = preferences.update(|current| { let before = current.clone(); Self::apply_activation(current, &request.target, &provider_type); before - }) { - Ok(before) => before, - Err(error) => { - let rollback = restore_activation_runtime( - &adapter, - &model_store, - generation, - &request.target, - previous_lease.as_ref(), - &previous_preferences, - progress, - ) - .await; - return Err(activation_error(error, rollback)); - } - }; + })?; let channel_commit = credentials .mutate_channel(ChannelMutation::ActivateLocalAsr { id: channel_id, @@ -778,35 +659,18 @@ impl LocalAsrApi for LocalAsrService { "credential store returned an invalid local ASR activation result", ) }); - let mut rollback = restore_activation_preferences( + let rollback = restore_activation_preferences( &preferences, &committed_previous, &request, &provider_type, ); - rollback.extend( - restore_activation_runtime( - &adapter, - &model_store, - generation, - &request.target, - previous_lease.as_ref(), - &previous_preferences, - progress, - ) - .await, - ); + // Never reload the previous model during compensation. It + // stays selected and can load on the next explicit use. return Err(activation_error(error, rollback)); } }; - *active_lease - .lock() - .expect("local ASR activation lease lock poisoned") = Some(LocalAsrRuntimeLease { - target: request.target.clone(), - generation, - }); - let revision = preferences_revision.fetch_add(1, Ordering::SeqCst) + 1; events.publish( None, @@ -822,10 +686,11 @@ impl LocalAsrApi for LocalAsrService { ) .await; Ok(LocalAsrActivationResult { - target: request.target, + target: request.target.clone(), provider_id, generation, - prepared_model, + // Kept for wire compatibility; activation no longer prepares a model. + prepared_model: request.target.model_id().to_string(), }) }) } @@ -881,7 +746,8 @@ impl LocalAsrApi for LocalAsrService { let adapter = Arc::clone(&self.runtime); Box::pin(async move { let mut models = store.list_models(runtime)?; - models.retain(|model| adapter.supports_model(&model.target)); + models.retain(|model| adapter.supports_model(&model.target) + && crate::experimental_asr::ExperimentalAsrModel::from_model_id(model.target.model_id()).is_none()); let native = adapter .inspect_native_models( models @@ -1051,6 +917,9 @@ impl LocalAsrApi for LocalAsrService { &self, target: LocalAsrTarget, ) -> BoxFuture<'static, Result<(), BackendError>> { + if let Err(error) = self.validate_target(&target) { + return Box::pin(async move { Err(error) }); + } let runtime = target.runtime; let result = self.publish_preferences(|preferences| match runtime { LocalAsrRuntime::Generic => { @@ -1175,11 +1044,30 @@ impl LocalAsrApi for LocalAsrService { target: LocalAsrTarget, mirror: Option, ) -> BoxFuture<'static, Result<(), BackendError>> { + if let Err(error) = self.validate_target(&target) { + return Box::pin(async move { Err(error) }); + } let mirror = mirror.unwrap_or_else(|| { LocalAsrMirror::from_legacy(&self.preferences.get().local_asr_mirror) }); let store = Arc::clone(&self.model_store); - Box::pin(async move { store.download_target(target, mirror).await.map(|_| ()) }) + let adapter = Arc::clone(&self.runtime); + let source = + FoundryRuntimeSource::from_legacy(&self.preferences.get().foundry_local_runtime_source); + let events = self.events.clone(); + Box::pin(async move { + if store.is_native(&target)? { + let progress: ModelPrepareProgressSink = Arc::new(move |progress| { + events.publish(None, BackendEventKind::LocalAsrPrepareProgress(progress)); + }); + adapter + .download_native_model(target, source, progress) + .await + } else { + // No adapter calls here: downloading files cannot initialize inference. + store.download_target(target, mirror).await.map(|_| ()) + } + }) } fn cancel_download( @@ -1203,12 +1091,15 @@ impl LocalAsrApi for LocalAsrService { let progress: ModelPrepareProgressSink = Arc::new(move |progress| { progress_events.publish(None, BackendEventKind::LocalAsrPrepareProgress(progress)); }); - let operation = adapter.prepare(target, source, model_dir, progress); let preferences = Arc::clone(&self.preferences); let model_store = Arc::clone(&self.model_store); let events = self.events.clone(); Box::pin(async move { - let result = operation.await?; + ensure_model_installed(&adapter, &model_store, &target).await?; + if adapter.restart_required(runtime) { + return Err(restart_required_error()); + } + let result = adapter.prepare(target, source, model_dir, progress).await?; Self::publish_runtime_status(preferences, adapter, model_store, events, runtime).await; Ok(result) }) @@ -1235,18 +1126,41 @@ impl LocalAsrApi for LocalAsrService { } fn preload(&self, runtime: LocalAsrRuntime) -> BoxFuture<'static, Result<(), BackendError>> { - let preferences = self.preferences.get(); - let target = match LocalAsrTarget::parse(runtime, Self::active_model(&preferences, runtime)) - { - Ok(target) => target, - Err(error) => return Box::pin(async move { Err(error) }), - }; - let model_dir = match self.prepared_model_dir(&target) { - Ok(path) => path, - Err(error) => return Box::pin(async move { Err(error) }), - }; - self.runtime - .preload(target, model_dir, preferences.active_asr_provider) + let preferences = Arc::clone(&self.preferences); + let credentials = Arc::clone(&self.credentials); + let adapter = Arc::clone(&self.runtime); + let model_store = Arc::clone(&self.model_store); + Box::pin(async move { + // Explicit preload is allowed only for the enabled, selected channel. + // Do not trust legacy preferences: channel IDs can be UUIDs and a + // disabled channel may still have a saved model in preferences. + let active = credentials + .active_provider(crate::ProviderSlot::Asr) + .await?; + let channel = credentials + .list_channels(ChannelKind::Asr) + .await? + .into_iter() + .find(|channel| channel.id == active && channel.enabled) + .ok_or_else(|| { + BackendError::new( + BackendErrorCode::InvalidState, + "Enable and select a local ASR channel before loading its model", + ) + })?; + let mut settings = preferences.get(); + settings.active_asr_provider = channel.provider_type.clone(); + let target = LocalAsrTarget::parse(runtime, Self::active_model(&settings, runtime))?; + Self::validate_activation_provider(&target, &channel.provider_type)?; + ensure_model_installed(&adapter, &model_store, &target).await?; + if adapter.restart_required(runtime) { + return Err(restart_required_error()); + } + let model_dir = model_store.runtime_model_dir(&target)?; + adapter + .preload(target, model_dir, channel.provider_type) + .await + }) } fn delete_model(&self, target: LocalAsrTarget) -> BoxFuture<'static, Result<(), BackendError>> { @@ -1349,6 +1263,9 @@ impl LocalAsrApi for LocalAsrService { )); } let target = LocalAsrTarget::parse(LocalAsrRuntime::Generic, model_id)?; + if !runtime.supports_model(&target) { + return Err(BackendError::new(BackendErrorCode::Unsupported, "This local ASR engine is retired or unavailable")); + } if !model_store.is_native(&target)? && !model_store.is_installed(&target)? { return Err(BackendError::new( BackendErrorCode::InvalidState, diff --git a/openless-all/app/crates/openless-core/src/model_store.rs b/openless-all/app/crates/openless-core/src/model_store.rs index f36310f3a..8160a5961 100644 --- a/openless-all/app/crates/openless-core/src/model_store.rs +++ b/openless-all/app/crates/openless-core/src/model_store.rs @@ -206,6 +206,8 @@ pub struct ModelFileMapping { #[derive(Debug, Clone, PartialEq, Eq)] pub enum ModelFileSelector { + /// Revision-pinned artifacts, including non-Hugging Face Core ML bundles. + Pinned(Vec), QwenRepository, Exact(Vec), Native, @@ -226,7 +228,7 @@ impl ModelFileSelector { .iter() .find(|file| file.remote_path == remote_path) .map(|file| file.local_path.clone()), - Self::Native | Self::Archive { .. } | Self::QwenRepository => None, + Self::Pinned(_) | Self::Native | Self::Archive { .. } | Self::QwenRepository => None, } } } @@ -437,6 +439,7 @@ impl ModelCatalog { ], }, ); + entries.extend(crate::experimental_asr::catalog_entries()); Self { entries } } @@ -485,6 +488,12 @@ fn entry_is_complete(entry: &ModelCatalogEntry, directory: &Path) -> bool { return false; } match &entry.selector { + ModelFileSelector::Pinned(files) => files.iter().all(|file| { + directory + .join(&file.path) + .metadata() + .is_ok_and(|m| m.is_file() && m.len() == file.size_bytes) + }), ModelFileSelector::QwenRepository => true, ModelFileSelector::Exact(files) => files .iter() @@ -685,6 +694,12 @@ impl ModelStore { } } + #[cfg(test)] + pub(crate) fn with_test_catalog(mut self, entries: Vec) -> Self { + self.catalog = ModelCatalog { entries }; + self + } + pub fn with_progress_sink(self, sink: Arc) -> Self { *self .progress @@ -757,6 +772,15 @@ impl ModelStore { .find(target.runtime, target.model_id()) .ok_or_else(|| invalid("unknown local ASR model"))?; let files = match &entry.selector { + ModelFileSelector::Pinned(files) => files + .iter() + .map(|file| LocalAsrRemoteFile { + path: file.url.clone(), + local_path: Some(file.path.clone()), + size_bytes: file.size_bytes, + sha256: file.sha256.clone(), + }) + .collect(), ModelFileSelector::Native => { return Err(BackendError::new( BackendErrorCode::Unsupported, @@ -856,6 +880,9 @@ impl ModelStore { } let (cancelled, _active_guard) = self.begin_active_download(&target)?; let manifest = match entry.selector.clone() { + ModelFileSelector::Pinned(files) => { + ModelManifest::new(target.clone(), &entry.repository, files) + } ModelFileSelector::Native => unreachable!("native selector returned above"), ModelFileSelector::Archive { url, @@ -1286,6 +1313,18 @@ impl ModelStore { Ok(primary) } + /// File-only status for opt-in pinned packages. Runtime caches, Python + /// environments and licenses are not model-download progress. + pub fn pinned_model_status(&self, target: &LocalAsrTarget) -> Result { + let entry = self.catalog.find(target.runtime, target.model_id()) + .ok_or_else(|| invalid("unknown local ASR model"))?; + let ModelFileSelector::Pinned(files) = &entry.selector else { + return Err(BackendError::new(BackendErrorCode::Unsupported, "model does not use pinned files")); + }; + let manifest = ModelManifest::new(target.clone(), entry.repository.clone(), files.clone())?; + self.status(&manifest) + } + pub fn status(&self, manifest: &ModelManifest) -> Result { let dir = self.model_dir(&manifest.target)?; let complete_bytes = manifest @@ -1459,7 +1498,11 @@ impl ModelStore { if cancelled.load(Ordering::Acquire) { return Err(cancelled_error()); } - let end = (offset + self.config.chunk_size_bytes.max(1)).min(file.size_bytes) - 1; + // Optional packages use smaller resumable ranges: large MLX/Core ML + // objects must not repeatedly restart a 32 MiB body on slow links. + // Existing Windows/native and legacy transfers retain their settings. + let chunk_bytes = model_download_chunk_bytes(&manifest.target, self.config.chunk_size_bytes); + let end = (offset + chunk_bytes).min(file.size_bytes) - 1; let mut response = None; let mut last_error = None; for attempt in 0..=self.config.max_retries { @@ -1837,6 +1880,45 @@ impl ModelStore { Ok(()) } + /// Remove only known, application-managed legacy macOS ASR paths. Called + /// for both the configured and default roots so migration cannot resurrect + /// old downloads. Never remove the root, experimental packages or symlink targets. + pub fn retire_macos_local_models(&self, root: &Path) -> Result<(), BackendError> { + // A symlinked root grants no authority over the directory it points at. + // The configured/default root itself must be an owned directory. + if std::fs::symlink_metadata(root).is_ok_and(|metadata| metadata.file_type().is_symlink()) { + return Ok(()); + } + for entry in self.catalog.entries().iter().filter(|entry| { + entry.target.runtime == LocalAsrRuntime::Generic + && crate::LocalAsrModelId::from_wire_id(entry.target.model_id()).is_some() + }) { + let mut paths = legacy_source_candidates(root, &entry.target); + paths.push(root.join(staging_dir_name(&entry.target))); + for path in paths { + let parent = path.parent().expect("known model path has a parent"); + if parent != root && std::fs::symlink_metadata(parent) + .is_ok_and(|m| m.file_type().is_symlink()) + { + // A user-created indirection must not grant delete authority + // over an arbitrary directory outside the managed root. + continue; + } + let metadata = match std::fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => return Err(platform_error(error)), + }; + if metadata.is_dir() && !metadata.file_type().is_symlink() { + std::fs::remove_dir_all(&path).map_err(platform_error)?; + } else { + std::fs::remove_file(&path).map_err(platform_error)?; + } + } + } + Ok(()) + } + pub fn delete_model(&self, target: &LocalAsrTarget) -> Result<(), BackendError> { validate_model_id(target.model_id())?; if self.is_native(target)? { @@ -1883,6 +1965,14 @@ fn migrate_ready_sentinel(model_dir: &Path) -> Result<(), BackendError> { Ok(()) } +fn model_download_chunk_bytes(target: &LocalAsrTarget, configured: u64) -> u64 { + if crate::experimental_asr::ExperimentalAsrModel::from_model_id(target.model_id()).is_some() { + configured.clamp(1, 4 * 1024 * 1024) + } else { + configured.max(1) + } +} + fn legacy_source_candidates(root: &Path, target: &LocalAsrTarget) -> Vec { match target.runtime { LocalAsrRuntime::Generic => { @@ -1937,7 +2027,10 @@ fn mark_ready_if_complete( ModelFileSelector::Archive { required_paths, .. } => required_paths .iter() .all(|path| archive_required_path_is_complete(destination, path)), - ModelFileSelector::QwenRepository | ModelFileSelector::Native => false, + // Pinned artifacts need verified hashes from the downloader, not a legacy ready marker. + ModelFileSelector::Pinned(_) + | ModelFileSelector::QwenRepository + | ModelFileSelector::Native => false, }; if complete { std::fs::write(destination.join(MODEL_READY_SENTINEL), b"ready\n") @@ -2264,7 +2357,9 @@ fn copy_legacy_entry_missing( } Ok(()) } - ModelFileSelector::QwenRepository | ModelFileSelector::Archive { .. } => { + ModelFileSelector::Pinned(_) + | ModelFileSelector::QwenRepository + | ModelFileSelector::Archive { .. } => { copy_dir_missing(source, destination).map_err(platform_error) } ModelFileSelector::Native => Ok(()), @@ -3047,6 +3142,19 @@ async fn wait_until_cancelled(cancelled: Arc) { #[cfg(test)] mod tests { use super::*; + + #[test] + fn experimental_ranges_are_bounded_without_changing_windows_transfers() { + for model in crate::experimental_asr::ExperimentalAsrModel::ALL { + assert_eq!(model_download_chunk_bytes(&model.target(), DEFAULT_MODEL_CHUNK_BYTES), 4 * 1024 * 1024); + assert_eq!(model_download_chunk_bytes(&model.target(), 4), 4); + assert_eq!(model_download_chunk_bytes(&model.target(), 0), 1); + } + let windows = LocalAsrTarget::parse(LocalAsrRuntime::SherpaOnnx, "sense-voice-small-zh").unwrap(); + assert_eq!(model_download_chunk_bytes(&windows, DEFAULT_MODEL_CHUNK_BYTES), DEFAULT_MODEL_CHUNK_BYTES); + let legacy = LocalAsrTarget::parse(LocalAsrRuntime::Generic, "whisper-small").unwrap(); + assert_eq!(model_download_chunk_bytes(&legacy, DEFAULT_MODEL_CHUNK_BYTES), DEFAULT_MODEL_CHUNK_BYTES); + } use std::sync::atomic::AtomicUsize; struct FakeTransport { diff --git a/openless-all/app/crates/openless-core/src/provider_rules.rs b/openless-all/app/crates/openless-core/src/provider_rules.rs index 82a56a7d7..936b32362 100644 --- a/openless-all/app/crates/openless-core/src/provider_rules.rs +++ b/openless-all/app/crates/openless-core/src/provider_rules.rs @@ -47,6 +47,9 @@ const ASR_PROVIDER_TYPES: &[(&str, &str)] = &[ ("foundry-local-whisper", "asrFoundryLocalWhisper"), ("local-whisper", "asrLocalWhisper"), ("sherpa-onnx-local", "asrSherpaOnnxLocal"), + ("experimental-textstream", "asrExperimentalTextStream"), + ("experimental-sensevoice", "asrExperimentalSenseVoice"), + ("experimental-embedding-gemma", "asrExperimentalGemma"), ("local-qwen3-mlx", "asrLocalQwen3Mlx"), ("local-qwen3-c", "asrLocalQwen3C"), ("local-qwen3", "asrLocalQwen3"), @@ -227,16 +230,12 @@ fn provider_descriptor_with_label( let (default_endpoint, default_model, auth_requirement, validation_probe) = match kind { ProviderKind::Asr if is_local_asr_provider(&id) => ( None, - None, + crate::experimental_asr::ExperimentalAsrModel::from_provider(&id).map(|m| m.model_id()), AuthRequirement::None, - // Apple Speech starts instantly and needs no download, so its card - // can run a real validation probe; download-based local engines stay - // unverifiable here and report readiness from the local model page. - if id == "apple-speech" { - ValidationProbe::AsrNativeSilence - } else { - ValidationProbe::Unsupported - }, + // No local descriptor may silently initialize inference through + // the generic credentials probe. Legacy IDs are metadata only; + // experimental readiness belongs to the explicit opt-in service. + ValidationProbe::Unsupported, ), ProviderKind::Asr => ( default_asr_endpoint(&id), @@ -345,6 +344,9 @@ fn is_local_asr_provider(provider_type: &str) -> bool { "foundry-local-whisper" | "local-whisper" | "sherpa-onnx-local" + | "experimental-textstream" + | "experimental-sensevoice" + | "experimental-embedding-gemma" | "local-qwen3-mlx" | "local-qwen3-c" | "local-qwen3" @@ -578,6 +580,10 @@ pub fn default_asr_endpoint(provider_type: &str) -> Option<&'static str> { } pub fn default_asr_model(provider_type: &str) -> Option<&'static str> { + if let Some(model) = crate::experimental_asr::ExperimentalAsrModel::from_provider(provider_type) + { + return Some(model.model_id()); + } match provider_type { "elevenlabs" => Some(crate::asr::elevenlabs::DEFAULT_MODEL), "bailian" => Some(crate::asr::bailian::DEFAULT_MODEL), @@ -1580,11 +1586,11 @@ mod tests { } #[test] - fn apple_speech_probes_natively_while_download_engines_stay_unsupported() { + fn retired_and_experimental_models_never_probe_inference_through_credentials() { let apple = provider_descriptor(ProviderKind::Asr, "apple-speech").unwrap(); - assert_eq!(apple.validation_probe, ValidationProbe::AsrNativeSilence); + assert_eq!(apple.validation_probe, ValidationProbe::Unsupported); assert_eq!(apple.auth_requirement, AuthRequirement::None); - for provider in ["local-whisper", "local-qwen3", "sherpa-onnx-local"] { + for provider in ["local-whisper", "local-qwen3", "sherpa-onnx-local", "experimental-textstream", "experimental-sensevoice", "experimental-embedding-gemma"] { assert_eq!( provider_descriptor(ProviderKind::Asr, provider) .unwrap() diff --git a/openless-all/app/crates/openless-core/src/provider_service.rs b/openless-all/app/crates/openless-core/src/provider_service.rs index 41f360d48..74b607463 100644 --- a/openless-all/app/crates/openless-core/src/provider_service.rs +++ b/openless-all/app/crates/openless-core/src/provider_service.rs @@ -1328,68 +1328,34 @@ mod tests { assert!(!request.contains("authorization:")); } - /// Minimal host-engine fixture: accepts any PCM and finishes successfully, - /// mirroring what the Apple Speech engine returns for a silence probe. - struct FixtureNativeEngine; - - struct FixtureNativeSession; - - impl crate::ports::AudioConsumer for FixtureNativeSession { - fn consume_pcm_chunk(&self, _pcm: &[u8]) {} - } - - impl crate::ports::TranscriptionSession for FixtureNativeSession { - fn finish( - &self, - ) -> BoxFuture<'static, Result> { - Box::pin(async { - Ok(crate::ports::TranscriptOutput { - text: String::new(), - duration_ms: 500, - }) - }) - } - - fn cancel(&self) -> BoxFuture<'static, Result<(), BackendError>> { - Box::pin(async { Ok(()) }) - } - } - - impl TranscriptionEngine for FixtureNativeEngine { + struct ForbiddenNativeEngine; + impl TranscriptionEngine for ForbiddenNativeEngine { fn start( &self, _session_id: SessionId, - context: Arc, + _context: Arc, _partials: Arc, - ) -> BoxFuture<'static, Result, BackendError>> - { - assert_eq!(context.asr.provider_type, "apple-speech"); - Box::pin(async { - Ok(Arc::new(FixtureNativeSession) as Arc) - }) + ) -> BoxFuture<'static, Result, BackendError>> { + panic!("credentials validation must never initialize a local inference engine"); } } #[tokio::test] - async fn apple_speech_validates_through_the_injected_native_engine() { - let credentials = Arc::new(InMemoryCredentialStore::default()); - let channel = - create_channel_with_values(&credentials, ChannelKind::Asr, "apple-speech", &[]).await; - let request = ProviderRequest { - thinking_enabled: false, - kind: ProviderKind::Asr, - channel_id: Some(channel), - }; - - // Without the host engine the native probe stays explicitly unsupported. - let without = ProviderService::new(credentials.clone(), Arc::new(crate::TokioTaskSpawner)); - let error = without.validate(request.clone()).await.unwrap_err(); - assert_eq!(error.code, BackendErrorCode::Unsupported); - - // With the engine injected the probe runs against the real engine port. - let with = ProviderService::new(credentials, Arc::new(crate::TokioTaskSpawner)) - .with_native_transcription(Arc::new(FixtureNativeEngine)); - with.validate(request).await.unwrap(); + async fn retired_and_experimental_channels_reject_generic_probes_before_native_calls() { + for provider in [ + "apple-speech", "local-whisper", "local-qwen3", "local-qwen3-mlx", "local-qwen3-c", + "experimental-textstream", "experimental-sensevoice", "experimental-embedding-gemma", + "foundry-local-whisper", "sherpa-onnx-local", + ] { + let credentials = Arc::new(InMemoryCredentialStore::default()); + let channel = create_channel_with_values(&credentials, ChannelKind::Asr, provider, &[]).await; + let request = ProviderRequest { thinking_enabled: false, kind: ProviderKind::Asr, channel_id: Some(channel) }; + let without = ProviderService::new(credentials.clone(), Arc::new(crate::TokioTaskSpawner)); + assert_eq!(without.validate(request.clone()).await.unwrap_err().code, BackendErrorCode::Unsupported); + let with = ProviderService::new(credentials, Arc::new(crate::TokioTaskSpawner)) + .with_native_transcription(Arc::new(ForbiddenNativeEngine)); + assert_eq!(with.validate(request).await.unwrap_err().code, BackendErrorCode::Unsupported); + } } #[tokio::test] diff --git a/openless-all/app/crates/openless-core/src/settings.rs b/openless-all/app/crates/openless-core/src/settings.rs index eb2100d1a..0f1a573a3 100644 --- a/openless-all/app/crates/openless-core/src/settings.rs +++ b/openless-all/app/crates/openless-core/src/settings.rs @@ -5,6 +5,20 @@ use crate::shared_types::{ HotkeyMode, ShortcutBinding, StylePackHotkey, UserPreferences, WindowsInsertionMode, }; +pub(crate) fn ensure_testing_features_enabled( + preferences: &UserPreferences, +) -> Result<(), BackendError> { + if preferences.testing_features_enabled { + Ok(()) + } else { + Err(BackendError::new( + crate::BackendErrorCode::PermissionDenied, + "Enable features under testing in Advanced settings first", + ) + .retryable(false)) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum SettingsCollisionPolicy { @@ -71,7 +85,7 @@ impl From<&UserPreferences> for HotkeyRuntimeTarget { switch_style: preferences.switch_style_hotkey.clone(), open_app: preferences.open_app_hotkey.clone(), selection_polish: preferences.selection_polish_hotkey.clone(), - coding_agent_enabled: preferences.coding_agent_enabled, + coding_agent_enabled: preferences.less_computer_available(), coding_agent_voice: preferences.coding_agent_voice_hotkey.clone(), coding_agent_panel: preferences.coding_agent_panel_hotkey.clone(), coding_agent_quick: preferences.coding_agent_quick_hotkey.clone(), diff --git a/openless-all/app/crates/openless-core/src/shared_types.rs b/openless-all/app/crates/openless-core/src/shared_types.rs index 070d29fe6..5e35c2d4f 100644 --- a/openless-all/app/crates/openless-core/src/shared_types.rs +++ b/openless-all/app/crates/openless-core/src/shared_types.rs @@ -599,6 +599,9 @@ pub struct UserPreferences { /// a disabled pack auto-enables and activates it. #[serde(default)] pub style_pack_hotkeys: Vec, + /// Device-local opt-in for unfinished features. Hidden and unavailable by default. + #[serde(default)] + pub testing_features_enabled: bool, /// Less Computer: whether enabled. Default off; users must enable it in Advanced settings. #[serde(default)] pub coding_agent_enabled: bool, @@ -873,6 +876,10 @@ pub struct UserPreferences { } impl UserPreferences { + pub fn less_computer_available(&self) -> bool { + self.testing_features_enabled && self.coding_agent_enabled + } + pub fn preserve_style_preferences_from(&mut self, current: &Self) { self.default_mode = current.default_mode; self.enabled_modes = current.enabled_modes.clone(); @@ -1050,6 +1057,8 @@ struct UserPreferencesWire { #[serde(default)] style_pack_hotkeys: Vec, #[serde(default)] + testing_features_enabled: bool, + #[serde(default)] coding_agent_enabled: bool, #[serde(default = "default_coding_agent_provider")] coding_agent_provider: String, @@ -1269,6 +1278,7 @@ impl Default for UserPreferencesWire { switch_style_hotkey: prefs.switch_style_hotkey, open_app_hotkey: prefs.open_app_hotkey, style_pack_hotkeys: prefs.style_pack_hotkeys, + testing_features_enabled: prefs.testing_features_enabled, coding_agent_enabled: prefs.coding_agent_enabled, coding_agent_provider: prefs.coding_agent_provider, coding_agent_model: prefs.coding_agent_model, @@ -1471,6 +1481,7 @@ impl<'de> Deserialize<'de> for UserPreferences { selection_voice_edit_plan_format: wire.selection_voice_edit_plan_format, selection_voice_edit_system_prompt: wire.selection_voice_edit_system_prompt, qa_save_history: wire.qa_save_history, + testing_features_enabled: wire.testing_features_enabled, coding_agent_enabled: wire.coding_agent_enabled, coding_agent_provider: wire.coding_agent_provider, coding_agent_model: wire.coding_agent_model, @@ -1849,6 +1860,7 @@ impl Default for UserPreferences { switch_style_hotkey: default_switch_style_hotkey(), open_app_hotkey: default_open_app_hotkey(), style_pack_hotkeys: Vec::new(), + testing_features_enabled: false, coding_agent_enabled: false, coding_agent_provider: default_coding_agent_provider(), coding_agent_model: None, @@ -2504,12 +2516,9 @@ impl PlatformCapabilities { supports_overlay: true, supports_desktop_hotkey: true, supports_tray: true, - supports_local_asr: cfg!(any( - target_os = "macos", - target_os = "linux", - target_os = "windows" - )), - supports_local_qwen3_mlx: cfg!(all(target_os = "macos", target_arch = "aarch64")), + // Legacy manager is retired on macOS; experiments use their own entry. + supports_local_asr: cfg!(any(target_os = "linux", target_os = "windows")), + supports_local_qwen3_mlx: false, supports_in_app_dictation: false, supports_auto_update: true, supports_voice_edit: true, @@ -2870,6 +2879,29 @@ mod translation_effective_tests { mod tests { use super::*; + #[test] + fn legacy_less_computer_settings_remain_sealed_until_device_opt_in() { + let legacy: UserPreferences = serde_json::from_value(serde_json::json!({ + "codingAgentEnabled": true, + "codingAgentProvider": "codex-cli", + "codingAgentModel": "saved-model" + })) + .unwrap(); + assert!(!UserPreferences::default().testing_features_enabled); + assert!(!legacy.testing_features_enabled); + assert!(legacy.coding_agent_enabled); + assert!(!legacy.less_computer_available()); + let mut opted_in = legacy.clone(); + opted_in.testing_features_enabled = true; + let restored: UserPreferences = + serde_json::from_value(serde_json::to_value(&opted_in).unwrap()).unwrap(); + assert!(restored.less_computer_available()); + opted_in.testing_features_enabled = false; + assert!(!opted_in.less_computer_available()); + assert_eq!(opted_in.coding_agent_model, legacy.coding_agent_model); + assert_eq!(opted_in.coding_agent_provider, legacy.coding_agent_provider); + } + #[test] fn macos_newline_modes_round_trip_legacy_auto_and_line_feed() { assert_eq!(MacosNewlineMode::default(), MacosNewlineMode::Auto); diff --git a/openless-all/app/crates/openless-core/tests/auxiliary_voice_lifecycle.rs b/openless-all/app/crates/openless-core/tests/auxiliary_voice_lifecycle.rs index c3b724510..f83b17ec1 100644 --- a/openless-all/app/crates/openless-core/tests/auxiliary_voice_lifecycle.rs +++ b/openless-all/app/crates/openless-core/tests/auxiliary_voice_lifecycle.rs @@ -335,6 +335,7 @@ fn backend( ); let mut prefs = backend.get_preferences(); prefs.coding_agent_enabled = true; + prefs.testing_features_enabled = true; backend .update_settings(prefs, SettingsUpdateOptions::STRICT, &NoopSettingsRuntime) .unwrap(); diff --git a/openless-all/app/crates/openless-core/tests/local_asr_contract.rs b/openless-all/app/crates/openless-core/tests/local_asr_contract.rs index 0040d52c2..94c02c4c6 100644 --- a/openless-all/app/crates/openless-core/tests/local_asr_contract.rs +++ b/openless-all/app/crates/openless-core/tests/local_asr_contract.rs @@ -82,7 +82,9 @@ struct RecordingLocalAsrRuntime { restart_on_rebind: std::sync::atomic::AtomicBool, emit_prepare_progress: std::sync::atomic::AtomicBool, operations: Mutex>, - during_prepare: Mutex>>, + during_validation: Mutex>>, + missing_native: std::sync::atomic::AtomicBool, + restart_needed: std::sync::atomic::AtomicBool, loaded_models: Arc>>, test_transcript: Mutex, tested_providers: Mutex>, @@ -129,6 +131,7 @@ impl ModelRuntimeAdapter for RecordingLocalAsrRuntime { .unwrap() .clone() .unwrap_or(LocalAsrRuntimeStatus { + restart_required: false, runtime: settings.runtime, provider_id: settings.provider_id, available: true, @@ -155,12 +158,16 @@ impl ModelRuntimeAdapter for RecordingLocalAsrRuntime { &self, targets: Vec, ) -> BoxFuture<'static, Result, BackendError>> { + if let Some(update) = self.during_validation.lock().unwrap().take() { + update(); + } + let installed = !self.missing_native.load(std::sync::atomic::Ordering::SeqCst); Box::pin(async move { Ok(targets .into_iter() .map(|target| NativeModelState { target, - installed: true, + installed, size_bytes: Some(64 * 1024 * 1024), display_name: Some("Whisper Small Native".into()), }) @@ -168,6 +175,21 @@ impl ModelRuntimeAdapter for RecordingLocalAsrRuntime { }) } + fn restart_required(&self, _: LocalAsrRuntime) -> bool { + self.restart_needed.load(std::sync::atomic::Ordering::SeqCst) + } + + fn download_native_model( + &self, + target: LocalAsrTarget, + _: FoundryRuntimeSource, + _: openless_core::ModelPrepareProgressSink, + ) -> BoxFuture<'static, Result<(), BackendError>> { + self.operations.lock().unwrap().push(format!("download:{}", target.model_id())); + self.restart_needed.store(true, std::sync::atomic::Ordering::SeqCst); + self.release(target.runtime) + } + fn delete_native_model( &self, target: LocalAsrTarget, @@ -199,9 +221,6 @@ impl ModelRuntimeAdapter for RecordingLocalAsrRuntime { _: PathBuf, progress: openless_core::ModelPrepareProgressSink, ) -> BoxFuture<'static, Result> { - if let Some(update) = self.during_prepare.lock().unwrap().take() { - update(); - } self.operations .lock() .unwrap() @@ -232,6 +251,7 @@ impl ModelRuntimeAdapter for RecordingLocalAsrRuntime { } let model_id = target.model_id().to_string(); *self.status.lock().unwrap() = Some(LocalAsrRuntimeStatus { + restart_required: false, runtime: target.runtime, provider_id: target.runtime.provider_id().to_string(), available: true, @@ -373,10 +393,16 @@ impl ModelRuntimeAdapter for RecordingLocalAsrRuntime { } } +#[derive(Default)] +struct CredentialStatusPause { + resume: tokio::sync::Notify, +} + #[derive(Default)] struct FailingActiveCredentialStore { inner: InMemoryCredentialStore, fail_set: std::sync::atomic::AtomicBool, + pause_next_status: Mutex>>, } impl CredentialStore for FailingActiveCredentialStore { @@ -384,7 +410,14 @@ impl CredentialStore for FailingActiveCredentialStore { &self, preferences: openless_core::UserPreferences, ) -> BoxFuture<'static, Result> { - self.inner.status(preferences) + let pause = self.pause_next_status.lock().unwrap().take(); + let status = self.inner.status(preferences); + Box::pin(async move { + if let Some(pause) = pause { + pause.resume.notified().await; + } + status.await + }) } fn read( @@ -615,23 +648,13 @@ async fn local_asr_activation_owns_channel_creation_and_enabling() { } } - runtime - .fail_prepare - .store(true, std::sync::atomic::Ordering::SeqCst); - assert!(backend.activate_local_asr(request.clone()).await.is_err()); - assert_eq!( - backend.list_channels(ChannelKind::Asr).await.unwrap(), - previous - ); - assert_eq!( - backend.active_provider(ProviderSlot::Asr).await.unwrap(), - cloud - ); - - runtime - .fail_prepare - .store(false, std::sync::atomic::Ordering::SeqCst); + // Even an unavailable/broken native loader must not be called just + // because a downloaded model is enabled. + runtime.fail_prepare.store(true, std::sync::atomic::Ordering::SeqCst); + runtime.fail_preload.store(true, std::sync::atomic::Ordering::SeqCst); + runtime.operations.lock().unwrap().clear(); let result = backend.activate_local_asr(request).await.unwrap(); + assert!(runtime.operations.lock().unwrap().is_empty()); let channels = backend.list_channels(ChannelKind::Asr).await.unwrap(); assert_eq!(channels.len(), if cloud_exists { 2 } else { 1 }); assert_eq!(channels[0].id, result.provider_id); @@ -651,7 +674,7 @@ async fn local_asr_activation_owns_channel_creation_and_enabling() { } #[tokio::test] -async fn local_asr_activation_prepares_before_committing_provider_and_preferences() { +async fn local_asr_activation_commits_without_preparing_or_loading() { let (data_dir, runtime, backend) = local_asr_backend_with_credentials( Arc::new(InMemoryCredentialStore::default()), Some("openai-compatible"), @@ -686,15 +709,12 @@ async fn local_asr_activation_prepares_before_committing_provider_and_preference backend.active_provider(ProviderSlot::Asr).await.unwrap(), "foundry-local-whisper" ); - assert_eq!( - runtime.operations.lock().unwrap().as_slice(), - ["prepare:whisper-medium", "preload:whisper-medium"] - ); + assert!(runtime.operations.lock().unwrap().is_empty()); let _ = std::fs::remove_dir_all(data_dir); } #[tokio::test] -async fn local_asr_activation_preserves_settings_edited_during_prepare_and_rollback() { +async fn local_asr_activation_preserves_settings_edited_during_validation_and_rollback() { for fail_commit in [false, true] { let credentials = Arc::new(FailingActiveCredentialStore::default()); let (data_dir, runtime, backend) = @@ -708,7 +728,7 @@ async fn local_asr_activation_preserves_settings_edited_during_prepare_and_rollb .fail_set .store(fail_commit, std::sync::atomic::Ordering::SeqCst); let weak = Arc::downgrade(&backend); - *runtime.during_prepare.lock().unwrap() = Some(Box::new(move || { + *runtime.during_validation.lock().unwrap() = Some(Box::new(move || { // A settings IPC can finish while native preparation is pending. // Activation and rollback own the model choice, not this setting. let backend = weak.upgrade().unwrap(); @@ -846,7 +866,7 @@ async fn local_asr_activation_keeps_channel_id_and_provider_type_distinct() { } #[tokio::test] -async fn local_asr_activation_preserves_a_channel_edited_during_preparation() { +async fn local_asr_activation_preserves_a_channel_edited_during_validation() { use futures_util::FutureExt; let credentials = Arc::new(InMemoryCredentialStore::default()); let (data_dir, runtime, backend) = @@ -864,7 +884,7 @@ async fn local_asr_activation_preserves_a_channel_edited_during_preparation() { .await .unwrap(); let edited = local.clone(); - *runtime.during_prepare.lock().unwrap() = Some(Box::new(move || { + *runtime.during_validation.lock().unwrap() = Some(Box::new(move || { credentials .mutate_channel(ChannelMutation::SetProviderType { kind: ChannelKind::Asr, @@ -898,30 +918,140 @@ async fn local_asr_activation_preserves_a_channel_edited_during_preparation() { let _ = std::fs::remove_dir_all(data_dir); } +fn install_fixture(data_dir: &std::path::Path, target: &LocalAsrTarget) -> PathBuf { + let store = ModelStore::new(ModelStoreConfig::new(data_dir.join("models")).unwrap()).unwrap(); + let dir = store.model_dir(target).unwrap(); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(openless_core::MODEL_READY_SENTINEL), b"ready").unwrap(); + match (target.runtime, target.model_id()) { + (LocalAsrRuntime::Generic, "whisper-small") => { + std::fs::write(dir.join("ggml-small.bin"), b"model").unwrap(); + } + (LocalAsrRuntime::SherpaOnnx, _) => { + std::fs::write(dir.join("model.int8.onnx"), b"model").unwrap(); + std::fs::write(dir.join("tokens.txt"), b"tokens").unwrap(); + } + _ => {} + } + dir +} + #[tokio::test] -async fn local_asr_activation_rolls_back_runtime_when_preload_fails() { - let (data_dir, runtime, backend) = local_asr_backend_with_credentials( - Arc::new(InMemoryCredentialStore::default()), - Some("openai-compatible"), - ); - backend +async fn startup_and_read_only_queries_do_not_download_prepare_or_preload() { + for provider in [ + "volcengine", + "local-qwen3-mlx", + "local-whisper", + "foundry-local-whisper", + "sherpa-onnx-local", + ] { + for downloaded in [false, true] { + let (data_dir, runtime, backend) = local_asr_backend_with_credentials( + Arc::new(InMemoryCredentialStore::default()), + Some(provider), + ); + if downloaded { + install_fixture( + &data_dir, + &LocalAsrTarget::parse(LocalAsrRuntime::Generic, "qwen3-asr-0.6b").unwrap(), + ); + } + for kind in [ + LocalAsrRuntime::Generic, + LocalAsrRuntime::Foundry, + LocalAsrRuntime::SherpaOnnx, + ] { + backend.services().local_asr.settings(kind).await.unwrap(); + backend + .services() + .local_asr + .list_models(kind) + .await + .unwrap(); + assert!( + !backend + .services() + .local_asr + .runtime_status(kind) + .await + .unwrap() + .loaded + ); + } + assert!( + runtime.operations.lock().unwrap().is_empty(), + "{provider}, downloaded={downloaded}" + ); + std::fs::remove_dir_all(data_dir).unwrap(); + } + } +} + +#[tokio::test] +async fn missing_models_are_rejected_before_any_native_operation() { + for (kind, model, provider) in [ + ( + LocalAsrRuntime::Generic, + "qwen3-asr-0.6b", + "local-qwen3-mlx", + ), + (LocalAsrRuntime::Generic, "whisper-small", "local-whisper"), + ( + LocalAsrRuntime::SherpaOnnx, + "sense-voice-small-zh", + "sherpa-onnx-local", + ), + ( + LocalAsrRuntime::Foundry, + "whisper-small", + "foundry-local-whisper", + ), + ] { + let (data_dir, runtime, backend) = local_asr_backend(); + runtime + .missing_native + .store(true, std::sync::atomic::Ordering::SeqCst); + let target = LocalAsrTarget::parse(kind, model).unwrap(); + let api = &backend.services().local_asr; + assert!(backend + .activate_local_asr(LocalAsrActivationRequest { + target: target.clone(), + provider_id: provider.into(), + }) + .await + .is_err()); + assert!(api.prepare(target).await.is_err()); + assert!(api.preload(kind).await.is_err()); + assert!(runtime.operations.lock().unwrap().is_empty()); + assert!(backend + .list_channels(ChannelKind::Asr) + .await + .unwrap() + .is_empty()); + std::fs::remove_dir_all(data_dir).unwrap(); + } +} + +#[tokio::test] +async fn activation_commit_failure_does_not_load_or_restore_a_runtime() { + let credentials = Arc::new(FailingActiveCredentialStore::default()); + let (data_dir, runtime, backend) = + local_asr_backend_with_credentials(credentials.clone(), Some("openai-compatible")); + credentials + .inner .set_active_provider(ProviderSlot::Asr, "openai-compatible".into()) .await .unwrap(); - runtime - .fail_preload + credentials + .fail_set .store(true, std::sync::atomic::Ordering::SeqCst); - let target = LocalAsrTarget::parse(LocalAsrRuntime::Foundry, "whisper-medium").unwrap(); - - let error = backend + assert!(backend .activate_local_asr(LocalAsrActivationRequest { - target, + target: LocalAsrTarget::parse(LocalAsrRuntime::Foundry, "whisper-medium").unwrap(), provider_id: "foundry-local-whisper".into(), }) .await - .expect_err("preload failure must abort the transaction"); - - assert_eq!(error.code, BackendErrorCode::Platform); + .is_err()); assert_eq!( backend.get_preferences().active_asr_provider, "openai-compatible" @@ -930,521 +1060,543 @@ async fn local_asr_activation_rolls_back_runtime_when_preload_fails() { backend.active_provider(ProviderSlot::Asr).await.unwrap(), "openai-compatible" ); - assert_eq!( - runtime.operations.lock().unwrap().as_slice(), - [ - "prepare:whisper-medium", - "preload:whisper-medium", - "release-lease:whisper-medium:1" - ] - ); - assert!(!runtime.status.lock().unwrap().as_ref().unwrap().loaded); - let _ = std::fs::remove_dir_all(data_dir); + assert!(runtime.operations.lock().unwrap().is_empty()); + std::fs::remove_dir_all(data_dir).unwrap(); } #[tokio::test] -async fn local_asr_activation_rolls_back_runtime_when_prepare_fails() { - let (data_dir, runtime, backend) = local_asr_backend_with_credentials( - Arc::new(InMemoryCredentialStore::default()), - Some("openai-compatible"), +async fn switching_models_releases_old_inference_but_does_not_load_the_selection() { + let (data_dir, runtime, backend) = local_asr_backend(); + for (model, provider) in [ + ("qwen3-asr-0.6b", "local-qwen3-mlx"), + ("whisper-small", "local-whisper"), + ] { + let target = LocalAsrTarget::parse(LocalAsrRuntime::Generic, model).unwrap(); + install_fixture(&data_dir, &target); + backend + .activate_local_asr(LocalAsrActivationRequest { + target, + provider_id: provider.into(), + }) + .await + .unwrap(); + assert!(runtime.loaded_models.lock().unwrap().is_empty()); + // Explicit loading remains possible; activation alone never calls it. + backend + .services() + .local_asr + .preload(LocalAsrRuntime::Generic) + .await + .unwrap(); + } + assert_eq!( + *runtime.operations.lock().unwrap(), + [ + "preload:qwen3-asr-0.6b", + "release-lease:qwen3-asr-0.6b:2", + "preload:whisper-small", + ] ); backend - .set_active_provider(ProviderSlot::Asr, "openai-compatible".into()) + .set_active_provider(ProviderSlot::Asr, "volcengine".into()) .await .unwrap(); - runtime - .fail_prepare - .store(true, std::sync::atomic::Ordering::SeqCst); - let target = LocalAsrTarget::parse(LocalAsrRuntime::Foundry, "whisper-medium").unwrap(); - - let error = backend - .activate_local_asr(LocalAsrActivationRequest { - target, - provider_id: "foundry-local-whisper".into(), - }) - .await - .expect_err("prepare failure must abort the transaction"); - - assert_eq!(error.code, BackendErrorCode::Platform); - assert_eq!( - backend.get_preferences().active_asr_provider, - "openai-compatible" - ); + assert!(runtime.loaded_models.lock().unwrap().is_empty()); assert_eq!( - runtime.operations.lock().unwrap().as_slice(), - ["prepare:whisper-medium", "release-lease:whisper-medium:1"] + runtime.operations.lock().unwrap().last().unwrap(), + "release:Generic" ); - let _ = std::fs::remove_dir_all(data_dir); + std::fs::remove_dir_all(data_dir).unwrap(); } #[tokio::test] -async fn local_asr_activation_rolls_back_when_active_provider_commit_fails() { - let credentials = Arc::new(FailingActiveCredentialStore::default()); - credentials - .inner - .set_active_provider(ProviderSlot::Asr, "openai-compatible".into()) +async fn channel_reordering_away_from_local_asr_releases_its_runtime() { + let (data_dir, runtime, backend) = local_asr_backend(); + let cloud = backend + .create_channel(ChannelKind::Asr, "volcengine".into(), "Cloud".into()) .await .unwrap(); - let (data_dir, runtime, backend) = - local_asr_backend_with_credentials(credentials.clone(), Some("openai-compatible")); - credentials - .fail_set - .store(true, std::sync::atomic::Ordering::SeqCst); - let target = LocalAsrTarget::parse(LocalAsrRuntime::Foundry, "whisper-medium").unwrap(); - - let error = backend + let target = LocalAsrTarget::parse(LocalAsrRuntime::Generic, "qwen3-asr-0.6b").unwrap(); + install_fixture(&data_dir, &target); + let local = backend .activate_local_asr(LocalAsrActivationRequest { target, - provider_id: "foundry-local-whisper".into(), + provider_id: "local-qwen3-mlx".into(), }) .await - .expect_err("credential commit failure must abort the transaction"); - - assert_eq!(error.code, BackendErrorCode::Platform); - assert_eq!( - backend.get_preferences().active_asr_provider, - "openai-compatible" - ); + .unwrap() + .provider_id; + backend + .services() + .local_asr + .preload(LocalAsrRuntime::Generic) + .await + .unwrap(); + backend + .reorder_channels(ChannelKind::Asr, vec![cloud.clone(), local]) + .await + .unwrap(); assert_eq!( - credentials - .active_provider(ProviderSlot::Asr) - .await - .unwrap(), - "openai-compatible" + backend.active_provider(ProviderSlot::Asr).await.unwrap(), + cloud ); + assert!(runtime.loaded_models.lock().unwrap().is_empty()); assert_eq!( - runtime.operations.lock().unwrap().as_slice(), - [ - "prepare:whisper-medium", - "preload:whisper-medium", - "release-lease:whisper-medium:1" - ] + runtime.operations.lock().unwrap().last().unwrap(), + "release:Generic" ); - let _ = std::fs::remove_dir_all(data_dir); + std::fs::remove_dir_all(data_dir).unwrap(); } #[tokio::test] -async fn local_asr_activation_releases_the_previous_runtime_lease_before_channel_commit() { - let (data_dir, runtime, backend) = local_asr_backend_with_credentials( - Arc::new(InMemoryCredentialStore::default()), - Some("openai-compatible"), - ); +async fn failed_old_runtime_release_preserves_selection_without_loading_any_replacement() { + let (data_dir, runtime, backend) = local_asr_backend(); let qwen = LocalAsrTarget::parse(LocalAsrRuntime::Generic, "qwen3-asr-0.6b").unwrap(); - let qwen_dir = data_dir.join("models").join(qwen.model_id()); - std::fs::create_dir_all(&qwen_dir).unwrap(); - std::fs::write(qwen_dir.join(openless_core::MODEL_READY_SENTINEL), b"ready").unwrap(); - let first = backend + install_fixture(&data_dir, &qwen); + backend .activate_local_asr(LocalAsrActivationRequest { - target: qwen.clone(), - provider_id: "local-qwen3".into(), + target: qwen, + provider_id: "local-qwen3-mlx".into(), }) .await .unwrap(); - let foundry = LocalAsrTarget::parse(LocalAsrRuntime::Foundry, "whisper-medium").unwrap(); - - let second = backend + runtime + .fail_release + .store(true, std::sync::atomic::Ordering::SeqCst); + assert!(backend .activate_local_asr(LocalAsrActivationRequest { - target: foundry.clone(), + target: LocalAsrTarget::parse(LocalAsrRuntime::Foundry, "whisper-medium").unwrap(), provider_id: "foundry-local-whisper".into(), }) .await - .unwrap(); - - assert_eq!((first.generation, second.generation), (1, 2)); - assert_eq!(second.target, foundry); - assert_eq!( - runtime.operations.lock().unwrap().as_slice(), - [ - "prepare:qwen3-asr-0.6b", - "preload:qwen3-asr-0.6b", - "prepare:whisper-medium", - "preload:whisper-medium", - "release-lease:qwen3-asr-0.6b:1" - ] - ); + .is_err()); assert_eq!( - runtime - .status - .lock() - .unwrap() - .as_ref() - .unwrap() - .model_id - .as_deref(), - Some("whisper-medium") + backend.get_preferences().active_asr_provider, + "local-qwen3-mlx" ); - let _ = std::fs::remove_dir_all(data_dir); + assert_eq!(*runtime.operations.lock().unwrap(), ["release-lease:qwen3-asr-0.6b:2"]); + std::fs::remove_dir_all(data_dir).unwrap(); } #[tokio::test] -async fn local_asr_activation_switches_qwen_and_whisper_without_releasing_the_new_generic_lease() { - let (data_dir, runtime, backend) = local_asr_backend_with_credentials( - Arc::new(InMemoryCredentialStore::default()), - Some("openai-compatible"), - ); - let qwen = LocalAsrTarget::parse(LocalAsrRuntime::Generic, "qwen3-asr-0.6b").unwrap(); - let whisper = LocalAsrTarget::parse(LocalAsrRuntime::Generic, "whisper-small").unwrap(); - let qwen_dir = data_dir.join("models").join(qwen.model_id()); - let whisper_dir = data_dir.join("models").join(whisper.model_id()); - std::fs::create_dir_all(&qwen_dir).unwrap(); - std::fs::create_dir_all(&whisper_dir).unwrap(); - std::fs::write(qwen_dir.join(openless_core::MODEL_READY_SENTINEL), b"ready").unwrap(); - std::fs::write( - whisper_dir.join(openless_core::MODEL_READY_SENTINEL), - b"ready", - ) - .unwrap(); - std::fs::write(whisper_dir.join("ggml-small.bin"), b"model").unwrap(); - - let first = backend +async fn disabling_releases_but_keeps_files_and_stays_cold_after_restart() { + let credentials = Arc::new(InMemoryCredentialStore::default()); + let (data_dir, runtime, backend) = + local_asr_backend_with_credentials(credentials.clone(), None); + let target = LocalAsrTarget::parse(LocalAsrRuntime::Generic, "qwen3-asr-0.6b").unwrap(); + let dir = install_fixture(&data_dir, &target); + let activation = backend .activate_local_asr(LocalAsrActivationRequest { - target: qwen, - provider_id: "local-qwen3".into(), + target, + provider_id: "local-qwen3-mlx".into(), }) .await .unwrap(); - let second = backend - .activate_local_asr(LocalAsrActivationRequest { - target: whisper.clone(), - provider_id: "local-whisper".into(), - }) + backend + .services() + .local_asr + .preload(LocalAsrRuntime::Generic) + .await + .unwrap(); + backend + .set_channel_enabled(ChannelKind::Asr, activation.provider_id, false) .await .unwrap(); + assert!(runtime.loaded_models.lock().unwrap().is_empty()); + assert!(dir.join(openless_core::MODEL_READY_SENTINEL).is_file()); + assert!(backend + .services() + .local_asr + .preload(LocalAsrRuntime::Generic) + .await + .is_err()); + drop(backend); - assert_eq!((first.generation, second.generation), (1, 2)); - assert_eq!( - *runtime.loaded_models.lock().unwrap(), - std::collections::HashMap::from([(whisper.clone(), second.generation)]), - "Qwen and Whisper use separate caches; only the new lease may remain loaded" - ); - assert_eq!( - runtime.operations.lock().unwrap().as_slice(), - [ - "prepare:qwen3-asr-0.6b", - "preload:qwen3-asr-0.6b", - "prepare:whisper-small", - "preload:whisper-small", - "release-lease:qwen3-asr-0.6b:1" - ] - ); - assert_eq!( - runtime - .status - .lock() - .unwrap() - .as_ref() - .unwrap() - .model_id - .as_deref(), - Some(whisper.model_id()) - ); - // Reuse the first model under a new activation generation, then deliver - // its obsolete lease. A matching model ID alone must not unload it. - let third = backend - .activate_local_asr(LocalAsrActivationRequest { - target: first.target.clone(), - provider_id: "local-qwen3".into(), - }) - .await - .unwrap(); - runtime - .release_lease(LocalAsrRuntimeLease { - target: first.target.clone(), - generation: first.generation, - }) - .await - .unwrap(); - assert_eq!( - *runtime.loaded_models.lock().unwrap(), - std::collections::HashMap::from([(third.target.clone(), third.generation)]), - ); - assert!(runtime.status.lock().unwrap().as_ref().unwrap().loaded); - // An ordinary preload also supersedes the activation's ownership. - runtime - .preload(third.target.clone(), PathBuf::new(), "local-qwen3".into()) - .await - .unwrap(); - runtime - .release_lease(LocalAsrRuntimeLease { - target: third.target.clone(), - generation: third.generation, - }) - .await - .unwrap(); - assert_eq!( - runtime.loaded_models.lock().unwrap().get(&third.target), - Some(&0) - ); - // The channel settings path may have selected and normally preloaded a - // different model since the last atomic activation. Retire that current - // cache, not the stale lease remembered by the model page. - backend + let fresh_runtime = Arc::new(RecordingLocalAsrRuntime::default()); + let mut dependencies = BackendDependencies::unsupported(); + dependencies.local_asr_runtime = Some(fresh_runtime.clone()); + dependencies.credential_store = credentials; + let restarted = OpenLessBackend::new( + BackendConfig { + data_dir: data_dir.clone(), + ..BackendConfig::default() + }, + dependencies, + ) + .unwrap(); + assert!(restarted .services() .local_asr - .release(LocalAsrRuntime::Generic) + .preload(LocalAsrRuntime::Generic) .await - .unwrap(); - let mut preferences = backend.get_preferences(); - preferences.active_asr_provider = "local-whisper".into(); - backend - .update_settings( - preferences, - openless_core::SettingsUpdateOptions::SETTINGS_DOCUMENT, - &openless_core::NoopSettingsRuntime, - ) - .unwrap(); + .is_err()); + assert!(fresh_runtime.operations.lock().unwrap().is_empty()); + assert!(!restarted.list_channels(ChannelKind::Asr).await.unwrap()[0].enabled); + std::fs::remove_dir_all(data_dir).unwrap(); +} + +async fn loaded_local_channel( + credentials: Arc, +) -> ( + PathBuf, + Arc, + OpenLessBackend, + String, +) { + let (data_dir, runtime, backend) = local_asr_backend_with_credentials(credentials, None); + let target = LocalAsrTarget::parse(LocalAsrRuntime::Generic, "qwen3-asr-0.6b").unwrap(); + install_fixture(&data_dir, &target); + let local = backend + .activate_local_asr(LocalAsrActivationRequest { + target, + provider_id: "local-qwen3-mlx".into(), + }) + .await + .unwrap() + .provider_id; backend .services() .local_asr .preload(LocalAsrRuntime::Generic) .await .unwrap(); - let fourth = backend - .activate_local_asr(LocalAsrActivationRequest { - target: third.target.clone(), - provider_id: "local-qwen3".into(), - }) - .await - .unwrap(); - assert_eq!( - *runtime.loaded_models.lock().unwrap(), - std::collections::HashMap::from([(fourth.target, fourth.generation)]), - ); - let _ = std::fs::remove_dir_all(data_dir); + runtime.operations.lock().unwrap().clear(); + (data_dir, runtime, backend, local) +} + +async fn retire_channel(backend: &OpenLessBackend, id: String, action: &str) { + match action { + "disable" => backend + .set_channel_enabled(ChannelKind::Asr, id, false) + .await + .unwrap(), + "delete" => backend.delete_channel(ChannelKind::Asr, id).await.unwrap(), + "provider_type" => backend + .set_channel_provider_type(ChannelKind::Asr, id, "openai-compatible".into()) + .await + .unwrap(), + "delete_blank" => assert!(backend + .delete_channel_if_blank(ChannelKind::Asr, id) + .await + .unwrap()), + _ => panic!("unknown channel retirement fixture"), + } } #[tokio::test] -async fn generic_activation_failure_restores_the_previous_independent_cache_lease() { - for failure in ["prepare", "preload", "metadata"] { - let credentials = Arc::new(FailingActiveCredentialStore::default()); - let (data_dir, runtime, backend) = - local_asr_backend_with_credentials(credentials.clone(), Some("openai-compatible")); - let qwen = LocalAsrTarget::parse(LocalAsrRuntime::Generic, "qwen3-asr-0.6b").unwrap(); - let whisper = LocalAsrTarget::parse(LocalAsrRuntime::Generic, "whisper-small").unwrap(); - for target in [&qwen, &whisper] { - let dir = data_dir.join("models").join(target.model_id()); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join(openless_core::MODEL_READY_SENTINEL), b"ready").unwrap(); - if target == &whisper { - std::fs::write(dir.join("ggml-small.bin"), b"model").unwrap(); - } - } - let first = backend - .activate_local_asr(LocalAsrActivationRequest { - target: qwen.clone(), - provider_id: "local-qwen3".into(), - }) +async fn non_active_same_provider_channel_edits_do_not_release_the_active_runtime() { + for action in ["disable", "delete", "provider_type", "delete_blank"] { + let (data_dir, runtime, backend, local) = + loaded_local_channel(Arc::new(InMemoryCredentialStore::default())).await; + let spare = backend + .create_channel(ChannelKind::Asr, "local-qwen3-mlx".into(), String::new()) .await .unwrap(); - match failure { - "prepare" => &runtime.fail_prepare, - "preload" => &runtime.fail_preload, - _ => &credentials.fail_set, - } - .store(true, std::sync::atomic::Ordering::SeqCst); - assert!( - backend - .activate_local_asr(LocalAsrActivationRequest { - target: whisper, - provider_id: "local-whisper".into(), - }) - .await - .is_err(), - "{failure}" - ); - assert_eq!( - backend.get_preferences().active_asr_provider, - "local-qwen3", - "{failure}" - ); + retire_channel(&backend, spare, action).await; assert_eq!( backend.active_provider(ProviderSlot::Asr).await.unwrap(), - first.provider_id + local ); - assert_eq!( - *runtime.loaded_models.lock().unwrap(), - std::collections::HashMap::from([(qwen, first.generation)]), - "{failure}: rollback must restore exactly the previous cache and its owner" + assert!( + runtime.operations.lock().unwrap().is_empty(), + "{action} retired another channel's runtime" ); - let _ = std::fs::remove_dir_all(data_dir); + assert!(!runtime.loaded_models.lock().unwrap().is_empty()); + std::fs::remove_dir_all(data_dir).unwrap(); } } #[tokio::test] -async fn native_activation_without_saved_lease_preserves_the_prepared_model() { - for (kind, provider) in [ - (LocalAsrRuntime::Foundry, "foundry-local-whisper"), - (LocalAsrRuntime::SherpaOnnx, "sherpa-onnx-local"), - ] { - let (data_dir, runtime, backend) = local_asr_backend_with_credentials( - Arc::new(InMemoryCredentialStore::default()), - Some(provider), - ); - let target = LocalAsrTarget::parse(kind, kind.default_model()).unwrap(); - if kind == LocalAsrRuntime::SherpaOnnx { - let store = - ModelStore::new(ModelStoreConfig::new(data_dir.join("models")).unwrap()).unwrap(); - let dir = store.model_dir(&target).unwrap(); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join(openless_core::MODEL_READY_SENTINEL), b"ready").unwrap(); - std::fs::write(dir.join("model.int8.onnx"), b"model").unwrap(); - std::fs::write(dir.join("tokens.txt"), b"tokens").unwrap(); - } +async fn active_local_channel_edits_release_its_runtime_when_no_local_owner_remains() { + for action in ["disable", "delete", "provider_type", "delete_blank"] { + let (data_dir, runtime, backend, local) = + loaded_local_channel(Arc::new(InMemoryCredentialStore::default())).await; backend - .services() - .local_asr - .prepare(target.clone()) - .await - .unwrap(); - runtime.operations.lock().unwrap().clear(); - let request = LocalAsrActivationRequest { - target: target.clone(), - provider_id: provider.into(), - }; - runtime - .fail_prepare - .store(true, std::sync::atomic::Ordering::SeqCst); - assert!(backend.activate_local_asr(request.clone()).await.is_err()); - assert!(runtime.status.lock().unwrap().as_ref().unwrap().loaded); - backend.activate_local_asr(request).await.unwrap(); - assert!(runtime - .operations - .lock() - .unwrap() - .iter() - .all(|operation| !operation.starts_with("release"))); - let status = backend - .services() - .local_asr - .runtime_status(kind) + .create_channel(ChannelKind::Asr, "volcengine".into(), "Cloud".into()) .await .unwrap(); - assert_eq!(status.model_id.as_deref(), Some(target.model_id())); - assert!(status.loaded); - let _ = std::fs::remove_dir_all(data_dir); + retire_channel(&backend, local, action).await; + assert_eq!( + *runtime.operations.lock().unwrap(), + ["release:Generic"], + "{action}" + ); + assert!(runtime.loaded_models.lock().unwrap().is_empty()); + std::fs::remove_dir_all(data_dir).unwrap(); } } #[tokio::test] -async fn local_asr_activation_restores_state_when_the_previous_lease_cannot_release() { - let (data_dir, runtime, backend) = local_asr_backend_with_credentials( - Arc::new(InMemoryCredentialStore::default()), - Some("openai-compatible"), - ); - let qwen = LocalAsrTarget::parse(LocalAsrRuntime::Generic, "qwen3-asr-0.6b").unwrap(); - let qwen_dir = data_dir.join("models").join(qwen.model_id()); - std::fs::create_dir_all(&qwen_dir).unwrap(); - std::fs::write(qwen_dir.join(openless_core::MODEL_READY_SENTINEL), b"ready").unwrap(); - backend - .activate_local_asr(LocalAsrActivationRequest { - target: qwen.clone(), - provider_id: "local-qwen3".into(), - }) +async fn committed_channel_changes_are_published_even_when_runtime_release_fails() { + let (data_dir, runtime, backend, local) = + loaded_local_channel(Arc::new(InMemoryCredentialStore::default())).await; + let cloud = backend + .create_channel(ChannelKind::Asr, "volcengine".into(), "Cloud".into()) .await .unwrap(); + let mut events = backend.subscribe(); runtime .fail_release .store(true, std::sync::atomic::Ordering::SeqCst); - let foundry = LocalAsrTarget::parse(LocalAsrRuntime::Foundry, "whisper-medium").unwrap(); - let error = backend - .activate_local_asr(LocalAsrActivationRequest { - target: foundry, - provider_id: "foundry-local-whisper".into(), - }) + .set_channel_enabled(ChannelKind::Asr, local, false) .await - .expect_err("old runtime release must be part of the transaction"); - + .unwrap_err(); assert_eq!(error.code, BackendErrorCode::Platform); - assert_eq!(backend.get_preferences().active_asr_provider, "local-qwen3"); + assert_eq!(backend.active_provider(ProviderSlot::Asr).await.unwrap(), cloud); + assert!(matches!( + events.try_recv().unwrap().kind, + BackendEventKind::CredentialsChanged(_) + )); + std::fs::remove_dir_all(data_dir).unwrap(); +} + +#[tokio::test] +async fn a_same_runtime_channel_takeover_preserves_the_shared_loaded_instance() { + let (data_dir, runtime, backend, local) = + loaded_local_channel(Arc::new(InMemoryCredentialStore::default())).await; + let replacement = backend + .create_channel( + ChannelKind::Asr, + "local-qwen3-mlx".into(), + "Replacement".into(), + ) + .await + .unwrap(); + backend + .set_channel_enabled(ChannelKind::Asr, local, false) + .await + .unwrap(); assert_eq!( backend.active_provider(ProviderSlot::Asr).await.unwrap(), - "local-qwen3" - ); - assert_eq!( - runtime - .status - .lock() - .unwrap() - .as_ref() - .unwrap() - .model_id - .as_deref(), - Some(qwen.model_id()) - ); - assert_eq!( - runtime.operations.lock().unwrap().as_slice(), - [ - "prepare:qwen3-asr-0.6b", - "preload:qwen3-asr-0.6b", - "prepare:whisper-medium", - "preload:whisper-medium", - "release-lease:qwen3-asr-0.6b:1", - "release-lease:whisper-medium:2", - "prepare:qwen3-asr-0.6b", - "preload:qwen3-asr-0.6b" - ] + replacement ); - let _ = std::fs::remove_dir_all(data_dir); + assert!(runtime.operations.lock().unwrap().is_empty()); + assert!(!runtime.loaded_models.lock().unwrap().is_empty()); + std::fs::remove_dir_all(data_dir).unwrap(); } #[tokio::test] -async fn failed_activation_restores_the_channel_selected_during_native_preparation() { - use futures_util::FutureExt; - let credentials = Arc::new(InMemoryCredentialStore::default()); - let (data_dir, runtime, backend) = - local_asr_backend_with_credentials(credentials.clone(), Some("local-qwen3")); - let qwen = LocalAsrTarget::parse(LocalAsrRuntime::Generic, "qwen3-asr-0.6b").unwrap(); - let qwen_dir = data_dir.join("models").join(qwen.model_id()); - std::fs::create_dir_all(&qwen_dir).unwrap(); - std::fs::write(qwen_dir.join(openless_core::MODEL_READY_SENTINEL), b"ready").unwrap(); - backend - .activate_local_asr(LocalAsrActivationRequest { - target: qwen, - provider_id: "local-qwen3".into(), +async fn late_credentials_refresh_never_releases_a_reselected_or_reloaded_local_owner() { + for action in ["disable", "delete", "provider_type", "select", "reorder"] { + let credentials = Arc::new(FailingActiveCredentialStore::default()); + let (data_dir, runtime, backend, local) = loaded_local_channel(credentials.clone()).await; + let cloud = backend + .create_channel(ChannelKind::Asr, "volcengine".into(), "Cloud".into()) + .await + .unwrap(); + let pause = Arc::new(CredentialStatusPause::default()); + *credentials.pause_next_status.lock().unwrap() = Some(pause.clone()); + let retiring: BoxFuture<'_, ()> = match action { + "select" => Box::pin(async { + backend + .set_active_provider(ProviderSlot::Asr, cloud.clone()) + .await + .unwrap(); + }), + "reorder" => Box::pin(async { + backend + .reorder_channels(ChannelKind::Asr, vec![cloud.clone(), local.clone()]) + .await + .unwrap(); + }), + _ => Box::pin(retire_channel(&backend, local.clone(), action)), + }; + tokio::pin!(retiring); + assert!(futures_util::poll!(retiring.as_mut()).is_pending()); + assert_eq!( + *runtime.operations.lock().unwrap(), + ["release:Generic"], + "{action}: release must precede refresh" + ); + tokio::time::timeout(std::time::Duration::from_secs(2), async { + if action == "provider_type" { + backend + .set_channel_provider_type( + ChannelKind::Asr, + local.clone(), + "local-qwen3-mlx".into(), + ) + .await + .unwrap(); + } + backend + .activate_local_asr(LocalAsrActivationRequest { + target: LocalAsrTarget::parse(LocalAsrRuntime::Generic, "qwen3-asr-0.6b") + .unwrap(), + provider_id: "local-qwen3-mlx".into(), + }) + .await + .unwrap(); + backend + .services() + .local_asr + .preload(LocalAsrRuntime::Generic) + .await + .unwrap(); }) .await + .expect("a delayed status read must not lock out a replacement owner"); + let reloaded = runtime.loaded_models.lock().unwrap().clone(); + assert!(!reloaded.is_empty()); + pause.resume.notify_one(); + retiring.await; + assert_eq!( + *runtime.loaded_models.lock().unwrap(), + reloaded, + "{action}: stale cleanup unloaded the new instance" + ); + assert_eq!( + *runtime.operations.lock().unwrap(), + ["release:Generic", "preload:qwen3-asr-0.6b"], + "{action}" + ); + std::fs::remove_dir_all(data_dir).unwrap(); + } +} + +#[tokio::test] +async fn preload_cannot_use_a_saved_local_model_while_cloud_is_selected() { + let (data_dir, runtime, backend) = local_asr_backend(); + let target = LocalAsrTarget::parse(LocalAsrRuntime::Generic, "qwen3-asr-0.6b").unwrap(); + install_fixture(&data_dir, &target); + backend + .create_channel(ChannelKind::Asr, "volcengine".into(), "Cloud".into()) + .await + .unwrap(); + backend + .services() + .local_asr + .set_active_model(target) + .await .unwrap(); - let backend = Arc::new(backend); - let weak = Arc::downgrade(&backend); - *runtime.during_prepare.lock().unwrap() = Some(Box::new(move || { - // The in-memory store completes synchronously. This models the user - // choosing C while preparation for B is still awaiting native work. - credentials - .set_active_provider(ProviderSlot::Asr, "openai-compatible".into()) - .now_or_never() - .unwrap() - .unwrap(); - let backend = weak.upgrade().unwrap(); - let mut next = backend.get_preferences(); - next.active_asr_provider = "openai-compatible".into(); - backend - .update_settings( - next, - openless_core::SettingsUpdateOptions::SETTINGS_DOCUMENT, - &openless_core::NoopSettingsRuntime, - ) - .unwrap(); - })); - runtime - .fail_release - .store(true, std::sync::atomic::Ordering::SeqCst); assert!(backend - .activate_local_asr(LocalAsrActivationRequest { - target: LocalAsrTarget::parse(LocalAsrRuntime::Foundry, "whisper-medium").unwrap(), - provider_id: "foundry-local-whisper".into(), - }) + .services() + .local_asr + .preload(LocalAsrRuntime::Generic) .await .is_err()); + assert!(runtime.operations.lock().unwrap().is_empty()); + std::fs::remove_dir_all(data_dir).unwrap(); +} + +#[tokio::test] +async fn native_download_does_not_enable_and_requires_restart_before_activation() { + let (data_dir, runtime, backend) = local_asr_backend(); + let before = backend.get_preferences(); + let target = LocalAsrTarget::parse(LocalAsrRuntime::Foundry, "whisper-medium").unwrap(); + backend + .services() + .local_asr + .start_download(target.clone(), None) + .await + .unwrap(); assert_eq!( - backend.get_preferences().active_asr_provider, - "openai-compatible" + *runtime.operations.lock().unwrap(), + ["download:whisper-medium", "release:Foundry"] ); + assert!(backend + .list_channels(ChannelKind::Asr) + .await + .unwrap() + .is_empty()); assert_eq!( - backend.active_provider(ProviderSlot::Asr).await.unwrap(), - "openai-compatible" + backend.get_preferences().active_asr_provider, + before.active_asr_provider ); - let _ = std::fs::remove_dir_all(data_dir); + let status = backend + .services() + .local_asr + .runtime_status(LocalAsrRuntime::Foundry) + .await + .unwrap(); + assert!(status.restart_required); + assert!(!status.loaded); + assert!(backend + .activate_local_asr(LocalAsrActivationRequest { + target: target.clone(), + provider_id: "foundry-local-whisper".into(), + }) + .await + .is_err()); + assert!(backend.services().local_asr.prepare(target).await.is_err()); + std::fs::remove_dir_all(data_dir).unwrap(); +} + +struct DownloadOnlyTransport; +impl openless_core::ModelTransport for DownloadOnlyTransport { + fn request( + &self, + request: openless_core::ModelTransportRequest, + ) -> BoxFuture<'static, Result> { + Box::pin(async move { + let (status, bytes, content_range) = if request.url.contains("/tree/") { + ( + 200, + br#"[{"type":"file","path":"ggml-small.bin","size":8}]"#.to_vec(), + None, + ) + } else { + let (start, end) = request.range.unwrap_or((0, 7)); + ( + 206, + b"testdata"[start as usize..=end as usize].to_vec(), + Some(openless_core::ModelContentRange { + start, + end, + total: 8, + }), + ) + }; + Ok(openless_core::ModelTransportResponse { + status, + metadata: openless_core::ModelHttpMetadata { + content_length: Some(bytes.len() as u64), + content_range, + link: None, + }, + bytes, + }) + }) + } +} + +#[tokio::test] +async fn successful_file_download_never_calls_the_runtime_or_activates_a_channel() { + let data_dir = + std::env::temp_dir().join(format!("openless-download-only-{}", uuid::Uuid::new_v4())); + let runtime = Arc::new(RecordingLocalAsrRuntime::default()); + let store = Arc::new(ModelStore::with_transport( + ModelStoreConfig::new(data_dir.join("models")).unwrap(), + Arc::new(DownloadOnlyTransport), + )); + let mut dependencies = BackendDependencies::unsupported(); + dependencies.local_asr_runtime = Some(runtime.clone()); + dependencies.credential_store = Arc::new(InMemoryCredentialStore::default()); + dependencies.services.configure_model_store(store.clone()); + let backend = OpenLessBackend::new( + BackendConfig { + data_dir: data_dir.clone(), + ..BackendConfig::default() + }, + dependencies, + ) + .unwrap(); + let target = LocalAsrTarget::parse(LocalAsrRuntime::Generic, "whisper-small").unwrap(); + backend + .services() + .local_asr + .start_download(target.clone(), None) + .await + .unwrap(); + assert!(store.is_installed(&target).unwrap()); + assert!(runtime.operations.lock().unwrap().is_empty()); + assert!(backend + .list_channels(ChannelKind::Asr) + .await + .unwrap() + .is_empty()); + std::fs::remove_dir_all(data_dir).unwrap(); } #[test] diff --git a/openless-all/app/crates/openless-core/tests/macos_asr_retirement.rs b/openless-all/app/crates/openless-core/tests/macos_asr_retirement.rs new file mode 100644 index 000000000..648fecc8e --- /dev/null +++ b/openless-all/app/crates/openless-core/tests/macos_asr_retirement.rs @@ -0,0 +1,209 @@ +use openless_core::{ + credentials::is_retired_macos_asr_provider, BackendConfig, BackendDependencies, ChannelKind, + ChannelSummary, CredentialMetadata, InMemoryCredentialStore, LocalAsrActivationRequest, + LocalAsrRuntime, LocalAsrTarget, ModelRuntimeAdapter, ModelStore, ModelStoreConfig, + OpenLessBackend, ProviderSlot, +}; +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; + +struct Temp(PathBuf); +impl Temp { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "openless-macos-retirement-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&path).unwrap(); + Self(path) + } +} +impl Drop for Temp { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} +fn write(path: &Path) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, b"fixture").unwrap(); +} +fn channel(id: &str, provider: &str, enabled: bool, order: u32) -> ChannelSummary { + ChannelSummary { + id: id.into(), + name: format!("Custom {id}"), + provider_type: provider.into(), + enabled, + order, + last_test: None, + } +} + +#[test] +fn retirement_disables_renamed_legacy_channels_but_preserves_cloud_windows_and_experiments() { + let mut metadata = CredentialMetadata::from_parts( + vec![ + channel("uuid-mlx", "local-qwen3-mlx", true, 0), + channel("cloud", "volcengine", true, 1), + channel("sense", "experimental-sensevoice", true, 2), + channel("windows", "foundry-local-whisper", true, 3), + channel("apple", "apple-speech", true, 4), + ], + vec![channel("llm", "openai", true, 0)], + "uuid-mlx", + "llm", + "omni", + 8, + ); + assert!(metadata.retire_macos_local_asr()); + let channels = metadata.list_channels(ChannelKind::Asr); + for channel in &channels { + assert_eq!( + channel.enabled, + !is_retired_macos_asr_provider(&channel.provider_type) + ); + } + assert_eq!( + channels.iter().find(|c| c.id == "uuid-mlx").unwrap().name, + "Custom uuid-mlx" + ); + assert_eq!(metadata.active_provider(ProviderSlot::Asr), "cloud"); + assert_eq!(metadata.active_provider(ProviderSlot::Llm), "llm"); + assert_eq!(metadata.active_provider(ProviderSlot::Omni), "omni"); + let revision = metadata.revision(); + assert!(!metadata.retire_macos_local_asr()); + assert_eq!(metadata.revision(), revision); +} + +#[test] +fn retirement_does_not_turn_on_a_disabled_cloud_channel_or_break_metadata_revisions() { + let metadata = CredentialMetadata::from_parts( + vec![ + channel("local", "local-whisper", true, 0), + channel("cloud", "volcengine", false, 1), + ], + vec![], + "local", + "", + "", + 4, + ); + let view = metadata.clone().retired_macos_view(); + assert_eq!(view.revision(), metadata.revision()); + assert_eq!(view.active_provider(ProviderSlot::Asr), ""); + assert!(view + .list_channels(ChannelKind::Asr) + .iter() + .all(|c| !c.enabled)); +} + +#[test] +fn cleanup_removes_only_known_legacy_paths_and_is_repeatable() { + let temp = Temp::new(); + let root = temp.0.join("OpenLess/models"); + let store = ModelStore::new(ModelStoreConfig::new(root.clone()).unwrap()).unwrap(); + let old = [ + root.join("qwen3-asr-0.6b/model.safetensors"), + root.join("qwen3-asr/qwen3-asr-1.7b/model.safetensors"), + root.join("whisper-small/ggml-small.bin"), + root.join("whisper-large-v3-turbo-q5/ggml-large-v3-turbo-q5_0.bin"), + root.join(".qwen3-asr-0.6b.staging/part"), + ]; + let preserved = [ + root.join("experimental-sensevoice-small/model.int8.onnx"), + root.join("experimental-embedding-gemma-en/reader_P6.mlmodelc/data"), + root.join("sherpa-onnx/sense-voice-small-zh/model.int8.onnx"), + root.join("unrelated/notes.txt"), + temp.0.join("other-app/model.safetensors"), + ]; + for path in old.iter().chain(preserved.iter()) { + write(path); + } + store.retire_macos_local_models(&root).unwrap(); + store.retire_macos_local_models(&root).unwrap(); + assert!(old.iter().all(|path| !path.exists())); + assert!(preserved.iter().all(|path| path.exists())); + assert!(root.is_dir()); +} + +#[cfg(unix)] +#[test] +fn cleanup_never_follows_model_or_parent_symlinks() { + use std::os::unix::fs::symlink; + let temp = Temp::new(); + let root = temp.0.join("models"); + let outside = temp.0.join("other-app"); + write(&outside.join("qwen3-asr-0.6b/weights")); + write(&outside.join("keep")); + std::fs::create_dir_all(&root).unwrap(); + symlink(&outside, root.join("qwen3-asr")).unwrap(); + symlink(&outside, root.join("whisper-small")).unwrap(); + let store = ModelStore::new(ModelStoreConfig::new(root.clone()).unwrap()).unwrap(); + store.retire_macos_local_models(&root).unwrap(); + assert!(outside.join("keep").is_file()); + assert!(outside.join("qwen3-asr-0.6b/weights").is_file()); + assert!(std::fs::symlink_metadata(root.join("whisper-small")).is_err()); +} + +#[cfg(unix)] +#[test] +fn cleanup_never_follows_a_symlinked_models_root() { + use std::os::unix::fs::symlink; + let temp = Temp::new(); + let outside = temp.0.join("other-app"); + write(&outside.join("whisper-small/weights")); + let root = temp.0.join("models"); + symlink(&outside, &root).unwrap(); + let store = ModelStore::new(ModelStoreConfig::new(root.clone()).unwrap()).unwrap(); + store.retire_macos_local_models(&root).unwrap(); + assert!(outside.join("whisper-small/weights").is_file()); + assert!(std::fs::symlink_metadata(root) + .unwrap() + .file_type() + .is_symlink()); +} + +struct RetiredRuntime; +impl ModelRuntimeAdapter for RetiredRuntime {} + +#[tokio::test] +async fn retired_host_rejects_download_activation_prepare_preload_and_test_before_native_calls() { + let temp = Temp::new(); + let mut deps = BackendDependencies::unsupported(); + deps.local_asr_runtime = Some(Arc::new(RetiredRuntime)); + deps.credential_store = Arc::new(InMemoryCredentialStore::default()); + let backend = OpenLessBackend::new( + BackendConfig { + data_dir: temp.0.clone(), + ..BackendConfig::default() + }, + deps, + ) + .unwrap(); + let target = LocalAsrTarget::parse(LocalAsrRuntime::Generic, "qwen3-asr-0.6b").unwrap(); + let api = &backend.services().local_asr; + assert!(api.start_download(target.clone(), None).await.is_err()); + assert!(api.set_active_model(target.clone()).await.is_err()); + assert!(api.prepare(target.clone()).await.is_err()); + assert!(api.preload(LocalAsrRuntime::Generic).await.is_err()); + assert!(api.test_model(target.clone()).await.is_err()); + let legacy_channel = backend + .create_channel(ChannelKind::Asr, "local-qwen3-mlx".into(), "Legacy".into()) + .await + .unwrap(); + let error = api.test_channel(legacy_channel).await.unwrap_err(); + assert_eq!(error.code, openless_core::BackendErrorCode::Unsupported); + assert!(backend + .activate_local_asr(LocalAsrActivationRequest { + target, + provider_id: "local-qwen3-mlx".into() + }) + .await + .is_err()); + assert!(api + .list_models(LocalAsrRuntime::Generic) + .await + .unwrap() + .is_empty()); +} diff --git a/openless-all/app/e2e/auxiliary-smoke.spec.ts b/openless-all/app/e2e/auxiliary-smoke.spec.ts new file mode 100644 index 000000000..047dbd088 --- /dev/null +++ b/openless-all/app/e2e/auxiliary-smoke.spec.ts @@ -0,0 +1,27 @@ +import { test, expect } from '@playwright/test'; + +test('auxiliary window entries load without blank-page exceptions or startup video', async ({ + page, +}) => { + const errors: string[] = []; + page.on('pageerror', (error) => errors.push(error.message)); + for (const kind of [ + 'qa', + 'selection-voice-intent', + 'voice-edit', + 'less-computer', + 'less-computer-glow', + 'capsule', + 'capsule-rail', + ]) { + await page.goto('/?window=' + kind); + await page.waitForLoadState('networkidle'); + await expect(page.locator('.ol-first-frame')).toHaveCount(0); + await expect(page.locator('video')).toHaveCount(0); + // Idle capsule/rail/glow can intentionally be empty; business surfaces must not be. + if (['qa', 'voice-edit', 'less-computer'].includes(kind)) { + expect(await page.locator('#root').textContent()).not.toBe(''); + } + } + expect(errors).toEqual([]); +}); diff --git a/openless-all/app/e2e/fixtures/refinements.html b/openless-all/app/e2e/fixtures/refinements.html new file mode 100644 index 000000000..75ce49b8b --- /dev/null +++ b/openless-all/app/e2e/fixtures/refinements.html @@ -0,0 +1,11 @@ + + + + + Interaction tests + + +
+ + + diff --git a/openless-all/app/e2e/fixtures/refinements.tsx b/openless-all/app/e2e/fixtures/refinements.tsx new file mode 100644 index 000000000..52189a5e8 --- /dev/null +++ b/openless-all/app/e2e/fixtures/refinements.tsx @@ -0,0 +1,194 @@ +import React, { useEffect, useState, useRef } from 'react'; +import { createRoot } from 'react-dom/client'; +import { AnimatePresence } from 'framer-motion'; +import { i18nReady } from '../../src/i18n'; +import { useSpringReorder } from '../../src/lib/useSpringReorder'; +import { initInteractionMode } from '../../src/lib/interactionMode'; +import { Toggle } from '../../src/pages/settings/shared'; +import { Collapse, ExpandableText } from '../../src/components/ui/Disclosure'; +import { SelectLite } from '../../src/components/ui/SelectLite'; +import { PresenceModal } from '../../src/components/ui/Modal'; +import { ChoiceChips } from '../../src/components/ui/ChoiceChips'; +import { DismissibleCard } from '../../src/components/ui/DismissibleCard'; +import { cardDeparture } from '../../src/lib/cardDeparture'; +import '../../src/styles/tokens.css'; +import '../../src/styles/global.css'; +import '../../src/styles/refinements.css'; + +function Fixture() { + const [items, setItems] = useState([ + { id: 'Alpha' }, + { id: 'Beta' }, + { id: 'Gamma' }, + { id: 'Delta' }, + ]); + const [commits, setCommits] = useState(0); + const reorder = useSpringReorder(items, (next) => { + setItems(next); + setCommits((count) => count + 1); + }); + const [toggles, setToggles] = useState([false, true, false]); + const [chip, setChip] = useState('one'); + const [deleted, setDeleted] = useState(false); + const card = useRef(null); + const dismiss = async () => { + await cardDeparture(card.current ?? undefined); + setDeleted(true); + }; + return ( +
+
+ {items.map((item) => ( +
{ + if (el) reorder.rows.current.set(item.id, el); + else reorder.rows.current.delete(item.id); + }} + style={{ + height: 64, + background: 'var(--ol-surface-2)', + display: 'flex', + alignItems: 'center', + }} + > + + {item.id} +
+ ))} +
+ {commits} +
+ {toggles.map((on, i) => ( + + setToggles((current) => current.map((value, index) => (i === index ? next : value))) + } + /> + ))} +
+
+ `第 ${i + 1} 行 · Readable content.`).join( + '\n', + )} + /> +
+ + + <> + {!deleted && ( + void dismiss()} + data-testid="card" + exit={{ opacity: 0 }} + style={{ width: 320, height: 120, padding: 18, background: 'var(--ol-surface-2)' }} + > + Swipe this card + + + )} + + + {deleted && } + + + +
+ ); +} +function LiveSelectFixture() { + const [pulse, setPulse] = useState(0); + const [value, setValue] = useState('item-0'); + const [searched, setSearched] = useState('item-0'); + useEffect(() => { + const timer = window.setInterval(() => setPulse((current) => current + 1), 120); + return () => window.clearInterval(timer); + }, []); + const options = Array.from({ length: 60 }, (_, index) => ({ + value: `item-${index}`, + label: `Option ${index}`, + trailing: {pulse}, + })); + return ( +
+ + +
+ ); +} +function NoticeFixture() { + const [open, setOpen] = useState(false); + return ( +
+ + +
+ Working status +
+
+

Following content

+
+ ); +} +function ModalFixture() { + const [open, setOpen] = useState(false); + return ( + <> + + setOpen(false)} + render={() => ( + <> +

Modal fixture

+ + + )} + /> + + ); +} +initInteractionMode(); +void i18nReady.then(() => + createRoot(document.getElementById('root')!).render( + + + , + ), +); diff --git a/openless-all/app/e2e/fixtures/typography.html b/openless-all/app/e2e/fixtures/typography.html new file mode 100644 index 000000000..76280423f --- /dev/null +++ b/openless-all/app/e2e/fixtures/typography.html @@ -0,0 +1,11 @@ + + + + + Answer typography fixture + + +
+ + + diff --git a/openless-all/app/e2e/fixtures/typography.tsx b/openless-all/app/e2e/fixtures/typography.tsx new file mode 100644 index 000000000..304b1732c --- /dev/null +++ b/openless-all/app/e2e/fixtures/typography.tsx @@ -0,0 +1,91 @@ +import React, { useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import { AssistantMarkdown } from '../../src/components/chat/markdown'; +import '../../src/styles/tokens.css'; + +function Fixture() { + const [draft, setDraft] = useState(''); + const [markdown, setMarkdown] = useState(''); + const [streaming, setStreaming] = useState(false); + const [mounted, setMounted] = useState(true); + return ( +
+