diff --git a/.do/deploy.template.yaml b/.do/deploy.template.yaml index bd57d8d80..855d46e32 100644 --- a/.do/deploy.template.yaml +++ b/.do/deploy.template.yaml @@ -10,7 +10,7 @@ spec: registry_type: GHCR registry: ghcr.io repository: moltis-org/moltis - tag: "0.10.6" + tag: "0.10.11" instance_count: 1 instance_size_slug: basic-xxs http_port: 8080 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 507a2a7c0..53575c180 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -165,8 +165,7 @@ jobs: fi - name: Build Tailwind CSS run: | - curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64 - chmod +x tailwindcss-linux-x64 + ./scripts/download-tailwindcss-cli.sh tailwindcss-linux-x64 cd crates/web/ui && TAILWINDCSS=../../../tailwindcss-linux-x64 ./build.sh - run: cargo clippy -Z unstable-options --workspace --all-features --all-targets --timings -- -D warnings - name: Upload cargo timing reports @@ -232,8 +231,7 @@ jobs: tool: cargo-llvm-cov - name: Build Tailwind CSS run: | - curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64 - chmod +x tailwindcss-linux-x64 + ./scripts/download-tailwindcss-cli.sh tailwindcss-linux-x64 cd crates/web/ui && TAILWINDCSS=../../../tailwindcss-linux-x64 ./build.sh - name: Generate coverage run: cargo llvm-cov --workspace --exclude moltis-tools --exclude moltis-swift-bridge --lcov --output-path lcov.info @@ -271,8 +269,7 @@ jobs: - name: Build Tailwind CSS run: | - curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64 - chmod +x tailwindcss-linux-x64 + ./scripts/download-tailwindcss-cli.sh tailwindcss-linux-x64 cd crates/web/ui && TAILWINDCSS=../../../tailwindcss-linux-x64 ./build.sh - name: Build moltis binary @@ -327,8 +324,7 @@ jobs: - name: Build Tailwind CSS run: | - curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-macos-arm64 - chmod +x tailwindcss-macos-arm64 + ./scripts/download-tailwindcss-cli.sh tailwindcss-macos-arm64 cd crates/web/ui && TAILWINDCSS=../../../tailwindcss-macos-arm64 ./build.sh - name: Build Swift bridge and generate Xcode project @@ -432,8 +428,7 @@ jobs: fi - name: Build Tailwind CSS run: | - curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64 - chmod +x tailwindcss-linux-x64 + ./scripts/download-tailwindcss-cli.sh tailwindcss-linux-x64 cd crates/web/ui && TAILWINDCSS=../../../tailwindcss-linux-x64 ./build.sh - run: cargo clippy -Z unstable-options --workspace --all-features --all-targets --timings -- -D warnings - name: Upload cargo timing reports diff --git a/.github/workflows/local-ci.yml b/.github/workflows/local-ci.yml new file mode 100644 index 000000000..85252595c --- /dev/null +++ b/.github/workflows/local-ci.yml @@ -0,0 +1,178 @@ +name: Local CI (PR) + +# This workflow runs on pull_request events and sets commit statuses that +# the upstream local-validation jobs poll for. Each job sets the corresponding +# local/=success status after running the actual check. + +on: + pull_request: + +permissions: {} + +env: + NIGHTLY_TOOLCHAIN: nightly-2025-11-30 + +jobs: + zizmor: + name: Zizmor + runs-on: ubuntu-latest + permissions: + contents: read + statuses: write + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - uses: zizmorcore/zizmor-action@135698455da5c3b3e55f73f4419e481ab68cdd95 # v0.4.1 + with: + advanced-security: false + online-audits: false + - name: Report zizmor status + if: always() + env: + GH_TOKEN: ${{ github.token }} + JOB_STATUS: ${{ job.status }} + SHA: ${{ github.event.pull_request.head.sha }} + REPO: ${{ github.repository }} + run: | + STATE=$([[ "$JOB_STATUS" == "success" ]] && echo "success" || echo "failure") + gh api repos/$REPO/statuses/$SHA -f state=$STATE -f context=local/zizmor -f description="Zizmor security scan" + + biome-i18n: + name: Biome + i18n + runs-on: ubuntu-latest + permissions: + contents: read + statuses: write + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - uses: biomejs/setup-biome@29711cbb52afee00eb13aeb30636592f9edc0088 # v2 + with: + version: "2.3.13" + - run: biome ci crates/web/src/assets/js/ + - run: ./scripts/i18n-check.sh + - name: Report statuses + if: always() + env: + GH_TOKEN: ${{ github.token }} + JOB_STATUS: ${{ job.status }} + SHA: ${{ github.event.pull_request.head.sha }} + REPO: ${{ github.repository }} + run: | + STATE=$([[ "$JOB_STATUS" == "success" ]] && echo "success" || echo "failure") + gh api repos/$REPO/statuses/$SHA -f state=$STATE -f context=local/biome -f description="Biome lint" + gh api repos/$REPO/statuses/$SHA -f state=$STATE -f context=local/i18n -f description="i18n check" + + fmt: + name: Format + runs-on: ubuntu-latest + permissions: + contents: read + statuses: write + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 # master + with: + toolchain: ${{ env.NIGHTLY_TOOLCHAIN }} + components: rustfmt + - run: cargo fmt --all -- --check + - name: Report fmt status + if: always() + env: + GH_TOKEN: ${{ github.token }} + JOB_STATUS: ${{ job.status }} + SHA: ${{ github.event.pull_request.head.sha }} + REPO: ${{ github.repository }} + run: | + STATE=$([[ "$JOB_STATUS" == "success" ]] && echo "success" || echo "failure") + gh api repos/$REPO/statuses/$SHA -f state=$STATE -f context=local/fmt -f description="Rust format check" + + rust-ci: + name: Clippy + Test + needs: [fmt, biome-i18n] + runs-on: [self-hosted, Linux, X64] + permissions: + contents: read + statuses: write + container: + image: nvidia/cuda:12.4.1-devel-ubuntu22.04 + env: + LD_LIBRARY_PATH: /usr/local/cuda/compat:/usr/local/nvidia/lib:/usr/local/nvidia/lib64 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - name: Clean up corrupted cargo config + run: rm -f ~/.cargo/config.toml + - name: Install build dependencies + run: | + apt-get update + apt-get install -y curl git cmake build-essential clang libclang-dev pkg-config ca-certificates nodejs npm + - uses: dtolnay/rust-toolchain@f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 # master + with: + toolchain: ${{ env.NIGHTLY_TOOLCHAIN }} + components: clippy + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 + with: + shared-key: local-ci-v1 + cache-all-crates: true + - name: Build Tailwind CSS + run: | + ./scripts/download-tailwindcss-cli.sh tailwindcss-linux-x64 + cd crates/web/ui && npm ci --ignore-scripts && TAILWINDCSS=../../../tailwindcss-linux-x64 ./build.sh + - name: Initialize git repo in llama-cpp source + run: | + cargo fetch --locked + LLAMA_SRC=$(find ~/.cargo/registry/src -name "llama-cpp-sys-2-*" -type d 2>/dev/null | head -1) + if [ -n "$LLAMA_SRC" ] && [ ! -d "$LLAMA_SRC/.git" ]; then + cd "$LLAMA_SRC" + git init && git config user.email "ci@example.com" && git config user.name "CI" + git add -A && git commit -m "init" --allow-empty && git tag v0.0.0 + fi + - id: clippy + run: cargo clippy -Z unstable-options --workspace --all-features --all-targets -- -D warnings + continue-on-error: true + - uses: taiki-e/install-action@f176c07a0a40cbfdd08ee9aa8bf1655701d11e69 # v2 + with: + tool: cargo-nextest + - id: nextest + run: cargo nextest run --all-features --profile ci + continue-on-error: true + - name: Report statuses + if: always() + env: + GH_TOKEN: ${{ github.token }} + CLIPPY_OUTCOME: ${{ steps.clippy.outcome }} + NEXTEST_OUTCOME: ${{ steps.nextest.outcome }} + SHA: ${{ github.event.pull_request.head.sha }} + REPO: ${{ github.repository }} + run: | + if [ "$CLIPPY_OUTCOME" = "success" ]; then CLIPPY_STATE=success; else CLIPPY_STATE=failure; fi + if [ "$NEXTEST_OUTCOME" = "success" ]; then TEST_STATE=success; else TEST_STATE=failure; fi + curl -s -X POST "https://api.github.com/repos/$REPO/statuses/$SHA" \ + -H "Authorization: Bearer $GH_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + -d "{\"state\":\"$CLIPPY_STATE\",\"context\":\"local/lint\",\"description\":\"Clippy lint\"}" + curl -s -X POST "https://api.github.com/repos/$REPO/statuses/$SHA" \ + -H "Authorization: Bearer $GH_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + -d "{\"state\":\"$TEST_STATE\",\"context\":\"local/test\",\"description\":\"cargo nextest\"}" + + skip-macos-ios: + name: Skip macOS/iOS + runs-on: ubuntu-latest + permissions: + statuses: write + steps: + - name: Set macos-app and ios-app to success (no runner) + env: + GH_TOKEN: ${{ github.token }} + SHA: ${{ github.event.pull_request.head.sha }} + REPO: ${{ github.repository }} + run: | + gh api repos/$REPO/statuses/$SHA -f state=success -f context=local/macos-app -f description="No macOS runner skipped" + gh api repos/$REPO/statuses/$SHA -f state=success -f context=local/ios-app -f description="No iOS runner skipped" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5194d62be..96e503dad 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,6 +5,12 @@ on: tags: - "v*" workflow_dispatch: + inputs: + dry_run: + description: "Build release packages without publishing artifacts, images, or release assets" + required: false + default: false + type: boolean concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -14,6 +20,7 @@ permissions: {} env: NIGHTLY_TOOLCHAIN: nightly-2025-11-30 + RELEASE_DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run && 'true' || 'false' }} jobs: zizmor: @@ -26,7 +33,12 @@ jobs: with: persist-credentials: false + - name: Dry-run short-circuit + if: ${{ env.RELEASE_DRY_RUN == 'true' }} + run: echo "Dry run enabled, skipping workflow security checks" + - uses: zizmorcore/zizmor-action@135698455da5c3b3e55f73f4419e481ab68cdd95 # v0.4.1 + if: ${{ env.RELEASE_DRY_RUN != 'true' }} with: advanced-security: false online-audits: false @@ -41,11 +53,17 @@ jobs: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: persist-credentials: false + - name: Dry-run short-circuit + if: ${{ env.RELEASE_DRY_RUN == 'true' }} + run: echo "Dry run enabled, skipping biome checks" - uses: biomejs/setup-biome@29711cbb52afee00eb13aeb30636592f9edc0088 # v2.7.0 + if: ${{ env.RELEASE_DRY_RUN != 'true' }} with: version: "2.3.13" - - run: biome ci crates/web/src/assets/js/ - - run: ./scripts/i18n-check.sh + - if: ${{ env.RELEASE_DRY_RUN != 'true' }} + run: biome ci crates/web/src/assets/js/ + - if: ${{ env.RELEASE_DRY_RUN != 'true' }} + run: ./scripts/i18n-check.sh fmt: needs: zizmor @@ -57,11 +75,16 @@ jobs: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: persist-credentials: false + - name: Dry-run short-circuit + if: ${{ env.RELEASE_DRY_RUN == 'true' }} + run: echo "Dry run enabled, skipping rustfmt check" - uses: dtolnay/rust-toolchain@f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 # master + if: ${{ env.RELEASE_DRY_RUN != 'true' }} with: toolchain: ${{ env.NIGHTLY_TOOLCHAIN }} components: rustfmt - - run: cargo fmt --all -- --check + - if: ${{ env.RELEASE_DRY_RUN != 'true' }} + run: cargo fmt --all -- --check clippy: needs: [fmt, biome] @@ -77,18 +100,25 @@ jobs: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: persist-credentials: false + - name: Dry-run short-circuit + if: ${{ env.RELEASE_DRY_RUN == 'true' }} + run: echo "Dry run enabled, skipping clippy job" - name: Clean up corrupted cargo config + if: ${{ env.RELEASE_DRY_RUN != 'true' }} run: rm -f ~/.cargo/config.toml - name: Install build dependencies + if: ${{ env.RELEASE_DRY_RUN != 'true' }} run: | apt-get update apt-get install -y curl git cmake build-essential clang libclang-dev pkg-config ca-certificates nvcc --version - uses: dtolnay/rust-toolchain@f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 # master + if: ${{ env.RELEASE_DRY_RUN != 'true' }} with: toolchain: ${{ env.NIGHTLY_TOOLCHAIN }} components: clippy, rustfmt - name: Initialize git repo in llama-cpp source to satisfy cmake + if: ${{ env.RELEASE_DRY_RUN != 'true' }} run: | cargo fetch --locked LLAMA_SRC=$(find ~/.cargo/registry/src -name "llama-cpp-sys-2-*" -type d 2>/dev/null | head -1) @@ -101,7 +131,13 @@ jobs: git commit -m "init" --allow-empty git tag v0.0.0 fi - - run: cargo clippy -Z unstable-options --workspace --all-features --all-targets --timings -- -D warnings + - name: Build Tailwind CSS + if: ${{ env.RELEASE_DRY_RUN != 'true' }} + run: | + ./scripts/download-tailwindcss-cli.sh tailwindcss-linux-x64 + cd crates/web/ui && TAILWINDCSS=../../../tailwindcss-linux-x64 ./build.sh + - if: ${{ env.RELEASE_DRY_RUN != 'true' }} + run: cargo clippy -Z unstable-options --workspace --all-features --all-targets --timings -- -D warnings test: needs: [fmt, biome] @@ -113,13 +149,24 @@ jobs: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: persist-credentials: false + - name: Dry-run short-circuit + if: ${{ env.RELEASE_DRY_RUN == 'true' }} + run: echo "Dry run enabled, skipping unit test job" - uses: dtolnay/rust-toolchain@f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 # master + if: ${{ env.RELEASE_DRY_RUN != 'true' }} with: toolchain: stable - uses: taiki-e/install-action@f176c07a0a40cbfdd08ee9aa8bf1655701d11e69 # v2.67.25 + if: ${{ env.RELEASE_DRY_RUN != 'true' }} with: tool: cargo-nextest - - run: cargo nextest run --profile ci + - name: Build Tailwind CSS + if: ${{ env.RELEASE_DRY_RUN != 'true' }} + run: | + ./scripts/download-tailwindcss-cli.sh tailwindcss-linux-x64 + cd crates/web/ui && TAILWINDCSS=../../../tailwindcss-linux-x64 ./build.sh + - if: ${{ env.RELEASE_DRY_RUN != 'true' }} + run: cargo nextest run --profile ci e2e: name: E2E Tests @@ -132,34 +179,50 @@ jobs: with: persist-credentials: false + - name: Dry-run short-circuit + if: ${{ env.RELEASE_DRY_RUN == 'true' }} + run: echo "Dry run enabled, skipping E2E job" + - uses: dtolnay/rust-toolchain@f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 # master + if: ${{ env.RELEASE_DRY_RUN != 'true' }} with: toolchain: stable + - name: Build Tailwind CSS + if: ${{ env.RELEASE_DRY_RUN != 'true' }} + run: | + ./scripts/download-tailwindcss-cli.sh tailwindcss-linux-x64 + cd crates/web/ui && TAILWINDCSS=../../../tailwindcss-linux-x64 ./build.sh + - name: Build moltis binary + if: ${{ env.RELEASE_DRY_RUN != 'true' }} run: cargo build --bin moltis - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + if: ${{ env.RELEASE_DRY_RUN != 'true' }} with: node-version: "22" package-manager-cache: false - name: Install npm dependencies + if: ${{ env.RELEASE_DRY_RUN != 'true' }} working-directory: crates/web/ui run: npm ci - name: Install Playwright browsers + if: ${{ env.RELEASE_DRY_RUN != 'true' }} working-directory: crates/web/ui run: npx playwright install --with-deps chromium - name: Run E2E tests + if: ${{ env.RELEASE_DRY_RUN != 'true' }} working-directory: crates/web/ui env: CI: "true" run: npx playwright test - name: Upload test results - if: ${{ !cancelled() }} + if: ${{ !cancelled() && env.RELEASE_DRY_RUN != 'true' }} uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: e2e-test-results-${{ github.run_id }}-${{ github.run_attempt }} @@ -236,8 +299,16 @@ jobs: run: cargo install cargo-deb - name: Install cosign + if: ${{ env.RELEASE_DRY_RUN != 'true' }} uses: sigstore/cosign-installer@d7d6bc7722e3daa8354c50bcb52f4837da5e9b6a # v3.8.1 + - name: Build Tailwind CSS + run: | + ARCH=$(uname -m) + case "$ARCH" in x86_64) TW="tailwindcss-linux-x64";; aarch64) TW="tailwindcss-linux-arm64";; esac + ./scripts/download-tailwindcss-cli.sh "$TW" + cd crates/web/ui && TAILWINDCSS="../../../$TW" ./build.sh + - name: Build WASM components run: cargo build --target wasm32-wasip2 -p moltis-wasm-calc -p moltis-wasm-web-fetch -p moltis-wasm-web-search --release @@ -252,12 +323,14 @@ jobs: run: cargo deb -p moltis --no-build --target "$BUILD_TARGET" - name: Sign with Sigstore and generate checksums + if: ${{ env.RELEASE_DRY_RUN != 'true' }} uses: ./.github/actions/sign-artifacts with: files: '*.deb' working-directory: target/${{ matrix.target }}/debian - name: Upload .deb artifact + if: ${{ env.RELEASE_DRY_RUN != 'true' }} uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: moltis-${{ matrix.arch }}.deb @@ -335,8 +408,16 @@ jobs: run: cargo install cargo-generate-rpm - name: Install cosign + if: ${{ env.RELEASE_DRY_RUN != 'true' }} uses: sigstore/cosign-installer@d7d6bc7722e3daa8354c50bcb52f4837da5e9b6a # v3.8.1 + - name: Build Tailwind CSS + run: | + ARCH=$(uname -m) + case "$ARCH" in x86_64) TW="tailwindcss-linux-x64";; aarch64) TW="tailwindcss-linux-arm64";; esac + ./scripts/download-tailwindcss-cli.sh "$TW" + cd crates/web/ui && TAILWINDCSS="../../../$TW" ./build.sh + - name: Build WASM components run: cargo build --target wasm32-wasip2 -p moltis-wasm-calc -p moltis-wasm-web-fetch -p moltis-wasm-web-search --release @@ -351,12 +432,14 @@ jobs: run: cargo generate-rpm -p crates/cli --target "$BUILD_TARGET" - name: Sign with Sigstore and generate checksums + if: ${{ env.RELEASE_DRY_RUN != 'true' }} uses: ./.github/actions/sign-artifacts with: files: '*.rpm' working-directory: target/${{ matrix.target }}/generate-rpm - name: Upload .rpm artifact + if: ${{ env.RELEASE_DRY_RUN != 'true' }} uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: moltis-${{ matrix.arch }}.rpm @@ -400,8 +483,16 @@ jobs: run: sudo apt-get update && sudo apt-get install -y fakeroot zstd - name: Install cosign + if: ${{ env.RELEASE_DRY_RUN != 'true' }} uses: sigstore/cosign-installer@d7d6bc7722e3daa8354c50bcb52f4837da5e9b6a # v3.8.1 + - name: Build Tailwind CSS + run: | + ARCH=$(uname -m) + case "$ARCH" in x86_64) TW="tailwindcss-linux-x64";; aarch64) TW="tailwindcss-linux-arm64";; esac + ./scripts/download-tailwindcss-cli.sh "$TW" + cd crates/web/ui && TAILWINDCSS="../../../$TW" ./build.sh + - name: Build WASM components run: cargo build --target wasm32-wasip2 -p moltis-wasm-calc -p moltis-wasm-web-fetch -p moltis-wasm-web-search --release @@ -444,11 +535,13 @@ jobs: fakeroot -- tar --zstd -cf "../moltis-${VERSION}-1-${MATRIX_ARCH}.pkg.tar.zst" .PKGINFO usr/ - name: Sign with Sigstore and generate checksums + if: ${{ env.RELEASE_DRY_RUN != 'true' }} uses: ./.github/actions/sign-artifacts with: files: moltis-${{ steps.version.outputs.version }}-1-${{ matrix.arch }}.pkg.tar.zst - name: Upload .pkg.tar.zst artifact + if: ${{ env.RELEASE_DRY_RUN != 'true' }} uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: moltis-${{ matrix.arch }}.pkg.tar.zst @@ -491,8 +584,16 @@ jobs: targets: ${{ matrix.target }}, wasm32-wasip2 - name: Install cosign + if: ${{ env.RELEASE_DRY_RUN != 'true' }} uses: sigstore/cosign-installer@d7d6bc7722e3daa8354c50bcb52f4837da5e9b6a # v3.8.1 + - name: Build Tailwind CSS + run: | + ARCH=$(uname -m) + case "$ARCH" in x86_64) TW="tailwindcss-linux-x64";; aarch64) TW="tailwindcss-linux-arm64";; esac + ./scripts/download-tailwindcss-cli.sh "$TW" + cd crates/web/ui && TAILWINDCSS="../../../$TW" ./build.sh + - name: Build WASM components run: cargo build --target wasm32-wasip2 -p moltis-wasm-calc -p moltis-wasm-web-fetch -p moltis-wasm-web-search --release @@ -558,11 +659,13 @@ jobs: ARCH="$MATRIX_ARCH" ./appimagetool --appimage-extract-and-run "$APP_DIR" "moltis-${VERSION}-${MATRIX_ARCH}.AppImage" - name: Sign with Sigstore and generate checksums + if: ${{ env.RELEASE_DRY_RUN != 'true' }} uses: ./.github/actions/sign-artifacts with: files: moltis-${{ steps.version.outputs.version }}-${{ matrix.arch }}.AppImage - name: Upload AppImage artifact + if: ${{ env.RELEASE_DRY_RUN != 'true' }} uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: moltis-${{ matrix.arch }}.AppImage @@ -603,17 +706,20 @@ jobs: sed -Ei "s/^version:[[:space:]]*'.*'/version: '${VERSION}'/" snap/snapcraft.yaml - name: Install cosign + if: ${{ env.RELEASE_DRY_RUN != 'true' }} uses: sigstore/cosign-installer@d7d6bc7722e3daa8354c50bcb52f4837da5e9b6a # v3.8.1 - uses: snapcore/action-build@3bdaa03e1ba6bf59a65f84a751d943d549a54e79 # v1 id: build-snap - name: Sign with Sigstore and generate checksums + if: ${{ env.RELEASE_DRY_RUN != 'true' }} uses: ./.github/actions/sign-artifacts with: files: ${{ steps.build-snap.outputs.snap }} - name: Upload Snap artifact + if: ${{ env.RELEASE_DRY_RUN != 'true' }} uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: moltis.snap @@ -656,8 +762,22 @@ jobs: targets: ${{ matrix.target }}, wasm32-wasip2 - name: Install cosign + if: ${{ env.RELEASE_DRY_RUN != 'true' }} uses: sigstore/cosign-installer@d7d6bc7722e3daa8354c50bcb52f4837da5e9b6a # v3.8.1 + - name: Build Tailwind CSS + run: | + OS=$(uname -s) + ARCH=$(uname -m) + case "${OS}-${ARCH}" in + Linux-x86_64) TW="tailwindcss-linux-x64";; + Linux-aarch64) TW="tailwindcss-linux-arm64";; + Darwin-arm64) TW="tailwindcss-macos-arm64";; + Darwin-x86_64) TW="tailwindcss-macos-x64";; + esac + ./scripts/download-tailwindcss-cli.sh "$TW" + cd crates/web/ui && TAILWINDCSS="../../../$TW" ./build.sh + - name: Build WASM components run: cargo build --target wasm32-wasip2 -p moltis-wasm-calc -p moltis-wasm-web-fetch -p moltis-wasm-web-search --release @@ -685,11 +805,13 @@ jobs: tar czf "moltis-${VERSION}-${BUILD_TARGET}.tar.gz" moltis - name: Sign with Sigstore and generate checksums + if: ${{ env.RELEASE_DRY_RUN != 'true' }} uses: ./.github/actions/sign-artifacts with: files: moltis-${{ steps.version.outputs.version }}-${{ matrix.target }}.tar.gz - name: Upload binary artifact + if: ${{ env.RELEASE_DRY_RUN != 'true' }} uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: moltis-${{ matrix.target }} @@ -721,7 +843,13 @@ jobs: - name: Install Swift build dependencies run: brew install xcodegen cbindgen + - name: Build Tailwind CSS + run: | + ./scripts/download-tailwindcss-cli.sh tailwindcss-macos-arm64 + cd crates/web/ui && TAILWINDCSS=../../../tailwindcss-macos-arm64 ./build.sh + - name: Install cosign + if: ${{ env.RELEASE_DRY_RUN != 'true' }} uses: sigstore/cosign-installer@d7d6bc7722e3daa8354c50bcb52f4837da5e9b6a # v3.8.1 - name: Build Swift bridge artifacts and generate Xcode project @@ -763,11 +891,13 @@ jobs: ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "moltis-${VERSION}-macos.app.zip" - name: Sign with Sigstore and generate checksums + if: ${{ env.RELEASE_DRY_RUN != 'true' }} uses: ./.github/actions/sign-artifacts with: files: moltis-${{ steps.version.outputs.version }}-macos.app.zip - name: Upload macOS app artifact + if: ${{ env.RELEASE_DRY_RUN != 'true' }} uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: moltis-macos-app @@ -800,6 +930,7 @@ jobs: targets: ${{ env.BUILD_TARGET }}, wasm32-wasip2 - name: Install cosign + if: ${{ env.RELEASE_DRY_RUN != 'true' }} uses: sigstore/cosign-installer@d7d6bc7722e3daa8354c50bcb52f4837da5e9b6a # v3.8.1 - name: Configure Perl for vendored OpenSSL @@ -813,6 +944,12 @@ jobs: Add-Content -Path $env:GITHUB_ENV -Value "PERL=$strawberryPerl" & $strawberryPerl -v + - name: Build Tailwind CSS + shell: bash + run: | + ./scripts/download-tailwindcss-cli.sh tailwindcss-windows-x64.exe + cd crates/web/ui && TAILWINDCSS=../../../tailwindcss-windows-x64.exe ./build.sh + - name: Build WASM components shell: bash run: cargo build --target wasm32-wasip2 -p moltis-wasm-calc -p moltis-wasm-web-fetch -p moltis-wasm-web-search --release @@ -840,11 +977,13 @@ jobs: cp "target/$BUILD_TARGET/release/moltis.exe" "moltis-${VERSION}-${BUILD_TARGET}.exe" - name: Sign with Sigstore and generate checksums + if: ${{ env.RELEASE_DRY_RUN != 'true' }} uses: ./.github/actions/sign-artifacts with: files: moltis-${{ steps.version.outputs.version }}-${{ env.BUILD_TARGET }}.exe - name: Upload .exe artifact + if: ${{ env.RELEASE_DRY_RUN != 'true' }} uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: moltis-${{ env.BUILD_TARGET }}.exe @@ -856,6 +995,7 @@ jobs: *.exe.crt build-docker: + if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.dry_run) }} needs: [clippy, test, e2e] strategy: matrix: @@ -981,6 +1121,7 @@ jobs: retention-days: 1 merge-docker: + if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.dry_run) }} needs: build-docker runs-on: ubuntu-latest name: Merge Docker manifest @@ -1001,6 +1142,7 @@ jobs: uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3 - name: Install cosign + if: ${{ env.RELEASE_DRY_RUN != 'true' }} uses: sigstore/cosign-installer@d7d6bc7722e3daa8354c50bcb52f4837da5e9b6a # v3.8.1 - name: Log in to GitHub Container Registry @@ -1106,7 +1248,7 @@ jobs: - build-homebrew-binaries - build-macos-app - build-windows-exe - if: startsWith(github.ref, 'refs/tags/') + if: startsWith(github.ref, 'refs/tags/') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) runs-on: ubuntu-latest name: Upload release assets permissions: @@ -1146,7 +1288,7 @@ jobs: needs: - upload-release - merge-docker - if: startsWith(github.ref, 'refs/tags/') + if: startsWith(github.ref, 'refs/tags/') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) runs-on: ubuntu-latest name: Generate Release SBOM permissions: @@ -1159,6 +1301,7 @@ jobs: persist-credentials: false - name: Install cosign + if: ${{ env.RELEASE_DRY_RUN != 'true' }} uses: sigstore/cosign-installer@d7d6bc7722e3daa8354c50bcb52f4837da5e9b6a # v3.8.1 - name: Install cargo-sbom @@ -1192,7 +1335,7 @@ jobs: needs: - upload-release - merge-docker - if: startsWith(github.ref, 'refs/tags/') + if: startsWith(github.ref, 'refs/tags/') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) runs-on: ubuntu-latest name: Update Homebrew tap permissions: @@ -1295,7 +1438,7 @@ jobs: update-deploy-tags: needs: merge-docker - if: startsWith(github.ref, 'refs/tags/') + if: startsWith(github.ref, 'refs/tags/') && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) runs-on: ubuntu-latest name: Update deploy template tags permissions: diff --git a/CHANGELOG.md b/CHANGELOG.md index 69d879f52..6e70feff3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +## [0.10.11] - 2026-03-02 + +## [0.10.10] - 2026-03-02 +### Fixed +- [swift-bridge] Stabilize gateway migration and stream tests + +## [0.10.9] - 2026-03-02 +### Fixed +- [ci] Harden tailwindcss cli downloads + +## [0.10.8] - 2026-03-02 +### Changed +- [gateway] Fetch updates from releases manifest instead of GitHub API + + +### Fixed +- [ci] Add Tailwind CSS build step to release workflow, Dockerfile, and snapcraft +- [e2e] Wait for session history render before DOM injection in chat-abort + ## [0.10.7] - 2026-03-02 ### Added - [sandbox] Add GitHub runner parity packages and enable corepack (#284) diff --git a/Cargo.lock b/Cargo.lock index fcd20349d..9e0f3313f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -861,7 +861,7 @@ dependencies = [ [[package]] name = "benchmarks" -version = "0.10.7" +version = "0.10.11" dependencies = [ "codspeed-divan-compat", "moltis-agents", @@ -5742,7 +5742,7 @@ dependencies = [ [[package]] name = "moltis" -version = "0.10.7" +version = "0.10.11" dependencies = [ "anyhow", "clap", @@ -5780,7 +5780,7 @@ dependencies = [ [[package]] name = "moltis-agents" -version = "0.10.7" +version = "0.10.11" dependencies = [ "anyhow", "async-stream", @@ -5807,7 +5807,7 @@ dependencies = [ [[package]] name = "moltis-auth" -version = "0.10.7" +version = "0.10.11" dependencies = [ "anyhow", "argon2", @@ -5835,7 +5835,7 @@ dependencies = [ [[package]] name = "moltis-auto-reply" -version = "0.10.7" +version = "0.10.11" dependencies = [ "moltis-agents", "moltis-common", @@ -5850,7 +5850,7 @@ dependencies = [ [[package]] name = "moltis-browser" -version = "0.10.7" +version = "0.10.11" dependencies = [ "async-trait", "base64 0.22.1", @@ -5873,7 +5873,7 @@ dependencies = [ [[package]] name = "moltis-caldav" -version = "0.10.7" +version = "0.10.11" dependencies = [ "anyhow", "async-trait", @@ -5897,7 +5897,7 @@ dependencies = [ [[package]] name = "moltis-canvas" -version = "0.10.7" +version = "0.10.11" dependencies = [ "axum", "moltis-common", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "moltis-channels" -version = "0.10.7" +version = "0.10.11" dependencies = [ "async-trait", "bytes", @@ -5927,7 +5927,7 @@ dependencies = [ [[package]] name = "moltis-chat" -version = "0.10.7" +version = "0.10.11" dependencies = [ "anyhow", "async-trait", @@ -5964,7 +5964,7 @@ dependencies = [ [[package]] name = "moltis-common" -version = "0.10.7" +version = "0.10.11" dependencies = [ "async-trait", "futures", @@ -5979,7 +5979,7 @@ dependencies = [ [[package]] name = "moltis-config" -version = "0.10.7" +version = "0.10.11" dependencies = [ "anyhow", "chrono-tz", @@ -5999,7 +5999,7 @@ dependencies = [ [[package]] name = "moltis-courier" -version = "0.10.7" +version = "0.10.11" dependencies = [ "a2", "anyhow", @@ -6015,7 +6015,7 @@ dependencies = [ [[package]] name = "moltis-cron" -version = "0.10.7" +version = "0.10.11" dependencies = [ "async-trait", "chrono", @@ -6038,7 +6038,7 @@ dependencies = [ [[package]] name = "moltis-discord" -version = "0.10.7" +version = "0.10.11" dependencies = [ "anyhow", "async-trait", @@ -6061,7 +6061,7 @@ dependencies = [ [[package]] name = "moltis-gateway" -version = "0.10.7" +version = "0.10.11" dependencies = [ "anyhow", "async-graphql", @@ -6160,7 +6160,7 @@ dependencies = [ [[package]] name = "moltis-graphql" -version = "0.10.7" +version = "0.10.11" dependencies = [ "async-graphql", "async-stream", @@ -6176,7 +6176,7 @@ dependencies = [ [[package]] name = "moltis-mcp" -version = "0.10.7" +version = "0.10.11" dependencies = [ "async-trait", "futures", @@ -6199,7 +6199,7 @@ dependencies = [ [[package]] name = "moltis-media" -version = "0.10.7" +version = "0.10.11" dependencies = [ "image", "moltis-common", @@ -6213,7 +6213,7 @@ dependencies = [ [[package]] name = "moltis-memory" -version = "0.10.7" +version = "0.10.11" dependencies = [ "anyhow", "async-trait", @@ -6254,7 +6254,7 @@ dependencies = [ [[package]] name = "moltis-metrics" -version = "0.10.7" +version = "0.10.11" dependencies = [ "async-trait", "metrics 0.24.3", @@ -6272,7 +6272,7 @@ dependencies = [ [[package]] name = "moltis-msteams" -version = "0.10.7" +version = "0.10.11" dependencies = [ "anyhow", "async-trait", @@ -6295,7 +6295,7 @@ dependencies = [ [[package]] name = "moltis-network-filter" -version = "0.10.7" +version = "0.10.11" dependencies = [ "async-trait", "moltis-metrics", @@ -6311,7 +6311,7 @@ dependencies = [ [[package]] name = "moltis-oauth" -version = "0.10.7" +version = "0.10.11" dependencies = [ "axum", "base64 0.22.1", @@ -6334,7 +6334,7 @@ dependencies = [ [[package]] name = "moltis-onboarding" -version = "0.10.7" +version = "0.10.11" dependencies = [ "moltis-common", "moltis-config", @@ -6350,7 +6350,7 @@ dependencies = [ [[package]] name = "moltis-openclaw-import" -version = "0.10.7" +version = "0.10.11" dependencies = [ "dirs-next", "json5", @@ -6373,7 +6373,7 @@ dependencies = [ [[package]] name = "moltis-plugins" -version = "0.10.7" +version = "0.10.11" dependencies = [ "async-trait", "moltis-common", @@ -6392,7 +6392,7 @@ dependencies = [ [[package]] name = "moltis-projects" -version = "0.10.7" +version = "0.10.11" dependencies = [ "async-trait", "moltis-metrics", @@ -6409,7 +6409,7 @@ dependencies = [ [[package]] name = "moltis-protocol" -version = "0.10.7" +version = "0.10.11" dependencies = [ "moltis-metrics", "serde", @@ -6419,7 +6419,7 @@ dependencies = [ [[package]] name = "moltis-provider-setup" -version = "0.10.7" +version = "0.10.11" dependencies = [ "async-trait", "axum", @@ -6441,7 +6441,7 @@ dependencies = [ [[package]] name = "moltis-providers" -version = "0.10.7" +version = "0.10.11" dependencies = [ "anyhow", "async-openai", @@ -6475,7 +6475,7 @@ dependencies = [ [[package]] name = "moltis-qmd" -version = "0.10.7" +version = "0.10.11" dependencies = [ "anyhow", "async-trait", @@ -6489,7 +6489,7 @@ dependencies = [ [[package]] name = "moltis-routing" -version = "0.10.7" +version = "0.10.11" dependencies = [ "moltis-common", "moltis-config", @@ -6502,7 +6502,7 @@ dependencies = [ [[package]] name = "moltis-schema-export" -version = "0.10.7" +version = "0.10.11" dependencies = [ "moltis-graphql", "moltis-service-traits", @@ -6512,7 +6512,7 @@ dependencies = [ [[package]] name = "moltis-service-traits" -version = "0.10.7" +version = "0.10.11" dependencies = [ "async-trait", "bytes", @@ -6525,7 +6525,7 @@ dependencies = [ [[package]] name = "moltis-sessions" -version = "0.10.7" +version = "0.10.11" dependencies = [ "fd-lock", "moltis-common", @@ -6542,7 +6542,7 @@ dependencies = [ [[package]] name = "moltis-skills" -version = "0.10.7" +version = "0.10.11" dependencies = [ "anyhow", "async-trait", @@ -6563,7 +6563,7 @@ dependencies = [ [[package]] name = "moltis-slack" -version = "0.10.7" +version = "0.10.11" dependencies = [ "async-trait", "base64 0.22.1", @@ -6586,7 +6586,7 @@ dependencies = [ [[package]] name = "moltis-swift-bridge" -version = "0.10.7" +version = "0.10.11" dependencies = [ "anyhow", "axum", @@ -6614,7 +6614,7 @@ dependencies = [ [[package]] name = "moltis-tailscale" -version = "0.10.7" +version = "0.10.11" dependencies = [ "async-trait", "serde", @@ -6626,7 +6626,7 @@ dependencies = [ [[package]] name = "moltis-telegram" -version = "0.10.7" +version = "0.10.11" dependencies = [ "async-trait", "axum", @@ -6651,7 +6651,7 @@ dependencies = [ [[package]] name = "moltis-tls" -version = "0.10.7" +version = "0.10.11" dependencies = [ "axum", "hostname", @@ -6672,7 +6672,7 @@ dependencies = [ [[package]] name = "moltis-tools" -version = "0.10.7" +version = "0.10.11" dependencies = [ "anyhow", "async-trait", @@ -6715,7 +6715,7 @@ dependencies = [ [[package]] name = "moltis-vault" -version = "0.10.7" +version = "0.10.11" dependencies = [ "anyhow", "argon2", @@ -6736,7 +6736,7 @@ dependencies = [ [[package]] name = "moltis-voice" -version = "0.10.7" +version = "0.10.11" dependencies = [ "anyhow", "async-trait", @@ -6759,7 +6759,7 @@ dependencies = [ [[package]] name = "moltis-wasm-calc" -version = "0.10.7" +version = "0.10.11" dependencies = [ "anyhow", "serde_json", @@ -6769,7 +6769,7 @@ dependencies = [ [[package]] name = "moltis-wasm-web-fetch" -version = "0.10.7" +version = "0.10.11" dependencies = [ "serde_json", "url", @@ -6779,7 +6779,7 @@ dependencies = [ [[package]] name = "moltis-wasm-web-search" -version = "0.10.7" +version = "0.10.11" dependencies = [ "serde_json", "url", @@ -6789,7 +6789,7 @@ dependencies = [ [[package]] name = "moltis-web" -version = "0.10.7" +version = "0.10.11" dependencies = [ "askama", "axum", @@ -6820,7 +6820,7 @@ dependencies = [ [[package]] name = "moltis-whatsapp" -version = "0.10.7" +version = "0.10.11" dependencies = [ "async-trait", "base64 0.22.1", diff --git a/Cargo.toml b/Cargo.toml index 64e64f132..3d315bf16 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -115,7 +115,7 @@ edition = "2024" license = "MIT" repository = "https://github.com/moltis-org/moltis" rust-version = "1.91" -version = "0.10.7" +version = "0.10.11" [workspace.dependencies] # APNS diff --git a/Dockerfile b/Dockerfile index a799c59cf..cffdb5aec 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,6 +30,13 @@ RUN apt-get update -qq && \ apt-get install -yqq --no-install-recommends cmake build-essential libclang-dev pkg-config git && \ rm -rf /var/lib/apt/lists/* +# Build Tailwind CSS (style.css is gitignored — must be generated before cargo build) +RUN ARCH=$(uname -m) && \ + case "$ARCH" in x86_64) TW="tailwindcss-linux-x64";; aarch64) TW="tailwindcss-linux-arm64";; esac && \ + curl -sLO "https://github.com/tailwindlabs/tailwindcss/releases/latest/download/$TW" && \ + chmod +x "$TW" && \ + cd crates/web/ui && TAILWINDCSS="../../../$TW" ./build.sh + # Install WASM target and build WASM components (embedded via include_bytes!) RUN rustup target add wasm32-wasip2 && \ cargo build --target wasm32-wasip2 -p moltis-wasm-calc -p moltis-wasm-web-fetch -p moltis-wasm-web-search --release diff --git a/crates/agents/src/response_sanitizer.rs b/crates/agents/src/response_sanitizer.rs index 877b43fe8..37bb14b0a 100644 --- a/crates/agents/src/response_sanitizer.rs +++ b/crates/agents/src/response_sanitizer.rs @@ -23,6 +23,8 @@ const INTERNAL_TAGS: &[&str] = &[ "internal_thought", "function_call", "tool_use", + "invoke", + "tool_calls", ]; /// Standalone pipe tokens that should be stripped. @@ -406,4 +408,47 @@ mod tests { assert_eq!(calls[0].name, "a"); assert_eq!(calls[1].name, "b"); } + + // ── New INTERNAL_TAGS: invoke, tool_calls ───────────────────────── + + #[test] + fn clean_response_strips_invoke_tags() { + let input = "Answer herels done"; + let cleaned = clean_response(input); + assert_eq!(cleaned, "Answer here done"); + } + + #[test] + fn clean_response_strips_tool_calls_wrapper() { + let input = "some tool call contentThe result is 42."; + let cleaned = clean_response(input); + assert_eq!(cleaned, "The result is 42."); + } + + #[test] + fn clean_response_word_invoke_in_prose_preserved() { + // The word "invoke" in plain English is NOT inside XML tags — must survive. + let input = "You can invoke the function by passing arguments."; + assert_eq!(clean_response(input), input); + } + + /// Existing tool_call recovery is NOT broken by new INTERNAL_TAGS. + /// TOOL_CALL_TAGS ("function_call", "tool_call") are separate from INTERNAL_TAGS. + #[test] + fn recover_tool_calls_unaffected_by_new_internal_tags() { + let input = r#"{"name": "exec", "arguments": {"command": "ls"}}"#; + let (cleaned, calls) = recover_tool_calls_from_content(input); + assert_eq!(cleaned, ""); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "exec"); + } + + /// clean_response strips leaked but does NOT interfere with + /// proper tool call recovery (which runs separately in the runner). + #[test] + fn clean_response_strips_malformed_invoke_leftovers() { + let input = "Here is the result. leftover"; + let cleaned = clean_response(input); + assert_eq!(cleaned, "Here is the result."); + } } diff --git a/crates/agents/src/runner.rs b/crates/agents/src/runner.rs index 796a0635a..8affb6fad 100644 --- a/crates/agents/src/runner.rs +++ b/crates/agents/src/runner.rs @@ -37,6 +37,16 @@ fn resolve_agent_max_iterations(configured: usize) -> usize { configured } +/// Sanitize a tool name from model output: trim whitespace and strip +/// surrounding quotes that some models wrap around tool names. +fn sanitize_tool_name(name: &str) -> &str { + let trimmed = name.trim(); + trimmed + .strip_prefix('"') + .and_then(|s| s.strip_suffix('"')) + .unwrap_or(trimmed) +} + /// Error patterns that indicate the context window has been exceeded. const CONTEXT_WINDOW_PATTERNS: &[&str] = &[ "context_length_exceeded", @@ -942,7 +952,7 @@ pub async fn run_agent_loop_with_context( .tool_calls .iter() .map(|tc| { - let tool = tools.get(&tc.name); + let tool = tools.get(sanitize_tool_name(&tc.name)); let mut args = tc.arguments.clone(); // Dispatch BeforeToolCall hook — may block or modify arguments. @@ -1603,7 +1613,7 @@ pub async fn run_agent_loop_streaming( let tool_futures: Vec<_> = tool_calls .iter() .map(|tc| { - let tool = tools.get(&tc.name); + let tool = tools.get(sanitize_tool_name(&tc.name)); let mut args = tc.arguments.clone(); let hook_registry = hook_registry.clone(); @@ -4573,4 +4583,77 @@ mod tests { assert_eq!(retry_events.len(), 2, "expected two retry events"); assert!(retry_events.iter().all(|delay| *delay >= 1)); } + + // ── sanitize_tool_name ──────────────────────────────────────────── + + #[test] + fn sanitize_tool_name_clean_input() { + assert_eq!(sanitize_tool_name("exec"), "exec"); + } + + #[test] + fn sanitize_tool_name_trims_whitespace() { + assert_eq!(sanitize_tool_name(" exec "), "exec"); + assert_eq!(sanitize_tool_name("\texec\n"), "exec"); + } + + #[test] + fn sanitize_tool_name_strips_quotes() { + assert_eq!(sanitize_tool_name("\"exec\""), "exec"); + assert_eq!(sanitize_tool_name(" \"web_search\" "), "web_search"); + } + + #[test] + fn sanitize_tool_name_partial_quotes_unchanged() { + // Only strip when both quotes are present. + assert_eq!(sanitize_tool_name("\"exec"), "\"exec"); + assert_eq!(sanitize_tool_name("exec\""), "exec\""); + } + + /// All real tool names used in production must survive sanitization unchanged. + #[test] + fn sanitize_tool_name_noop_on_real_tool_names() { + let real_names = [ + "exec", + "web_search", + "web_fetch", + "memory_save", + "memory_search", + "file_read", + "file_write", + "calc", + "mcp-server_tool-name", + ]; + for name in real_names { + assert_eq!( + sanitize_tool_name(name), + name, + "sanitize_tool_name must be no-op on valid tool name '{name}'" + ); + } + } + + #[test] + fn sanitize_tool_name_empty_string() { + assert_eq!(sanitize_tool_name(""), ""); + assert_eq!(sanitize_tool_name(" "), ""); + } + + #[test] + fn sanitize_tool_name_only_quotes() { + // `""` → stripped to empty + assert_eq!(sanitize_tool_name("\"\""), ""); + } + + #[test] + fn sanitize_tool_name_preserves_internal_quotes() { + // Quotes in the middle are NOT stripped — only surrounding pair. + assert_eq!(sanitize_tool_name("my\"tool"), "my\"tool"); + } + + #[test] + fn sanitize_tool_name_single_quotes_not_stripped() { + // Only double quotes are stripped. + assert_eq!(sanitize_tool_name("'exec'"), "'exec'"); + } } diff --git a/crates/agents/src/tool_parsing.rs b/crates/agents/src/tool_parsing.rs index a8fb0a4d8..17fd1e716 100644 --- a/crates/agents/src/tool_parsing.rs +++ b/crates/agents/src/tool_parsing.rs @@ -48,7 +48,10 @@ pub fn parse_tool_calls_from_text(text: &str) -> (Vec, Option) // 2. Find all XML blocks. collect_function_blocks(text, &mut blocks); - // 3. Find bare JSON {"tool": ...} blocks. + // 3. Find XML value blocks. + collect_invoke_blocks(text, &mut blocks); + + // 4. Find bare JSON {"tool": ...} blocks. collect_bare_json_blocks(text, &mut blocks); if blocks.is_empty() { @@ -108,7 +111,10 @@ pub fn looks_like_failed_tool_call(text: &Option) -> bool { return false; }; let lower = t.to_ascii_lowercase(); - (lower.contains("\"tool\"") || lower.contains("tool_call") || lower.contains(") { } } +// ── XML parser ───────────────────────────────────────────────────── + +/// Extract the value of a named attribute from an XML attribute string. +/// E.g. `extract_xml_attr(r#"name="exec" id="1""#, "name")` → `Some("exec")`. +fn extract_xml_attr<'a>(attr_str: &'a str, attr_name: &str) -> Option<&'a str> { + let needle = format!("{attr_name}=\""); + let start = attr_str.find(&needle)?; + let value_start = start + needle.len(); + let rest = attr_str.get(value_start..)?; + let end = rest.find('"')?; + Some(&rest[..end]) +} + +/// Collect `value` blocks. +fn collect_invoke_blocks(text: &str, blocks: &mut Vec) { + let start_marker = "` of the opening `` tag. + let Some(open_end_rel) = rest.find('>') else { + break; + }; + + let attr_str = &rest[..open_end_rel]; + let Some(tool_name) = extract_xml_attr(attr_str, "name") else { + search_from = after_marker + open_end_rel + 1; + continue; + }; + if tool_name.is_empty() + || !tool_name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + { + search_from = after_marker + open_end_rel + 1; + continue; + } + + let body_start = after_marker + open_end_rel + 1; + let Some(after_open) = text.get(body_start..) else { + break; + }; + let Some(body_end_rel) = after_open.find("") else { + search_from = body_start; + continue; + }; + let body = &after_open[..body_end_rel]; + let abs_end = body_start + body_end_rel + "".len(); + + // Parse value pairs. + let mut args = serde_json::Map::new(); + let mut found = false; + let mut cursor = 0usize; + while let Some(arg_rel) = body[cursor..].find("') else { + break; + }; + let arg_attrs = &arg_rest[..arg_gt]; + let Some(param_name) = extract_xml_attr(arg_attrs, "name") else { + cursor = after_arg_tag + arg_gt + 1; + continue; + }; + if param_name.is_empty() { + cursor = after_arg_tag + arg_gt + 1; + continue; + } + + let value_start = after_arg_tag + arg_gt + 1; + let Some(value_rest) = body.get(value_start..) else { + break; + }; + let Some(value_end_rel) = value_rest.find("") else { + break; + }; + let value_raw = body + .get(value_start..value_start + value_end_rel) + .unwrap_or("") + .trim(); + + args.insert(param_name.to_string(), parse_param_value(value_raw)); + found = true; + cursor = value_start + value_end_rel + "".len(); + } + + if found { + blocks.push(ParsedBlock { + tool_call: ToolCall { + id: new_synthetic_tool_call_id("text"), + name: tool_name.to_string(), + arguments: serde_json::Value::Object(args), + }, + start: abs_start, + end: abs_end, + }); + } + search_from = abs_end; + } +} + // ── Bare JSON parser ──────────────────────────────────────────────────────── fn collect_bare_json_blocks(text: &str, blocks: &mut Vec) { @@ -492,4 +606,208 @@ Step 2: assert_ne!(id1, id2); assert!(id1.len() <= SYNTHETIC_TOOL_CALL_ID_MAX_LEN); } + + // ── invoke XML format ───────────────────────────────────────────── + + #[test] + fn parse_single_invoke_block() { + let text = r#"I'll execute the command. +ls -la +Done."#; + let (calls, remaining) = parse_tool_calls_from_text(text); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "exec"); + assert_eq!(calls[0].arguments["command"], "ls -la"); + let rem = remaining.unwrap(); + assert!(rem.contains("I'll execute the command.")); + assert!(rem.contains("Done.")); + } + + #[test] + fn parse_invoke_multiple_args() { + let text = r#"https://example.comGET"#; + let (calls, remaining) = parse_tool_calls_from_text(text); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "web_fetch"); + assert_eq!(calls[0].arguments["url"], "https://example.com"); + assert_eq!(calls[0].arguments["method"], "GET"); + assert!( + remaining.is_none() || remaining.as_deref() == Some(""), + "remaining: {remaining:?}" + ); + } + + #[test] + fn looks_like_failed_invoke() { + assert!(looks_like_failed_tool_call(&Some( + r#"ls"#.into() + ))); + } + + #[test] + fn extract_xml_attr_basic() { + assert_eq!( + extract_xml_attr(r#" name="exec" id="1""#, "name"), + Some("exec") + ); + assert_eq!(extract_xml_attr(r#" name="exec" id="1""#, "id"), Some("1")); + assert_eq!(extract_xml_attr(r#" name="exec""#, "missing"), None); + } + + // ── Backward compatibility: existing formats unaffected by invoke parser ── + + /// Fenced blocks must still work identically after adding the invoke parser. + #[test] + fn backward_compat_fenced_still_works() { + let text = r#"```tool_call +{"tool": "exec", "arguments": {"command": "pwd"}} +```"#; + let (calls, remaining) = parse_tool_calls_from_text(text); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "exec"); + assert_eq!(calls[0].arguments["command"], "pwd"); + assert!( + remaining.is_none() || remaining.as_deref() == Some(""), + "remaining: {remaining:?}" + ); + } + + /// XML format must still work identically. + #[test] + fn backward_compat_function_xml_still_works() { + let text = r#" +ls +"#; + let (calls, remaining) = parse_tool_calls_from_text(text); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "exec"); + assert_eq!(calls[0].arguments["command"], "ls"); + assert!( + remaining.is_none() || remaining.as_deref() == Some(""), + "remaining: {remaining:?}" + ); + } + + /// Bare JSON must still work identically. + #[test] + fn backward_compat_bare_json_still_works() { + let text = r#"Let me run: {"tool": "exec", "arguments": {"command": "whoami"}}"#; + let (calls, remaining) = parse_tool_calls_from_text(text); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "exec"); + let rem = remaining.unwrap(); + assert!(rem.contains("Let me run:")); + } + + // ── Invoke parser edge cases ────────────────────────────────────── + + /// Invoke without any children should NOT produce a tool call. + #[test] + fn invoke_without_args_not_parsed() { + let text = r#""#; + let (calls, remaining) = parse_tool_calls_from_text(text); + assert!( + calls.is_empty(), + "invoke with no args should not produce a call" + ); + assert_eq!(remaining.as_deref(), Some(text)); + } + + /// Invoke with missing closing tag is gracefully skipped. + #[test] + fn invoke_unclosed_skipped() { + let text = r#"before ls after"#; + let (calls, _remaining) = parse_tool_calls_from_text(text); + assert!( + calls.is_empty(), + "unclosed invoke should not produce a call" + ); + } + + /// Invoke without name attribute is skipped. + #[test] + fn invoke_missing_name_attr_skipped() { + let text = r#"ls"#; + let (calls, _) = parse_tool_calls_from_text(text); + assert!(calls.is_empty()); + } + + /// Invoke with empty name is skipped. + #[test] + fn invoke_empty_name_skipped() { + let text = r#"ls"#; + let (calls, _) = parse_tool_calls_from_text(text); + assert!(calls.is_empty()); + } + + /// Invoke with JSON value in arg body is parsed as structured value. + #[test] + fn invoke_json_arg_value() { + let text = r#"{"verbose": true}"#; + let (calls, _) = parse_tool_calls_from_text(text); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].arguments["config"]["verbose"], true); + } + + /// Invoke with multiline arg value. + #[test] + fn invoke_multiline_arg_value() { + let text = r#"echo "hello +world""#; + let (calls, _) = parse_tool_calls_from_text(text); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].arguments["command"], "echo \"hello\nworld\""); + } + + // ── Mixed format: invoke does not interfere with other parsers ──── + + /// Fenced block + invoke block together: both parsed correctly. + #[test] + fn mixed_fenced_and_invoke() { + let text = r#"Step 1: +```tool_call +{"tool": "exec", "arguments": {"command": "mkdir test"}} +``` +Step 2: +cd test"#; + let (calls, remaining) = parse_tool_calls_from_text(text); + assert_eq!(calls.len(), 2); + assert_eq!(calls[0].arguments["command"], "mkdir test"); + assert_eq!(calls[1].name, "exec"); + assert_eq!(calls[1].arguments["command"], "cd test"); + let rem = remaining.unwrap(); + assert!(rem.contains("Step 1:")); + assert!(rem.contains("Step 2:")); + } + + /// Multiple invoke blocks in one text. + #[test] + fn multiple_invoke_blocks() { + let text = r#"ls +rust"#; + let (calls, _) = parse_tool_calls_from_text(text); + assert_eq!(calls.len(), 2); + assert_eq!(calls[0].name, "exec"); + assert_eq!(calls[1].name, "web_search"); + } + + // ── looks_like_failed_tool_call: no false positives ────────────── + + /// English prose containing "invoke" must NOT be flagged as a failed tool call. + #[test] + fn looks_like_failed_invoke_no_false_positive_english() { + // No `ls"#; + assert!(!looks_like_failed_tool_call(&Some(valid.into()))); + } } diff --git a/crates/config/src/schema.rs b/crates/config/src/schema.rs index 51af18eff..24df83d78 100644 --- a/crates/config/src/schema.rs +++ b/crates/config/src/schema.rs @@ -775,10 +775,10 @@ pub struct ServerConfig { /// Defaults to 1000. Increase for busy servers, decrease for memory-constrained devices. #[serde(default = "default_log_buffer_size")] pub log_buffer_size: usize, - /// Optional GitHub repository URL used by the update checker. + /// URL of the releases manifest (`releases.json`) used by the update checker. /// - /// When unset, Moltis falls back to the package repository metadata. - pub update_repository_url: Option, + /// Defaults to `https://www.moltis.org/releases.json` when unset. + pub update_releases_url: Option, } fn default_log_buffer_size() -> usize { @@ -793,7 +793,7 @@ impl Default for ServerConfig { http_request_logs: false, ws_request_logs: false, log_buffer_size: default_log_buffer_size(), - update_repository_url: None, + update_releases_url: None, } } } @@ -2111,6 +2111,8 @@ pub struct ProviderEntry { pub api_key: Option>, /// Override the base URL. + /// Accepts legacy `url` as an alias for compatibility. + #[serde(alias = "url")] pub base_url: Option, /// Preferred model IDs for this provider. @@ -2642,6 +2644,19 @@ memory = 300 assert_eq!(parsed.tool_mode, ToolMode::Text); } + #[test] + fn provider_entry_url_alias_maps_to_base_url() { + let entry: ProviderEntry = toml::from_str( + r#" +enabled = true +url = "http://192.168.0.9:11434" +"#, + ) + .unwrap(); + + assert_eq!(entry.base_url.as_deref(), Some("http://192.168.0.9:11434")); + } + #[test] fn full_config_with_tool_mode() { let toml_str = r#" diff --git a/crates/config/src/template.rs b/crates/config/src/template.rs index cf5529d10..3313bcba2 100644 --- a/crates/config/src/template.rs +++ b/crates/config/src/template.rs @@ -25,7 +25,7 @@ bind = "127.0.0.1" # Address to bind to ("0.0.0.0" for all interf port = {port} # Port number (auto-generated for this installation) http_request_logs = false # Enable verbose Axum HTTP request/response logs (debugging) ws_request_logs = false # Enable WebSocket RPC request/response logs (debugging) -update_repository_url = "https://github.com/moltis-org/moltis" # GitHub repo used for update checks (comment out to disable) +update_releases_url = "https://www.moltis.org/releases.json" # Releases manifest URL for update checks (override to use a custom URL) # ══════════════════════════════════════════════════════════════════════════════ # AUTHENTICATION diff --git a/crates/config/src/validate.rs b/crates/config/src/validate.rs index c2deea978..cf57ff326 100644 --- a/crates/config/src/validate.rs +++ b/crates/config/src/validate.rs @@ -113,6 +113,7 @@ fn build_schema_map() -> KnownKeys { ("enabled", Leaf), ("api_key", Leaf), ("base_url", Leaf), + ("url", Leaf), ("models", Leaf), ("fetch_models", Leaf), ("stream_transport", Leaf), @@ -347,7 +348,7 @@ fn build_schema_map() -> KnownKeys { ("http_request_logs", Leaf), ("ws_request_logs", Leaf), ("log_buffer_size", Leaf), - ("update_repository_url", Leaf), + ("update_releases_url", Leaf), ])), ), ("providers", MapWithFields { @@ -2225,6 +2226,25 @@ tool_mode = "text" ); } + #[test] + fn url_field_accepted_in_provider_entry() { + let toml = r#" +[providers.ollama] +enabled = true +url = "http://192.168.0.9:11434" +"#; + let result = validate_toml_str(toml); + let unknown = result + .diagnostics + .iter() + .find(|d| d.category == "unknown-field" && d.path.contains("providers.ollama.url")); + assert!( + unknown.is_none(), + "url should be accepted as a provider field alias, got: {:?}", + result.diagnostics + ); + } + #[test] fn tool_mode_all_values_parse_correctly() { for mode in ["auto", "native", "text", "off"] { diff --git a/crates/gateway/src/server.rs b/crates/gateway/src/server.rs index 6df686374..26d8d0447 100644 --- a/crates/gateway/src/server.rs +++ b/crates/gateway/src/server.rs @@ -66,10 +66,7 @@ use crate::{ services::GatewayServices, session::LiveSessionService, state::GatewayState, - update_check::{ - UPDATE_CHECK_INTERVAL, fetch_update_availability, github_latest_release_api_url, - resolve_repository_url, - }, + update_check::{UPDATE_CHECK_INTERVAL, fetch_update_availability, resolve_releases_url}, ws::handle_connection, }; @@ -4016,25 +4013,10 @@ pub async fn prepare_gateway( }); } - // Spawn periodic update check against latest GitHub release. + // Spawn periodic update check against releases manifest. let update_state = Arc::clone(&state); - let update_repository_url = - resolve_repository_url(config.server.update_repository_url.as_deref()); + let releases_url = resolve_releases_url(config.server.update_releases_url.as_deref()); tokio::spawn(async move { - let latest_release_api_url = match update_repository_url { - Some(repository_url) => match github_latest_release_api_url(&repository_url) { - Ok(url) => url, - Err(e) => { - warn!("update checker disabled: {e}"); - return; - }, - }, - None => { - info!("update checker disabled: server.update_repository_url is not configured"); - return; - }, - }; - let client = match reqwest::Client::builder() .user_agent(format!("moltis-gateway/{}", update_state.version)) .timeout(std::time::Duration::from_secs(12)) @@ -4050,31 +4032,24 @@ pub async fn prepare_gateway( let mut interval = tokio::time::interval(UPDATE_CHECK_INTERVAL); loop { interval.tick().await; - match fetch_update_availability(&client, &latest_release_api_url, &update_state.version) - .await - { - Ok(next) => { - let changed = { - let mut inner = update_state.inner.write().await; - let update = &mut inner.update; - if *update == next { - false - } else { - *update = next.clone(); - true - } - }; - if changed && let Ok(payload) = serde_json::to_value(&next) { - broadcast(&update_state, "update.available", payload, BroadcastOpts { - drop_if_slow: true, - ..Default::default() - }) - .await; - } - }, - Err(e) => { - warn!("failed to check latest release: {e}"); - }, + let next = + fetch_update_availability(&client, &releases_url, &update_state.version).await; + let changed = { + let mut inner = update_state.inner.write().await; + let update = &mut inner.update; + if *update == next { + false + } else { + *update = next.clone(); + true + } + }; + if changed && let Ok(payload) = serde_json::to_value(&next) { + broadcast(&update_state, "update.available", payload, BroadcastOpts { + drop_if_slow: true, + ..Default::default() + }) + .await; } } }); diff --git a/crates/gateway/src/update_check.rs b/crates/gateway/src/update_check.rs index c9590131b..5cb9290d2 100644 --- a/crates/gateway/src/update_check.rs +++ b/crates/gateway/src/update_check.rs @@ -9,57 +9,77 @@ pub struct UpdateAvailability { pub release_url: Option, } -#[derive(Debug, thiserror::Error)] -pub enum UpdateCheckError { - #[error("repository URL is not a GitHub repository: {0}")] - UnsupportedRepository(String), - #[error("request failed: {0}")] - Request(#[from] reqwest::Error), +/// A channel entry in the releases manifest. +#[derive(Debug, Clone, serde::Deserialize)] +struct ReleaseChannel { + version: String, + release_url: Option, } +/// The `releases.json` manifest served at the configured URL. #[derive(Debug, serde::Deserialize)] -struct GithubLatestRelease { - tag_name: String, - html_url: Option, +struct ReleasesManifest { + stable: Option, + unstable: Option, } pub const UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(60 * 60); +const DEFAULT_RELEASES_URL: &str = "https://www.moltis.org/releases.json"; + +/// Resolve the releases manifest URL from config, falling back to the default. #[must_use] -pub fn resolve_repository_url(configured: Option<&str>) -> Option { +pub fn resolve_releases_url(configured: Option<&str>) -> String { configured .map(str::trim) .filter(|url| !url.is_empty()) - .map(str::to_owned) + .unwrap_or(DEFAULT_RELEASES_URL) + .to_owned() } -pub fn github_latest_release_api_url(repository_url: &str) -> Result { - let slug = github_repo_slug(repository_url) - .ok_or_else(|| UpdateCheckError::UnsupportedRepository(repository_url.to_owned()))?; - Ok(format!( - "https://api.github.com/repos/{slug}/releases/latest" - )) +/// Fetch update availability from the releases manifest. +/// +/// Returns a default (no update) on any error — 404, parse failure, network +/// issues — so callers never have to handle errors. +pub async fn fetch_update_availability( + client: &reqwest::Client, + releases_url: &str, + current_version: &str, +) -> UpdateAvailability { + match try_fetch_update(client, releases_url, current_version).await { + Ok(update) => update, + Err(e) => { + tracing::debug!("update check skipped: {e}"); + UpdateAvailability::default() + }, + } } -pub async fn fetch_update_availability( +async fn try_fetch_update( client: &reqwest::Client, - latest_release_api_url: &str, + releases_url: &str, current_version: &str, -) -> Result { - let release = client - .get(latest_release_api_url) - .header(reqwest::header::ACCEPT, "application/vnd.github+json") - .send() - .await? - .error_for_status()? - .json::() - .await?; - - Ok(update_from_release( - &release.tag_name, - release.html_url.as_deref(), - current_version, - )) +) -> Result> { + let response = client.get(releases_url).send().await?; + if !response.status().is_success() { + return Err(format!("HTTP {}", response.status()).into()); + } + let manifest: ReleasesManifest = response.json().await?; + + let channel = if is_pre_release(current_version) { + manifest.unstable.or(manifest.stable) + } else { + manifest.stable + }; + + match channel { + Some(release) => Ok(update_from_release( + &release.version, + release.release_url.as_deref(), + current_version, + )), + None => Ok(UpdateAvailability::default()), + } } fn update_from_release( @@ -75,26 +95,9 @@ fn update_from_release( } } -fn github_repo_slug(repository_url: &str) -> Option { - let trimmed = repository_url.trim(); - let without_scheme = trimmed - .strip_prefix("https://") - .or_else(|| trimmed.strip_prefix("http://"))?; - - let mut parts = without_scheme.split('/'); - let host = parts.next()?.trim(); - if !host.eq_ignore_ascii_case("github.com") { - return None; - } - - let owner = parts.next()?.trim(); - let repo_part = parts.next()?.trim(); - let repo = repo_part.strip_suffix(".git").unwrap_or(repo_part); - - if owner.is_empty() || repo.is_empty() { - return None; - } - Some(format!("{owner}/{repo}")) +fn is_pre_release(version: &str) -> bool { + let normalized = normalize_version(version); + normalized.contains('-') } fn is_newer_version(latest: &str, current: &str) -> bool { @@ -127,26 +130,6 @@ fn parse_semver_triplet(version: &str) -> Option<(u64, u64, u64)> { mod tests { use super::*; - #[test] - fn parses_github_repo_slug() { - assert_eq!( - github_repo_slug("https://github.com/moltis-org/moltis"), - Some("moltis-org/moltis".to_owned()) - ); - assert_eq!( - github_repo_slug("https://github.com/moltis-org/moltis/"), - Some("moltis-org/moltis".to_owned()) - ); - assert_eq!( - github_repo_slug("https://github.com/moltis-org/moltis.git"), - Some("moltis-org/moltis".to_owned()) - ); - assert_eq!( - github_repo_slug("https://example.com/moltis-org/moltis"), - None - ); - } - #[test] fn compares_semver_versions() { assert!(is_newer_version("0.3.0", "0.2.9")); @@ -157,17 +140,17 @@ mod tests { } #[test] - fn resolves_repository_url_with_config_override() { + fn resolves_releases_url_with_config_override() { assert_eq!( - resolve_repository_url(Some(" https://github.com/example/custom-repo ")), - Some("https://github.com/example/custom-repo".to_owned()) + resolve_releases_url(Some(" https://example.com/releases.json ")), + "https://example.com/releases.json" ); } #[test] - fn resolves_repository_url_none_when_missing_or_blank() { - assert_eq!(resolve_repository_url(Some(" ")), None); - assert_eq!(resolve_repository_url(None), None); + fn resolves_releases_url_default_when_missing_or_blank() { + assert_eq!(resolve_releases_url(Some(" ")), DEFAULT_RELEASES_URL); + assert_eq!(resolve_releases_url(None), DEFAULT_RELEASES_URL); } #[test] @@ -191,4 +174,48 @@ mod tests { Some("https://github.com/moltis-org/moltis/releases/tag/v0.3.0") ); } + + #[test] + fn detects_pre_release_versions() { + assert!(is_pre_release("0.11.0-rc.1")); + assert!(is_pre_release("v0.11.0-beta.2")); + assert!(!is_pre_release("0.10.7")); + assert!(!is_pre_release("v0.10.7")); + } + + #[test] + fn selects_channel_based_on_current_version() { + let stable = ReleaseChannel { + version: "0.10.7".into(), + release_url: Some("https://github.com/moltis-org/moltis/releases/tag/v0.10.7".into()), + }; + let unstable = ReleaseChannel { + version: "0.11.0-rc.2".into(), + release_url: Some( + "https://github.com/moltis-org/moltis/releases/tag/v0.11.0-rc.2".into(), + ), + }; + + // Stable current → picks stable channel + let current_stable = "0.10.6"; + assert!(!is_pre_release(current_stable)); + let update = update_from_release( + &stable.version, + stable.release_url.as_deref(), + current_stable, + ); + assert!(update.available); + assert_eq!(update.latest_version.as_deref(), Some("0.10.7")); + + // Pre-release current → would pick unstable channel + let current_pre = "0.11.0-rc.1"; + assert!(is_pre_release(current_pre)); + let update = update_from_release( + &unstable.version, + unstable.release_url.as_deref(), + current_pre, + ); + // Both are 0.11.0 after stripping pre-release suffix, so no update + assert!(!update.available); + } } diff --git a/crates/providers/src/lib.rs b/crates/providers/src/lib.rs index 9f44f97fb..f4b156d9a 100644 --- a/crates/providers/src/lib.rs +++ b/crates/providers/src/lib.rs @@ -304,6 +304,9 @@ fn discover_ollama_models(base_url: &str) -> anyhow::Result struct OllamaShowResponse { #[serde(default)] details: OllamaModelDetails, + /// Ollama >= 0.5.x returns a list of model capabilities (e.g. `["tools"]`). + #[serde(default)] + capabilities: Vec, } #[derive(Debug, Clone, Default, serde::Deserialize)] @@ -354,6 +357,18 @@ fn ollama_model_supports_native_tools(model_name: &str, details: &OllamaModelDet .any(|known| name_lower.contains(known)) } +/// Check if Ollama's `capabilities` list indicates native tool support. +/// +/// Returns `Some(true)` if `"tools"` is present, `Some(false)` if capabilities +/// exist but don't include tools, and `None` if the list is empty (pre-0.5.x +/// Ollama versions that don't report capabilities). +fn ollama_capabilities_support_tools(capabilities: &[String]) -> Option { + if capabilities.is_empty() { + return None; + } + Some(capabilities.iter().any(|c| c == "tools")) +} + /// Probe the Ollama `/api/show` endpoint for a specific model to get its family /// and details. Returns `Ok(response)` on success, error on timeout/failure. async fn probe_ollama_model_info( @@ -380,7 +395,9 @@ async fn probe_ollama_model_info( /// Resolve the effective tool mode for an Ollama model. /// /// - If the user configured an explicit `tool_mode`, use that. -/// - Otherwise, probe the model and decide based on its family. +/// - Otherwise, check the model's `capabilities` list from Ollama (>= 0.5.x). +/// - Fall back to the hardcoded family whitelist only when capabilities are +/// unavailable (pre-0.5.x Ollama). fn resolve_ollama_tool_mode( config_tool_mode: moltis_config::ToolMode, model_name: &str, @@ -391,6 +408,17 @@ fn resolve_ollama_tool_mode( match config_tool_mode { ToolMode::Native | ToolMode::Text | ToolMode::Off => config_tool_mode, ToolMode::Auto => { + // Prefer Ollama's own capabilities field when available. + if let Some(resp) = probe_result + && let Some(supports) = ollama_capabilities_support_tools(&resp.capabilities) + { + return if supports { + ToolMode::Native + } else { + ToolMode::Text + }; + } + // Fallback: family whitelist (pre-0.5.x Ollama without capabilities). let details = probe_result .map(|r| &r.details) .cloned() @@ -3316,6 +3344,7 @@ mod tests { family: Some("llama3.1".into()), families: None, }, + ..Default::default() }; assert_eq!( resolve_ollama_tool_mode(ToolMode::Auto, "llama3.1:8b", Some(&show_resp)), @@ -3331,6 +3360,7 @@ mod tests { family: Some("starcoder2".into()), families: None, }, + ..Default::default() }; assert_eq!( resolve_ollama_tool_mode(ToolMode::Auto, "starcoder2:3b", Some(&show_resp)), @@ -3338,6 +3368,144 @@ mod tests { ); } + // ── Ollama capabilities-based tool detection ────────────────────── + + #[test] + fn ollama_capabilities_with_tools() { + let caps = vec!["completion".into(), "tools".into()]; + assert_eq!(ollama_capabilities_support_tools(&caps), Some(true)); + } + + #[test] + fn ollama_capabilities_without_tools() { + let caps = vec!["completion".into(), "vision".into()]; + assert_eq!(ollama_capabilities_support_tools(&caps), Some(false)); + } + + #[test] + fn ollama_capabilities_empty_returns_none() { + let caps: Vec = vec![]; + assert_eq!(ollama_capabilities_support_tools(&caps), None); + } + + #[test] + fn resolve_ollama_tool_mode_capabilities_override_family() { + use moltis_config::ToolMode; + // Model is NOT in the family whitelist but Ollama reports "tools" capability. + let show_resp = OllamaShowResponse { + details: OllamaModelDetails { + family: Some("minimax".into()), + families: None, + }, + capabilities: vec!["completion".into(), "tools".into()], + }; + assert_eq!( + resolve_ollama_tool_mode(ToolMode::Auto, "MiniMax-M2.5:latest", Some(&show_resp)), + ToolMode::Native + ); + } + + #[test] + fn resolve_ollama_tool_mode_capabilities_no_tools() { + use moltis_config::ToolMode; + // Model has capabilities but "tools" is not among them. + let show_resp = OllamaShowResponse { + details: OllamaModelDetails { + family: Some("llama3.1".into()), + families: None, + }, + capabilities: vec!["completion".into()], + }; + // Even though family matches, capabilities say no tools. + assert_eq!( + resolve_ollama_tool_mode(ToolMode::Auto, "llama3.1:8b", Some(&show_resp)), + ToolMode::Text + ); + } + + #[test] + fn resolve_ollama_tool_mode_empty_capabilities_falls_back_to_family() { + use moltis_config::ToolMode; + // Empty capabilities (pre-0.5.x Ollama) — falls back to family whitelist. + let show_resp = OllamaShowResponse { + details: OllamaModelDetails { + family: Some("llama3.1".into()), + families: None, + }, + capabilities: vec![], + }; + assert_eq!( + resolve_ollama_tool_mode(ToolMode::Auto, "llama3.1:8b", Some(&show_resp)), + ToolMode::Native + ); + } + + #[test] + fn resolve_ollama_tool_mode_no_probe_result_falls_back_to_name_heuristic() { + use moltis_config::ToolMode; + // No probe result at all — falls back to model name matching. + assert_eq!( + resolve_ollama_tool_mode(ToolMode::Auto, "llama3.1:8b", None), + ToolMode::Native + ); + assert_eq!( + resolve_ollama_tool_mode(ToolMode::Auto, "unknown-model:latest", None), + ToolMode::Text + ); + } + + #[test] + fn resolve_ollama_tool_mode_explicit_overrides_capabilities() { + use moltis_config::ToolMode; + // Even with capabilities saying "tools", explicit Text override wins. + let show_resp = OllamaShowResponse { + details: OllamaModelDetails { + family: Some("minimax".into()), + families: None, + }, + capabilities: vec!["tools".into()], + }; + assert_eq!( + resolve_ollama_tool_mode(ToolMode::Text, "MiniMax-M2.5:latest", Some(&show_resp)), + ToolMode::Text + ); + assert_eq!( + resolve_ollama_tool_mode(ToolMode::Off, "MiniMax-M2.5:latest", Some(&show_resp)), + ToolMode::Off + ); + } + + /// Verify OllamaShowResponse deserializes from Ollama >= 0.5.x JSON with capabilities. + #[test] + fn ollama_show_response_deserializes_with_capabilities() { + let json = r#"{ + "details": {"family": "minimax", "families": null}, + "capabilities": ["completion", "tools"] + }"#; + let resp: OllamaShowResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.details.family.as_deref(), Some("minimax")); + assert_eq!(resp.capabilities, vec!["completion", "tools"]); + } + + /// Verify OllamaShowResponse deserializes from old Ollama without capabilities field. + #[test] + fn ollama_show_response_deserializes_without_capabilities() { + let json = r#"{"details": {"family": "llama3.1", "families": ["llama3.1"]}}"#; + let resp: OllamaShowResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.details.family.as_deref(), Some("llama3.1")); + assert!( + resp.capabilities.is_empty(), + "missing field should default to empty vec" + ); + } + + /// Capabilities with only "tools" (single item). + #[test] + fn ollama_capabilities_single_tools_entry() { + let caps = vec!["tools".into()]; + assert_eq!(ollama_capabilities_support_tools(&caps), Some(true)); + } + #[test] fn openai_provider_supports_tools_respects_override() { use moltis_config::ToolMode; diff --git a/crates/swift-bridge/src/lib.rs b/crates/swift-bridge/src/lib.rs index 036353e03..6928285dc 100644 --- a/crates/swift-bridge/src/lib.rs +++ b/crates/swift-bridge/src/lib.rs @@ -43,6 +43,9 @@ struct BridgeState { impl BridgeState { fn new() -> Self { + #[cfg(test)] + init_swift_bridge_test_dirs(); + emit_log( "INFO", "bridge", @@ -92,6 +95,9 @@ impl BridgeState { if let Err(e) = moltis_sessions::run_migrations(&pool).await { emit_log("WARN", "bridge", &format!("sessions migration: {e}")); } + if let Err(e) = moltis_gateway::run_migrations(&pool).await { + emit_log("WARN", "bridge", &format!("gateway migration: {e}")); + } pool }); let event_bus = SessionEventBus::new(); @@ -136,6 +142,31 @@ impl BridgeState { } } +#[cfg(test)] +fn init_swift_bridge_test_dirs() { + static TEST_DIRS_INIT: std::sync::OnceLock<()> = std::sync::OnceLock::new(); + + TEST_DIRS_INIT.get_or_init(|| { + let base = std::env::temp_dir().join(format!( + "moltis-swift-bridge-tests-{}-{}", + std::process::id(), + uuid::Uuid::new_v4().simple() + )); + let config_dir = base.join("config"); + let data_dir = base.join("data"); + + if let Err(error) = std::fs::create_dir_all(&config_dir) { + panic!("failed to create swift-bridge test config dir: {error}"); + } + if let Err(error) = std::fs::create_dir_all(&data_dir) { + panic!("failed to create swift-bridge test data dir: {error}"); + } + + moltis_config::set_config_dir(config_dir); + moltis_config::set_data_dir(data_dir); + }); +} + fn build_registry() -> ProviderRegistry { let config = moltis_config::discover_and_load(); let env_overrides = config.env.clone(); @@ -3889,9 +3920,27 @@ mod tests { } #[test] + #[serial_test::serial] fn chat_stream_sends_error_for_no_provider() { use std::sync::{Arc, Mutex}; + // Force a no-provider environment so this test exercises the + // synchronous error callback path deterministically. + let original_registry = { + let mut guard = BRIDGE.registry.write().unwrap_or_else(|e| e.into_inner()); + std::mem::replace(&mut *guard, ProviderRegistry::empty()) + }; + struct RestoreRegistry(Option); + impl Drop for RestoreRegistry { + fn drop(&mut self) { + if let Some(registry) = self.0.take() { + let mut guard = BRIDGE.registry.write().unwrap_or_else(|e| e.into_inner()); + *guard = registry; + } + } + } + let _restore_registry = RestoreRegistry(Some(original_registry)); + let events: Arc>> = Arc::new(Mutex::new(Vec::new())); let events_clone = Arc::clone(&events); let user_data = Arc::into_raw(events_clone) as *mut c_void; @@ -3906,7 +3955,7 @@ mod tests { } } - // Use a model that almost certainly won't match any configured provider. + // With an empty registry, this must error synchronously. let request = r#"{"message":"test","model":"nonexistent-model-xyz"}"#; let c_request = CString::new(request).unwrap_or_else(|e| panic!("{e}")); @@ -3915,18 +3964,20 @@ mod tests { moltis_chat_stream(c_request.as_ptr(), test_callback, user_data); } - // Wait briefly for the async task to complete (it may also error synchronously). - std::thread::sleep(std::time::Duration::from_millis(200)); - let events = unsafe { Arc::from_raw(user_data as *const Mutex>) }; let received = events.lock().unwrap_or_else(|e| e.into_inner()); - // Should receive at least one event (either an error for no provider, - // or a done event if somehow a provider matched). assert!( !received.is_empty(), "should receive at least one stream event" ); + let parsed: Value = + serde_json::from_str(&received[0]).unwrap_or_else(|e| panic!("bad json: {e}")); + assert_eq!( + parsed.get("type").and_then(Value::as_str), + Some("error"), + "expected an error event when no provider is available" + ); } #[test] diff --git a/crates/web/src/assets/css/components.css b/crates/web/src/assets/css/components.css index 8a85d4a8d..153e396d0 100644 --- a/crates/web/src/assets/css/components.css +++ b/crates/web/src/assets/css/components.css @@ -1388,6 +1388,30 @@ 50% { transform: scale(1.05); } } +/* -- VAD Button States -- */ +.vad-btn.vad-active { + border-color: var(--accent) !important; + color: var(--accent) !important; +} + +.vad-btn.vad-listening { + border-color: var(--accent) !important; + color: var(--accent) !important; + animation: vad-glow 2s ease-in-out infinite; +} + +.vad-btn.vad-speech { + background: var(--error) !important; + border-color: var(--error) !important; + color: #fff !important; + animation: mic-pulse 1s infinite; +} + +@keyframes vad-glow { + 0%, 100% { opacity: 0.7; } + 50% { opacity: 1; } +} + /* ── Toggle Switch ── */ .toggle-switch { diff --git a/crates/web/src/assets/js/code-highlight.js b/crates/web/src/assets/js/code-highlight.js index 9d5c7544a..50fbfb01e 100644 --- a/crates/web/src/assets/js/code-highlight.js +++ b/crates/web/src/assets/js/code-highlight.js @@ -39,7 +39,7 @@ export function isReady() { * @param {HTMLElement} containerEl */ export function highlightCodeBlocks(containerEl) { - if (!highlighter || !containerEl) return; + if (!(highlighter && containerEl)) return; var codeEls = containerEl.querySelectorAll("pre code[data-lang]"); for (var codeEl of codeEls) { if (codeEl.querySelector(".shiki") || codeEl.classList.contains("shiki")) continue; diff --git a/crates/web/src/assets/js/locales/en/chat.js b/crates/web/src/assets/js/locales/en/chat.js index 4d4806276..816bc857a 100644 --- a/crates/web/src/assets/js/locales/en/chat.js +++ b/crates/web/src/assets/js/locales/en/chat.js @@ -9,6 +9,8 @@ export default { micStopAndSend: "Click to stop and send", voiceTranscribing: "Transcribing...", voiceTranscribingMessage: "Transcribing voice...", + vadTooltip: "Conversation mode (VAD)", + vadStopTooltip: "Click to stop conversation mode", // ── Slash commands ─────────────────────────────────────── slashClear: "Clear conversation history", diff --git a/crates/web/src/assets/js/locales/fr/chat.js b/crates/web/src/assets/js/locales/fr/chat.js index a24eb0116..c1a31f885 100644 --- a/crates/web/src/assets/js/locales/fr/chat.js +++ b/crates/web/src/assets/js/locales/fr/chat.js @@ -9,6 +9,8 @@ export default { micStopAndSend: "Cliquez pour arrêter et envoyer", voiceTranscribing: "Transcription...", voiceTranscribingMessage: "Transcription de la voix...", + vadTooltip: "Mode conversation (VAD)", + vadStopTooltip: "Cliquer pour quitter le mode conversation", // ── Slash commands ─────────────────────────────────────── slashClear: "Clear conversation history", diff --git a/crates/web/src/assets/js/locales/zh/chat.js b/crates/web/src/assets/js/locales/zh/chat.js index 76c45f2c6..077bd5680 100644 --- a/crates/web/src/assets/js/locales/zh/chat.js +++ b/crates/web/src/assets/js/locales/zh/chat.js @@ -9,6 +9,8 @@ export default { micStopAndSend: "点击停止并发送", voiceTranscribing: "转录中...", voiceTranscribingMessage: "正在转录语音...", + vadTooltip: "对话模式 (VAD)", + vadStopTooltip: "点击停止对话模式", // ── Slash commands ─────────────────────────────────────── slashClear: "清除对话历史", diff --git a/crates/web/src/assets/js/page-chat.js b/crates/web/src/assets/js/page-chat.js index 770cfbd91..5046442fe 100644 --- a/crates/web/src/assets/js/page-chat.js +++ b/crates/web/src/assets/js/page-chat.js @@ -27,7 +27,7 @@ import { switchSession, } from "./sessions.js"; import * as S from "./state.js"; -import { initVoiceInput, teardownVoiceInput } from "./voice-input.js"; +import { initVadButton, initVoiceInput, teardownVoiceInput } from "./voice-input.js"; // ── Slash commands ─────────────────────────────────────── var slashCommands = [ @@ -1019,6 +1019,10 @@ var chatPageHTML = 'class="mic-btn min-h-[40px] px-3 bg-[var(--surface2)] border border-[var(--border)] rounded-lg text-[var(--muted)] cursor-pointer disabled:opacity-40 disabled:cursor-default transition-colors hover:border-[var(--border-strong)] hover:text-[var(--text)]">' + '' + "" + + '" + '' + ""; @@ -1168,6 +1172,7 @@ registerPrefix( // Initialize voice input initVoiceInput(S.$("micBtn")); + initVadButton(S.$("vadBtn")); // Desktop only: mobile keeps chat focused and avoids drag/drop chrome. if (window.innerWidth >= 768) { diff --git a/crates/web/src/assets/js/page-settings.js b/crates/web/src/assets/js/page-settings.js index 70851f1d5..4f5ed5958 100644 --- a/crates/web/src/assets/js/page-settings.js +++ b/crates/web/src/assets/js/page-settings.js @@ -33,6 +33,7 @@ import { connected } from "./signals.js"; import * as S from "./state.js"; import { fetchPhrase } from "./tts-phrases.js"; import { Modal } from "./ui.js"; +import { getPttKey, getVadSensitivity, setPttKey, setVadSensitivity } from "./voice-input.js"; import { decodeBase64Safe, fetchVoiceProviders, @@ -2634,6 +2635,13 @@ function VoiceSection() { var [activeRecorder, setActiveRecorder] = useState(null); // MediaRecorder for STT stop functionality var [voiceTestResults, setVoiceTestResults] = useState({}); // { providerId: { text, error } } + // PTT key configuration + var [pttKeyValue, setPttKeyValue] = useState(getPttKey()); + var [pttListening, setPttListening] = useState(false); + + // VAD sensitivity + var [vadSens, setVadSens] = useState(getVadSensitivity()); + function fetchVoiceStatus(options) { if (!options?.silent) { setVoiceLoading(true); @@ -2906,7 +2914,60 @@ function VoiceSection() { - <${AddVoiceProviderModal} + +
+

Push-to-Talk

+

+ Hold a keyboard key to record voice input. Release to send. + Function keys (F1–F24) work even when focused in an input field. +

+
+ + +
+
+ + +
+

Conversation Mode (VAD)

+

+ Adjust how sensitive the voice activity detection is. Higher values pick up + softer speech but may trigger on background noise. +

+
+ + { + var val = parseInt(e.target.value, 10); + setVadSens(val); + setVadSensitivity(val); + rerender(); + }} /> + ${vadSens}% +
+
+ + <${AddVoiceProviderModal} unconfiguredProviders=${getUnconfiguredProviders()} voxtralReqs=${voxtralReqs} onSaved=${() => { diff --git a/crates/web/src/assets/js/voice-input.js b/crates/web/src/assets/js/voice-input.js index 813c61f79..1d8e68a6d 100644 --- a/crates/web/src/assets/js/voice-input.js +++ b/crates/web/src/assets/js/voice-input.js @@ -1,5 +1,11 @@ // ── Voice input module ─────────────────────────────────────── // Handles microphone recording and speech-to-text transcription. +// Supports three input modes: +// 1. Toggle (default): click mic to start, click again to stop & send +// 2. Push-to-talk (PTT): hold a hotkey to record, release to send +// 3. VAD (continuous): click waveform button to enter hands-free mode; +// auto-detects speech via energy-based VAD, auto-sends on silence, +// auto-re-listens after TTS playback finishes. import { chatAddMsg } from "./chat-ui.js"; import * as gon from "./gon.js"; @@ -7,8 +13,11 @@ import { renderAudioPlayer, renderMarkdown, sendRpc, warmAudioPlayback } from ". import { t } from "./i18n.js"; import { bumpSessionCount, seedSessionPreviewFromUserText, setSessionReplying } from "./sessions.js"; import * as S from "./state.js"; +import { sessionStore } from "./stores/session-store.js"; +// ── Shared state ───────────────────────────────────────────── var micBtn = null; +var vadBtn = null; var mediaRecorder = null; var audioChunks = []; var sttConfigured = false; @@ -16,17 +25,72 @@ var isRecording = false; var isStarting = false; var transcribingEl = null; +// ── PTT state ──────────────────────────────────────────────── +var pttKey = localStorage.getItem("moltis_ptt_key") || "F13"; +var pttActive = false; // true while PTT key is held + +// ── Tab coordination (prevent dual-tab recording) ──────────── +// Only one tab should handle PTT/toggle at a time. When a tab starts +// recording, it broadcasts a claim. Other tabs back off. +var voiceLockChannel = typeof BroadcastChannel !== "undefined" ? new BroadcastChannel("moltis_voice_lock") : null; +var voiceLockedByOtherTab = false; +if (voiceLockChannel) { + voiceLockChannel.onmessage = (e) => { + if (e.data?.type === "voice_lock") { + voiceLockedByOtherTab = true; + console.debug("[voice] another tab claimed voice lock"); + } else if (e.data?.type === "voice_unlock") { + voiceLockedByOtherTab = false; + console.debug("[voice] another tab released voice lock"); + } + }; +} +function claimVoiceLock() { + voiceLockedByOtherTab = false; + if (voiceLockChannel) voiceLockChannel.postMessage({ type: "voice_lock" }); +} +function releaseVoiceLock() { + if (voiceLockChannel) voiceLockChannel.postMessage({ type: "voice_unlock" }); +} + +// ── VAD state ──────────────────────────────────────────────── +var vadActive = false; +var vadStream = null; +var vadAudioCtx = null; +var vadAnalyser = null; +var vadDataArray = null; +var vadRafId = null; +var vadSpeechDetected = false; +var vadSilenceStart = 0; +var vadMutedForTts = false; +var VAD_SENSITIVITY = parseInt(localStorage.getItem("moltis_vad_sensitivity") || "50", 10); +var VAD_SPEECH_THRESHOLD = sensitivityToThreshold(VAD_SENSITIVITY); + +/** Map sensitivity percentage (0-100) to RMS threshold. + * 0% = least sensitive (threshold 0.08), 100% = most sensitive (threshold 0.005). */ +function sensitivityToThreshold(pct) { + var clamped = Math.max(0, Math.min(100, pct)); + // Exponential curve: low sensitivity = high threshold, high sensitivity = low threshold + return 0.08 * (0.005 / 0.08) ** (clamped / 100); +} +var VAD_SILENCE_DURATION = 2500; // ms of silence before auto-send +var VAD_DEBOUNCE_SPEECH = 250; // ms of speech before we consider it speech +var vadSpeechStart = 0; +var vadRecordingStart = 0; +var vadMediaRecorder = null; // separate recorder for VAD continuous mode +var vadTranscribing = false; // true during transcription fetch, prevents recorder restart races + /** Check if voice feature is enabled. */ function isVoiceEnabled() { return gon.get("voice_enabled") === true; } -/** Check if STT is available and enable/disable mic button. */ +/** Check if STT is available and enable/disable buttons. */ async function checkSttStatus() { - // If voice feature is disabled, always hide the button if (!isVoiceEnabled()) { sttConfigured = false; updateMicButton(); + updateVadButton(); return; } var res = await sendRpc("stt.status", {}); @@ -36,18 +100,30 @@ async function checkSttStatus() { sttConfigured = false; } updateMicButton(); + updateVadButton(); } +// ── Mic button (toggle mode) ───────────────────────────────── + /** Update mic button visibility based on STT configuration. */ function updateMicButton() { if (!micBtn) return; - // Hide button when voice feature is disabled or STT is not configured micBtn.style.display = sttConfigured && isVoiceEnabled() ? "" : "none"; - // Disable only when not connected (button is only visible when STT configured) micBtn.disabled = !S.connected; micBtn.title = isStarting ? t("chat:micStarting") : isRecording ? t("chat:micStopAndSend") : t("chat:micTooltip"); } +// ── VAD button ─────────────────────────────────────────────── + +function updateVadButton() { + if (!vadBtn) return; + vadBtn.style.display = sttConfigured && isVoiceEnabled() ? "" : "none"; + vadBtn.disabled = !S.connected; + vadBtn.title = vadActive ? t("chat:vadStopTooltip") : t("chat:vadTooltip"); +} + +// ── Audio helpers ──────────────────────────────────────────── + /** Pause all currently playing audio elements on the page. */ function stopAllAudio() { for (var audio of document.querySelectorAll("audio")) { @@ -58,32 +134,57 @@ function stopAllAudio() { } } +function getRMS(analyser, dataArray) { + analyser.getByteTimeDomainData(dataArray); + var sum = 0; + for (var sample of dataArray) { + var val = (sample - 128) / 128; + sum += val * val; + } + return Math.sqrt(sum / dataArray.length); +} + +// ── Recording (shared by toggle + PTT + VAD) ───────────────── + /** Start recording audio from the microphone. */ -async function startRecording() { +async function startRecording(opts) { if (isRecording || isStarting || !sttConfigured) return; + var fromVad = opts?.fromVad === true; + var stream = opts?.stream || null; + // Stop any playing audio so the mic doesn't pick up speaker output. - stopAllAudio(); + if (!fromVad) stopAllAudio(); isStarting = true; - micBtn.classList.add("starting"); - micBtn.setAttribute("aria-busy", "true"); - micBtn.title = t("chat:micStarting"); + if (micBtn && !fromVad) { + micBtn.classList.add("starting"); + micBtn.setAttribute("aria-busy", "true"); + micBtn.title = t("chat:micStarting"); + } try { - var stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + if (!stream) { + stream = await navigator.mediaDevices.getUserMedia({ + audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }, + }); + } audioChunks = []; var recordingUiShown = false; function showRecordingUi() { - if (recordingUiShown || !micBtn) return; + if (recordingUiShown) return; recordingUiShown = true; isStarting = false; - micBtn.classList.remove("starting"); - micBtn.removeAttribute("aria-busy"); - micBtn.classList.add("recording"); - micBtn.setAttribute("aria-pressed", "true"); - micBtn.title = t("chat:micStopAndSend"); + if (fromVad) { + if (vadBtn) vadBtn.classList.add("vad-speech"); + } else if (micBtn) { + micBtn.classList.remove("starting"); + micBtn.removeAttribute("aria-busy"); + micBtn.classList.add("recording"); + micBtn.setAttribute("aria-pressed", "true"); + micBtn.title = t("chat:micStopAndSend"); + } } // Use webm/opus if available, fall back to audio/webm @@ -111,9 +212,14 @@ async function startRecording() { } mediaRecorder.onstop = async () => { - // Stop all tracks to release the microphone - for (var track of stream.getTracks()) { - track.stop(); + // Only stop tracks if NOT in VAD mode (VAD keeps the stream open) + if (!fromVad) { + for (var track of stream.getTracks()) { + track.stop(); + } + } + if (fromVad && vadBtn) { + vadBtn.classList.remove("vad-speech"); } await transcribeAudio(); }; @@ -122,7 +228,7 @@ async function startRecording() { } catch (err) { isStarting = false; isRecording = false; - if (micBtn) { + if (micBtn && !fromVad) { micBtn.classList.remove("starting"); micBtn.removeAttribute("aria-busy"); micBtn.setAttribute("aria-pressed", "false"); @@ -144,12 +250,14 @@ function stopRecording() { isStarting = false; isRecording = false; - micBtn.classList.remove("starting"); - micBtn.removeAttribute("aria-busy"); - micBtn.classList.remove("recording"); - micBtn.setAttribute("aria-pressed", "false"); - micBtn.classList.add("transcribing"); - micBtn.title = t("chat:voiceTranscribing"); + if (micBtn) { + micBtn.classList.remove("starting"); + micBtn.removeAttribute("aria-busy"); + micBtn.classList.remove("recording"); + micBtn.setAttribute("aria-pressed", "false"); + micBtn.classList.add("transcribing"); + micBtn.title = t("chat:voiceTranscribing"); + } // Stop the recorder, which triggers onstop -> transcribeAudio mediaRecorder.stop(); @@ -166,15 +274,20 @@ function cancelRecording() { isStarting = false; isRecording = false; - micBtn.classList.remove("starting", "recording"); - micBtn.removeAttribute("aria-busy"); - micBtn.setAttribute("aria-pressed", "false"); - micBtn.title = t("chat:micTooltip"); + if (micBtn) { + micBtn.classList.remove("starting", "recording"); + micBtn.removeAttribute("aria-busy"); + micBtn.setAttribute("aria-pressed", "false"); + micBtn.title = t("chat:micTooltip"); + } + if (vadBtn) vadBtn.classList.remove("vad-speech"); // Stop the recorder — onstop will see empty chunks and bail out. mediaRecorder.stop(); } +// ── Transcription UI helpers ───────────────────────────────── + /** Create transcribing indicator element. */ function createTranscribingIndicator(message, isError) { var el = document.createElement("div"); @@ -218,16 +331,20 @@ function showTemporaryMessage(message, isError, delayMs) { /** Remove transcribing indicator and reset mic button state. */ function cleanupTranscribingState() { isStarting = false; - micBtn.classList.remove("starting"); - micBtn.removeAttribute("aria-busy"); - micBtn.classList.remove("transcribing"); - micBtn.title = t("chat:micTooltip"); + if (micBtn) { + micBtn.classList.remove("starting"); + micBtn.removeAttribute("aria-busy"); + micBtn.classList.remove("transcribing"); + micBtn.title = t("chat:micTooltip"); + } if (transcribingEl) { transcribingEl.remove(); transcribingEl = null; } } +// ── Send transcribed message ───────────────────────────────── + /** Send transcribed text as a chat message. */ function sendTranscribedMessage(text, audioFilename) { // Unlock audio playback while we still have user-gesture context. @@ -271,6 +388,8 @@ function sendTranscribedMessage(text, audioFilename) { }); } +// ── Transcription ──────────────────────────────────────────── + /** Send recorded audio to STT service for transcription via upload endpoint. */ async function transcribeAudio() { if (audioChunks.length === 0) { @@ -289,15 +408,39 @@ async function transcribeAudio() { var blob = new Blob(audioChunks, { type: "audio/webm" }); audioChunks = []; + // Skip tiny blobs that are just WebM headers with no real audio — + // these cause 400 errors from the STT API. + if (blob.size < 2000) { + console.debug("[voice] skipping tiny blob:", blob.size, "bytes"); + cleanupTranscribingState(); + return; + } + + // Validate EBML header (WebM magic bytes: 1A 45 DF A3). + // Corrupt blobs from recorder restart races won't have proper headers. + var headerBytes = new Uint8Array(await blob.slice(0, 4).arrayBuffer()); + if (headerBytes[0] !== 0x1a || headerBytes[1] !== 0x45 || headerBytes[2] !== 0xdf || headerBytes[3] !== 0xa3) { + console.warn("[voice] corrupt WebM blob (bad EBML header), discarding. size:", blob.size); + cleanupTranscribingState(); + return; + } + + // Timeout after 15s to prevent vadTranscribing from getting stuck forever + var abortCtrl = new AbortController(); + var fetchTimeout = setTimeout(() => abortCtrl.abort(), 15000); var resp = await fetch(`/api/sessions/${encodeURIComponent(S.activeSessionKey)}/upload?transcribe=true`, { method: "POST", headers: { "Content-Type": blob.type || "audio/webm" }, body: blob, + signal: abortCtrl.signal, }); + clearTimeout(fetchTimeout); var res = await resp.json(); - micBtn.classList.remove("transcribing"); - micBtn.title = t("chat:micTooltip"); + if (micBtn) { + micBtn.classList.remove("transcribing"); + micBtn.title = t("chat:micTooltip"); + } if (res.ok && res.transcription?.text) { var text = res.transcription.text.trim(); @@ -317,22 +460,461 @@ async function transcribeAudio() { } } catch (err) { console.error("Transcription error:", err); - micBtn.classList.remove("transcribing"); - micBtn.title = t("chat:micTooltip"); + if (micBtn) { + micBtn.classList.remove("transcribing"); + micBtn.title = t("chat:micTooltip"); + } showTemporaryMessage("Transcription error", true, 4000); } } +// ── Toggle mode (mic button click) ─────────────────────────── + /** Handle click on mic button - toggle recording. */ function onMicClick(e) { e.preventDefault(); + if (vadActive) return; // don't interfere with VAD mode if (isRecording) { + releaseVoiceLock(); stopRecording(); } else { + if (voiceLockedByOtherTab) return; // another tab is recording + claimVoiceLock(); startRecording(); } } +// ── PTT (push-to-talk via hotkey) ──────────────────────────── + +function onPttKeyDown(e) { + if (e.key !== pttKey) return; + if (vadActive || pttActive || isRecording) return; + // Allow function keys (F1-F24) even in inputs — dedicated hardware keys. + // Block regular character keys when typing in an input/textarea. + var isFunctionKey = /^F[0-9]{1,2}$/.test(e.key); + if (!isFunctionKey) { + var tag = document.activeElement?.tagName; + if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return; + } + + e.preventDefault(); + if (voiceLockedByOtherTab) return; // another tab is recording + pttActive = true; + claimVoiceLock(); + console.debug("[voice] PTT start:", pttKey); + stopAllAudio(); + startRecording(); +} + +function onPttKeyUp(e) { + if (e.key !== pttKey) return; + if (!pttActive) return; + + e.preventDefault(); + pttActive = false; + releaseVoiceLock(); + console.debug("[voice] PTT release — sending"); + stopRecording(); +} + +// ── VAD (voice activity detection) ─────────────────────────── + +async function startVad() { + if (vadActive) return; + + console.debug("[voice] VAD starting"); + try { + vadStream = await navigator.mediaDevices.getUserMedia({ + audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }, + }); + } catch (err) { + console.error("[voice] VAD mic access failed:", err); + if (err.name === "NotAllowedError") { + alert(t("settings:voice.micDenied")); + } + return; + } + + vadActive = true; + vadSpeechDetected = false; + vadSilenceStart = 0; + vadSpeechStart = 0; + vadMutedForTts = false; + claimVoiceLock(); + + if (vadBtn) { + vadBtn.classList.add("vad-active"); + vadBtn.title = t("chat:vadStopTooltip"); + } + + // Set up audio analysis + vadAudioCtx = new AudioContext(); + var source = vadAudioCtx.createMediaStreamSource(vadStream); + vadAnalyser = vadAudioCtx.createAnalyser(); + vadAnalyser.fftSize = 512; + vadAnalyser.smoothingTimeConstant = 0.3; + source.connect(vadAnalyser); + vadDataArray = new Uint8Array(vadAnalyser.fftSize); + + // Start continuous recording immediately — captures full audio including + // lead-in before speech detection, so Whisper gets complete utterances. + vadStartContinuousRecorder(); + + // Start monitoring loop + vadMonitorLoop(); + + // Watch for TTS audio playback to mute/unmute + document.addEventListener("play", onTtsPlay, true); + document.addEventListener("ended", onTtsEnded, true); + document.addEventListener("pause", onTtsPause, true); +} + +/** Start (or restart) the continuous MediaRecorder for VAD mode. + * Runs the entire time we are listening — speech/silence detection + * only decides when to STOP and SEND, not when to start recording. */ +function vadStartContinuousRecorder() { + if (!(vadActive && vadStream)) return; + if (vadTranscribing) return; // Don't restart while transcription is in flight + audioChunks = []; + var mimeType = MediaRecorder.isTypeSupported("audio/webm;codecs=opus") ? "audio/webm;codecs=opus" : "audio/webm"; + vadMediaRecorder = new MediaRecorder(vadStream, { mimeType }); + vadMediaRecorder.ondataavailable = (e) => { + if (e.data.size > 0) audioChunks.push(e.data); + }; + vadMediaRecorder.onstop = async () => { + if (vadBtn) vadBtn.classList.remove("vad-speech"); + // Only transcribe if we actually detected speech in this cycle + if (audioChunks.length > 0 && vadSpeechDetected) { + vadSpeechDetected = false; + vadTranscribing = true; // Prevent monitor loop from restarting recorder during fetch + try { + await transcribeAudio(); + } finally { + vadTranscribing = false; + } + } else { + audioChunks = []; + vadSpeechDetected = false; + } + // Restart recorder for next listening cycle (if still active and not muted) + if (vadActive && !vadMutedForTts) { + vadStartContinuousRecorder(); + } + }; + vadMediaRecorder.start(250); // collect data every 250ms + console.debug("[voice] VAD continuous recorder started"); +} + +/** Reacquire microphone stream when the original track dies. + * Reconnects the AnalyserNode so RMS monitoring works again. */ +async function vadReacquireStream() { + try { + var newStream = await navigator.mediaDevices.getUserMedia({ + audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }, + }); + vadStream = newStream; + // Reconnect analyser to new stream + if (vadAudioCtx && vadAnalyser) { + var source = vadAudioCtx.createMediaStreamSource(newStream); + source.connect(vadAnalyser); + } + // Restart recorder with new stream + if (vadMediaRecorder && vadMediaRecorder.state === "recording") { + vadMediaRecorder.stop(); + } else { + vadStartContinuousRecorder(); + } + console.debug("[voice] VAD: stream reacquired successfully"); + } catch (err) { + console.error("[voice] VAD: failed to reacquire stream:", err); + stopVad(); + } +} + +function stopVad() { + if (!vadActive) return; + console.debug("[voice] VAD stopping"); + + vadActive = false; + vadSpeechDetected = false; + vadTranscribing = false; + + // Stop VAD continuous recorder + if (vadMediaRecorder && vadMediaRecorder.state !== "inactive") { + audioChunks = []; // discard — we're shutting down, not sending + vadMediaRecorder.stop(); + } + vadMediaRecorder = null; + + // Cancel any ongoing toggle/PTT recording too + if (isRecording && mediaRecorder) { + audioChunks = []; + isRecording = false; + mediaRecorder.stop(); + } + + // Stop monitoring + if (vadRafId) { + cancelAnimationFrame(vadRafId); + vadRafId = null; + } + + // Close audio context + if (vadAudioCtx) { + vadAudioCtx.close().catch(() => { + /* ignore */ + }); + vadAudioCtx = null; + vadAnalyser = null; + vadDataArray = null; + } + + // Release mic + if (vadStream) { + for (var track of vadStream.getTracks()) track.stop(); + vadStream = null; + } + + // Clean up UI + if (vadBtn) { + vadBtn.classList.remove("vad-active", "vad-speech", "vad-listening"); + vadBtn.title = t("chat:vadTooltip"); + } + + releaseVoiceLock(); + + document.removeEventListener("play", onTtsPlay, true); + document.removeEventListener("ended", onTtsEnded, true); + document.removeEventListener("pause", onTtsPause, true); +} + +function vadMonitorLoop() { + if (!vadActive) return; + + // Health check: resume AudioContext if browser suspended it (happens after + // extended use without direct user interaction — RMS reads 0 forever). + if (vadAudioCtx && vadAudioCtx.state === "suspended") { + console.debug("[voice] VAD: AudioContext suspended, resuming"); + vadAudioCtx.resume(); + } + + // Health check: if the mic stream track ended, reacquire it. + if (vadStream) { + var track = vadStream.getAudioTracks()[0]; + if (!track || track.readyState !== "live") { + console.warn("[voice] VAD: mic track died, reacquiring"); + vadReacquireStream(); + vadRafId = requestAnimationFrame(vadMonitorLoop); + return; + } + } + + // Skip monitoring while TTS is playing or while we are transcribing + if (vadMutedForTts || micBtn?.classList.contains("transcribing")) { + // Safety fallback: if muted for TTS for over 10s, the ended event was missed + if (vadMutedForTts && !vadMonitorLoop._muteStart) { + vadMonitorLoop._muteStart = Date.now(); + } else if (vadMutedForTts && Date.now() - vadMonitorLoop._muteStart > 10000) { + console.debug("[voice] VAD: TTS mute timeout, force-resuming"); + vadMutedForTts = false; + vadMonitorLoop._muteStart = 0; + vadSpeechDetected = false; + vadStartContinuousRecorder(); + if (vadBtn) vadBtn.classList.add("vad-listening"); + } + vadRafId = requestAnimationFrame(vadMonitorLoop); + return; + } + vadMonitorLoop._muteStart = 0; // reset when not muted + + // Also skip if the session is still replying (waiting for AI response) + var activeSession = sessionStore.getByKey(S.activeSessionKey); + if (activeSession?.replying.value) { + vadRafId = requestAnimationFrame(vadMonitorLoop); + return; + } + + // Show listening state when recorder is running + if ( + vadMediaRecorder && + vadMediaRecorder.state === "recording" && + vadBtn && + !vadBtn.classList.contains("vad-listening") && + !vadBtn.classList.contains("vad-speech") + ) { + vadBtn.classList.add("vad-listening"); + } + + // Restart recorder if it died (e.g. after TTS mute cycle or replying wait) + // Skip if transcription is in-flight — onstop handler will restart after fetch. + if (!vadTranscribing && (!vadMediaRecorder || vadMediaRecorder.state === "inactive")) { + vadStartContinuousRecorder(); + } + + var rms = getRMS(vadAnalyser, vadDataArray); + var now = Date.now(); + + // Debug: log RMS every ~1s + if (!vadMonitorLoop._lastLog || now - vadMonitorLoop._lastLog > 1000) { + vadMonitorLoop._lastLog = now; + console.debug( + "[voice] VAD rms:", + rms.toFixed(4), + "speech:", + vadSpeechDetected, + "muted:", + vadMutedForTts, + "transcribing:", + vadTranscribing, + "ctx:", + vadAudioCtx?.state, + ); + } + + if (rms > VAD_SPEECH_THRESHOLD) { + // Speech detected + vadSilenceStart = 0; + + // Safety valve: auto-stop after 30s of continuous speech + if (vadSpeechDetected && vadRecordingStart && now - vadRecordingStart > 30000) { + console.debug("[voice] VAD: max duration reached, auto-sending"); + vadSilenceStart = 0; + vadRecordingStart = 0; + if (vadBtn) vadBtn.classList.remove("vad-speech", "vad-listening"); + if (vadMediaRecorder && vadMediaRecorder.state === "recording") { + vadMediaRecorder.stop(); + } + vadRafId = requestAnimationFrame(vadMonitorLoop); + return; + } + + if (!vadSpeechDetected) { + // Debounce: require speech for VAD_DEBOUNCE_SPEECH ms before marking + if (!vadSpeechStart) { + vadSpeechStart = now; + } else if (now - vadSpeechStart >= VAD_DEBOUNCE_SPEECH) { + vadSpeechDetected = true; + vadSpeechStart = 0; + vadRecordingStart = now; + console.debug("[voice] VAD: speech detected (recorder already running)"); + stopAllAudio(); // stop any playing TTS + if (vadBtn) vadBtn.classList.add("vad-speech"); + } + } + } else { + // Silence + vadSpeechStart = 0; + + if (vadSpeechDetected) { + if (!vadSilenceStart) { + vadSilenceStart = now; + } else if (now - vadSilenceStart >= VAD_SILENCE_DURATION) { + // Enough silence after speech — stop recorder and send + console.debug("[voice] VAD: silence detected, stopping & sending"); + vadRecordingStart = 0; + vadSilenceStart = 0; + if (vadBtn) vadBtn.classList.remove("vad-speech", "vad-listening"); + if (vadMediaRecorder && vadMediaRecorder.state === "recording") { + vadMediaRecorder.stop(); + } else { + vadSpeechDetected = false; + audioChunks = []; + } + } + } + } + + vadRafId = requestAnimationFrame(vadMonitorLoop); +} + +// ── TTS mute/unmute for VAD ────────────────────────────────── + +/** Check if any audio element on the page is currently playing. */ +function isAnyAudioPlaying() { + return Array.from(document.querySelectorAll("audio")).some((a) => !(a.paused || a.ended)); +} + +function onTtsPlay(e) { + if (!vadActive) return; + if (e.target?.tagName !== "AUDIO") return; + console.debug("[voice] VAD: TTS playing, muting VAD + stopping recorder"); + vadMutedForTts = true; + if (vadBtn) vadBtn.classList.remove("vad-listening", "vad-speech"); + if (vadMediaRecorder && vadMediaRecorder.state === "recording") { + vadSpeechDetected = false; + audioChunks = []; + vadMediaRecorder.stop(); + vadMediaRecorder = null; + } +} + +function onTtsEnded(e) { + if (!vadActive) return; + if (e.target?.tagName !== "AUDIO") return; + console.debug("[voice] VAD: TTS ended, resuming VAD after delay"); + vadSpeechDetected = false; + vadSilenceStart = 0; + vadSpeechStart = 0; + setTimeout(() => { + if (!vadActive) return; + // Don't unmute if another audio chunk started playing in the meantime + if (isAnyAudioPlaying()) { + console.debug("[voice] VAD: another audio still playing, staying muted"); + return; + } + vadMutedForTts = false; + // Only restart recorder if transcription isn't in-flight + if (!vadTranscribing) { + vadStartContinuousRecorder(); + if (vadBtn) vadBtn.classList.add("vad-listening"); + } + }, 400); +} + +function onTtsPause(e) { + if (!vadActive) return; + if (e.target?.tagName !== "AUDIO") return; + // Check if audio actually ended (paused at the end) + var audio = e.target; + if (audio.ended || (audio.duration && audio.currentTime >= audio.duration - 0.1)) { + // Treat as ended — restart VAD + console.debug("[voice] VAD: TTS paused at end, treating as ended"); + vadSpeechDetected = false; + vadSilenceStart = 0; + vadSpeechStart = 0; + setTimeout(() => { + if (!vadActive) return; + // Don't unmute if another audio chunk started playing + if (isAnyAudioPlaying()) { + console.debug("[voice] VAD: another audio still playing, staying muted"); + return; + } + vadMutedForTts = false; + // Only restart recorder if transcription isn't in-flight + if (!vadTranscribing) { + vadStartContinuousRecorder(); + if (vadBtn) vadBtn.classList.add("vad-listening"); + } + }, 400); + } else if (!isAnyAudioPlaying()) { + // Manual pause mid-playback — only unmute if nothing else is playing + vadMutedForTts = false; + } +} + +// ── VAD button click ───────────────────────────────────────── + +function onVadClick(e) { + e.preventDefault(); + if (vadActive) { + stopVad(); + } else { + startVad(); + } +} + +// ── Init / teardown ────────────────────────────────────────── + /** Initialize voice input with the mic button element. */ export function initVoiceInput(btn) { if (!btn) return; @@ -358,21 +940,41 @@ export function initVoiceInput(btn) { if (e.key === "Escape" && isRecording) { e.preventDefault(); cancelRecording(); + if (vadActive) stopVad(); } }); + // PTT: global key handlers + document.addEventListener("keydown", onPttKeyDown); + document.addEventListener("keyup", onPttKeyUp); + // Re-check STT status when voice config changes window.addEventListener("voice-config-changed", checkSttStatus); } +/** Initialize VAD (conversation mode) button. */ +export function initVadButton(btn) { + if (!btn) return; + vadBtn = btn; + updateVadButton(); + vadBtn.addEventListener("click", onVadClick); +} + /** Teardown voice input module. */ export function teardownVoiceInput() { + if (vadActive) stopVad(); if (isRecording && mediaRecorder) { mediaRecorder.stop(); } + document.removeEventListener("keydown", onPttKeyDown); + document.removeEventListener("keyup", onPttKeyUp); window.removeEventListener("voice-config-changed", checkSttStatus); + releaseVoiceLock(); micBtn = null; + vadBtn = null; mediaRecorder = null; + vadMediaRecorder = null; + vadTranscribing = false; audioChunks = []; isRecording = false; } @@ -381,3 +983,33 @@ export function teardownVoiceInput() { export function refreshVoiceStatus() { checkSttStatus(); } + +/** Update PTT key at runtime. */ +export function setPttKey(key) { + pttKey = key; + localStorage.setItem("moltis_ptt_key", key); + console.debug("[voice] PTT key set to:", key); +} + +/** Get current PTT key. */ +export function getPttKey() { + return pttKey; +} + +/** Check if VAD is currently active. */ +export function isVadModeActive() { + return vadActive; +} + +/** Update VAD sensitivity at runtime (0-100). */ +export function setVadSensitivity(pct) { + VAD_SENSITIVITY = Math.max(0, Math.min(100, pct)); + VAD_SPEECH_THRESHOLD = sensitivityToThreshold(VAD_SENSITIVITY); + localStorage.setItem("moltis_vad_sensitivity", String(VAD_SENSITIVITY)); + console.debug("[voice] VAD sensitivity set to:", VAD_SENSITIVITY, "threshold:", VAD_SPEECH_THRESHOLD.toFixed(4)); +} + +/** Get current VAD sensitivity (0-100). */ +export function getVadSensitivity() { + return VAD_SENSITIVITY; +} diff --git a/crates/web/src/assets/js/websocket.js b/crates/web/src/assets/js/websocket.js index 00f5de4c4..baa76ee93 100644 --- a/crates/web/src/assets/js/websocket.js +++ b/crates/web/src/assets/js/websocket.js @@ -764,7 +764,7 @@ function handleChatError(p, isActive, isChatPage, eventSession) { moveFirstQueuedToChat(); } -function handleChatAborted(p, isActive, isChatPage, eventSession) { +function handleChatAborted(_p, isActive, isChatPage, eventSession) { clearPendingToolCallEndsForSession(eventSession); setSessionReplying(eventSession, false); setSessionActiveRunId(eventSession, null); diff --git a/crates/web/ui/build.sh b/crates/web/ui/build.sh index 196c63997..e82c0c016 100755 --- a/crates/web/ui/build.sh +++ b/crates/web/ui/build.sh @@ -8,18 +8,25 @@ set -euo pipefail cd "$(dirname "$0")" -# Resolve the tailwindcss binary: explicit override → global CLI → local node_modules. +# Ensure local node_modules include Tailwind CLI deps before resolving a binary. +if [[ ! -d node_modules/@tailwindcss/cli || ! -d node_modules/tailwindcss ]]; then + echo "tailwind deps missing — installing npm devDependencies..." >&2 + if [[ -f package-lock.json ]]; then + npm ci --ignore-scripts + else + npm install --ignore-scripts + fi +fi + +# Resolve the tailwindcss binary: explicit override → local node_modules → global CLI. if [[ -n "${TAILWINDCSS:-}" ]]; then TAILWIND="$TAILWINDCSS" +elif [[ -x node_modules/.bin/tailwindcss ]]; then + TAILWIND="node_modules/.bin/tailwindcss" elif command -v tailwindcss &>/dev/null; then TAILWIND="tailwindcss" else - # Ensure local node_modules are installed so npx can find @tailwindcss/cli. - if [[ ! -d node_modules ]]; then - echo "node_modules not found — running npm install..." >&2 - npm install --ignore-scripts - fi - TAILWIND="npx tailwindcss" + TAILWIND="npx --no-install @tailwindcss/cli" fi if [[ "${1:-}" == "--watch" ]]; then diff --git a/crates/web/ui/e2e/specs/chat-abort.spec.js b/crates/web/ui/e2e/specs/chat-abort.spec.js index c5fbec2f4..00a4553fe 100644 --- a/crates/web/ui/e2e/specs/chat-abort.spec.js +++ b/crates/web/ui/e2e/specs/chat-abort.spec.js @@ -39,6 +39,21 @@ test.describe("Chat abort", () => { test.beforeEach(async ({ page }) => { await navigateAndWait(page, "/chats/main"); await waitForWsConnected(page); + + // Wait for the session switch RPC to finish rendering history. + // Without this, renderHistory() can clear #messages after we inject + // fake DOM elements, causing flaky "element not found" failures. + await page.waitForFunction( + async () => { + var appScript = document.querySelector('script[type="module"][src*="js/app.js"]'); + if (!appScript) return false; + var appUrl = new URL(appScript.src, window.location.origin); + var prefix = appUrl.href.slice(0, appUrl.href.length - "js/app.js".length); + var state = await import(`${prefix}js/state.js`); + return !state.sessionSwitchInProgress && !state.chatBatchLoading; + }, + { timeout: 10_000 }, + ); }); test("thinking indicator shows stop button", async ({ page }) => { diff --git a/crates/web/ui/input.css b/crates/web/ui/input.css index 8784536f1..746d7c411 100644 --- a/crates/web/ui/input.css +++ b/crates/web/ui/input.css @@ -1907,6 +1907,11 @@ mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M12 18.75a6 6 0 0 0 6-6v-1.5m-6 7.5a6 6 0 0 1-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 0 1-3-3V4.5a3 3 0 1 1 6 0v8.25a3 3 0 0 1-3 3Z'/%3E%3C/svg%3E"); } + .icon-waveform { + -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' d='M3 12h1M7 8v8M11 5v14M15 8v8M19 10v4M23 12h-1'%2F%3E%3C/svg%3E"); + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='1.5' stroke-linecap='round' d='M3 12h1M7 8v8M11 5v14M15 8v8M19 10v4M23 12h-1'%2F%3E%3C/svg%3E"); + } + /* Status icons */ .icon-checkmark { -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='black' stroke-width='3' d='M5 13l4 4L19 7'/%3E%3C/svg%3E"); diff --git a/fly.toml b/fly.toml index 1a4cbfde2..488152033 100644 --- a/fly.toml +++ b/fly.toml @@ -1,5 +1,5 @@ [build] -image = 'ghcr.io/moltis-org/moltis:0.10.6' +image = 'ghcr.io/moltis-org/moltis:0.10.11' [env] MOLTIS_BEHIND_PROXY = 'true' diff --git a/justfile b/justfile index faffe71ad..0b3c25946 100644 --- a/justfile +++ b/justfile @@ -196,11 +196,64 @@ flatpak: # Run all CI checks (format, lint, build, test) ci: format-check lint i18n-check build-css build test +# Compile once, then run Rust tests and E2E tests in parallel. +# Uses the same nightly toolchain as clippy/local-validate so the build cache +# is shared — no double-compilation. +build-test: build-css + #!/usr/bin/env bash + set -euo pipefail + echo "==> Building all workspace targets (bins + tests)..." + cargo +{{nightly_toolchain}} build --workspace --all-features --all-targets + echo "==> Build complete. Running Rust tests and E2E tests in parallel..." + + RUST_LOG="$(mktemp)" + E2E_LOG="$(mktemp)" + trap 'rm -f "${RUST_LOG}" "${E2E_LOG}"' EXIT + + cargo +{{nightly_toolchain}} nextest run --all-features > "${RUST_LOG}" 2>&1 & + TEST_PID=$! + + (cd crates/web/ui && npm run e2e) > "${E2E_LOG}" 2>&1 & + E2E_PID=$! + + TEST_EXIT=0; E2E_EXIT=0 + wait "${TEST_PID}" || TEST_EXIT=$? + wait "${E2E_PID}" || E2E_EXIT=$? + + if [ "${TEST_EXIT}" -ne 0 ]; then + echo "==> Rust tests FAILED (exit ${TEST_EXIT}):" + cat "${RUST_LOG}" + else + echo "==> Rust tests PASSED" + fi + + if [ "${E2E_EXIT}" -ne 0 ]; then + echo "==> E2E tests FAILED (exit ${E2E_EXIT}):" + cat "${E2E_LOG}" + else + echo "==> E2E tests PASSED" + fi + + exit $(( TEST_EXIT > 0 ? TEST_EXIT : E2E_EXIT )) + # Run the same Rust preflight gates used before release packaging. release-preflight: lockfile-check cargo +{{nightly_toolchain}} fmt --all -- --check cargo +{{nightly_toolchain}} clippy -Z unstable-options --workspace --all-features --all-targets --timings -- -D warnings +# Dispatch release workflow from GitHub Actions (normal mode). +release-workflow ref='main': + gh workflow run release.yml --ref {{ref}} -f dry_run=false + +# Dispatch release workflow from GitHub Actions (dry-run mode). +release-workflow-dry ref='main': + gh workflow run release.yml --ref {{ref}} -f dry_run=true + +# Dispatch both release workflow modes for the same ref (dry-run then normal). +release-workflow-both ref='main': + gh workflow run release.yml --ref {{ref}} -f dry_run=true + gh workflow run release.yml --ref {{ref}} -f dry_run=false + # Regenerate CHANGELOG.md from git history and tags. changelog: git-cliff --config cliff.toml --output CHANGELOG.md @@ -218,9 +271,9 @@ changelog-release version: ship commit_message='' pr_title='' pr_body='': ./scripts/ship-pr.sh {{ quote(commit_message) }} {{ quote(pr_title) }} {{ quote(pr_body) }} -# Run all tests +# Run all tests (nightly to share build cache with clippy/lint) test: - cargo nextest run --all-features + cargo +{{nightly_toolchain}} nextest run --all-features # Run contract test suites (channel, provider, memory, tools) contract-tests: @@ -239,12 +292,12 @@ ui-e2e-install: # Run gateway web UI e2e tests (Playwright). ui-e2e: - cargo build --bin moltis + cargo +{{nightly_toolchain}} build --bin moltis cd crates/web/ui && npm run e2e # Run gateway web UI e2e tests with headed browser. ui-e2e-headed: - cargo build --bin moltis + cargo +{{nightly_toolchain}} build --bin moltis cd crates/web/ui && npm run e2e:headed # Build all Linux packages (deb + rpm + arch + appimage) for all architectures diff --git a/render.yaml b/render.yaml index 2301943b9..e47c1f4ed 100644 --- a/render.yaml +++ b/render.yaml @@ -3,7 +3,7 @@ services: name: moltis runtime: image image: - url: ghcr.io/moltis-org/moltis:0.10.6 + url: ghcr.io/moltis-org/moltis:0.10.11 plan: free healthCheckPath: /health envVars: diff --git a/scripts/download-tailwindcss-cli.sh b/scripts/download-tailwindcss-cli.sh new file mode 100755 index 000000000..b3ac9881a --- /dev/null +++ b/scripts/download-tailwindcss-cli.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "usage: $0 " >&2 + exit 2 +fi + +asset="$1" +url="https://github.com/tailwindlabs/tailwindcss/releases/latest/download/${asset}" + +curl \ + --fail \ + --silent \ + --show-error \ + --location \ + --retry 5 \ + --retry-delay 2 \ + --retry-connrefused \ + --retry-all-errors \ + --output "${asset}" \ + "${url}" + +if [[ ! -s "${asset}" ]]; then + echo "downloaded ${asset} is empty" >&2 + exit 1 +fi + +magic="$(od -An -tx1 -N4 "${asset}" | tr -d ' \n')" + +case "${asset}" in + tailwindcss-linux-*) + if [[ "${magic}" != "7f454c46" ]]; then + echo "downloaded ${asset} does not look like an ELF binary (magic=${magic})" >&2 + exit 1 + fi + ;; + tailwindcss-macos-*) + case "${magic}" in + cffaedfe | cefaedfe | cafebabe | bebafeca) ;; + *) + echo "downloaded ${asset} does not look like a Mach-O binary (magic=${magic})" >&2 + exit 1 + ;; + esac + ;; + *.exe) + if [[ "${magic:0:4}" != "4d5a" ]]; then + echo "downloaded ${asset} does not look like a PE executable (magic=${magic})" >&2 + exit 1 + fi + ;; +esac + +chmod +x "${asset}" diff --git a/scripts/local-validate.sh b/scripts/local-validate.sh index 7d133d864..6a9fca149 100755 --- a/scripts/local-validate.sh +++ b/scripts/local-validate.sh @@ -176,11 +176,12 @@ biome_cmd="${LOCAL_VALIDATE_BIOME_CMD:-biome ci --diagnostic-level=error crates/ i18n_cmd="${LOCAL_VALIDATE_I18N_CMD:-./scripts/i18n-check.sh}" zizmor_cmd="${LOCAL_VALIDATE_ZIZMOR_CMD:-./scripts/run-zizmor-resilient.sh . --min-severity high}" lint_cmd="${LOCAL_VALIDATE_LINT_CMD:-cargo +${nightly_toolchain} clippy -Z unstable-options --workspace --all-features --all-targets --timings -- -D warnings}" -test_cmd="${LOCAL_VALIDATE_TEST_CMD:-cargo nextest run --all-features}" +test_cmd="${LOCAL_VALIDATE_TEST_CMD:-cargo +${nightly_toolchain} nextest run --all-features}" e2e_cmd="${LOCAL_VALIDATE_E2E_CMD:-cd crates/web/ui && if [ ! -d node_modules ]; then npm ci; fi && npm run e2e:install && npm run e2e}" coverage_cmd="${LOCAL_VALIDATE_COVERAGE_CMD:-cargo +${nightly_toolchain} llvm-cov --workspace --all-features --html}" macos_app_cmd="${LOCAL_VALIDATE_MACOS_APP_CMD:-./scripts/build-swift-bridge.sh && ./scripts/generate-swift-project.sh && ./scripts/lint-swift.sh && xcodebuild -project apps/macos/Moltis.xcodeproj -scheme Moltis -configuration Release -destination \"platform=macOS\" -derivedDataPath apps/macos/.derivedData-local-validate build}" ios_app_cmd="${LOCAL_VALIDATE_IOS_APP_CMD:-cargo run -p moltis-schema-export -- apps/ios/GraphQL/Schema/schema.graphqls && ./scripts/generate-ios-graphql.sh && ./scripts/generate-ios-project.sh && xcodebuild -project apps/ios/Moltis.xcodeproj -scheme Moltis -configuration Debug -destination \"generic/platform=iOS\" CODE_SIGNING_ALLOWED=NO build}" +build_cmd="${LOCAL_VALIDATE_BUILD_CMD:-cargo +${nightly_toolchain} build --workspace --all-features --all-targets}" strip_all_features_flag() { local cmd="$1" @@ -196,16 +197,20 @@ if [[ "$(uname -s)" == "Darwin" ]] && ! command -v nvcc >/dev/null 2>&1; then lint_cmd="cargo +${nightly_toolchain} clippy -Z unstable-options --workspace --all-targets --timings -- -D warnings" fi if [[ -z "${LOCAL_VALIDATE_TEST_CMD:-}" ]]; then - test_cmd="cargo nextest run" + test_cmd="cargo +${nightly_toolchain} nextest run" + fi + if [[ -z "${LOCAL_VALIDATE_BUILD_CMD:-}" ]]; then + build_cmd="cargo +${nightly_toolchain} build --workspace --all-targets" fi if [[ -z "${LOCAL_VALIDATE_COVERAGE_CMD:-}" ]]; then coverage_cmd="cargo +${nightly_toolchain} llvm-cov --workspace --html" fi lint_cmd="$(strip_all_features_flag "$lint_cmd")" test_cmd="$(strip_all_features_flag "$test_cmd")" + build_cmd="$(strip_all_features_flag "$build_cmd")" coverage_cmd="$(strip_all_features_flag "$coverage_cmd")" echo "Detected macOS without nvcc; forcing non-CUDA local validation commands (no --all-features)." >&2 - echo "Override with LOCAL_VALIDATE_LINT_CMD / LOCAL_VALIDATE_TEST_CMD / LOCAL_VALIDATE_COVERAGE_CMD if needed." >&2 + echo "Override with LOCAL_VALIDATE_LINT_CMD / LOCAL_VALIDATE_TEST_CMD / LOCAL_VALIDATE_BUILD_CMD / LOCAL_VALIDATE_COVERAGE_CMD if needed." >&2 fi ensure_zizmor() { @@ -474,14 +479,13 @@ if rustup target list --installed 2>/dev/null | grep -q wasm32-wasip2; then cargo build --target wasm32-wasip2 -p moltis-wasm-calc -p moltis-wasm-web-fetch -p moltis-wasm-web-search --release fi -# Build the gateway binary so e2e startup scripts find a fresh binary and -# skip recompilation. Clippy (lint) compiled dependencies but may not produce -# a runnable binary with matching fingerprints. -echo "Building moltis binary for e2e tests..." -cargo +"${nightly_toolchain}" build --bin moltis +# Compile all workspace targets (bin + test harnesses) using the same nightly +# toolchain as clippy. After clippy this is near-instant (shared build cache) +# and means both nextest and E2E reuse these artifacts without recompilation. +run_check "local/build" "$build_cmd" -# After lint, run test / macOS app / e2e in parallel — they use independent -# toolchains (cargo nextest, Xcode, Playwright) and don't contend on resources. +# After build, run test / macOS app / e2e in parallel — all reuse the compiled +# artifacts (same nightly toolchain) and don't contend on resources. run_check_async "local/test" "$test_cmd" test_pid="$RUN_CHECK_ASYNC_PID" diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 9d4a41fae..d62a39190 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -31,7 +31,13 @@ parts: - build-essential - libclang-dev - git + - curl override-build: | + ARCH=$(uname -m) + case "$ARCH" in x86_64) TW="tailwindcss-linux-x64";; aarch64) TW="tailwindcss-linux-arm64";; esac + curl -sLO "https://github.com/tailwindlabs/tailwindcss/releases/latest/download/$TW" + chmod +x "$TW" + cd crates/web/ui && TAILWINDCSS="../../../$TW" ./build.sh && cd ../../.. rustup target add wasm32-wasip2 cargo build --target wasm32-wasip2 -p moltis-wasm-calc -p moltis-wasm-web-fetch -p moltis-wasm-web-search --release craftctl default