diff --git a/.env.example b/.env.example index fe0f0c8..e3b2d9d 100644 --- a/.env.example +++ b/.env.example @@ -11,17 +11,24 @@ OPENAI_API_KEY=sk-your-key-here OPENAI_BASE_URL=https://api.deepseek.com/v1 OPENAI_MODEL=deepseek-chat +# enabled = let the model/provider run thinking if it supports it. +# disabled = request non-thinking output with thinking:{type:disabled}. +OPENAI_THINKING_MODE=disabled # ------------------------------------------------------------------------- -# GPU inference services (SenseVoice STT + CosyVoice TTS) +# Speech Provider API (macOS native helper by default) # ------------------------------------------------------------------------- -# Host or IP of the GPU services. Use `localhost` for single-machine dev. -GPU_HOST=localhost -SENSEVOICE_WS_PORT=8000 -COSYVOICE_WS_PORT=8001 +# Setup manages these for most users. Advanced users may point them at any +# service that implements docs/provider-api.md. +VOCALIZE_STT_PROVIDER_URL=http://127.0.0.1:8765 +VOCALIZE_TTS_PROVIDER_URL=http://127.0.0.1:8765 +VOCALIZE_PROVIDER_CONNECT_TIMEOUT_S=5.0 +VOCALIZE_SPEECH_PROVIDER_AUTO_START=0 +VOCALIZE_SPEECH_PROVIDER_COMMAND= +VOCALIZE_SPEECH_PROVIDER_STARTUP_TIMEOUT_S=5.0 # ------------------------------------------------------------------------- -# Orchestrator (FastAPI on Pi, or local dev box) +# Orchestrator (packaged local app or source dev server) # ------------------------------------------------------------------------- # Bind address: 127.0.0.1 for local dev, 0.0.0.0 for production deploy. # Non-localhost values activate the D-11 startup guard (requires @@ -44,7 +51,7 @@ DEFAULT_LANGUAGE=zh LOG_DIR=logs # ------------------------------------------------------------------------- -# Frontend (Next.js — baked into the JS bundle at build time) +# Frontend (Vite — baked into the JS bundle at build time) # ------------------------------------------------------------------------- -NEXT_PUBLIC_VOCALIZE_API_BASE_URL=https://api.example.com -NEXT_PUBLIC_VOCALIZE_WS_BASE_URL=wss://api.example.com +VITE_VOCALIZE_API_BASE_URL=http://127.0.0.1:8000 +VITE_VOCALIZE_WS_BASE_URL= diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 5f67fea..650ba56 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -49,7 +49,7 @@ body: label: "Environment" description: "Your setup details." placeholder: | - - OS: macOS 15 / Ubuntu 24.04 / Debian 12 / Raspberry Pi OS + - OS: macOS 15 - Python version: 3.11.x - Node version: 20.x - Deployment mode: local dev / Linux-host production / other diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 14dc610..2f799f5 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -21,5 +21,7 @@ Closes #... - [ ] Code follows project style (ruff and mypy clean for Python; tsc clean for TypeScript) - [ ] Tests added or updated to cover the change - [ ] CI is green (or failures are pre-existing and documented) +- [ ] Public tree audit is green for release-facing changes +- [ ] DGPisces maintainer review is requested and required before merge - [ ] Commit messages follow the `feat/fix/docs/chore/test/refactor(): ` convention (see CONTRIBUTING.md) - [ ] No secrets, internal hostnames, real API keys, or real tunnel names in the diff diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 191c3c0..f58fa68 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ permissions: jobs: backend: - name: Backend lint and scoped tests + name: Backend lint, type, and unit tests runs-on: ubuntu-latest steps: - name: Check out repository @@ -24,6 +24,9 @@ jobs: with: python-version: '3.11' + - name: Install system audio headers + run: sudo apt-get update && sudo apt-get install -y portaudio19-dev + - name: Install backend dependencies run: | python -m venv .venv @@ -34,36 +37,74 @@ jobs: - name: Ruff run: | . .venv/bin/activate - ruff check src/ - - - name: Mypy + ruff check src tools \ + tests/test_cli.py \ + tests/test_doctor.py \ + tests/test_install_scripts.py \ + tests/test_provider_api_clients.py \ + tests/test_provider_runtime.py \ + tests/test_runtime_paths.py \ + tests/test_release_artifacts.py + + - name: Mypy productized modules run: | . .venv/bin/activate mypy \ - src/vocalize/server/frames.py \ - src/vocalize/server/runner.py \ - tests/integration/ai_merchant.py \ - tests/integration/test_ai_merchant.py \ - tests/integration/judge.py \ - tests/integration/test_judge.py \ - tests/integration/conftest.py \ - --ignore-missing-imports \ - --no-error-summary + src/vocalize/cli.py \ + src/vocalize/doctor.py \ + src/vocalize/install_state.py \ + src/vocalize/runtime_paths.py \ + src/vocalize/provider_runtime.py \ + tools/release/artifacts.py \ + tools/ci/public_tree_audit.py - name: Pytest + run: | + . .venv/bin/activate + pytest tests --ignore=tests/integration + + provider-contract: + name: Provider API contract tests + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.11' + + - name: Install backend dependencies + run: | + python -m venv .venv + . .venv/bin/activate + python -m pip install --upgrade pip + python -m pip install -e '.[dev]' + + - name: Provider contract pytest run: | . .venv/bin/activate pytest \ - tests/test_server_frames.py \ - tests/test_server_ws_integration.py \ - tests/test_runner_phase_transitions.py \ - tests/integration/test_ai_merchant.py \ - tests/integration/test_judge.py \ - -k 'merchant_text_inject or test_frames or text_frames or ai_merchant or judge' \ + tests/test_provider_api_clients.py \ + tests/test_provider_runtime.py \ -q + macos-speech-provider: + name: macOS speech provider build + runs-on: macos-14 + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Build Swift helper + run: swift build -c release --package-path macos/VocalizeSpeechProvider + + - name: Check helper binary exists + run: test -x macos/VocalizeSpeechProvider/.build/release/VocalizeSpeechProvider + frontend: - name: Frontend type and unit tests + name: Frontend lint, build, and unit tests runs-on: ubuntu-latest steps: - name: Check out repository @@ -80,17 +121,21 @@ jobs: working-directory: frontend run: npm ci - - name: TypeScript type check + - name: Lint and type check working-directory: frontend - run: npx tsc --noEmit --pretty false + run: npm run lint + + - name: Build static frontend + working-directory: frontend + run: npm run build - name: Vitest working-directory: frontend run: npm test - playwright-loopback: - name: Playwright loopback - runs-on: ubuntu-latest + packaging-installer: + name: Packaging and installer smoke + runs-on: macos-14 steps: - name: Check out repository uses: actions/checkout@v6 @@ -100,13 +145,6 @@ jobs: with: python-version: '3.11' - - name: Install backend dependencies - run: | - python -m venv .venv - . .venv/bin/activate - python -m pip install --upgrade pip - python -m pip install -e '.[dev]' - - name: Set up Node uses: actions/setup-node@v6 with: @@ -114,30 +152,48 @@ jobs: cache: npm cache-dependency-path: frontend/package-lock.json - - name: Install frontend dependencies - working-directory: frontend - run: npm ci + - name: Install backend dependencies + run: | + python -m venv .venv + . .venv/bin/activate + python -m pip install --upgrade pip + python -m pip install -e '.[dev]' - - name: Install Playwright browser - working-directory: frontend - run: npm exec -- playwright install --with-deps chromium + - name: Shell syntax + run: bash -n install/install.sh install/uninstall.sh install/verify-release.sh scripts/build-macos-release.sh - - name: Playwright integration tests - working-directory: frontend - run: > - npm run test:integration -- - ../tests/integration/laptop-loopback.spec.ts - ../tests/integration/post-call-callback.spec.ts + - name: Build unsigned macOS artifact + run: scripts/build-macos-release.sh --signing-mode skip + + - name: Verify checksum + run: | + zip_path="$(ls dist/release/VocalizeAI-*-macos-*.zip | head -n 1)" + bash install/verify-release.sh dist/release/SHA256SUMS "$zip_path" - ai-merchant: - name: AI merchant scenarios + - name: Install, setup, and uninstall smoke + run: | + set -euo pipefail + tmp_dir="$(mktemp -d)" + zip_path="$(ls dist/release/VocalizeAI-*-macos-*.zip | head -n 1)" + bash install/install.sh \ + --artifact "$zip_path" \ + --checksums dist/release/SHA256SUMS \ + --install-dir "${tmp_dir}/VocalizeAI" \ + --yes + "${tmp_dir}/VocalizeAI/vocalize" --help >/tmp/vocalize-help.txt + "${tmp_dir}/VocalizeAI/vocalize" setup \ + --non-interactive \ + --llm-api-key sk-test \ + --llm-base-url https://llm.example/v1 \ + --llm-model test-model \ + --global-command no \ + --open-browser no + bash "${tmp_dir}/VocalizeAI/uninstall.sh" --yes + test ! -e "${tmp_dir}/VocalizeAI" + + public-tree-audit: + name: Public tree audit runs-on: ubuntu-latest - # Skip on fork PRs — secrets are not available and this job would fail - # rather than produce a meaningful result. External contributors get full - # coverage from backend + frontend + playwright-loopback jobs above. - if: > - github.event.pull_request.head.repo.full_name == github.repository || - github.event_name == 'push' steps: - name: Check out repository uses: actions/checkout@v6 @@ -147,56 +203,14 @@ jobs: with: python-version: '3.11' - - name: Install backend dependencies + - name: Build public candidate file list run: | - python -m venv .venv - . .venv/bin/activate - python -m pip install --upgrade pip - python -m pip install -e '.[dev]' - - - name: Run AI merchant scenarios (deterministic judge) - # Intentionally does NOT pass --ai-provider-required and does NOT - # inject DEEPSEEK_API_KEY: the current text-bypass harness only - # drives merchant-side frames (merchant_text_inject) and cannot - # produce the user-side actions that 7 of 8 CI scenarios require - # (preflight supplements, clarification acks, user takeover, WS - # reconnect, on-demand translate). Running real DeepSeek-V4-Pro - # against that limited evidence yields 23/24 must-pass failures - # that are structural harness gaps, not regressions. The - # deterministic judge (deterministic_judge_case) instead asserts - # evidence-shape invariants on every PR. - # - # Real-LLM judge coverage is preserved by the release-audio gate - # (`pytest --release-audio --ai-provider-required`) which runs - # manually before each release with DEEPSEEK_API_KEY exported in - # the operator shell. - # - # Extending the harness to drive user-side flows is tracked as a - # follow-up (see STATE.md "B3b text-bypass harness gap"). - env: - VOCALIZE_ENABLE_TEST_FRAMES: "1" - run: | - export AI_MERCHANT_PR_COMMAND="pytest tests/integration/test_ai_merchant.py -q" - python - <<'PY' - import os - - command = os.environ["AI_MERCHANT_PR_COMMAND"] - forbidden = ("--release-audio", "release_audio") - selected = [token for token in forbidden if token in command] - if selected: - raise SystemExit( - "pull_request AI merchant command must not select " - f"release-audio cases: {selected}" - ) - PY - . .venv/bin/activate - $AI_MERCHANT_PR_COMMAND - - - name: Upload AI merchant failure evidence - if: failure() - uses: actions/upload-artifact@v7 - with: - name: ai-merchant-evidence - path: tests/integration/evidence/ - if-no-files-found: ignore - retention-days: 14 + git ls-files -z --full-name > /tmp/vocalize-tracked-files.bin + python scripts/build-public-filelist.py \ + --allow install/public-allowlist.md \ + --deny .public-sync-deny \ + --tracked-null /tmp/vocalize-tracked-files.bin \ + > /tmp/vocalize-public-files.txt + + - name: Audit public candidate + run: python -m tools.ci.public_tree_audit --root . --file-list /tmp/vocalize-public-files.txt diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..fd8050b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,145 @@ +name: Release + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + tag: + description: 'Existing release tag, for example v0.1.0' + required: true + type: string + +permissions: + contents: write + +jobs: + macos-release: + name: Build signed macOS release + runs-on: macos-14 + steps: + - name: Check out repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Set release tag + id: release + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + tag="${{ inputs.tag }}" + else + tag="${GITHUB_REF_NAME}" + fi + case "$tag" in + v*) ;; + *) echo "Release tag must start with v: $tag" >&2; exit 2 ;; + esac + echo "tag=$tag" >> "$GITHUB_OUTPUT" + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.11' + + - name: Set up Node + uses: actions/setup-node@v6 + with: + node-version: '20' + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install backend dependencies + run: | + python -m venv .venv + . .venv/bin/activate + python -m pip install --upgrade pip + python -m pip install -e '.[dev]' + + - name: Import Developer ID certificate + env: + CERTIFICATE_BASE64: ${{ secrets.APPLE_DEVELOPER_ID_CERTIFICATE_BASE64 }} + CERTIFICATE_PASSWORD: ${{ secrets.APPLE_DEVELOPER_ID_CERTIFICATE_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.MACOS_KEYCHAIN_PASSWORD }} + run: | + set -euo pipefail + : "${CERTIFICATE_BASE64:?missing APPLE_DEVELOPER_ID_CERTIFICATE_BASE64}" + : "${CERTIFICATE_PASSWORD:?missing APPLE_DEVELOPER_ID_CERTIFICATE_PASSWORD}" + keychain_password="${KEYCHAIN_PASSWORD:-$(openssl rand -hex 24)}" + certificate_path="${RUNNER_TEMP}/developer-id.p12" + keychain_path="${RUNNER_TEMP}/vocalize-signing.keychain-db" + echo "$CERTIFICATE_BASE64" | base64 --decode > "$certificate_path" + security create-keychain -p "$keychain_password" "$keychain_path" + security set-keychain-settings -lut 21600 "$keychain_path" + security unlock-keychain -p "$keychain_password" "$keychain_path" + security import "$certificate_path" \ + -P "$CERTIFICATE_PASSWORD" \ + -A \ + -t cert \ + -f pkcs12 \ + -k "$keychain_path" + security list-keychains -d user -s "$keychain_path" + security default-keychain -s "$keychain_path" + security set-key-partition-list \ + -S apple-tool:,apple:,codesign: \ + -s \ + -k "$keychain_password" \ + "$keychain_path" + + - name: Build, sign, notarize, and package + env: + APPLE_DEVELOPER_ID_APPLICATION: ${{ secrets.APPLE_DEVELOPER_ID_APPLICATION }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + run: scripts/build-macos-release.sh --signing-mode developer-id + + - name: Verify checksums and installed artifact + run: | + set -euo pipefail + zip_path="$(ls dist/release/VocalizeAI-*-macos-*.zip | head -n 1)" + bash install/verify-release.sh dist/release/SHA256SUMS "$zip_path" + tmp_dir="$(mktemp -d)" + bash install/install.sh \ + --artifact "$zip_path" \ + --checksums dist/release/SHA256SUMS \ + --install-dir "${tmp_dir}/VocalizeAI" \ + --yes + "${tmp_dir}/VocalizeAI/vocalize" --help >/tmp/vocalize-help.txt + "${tmp_dir}/VocalizeAI/vocalize" setup \ + --non-interactive \ + --llm-api-key sk-test \ + --llm-base-url https://llm.example/v1 \ + --llm-model test-model \ + --global-command no \ + --open-browser no + bash "${tmp_dir}/VocalizeAI/uninstall.sh" --yes + test ! -e "${tmp_dir}/VocalizeAI" + + - name: Upload workflow artifacts + uses: actions/upload-artifact@v7 + with: + name: vocalizeai-macos-release + path: | + dist/release/VocalizeAI-*-macos-*.zip + dist/release/SHA256SUMS + dist/release/install.sh + if-no-files-found: error + + - name: Publish GitHub Release assets + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.release.outputs.tag }} + run: | + set -euo pipefail + if gh release view "$TAG" >/dev/null 2>&1; then + gh release upload "$TAG" dist/release/VocalizeAI-*-macos-*.zip dist/release/SHA256SUMS dist/release/install.sh --clobber + else + gh release create "$TAG" \ + dist/release/VocalizeAI-*-macos-*.zip \ + dist/release/SHA256SUMS \ + dist/release/install.sh \ + --verify-tag \ + --generate-notes + fi diff --git a/.gitignore b/.gitignore index 379eb1a..5bf368d 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,7 @@ node_modules/ .next/ test-results/ tests/integration/evidence/ -docs/release/24h-stability-evidence-runs/ +docs/release/*evidence-runs/ *.tsbuildinfo .ruff_cache/ .pytest_cache/ diff --git a/.public-sync-deny b/.public-sync-deny new file mode 100644 index 0000000..8a35e99 --- /dev/null +++ b/.public-sync-deny @@ -0,0 +1,14 @@ +# Private/internal planning files are already denied by scripts/build-public-filelist.py. +# This project deny file removes legacy v1 deployment assets from the v0.1 public export. + +infra/gpu-services/ +infra/orchestrator/ +docs/deploy/linux.md +docs/release/24h-stability-evidence.md +docs/release/24h-stability-evidence-runs/ +demos/phase1_stt_mic.py +demos/phase2_stt_llm.py +demos/phase3_full_voice_local.py +scripts/stability-24h-driver.py +src/vocalize/stt/sensevoice.py +src/vocalize/tts/cosyvoice.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 802634d..a236657 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,28 +11,24 @@ _No unreleased changes yet._ --- -## [1.0.0] — 2026-05-18 +## [0.1.0] — 2026-05-31 -**First public release.** Apache 2.0 open-source milestone. - -Internal source: `d9bd923` · Public repo first commit: `591aa9e` - -Release notes: https://github.com/DGPisces/VocalizeAI/releases/tag/v1.0.0 +Initial Mac-first public release. ### Added -- **Universal phone-task engine** — 5-layer dialogue pipeline (preflight, question, - translate, summarize, merchant) handles arbitrary phone-call scenarios without - per-task configuration. -- **Bilingual zh/en UI** — React frontend with full Chinese and English locale - support from day 1. -- **Raspberry Pi orchestrator** — production-grade deploy target; install script - + systemd unit + cloudflared tunnel for zero-port-forward remote access. -- **Audio loopback testing** — Playwright + pytest harness drives - laptop-loopback calls and post-call callback flows; 8 text-bypass AI merchant - scenarios with a deterministic judge. -- **Apache 2.0 license** — OSS launch with SECURITY.md, CONTRIBUTING.md, - issue templates, PR template, and CODEOWNERS. - -[Unreleased]: https://github.com/DGPisces/VocalizeAI/compare/v1.0.0...HEAD -[1.0.0]: https://github.com/DGPisces/VocalizeAI/releases/tag/v1.0.0 +- Local macOS release artifact with packaged backend, bundled Vite web console, + native macOS speech helper, installer, updater, and uninstaller. +- LLM-only setup through `./vocalize setup`. +- `./vocalize doctor`, `start`, `stop`, `status`, `logs`, `update`, and + `uninstall` commands. +- Vocalize Provider API for realtime STT/TTS health, capabilities, streaming + events, cancellation, and structured errors. +- Chinese and English web console with task creation, readiness, live + transcript, clarification, user supplement, manual takeover, diagnostics, + settings, and post-call review. +- Productized CI gates for backend, Provider API, macOS helper, frontend, + packaging/installer smoke, and public-tree audit. + +[Unreleased]: https://github.com/DGPisces/VocalizeAI/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/DGPisces/VocalizeAI/releases/tag/v0.1.0 diff --git a/CODEOWNERS b/CODEOWNERS index 7200ff2..2a75593 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,3 +1,3 @@ -# Single maintainer at v1.0 OSS launch. +# Single maintainer for the v0.1 Mac-first public reset. # All PRs route to @DGPisces for review. * @DGPisces diff --git a/README.md b/README.md index a74d0da..75536da 100644 --- a/README.md +++ b/README.md @@ -2,207 +2,146 @@ > Chinese version: [README.zh-CN.md](README.zh-CN.md) -VocalizeAI is a bilingual (zh/en) AI phone agent. v1 transforms it from a -restaurant-only booking bot into a universal phone-task engine: describe any -phone task in natural language, and the AI plans the schema, collects the -required info from you, and handles the call with the merchant — relaying -across languages when needed. +VocalizeAI is a Mac-first AI phone-task assistant. You describe what the call +should accomplish, the app collects any missing details, then drives the task +through a local web console with live transcript, clarification, takeover, and +post-call review. -## Current Status +The public `v0.1.0` path is intentionally simple: install the macOS artifact, +configure your OpenAI-compatible LLM endpoint, and use the built-in macOS +speech provider. STT and TTS are exposed through the Vocalize Provider API, so +advanced users can replace speech services later without changing the task +engine. -**v1 ships** the universal phone-task engine, Web console, and a Linux-host -orchestrator deployment (Raspberry Pi was the original reference target; -any modern Linux host with systemd works). The backend 5-layer prompt architecture -(task_planner / preflight / merchant_agent / clarification_collector / relay) -handles any phone task — restaurant bookings, service appointments, balance -inquiries, status checks, and more. An OSS mirror is available at -[github.com/DGPisces/VocalizeAI](https://github.com/DGPisces/VocalizeAI) -under Apache 2.0. +## Install on macOS -## Quick Start - -**Prerequisites:** Python 3.11+, Node 20+, git, curl. Optional: `uv` (auto-installed by the script). +Download the release zip and `SHA256SUMS` from the GitHub Release page, then run +the installer from the folder where you want `VocalizeAI/` to be created. ```bash -# 1. Install all dependencies in one step -bash install/dev-install.sh - -# 2. Edit .env and set at minimum: OPENAI_API_KEY -# (dev-install.sh already copied .env.example -> .env if it was absent) -$EDITOR .env - -# 3. Start the backend -source .venv/bin/activate -uvicorn vocalize.main:app --host 127.0.0.1 --port 8000 --reload - -# 4. Start the frontend (second terminal) -cd frontend && npm run dev - -# 5. Verify the installation -bash scripts/smoke.sh -# Exit 0 = working dev environment (≤15 min from clone to passing smoke) +bash install/install.sh \ + --artifact VocalizeAI-0.1.0-macos-arm64.zip \ + --checksums SHA256SUMS ``` -**Reproducible install:** after activating the venv, run `uv pip sync uv.lock` for -deterministic Python dependency installation pinned to the committed lock file. - -For the full Mac/Linux runbook with env-var descriptions and troubleshooting, see -[docs/deploy/local.md](docs/deploy/local.md). - -## v1 — Universal Phone Agent (CLI) +Then configure and start: ```bash -# (in project venv) -export OPENAI_BASE_URL="https://api.deepseek.com" -export OPENAI_API_KEY="..." -python -m demos.phase5_universal_agent_cli -``` - -The demo runs the full universal phone-agent engine in headless mode: - -1. You describe a task in natural language ("book a 7 pm table for 4 at - Joy Sushi"). -2. Layer 1 (`task_planner`) emits a `TaskSchema` — the slots to collect, - the readiness criteria, and the relay strategy. -3. Layer 2 (`preflight`) drives a user-side conversation until all - high-criticality slots are filled. -4. Layer 3 (`merchant_agent`) places and runs the call. -5. Layers 4–5 (`clarification_collector`, `relay`) handle mid-call - clarifications and cross-lingual translation. - -## Repository layout - -``` -VocalizeAI/ -├── src/vocalize/ # main backend package (service-boundary modules) -│ ├── transports/ # audio I/O — local mic, speakerphone bridge -│ ├── stt/ # speech-to-text — SenseVoice streaming -│ ├── llm/ # LLM — OpenAI-compatible streaming + tool-calling -│ ├── tts/ # text-to-speech — CosyVoice streaming -│ ├── dialogue/ # orchestrator, state machine, prompts, tools -│ ├── reflection/ # post-call review -│ ├── server/ # FastAPI app — REST sessions + WS frames -│ ├── pipeline.py # asyncio main pipeline -│ ├── config.py # env / .env loading -│ └── logger.py # system + dialogue logging -├── frontend/ # Next.js 14 web console -│ ├── app/ # App Router routes -│ ├── components/ # BrowserAudioBridge, LiveConsole, etc. -│ ├── lib/ # WS client, audio utils, REST client -│ ├── messages/ # next-intl zh/en bundles -│ └── tests/ # vitest unit tests -├── demos/ # runnable demos -├── infra/ # deployment scripts (GPU node, Linux orchestrator) -├── tests/ # pytest suite -│ └── integration/ # Playwright laptop-loopback + AI-merchant harness -├── install/ # one-shot install scripts -│ ├── dev-install.sh # Mac/Linux local dev setup -│ └── install.sh # Linux production deploy (Raspberry Pi is one example target) -├── docs/ # architecture, deploy guides, release evidence -├── scripts/ # smoke test and utility scripts -│ └── smoke.sh # post-install end-to-end verification -├── pyproject.toml # single source of truth for backend dependencies -├── uv.lock # pinned Python dependency lock -└── .env.example # env-var template (17 keys) +cd VocalizeAI +./vocalize setup +./vocalize doctor +./vocalize start ``` -## Self-host quickstart +`setup` asks for: -### Required env vars for non-localhost deployment +- LLM base URL +- LLM API key +- LLM model +- whether to enable or disable LLM thinking mode +- local web port +- whether to add an optional global `vocalize` command +- whether `start` should open the browser automatically -| Variable | Purpose | -|----------|---------| -| `VOCALIZE_WS_BASE_URL` | WebSocket base URL returned to clients (e.g., `wss://api.example.com`); required in non-localhost mode to prevent Host-header spoofing | -| `VOCALIZE_CORS_ORIGINS` | Comma-separated allowed CORS origins; **required** in non-localhost mode (no default) | +You do not choose a speech model in the default macOS install. VocalizeAI starts +the bundled macOS speech helper and connects to it through the Provider API. +When browser auto-open is enabled, `start` waits for the local server health +endpoint before opening the page. -See `.env.example` for the full env-var inventory including LLM, GPU service, -and frontend build-time variables. - -For the full Linux-host production deployment runbook (Raspberry Pi is one -example target), see [docs/deploy/linux.md](docs/deploy/linux.md). - -### GPU node requirements - -SenseVoice (STT) and CosyVoice (TTS) run as separate GPU services and connect -to the orchestrator host over Tailscale. GPU services are optional for local dev -(the LLM path works without them). See [docs/deploy/linux.md](docs/deploy/linux.md) -for the GPU node setup. - -## Run the dev server +## Update or Uninstall ```bash -source .venv/bin/activate +# update from a newer release artifact while preserving config/logs/cache +./vocalize update --artifact ../VocalizeAI-0.1.1-macos-arm64.zip --checksums ../SHA256SUMS -# optional: configure GPU services so /health reports gpu_reachable=true -export GPU_HOST=100.x.y.z # Tailscale IP of GPU node -export SENSEVOICE_WS_PORT=8000 # STT service -export COSYVOICE_WS_PORT=8001 # TTS service - -uvicorn vocalize.main:app --host 127.0.0.1 --port 8000 --reload +# remove this local install and the optional recorded global symlink +./vocalize uninstall +# or +bash uninstall.sh ``` -In another terminal: +The installer is local by default. It does not install global Python packages, +Node packages, launch agents, system services, or shell modifications. The +optional global command is a removable symlink recorded in the install config. -```bash -curl -s http://127.0.0.1:8000/health -# → {"ok": true, "gpu_reachable": true} +## What the App Provides -SESSION=$(curl -s -X POST http://127.0.0.1:8000/api/sessions | python3 -c \ - 'import sys,json; print(json.load(sys.stdin)["session_id"])') +- Mac-first local install under `VocalizeAI/` +- LLM-only setup for ordinary users +- Native macOS STT/TTS through a bundled helper +- Provider API boundary for custom STT/TTS services +- React + Vite web console served by the packaged backend +- Task creation, readiness, live transcript, clarification, manual takeover, + hangup/end, diagnostics, settings, and post-call review +- Chinese and English UI -curl -s -X POST "http://127.0.0.1:8000/api/sessions/$SESSION/task" \ - -H 'Content-Type: application/json' \ - -d '{"task":"帮我订海底捞"}' +## Provider API -# brew install websocat (macOS) or apt install websocat (Linux) -websocat ws://127.0.0.1:8000/ws/sessions/$SESSION -# → server emits state_update / transcript_update / readiness_change frames +The speech boundary is documented in [docs/provider-api.md](docs/provider-api.md). +The default helper implements the same API that custom providers use: -# Or run the full smoke test: -bash scripts/smoke.sh -``` +- health and capability discovery +- realtime STT partial/final transcript events +- streaming TTS events +- cancellation and structured errors -For the system architecture — 5-layer dialogue pipeline, TaskPhase state machine, -WS frame catalogue, and REST surface — see [docs/architecture.md](docs/architecture.md). +For `v0.1.0`, macOS is the supported public platform. Other platforms can be +added later by implementing the same Provider API. -## Run the web console +## Development -Terminal 1: +Source development still uses a normal local toolchain. ```bash +bash install/dev-install.sh +$EDITOR .env source .venv/bin/activate uvicorn vocalize.main:app --host 127.0.0.1 --port 8000 --reload ``` -Terminal 2: +In another terminal: ```bash cd frontend -export NEXT_PUBLIC_VOCALIZE_API_BASE_URL=http://127.0.0.1:8000 -npm run dev -- --hostname 127.0.0.1 --port 3000 +npm ci +npm run dev -- --host 127.0.0.1 --port 3000 ``` -Open `http://127.0.0.1:3000`. +Useful checks: -The frontend calls FastAPI directly through `NEXT_PUBLIC_VOCALIZE_API_BASE_URL`; -it does not proxy `/api` through Next.js. If the backend is configured with -`VOCALIZE_WS_BASE_URL` on a different host, set the matching browser allowlist -with `NEXT_PUBLIC_VOCALIZE_WS_BASE_URL`. +```bash +.venv/bin/python -m pytest +cd frontend && npm run lint && npm run build && npm test +bash -n install/install.sh install/uninstall.sh scripts/build-macos-release.sh +``` -## Contributing +## Repository Layout -See [CONTRIBUTING.md](CONTRIBUTING.md) for how to file issues, run tests, -follow code style, and submit contributions. Issue + PR templates live under `.github/`. +```text +VocalizeAI/ +├── src/vocalize/ # backend package and task engine +│ ├── providers/ # STT/TTS Provider API clients +│ ├── llm/ # OpenAI-compatible streaming client +│ ├── dialogue/ # planner, preflight, merchant agent, relay +│ ├── server/ # FastAPI app and WebSocket frames +│ └── config.py # env and install config loading +├── macos/ # native macOS speech provider helper +├── frontend/ # React + Vite web console +├── install/ # artifact installer and uninstaller +├── packaging/ # PyInstaller packaging config +├── tools/ # release and CI helpers +├── tests/ # pytest suite +├── docs/ # provider, architecture, release docs +├── pyproject.toml # backend package metadata +├── uv.lock # pinned Python dependency lock +└── .env.example # development config template +``` -## Security +## Release Gates -VocalizeAI is self-deploy: every operator runs their own backend on -their own infrastructure, and there is no centrally hosted instance to -defend. Report any security-relevant finding via GitHub Issues — same -as any other bug — so every operator can pick up the fix. Self-deploy -operators are responsible for restricting reachability at the network -or proxy layer (Cloudflare Access, VPN, reverse-proxy auth, etc.). -Per-user authentication is v1.x scope (requirement `AUTH-01`). +Before a public release, CI must pass backend, Provider API, macOS helper, +frontend, packaging/installer, and public-tree audit checks. The final artifact +also requires signed/notarized macOS packaging and human clean-install testing. ## License diff --git a/README.zh-CN.md b/README.zh-CN.md index 2382df1..0304fbd 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -2,195 +2,138 @@ > English version: [README.md](README.md) -VocalizeAI 是双语(中/英)AI 电话代理。v1 把它从只能订餐厅的 bot 改造成 -通用电话任务引擎:用自然语言描述任何电话任务,AI 会自动规划槽位结构、 -向你收集必需信息、然后跟商家通话 —— 必要时跨语言传译。 +VocalizeAI 是 Mac-first 的 AI 电话任务助手。你描述要完成的电话任务,应用会收集 +缺失信息,然后通过本地网页控制台完成任务流程,包括实时转写、澄清、人工接管、 +挂断/结束、诊断和通话后复盘。 -## 当前状态 +公开版 `v0.1.0` 的默认路径只有三步:安装 macOS artifact,配置 +OpenAI-compatible LLM 接口,使用内置 macOS 原生语音 provider。STT 和 TTS 都通过 +Vocalize Provider API 接入;高级用户以后可以替换语音服务,但普通用户不需要选择 +任何语音模型。 -**v1 已发布**:通用电话任务引擎、Web 控制台、树莓派编排器部署全部就绪。 -后端 5 层 prompt 架构(task_planner / preflight / merchant_agent / -clarification_collector / relay)可处理任何电话任务 —— 餐厅订位、预约服务、 -查询余额、状态查询等。OSS 镜像发布于 -[github.com/DGPisces/VocalizeAI](https://github.com/DGPisces/VocalizeAI), -协议 Apache 2.0。 +## macOS 安装 -## 快速开始 - -**前提条件:** Python 3.11+、Node 20+、git、curl。可选:`uv`(安装脚本会自动安装)。 +从 GitHub Release 下载 release zip 和 `SHA256SUMS`,然后在你希望创建 +`VocalizeAI/` 的文件夹里运行: ```bash -# 1. 一键安装所有依赖 -bash install/dev-install.sh - -# 2. 编辑 .env,至少设置 OPENAI_API_KEY -# (如果 .env 不存在,安装脚本已自动从 .env.example 复制) -$EDITOR .env - -# 3. 启动后端 -source .venv/bin/activate -uvicorn vocalize.main:app --host 127.0.0.1 --port 8000 --reload - -# 4. 启动前端(另开一个终端) -cd frontend && npm run dev - -# 5. 验证安装 -bash scripts/smoke.sh -# 退出码 0 = 开发环境正常(从克隆到 smoke 通过 ≤15 分钟) +bash install/install.sh \ + --artifact VocalizeAI-0.1.0-macos-arm64.zip \ + --checksums SHA256SUMS ``` -**确定性安装:** 激活 venv 后运行 `uv pip sync uv.lock`,可使用提交的锁文件做确定性 Python 依赖安装。 - -完整的 Mac/Linux 安装手册(含环境变量说明和故障排查),见 -[docs/deploy/local.md](docs/deploy/local.md)。 - -## v1 — 通用电话代理(CLI) +进入安装目录并启动: ```bash -# (in project venv) -export OPENAI_BASE_URL="https://api.deepseek.com" -export OPENAI_API_KEY="..." -python -m demos.phase5_universal_agent_cli -``` - -该 demo 在无界面模式下运行完整通用电话代理引擎: - -1. 你用自然语言描述一个任务("帮我订 Joy Sushi 今晚 7 点 4 个人的位子")。 -2. Layer 1(`task_planner`)输出一个 `TaskSchema` —— 要收集的槽位、 - readiness 判定标准、relay 翻译策略。 -3. Layer 2(`preflight`)跟用户对话,直到所有高关键性槽位都填满。 -4. Layer 3(`merchant_agent`)拨打并主导通话。 -5. Layer 4–5(`clarification_collector`、`relay`)处理通话中追加澄清和 - 跨语言翻译。 - -## 仓库结构 - -``` -VocalizeAI/ -├── src/vocalize/ # main backend package (service-boundary modules) -│ ├── transports/ # audio I/O — local mic, speakerphone bridge -│ ├── stt/ # speech-to-text — SenseVoice streaming -│ ├── llm/ # LLM — OpenAI-compatible streaming + tool-calling -│ ├── tts/ # text-to-speech — CosyVoice streaming -│ ├── dialogue/ # orchestrator, state machine, prompts, tools -│ ├── reflection/ # post-call review -│ ├── server/ # FastAPI app — REST sessions + WS frames -│ ├── pipeline.py # asyncio main pipeline -│ ├── config.py # env / .env loading -│ └── logger.py # system + dialogue logging -├── frontend/ # Next.js 14 web console -│ ├── app/ # App Router routes -│ ├── components/ # BrowserAudioBridge, LiveConsole, etc. -│ ├── lib/ # WS client, audio utils, REST client -│ ├── messages/ # next-intl zh/en bundles -│ └── tests/ # vitest unit tests -├── demos/ # runnable demos -├── infra/ # 部署脚本(GPU 节点、Linux 编排器) -├── tests/ # pytest suite -│ └── integration/ # Playwright laptop-loopback + AI-merchant harness -├── install/ # 一键安装脚本 -│ ├── dev-install.sh # Mac/Linux 本地开发环境安装 -│ └── install.sh # Linux 生产部署安装(树莓派是一种受支持的目标) -├── docs/ # 架构文档、部署指南、发布记录 -├── scripts/ # smoke 测试和工具脚本 -│ └── smoke.sh # 安装后端到端验证脚本 -├── pyproject.toml # 后端依赖单一来源 -├── uv.lock # Python 依赖固定锁文件 -└── .env.example # 环境变量模板(17 个 key) +cd VocalizeAI +./vocalize setup +./vocalize doctor +./vocalize start ``` -## 自托管快速上手 +`setup` 会询问: -### 非 localhost 部署必需环境变量 +- LLM base URL +- LLM API key +- LLM model +- 是否添加可选的全局 `vocalize` 命令 +- `start` 是否自动打开浏览器 -| 变量 | 用途 | -|------|------| -| `VOCALIZE_WS_BASE_URL` | 返回给客户端的 WebSocket 基地址(如 `wss://api.example.com`);非 localhost 模式必填,防止 Host 头欺骗 | -| `VOCALIZE_CORS_ORIGINS` | 允许的 CORS 来源(逗号分隔);非 localhost 模式**必填**(无默认值) | +默认 macOS 安装不会让用户选择语音模型。VocalizeAI 会启动打包好的 macOS speech +helper,并通过 Provider API 与它通信。 -完整环境变量清单(含 LLM、GPU 服务、前端构建变量)见 `.env.example`。 - -完整的树莓派生产部署手册,见 [docs/deploy/linux.md](docs/deploy/linux.md)。 - -### GPU 节点要求 - -SenseVoice(STT)和 CosyVoice(TTS)作为独立 GPU 服务运行,通过 Tailscale -与树莓派编排器连接。本地开发不需要 GPU(只需 LLM 路径即可运行)。GPU 节点配置见 -[docs/deploy/linux.md](docs/deploy/linux.md)。 - -## 跑开发服务器 +## 更新或卸载 ```bash -source .venv/bin/activate +# 使用新 release artifact 更新,保留 config/logs/cache +./vocalize update --artifact ../VocalizeAI-0.1.1-macos-arm64.zip --checksums ../SHA256SUMS -# optional: configure GPU services so /health reports gpu_reachable=true -export GPU_HOST=100.x.y.z # GPU 节点的 Tailscale IP -export SENSEVOICE_WS_PORT=8000 # STT 服务 -export COSYVOICE_WS_PORT=8001 # TTS 服务 - -uvicorn vocalize.main:app --host 127.0.0.1 --port 8000 --reload +# 删除本地安装和可选的全局 symlink +./vocalize uninstall +# 或 +bash uninstall.sh ``` -另开一个终端: +默认安装只写入当前文件夹下的 `VocalizeAI/`。它不会默认安装全局 Python 包、Node +包、launch agent、system service,也不会修改 shell 配置。可选全局命令只是一个可 +移除 symlink,并记录在安装配置里。 -```bash -curl -s http://127.0.0.1:8000/health -# → {"ok": true, "gpu_reachable": true} +## 功能范围 -SESSION=$(curl -s -X POST http://127.0.0.1:8000/api/sessions | python3 -c \ - 'import sys,json; print(json.load(sys.stdin)["session_id"])') +- 本地 `VocalizeAI/` 安装目录 +- 普通用户只配置 LLM +- 内置 macOS 原生 STT/TTS helper +- 可扩展 Provider API,用于自定义 STT/TTS 服务 +- 打包后端直接托管 React + Vite 控制台 +- 创建任务、readiness、实时转写、澄清、人工接管、挂断/结束、诊断、设置和复盘 +- 中文和英文界面 -curl -s -X POST "http://127.0.0.1:8000/api/sessions/$SESSION/task" \ - -H 'Content-Type: application/json' \ - -d '{"task":"帮我订海底捞"}' +## Provider API -# brew install websocat(macOS)或 apt install websocat(Linux) -websocat ws://127.0.0.1:8000/ws/sessions/$SESSION -# → server emits state_update / transcript_update / readiness_change frames +语音边界见 [docs/provider-api.md](docs/provider-api.md)。默认 macOS helper 和自定义 +provider 使用同一套 API: -# 或者运行完整 smoke 测试: -bash scripts/smoke.sh -``` +- health 和 capability discovery +- realtime STT partial/final transcript event +- streaming TTS event +- cancellation 和结构化错误 -系统架构说明(5 层对话流水线、TaskPhase 状态机、WS 帧目录、REST 接口),见 -[docs/architecture.md](docs/architecture.md)。 +`v0.1.0` 的公开支持平台是 macOS。其他平台可以通过实现同一个 Provider API 扩展。 -## 跑 Web 控制台 +## 开发 -终端 1: +源码开发仍然使用本地开发工具链。 ```bash +bash install/dev-install.sh +$EDITOR .env source .venv/bin/activate uvicorn vocalize.main:app --host 127.0.0.1 --port 8000 --reload ``` -终端 2: +另开一个终端: ```bash cd frontend -export NEXT_PUBLIC_VOCALIZE_API_BASE_URL=http://127.0.0.1:8000 -npm run dev -- --hostname 127.0.0.1 --port 3000 +npm ci +npm run dev -- --host 127.0.0.1 --port 3000 ``` -打开 `http://127.0.0.1:3000`。 +常用检查: -前端通过 `NEXT_PUBLIC_VOCALIZE_API_BASE_URL` 直接调 FastAPI,不走 Next.js 代理。 -若后端将 `VOCALIZE_WS_BASE_URL` 配置在其他主机,也要相应设置 -`NEXT_PUBLIC_VOCALIZE_WS_BASE_URL`。 +```bash +.venv/bin/python -m pytest +cd frontend && npm run lint && npm run build && npm test +bash -n install/install.sh install/uninstall.sh scripts/build-macos-release.sh +``` -## 贡献 +## 仓库结构 -如何提 issue、运行测试、遵守代码风格、提交贡献,见 [CONTRIBUTING.md](CONTRIBUTING.md)。 -Issue 模板和 PR 模板位于 `.github/` 目录。 +```text +VocalizeAI/ +├── src/vocalize/ # 后端包和任务引擎 +│ ├── providers/ # STT/TTS Provider API clients +│ ├── llm/ # OpenAI-compatible streaming client +│ ├── dialogue/ # planner, preflight, merchant agent, relay +│ ├── server/ # FastAPI app 和 WebSocket frames +│ └── config.py # env 和安装配置加载 +├── macos/ # macOS 原生语音 provider helper +├── frontend/ # React + Vite 网页控制台 +├── install/ # artifact installer 和 uninstaller +├── packaging/ # PyInstaller 打包配置 +├── tools/ # release 和 CI helper +├── tests/ # pytest suite +├── docs/ # provider、architecture、release docs +├── pyproject.toml # 后端包元数据 +├── uv.lock # Python 依赖锁文件 +└── .env.example # 开发配置模板 +``` -## 安全 +## 发布门槛 -VocalizeAI 是自部署项目 —— 每个运维者在自己的基础设施上跑自己的后端, -没有"统一托管实例"可被攻击。任何安全相关发现请通过 GitHub Issues 上报, -与普通 bug 同一通道,这样每个运维者都能拿到修复。自部署运维者负责在网络/ -代理层(Cloudflare Access、VPN、反向代理认证等)限制可达性。每用户认证属于 -v1.x 范畴(需求 `AUTH-01`)。 +公开发布前,CI 必须通过后端、Provider API、macOS helper、前端、打包/安装和 +public-tree audit。最终 artifact 还必须完成 macOS 签名/公证和人工 clean-install 测试。 ## 许可证 -Apache 2.0 —— 见 [LICENSE](LICENSE)。 +Apache 2.0 — 见 [LICENSE](LICENSE)。 diff --git a/docs/architecture.md b/docs/architecture.md index 01f130c..b16431b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -22,9 +22,9 @@ in-call dialogue, speaking to the merchant (in the merchant's language if needed and transcribing both sides. If clarification is needed mid-call, Layer 4 pauses the call and asks the user; Layer 5 (relay) handles all cross-lingual translation. -This is the v1.x audio-bridge model. The system does not use a telephony -provider (no Twilio) — the iPhone's speakerphone is the physical bridge between -the AI audio pipeline and the PSTN call. +The public `v0.1.0` product is packaged for macOS. It does not use a telephony +provider; the user's phone remains the physical bridge to the call, while the +Mac runs the web console, backend, LLM client, and native speech helper. ### End-to-End Request Flow (Quick Reference) @@ -51,7 +51,7 @@ See: `src/vocalize/pipeline.py`, `src/vocalize/transports/` ```mermaid flowchart LR - UserBrowser["User Browser\n(Next.js console)"] + UserBrowser["User Browser\n(Vite console)"] AudioBridge["Frontend Audio Bridge\n(BrowserAudioBridge)"] iPhoneSpeaker["iPhone Speakerphone\n(merchant audio in/out)"] FastAPI["FastAPI Orchestrator\n(/api/sessions, /ws/sessions)"] @@ -62,8 +62,8 @@ flowchart LR L4["Layer 4\nclarification"] L5["Layer 5\nrelay"] LLM["LLM Service\n(OpenAI-compatible)"] - STT["STT Service\n(SenseVoice, GPU)"] - TTS["TTS Service\n(CosyVoice, GPU)"] + STT["STT Provider\n(macOS native by default)"] + TTS["TTS Provider\n(macOS native by default)"] Merchant["Merchant Phone\n(PSTN)"] UserBrowser -- "text + voice (WS binary)" --> AudioBridge @@ -75,8 +75,8 @@ flowchart LR L3 & L4 -- "audio pipeline" --> TTS AudioBridge -- "speakerphone bridge" --> iPhoneSpeaker iPhoneSpeaker <-- "PSTN call" --> Merchant - STT -- "WS (Tailscale)" --> Orchestrator - TTS -- "WS (Tailscale)" --> Orchestrator + STT -- "Provider API" --> Orchestrator + TTS -- "Provider API" --> Orchestrator ``` --- @@ -446,16 +446,15 @@ No auth required. **Response:** ```json -{ "ok": true, "gpu_reachable": true } +{ "ok": true, "speech_provider_reachable": true } ``` - `ok` is always `true` when the server is reachable. -- `gpu_reachable` is the result of TCP probes against `GPU_HOST:SENSEVOICE_WS_PORT` - and `GPU_HOST:COSYVOICE_WS_PORT` (1.5 s timeout each). Returns `false` if - `GPU_HOST` is unset or either probe fails. +- `speech_provider_reachable` reports whether the configured STT/TTS Provider API + endpoint responds to a lightweight probe. -**Note:** `GET /health` is the single health surface and already covers GPU -reachability via the TCP probe. There is no deeper health variant endpoint. +**Note:** `GET /health` is the single health surface and already covers Provider +API reachability. There is no deeper health variant endpoint. See: `src/vocalize/server/health.py` @@ -481,6 +480,8 @@ speakerphone). OpenAI-compatible HTTP API. Default provider is DeepSeek (`OPENAI_BASE_URL=https://api.deepseek.com/v1`, `OPENAI_MODEL=deepseek-chat`). Any OpenAI-compatible endpoint works (OpenAI, Qwen, local Ollama, etc.) by setting `OPENAI_BASE_URL` and `OPENAI_MODEL`. +`OPENAI_THINKING_MODE=disabled` requests non-thinking output; `enabled` leaves +thinking behavior to the selected model/provider. The `openai` Python SDK is used with streaming enabled; tool-calling is used for slot assessment in Layer 1 and readiness assessment in preflight. @@ -489,30 +490,30 @@ See: `src/vocalize/llm/` --- -### STT — SenseVoice +### STT — Provider API -SenseVoice runs as a persistent WebSocket server on the GPU host (default port -8000). The orchestrator opens a streaming STT connection per session and pipes -PCM audio frames to it, receiving transcript chunks in real-time. +The orchestrator opens a streaming STT Provider API connection per session and +pipes PCM audio frames to it, receiving transcript chunks in real time. The +Mac-first public setup uses the local macOS native helper by default. -Connection target: `ws://{GPU_HOST}:{SENSEVOICE_WS_PORT}` +Connection target: `{VOCALIZE_STT_PROVIDER_URL}/v1/stt/stream` -See: `src/vocalize/stt/` +See: `src/vocalize/providers/`, `docs/provider-api.md` --- -### TTS — CosyVoice +### TTS — Provider API -CosyVoice runs as a persistent WebSocket server on the GPU host (default port -8001). The orchestrator sends text to it and streams back PCM audio for playback -via the browser audio bridge. +The orchestrator sends text to the TTS Provider API and streams back PCM audio +for playback via the browser audio bridge. The Mac-first public setup uses the +local macOS native helper by default. -Connection target: `ws://{GPU_HOST}:{COSYVOICE_WS_PORT}` +Connection target: `{VOCALIZE_TTS_PROVIDER_URL}/v1/tts/stream` -Voice cloning: the `preferred_voice_id` field in `CreateSessionRequest` selects -a pre-registered voice profile; defaults to the system voice if unset. +Voice selection is provider-defined. The public framework only requires the +Provider API contract, not a specific model or platform. -See: `src/vocalize/tts/` +See: `src/vocalize/providers/`, `docs/provider-api.md` --- @@ -555,9 +556,8 @@ See: `src/vocalize/server/ws.py`, `frontend/lib/audio*`, `frontend/components/Br | Post-call review | `src/vocalize/reflection/` | | Env/config loading | `src/vocalize/config.py` | | Asyncio main pipeline | `src/vocalize/pipeline.py` | -| Frontend (Next.js 14) | `frontend/` | -| Pi deployment assets | `infra/orchestrator/` | -| GPU services setup | `infra/gpu-services/` | +| Frontend (React + Vite) | `frontend/` | +| macOS speech helper | `macos/VocalizeSpeechProvider/` | | Backend tests (pytest) | `tests/` | | Integration tests (Playwright) | `tests/integration/` | @@ -570,13 +570,11 @@ API consumers and security researchers. VocalizeAI is a self-deploy project (no centrally hosted instance); report security-relevant findings via GitHub Issues — same channel as any other bug. -### Authentication (D-08, retired) +### Local-Only Default -The original D-08 shared-invite-token gate has been removed; v1 ships no -backend-level auth on `POST /api/sessions` or the WebSocket. Self-deploy -operators are expected to restrict reachability at the network or proxy -layer (Cloudflare Access, VPN, reverse-proxy auth, etc.). Per-user -authentication is v1.x scope (requirement `AUTH-01`). +The public product binds to `127.0.0.1` by default and is designed for a local +macOS install. Operators who expose it beyond localhost must provide their own +network boundary and configure the canonical WebSocket base URL. ### Task Length Bound (D-09) @@ -587,8 +585,8 @@ the prompt-injection surface for Layer 1's LLM call. CORS is configured per `VOCALIZE_CORS_ORIGINS`. In non-localhost mode, it defaults to the configured production origin. `allow_methods` is restricted to -`["GET", "POST", "DELETE"]`. The frontend calls FastAPI directly — no Next.js -proxy layer sits between the browser and the backend. +`["GET", "POST", "DELETE"]`. The frontend calls FastAPI directly. In production, +FastAPI can also serve the built Vite bundle from `frontend/dist`. See: `src/vocalize/server/__init__.py` @@ -610,6 +608,6 @@ See: `src/vocalize/server/ws.py`, `src/vocalize/server/sessions.py` ## Further Reading -- **[docs/deploy/local.md](docs/deploy/local.md)** — Mac/Linux dev environment setup and env-var reference -- **[docs/deploy/linux.md](docs/deploy/linux.md)** — End-to-end Pi production deployment runbook +- **[docs/deploy/local.md](docs/deploy/local.md)** — local development setup and env-var reference +- **[docs/provider-api.md](docs/provider-api.md)** — STT/TTS Provider API - **[CONTRIBUTING.md](../CONTRIBUTING.md)** — Contributor flow, code style, commit conventions diff --git a/docs/deploy/linux.md b/docs/deploy/linux.md index a44a91a..08dee7f 100644 --- a/docs/deploy/linux.md +++ b/docs/deploy/linux.md @@ -1,10 +1,9 @@ # Deploying VocalizeAI on a Linux Host -This runbook covers end-to-end production deployment of VocalizeAI on any -modern Linux host with systemd: the orchestrator runs on this host, GPU -services (SenseVoice STT + CosyVoice TTS) run on a separate machine reachable -over Tailscale, and a Cloudflare Tunnel fronts the orchestrator host to the -public internet. +This runbook covers end-to-end production deployment of the VocalizeAI backend +on any modern Linux host with systemd. The orchestrator runs on this host, speech +is accessed through the Provider API, and a Cloudflare Tunnel can front the +service to the public internet. Tested on **Debian 12**, **Ubuntu 22.04 / 24.04**, and **Raspberry Pi OS (Bookworm)**. A Raspberry Pi was the original reference target — see @@ -20,15 +19,8 @@ the BOM, OS imaging, and SSH bootstrap steps for that specific target. - Python 3.11 (installed by step 1 of `install/install.sh`). - Persistent internet connection (Cloudflare Tunnel requires outbound HTTPS). -**GPU node (separate machine):** -- NVIDIA RTX-class GPU (GTX 1080 or better; RTX 30/40 series recommended). -- Windows + WSL2 or Linux (PyTorch 2.7.1+cu128). -- Reachable from the orchestrator host over Tailscale on the configured - `GPU_HOST` IP/hostname. - **Network:** -- Tailscale account (free tier is sufficient) with both the orchestrator host - and the GPU node enrolled. +- Optional private network/VPN if your speech provider is not on the same host. - Cloudflare account with a domain pointed at Cloudflare DNS (free tier is sufficient). @@ -50,30 +42,18 @@ For the Raspberry Pi-specific imaging / first-boot steps, see --- -## Tailscale Setup +## Speech Provider Network -Tailscale provides the encrypted overlay network between the orchestrator -host and the GPU node. +The public Mac-first path expects a Provider API service for speech. On macOS, +that is the native helper documented in [../macos-speech-provider.md](../macos-speech-provider.md). +If your Linux backend talks to a provider on another machine, put both machines +on a private network or VPN and set the Provider API URLs in `.env`. ```bash -# Install Tailscale on the orchestrator host: -curl -fsSL https://tailscale.com/install.sh | sh -sudo tailscale up - -# Verify the GPU node is visible: -tailscale status -# You should see your GPU node listed with its Tailscale IP. - -# Test reachability (replace with your GPU node's Tailscale IP): -# nc -zv 8000 # SenseVoice STT -# nc -zv 8001 # CosyVoice TTS +# Example: provider on the same host +curl -s http://127.0.0.1:8765/v1/capabilities ``` -Set `GPU_HOST` in `/opt/vocalize/.env` to the GPU node's Tailscale IP. - -If the GPU services are not yet running, use `install/install.sh --skip-gpu` -to proceed with installation without the GPU-reachability check. - --- ## Clone and Install @@ -91,7 +71,6 @@ bash install/install.sh # Or run selectively: bash install/install.sh --steps "1,2,6" # apt + venv + systemd only -bash install/install.sh --skip-gpu # skip GPU-reachability check in step 7 bash install/install.sh --skip-tunnel # skip step 5 (Cloudflare Tunnel info) ``` @@ -101,7 +80,7 @@ bash install/install.sh --skip-tunnel # skip step 5 (Cloudflare Tunnel info |------|--------| | 1 | `apt-get install` python3.11 python3.11-venv python3-pip build-essential rsync | | 2 | Create `.venv` in `/opt/vocalize`, `pip install -e .` | -| 3 | GPU services note (GPU lives on a separate host; no on-orchestrator install) | +| 3 | Speech Provider API configuration note | | 4 | Tailscale presence check (warns if absent) | | 5 | Cloudflare Tunnel token-install instructions | | 6 | Copy `vocalize.service` to `/etc/systemd/system/`, copy `.env.template` to `/opt/vocalize/.env` if absent, `systemctl enable vocalize` | @@ -119,16 +98,18 @@ After step 6 copies `.env.template` to `/opt/vocalize/.env`, edit it: sudo nano /opt/vocalize/.env ``` -**Full env-var reference** (17 keys from `.env.example`): +**Full env-var reference**: | Key | Required? | Purpose | |-----|-----------|---------| | `OPENAI_API_KEY` | **yes** | LLM authentication (any OpenAI-compatible provider) | | `OPENAI_BASE_URL` | default ok | LLM endpoint; default `https://api.deepseek.com/v1` | | `OPENAI_MODEL` | default ok | Model name; default `deepseek-chat` | -| `GPU_HOST` | yes (if using GPU) | STT/TTS host — Tailscale IP of your GPU node | -| `SENSEVOICE_WS_PORT` | default ok | STT port; default `8000` | -| `COSYVOICE_WS_PORT` | default ok | TTS port; default `8001` | +| `OPENAI_THINKING_MODE` | default ok | `enabled` or `disabled`; default `disabled` for non-thinking LLM calls | +| `VOCALIZE_STT_PROVIDER_URL` | default ok | STT Provider API base URL; macOS default is the local native helper | +| `VOCALIZE_TTS_PROVIDER_URL` | default ok | TTS Provider API base URL; macOS default is the local native helper | +| `VOCALIZE_SPEECH_PROVIDER_AUTO_START` | default ok | Set `1` to let the backend start the configured speech helper command | +| `VOCALIZE_SPEECH_PROVIDER_COMMAND` | optional | Command used when auto-start is enabled | | `VOCALIZE_HOST` | default ok | uvicorn bind host; set to `0.0.0.0` for production | | `VOCALIZE_PORT` | default ok | uvicorn bind port; default `8080` | | `ORCHESTRATOR_LISTEN_PORT` | default ok | Orchestrator service port; default `8080` (legacy compatibility) | @@ -136,8 +117,8 @@ sudo nano /opt/vocalize/.env | `VOCALIZE_CORS_ORIGINS` | default ok | Comma-separated allowed CORS origins; default auto-picked from VOCALIZE_HOST | | `DEFAULT_LANGUAGE` | default ok | `zh` or `en`; default `zh` | | `LOG_DIR` | default ok | Log directory; default `logs` | -| `NEXT_PUBLIC_VOCALIZE_API_BASE_URL` | yes (for frontend) | Frontend API base URL; baked into JS bundle at build time | -| `NEXT_PUBLIC_VOCALIZE_WS_BASE_URL` | optional | Frontend WS base; derived from API base if absent | +| `VITE_VOCALIZE_API_BASE_URL` | yes (for frontend) | Frontend API base URL; baked into JS bundle at build time | +| `VITE_VOCALIZE_WS_BASE_URL` | optional | Frontend WS base; derived from API base if absent | **Example production `.env` (use your own values for all `<...>` placeholders):** @@ -145,13 +126,16 @@ sudo nano /opt/vocalize/.env OPENAI_API_KEY= OPENAI_BASE_URL=https://api.deepseek.com/v1 OPENAI_MODEL=deepseek-chat -GPU_HOST= -SENSEVOICE_WS_PORT=8000 -COSYVOICE_WS_PORT=8001 +OPENAI_THINKING_MODE=disabled +VOCALIZE_STT_PROVIDER_URL=http://127.0.0.1:8765 +VOCALIZE_TTS_PROVIDER_URL=http://127.0.0.1:8765 +VOCALIZE_SPEECH_PROVIDER_AUTO_START=1 +VOCALIZE_SPEECH_PROVIDER_COMMAND= VOCALIZE_HOST=0.0.0.0 VOCALIZE_PORT=8080 VOCALIZE_WS_BASE_URL=wss://api. VOCALIZE_CORS_ORIGINS=https:// +VITE_VOCALIZE_API_BASE_URL=https://api. ``` --- @@ -175,12 +159,10 @@ sudo cloudflared service install sudo systemctl status cloudflared ``` -The reference ingress shape for this project is documented in -`infra/orchestrator/cloudflared-config.yml` (maps -`vocalize-api.` → `http://localhost:8080` and -`vocalize.` → `http://localhost:3000`). Configure -the actual public hostname routing in the Cloudflare dashboard under -your tunnel's Public Hostnames tab. +The backend can serve the built Vite console from `frontend/dist`, so a simple +deployment may route both API and UI traffic to the FastAPI process. Configure +the actual public hostname routing in the Cloudflare dashboard under your +tunnel's Public Hostnames tab. Use `` as the tunnel name — do not use another person's tunnel name; tunnels are account-specific. @@ -293,16 +275,13 @@ sudo journalctl -u cloudflared -n 50 Check the Cloudflare dashboard for connector status. Ensure the tunnel token matches the connector the dashboard expects. -**GPU services unreachable (`gpu_reachable=false` in `/health`):** +**Speech provider unreachable (`speech_provider_reachable=false` in `/health`):** ```bash -# Confirm Tailscale is up and the GPU node is reachable: -tailscale status - -# Test TCP connectivity to each GPU service port: -nc -zv $GPU_HOST $SENSEVOICE_WS_PORT # e.g. nc -zv 100.x.y.z 8000 -nc -zv $GPU_HOST $COSYVOICE_WS_PORT # e.g. nc -zv 100.x.y.z 8001 +# Check Provider API capabilities: +curl -s "$VOCALIZE_STT_PROVIDER_URL/v1/capabilities" ``` -Check that SenseVoice and CosyVoice are running on the GPU host. +Check that the configured Provider API service is running and reachable from the +backend host. **Orchestrator fails to start:** ```bash diff --git a/docs/deploy/local.md b/docs/deploy/local.md index b7b83b0..b833c10 100644 --- a/docs/deploy/local.md +++ b/docs/deploy/local.md @@ -65,16 +65,18 @@ After the installer runs, edit `.env`: $EDITOR .env ``` -**All 17 env vars explained:** +**Core env vars explained:** | Key | Required? | Purpose | |-----|-----------|---------| | `OPENAI_API_KEY` | **yes** | LLM authentication — any OpenAI-compatible provider (OpenAI, DeepSeek, Qwen, etc.) | | `OPENAI_BASE_URL` | default ok | LLM endpoint; default `https://api.deepseek.com/v1` | | `OPENAI_MODEL` | default ok | Model name; default `deepseek-chat` | -| `GPU_HOST` | only if using GPU | STT/TTS host; use `localhost` for single-machine dev, Tailscale IP for remote-GPU deployment (e.g. Raspberry Pi orchestrator → GPU node) | -| `SENSEVOICE_WS_PORT` | default ok | SenseVoice STT WebSocket port; default `8000` | -| `COSYVOICE_WS_PORT` | default ok | CosyVoice TTS WebSocket port; default `8001` | +| `OPENAI_THINKING_MODE` | default ok | `enabled` or `disabled`; default `disabled` for non-thinking LLM calls | +| `VOCALIZE_STT_PROVIDER_URL` | default ok | STT Provider API base URL; defaults to the local macOS native helper | +| `VOCALIZE_TTS_PROVIDER_URL` | default ok | TTS Provider API base URL; defaults to the local macOS native helper | +| `VOCALIZE_SPEECH_PROVIDER_AUTO_START` | default ok | Set `1` to let the backend start the configured speech helper command | +| `VOCALIZE_SPEECH_PROVIDER_COMMAND` | optional | Command used when auto-start is enabled | | `VOCALIZE_HOST` | default ok | uvicorn bind host; `127.0.0.1` for local dev, `0.0.0.0` for production | | `VOCALIZE_PORT` | default ok | uvicorn bind port; default `8080` (note: dev `main.py` defaults to 8000) | | `ORCHESTRATOR_LISTEN_PORT` | default ok | Orchestrator service port; default `8080` (legacy; mirrors `VOCALIZE_PORT`) | @@ -82,8 +84,8 @@ $EDITOR .env | `VOCALIZE_CORS_ORIGINS` | default ok | Comma-separated allowed CORS origins; auto-picked from VOCALIZE_HOST in dev mode | | `DEFAULT_LANGUAGE` | default ok | Session default language; `zh` or `en`; default `zh` | | `LOG_DIR` | default ok | Log directory; default `logs` | -| `NEXT_PUBLIC_VOCALIZE_API_BASE_URL` | yes for frontend | Frontend API base URL baked into the Next.js JS bundle at build time | -| `NEXT_PUBLIC_VOCALIZE_WS_BASE_URL` | optional | Frontend WS base; derived from `NEXT_PUBLIC_VOCALIZE_API_BASE_URL` if absent | +| `VITE_VOCALIZE_API_BASE_URL` | yes for dev frontend | Frontend API base URL baked into the Vite JS bundle at build time | +| `VITE_VOCALIZE_WS_BASE_URL` | optional | Frontend WS base; derived from `VITE_VOCALIZE_API_BASE_URL` if absent | **Backend auth posture:** v1 ships no request-level auth on `POST /api/sessions` or the WebSocket. For non-localhost deployments, @@ -91,15 +93,15 @@ restrict reachability at the network or proxy layer (Cloudflare Access, VPN, reverse-proxy auth, etc.). Per-user auth is v1.x scope (requirement `AUTH-01`). -**Minimum for local dev (no GPU):** +**Minimum for local dev on macOS:** ```bash OPENAI_API_KEY= -NEXT_PUBLIC_VOCALIZE_API_BASE_URL=http://127.0.0.1:8000 +OPENAI_THINKING_MODE=disabled +VITE_VOCALIZE_API_BASE_URL=http://127.0.0.1:8000 ``` -With these two set, the backend and frontend work end-to-end. GPU services -(`GPU_HOST`) are optional — `GET /health` will report `gpu_reachable=false` but -the LLM path (task planning, preflight) still works. +With these values set, the backend and frontend work end-to-end. STT/TTS default to +the local macOS native speech helper. --- @@ -123,7 +125,7 @@ running: ```bash curl -s http://127.0.0.1:8000/health -# → {"ok": true, "gpu_reachable": false} +# → {"ok": true, "speech_provider_reachable": true} ``` --- @@ -134,14 +136,13 @@ Open a second terminal: ```bash cd frontend -npm run dev -- --hostname 127.0.0.1 --port 3000 +npm run dev -- --host 127.0.0.1 --port 3000 ``` Open `http://127.0.0.1:3000` in your browser. -The frontend calls the backend directly through `NEXT_PUBLIC_VOCALIZE_API_BASE_URL`. -If the frontend was built without setting this variable, it will default to -`http://127.0.0.1:8000`. Set it explicitly in `.env` for consistent behaviour. +The frontend calls the backend directly through `VITE_VOCALIZE_API_BASE_URL`. +Set it before starting or building the frontend. --- @@ -195,9 +196,9 @@ does not require physical hardware. `RuntimeError` (D-11 guard) — set it or switch to `VOCALIZE_HOST=127.0.0.1` **Frontend can't reach backend:** -- Check that `NEXT_PUBLIC_VOCALIZE_API_BASE_URL` in `.env` matches the backend port +- Check that `VITE_VOCALIZE_API_BASE_URL` in `.env` matches the backend port (default `http://127.0.0.1:8000`) -- Restart the frontend dev server after editing `.env` (Next.js bakes env vars at +- Restart the frontend dev server after editing `.env` (Vite bakes env vars at build time; hot-reload does NOT pick up `.env` changes) **`scripts/smoke.sh` fails on the WS step:** @@ -209,8 +210,9 @@ does not require physical hardware. - Check that the backend is actually running and the WS route is up: `curl -s http://127.0.0.1:8000/health` should return `{"ok": true, ...}` -**`/health` returns `gpu_reachable=false`:** -- This is expected when GPU services are not running. The LLM path works without - GPU; only STT/TTS (audio pipeline) require the GPU host. -- To enable GPU: set `GPU_HOST` to your GPU node's Tailscale IP and ensure - SenseVoice + CosyVoice are running on that host. +**`/health` returns `speech_provider_reachable=false`:** +- Start the macOS speech helper or set `VOCALIZE_SPEECH_PROVIDER_AUTO_START=1` + with a valid `VOCALIZE_SPEECH_PROVIDER_COMMAND`. +- If you use a custom provider, check that `VOCALIZE_STT_PROVIDER_URL` and + `VOCALIZE_TTS_PROVIDER_URL` point to a service implementing + `docs/provider-api.md`. diff --git a/docs/macos-speech-provider.md b/docs/macos-speech-provider.md new file mode 100644 index 0000000..1ad903f --- /dev/null +++ b/docs/macos-speech-provider.md @@ -0,0 +1,53 @@ +# macOS Speech Provider Helper + +`macos/VocalizeSpeechProvider` is the default `v0.1.0` speech provider for the +Mac-first public release. It exposes the Vocalize Provider API on loopback and +uses macOS native speech components: + +- STT: Apple Speech (`SFSpeechRecognizer`) with streamed `pcm_s16le` audio. +- TTS: macOS `say` plus AVFoundation conversion to mono `pcm_s16le` at 24 kHz. + +## Build + +```bash +swift build --package-path macos/VocalizeSpeechProvider +``` + +## Run + +```bash +VOCALIZE_SPEECH_PROVIDER_PORT=8765 \ + macos/VocalizeSpeechProvider/.build/debug/VocalizeSpeechProvider +``` + +Then check: + +```bash +curl -s http://127.0.0.1:8765/v1/capabilities +``` + +## Backend Auto-Start + +The backend only starts the helper when explicitly configured: + +```bash +VOCALIZE_SPEECH_PROVIDER_AUTO_START=1 +VOCALIZE_SPEECH_PROVIDER_COMMAND=/absolute/path/to/VocalizeSpeechProvider +VOCALIZE_STT_PROVIDER_URL=http://127.0.0.1:8765 +VOCALIZE_TTS_PROVIDER_URL=http://127.0.0.1:8765 +``` + +The installer will write these values for packaged builds. Source-tree dev runs +may leave auto-start disabled and start the helper manually. + +## Permissions + +`/v1/capabilities` reports: + +- `permissions.speech_recognition` +- `permissions.microphone` +- `permissions.tts_voices_available` + +`vocalize doctor` treats missing Speech Recognition permission or missing TTS +voices as deployment blockers. On first use, macOS may prompt the user to grant +Speech Recognition access. diff --git a/docs/provider-api.md b/docs/provider-api.md new file mode 100644 index 0000000..44fffc2 --- /dev/null +++ b/docs/provider-api.md @@ -0,0 +1,142 @@ +# Vocalize Provider API + +VocalizeAI talks to speech services through a local Provider API. The default +`v0.1.0` provider is the macOS native speech helper, but the backend only +depends on this contract. + +The provider is expected to run on loopback by default. Public deployments +should not expose these endpoints to the internet. + +## Versioning + +Current API version: `v1`. + +Every response or event that includes `provider_api_version` must use `"1.0"`. +Future incompatible changes will use a new URL prefix. + +## Capabilities + +```http +GET /v1/capabilities +``` + +Response: + +```json +{ + "provider_api_version": "1.0", + "provider": "macos-native", + "realtime": true, + "stt": { + "realtime": true, + "input_encoding": "pcm_s16le", + "input_sample_rate": 16000, + "languages": ["zh-CN", "en-US"] + }, + "tts": { + "realtime": true, + "output_encoding": "pcm_s16le", + "output_sample_rate": 24000, + "languages": ["zh-CN", "en-US"] + }, + "permissions": { + "speech_recognition": "authorized", + "microphone": "authorized", + "tts_voices_available": 12 + } +} +``` + +`vocalize setup` and `vocalize doctor` use this endpoint to derive provider +settings. Ordinary users should not need to hand-edit STT/TTS parameters. + +## STT Streaming + +```http +GET /v1/stt/stream +Upgrade: websocket +``` + +Client-to-provider frames: + +```json +{"type":"start","provider_api_version":"1.0","language":"auto","session_id":"optional"} +``` + +Binary frames are raw audio bytes matching the provider's advertised input +format. The default realtime profile is mono `pcm_s16le` at 16 kHz. + +```json +{"type":"end_of_utterance"} +{"type":"stop"} +``` + +Provider-to-client transcript event: + +```json +{ + "type": "transcript", + "text": "book a table", + "is_final": false, + "confidence": 0.82, + "start_time": 0.0, + "end_time": 1.1, + "utterance_id": 0, + "language": "en", + "segments": [ + {"text":"book a table","language":"en","start_time":0.0,"end_time":1.1} + ] +} +``` + +`partial` and `final` transcripts for the same utterance must share +`utterance_id`. `language` may be `null` on partials. + +## TTS Streaming + +```http +GET /v1/tts/stream +Upgrade: websocket +``` + +Client-to-provider frames: + +```json +{"type":"start","provider_api_version":"1.0","language":"zh","session_id":"optional"} +{"type":"text","text":"您好。","language":"zh","is_final_segment":true} +{"type":"stop"} +``` + +Provider-to-client events: + +```json +{"type":"audio_start","sample_rate":24000,"encoding":"pcm_s16le","channels":1} +``` + +Binary frames contain audio bytes matching the `audio_start` metadata. + +```json +{"type":"audio_end"} +``` + +## Errors + +Providers should return structured errors over the active WebSocket: + +```json +{"type":"error","code":"permission_denied","message":"Microphone permission is missing","fatal":true} +``` + +`fatal=true` ends the current stream. Non-fatal errors may be logged and ignored +by the backend when the stream can continue safely. + +## Cancellation + +Clients cancel by sending `{"type":"stop"}` and closing the WebSocket. Providers +must stop recognition/synthesis promptly and release native resources. + +## Production Readiness + +The `realtime` profile is required for a production-ready VocalizeAI deployment. +Batch-only speech providers may be useful for diagnostics, but they do not +satisfy the public `v0.1.0` Mac-first product path. diff --git a/docs/release/24h-stability-evidence.md b/docs/release/24h-stability-evidence.md deleted file mode 100644 index 4d7a34d..0000000 --- a/docs/release/24h-stability-evidence.md +++ /dev/null @@ -1,172 +0,0 @@ -# 24h Stability Evidence - -Use this template for the DEPLOY-02 release evidence. Fill every -`# TODO(operator)` cell at release tag time. - -## Scope - -- Requirement: **DEPLOY-02** — Pi survives 24 h of mixed (synthetic + real-audio) - traffic without a process restart and within bounded resource/error budgets. -- Decision: **D-04** — hybrid workload: 48 synthetic text-bypass sessions - (one every 30 min for 24 h via `scripts/stability-24h-driver.py`) plus - ≥3 real-audio sessions interleaved by the operator. -- Decision: **D-05** — pass criteria; ALL sub-criteria must be green. -- Decision: **D-06** — evidence = Prometheus screenshots + release-notes link. - -**Why triple-source restart detection?** -`vocalize.service` has `Restart=always` (line 11). When systemd restarts -the process it silently resets in-process uptime. A single-source check -(e.g. only reading `vocalize_process_uptime_seconds`) would be a false green -if the process restarted and has been up long enough. Triple-source detection -closes this gap: -1. `systemctl show vocalize --property=ActiveEnterTimestamp` — timestamp of - last service activation (changes on restart). -2. `journalctl -u vocalize --since="24h ago" | grep -c "Started vocalize"` — - counts how many times systemd logged a start event (must be 0 restarts - over the evidence window, meaning the count observed at end − count at - start = 0). -3. `vocalize_process_uptime_seconds` from `/metrics` — the in-process timer - since `_START_T = time.time()` in `src/vocalize/server/metrics.py`; must - be ≥ 86400 s at end if no restart. - ---- - -## Operator Record - -| Field | Value | -| --- | --- | -| Release tag | `v1.0.0` (pending; this evidence gates the tag) | -| Commit SHA | `5602f72` (PR #39 merged; task_planner test-bypass deployed) | -| Operator | DGPisces | -| Date / time (start) | 2026-05-17T19:56:20Z | -| Date / time (end) | 2026-05-18T19:56:20Z | -| Backend URL | `http://localhost:8080` (Pi loopback; same orchestrator that `https://api.example.com` fronts via Cloudflare Tunnel) | -| Browser and version | N/A — driver-only run; real-audio interleave intentionally skipped (see Real-Audio Interleave Log below) | -| STT service health | Pass (`/health` reported `gpu_reachable: true` throughout) | -| TTS service health | Pass (CosyVoice docker container up 2 days, healthy) | -| LLM / DeepSeek judge health | Pass (no auth or rate-limit errors; merchant_agent LLM calls succeeded across 48 cycles) | -| CI workflow run URL | https://github.com/DGPisces/VocalizeAI/actions/runs/26000759439 (PR #39, all 4 jobs green) | -| Release notes URL | (pending v1.0.0 tag creation) | -| Pi systemd `ActiveEnterTimestamp` at **start** | `Sun 2026-05-17 12:54:46 PDT` | -| Pi systemd `ActiveEnterTimestamp` at **end** | `Sun 2026-05-17 12:54:46 PDT` (unchanged) | -| `vocalize_process_uptime_seconds` at **start** | `93.14` | -| `vocalize_process_uptime_seconds` at **end** | `86493.17` (≈1442 min, ≥86400 target) | -| `journalctl -u vocalize Started` count at **start** | 1 (baseline) | -| `journalctl -u vocalize Started` count at **end** | 1 (delta = 0) | -| `VOCALIZE_ENABLE_TEST_FRAMES` on Pi during run | `1` (set in /home//vocalize/.env for the run; removed post-evidence) | -| `VOCALIZE_TEST_BYPASS_TASK_PLANNER` on Pi during run | `1` (PR #39 introduced this gate; bypassed task_planner LLM so driver could reach READY_TO_DIAL deterministically. Removed post-evidence.) | - ---- - -## Pass Criteria Table - -All rows must show **Pass** before this evidence is considered complete. - -| Criterion | Source | Target | Observed | Pass | -| --- | --- | --- | --- | --- | -| No process restart (systemd) | `systemctl show vocalize --property=ActiveEnterTimestamp` | unchanged over window | `Sun 2026-05-17 12:54:46 PDT` (unchanged) | **Pass** | -| No process restart (journalctl) | `journalctl -u vocalize --since="24h ago" \| grep -c "Started vocalize"` | = 0 new starts over window | 0 new starts | **Pass** | -| Process uptime ≥ 1440 min | `/metrics` `vocalize_process_uptime_seconds` (end value) | ≥ 86400 | 86493.17 s (≈1442 min) | **Pass** | -| RSS growth < 200 MB | `/metrics` `vocalize_process_rss_bytes` (end − start) | < 209715200 | 109,260,128 (109 MB) − 79,384,576 (79 MB) = **29,876,128 (30 MB)** | **Pass** | -| ERROR-log entries ≤ 5 | `/metrics` `vocalize_error_log_total` (end − start) | ≤ 5 | 8 − 0 = **8** | **Partial — exceeds threshold by 3** (see Known Gaps row "D-05.3 ERROR threshold exceeded") | -| Stale-sweep clears registry | Triangulation via WS lifecycle counters (sweep log entries not surfaced by `journalctl` — see Known Gaps row "Log visibility for ERROR-level events") | `vocalize_ws_sessions_opened_total` == `vocalize_ws_sessions_closed_total{reason="normal"}` and no `reason="error"` closures | opened=49, closed_normal=49, closed_error=0 (every WS that opened was cleanly closed) | **Pass (triangulated)** | - ---- - -## Synthetic Workload Log - -Run via `scripts/stability-24h-driver.py`. Paste the generated per-run -evidence file from `docs/release/24h-stability-evidence-runs/.md` -into this section. - -Expected: 48 cycles (one per 30 min × 24 h). OK count must be ≥ 45 (≤ 3 -transient failures acceptable). - -**Result: 48/48 OK, 0 FAIL.** Median cycle elapsed time: ~3.4s (range 2.3 – 5.3 s). Full per-cycle table archived at `docs/release/24h-stability-evidence-runs/2026-05-17T195620Z-pi-loopback.md` on the Pi (gitignored per Known Gaps). - -Driver command used: -```bash -export VOCALIZE_API_BASE=http://localhost:8080 -python scripts/stability-24h-driver.py \ - --duration-minutes 1440 \ - --scenario balance_inquiry_en_query \ - --seed direct \ - --evidence-out docs/release/24h-stability-evidence-runs/2026-05-17T195620Z-pi-loopback.md -``` - -Driver invoked with API base = `http://localhost:8080` (Pi loopback) rather than `https://api.example.com` because the driver ran on the Pi itself per operator preference (full automation, no Attu host). The Cloudflare Tunnel ingress for `vocalize.example.com` was verified live separately during DEPLOY-03 deploy (curl → HTTP/2 502 + Universal SSL `*.example.com` cert). - ---- - -## Real-Audio Interleave Log - -Operator manually conducts ≥3 real-audio sessions during the 24 h window. -Must cover zh + en + at least one impatient-merchant behavior. - -| # | Timestamp | Language | Merchant style | Verdict | Notes | -| --- | --- | --- | --- | --- | --- | -| 1 | — | — | — | **Skipped** | Operator chose full automation for v1.0.0 release; manual real-audio interleave deferred. In-call paths (TTS streaming, long WS, post_call_review) are exercised by the synthetic driver via full happy-path cycles thanks to PR #39 `VOCALIZE_TEST_BYPASS_TASK_PLANNER` gate, but real STT (mic audio) is not covered by this run. See Known Gaps row "Real-audio interleave skipped" below. Real-audio smoke remains covered by the Phase 3 `release_audio_{en,zh}_bridge` scenarios, which are gated to a separate manual release-time activation per Phase 3 D-05. | -| 2 | — | — | — | **Skipped** | (same) | -| 3 | — | — | — | **Skipped** | (same) | - ---- - -## Prometheus Snapshots - -Snapshots taken at ~4 h intervals by the driver (`--metrics-snapshot-interval-hours 4`). -File paths are under `docs/release/24h-stability-evidence-runs//`. - -| Interval | Timestamp (UTC) | `vocalize_process_uptime_seconds` | `vocalize_process_rss_bytes` | `vocalize_error_log_total` | `vocalize_active_sessions` | -| --- | --- | --- | --- | --- | --- | -| T+0h | 2026-05-17 19:56:20 | 93.14 | 79,384,576 (79 MB) | 0 | 1 | -| T+4h | 2026-05-17 23:56:23 | 14,496 | 95,113,216 (95 MB) | 0 | 2 | -| T+8.5h | 2026-05-18 04:26:25 | 30,697 | 98,783,232 (99 MB) | 0 | 2 | -| T+13h | 2026-05-18 08:56:25 | 46,898 | 102,846,464 (103 MB) | **2** | 2 | -| T+17.5h | 2026-05-18 13:26:25 | 63,098 | 106,516,480 (107 MB) | 2 | 2 | -| T+22h | 2026-05-18 17:56:29 | 79,301 | 108,744,704 (109 MB) | **8** | 2 | -| T+24h | 2026-05-18 19:56:20 | 86,493 | 108,744,704 (109 MB) | 8 | 2 | - -Interval cadence is ~4.5 h (driver default `--metrics-snapshot-interval-hours 4`, drift accumulates due to 30-min cycle alignment). - -`vocalize_active_sessions = 2` throughout post-cycle-1 is the **W-02 known-issue gauge** counting all registered sessions; the WS-side opened-vs-closed reconciliation (49==49 normal) is the authoritative "registry empty" signal. See Known Gaps row. - -Error budget timeline: -- T+0h → T+8.5h: 0 errors (first 8.5h clean). -- T+13h: +2 errors (within cycles 28–29 window). Source not visible in journalctl (see "Log visibility for ERROR-level events" in Known Gaps). -- T+22h: +6 more errors (within cycles 36–44 window). All driver cycles still passed OK. - ---- - -## Known Gaps - -| Gap | Impact | Owner | Follow-up | -| --- | --- | --- | --- | -| **D-05.3 ERROR threshold exceeded** (8 > 5) | 8 ERROR-level events fired over 24h; all driver cycles still completed OK (48/48), process did not restart, no functional regression observed. Errors clustered in two windows (cycles 28–29 and 36–44) consistent with transient external dependency hiccups (DeepSeek API latency, network blips during merchant_agent LLM calls). Accepted as **PARTIAL** for v1.0.0 release: stability primary signal (no restart, RSS healthy, full cycle pass rate) is green; the auxiliary error counter trips a conservative budget that does not reflect actual cycle outcomes. | Release operator | Track as v1.0.1 follow-up: instrument LLM client retries + persist ERROR-level logs (see next row) so the counter is auditable and the threshold is meaningful. | -| **Log visibility for ERROR-level events** | `src/vocalize/logger.py:setup_logging()` is never invoked from `main.py`, so `log.error(...)` calls from production code reach the root logger with no `FileHandler` or stderr handler attached. The `ErrorCounterHandler` in `metrics.py` still increments `vocalize_error_log_total`, but the log message text is dropped. journalctl shows no ERROR/Exception lines for the 24h window because uvicorn's stderr capture also missed them. This is why the 8 errors above have no root cause attribution. | — | Open follow-up: either call `setup_logging()` from `main.py` (writes to `/system.log`) or add a default `StreamHandler(sys.stderr)` to the root logger at app init. Either fixes the auditability gap without changing observable behavior. | -| **Real-audio interleave skipped** | Operator chose full automation for v1.0.0 release; the 24h driver covers preflight + orchestrator state machine + merchant_agent LLM + TTS streaming + post_call_review entry (via PR #39 task_planner bypass) but does NOT exercise the real STT path (mic audio → SenseVoice). | — | The Phase 3 `release_audio_{en,zh}_bridge` scenarios cover real STT end-to-end with browser microphone and are activated at release-time per Phase 3 D-05. They run pre-tag (release-only gate), independent of this 24h evidence. | -| Per-run evidence files are gitignored (`docs/release/24h-stability-evidence-runs/`) | Evidence directory not in source control; only this filled template is committed at release | Release operator | The full per-cycle table from this run is summarized inline above (Synthetic Workload Log). The raw run-specific file lives at `~/vocalize/docs/release/24h-stability-evidence-runs/2026-05-17T195620Z-pi-loopback.md` on the Pi. | -| `Restart=always` means single-source restart detection is unreliable | Addressed by triple-source methodology above | — | No action needed; methodology documented | -| `VOCALIZE_ENABLE_TEST_FRAMES=1` and `VOCALIZE_TEST_BYPASS_TASK_PLANNER=1` must be unset post-run | Both test-frame surfaces are open during the evidence window | Release operator | **Unset before v1.0.0 tag.** Verify with `systemctl show vocalize -p Environment` env dump. | -| `vocalize_active_sessions` gauge measures total registered sessions, not WS-active ones | The gauge climbs linearly over 48 synthetic cycles because `stability-24h-driver.py` does not DELETE sessions and `sweep_stale` skips `POST_CALL_REVIEW` sessions. Do NOT use this gauge to verify the D-05 "registry empty" criterion. Instead, verify via the WS lifecycle reconciliation (opened == closed_normal) per the Pass Criteria table above. | — | No action needed; semantics documented here. (Same finding as PR #38 supplementary code review W-02; already documented in inline metric help text.) | - ---- - -## Release Notes Linkage - -- Release notes URL: (pending v1.0.0 tag) -- Filled checklist or evidence artifact URL: this file (`docs/release/24h-stability-evidence.md` at release commit) -- Checklist backlink to release tag: (pending v1.0.0 tag) -- CI workflow run URL: https://github.com/DGPisces/VocalizeAI/actions/runs/26000759439 (PR #39 final green run) -- AI-merchant artifact URL: archived under `docs/release/24h-stability-evidence-runs/2026-05-17T195620Z-pi-loopback.md` on Pi - -Only link evidence approved for release communication. Do not publish private -working notes or internal planning content. - ---- - -## Sign-Off - -| Role | Name | Date | Decision | Notes | -| --- | --- | --- | --- | --- | -| Release operator | DGPisces | 2026-05-18 | **PARTIAL — Approved with documented gap** | 4/4 stability primary criteria pass (no restart, uptime ≥1440 min, RSS Δ <200 MB, WS lifecycle clean). D-05.3 ERROR-log counter exceeded threshold (8 > 5) but all 48 driver cycles passed OK; observability gap documented in Known Gaps + tracked as v1.0.1 follow-up. Real-audio interleave deferred per release-time gate. | -| Release reviewer | | | Pass / Fail | | diff --git a/docs/release/RELEASE-FLOW.md b/docs/release/RELEASE-FLOW.md index 22d0591..9e7edb3 100644 --- a/docs/release/RELEASE-FLOW.md +++ b/docs/release/RELEASE-FLOW.md @@ -1,125 +1,79 @@ # VocalizeAI Release Flow -Manual release recipe for VocalizeAI. No automated tooling (Release Please, semantic-release) -is adopted at v1 scale — the solo-maintainer overhead exceeds the benefit. - -v1.0.0 was the first exercise of this flow (Phase 8), establishing the baseline. -Each subsequent release follows the same steps. - ---- +Release flow for the Mac-first public product. ## Prerequisites -- `gh` CLI authenticated (`gh auth status`) -- Working directory clean on `main` (`git status`) -- All CI checks green on `main` +- Working tree clean on the release branch. +- CI green, including backend, Provider API, macOS helper, frontend, + packaging/installer, and public-tree audit. +- Required review from DGPisces. +- Apple Developer ID signing and notarization secrets configured in GitHub. +- Human clean-install test plan ready. ---- - -## Step 1 — Create a release branch +## Step 1 — Prepare the Release Branch ```bash -git checkout main && git pull +git checkout main +git pull git checkout -b release/vX.Y.Z ``` ---- +Update the version in `pyproject.toml` if needed, then update `CHANGELOG.md`. -## Step 2 — Bump version in pyproject.toml +## Step 2 — Run Local Release Checks ```bash -# Edit pyproject.toml: update [project] version = "X.Y.Z" -$EDITOR pyproject.toml +.venv/bin/python -m pytest tests --ignore=tests/integration +cd frontend && npm run lint && npm run build && npm test +cd .. +bash -n install/install.sh install/uninstall.sh scripts/build-macos-release.sh ``` -Commit: +For a local unsigned artifact smoke: ```bash -git add pyproject.toml -git commit -m "chore(release): bump version to X.Y.Z" +scripts/build-macos-release.sh --signing-mode skip +bash install/verify-release.sh dist/release/SHA256SUMS dist/release/VocalizeAI-*-macos-*.zip ``` ---- - -## Step 3 — Write CHANGELOG entry - -Edit `CHANGELOG.md` at repo root: - -1. Move items from `## [Unreleased]` into a new `## [X.Y.Z] — YYYY-MM-DD` section. -2. Leave `## [Unreleased]` empty with the stub line. -3. Add the comparison link at the bottom: - `[X.Y.Z]: https://github.com/DGPisces/VocalizeAI/compare/vA.B.C...vX.Y.Z` - -```bash -git add CHANGELOG.md -git commit -m "docs(release): CHANGELOG entry for vX.Y.Z" -``` - ---- - -## Step 4 — Open and merge the release PR +## Step 3 — Open the Release PR ```bash git push -u origin release/vX.Y.Z gh pr create \ --base main \ --title "Release vX.Y.Z" \ - --body "Bump version to X.Y.Z. See CHANGELOG.md for details." + --body "Prepare VocalizeAI vX.Y.Z. See CHANGELOG.md for details." ``` -Wait for CI to pass, self-approve (solo maintainer), then merge to `main`. +Merge only after CI is green and the maintainer review is complete. -```bash -# After merge: -git checkout main && git pull -``` - ---- - -## Step 5 — Tag the release +## Step 4 — Tag the Release ```bash +git checkout main +git pull git tag -a vX.Y.Z -m "Release vX.Y.Z" git push origin vX.Y.Z ``` ---- - -## Step 6 — Create GitHub Release - -Prepare a release notes file (can reuse the CHANGELOG section): - -```bash -# Extract the relevant CHANGELOG section into a temp file, or write inline: -gh release create vX.Y.Z \ - --title "vX.Y.Z" \ - --notes-file path/to/release-notes.md \ - --verify-tag -``` - -Alternatively, draft the release in the GitHub UI after pushing the tag. - ---- - -## Step 7 — Sync to public repo - -Run the sync-private-to-public flow (documented in `.planning/skills/`) to push -sanitized source to `github.com/DGPisces/VocalizeAI`. The tag and release travel -with the sync. - ---- +The GitHub `Release` workflow builds the signed/notarized macOS artifact, +generates `SHA256SUMS`, verifies install/setup/uninstall smoke, and publishes +the assets to the GitHub Release. -## Step 8 — Post-release verification +## Step 5 — Human Acceptance -- Confirm the GitHub Release page shows the correct tag and notes. -- Confirm the Discussions > Announcements tab auto-created a release announcement (optional). -- Run layer4 smoke checklist against the production Pi deployment: - `docs/release/layer4-smoke-checklist.md`. +Before the public reset or release announcement, a tester must install from the +GitHub Release artifact and verify: ---- +- `./vocalize setup` with only LLM values, thinking mode, and local port +- `./vocalize doctor` with production checks enabled +- `./vocalize start` +- one Chinese and one English end-to-end smoke task +- `./vocalize uninstall` or `uninstall.sh` -## History +## Branch Protection -| Version | Date | Internal SHA | Public SHA | -|---------|------------|--------------|------------| -| 1.0.0 | 2026-05-18 | d9bd923 | 591aa9e | +See [branch-protection.md](branch-protection.md) for required check names, +review policy, and release secret names. diff --git a/docs/release/branch-protection.md b/docs/release/branch-protection.md new file mode 100644 index 0000000..b7d8b03 --- /dev/null +++ b/docs/release/branch-protection.md @@ -0,0 +1,41 @@ +# Branch Protection and Review Gates + +Public `main` must be protected after the `v0.1.0` reset. + +## Required PR Checks + +- `Backend lint, type, and unit tests` +- `Provider API contract tests` +- `macOS speech provider build` +- `Frontend lint, build, and unit tests` +- `Packaging and installer smoke` +- `Public tree audit` + +## Required Review + +- Require at least one approving review. +- Require review from Code Owners. +- `CODEOWNERS` routes every path to `@DGPisces`; every PR must be reviewed by + DGPisces before merge. +- Do not allow direct pushes to `main`, except for the one-time orphan public + reset operation in Phase 24. + +## Release Secrets + +The `Release` workflow fails closed unless these repository secrets exist: + +- `APPLE_DEVELOPER_ID_CERTIFICATE_BASE64` +- `APPLE_DEVELOPER_ID_CERTIFICATE_PASSWORD` +- `APPLE_DEVELOPER_ID_APPLICATION` +- `APPLE_ID` +- `APPLE_TEAM_ID` +- `APPLE_APP_SPECIFIC_PASSWORD` +- `MACOS_KEYCHAIN_PASSWORD` optional; generated per run if absent + +## Maintainer Setup Checklist + +1. Enable branch protection or a ruleset for `main`. +2. Require the checks listed above. +3. Require Code Owner review. +4. Disable direct pushes to `main`. +5. Add the Apple release secrets before creating the public `v0.1.0` release. diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json index bffb357..faa90fa 100644 --- a/frontend/.eslintrc.json +++ b/frontend/.eslintrc.json @@ -1,3 +1,10 @@ { - "extends": "next/core-web-vitals" + "root": true, + "env": { + "browser": true, + "es2022": true + }, + "extends": [ + "eslint:recommended" + ] } diff --git a/frontend/app/[locale]/layout.tsx b/frontend/app/[locale]/layout.tsx deleted file mode 100644 index cd90fcc..0000000 --- a/frontend/app/[locale]/layout.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import type { Metadata } from "next"; -import { NextIntlClientProvider, useMessages } from "next-intl"; -import { unstable_setRequestLocale } from "next-intl/server"; -import "../globals.css"; -import "../components.css"; -import { SUPPORTED_LOCALES } from "../../i18n"; - -export function generateStaticParams() { - return SUPPORTED_LOCALES.map(locale => ({ locale })); -} - -interface Props { - children: React.ReactNode; - params: { locale: string }; -} - -export async function generateMetadata({ params }: Props): Promise { - const ogImage = params.locale === "en" ? "/og/og-en.png" : "/og/og-zh.png"; - const description = - params.locale === "en" - ? "Browser audio bridge for AI phone tasks" - : "AI 电话助手的浏览器音频桥"; - return { - title: "VocalizeAI", - description, - openGraph: { images: [{ url: ogImage, width: 1200, height: 630 }] }, - }; -} - -export default function LocaleLayout({ children, params }: Props) { - unstable_setRequestLocale(params.locale); - const messages = useMessages(); - return ( - - - + + diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 8537e01..2b7c8a1 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -5,6 +5,7 @@ import type { TranscriptMessage, } from "./state"; import type { TaskPhaseValue } from "./ws"; +import { readPublicEnv } from "../src/env"; export interface CreateSessionRequest { preferred_voice_id?: string | null; // null = clear/unset @@ -46,9 +47,9 @@ export interface GetReviewResponse { } function apiBaseUrl(): string { - const value = process.env.NEXT_PUBLIC_VOCALIZE_API_BASE_URL; + const value = readPublicEnv("VOCALIZE_API_BASE_URL"); if (!value) { - throw new Error("NEXT_PUBLIC_VOCALIZE_API_BASE_URL is required"); + throw new Error("VITE_VOCALIZE_API_BASE_URL is required"); } return value.replace(/\/$/, ""); } diff --git a/frontend/lib/ws.ts b/frontend/lib/ws.ts index 689aaed..4ab30ee 100644 --- a/frontend/lib/ws.ts +++ b/frontend/lib/ws.ts @@ -5,6 +5,7 @@ import type { TranscriptMessageRole, TranscriptMessageSubtype, } from "./state"; +import { readPublicEnv } from "../src/env"; // TranscriptMessageSubtype mirrors backend subtype "filler" frames. // TranscriptMessageSubtype mirrors backend subtype "keepalive" frames. @@ -165,12 +166,12 @@ function hostsMatch(actual: URL, expected: URL): boolean { } export function trustedSessionWsUrl(rawUrl: string, sessionId: string): string { - const apiBase = process.env.NEXT_PUBLIC_VOCALIZE_API_BASE_URL; + const apiBase = readPublicEnv("VOCALIZE_API_BASE_URL"); if (!apiBase) { - throw new Error("NEXT_PUBLIC_VOCALIZE_API_BASE_URL is required"); + throw new Error("VITE_VOCALIZE_API_BASE_URL is required"); } const apiUrl = new URL(apiBase); - const configuredWsBase = process.env.NEXT_PUBLIC_VOCALIZE_WS_BASE_URL; + const configuredWsBase = readPublicEnv("VOCALIZE_WS_BASE_URL"); const expectedBase = configuredWsBase ? new URL(configuredWsBase) : new URL(`${apiUrl.protocol === "https:" ? "wss:" : "ws:"}//${apiUrl.host}`); diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 0f6bb44..dfb4cd9 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1,7 +1,7 @@ { "appName": "VocalizeAI", "start": "Start", - "taskLabel": "What phone task should the AI handle?", + "taskLabel": "Phone task", "taskPlaceholder": "Book a table for 4 at 7 pm tonight", "submitTask": "Submit task", "create_session": { @@ -18,14 +18,14 @@ "recovered": "Connection recovered. Review your call below." }, "ai_status": { - "filler_active": "AI is asking the merchant to hold…", - "keepalive_active": "AI is reassuring the merchant…", - "escalation_warning": "Merchant is impatient; AI will hand off to the callback flow" + "filler_active": "Merchant is waiting…", + "keepalive_active": "Checking…", + "escalation_warning": "Callback ready" }, "supplement_input": { "send": "Send", "placeholder_preflight": "Add more info anytime — e.g. change to 2 people", - "placeholder_default": "Hint to AI", + "placeholder_default": "Add details", "placeholder_takeover": "I'll speak" }, "preflight_chat": { @@ -34,7 +34,7 @@ }, "transcript_stream": { "aria_label": "Call transcript", - "supplement_label": "User hint", + "supplement_label": "Note", "takeover_label": "User (direct)", "callback_label": "Callback", "translate_button": "Translate" @@ -43,8 +43,8 @@ "answer": "Answer", "submit": "Submit", "answer_label": "Your answer", - "toast_title": "AI needs your input", - "timed_out_note": "You didn't answer in time; the AI told the merchant we'd call back." + "toast_title": "Input needed", + "timed_out_note": "Moved to callback." }, "hangup": { "button": "Hang up", @@ -55,21 +55,21 @@ "user_takeover": { "active": "I'm speaking", "inactive": "Take over", - "relay_hint": "Type in your language — the AI will speak it to the merchant in their language.", - "cross_lang_notice": "Cross-language takeover will arrive in v1.x. For now, type in the merchant's language." + "relay_hint": "Text will be relayed to the merchant.", + "cross_lang_notice": "For now, type in the merchant's language." }, "handover": { - "title": "Ready to hand the call to the AI", + "title": "Ready for handover", "step_pickup": "Pick up the iPhone", "step_speakerphone": "Enable speakerphone", "step_place_near_laptop": "Place it near the laptop", - "takeover_button": "AI takeover", + "takeover_button": "Handover", "checking_mic": "Checking microphone...", "disabled_tooltip": "Info changed, waiting for readiness", "mic_error": "Microphone unavailable. Allow browser microphone access before takeover." }, "readiness": { - "ready": "Info ready, AI can take over", + "ready": "Info ready", "waiting": "Waiting for key info", "missing": "Missing: {fields}" }, @@ -111,7 +111,7 @@ "permission_hint": "Allow microphone access to show real device names." }, "post_call_review": { - "title": "Call ended — please confirm what the AI told the merchant", + "title": "Call ended — review result", "empty_state": "Call completed; everything confirmed ✓", "back": "Back", "confirm_correct": "Confirm correct", diff --git a/frontend/messages/zh.json b/frontend/messages/zh.json index 2b21250..db0e56b 100644 --- a/frontend/messages/zh.json +++ b/frontend/messages/zh.json @@ -1,11 +1,11 @@ { "appName": "VocalizeAI", - "start": "开始预订", - "taskLabel": "你要 AI 帮你打什么电话?", + "start": "开始", + "taskLabel": "电话任务", "taskPlaceholder": "帮我订今晚 7 点 4 个人的位子", "submitTask": "提交任务", "create_session": { - "status": "正在创建会话...", + "status": "创建会话中...", "error": "创建会话失败:{message}", "retry": "重试" }, @@ -18,14 +18,14 @@ "recovered": "连接已恢复,进入通话回顾。" }, "ai_status": { - "filler_active": "AI 正在让对方稍等…", - "keepalive_active": "AI 正在告诉对方确认中…", - "escalation_warning": "对方表现不耐烦,AI 即将转入回拨流程" + "filler_active": "对方等待中…", + "keepalive_active": "确认中…", + "escalation_warning": "准备回拨" }, "supplement_input": { "send": "发送", "placeholder_preflight": "随时补充:例如改成两个人", - "placeholder_default": "提示 AI", + "placeholder_default": "补充条件", "placeholder_takeover": "我来说" }, "preflight_chat": { @@ -34,8 +34,8 @@ }, "transcript_stream": { "aria_label": "通话记录", - "supplement_label": "用户提示 AI", - "takeover_label": "用户(直传)", + "supplement_label": "补充", + "takeover_label": "直传", "callback_label": "回拨通话", "translate_button": "译" }, @@ -43,8 +43,8 @@ "answer": "回答", "submit": "提交", "answer_label": "你的回答", - "toast_title": "AI 需要你回答一个问题", - "timed_out_note": "你没及时回答,AI 已让对方稍候回拨。" + "toast_title": "需要补充信息", + "timed_out_note": "已转入回拨。" }, "hangup": { "button": "挂断", @@ -55,21 +55,21 @@ "user_takeover": { "active": "我来说", "inactive": "我来接话", - "relay_hint": "用你的语言输入,AI 会用商家的语言转述给对方。", - "cross_lang_notice": "跨语言接管将在 v1.x 提供。当前可换用商家语言输入。" + "relay_hint": "输入后会转述给对方。", + "cross_lang_notice": "当前请使用商家语言输入。" }, "handover": { - "title": "准备好把电话交给 AI 了", + "title": "可以交接通话", "step_pickup": "拿起 iPhone", "step_speakerphone": "打开扬声器", "step_place_near_laptop": "把它放在笔记本附近", - "takeover_button": "AI 接管", + "takeover_button": "交接", "checking_mic": "检查麦克风...", "disabled_tooltip": "信息已变,请等准备就绪", "mic_error": "麦克风不可用。请允许浏览器使用麦克风后再接管。" }, "readiness": { - "ready": "信息已足够,可以接管", + "ready": "信息已足够", "waiting": "等待关键信息", "missing": "缺少: {fields}" }, @@ -111,7 +111,7 @@ "permission_hint": "允许麦克风权限后可以显示真实设备名称。" }, "post_call_review": { - "title": "通话结束 · 请确认 AI 跟商家说的内容", + "title": "通话结束 · 核对结果", "empty_state": "通话顺利完成,所有信息均已确认 ✓", "back": "返回", "confirm_correct": "确认正确", diff --git a/frontend/middleware.ts b/frontend/middleware.ts deleted file mode 100644 index 7feed13..0000000 --- a/frontend/middleware.ts +++ /dev/null @@ -1,12 +0,0 @@ -import createMiddleware from "next-intl/middleware"; -import { DEFAULT_LOCALE, SUPPORTED_LOCALES } from "./i18n-config"; - -export default createMiddleware({ - locales: [...SUPPORTED_LOCALES], - defaultLocale: DEFAULT_LOCALE, - localePrefix: "always", -}); - -export const config = { - matcher: ["/((?!api|_next|.*\\..*).*)"], -}; diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts deleted file mode 100644 index 40c3d68..0000000 --- a/frontend/next-env.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -/// -/// - -// NOTE: This file should not be edited -// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs deleted file mode 100644 index 598584a..0000000 --- a/frontend/next.config.mjs +++ /dev/null @@ -1,3 +0,0 @@ -import createNextIntlPlugin from "next-intl/plugin"; -const withNextIntl = createNextIntlPlugin("./i18n.ts"); -export default withNextIntl({}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 421cb1d..19f60c7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,8 +9,6 @@ "version": "0.1.0", "dependencies": { "lucide-react": "^0.468.0", - "next": "^14.2.0", - "next-intl": "^3.26.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, @@ -23,10 +21,10 @@ "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "eslint": "^8.57.1", - "eslint-config-next": "^14.2.35", "jsdom": "^25.0.1", "knip": "^6.14.1", "typescript": "^5.7.2", + "vite": "^5.4.21", "vitest": "^2.1.8" } }, @@ -57,7 +55,6 @@ "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", @@ -73,7 +70,6 @@ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } @@ -88,6 +84,25 @@ "node": ">=6.9.0" } }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@csstools/color-helpers": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", @@ -176,6 +191,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -199,33 +215,11 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -691,66 +685,6 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/@formatjs/ecma402-abstract": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz", - "integrity": "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==", - "license": "MIT", - "dependencies": { - "@formatjs/fast-memoize": "2.2.7", - "@formatjs/intl-localematcher": "0.6.2", - "decimal.js": "^10.4.3", - "tslib": "^2.8.0" - } - }, - "node_modules/@formatjs/ecma402-abstract/node_modules/@formatjs/intl-localematcher": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.2.tgz", - "integrity": "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==", - "license": "MIT", - "dependencies": { - "tslib": "^2.8.0" - } - }, - "node_modules/@formatjs/fast-memoize": { - "version": "2.2.7", - "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.7.tgz", - "integrity": "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==", - "license": "MIT", - "dependencies": { - "tslib": "^2.8.0" - } - }, - "node_modules/@formatjs/icu-messageformat-parser": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.4.tgz", - "integrity": "sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==", - "license": "MIT", - "dependencies": { - "@formatjs/ecma402-abstract": "2.3.6", - "@formatjs/icu-skeleton-parser": "1.8.16", - "tslib": "^2.8.0" - } - }, - "node_modules/@formatjs/icu-skeleton-parser": { - "version": "1.8.16", - "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.16.tgz", - "integrity": "sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==", - "license": "MIT", - "dependencies": { - "@formatjs/ecma402-abstract": "2.3.6", - "tslib": "^2.8.0" - } - }, - "node_modules/@formatjs/intl-localematcher": { - "version": "0.5.10", - "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.5.10.tgz", - "integrity": "sha512-af3qATX+m4Rnd9+wHcjJ4w2ijq+rAVP3CCinJQvFv1kgSu1W6jypUmvleJxcewdxmutM8dmIRZFxO/IQBZmP2Q==", - "license": "MIT", - "dependencies": { - "tslib": "2" - } - }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -789,53 +723,6 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -843,179 +730,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.12", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", - "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@tybys/wasm-util": "^0.10.0" - } - }, - "node_modules/@next/env": { - "version": "14.2.35", - "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.35.tgz", - "integrity": "sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ==", - "license": "MIT" - }, - "node_modules/@next/eslint-plugin-next": { - "version": "14.2.35", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-14.2.35.tgz", - "integrity": "sha512-Jw9A3ICz2183qSsqwi7fgq4SBPiNfmOLmTPXKvlnzstUwyvBrtySiY+8RXJweNAs9KThb1+bYhZh9XWcNOr2zQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "glob": "10.3.10" - } - }, - "node_modules/@next/swc-darwin-arm64": { - "version": "14.2.33", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.33.tgz", - "integrity": "sha512-HqYnb6pxlsshoSTubdXKu15g3iivcbsMXg4bYpjL2iS/V6aQot+iyF4BUc2qA/J/n55YtvE4PHMKWBKGCF/+wA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-darwin-x64": { - "version": "14.2.33", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.33.tgz", - "integrity": "sha512-8HGBeAE5rX3jzKvF593XTTFg3gxeU4f+UWnswa6JPhzaR6+zblO5+fjltJWIZc4aUalqTclvN2QtTC37LxvZAA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-gnu": { - "version": "14.2.33", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.33.tgz", - "integrity": "sha512-JXMBka6lNNmqbkvcTtaX8Gu5by9547bukHQvPoLe9VRBx1gHwzf5tdt4AaezW85HAB3pikcvyqBToRTDA4DeLw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-musl": { - "version": "14.2.33", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.33.tgz", - "integrity": "sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-gnu": { - "version": "14.2.33", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.33.tgz", - "integrity": "sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-musl": { - "version": "14.2.33", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.33.tgz", - "integrity": "sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-arm64-msvc": { - "version": "14.2.33", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.33.tgz", - "integrity": "sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-ia32-msvc": { - "version": "14.2.33", - "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.33.tgz", - "integrity": "sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-x64-msvc": { - "version": "14.2.33", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.33.tgz", - "integrity": "sha512-nOjfZMy8B94MdisuzZo9/57xuFVLHJaDj5e/xrduJp9CV2/HrfxTRH2fbyLe+K9QT41WBLUd4iXX3R7jBp0EUg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1054,16 +768,6 @@ "node": ">= 8" } }, - "node_modules/@nolyfill/is-core-module": { - "version": "1.0.39", - "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", - "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.4.0" - } - }, "node_modules/@oxc-parser/binding-android-arm-eabi": { "version": "0.130.0", "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.130.0.tgz", @@ -1737,22 +1441,11 @@ "win32" ] }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, "node_modules/@playwright/test": { "version": "1.59.1", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "playwright": "1.59.1" @@ -2114,36 +1807,6 @@ "win32" ] }, - "node_modules/@rtsao/scc": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", - "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rushstack/eslint-patch": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.16.1.tgz", - "integrity": "sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==", - "dev": true, - "license": "MIT" - }, - "node_modules/@swc/counter": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", - "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", - "license": "Apache-2.0" - }, - "node_modules/@swc/helpers": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz", - "integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==", - "license": "Apache-2.0", - "dependencies": { - "@swc/counter": "^0.1.3", - "tslib": "^2.4.0" - } - }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -2250,8 +1913,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/estree": { "version": "1.0.8", @@ -2260,19 +1922,13 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/json5": { - "version": "0.0.29", - "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", - "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/node": { "version": "22.19.17", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2290,6 +1946,7 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -2301,2934 +1958,1038 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz", - "integrity": "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==", + "node_modules/@ungap/structured-clone": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", + "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.59.2", - "@typescript-eslint/type-utils": "8.59.2", - "@typescript-eslint/utils": "8.59.2", - "@typescript-eslint/visitor-keys": "8.59.2", - "ignore": "^7.0.5", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.59.2", - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" + "url": "https://opencollective.com/vitest" } }, - "node_modules/@typescript-eslint/parser": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.2.tgz", - "integrity": "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==", + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.59.2", - "@typescript-eslint/types": "8.59.2", - "@typescript-eslint/typescript-estree": "8.59.2", - "@typescript-eslint/visitor-keys": "8.59.2", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } } }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.2.tgz", - "integrity": "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==", + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.59.2", - "@typescript-eslint/types": "^8.59.2", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "tinyrainbow": "^1.2.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" + "url": "https://opencollective.com/vitest" } }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.2.tgz", - "integrity": "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==", + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.2", - "@typescript-eslint/visitor-keys": "8.59.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "url": "https://opencollective.com/vitest" } }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.2.tgz", - "integrity": "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==", + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", "dev": true, "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" + "url": "https://opencollective.com/vitest" } }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.2.tgz", - "integrity": "sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==", + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.2", - "@typescript-eslint/typescript-estree": "8.59.2", - "@typescript-eslint/utils": "8.59.2", - "debug": "^4.4.3", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "tinyspy": "^3.0.2" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "url": "https://opencollective.com/vitest" } }, - "node_modules/@typescript-eslint/types": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.2.tgz", - "integrity": "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==", + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", "dev": true, "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "url": "https://opencollective.com/vitest" } }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.2.tgz", - "integrity": "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==", + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.59.2", - "@typescript-eslint/tsconfig-utils": "8.59.2", - "@typescript-eslint/types": "8.59.2", - "@typescript-eslint/visitor-keys": "8.59.2", - "debug": "^4.4.3", - "minimatch": "^10.2.2", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.5.0" + "peer": true, + "bin": { + "acorn": "bin/acorn" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" + "node": ">=0.4.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "dev": true, "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, "engines": { - "node": "18 || 20 || >=22" + "node": ">= 14" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "MIT", "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, + "license": "MIT", "engines": { - "node": ">=10" + "node": ">=8" } }, - "node_modules/@typescript-eslint/utils": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.2.tgz", - "integrity": "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==", + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.59.2", - "@typescript-eslint/types": "8.59.2", - "@typescript-eslint/typescript-estree": "8.59.2" - }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=10" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.2.tgz", - "integrity": "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==", + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true, - "license": "MIT", + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@typescript-eslint/types": "8.59.2", - "eslint-visitor-keys": "^5.0.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "dequal": "^2.0.3" } }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">=12" } }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", - "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "dev": true, - "license": "ISC" + "license": "MIT" }, - "node_modules/@unrs/resolver-binding-android-arm-eabi": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", - "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", - "cpu": [ - "arm" - ], + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] + "license": "MIT" }, - "node_modules/@unrs/resolver-binding-android-arm64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", - "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", - "cpu": [ - "arm64" - ], + "node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ] + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } }, - "node_modules/@unrs/resolver-binding-darwin-arm64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", - "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", - "cpu": [ - "arm64" - ], + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] + "engines": { + "node": ">=8" + } }, - "node_modules/@unrs/resolver-binding-darwin-x64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", - "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", - "cpu": [ - "x64" - ], + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } }, - "node_modules/@unrs/resolver-binding-freebsd-x64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", - "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", - "cpu": [ - "x64" - ], + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", - "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", - "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", - "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", - "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", - "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", - "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", - "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", - "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-x64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", - "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-x64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", - "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-wasm32-wasi": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", - "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^0.2.11" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", - "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", - "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@unrs/resolver-binding-win32-x64-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", - "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@vitest/expect": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", - "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "2.1.9", - "@vitest/utils": "2.1.9", - "chai": "^5.1.2", - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", - "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "2.1.9", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.12" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", - "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", - "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "2.1.9", - "pathe": "^1.1.2" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", - "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "2.1.9", - "magic-string": "^0.30.12", - "pathe": "^1.1.2" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", - "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyspy": "^3.0.2" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", - "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "2.1.9", - "loupe": "^3.1.2", - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/array-buffer-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", - "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "is-array-buffer": "^3.0.5" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array-includes": { - "version": "3.1.9", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", - "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.24.0", - "es-object-atoms": "^1.1.1", - "get-intrinsic": "^1.3.0", - "is-string": "^1.1.1", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.findlast": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", - "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.findlastindex": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", - "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.9", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "es-shim-unscopables": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.flat": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", - "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.flatmap": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", - "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.tosorted": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", - "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.3", - "es-errors": "^1.3.0", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", - "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.1", - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "is-array-buffer": "^3.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/ast-types-flow": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", - "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/async-function": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", - "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/axe-core": { - "version": "4.11.4", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.4.tgz", - "integrity": "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==", - "dev": true, - "license": "MPL-2.0", - "engines": { - "node": ">=4" - } - }, - "node_modules/axobject-query": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", - "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/busboy": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", - "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", - "dependencies": { - "streamsearch": "^1.1.0" - }, - "engines": { - "node": ">=10.16.0" - } - }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/call-bind": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", - "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "get-intrinsic": "^1.3.0", - "set-function-length": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001791", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", - "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chalk/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, - "node_modules/client-only": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", - "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", - "license": "MIT" - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/css.escape": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", - "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cssstyle": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", - "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/css-color": "^3.2.0", - "rrweb-cssom": "^0.8.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/cssstyle/node_modules/rrweb-cssom": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", - "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", - "dev": true, - "license": "MIT" - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/damerau-levenshtein": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", - "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/data-urls": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", - "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/data-view-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", - "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/data-view-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", - "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/inspect-js" - } - }, - "node_modules/data-view-byte-offset": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", - "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decimal.js": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "license": "MIT" - }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/doctrine": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/dom-accessibility-api": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/es-abstract": { - "version": "1.24.2", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", - "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.2", - "arraybuffer.prototype.slice": "^1.0.4", - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "data-view-buffer": "^1.0.2", - "data-view-byte-length": "^1.0.2", - "data-view-byte-offset": "^1.0.1", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "es-set-tostringtag": "^2.1.0", - "es-to-primitive": "^1.3.0", - "function.prototype.name": "^1.1.8", - "get-intrinsic": "^1.3.0", - "get-proto": "^1.0.1", - "get-symbol-description": "^1.1.0", - "globalthis": "^1.0.4", - "gopd": "^1.2.0", - "has-property-descriptors": "^1.0.2", - "has-proto": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "internal-slot": "^1.1.0", - "is-array-buffer": "^3.0.5", - "is-callable": "^1.2.7", - "is-data-view": "^1.0.2", - "is-negative-zero": "^2.0.3", - "is-regex": "^1.2.1", - "is-set": "^2.0.3", - "is-shared-array-buffer": "^1.0.4", - "is-string": "^1.1.1", - "is-typed-array": "^1.1.15", - "is-weakref": "^1.1.1", - "math-intrinsics": "^1.1.0", - "object-inspect": "^1.13.4", - "object-keys": "^1.1.1", - "object.assign": "^4.1.7", - "own-keys": "^1.0.1", - "regexp.prototype.flags": "^1.5.4", - "safe-array-concat": "^1.1.3", - "safe-push-apply": "^1.0.0", - "safe-regex-test": "^1.1.0", - "set-proto": "^1.0.0", - "stop-iteration-iterator": "^1.1.0", - "string.prototype.trim": "^1.2.10", - "string.prototype.trimend": "^1.0.9", - "string.prototype.trimstart": "^1.0.8", - "typed-array-buffer": "^1.0.3", - "typed-array-byte-length": "^1.0.3", - "typed-array-byte-offset": "^1.0.4", - "typed-array-length": "^1.0.7", - "unbox-primitive": "^1.1.0", - "which-typed-array": "^1.1.19" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-iterator-helpers": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.2.tgz", - "integrity": "sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.9", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.24.2", - "es-errors": "^1.3.0", - "es-set-tostringtag": "^2.1.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.3.0", - "globalthis": "^1.0.4", - "gopd": "^1.2.0", - "has-property-descriptors": "^1.0.2", - "has-proto": "^1.2.0", - "has-symbols": "^1.1.0", - "internal-slot": "^1.1.0", - "iterator.prototype": "^1.1.5", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-shim-unscopables": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", - "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-to-primitive": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", - "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-callable": "^1.2.7", - "is-date-object": "^1.0.5", - "is-symbol": "^1.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", - "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", - "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.1", - "@humanwhocodes/config-array": "^0.13.0", - "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "@ungap/structured-clone": "^1.2.0", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", - "debug": "^4.3.2", - "doctrine": "^3.0.0", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "esquery": "^1.4.2", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3", - "strip-ansi": "^6.0.1", - "text-table": "^0.2.0" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-config-next": { - "version": "14.2.35", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-14.2.35.tgz", - "integrity": "sha512-BpLsv01UisH193WyT/1lpHqq5iJ/Orfz9h/NOOlAmTUq4GY349PextQ62K4XpnaM9supeiEn3TaOTeQO07gURg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@next/eslint-plugin-next": "14.2.35", - "@rushstack/eslint-patch": "^1.3.3", - "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", - "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", - "eslint-import-resolver-node": "^0.3.6", - "eslint-import-resolver-typescript": "^3.5.2", - "eslint-plugin-import": "^2.28.1", - "eslint-plugin-jsx-a11y": "^6.7.1", - "eslint-plugin-react": "^7.33.2", - "eslint-plugin-react-hooks": "^4.5.0 || 5.0.0-canary-7118f5dd7-20230705" - }, - "peerDependencies": { - "eslint": "^7.23.0 || ^8.0.0", - "typescript": ">=3.3.1" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/eslint-import-resolver-node": { - "version": "0.3.10", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", - "integrity": "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^3.2.7", - "is-core-module": "^2.16.1", - "resolve": "^2.0.0-next.6" - } - }, - "node_modules/eslint-import-resolver-node/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-import-resolver-typescript": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", - "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "@nolyfill/is-core-module": "1.0.39", - "debug": "^4.4.0", - "get-tsconfig": "^4.10.0", - "is-bun-module": "^2.0.0", - "stable-hash": "^0.0.5", - "tinyglobby": "^0.2.13", - "unrs-resolver": "^1.6.2" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint-import-resolver-typescript" - }, - "peerDependencies": { - "eslint": "*", - "eslint-plugin-import": "*", - "eslint-plugin-import-x": "*" - }, - "peerDependenciesMeta": { - "eslint-plugin-import": { - "optional": true - }, - "eslint-plugin-import-x": { - "optional": true - } - } - }, - "node_modules/eslint-module-utils": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", - "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^3.2.7" - }, - "engines": { - "node": ">=4" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } - } - }, - "node_modules/eslint-module-utils/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-plugin-import": { - "version": "2.32.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", - "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rtsao/scc": "^1.1.0", - "array-includes": "^3.1.9", - "array.prototype.findlastindex": "^1.2.6", - "array.prototype.flat": "^1.3.3", - "array.prototype.flatmap": "^1.3.3", - "debug": "^3.2.7", - "doctrine": "^2.1.0", - "eslint-import-resolver-node": "^0.3.9", - "eslint-module-utils": "^2.12.1", - "hasown": "^2.0.2", - "is-core-module": "^2.16.1", - "is-glob": "^4.0.3", - "minimatch": "^3.1.2", - "object.fromentries": "^2.0.8", - "object.groupby": "^1.0.3", - "object.values": "^1.2.1", - "semver": "^6.3.1", - "string.prototype.trimend": "^1.0.9", - "tsconfig-paths": "^3.15.0" - }, "engines": { - "node": ">=4" - }, - "peerDependencies": { - "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" - } - }, - "node_modules/eslint-plugin-import/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" + "node": ">=6" } }, - "node_modules/eslint-plugin-jsx-a11y": { - "version": "6.10.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", - "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", "dev": true, "license": "MIT", "dependencies": { - "aria-query": "^5.3.2", - "array-includes": "^3.1.8", - "array.prototype.flatmap": "^1.3.2", - "ast-types-flow": "^0.0.8", - "axe-core": "^4.10.0", - "axobject-query": "^4.1.0", - "damerau-levenshtein": "^1.0.8", - "emoji-regex": "^9.2.2", - "hasown": "^2.0.2", - "jsx-ast-utils": "^3.3.5", - "language-tags": "^1.0.9", - "minimatch": "^3.1.2", - "object.fromentries": "^2.0.8", - "safe-regex-test": "^1.0.3", - "string.prototype.includes": "^2.0.1" - }, - "engines": { - "node": ">=4.0" + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" }, - "peerDependencies": { - "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" - } - }, - "node_modules/eslint-plugin-jsx-a11y/node_modules/aria-query": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", - "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", - "dev": true, - "license": "Apache-2.0", "engines": { - "node": ">= 0.4" + "node": ">=18" } }, - "node_modules/eslint-plugin-react": { - "version": "7.37.5", - "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", - "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "license": "MIT", "dependencies": { - "array-includes": "^3.1.8", - "array.prototype.findlast": "^1.2.5", - "array.prototype.flatmap": "^1.3.3", - "array.prototype.tosorted": "^1.1.4", - "doctrine": "^2.1.0", - "es-iterator-helpers": "^1.2.1", - "estraverse": "^5.3.0", - "hasown": "^2.0.2", - "jsx-ast-utils": "^2.4.1 || ^3.0.0", - "minimatch": "^3.1.2", - "object.entries": "^1.1.9", - "object.fromentries": "^2.0.8", - "object.values": "^1.2.1", - "prop-types": "^15.8.1", - "resolve": "^2.0.0-next.5", - "semver": "^6.3.1", - "string.prototype.matchall": "^4.0.12", - "string.prototype.repeat": "^1.0.0" - }, - "engines": { - "node": ">=4" + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" }, - "peerDependencies": { - "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" - } - }, - "node_modules/eslint-plugin-react-hooks": { - "version": "5.0.0-canary-7118f5dd7-20230705", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.0.0-canary-7118f5dd7-20230705.tgz", - "integrity": "sha512-AZYbMo/NW9chdL7vk6HQzQhT+PvTAEVqWk9ziruUoW2kAOcN5qNyelv70e0F1VNQAbvutOC9oc+xfWycI9FxDw==", - "dev": true, - "license": "MIT", "engines": { "node": ">=10" }, - "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" - } - }, - "node_modules/eslint-scope": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=6.0.0" + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/espree": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "node_modules/chalk/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "acorn": "^8.9.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" + "color-convert": "^2.0.1" }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": ">=8" }, "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/fd-package-json": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fd-package-json/-/fd-package-json-2.0.0.tgz", - "integrity": "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "walk-up-path": "^4.0.0" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", "dev": true, "license": "MIT", "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } + "node": ">= 16" } - }, - "node_modules/file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, "license": "MIT", "dependencies": { - "flat-cache": "^3.0.4" + "color-name": "~1.1.4" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": ">=7.0.0" } }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "dev": true, "license": "MIT", "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" + "delayed-stream": "~1.0.0" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 0.8" } }, - "node_modules/flat-cache": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", - "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "license": "MIT", "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.3", - "rimraf": "^3.0.2" + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": ">= 8" } }, - "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", "dev": true, - "license": "ISC" + "license": "MIT" }, - "node_modules/for-each": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", "dev": true, "license": "MIT", "dependencies": { - "is-callable": "^1.2.7" + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=18" } }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", "dev": true, - "license": "ISC", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" }, "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=18" } }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "license": "MIT", "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "ms": "^2.1.3" }, "engines": { - "node": ">= 6" + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/formatly": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/formatly/-/formatly-0.3.0.tgz", - "integrity": "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==", + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", "dev": true, "license": "MIT", - "dependencies": { - "fd-package-json": "^2.0.0" - }, - "bin": { - "formatly": "bin/index.mjs" - }, "engines": { - "node": ">=18.3.0" + "node": ">=6" } }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true, - "license": "ISC" + "license": "MIT" }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">=0.4.0" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "dev": true, "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": ">=6" } }, - "node_modules/function.prototype.name": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", - "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "functions-have-names": "^1.2.3", - "hasown": "^2.0.2", - "is-callable": "^1.2.7" + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" }, "engines": { "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/functions-have-names": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/generator-function": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", - "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" } }, - "node_modules/get-intrinsic": { + "node_modules/es-errors": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "dev": true, "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, "engines": { "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "dev": true, "license": "MIT", "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" + "es-errors": "^1.3.0" }, "engines": { "node": ">= 0.4" } }, - "node_modules/get-symbol-description": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", - "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6" + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" }, "engines": { "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-tsconfig": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", - "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", "dev": true, + "hasInstallScript": true, "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, - "node_modules/glob": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz", - "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^2.3.5", - "minimatch": "^9.0.1", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", - "path-scurry": "^1.10.1" - }, "bin": { - "glob": "dist/esm/bin.mjs" + "esbuild": "bin/esbuild" }, "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" + "node": ">=12" }, - "engines": { - "node": ">=10.13.0" + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" } }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "type-fest": "^0.20.2" + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" }, "engines": { - "node": ">=8" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/eslint" } }, - "node_modules/globalthis": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", - "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", "dependencies": { - "define-properties": "^1.2.1", - "gopd": "^1.0.1" + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" }, "engines": { - "node": ">= 0.4" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/eslint" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "engines": { - "node": ">= 0.4" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/eslint" } }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "license": "ISC" - }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, - "node_modules/has-bigints": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", - "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "node_modules/eslint/node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": ">=6.0.0" } }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, "engines": { - "node": ">=8" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", "dependencies": { - "es-define-property": "^1.0.0" + "estraverse": "^5.1.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": ">=0.10" } }, - "node_modules/has-proto": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", - "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", "dependencies": { - "dunder-proto": "^1.0.0" + "estraverse": "^5.2.0" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=4.0" } }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=4.0" } }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, "license": "MIT", "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "@types/estree": "^1.0.0" } }, - "node_modules/hasown": { + "node_modules/esutils": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, + "license": "BSD-2-Clause", "engines": { - "node": ">= 0.4" + "node": ">=0.10.0" } }, - "node_modules/html-encoding-sniffer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", - "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-encoding": "^3.1.1" - }, + "license": "Apache-2.0", "engines": { - "node": ">=18" + "node": ">=12.0.0" } }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true, - "license": "MIT", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" + "reusify": "^1.0.4" } }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "node_modules/fd-package-json": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fd-package-json/-/fd-package-json-2.0.0.tgz", + "integrity": "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==", "dev": true, "license": "MIT", "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" + "walk-up-path": "^4.0.0" } }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, "engines": { - "node": ">=0.10.0" + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", "dev": true, "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, "engines": { - "node": ">= 4" + "node": "^10.12.0 || >=12.0.0" } }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, "license": "MIT", "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" }, "engines": { - "node": ">=6" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", "dev": true, "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "license": "ISC", "dependencies": { - "once": "^1.3.0", - "wrappy": "1" + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" } }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, - "node_modules/internal-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", - "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "dev": true, "license": "MIT", "dependencies": { - "es-errors": "^1.3.0", + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", - "side-channel": "^1.1.0" + "mime-types": "^2.1.12" }, "engines": { - "node": ">= 0.4" - } - }, - "node_modules/intl-messageformat": { - "version": "10.7.18", - "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.18.tgz", - "integrity": "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==", - "license": "BSD-3-Clause", - "dependencies": { - "@formatjs/ecma402-abstract": "2.3.6", - "@formatjs/fast-memoize": "2.2.7", - "@formatjs/icu-messageformat-parser": "2.11.4", - "tslib": "^2.8.0" + "node": ">= 6" } }, - "node_modules/is-array-buffer": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", - "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "node_modules/formatly": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/formatly/-/formatly-0.3.0.tgz", + "integrity": "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" + "fd-package-json": "^2.0.0" }, - "engines": { - "node": ">= 0.4" + "bin": { + "formatly": "bin/index.mjs" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": ">=18.3.0" } }, - "node_modules/is-async-function": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", - "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, "license": "MIT", - "dependencies": { - "async-function": "^1.0.0", - "call-bound": "^1.0.3", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" - }, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/is-bigint": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", - "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "dev": true, - "license": "MIT", - "dependencies": { - "has-bigints": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, + "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-boolean-object": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", - "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -5237,86 +2998,68 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-bun-module": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", - "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "dev": true, "license": "MIT", "dependencies": { - "semver": "^7.7.1" - } - }, - "node_modules/is-bun-module/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" }, "engines": { - "node": ">=10" + "node": ">= 0.4" } }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.4" + "dependencies": { + "resolve-pkg-maps": "^1.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, - "node_modules/is-core-module": { - "version": "2.16.2", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", - "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "hasown": "^2.0.3" + "is-glob": "^4.0.3" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=10.13.0" } }, - "node_modules/is-data-view": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", - "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", - "is-typed-array": "^1.1.13" + "type-fest": "^0.20.2" }, "engines": { - "node": ">= 0.4" + "node": ">=8" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-date-object": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", - "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "dev": true, "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, "engines": { "node": ">= 0.4" }, @@ -5324,25 +3067,29 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=8" } }, - "node_modules/is-finalizationregistry": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", - "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "dev": true, "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, "engines": { "node": ">= 0.4" }, @@ -5350,28 +3097,14 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-generator-function": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", - "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.4", - "generator-function": "^2.0.0", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" + "has-symbols": "^1.0.3" }, "engines": { "node": ">= 0.4" @@ -5380,228 +3113,176 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", "dev": true, "license": "MIT", "dependencies": { - "is-extglob": "^2.1.1" + "function-bind": "^1.1.2" }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-map": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", - "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", - "dev": true, - "license": "MIT", "engines": { "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-negative-zero": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", - "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.4" + "dependencies": { + "whatwg-encoding": "^3.1.1" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": ">=18" } }, - "node_modules/is-number-object": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", - "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" + "agent-base": "^7.1.0", + "debug": "^4.3.4" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 14" } }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "dev": true, "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, "engines": { - "node": ">=8" + "node": ">= 14" } }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" + "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=0.10.0" } }, - "node_modules/is-set": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", - "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 4" } }, - "node_modules/is-shared-array-buffer": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", - "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.3" + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" }, "engines": { - "node": ">= 0.4" + "node": ">=6" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-string": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", - "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=0.8.19" } }, - "node_modules/is-symbol": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", - "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", "dev": true, "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-symbols": "^1.1.0", - "safe-regex-test": "^1.1.0" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=8" } }, - "node_modules/is-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", - "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "once": "^1.3.0", + "wrappy": "1" } }, - "node_modules/is-weakmap": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", - "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=0.10.0" } }, - "node_modules/is-weakref": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", - "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.3" + "is-extglob": "^2.1.1" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=0.10.0" } }, - "node_modules/is-weakset": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", - "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", "dev": true, "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=8" } }, - "node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", "dev": true, "license": "MIT" }, @@ -5612,43 +3293,6 @@ "dev": true, "license": "ISC" }, - "node_modules/iterator.prototype": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", - "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.6", - "get-proto": "^1.0.0", - "has-symbols": "^1.1.0", - "set-function-name": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/jackspeak": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz", - "integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -5684,6 +3328,7 @@ "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssstyle": "^4.1.0", "data-urls": "^5.0.0", @@ -5740,22 +3385,6 @@ "dev": true, "license": "MIT" }, - "node_modules/jsx-ast-utils": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", - "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-includes": "^3.1.6", - "array.prototype.flat": "^1.3.1", - "object.assign": "^4.1.4", - "object.values": "^1.1.6" - }, - "engines": { - "node": ">=4.0" - } - }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5819,26 +3448,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/language-subtag-registry": { - "version": "0.3.23", - "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", - "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/language-tags": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", - "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", - "dev": true, - "license": "MIT", - "dependencies": { - "language-subtag-registry": "^0.3.20" - }, - "engines": { - "node": ">=0.10" - } - }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -5917,374 +3526,126 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/min-indent": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", - "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/napi-postinstall": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", - "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", - "dev": true, - "license": "MIT", - "bin": { - "napi-postinstall": "lib/cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/napi-postinstall" - } - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/next": { - "version": "14.2.35", - "resolved": "https://registry.npmjs.org/next/-/next-14.2.35.tgz", - "integrity": "sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig==", - "license": "MIT", - "dependencies": { - "@next/env": "14.2.35", - "@swc/helpers": "0.5.5", - "busboy": "1.6.0", - "caniuse-lite": "^1.0.30001579", - "graceful-fs": "^4.2.11", - "postcss": "8.4.31", - "styled-jsx": "5.1.1" - }, - "bin": { - "next": "dist/bin/next" - }, - "engines": { - "node": ">=18.17.0" - }, - "optionalDependencies": { - "@next/swc-darwin-arm64": "14.2.33", - "@next/swc-darwin-x64": "14.2.33", - "@next/swc-linux-arm64-gnu": "14.2.33", - "@next/swc-linux-arm64-musl": "14.2.33", - "@next/swc-linux-x64-gnu": "14.2.33", - "@next/swc-linux-x64-musl": "14.2.33", - "@next/swc-win32-arm64-msvc": "14.2.33", - "@next/swc-win32-ia32-msvc": "14.2.33", - "@next/swc-win32-x64-msvc": "14.2.33" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.1.0", - "@playwright/test": "^1.41.2", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "sass": "^1.3.0" - }, - "peerDependenciesMeta": { - "@opentelemetry/api": { - "optional": true - }, - "@playwright/test": { - "optional": true - }, - "sass": { - "optional": true - } - } - }, - "node_modules/next-intl": { - "version": "3.26.5", - "resolved": "https://registry.npmjs.org/next-intl/-/next-intl-3.26.5.tgz", - "integrity": "sha512-EQlCIfY0jOhRldiFxwSXG+ImwkQtDEfQeSOEQp6ieAGSLWGlgjdb/Ck/O7wMfC430ZHGeUKVKax8KGusTPKCgg==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/amannn" - } - ], - "license": "MIT", - "dependencies": { - "@formatjs/intl-localematcher": "^0.5.4", - "negotiator": "^1.0.0", - "use-intl": "^3.26.5" - }, - "peerDependencies": { - "next": "^10.0.0 || ^11.0.0 || ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" - } - }, - "node_modules/node-exports-info": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", - "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "array.prototype.flatmap": "^1.3.3", - "es-errors": "^1.3.0", - "object.entries": "^1.1.9", - "semver": "^6.3.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/nwsapi": { - "version": "2.2.23", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", - "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", - "engines": { - "node": ">=0.10.0" + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.4" + "node": ">= 0.6" } }, - "node_modules/object.assign": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", - "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0", - "has-symbols": "^1.1.0", - "object-keys": "^1.1.1" + "mime-db": "1.52.0" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 0.6" } }, - "node_modules/object.entries": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", - "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", "dev": true, "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.1.1" - }, "engines": { - "node": ">= 0.4" + "node": ">=4" } }, - "node_modules/object.fromentries": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", - "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0" + "brace-expansion": "^1.1.7" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": "*" } }, - "node_modules/object.groupby": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", - "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "dev": true, "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2" - }, - "engines": { - "node": ">= 0.4" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/object.values": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", - "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" + "bin": { + "nanoid": "bin/nanoid.cjs" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -6313,24 +3674,6 @@ "node": ">= 0.8.0" } }, - "node_modules/own-keys": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", - "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.2.6", - "object-keys": "^1.1.1", - "safe-push-apply": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/oxc-parser": { "version": "0.130.0", "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.130.0.tgz", @@ -6489,30 +3832,6 @@ "node": ">=8" } }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, - "license": "MIT" - }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/pathe": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", @@ -6534,6 +3853,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -6542,6 +3862,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -6553,7 +3874,7 @@ "version": "1.59.1", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "playwright-core": "1.59.1" @@ -6572,7 +3893,7 @@ "version": "1.59.1", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" @@ -6581,44 +3902,6 @@ "node": ">=18" } }, - "node_modules/possible-typed-array-names": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", - "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/postcss": { - "version": "8.4.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", - "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.6", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -6635,7 +3918,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -6645,25 +3927,6 @@ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, - "license": "MIT", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, - "node_modules/prop-types/node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, - "license": "MIT" - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -6700,6 +3963,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -6712,6 +3976,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -6725,8 +3990,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redent": { "version": "3.0.0", @@ -6742,74 +4006,6 @@ "node": ">=8" } }, - "node_modules/reflect.getprototypeof": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", - "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.9", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.7", - "get-proto": "^1.0.1", - "which-builtin-type": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/regexp.prototype.flags": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", - "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-errors": "^1.3.0", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "set-function-name": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve": { - "version": "2.0.0-next.6", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", - "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "is-core-module": "^2.16.1", - "node-exports-info": "^1.6.0", - "object-keys": "^1.1.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -6944,71 +4140,16 @@ }, { "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/safe-array-concat": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", - "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.9", - "call-bound": "^1.0.4", - "get-intrinsic": "^1.3.0", - "has-symbols": "^1.1.0", - "isarray": "^2.0.5" - }, - "engines": { - "node": ">=0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safe-push-apply": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", - "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "isarray": "^2.0.5" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safe-regex-test": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", - "dev": true, + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-regex": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "queue-microtask": "^1.2.2" } }, "node_modules/safer-buffer": { @@ -7040,65 +4181,6 @@ "loose-envify": "^1.1.0" } }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-function-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", - "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "functions-have-names": "^1.2.3", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-proto": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", - "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -7122,82 +4204,6 @@ "node": ">=8" } }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", - "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -7205,19 +4211,6 @@ "dev": true, "license": "ISC" }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/smol-toml": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz", @@ -7235,18 +4228,12 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } }, - "node_modules/stable-hash": { - "version": "0.0.5", - "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", - "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", - "dev": true, - "license": "MIT" - }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -7257,230 +4244,11 @@ "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" - }, - "node_modules/stop-iteration-iterator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", - "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "internal-slot": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/streamsearch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", - "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/string-width/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/string-width/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/string.prototype.includes": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", - "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.3" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/string.prototype.matchall": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", - "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.6", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.6", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "internal-slot": "^1.1.0", - "regexp.prototype.flags": "^1.5.3", - "set-function-name": "^2.0.2", - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.repeat": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", - "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-properties": "^1.1.3", - "es-abstract": "^1.17.5" - } - }, - "node_modules/string.prototype.trim": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", - "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "define-data-property": "^1.1.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-object-atoms": "^1.0.0", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimend": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", - "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimstart": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", - "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", + "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", @@ -7493,16 +4261,6 @@ "node": ">=8" } }, - "node_modules/strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -7529,29 +4287,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/styled-jsx": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz", - "integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==", - "license": "MIT", - "dependencies": { - "client-only": "0.0.1" - }, - "engines": { - "node": ">= 12.0.0" - }, - "peerDependencies": { - "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0" - }, - "peerDependenciesMeta": { - "@babel/core": { - "optional": true - }, - "babel-plugin-macros": { - "optional": true - } - } - }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -7565,19 +4300,6 @@ "node": ">=8" } }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -7699,50 +4421,13 @@ "node": ">=18" } }, - "node_modules/ts-api-utils": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", - "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, - "node_modules/tsconfig-paths": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", - "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/json5": "^0.0.29", - "json5": "^1.0.2", - "minimist": "^1.2.6", - "strip-bom": "^3.0.0" - } - }, - "node_modules/tsconfig-paths/node_modules/json5": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", - "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimist": "^1.2.0" - }, - "bin": { - "json5": "lib/cli.js" - } - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" + "dev": true, + "license": "0BSD", + "optional": true }, "node_modules/type-check": { "version": "0.4.0", @@ -7770,84 +4455,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/typed-array-buffer": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", - "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-typed-array": "^1.1.14" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/typed-array-byte-length": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", - "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "for-each": "^0.3.3", - "gopd": "^1.2.0", - "has-proto": "^1.2.0", - "is-typed-array": "^1.1.14" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typed-array-byte-offset": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", - "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "for-each": "^0.3.3", - "gopd": "^1.2.0", - "has-proto": "^1.2.0", - "is-typed-array": "^1.1.15", - "reflect.getprototypeof": "^1.0.9" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typed-array-length": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", - "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "is-typed-array": "^1.1.13", - "possible-typed-array-names": "^1.0.0", - "reflect.getprototypeof": "^1.0.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -7872,25 +4479,6 @@ "node": ">=14" } }, - "node_modules/unbox-primitive": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", - "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-bigints": "^1.0.2", - "has-symbols": "^1.1.0", - "which-boxed-primitive": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -7898,41 +4486,6 @@ "dev": true, "license": "MIT" }, - "node_modules/unrs-resolver": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", - "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "napi-postinstall": "^0.3.0" - }, - "funding": { - "url": "https://opencollective.com/unrs-resolver" - }, - "optionalDependencies": { - "@unrs/resolver-binding-android-arm-eabi": "1.11.1", - "@unrs/resolver-binding-android-arm64": "1.11.1", - "@unrs/resolver-binding-darwin-arm64": "1.11.1", - "@unrs/resolver-binding-darwin-x64": "1.11.1", - "@unrs/resolver-binding-freebsd-x64": "1.11.1", - "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", - "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", - "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", - "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", - "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", - "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-x64-musl": "1.11.1", - "@unrs/resolver-binding-wasm32-wasi": "1.11.1", - "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", - "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", - "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" - } - }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -7943,25 +4496,13 @@ "punycode": "^2.1.0" } }, - "node_modules/use-intl": { - "version": "3.26.5", - "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-3.26.5.tgz", - "integrity": "sha512-OdsJnC/znPvHCHLQH/duvQNXnP1w0hPfS+tkSi3mAbfjYBGh4JnyfdwkQBfIVf7t8gs9eSX/CntxUMvtKdG2MQ==", - "license": "MIT", - "dependencies": { - "@formatjs/fast-memoize": "^2.2.0", - "intl-messageformat": "^10.5.14" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" - } - }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -8236,95 +4777,6 @@ "node": ">= 8" } }, - "node_modules/which-boxed-primitive": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", - "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-bigint": "^1.1.0", - "is-boolean-object": "^1.2.1", - "is-number-object": "^1.1.1", - "is-string": "^1.1.1", - "is-symbol": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-builtin-type": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", - "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "function.prototype.name": "^1.1.6", - "has-tostringtag": "^1.0.2", - "is-async-function": "^2.0.0", - "is-date-object": "^1.1.0", - "is-finalizationregistry": "^1.1.0", - "is-generator-function": "^1.0.10", - "is-regex": "^1.2.1", - "is-weakref": "^1.0.2", - "isarray": "^2.0.5", - "which-boxed-primitive": "^1.1.0", - "which-collection": "^1.0.2", - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-collection": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", - "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-map": "^2.0.3", - "is-set": "^2.0.3", - "is-weakmap": "^2.0.2", - "is-weakset": "^2.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-typed-array": { - "version": "1.1.20", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", - "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", - "dev": true, - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "for-each": "^0.3.5", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -8352,123 +4804,6 @@ "node": ">=0.10.0" } }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 44c5e18..7c9624c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,18 +4,17 @@ "version": "0.1.0", "type": "module", "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start", - "lint": "next lint", + "dev": "vite", + "build": "tsc --noEmit && vite build", + "start": "vite --host 127.0.0.1", + "preview": "vite preview", + "lint": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", "test:integration": "playwright test -c playwright.config.ts" }, "dependencies": { "lucide-react": "^0.468.0", - "next": "^14.2.0", - "next-intl": "^3.26.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, @@ -28,10 +27,10 @@ "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "eslint": "^8.57.1", - "eslint-config-next": "^14.2.35", "jsdom": "^25.0.1", "knip": "^6.14.1", "typescript": "^5.7.2", + "vite": "^5.4.21", "vitest": "^2.1.8" } } diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index ec99269..fd58b6d 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -30,7 +30,7 @@ export default defineConfig({ timeout: 30_000 }, { - command: "NEXT_PUBLIC_VOCALIZE_API_BASE_URL=http://127.0.0.1:8000 NEXT_PUBLIC_E2E_AUDIO_HOOK=1 npm run dev -- --hostname localhost --port 3000", + command: "VITE_VOCALIZE_API_BASE_URL=http://127.0.0.1:8000 VITE_E2E_AUDIO_HOOK=1 npm run dev -- --host localhost --port 3000", url: "http://localhost:3000", reuseExistingServer: !process.env.CI, timeout: 30_000 diff --git a/frontend/playwright.release-audio.config.ts b/frontend/playwright.release-audio.config.ts index f986afc..7d2bede 100644 --- a/frontend/playwright.release-audio.config.ts +++ b/frontend/playwright.release-audio.config.ts @@ -14,7 +14,7 @@ process.env.NODE_PATH = [ const backendURL = process.env.VOCALIZE_RELEASE_AUDIO_BACKEND_URL ?? - process.env.NEXT_PUBLIC_VOCALIZE_API_BASE_URL; + process.env.VITE_VOCALIZE_API_BASE_URL; const frontendURL = process.env.VOCALIZE_RELEASE_AUDIO_FRONTEND_URL ?? "http://localhost:3000"; @@ -40,14 +40,14 @@ export default defineConfig({ ], webServer: [ { - command: "npm run dev -- --hostname localhost --port 3000", + command: "npm run dev -- --host localhost --port 3000", url: frontendURL, reuseExistingServer: false, timeout: 60_000, env: { ...process.env, - NEXT_PUBLIC_VOCALIZE_API_BASE_URL: backendURL, - NEXT_PUBLIC_E2E_AUDIO_HOOK: "1", + VITE_VOCALIZE_API_BASE_URL: backendURL, + VITE_E2E_AUDIO_HOOK: "1", }, }, ], diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..fb41f3e --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..30e6f77 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,147 @@ +import React from "react"; +import { Eye, Play, Settings2, Stethoscope } from "lucide-react"; + +import { CreateSessionClient } from "../app/[locale]/new/CreateSessionClient"; +import { LivePageClient } from "../app/[locale]/live/[session]/LivePageClient"; +import { ReviewPageClient } from "../app/[locale]/review/[session]/ReviewPageClient"; +import { LanguageToggle } from "../components/LanguageToggle"; +import { I18nProvider } from "./i18n"; +import { PreviewConsole } from "./PreviewConsole"; +import { usePathname } from "./router"; + +type Locale = "zh" | "en"; + +interface RouteState { + locale: Locale; + view: "home" | "new" | "live" | "review" | "preview"; + sessionId?: string; +} + +export function App() { + const pathname = usePathname(); + const route = parseRoute(pathname); + + return ( + + {route.view === "new" ? : null} + {route.view === "live" && route.sessionId ? ( + + ) : null} + {route.view === "review" && route.sessionId ? ( + + ) : null} + {route.view === "preview" ? : null} + {route.view === "home" ? : null} + + ); +} + +function HomeConsole({ locale }: { locale: Locale }) { + const startHref = `/${locale}/new`; + const previewHref = `/${locale}/preview`; + const copy = homeCopy(locale); + return ( +
+
+ + V + + VocalizeAI + {copy.subtitle} + + + {copy.status} + +
+
+
+

{copy.title}

+
+ {copy.taskHint} + + + {copy.start} + +
+
+ +
+
+ ); +} + +function parseRoute(pathname: string): RouteState { + const parts = pathname.split("/").filter(Boolean); + const locale: Locale = parts[0] === "en" ? "en" : "zh"; + const view = parts[1]; + if (view === "new") { + return { locale, view: "new" }; + } + if (view === "live" && parts[2]) { + return { locale, view: "live", sessionId: decodeURIComponent(parts[2]) }; + } + if (view === "review" && parts[2]) { + return { locale, view: "review", sessionId: decodeURIComponent(parts[2]) }; + } + if (view === "preview") { + return { locale, view: "preview" }; + } + return { locale, view: "home" }; +} + +function homeCopy(locale: Locale) { + if (locale === "en") { + return { + subtitle: "Local speech · LLM", + status: ".env ready", + preview: "Preview", + doctor: "Doctor", + settings: "Settings", + title: "Enter a task. Start the call.", + taskHint: "Book a table, check a status, change an appointment.", + start: "Start session", + readiness: "Readiness", + llm: "LLM", + speech: "Speech", + env: "Config", + }; + } + return { + subtitle: "本机语音 · LLM", + status: ".env 就绪", + preview: "预览界面", + doctor: "诊断", + settings: "设置", + title: "输入任务,开始通话。", + taskHint: "订位、查状态、改预约,直接写清条件。", + start: "开始会话", + readiness: "准备状态", + llm: "LLM", + speech: "语音", + env: "配置", + }; +} diff --git a/frontend/src/PreviewConsole.tsx b/frontend/src/PreviewConsole.tsx new file mode 100644 index 0000000..fa81a50 --- /dev/null +++ b/frontend/src/PreviewConsole.tsx @@ -0,0 +1,386 @@ +import React, { useMemo, useState } from "react"; +import { + Activity, + CheckCircle2, + CircleDot, + Headphones, + Languages, + MessageSquare, + Mic, + PhoneCall, + RotateCcw, + Settings2, + SlidersHorizontal, + Stethoscope, +} from "lucide-react"; + +import { LanguageToggle } from "../components/LanguageToggle"; + +type Locale = "zh" | "en"; +type PreviewMode = "plan" | "ready" | "live" | "review"; + +interface PreviewCopy { + subtitle: string; + status: string; + preview: string; + taskTitle: string; + taskValue: string; + taskHint: string; + tabs: Record; + readiness: string; + missing: string; + ready: string; + handover: string; + handoverSteps: string[]; + preflight: string; + transcript: string; + clarification: string; + supplement: string; + takeover: string; + hangup: string; + review: string; + settings: string; + diagnostics: string; + callbacks: string; + assumptions: string; + turns: string; + autoTranslate: string; + mic: string; + speaker: string; + doctor: string; + reset: string; + previewOnly: string; +} + +const modeOrder: PreviewMode[] = ["plan", "ready", "live", "review"]; + +export function PreviewConsole({ locale }: { locale: Locale }) { + const copy = useMemo(() => previewCopy(locale), [locale]); + const [mode, setMode] = useState("ready"); + const [showClarification, setShowClarification] = useState(true); + const [takeoverActive, setTakeoverActive] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(true); + const readiness = mode === "plan" ? 64 : 100; + + return ( +
+
+ + V + + VocalizeAI + {copy.subtitle} + + + {copy.status} + +
+ +
+
+ {modeOrder.map((item) => ( + + ))} +
+ {copy.previewOnly} +
+ +
+
+

{copy.taskTitle}

+
+ {copy.taskValue} + +
+

{copy.taskHint}

+
+ +
+ +
+
+ } label={copy.preflight} /> +
+ + + +
+
+ + +
+
+ +
+ } label={copy.handover} /> +
    + {copy.handoverSteps.map((step) => ( +
  1. {step}
  2. + ))} +
+ +
+ +
+ } label={copy.transcript} /> +
+ + + +
+
+ + + +
+
+ +
+ } label={copy.clarification} /> + {showClarification ? ( +
+ {locale === "en" ? "Window seat unavailable." : "没有靠窗位。"} + {locale === "en" ? "Accept a standard table?" : "普通座可以吗?"} +
+ + +
+
+ ) : ( + + )} +
+ +
+ } label={copy.review} /> +

+ {locale === "en" + ? "Reservation confirmed for four people at 7:00 pm." + : "已订今晚 7 点,4 位。"} +

+
+ {copy.assumptions}1 + {copy.callbacks}0 + {copy.turns}6 +
+
+ +
+ } label={copy.settings} /> + {settingsOpen ? ( +
+
+
{copy.mic}
+
Built-in
+
+
+
{copy.speaker}
+
Default
+
+
+
{copy.autoTranslate}
+
{locale === "en" ? "On" : "开"}
+
+
+ ) : ( + + )} +
+
+
+ ); +} + +function PanelTitle({ icon, label }: { icon: React.ReactElement; label: string }) { + return ( +
+ + {React.cloneElement(icon, { "aria-hidden": true, size: 17, strokeWidth: 2 })} + {label} + +
+ ); +} + +function StatusRow({ ok, label }: { ok: boolean; label: string }) { + return ( + + {label} + + ); +} + +function Message({ role, text }: { role: "system" | "user"; text: string }) { + return ( +
+ {text} +
+ ); +} + +function TranscriptRow({ + side, + label, + text, +}: { + side: "user" | "merchant" | "assistant"; + label: string; + text: string; +}) { + return ( +
+ {label} + {text} +
+ ); +} + +function previewCopy(locale: Locale): PreviewCopy { + if (locale === "en") { + return { + subtitle: "Frontend preview", + status: "No backend", + preview: "Preview", + taskTitle: "Frontend states", + taskValue: "Book a table tonight at 7:00 for four people", + taskHint: "Mock data only. Use this page to review layout and interactions.", + tabs: { + plan: "Plan", + ready: "Ready", + live: "Live", + review: "Review", + }, + readiness: "Readiness", + missing: "Party size missing", + ready: "Ready", + handover: "Handover", + handoverSteps: ["Open speakerphone", "Place phone near Mac", "Start handover"], + preflight: "Preflight", + transcript: "Transcript", + clarification: "Clarification", + supplement: "Add note", + takeover: "Take over", + hangup: "Hang up", + review: "Result", + settings: "Settings", + diagnostics: "Diagnostics", + callbacks: "Callbacks", + assumptions: "Checks", + turns: "Turns", + autoTranslate: "Translate", + mic: "Microphone", + speaker: "Speaker", + doctor: "Doctor", + reset: "Show prompt", + previewOnly: "Static UI preview", + }; + } + return { + subtitle: "前端预览", + status: "不连接后端", + preview: "预览", + taskTitle: "前端状态预览", + taskValue: "今晚 7 点,4 位,尽量靠窗", + taskHint: "只看界面和交互,不创建真实会话。", + tabs: { + plan: "规划", + ready: "就绪", + live: "通话", + review: "复盘", + }, + readiness: "准备", + missing: "缺少人数", + ready: "就绪", + handover: "交接", + handoverSteps: ["打开扬声器", "手机靠近 Mac", "开始交接"], + preflight: "预沟通", + transcript: "通话记录", + clarification: "补充确认", + supplement: "补充", + takeover: "接话", + hangup: "挂断", + review: "结果", + settings: "设置", + diagnostics: "诊断", + callbacks: "回拨", + assumptions: "核对项", + turns: "轮次", + autoTranslate: "翻译", + mic: "麦克风", + speaker: "扬声器", + doctor: "诊断", + reset: "重新显示", + previewOnly: "静态预览", + }; +} diff --git a/frontend/src/env.ts b/frontend/src/env.ts new file mode 100644 index 0000000..a7eb7cd --- /dev/null +++ b/frontend/src/env.ts @@ -0,0 +1,34 @@ +type ImportMetaWithEnv = ImportMeta & { + env?: Record; +}; + +export type PublicEnvKey = + | "VOCALIZE_API_BASE_URL" + | "VOCALIZE_WS_BASE_URL" + | "E2E_AUDIO_HOOK"; + +export function readPublicEnv(key: PublicEnvKey): string | undefined { + const viteValue = (import.meta as ImportMetaWithEnv).env?.[`VITE_${key}`]; + if (viteValue) { + return viteValue; + } + if (typeof process !== "undefined") { + return process.env?.[`VITE_${key}`]; + } + return undefined; +} + +export function readRuntimeMode(): string { + const mode = (import.meta as ImportMetaWithEnv).env?.MODE; + if (mode) { + return mode; + } + if (typeof process !== "undefined") { + return process.env?.NODE_ENV ?? "development"; + } + return "development"; +} + +export function isE2eAudioHookEnabled(): boolean { + return readPublicEnv("E2E_AUDIO_HOOK") === "1"; +} diff --git a/frontend/src/i18n.tsx b/frontend/src/i18n.tsx new file mode 100644 index 0000000..3a19be2 --- /dev/null +++ b/frontend/src/i18n.tsx @@ -0,0 +1,82 @@ +import React, { createContext, useContext } from "react"; + +import en from "../messages/en.json"; +import zh from "../messages/zh.json"; + +type Locale = "zh" | "en"; +type Messages = typeof zh; + +const MESSAGE_BY_LOCALE: Record = { + zh, + en: en as Messages, +}; + +interface I18nContextValue { + locale: Locale; + messages: Messages; +} + +const I18nContext = createContext({ + locale: "zh", + messages: zh, +}); + +interface ProviderProps { + children: React.ReactNode; + locale: string; + messages?: Messages; +} + +export function I18nProvider({ children, locale, messages }: ProviderProps) { + const normalized: Locale = locale === "en" ? "en" : "zh"; + return ( + + {children} + + ); +} + +export function useLocale(): Locale { + return useContext(I18nContext).locale; +} + +export function useMessages(): Messages { + return useContext(I18nContext).messages; +} + +export function useTranslations(namespace?: string) { + const { messages } = useContext(I18nContext); + return (key: string, values?: Record): string => { + const fullKey = namespace ? `${namespace}.${key}` : key; + const value = readPath(messages, fullKey); + const template = typeof value === "string" ? value : fullKey; + return interpolate(template, values); + }; +} + +function readPath(root: unknown, path: string): unknown { + return path.split(".").reduce((current, segment) => { + if (current && typeof current === "object" && segment in current) { + return (current as Record)[segment]; + } + return undefined; + }, root); +} + +function interpolate( + template: string, + values: Record | undefined, +): string { + if (!values) { + return template; + } + return template.replace(/\{([^}]+)\}/g, (_match, name: string) => { + const value = values[name.trim()]; + return value === undefined || value === null ? "" : String(value); + }); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..37743a6 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,17 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; + +import "../app/globals.css"; +import "../app/components.css"; +import { App } from "./App"; + +const root = document.getElementById("root"); +if (!root) { + throw new Error("missing #root element"); +} + +createRoot(root).render( + + + , +); diff --git a/frontend/src/metadata.ts b/frontend/src/metadata.ts new file mode 100644 index 0000000..8155dce --- /dev/null +++ b/frontend/src/metadata.ts @@ -0,0 +1,13 @@ +export function getOpenGraphImage(locale: string): string { + return locale === "en" ? "/og/og-en.png" : "/og/og-zh.png"; +} + +export function getPageMetadata(locale: string) { + return { + title: "VocalizeAI", + description: "Local speech and LLM task runner", + openGraph: { + images: [{ url: getOpenGraphImage(locale) }], + }, + }; +} diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx new file mode 100644 index 0000000..ed0c370 --- /dev/null +++ b/frontend/src/router.tsx @@ -0,0 +1,69 @@ +import { useEffect, useMemo, useState } from "react"; + +type NavigateOptions = string | URL; + +function currentPathname(): string { + if (typeof window === "undefined") { + return "/"; + } + return window.location.pathname || "/"; +} + +function currentSearch(): string { + if (typeof window === "undefined") { + return ""; + } + return window.location.search || ""; +} + +function notifyNavigation(): void { + window.dispatchEvent(new Event("vocalize:navigation")); +} + +function navigate(to: NavigateOptions, mode: "push" | "replace"): void { + const href = String(to); + if (mode === "push") { + window.history.pushState(null, "", href); + } else { + window.history.replaceState(null, "", href); + } + notifyNavigation(); +} + +export function useRouter() { + return useMemo( + () => ({ + push: (to: NavigateOptions) => navigate(to, "push"), + replace: (to: NavigateOptions) => navigate(to, "replace"), + }), + [], + ); +} + +export function usePathname(): string { + const [pathname, setPathname] = useState(currentPathname); + useEffect(() => { + const update = () => setPathname(currentPathname()); + window.addEventListener("popstate", update); + window.addEventListener("vocalize:navigation", update); + return () => { + window.removeEventListener("popstate", update); + window.removeEventListener("vocalize:navigation", update); + }; + }, []); + return pathname; +} + +export function useSearchParams(): URLSearchParams { + const [search, setSearch] = useState(currentSearch); + useEffect(() => { + const update = () => setSearch(currentSearch()); + window.addEventListener("popstate", update); + window.addEventListener("vocalize:navigation", update); + return () => { + window.removeEventListener("popstate", update); + window.removeEventListener("vocalize:navigation", update); + }; + }, []); + return useMemo(() => new URLSearchParams(search), [search]); +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/tests/api.test.ts b/frontend/tests/api.test.ts index 6d6d8f2..0d0395e 100644 --- a/frontend/tests/api.test.ts +++ b/frontend/tests/api.test.ts @@ -7,7 +7,7 @@ import { } from "../lib/api"; beforeEach(() => { - process.env.NEXT_PUBLIC_VOCALIZE_API_BASE_URL = "http://127.0.0.1:8000"; + process.env.VITE_VOCALIZE_API_BASE_URL = "http://127.0.0.1:8000"; vi.resetAllMocks(); }); diff --git a/frontend/tests/clarification-modal.test.tsx b/frontend/tests/clarification-modal.test.tsx index aff37cf..a74ab01 100644 --- a/frontend/tests/clarification-modal.test.tsx +++ b/frontend/tests/clarification-modal.test.tsx @@ -5,11 +5,11 @@ import { describe, expect, it, vi, afterEach } from "vitest"; import { fireEvent, render, screen, act } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { ClarificationModal } from "../components/ClarificationModal"; -import { NextIntlClientProvider } from "next-intl"; +import { I18nProvider } from "@/src/i18n"; import zh from "../messages/zh.json"; const wrap = (ui: React.ReactNode) => ( - {ui} + {ui} ); afterEach(() => { vi.useRealTimers(); }); diff --git a/frontend/tests/connection-state-chip.test.tsx b/frontend/tests/connection-state-chip.test.tsx index 89b43ac..eb0640c 100644 --- a/frontend/tests/connection-state-chip.test.tsx +++ b/frontend/tests/connection-state-chip.test.tsx @@ -1,12 +1,12 @@ import React from "react"; import { describe, expect, it } from "vitest"; import { render, screen } from "@testing-library/react"; -import { NextIntlClientProvider } from "next-intl"; +import { I18nProvider } from "@/src/i18n"; import zh from "../messages/zh.json"; import { ConnectionStateChip } from "../components/ConnectionStateChip"; const wrap = (ui: React.ReactNode) => ( - {ui} + {ui} ); describe("", () => { diff --git a/frontend/tests/create-session-client.test.tsx b/frontend/tests/create-session-client.test.tsx index b0ddca0..f73c08b 100644 --- a/frontend/tests/create-session-client.test.tsx +++ b/frontend/tests/create-session-client.test.tsx @@ -2,22 +2,22 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import React from "react"; import { CreateSessionClient } from "../app/[locale]/new/CreateSessionClient"; -import { NextIntlClientProvider } from "next-intl"; +import { I18nProvider } from "@/src/i18n"; import en from "../messages/en.json"; import zh from "../messages/zh.json"; const replaceMock = vi.hoisted(() => vi.fn()); const wrap = (ui: React.ReactNode) => ( - {ui} + {ui} ); const wrapEn = (ui: React.ReactNode) => ( - {ui} + {ui} ); beforeEach(() => { - process.env.NEXT_PUBLIC_VOCALIZE_API_BASE_URL = "http://127.0.0.1:8000"; + process.env.VITE_VOCALIZE_API_BASE_URL = "http://127.0.0.1:8000"; localStorage.clear(); vi.resetAllMocks(); }); @@ -26,7 +26,7 @@ afterEach(() => { vi.unstubAllGlobals(); }); -vi.mock("next/navigation", () => ({ +vi.mock("@/src/router", () => ({ useRouter: () => ({ replace: replaceMock }), })); @@ -172,7 +172,7 @@ describe("", () => { render(wrap( {}} />)); - expect(document.body).toHaveTextContent("正在创建会话..."); + expect(document.body).toHaveTextContent("创建会话中..."); }); it("shows an error and retries when session creation fails", async () => { diff --git a/frontend/tests/device-settings.test.tsx b/frontend/tests/device-settings.test.tsx index b64299c..ebf2557 100644 --- a/frontend/tests/device-settings.test.tsx +++ b/frontend/tests/device-settings.test.tsx @@ -4,18 +4,18 @@ import React from "react"; import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { render, screen, waitFor, act, fireEvent } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { NextIntlClientProvider } from "next-intl"; +import { I18nProvider } from "@/src/i18n"; import { DeviceSettings } from "../components/DeviceSettings"; import zh from "../messages/zh.json"; import en from "../messages/en.json"; import type { DevicePreferences } from "../components/DeviceSettings"; const wrap = (ui: React.ReactNode) => ( - {ui} + {ui} ); const wrapEn = (ui: React.ReactNode) => ( - {ui} + {ui} ); const mockDevices: MediaDeviceInfo[] = [ diff --git a/frontend/tests/fixtures/i18n-test-component.tsx b/frontend/tests/fixtures/i18n-test-component.tsx index 4594d30..f7c4ded 100644 --- a/frontend/tests/fixtures/i18n-test-component.tsx +++ b/frontend/tests/fixtures/i18n-test-component.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { useTranslations } from "next-intl"; +import { useTranslations } from "@/src/i18n"; export function TestComponent() { const t = useTranslations("supplement_input"); return ; diff --git a/frontend/tests/handover-panel.test.tsx b/frontend/tests/handover-panel.test.tsx index 0a52f9c..2e03214 100644 --- a/frontend/tests/handover-panel.test.tsx +++ b/frontend/tests/handover-panel.test.tsx @@ -4,12 +4,12 @@ import React from "react"; import { describe, expect, it, vi } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { NextIntlClientProvider } from "next-intl"; +import { I18nProvider } from "@/src/i18n"; import { HandoverPanel } from "../components/HandoverPanel"; import zh from "../messages/zh.json"; const wrap = (ui: React.ReactNode) => ( - {ui} + {ui} ); describe("", () => { @@ -24,16 +24,16 @@ describe("", () => { } }); - it("AI takeover button calls onTakeover", async () => { + it("handover button calls onTakeover", async () => { const onTakeover = vi.fn(); render(wrap()); - await userEvent.click(screen.getByRole("button", { name: /AI 接管|AI takeover/i })); + await userEvent.click(screen.getByRole("button", { name: /交接|handover/i })); expect(onTakeover).toHaveBeenCalledTimes(1); }); it("when disabled (readiness regressed), takeover button is disabled with tooltip", () => { render(wrap( {}} disabled />)); - const btn = screen.getByRole("button", { name: /AI 接管|AI takeover/i }); + const btn = screen.getByRole("button", { name: /交接|handover/i }); expect(btn).toBeDisabled(); expect(btn.getAttribute("title")).toMatch(/信息已变|Info changed/); }); @@ -41,7 +41,7 @@ describe("", () => { it("does not call onTakeover when disabled (defensive)", async () => { const onTakeover = vi.fn(); render(wrap()); - await userEvent.click(screen.getByRole("button", { name: /AI 接管|AI takeover/i })); + await userEvent.click(screen.getByRole("button", { name: /交接|handover/i })); expect(onTakeover).not.toHaveBeenCalled(); }); }); diff --git a/frontend/tests/hangup-button.test.tsx b/frontend/tests/hangup-button.test.tsx index f810526..de78153 100644 --- a/frontend/tests/hangup-button.test.tsx +++ b/frontend/tests/hangup-button.test.tsx @@ -5,11 +5,11 @@ import { describe, expect, it, vi } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { HangupButton } from "../components/HangupButton"; -import { NextIntlClientProvider } from "next-intl"; +import { I18nProvider } from "@/src/i18n"; import zh from "../messages/zh.json"; const wrap = (ui: React.ReactNode) => ( - {ui} + {ui} ); describe("", () => { diff --git a/frontend/tests/i18n-bootstrap.test.tsx b/frontend/tests/i18n-bootstrap.test.tsx index 5f8820e..9611fec 100644 --- a/frontend/tests/i18n-bootstrap.test.tsx +++ b/frontend/tests/i18n-bootstrap.test.tsx @@ -1,7 +1,7 @@ import React from "react"; import { describe, expect, it } from "vitest"; import { render, screen } from "@testing-library/react"; -import { NextIntlClientProvider } from "next-intl"; +import { I18nProvider } from "@/src/i18n"; import zh from "../messages/zh.json"; import en from "../messages/en.json"; import { TestComponent } from "./fixtures/i18n-test-component"; @@ -9,18 +9,18 @@ import { TestComponent } from "./fixtures/i18n-test-component"; describe("i18n bootstrap", () => { it("renders Chinese strings under locale=zh", () => { render( - + - , + , ); expect(screen.getByText("发送")).toBeInTheDocument(); }); it("renders English strings under locale=en", () => { render( - + - , + , ); expect(screen.getByText("Send")).toBeInTheDocument(); }); diff --git a/frontend/tests/language-toggle.test.tsx b/frontend/tests/language-toggle.test.tsx index 645dcee..333c6e8 100644 --- a/frontend/tests/language-toggle.test.tsx +++ b/frontend/tests/language-toggle.test.tsx @@ -6,22 +6,22 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { LanguageToggle } from "../components/LanguageToggle"; -// Mock next-intl's useLocale -vi.mock("next-intl", () => ({ +// Mock i18n helper's useLocale +vi.mock("@/src/i18n", () => ({ useLocale: vi.fn(() => "zh"), })); -// Mock next/navigation +// Mock router helper const mockReplace = vi.fn(); const mockSearchParams = vi.fn(() => new URLSearchParams()); -vi.mock("next/navigation", () => ({ +vi.mock("@/src/router", () => ({ useRouter: () => ({ replace: mockReplace }), usePathname: vi.fn(() => "/zh"), useSearchParams: () => mockSearchParams(), })); -import { useLocale } from "next-intl"; -import { usePathname } from "next/navigation"; +import { useLocale } from "@/src/i18n"; +import { usePathname } from "@/src/router"; const mockUseLocale = vi.mocked(useLocale); const mockUsePathname = vi.mocked(usePathname); diff --git a/frontend/tests/live-console.test.tsx b/frontend/tests/live-console.test.tsx index b7cef81..47ea137 100644 --- a/frontend/tests/live-console.test.tsx +++ b/frontend/tests/live-console.test.tsx @@ -17,8 +17,8 @@ describe("LiveConsole", () => { const validWsUrl = "ws://example.test/ws/sessions/s"; beforeEach(() => { - process.env.NEXT_PUBLIC_VOCALIZE_API_BASE_URL = "http://example.test"; - delete process.env.NEXT_PUBLIC_VOCALIZE_WS_BASE_URL; + process.env.VITE_VOCALIZE_API_BASE_URL = "http://example.test"; + delete process.env.VITE_VOCALIZE_WS_BASE_URL; localStorage.clear(); vi.mocked(postTask).mockClear(); }); @@ -26,8 +26,8 @@ describe("LiveConsole", () => { it("keeps handover disabled until backend readiness passes", async () => { render(); - expect(screen.getByRole("button", { name: "AI 接管" })).toBeDisabled(); - fireEvent.change(screen.getByLabelText("你要 AI 帮你打什么电话?"), { + expect(screen.getByRole("button", { name: "交接" })).toBeDisabled(); + fireEvent.change(screen.getByLabelText("电话任务"), { target: { value: "帮我订今晚七点四个人" } }); fireEvent.click(screen.getByRole("button", { name: "提交任务" })); @@ -40,7 +40,7 @@ describe("LiveConsole", () => { confidence: 1 }))); - expect(screen.getByRole("button", { name: "AI 接管" })).not.toBeDisabled(); + expect(screen.getByRole("button", { name: "交接" })).not.toBeDisabled(); }); it("does not render the test-only readiness override", () => { @@ -66,7 +66,7 @@ describe("LiveConsole", () => { expect(MockWebSocket.instances).toHaveLength(0); - fireEvent.change(screen.getByLabelText("你要 AI 帮你打什么电话?"), { + fireEvent.change(screen.getByLabelText("电话任务"), { target: { value: "帮我订今晚七点四个人" } }); fireEvent.click(screen.getByRole("button", { name: "提交任务" })); @@ -82,7 +82,7 @@ describe("LiveConsole", () => { it("rejects cross-origin WebSocket URLs before connecting", async () => { render(); - fireEvent.change(screen.getByLabelText("你要 AI 帮你打什么电话?"), { + fireEvent.change(screen.getByLabelText("电话任务"), { target: { value: "帮我订今晚七点四个人" } }); fireEvent.click(screen.getByRole("button", { name: "提交任务" })); @@ -94,7 +94,7 @@ describe("LiveConsole", () => { it("routes clarification replies through ack_clarification", async () => { render(); - fireEvent.change(screen.getByLabelText("你要 AI 帮你打什么电话?"), { + fireEvent.change(screen.getByLabelText("电话任务"), { target: { value: "帮我订今晚七点四个人" } }); fireEvent.click(screen.getByRole("button", { name: "提交任务" })); @@ -127,7 +127,7 @@ describe("LiveConsole", () => { it("samples audio diagnostics instead of appending every PCM packet", async () => { render(); - fireEvent.change(screen.getByLabelText("你要 AI 帮你打什么电话?"), { + fireEvent.change(screen.getByLabelText("电话任务"), { target: { value: "帮我订今晚七点四个人" } }); fireEvent.click(screen.getByRole("button", { name: "提交任务" })); @@ -153,7 +153,7 @@ describe("LiveConsole", () => { } }); render(); - fireEvent.change(screen.getByLabelText("你要 AI 帮你打什么电话?"), { + fireEvent.change(screen.getByLabelText("电话任务"), { target: { value: "帮我订今晚七点四个人" } }); fireEvent.click(screen.getByRole("button", { name: "提交任务" })); diff --git a/frontend/tests/live-page.test.tsx b/frontend/tests/live-page.test.tsx index 2873c42..f37a5e5 100644 --- a/frontend/tests/live-page.test.tsx +++ b/frontend/tests/live-page.test.tsx @@ -9,7 +9,7 @@ import React from "react"; import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { act, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { NextIntlClientProvider } from "next-intl"; +import { I18nProvider } from "@/src/i18n"; import zh from "../messages/zh.json"; import { LivePageClient } from "../app/[locale]/live/[session]/LivePageClient"; import { BrowserAudioBridge } from "../components/BrowserAudioBridge"; @@ -48,10 +48,10 @@ const mockDevices: MediaDeviceInfo[] = [ }, ]; -// next/navigation is mocked at the module level so the client component +// router helper is mocked at the module level so the client component // can import useRouter / useSearchParams without crashing under jsdom. const replaceMock = vi.fn(); -vi.mock("next/navigation", () => ({ +vi.mock("@/src/router", () => ({ useRouter: () => ({ replace: replaceMock, push: replaceMock }), useSearchParams: () => new URLSearchParams("ws=ws://example.test/ws/sessions/s-1"), usePathname: () => "/zh/live/s-1", @@ -129,9 +129,9 @@ function defaultGetSessionResponse( function wrap(ui: React.ReactNode) { return ( - + {ui} - + ); } @@ -189,8 +189,8 @@ function installCaptureContext() { } beforeEach(() => { - process.env.NEXT_PUBLIC_VOCALIZE_API_BASE_URL = "http://example.test"; - delete process.env.NEXT_PUBLIC_VOCALIZE_WS_BASE_URL; + process.env.VITE_VOCALIZE_API_BASE_URL = "http://example.test"; + delete process.env.VITE_VOCALIZE_WS_BASE_URL; localStorage.clear(); replaceMock.mockReset(); }); diff --git a/frontend/tests/merchant-lang-badge.test.tsx b/frontend/tests/merchant-lang-badge.test.tsx index e7547b3..2f39a37 100644 --- a/frontend/tests/merchant-lang-badge.test.tsx +++ b/frontend/tests/merchant-lang-badge.test.tsx @@ -5,11 +5,11 @@ import { describe, expect, it, vi } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MerchantLangBadge } from "../components/MerchantLangBadge"; -import { NextIntlClientProvider } from "next-intl"; +import { I18nProvider } from "@/src/i18n"; import zh from "../messages/zh.json"; const wrap = (ui: React.ReactNode) => ( - {ui} + {ui} ); describe("", () => { diff --git a/frontend/tests/og-metadata.test.ts b/frontend/tests/og-metadata.test.ts index de3a437..c909a30 100644 --- a/frontend/tests/og-metadata.test.ts +++ b/frontend/tests/og-metadata.test.ts @@ -1,37 +1,16 @@ // frontend/tests/og-metadata.test.ts // -// Unit-tests that generateMetadata() in [locale]/layout.tsx routes the -// correct OG image URL per locale. -// -// next-intl/server calls (unstable_setRequestLocale, useMessages) run only -// in the default export component, NOT in generateMetadata. We mock the -// next-intl modules so the import succeeds in the vitest/jsdom environment. - -import { describe, expect, it, vi } from "vitest"; - -// Mock next-intl before importing the module under test -vi.mock("next-intl", () => ({ - NextIntlClientProvider: () => null, - useMessages: () => ({}), -})); - -vi.mock("next-intl/server", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - unstable_setRequestLocale: () => undefined, - }; -}); +// Unit-tests that page metadata routes the correct OG image URL per locale. -// Dynamic import runs after vi.mock() hoisting is applied -const { generateMetadata } = await import("../app/[locale]/layout"); +import { describe, expect, it } from "vitest"; +import { getPageMetadata } from "../src/metadata"; describe("OG metadata", () => { it("produces an OG image entry per locale", async () => { - const metaZh = await generateMetadata({ params: { locale: "zh" }, children: null }); + const metaZh = getPageMetadata("zh"); expect((metaZh.openGraph?.images as any)?.[0]?.url).toContain("og-zh.png"); - const metaEn = await generateMetadata({ params: { locale: "en" }, children: null }); + const metaEn = getPageMetadata("en"); expect((metaEn.openGraph?.images as any)?.[0]?.url).toContain("og-en.png"); }); }); diff --git a/frontend/tests/post-call-review.test.tsx b/frontend/tests/post-call-review.test.tsx index 1f690f6..9b6d1c9 100644 --- a/frontend/tests/post-call-review.test.tsx +++ b/frontend/tests/post-call-review.test.tsx @@ -5,12 +5,12 @@ import { describe, expect, it, vi } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { PostCallReview } from "../components/PostCallReview"; -import { NextIntlClientProvider } from "next-intl"; +import { I18nProvider } from "@/src/i18n"; import zh from "../messages/zh.json"; import type { CallbackEntry, SlotAssumption } from "../lib/state"; const wrap = (ui: React.ReactNode) => ( - {ui} + {ui} ); const sa: SlotAssumption = { diff --git a/frontend/tests/preflight-chat.test.tsx b/frontend/tests/preflight-chat.test.tsx index a55e05a..876ba84 100644 --- a/frontend/tests/preflight-chat.test.tsx +++ b/frontend/tests/preflight-chat.test.tsx @@ -3,13 +3,13 @@ import { describe, expect, it, vi } from "vitest"; import { render, screen } from "@testing-library/react"; import { PreflightChat } from "../components/PreflightChat"; -import { NextIntlClientProvider } from "next-intl"; +import { I18nProvider } from "@/src/i18n"; import zh from "../messages/zh.json"; import type { TranscriptMessage } from "../lib/state"; import React from "react"; const wrap = (ui: React.ReactNode) => ( - {ui} + {ui} ); const aiQuestion: TranscriptMessage = { diff --git a/frontend/tests/preflight-summary-banner.test.tsx b/frontend/tests/preflight-summary-banner.test.tsx index b07449a..4314658 100644 --- a/frontend/tests/preflight-summary-banner.test.tsx +++ b/frontend/tests/preflight-summary-banner.test.tsx @@ -5,12 +5,12 @@ import { describe, expect, it } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { PreflightSummaryBanner } from "../components/PreflightSummaryBanner"; -import { NextIntlClientProvider } from "next-intl"; +import { I18nProvider } from "@/src/i18n"; import zh from "../messages/zh.json"; import type { TranscriptMessage } from "../lib/state"; const wrap = (ui: React.ReactNode) => ( - {ui} + {ui} ); const makeMsg = (id: string, role: TranscriptMessage["role"], text: string): TranscriptMessage => ({ diff --git a/frontend/tests/readiness-indicator.test.tsx b/frontend/tests/readiness-indicator.test.tsx index 7987156..1c8e64c 100644 --- a/frontend/tests/readiness-indicator.test.tsx +++ b/frontend/tests/readiness-indicator.test.tsx @@ -4,11 +4,11 @@ import React from "react"; import { describe, expect, it } from "vitest"; import { render, screen } from "@testing-library/react"; import { ReadinessIndicator } from "../components/ReadinessIndicator"; -import { NextIntlClientProvider } from "next-intl"; +import { I18nProvider } from "@/src/i18n"; import zh from "../messages/zh.json"; const wrap = (ui: React.ReactNode) => ( - {ui} + {ui} ); describe("", () => { @@ -17,7 +17,7 @@ describe("", () => { wrap() ); expect(container.querySelector(".alert--ok")).toBeInTheDocument(); - expect(screen.getByText("信息已足够,可以接管")).toBeInTheDocument(); + expect(screen.getByText("信息已足够")).toBeInTheDocument(); }); it("passed=false renders .alert--warn with waiting text", () => { diff --git a/frontend/tests/redial-confirm-modal.test.tsx b/frontend/tests/redial-confirm-modal.test.tsx index ef48462..90a7687 100644 --- a/frontend/tests/redial-confirm-modal.test.tsx +++ b/frontend/tests/redial-confirm-modal.test.tsx @@ -2,12 +2,12 @@ import React from "react"; import { describe, expect, it, vi } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { NextIntlClientProvider } from "next-intl"; +import { I18nProvider } from "@/src/i18n"; import zh from "../messages/zh.json"; import { RedialConfirmModal } from "../components/RedialConfirmModal"; const wrap = (ui: React.ReactNode) => ( - {ui} + {ui} ); describe("", () => { diff --git a/frontend/tests/review-page.test.tsx b/frontend/tests/review-page.test.tsx index d7d822c..2136b7f 100644 --- a/frontend/tests/review-page.test.tsx +++ b/frontend/tests/review-page.test.tsx @@ -2,19 +2,19 @@ import React from "react"; import { describe, expect, it, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { NextIntlClientProvider } from "next-intl"; +import { I18nProvider } from "@/src/i18n"; import zh from "../messages/zh.json"; import { MockWebSocket } from "./setup"; import { ReviewPageClient, type ReviewApiClient } from "../app/[locale]/review/[session]/ReviewPageClient"; import type { GetReviewResponse } from "../lib/api"; const pushMock = vi.fn(); -vi.mock("next/navigation", () => ({ +vi.mock("@/src/router", () => ({ useRouter: () => ({ push: pushMock }), })); const wrap = (ui: React.ReactNode) => ( - {ui} + {ui} ); function review(overrides: Partial = {}): GetReviewResponse { diff --git a/frontend/tests/session-recovered-toast.test.tsx b/frontend/tests/session-recovered-toast.test.tsx index 40abc98..744fb69 100644 --- a/frontend/tests/session-recovered-toast.test.tsx +++ b/frontend/tests/session-recovered-toast.test.tsx @@ -1,12 +1,12 @@ import React from "react"; import { act, render, screen } from "@testing-library/react"; -import { NextIntlClientProvider } from "next-intl"; +import { I18nProvider } from "@/src/i18n"; import { afterEach, describe, expect, it, vi } from "vitest"; import zh from "../messages/zh.json"; import { SessionRecoveredToast } from "../components/SessionRecoveredToast"; const wrap = (ui: React.ReactNode) => ( - {ui} + {ui} ); afterEach(() => { diff --git a/frontend/tests/settings-sheet.test.tsx b/frontend/tests/settings-sheet.test.tsx index 2968d01..b5652f3 100644 --- a/frontend/tests/settings-sheet.test.tsx +++ b/frontend/tests/settings-sheet.test.tsx @@ -4,12 +4,12 @@ import React from "react"; import { describe, expect, it, vi } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { NextIntlClientProvider } from "next-intl"; +import { I18nProvider } from "@/src/i18n"; import { Settings } from "../components/Settings"; import zh from "../messages/zh.json"; const wrap = (ui: React.ReactNode) => ( - {ui} + {ui} ); const defaultProps = { diff --git a/frontend/tests/styling-smoke.test.tsx b/frontend/tests/styling-smoke.test.tsx index 82b03e0..048ef30 100644 --- a/frontend/tests/styling-smoke.test.tsx +++ b/frontend/tests/styling-smoke.test.tsx @@ -19,6 +19,7 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; const COMPONENTS_CSS_PATH = resolve(__dirname, "..", "app", "components.css"); +const GLOBALS_CSS_PATH = resolve(__dirname, "..", "app", "globals.css"); /** * The full catalogue of component classes from @@ -122,3 +123,27 @@ describe("styling smoke (Task 0.2)", () => { expect(missingBodies).toEqual([]); }); }); + +describe("dark mode tokens", () => { + const globalsCss = readFileSync(GLOBALS_CSS_PATH, "utf8"); + const componentsCss = readFileSync(COMPONENTS_CSS_PATH, "utf8"); + + it("globals.css supports explicit and system dark mode", () => { + expect(globalsCss).toContain(':root[data-theme="dark"]'); + expect(globalsCss).toContain("@media (prefers-color-scheme: dark)"); + expect(globalsCss).toContain(':root:not([data-theme="light"]):not([data-theme="dark"])'); + expect(globalsCss).toContain("color-scheme: dark"); + }); + + it("hard-coded light surfaces use dark-aware variables", () => { + expect(globalsCss).toContain("--readiness-bg"); + expect(globalsCss).toContain("background: var(--readiness-bg)"); + expect(globalsCss).toContain("--assistant-row-bg"); + expect(globalsCss).toContain("background: var(--assistant-row-bg)"); + }); + + it("component shimmer respects system dark mode", () => { + expect(componentsCss).toContain("@media (prefers-color-scheme: dark)"); + expect(componentsCss).toContain(':root:not([data-theme="light"]):not([data-theme="dark"]) .skeleton-text::after'); + }); +}); diff --git a/frontend/tests/text-supplement-input.test.tsx b/frontend/tests/text-supplement-input.test.tsx index 5dbff8d..467f999 100644 --- a/frontend/tests/text-supplement-input.test.tsx +++ b/frontend/tests/text-supplement-input.test.tsx @@ -4,12 +4,12 @@ import { describe, expect, it, vi } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { TextSupplementInput } from "../components/TextSupplementInput"; -import { NextIntlClientProvider } from "next-intl"; +import { I18nProvider } from "@/src/i18n"; import zh from "../messages/zh.json"; import React from "react"; const wrap = (ui: React.ReactNode) => ( - {ui} + {ui} ); describe("", () => { @@ -56,7 +56,7 @@ describe("", () => { it("test_placeholder_resolves_for_in_call_default", () => { render(wrap( {}} phase="execution_active" mode="default" />)); - expect(screen.getByPlaceholderText("提示 AI")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("补充条件")).toBeInTheDocument(); }); it("test_placeholder_resolves_for_in_call_takeover", () => { diff --git a/frontend/tests/transcript-stream.test.tsx b/frontend/tests/transcript-stream.test.tsx index a5fc38d..a72b829 100644 --- a/frontend/tests/transcript-stream.test.tsx +++ b/frontend/tests/transcript-stream.test.tsx @@ -5,12 +5,12 @@ import { describe, expect, it, vi } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { TranscriptStream } from "../components/TranscriptStream"; -import { NextIntlClientProvider } from "next-intl"; +import { I18nProvider } from "@/src/i18n"; import zh from "../messages/zh.json"; import type { TranscriptMessage } from "../lib/state"; const wrap = (ui: React.ReactNode) => ( - {ui} + {ui} ); const merchantOriginal: TranscriptMessage = { @@ -68,9 +68,9 @@ describe("", () => { expect(original?.querySelector(".bubble__translation")?.textContent).toBe("你好"); }); - it("renders user_supplement as muted center bubble with 用户提示 AI label", () => { + it("renders user_supplement as muted center bubble with 补充 label", () => { render(wrap()); - expect(screen.getByText("用户提示 AI")).toBeInTheDocument(); + expect(screen.getByText("补充")).toBeInTheDocument(); }); it("renders callback_segment with separator + 回拨通话 label", () => { diff --git a/frontend/tests/user-takeover-button.test.tsx b/frontend/tests/user-takeover-button.test.tsx index 535f7aa..69cfe7a 100644 --- a/frontend/tests/user-takeover-button.test.tsx +++ b/frontend/tests/user-takeover-button.test.tsx @@ -5,11 +5,11 @@ import { describe, expect, it, vi } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { UserTakeoverButton } from "../components/UserTakeoverButton"; -import { NextIntlClientProvider } from "next-intl"; +import { I18nProvider } from "@/src/i18n"; import zh from "../messages/zh.json"; const wrap = (ui: React.ReactNode) => ( - {ui} + {ui} ); describe("", () => { diff --git a/frontend/tests/ws.test.ts b/frontend/tests/ws.test.ts index 7c9ea0b..0d74972 100644 --- a/frontend/tests/ws.test.ts +++ b/frontend/tests/ws.test.ts @@ -64,22 +64,22 @@ describe("ws frame codec", () => { }); it("accepts a separately configured backend WebSocket base URL", () => { - process.env.NEXT_PUBLIC_VOCALIZE_API_BASE_URL = "https://api.example.test"; - process.env.NEXT_PUBLIC_VOCALIZE_WS_BASE_URL = "wss://ws.example.test"; + process.env.VITE_VOCALIZE_API_BASE_URL = "https://api.example.test"; + process.env.VITE_VOCALIZE_WS_BASE_URL = "wss://ws.example.test"; try { expect( trustedSessionWsUrl("wss://ws.example.test/ws/sessions/abc", "abc") ).toBe("wss://ws.example.test/ws/sessions/abc"); } finally { - delete process.env.NEXT_PUBLIC_VOCALIZE_API_BASE_URL; - delete process.env.NEXT_PUBLIC_VOCALIZE_WS_BASE_URL; + delete process.env.VITE_VOCALIZE_API_BASE_URL; + delete process.env.VITE_VOCALIZE_WS_BASE_URL; } }); it("accepts local loopback aliases for the same WebSocket listener", () => { - process.env.NEXT_PUBLIC_VOCALIZE_API_BASE_URL = "http://127.0.0.1:8000"; - delete process.env.NEXT_PUBLIC_VOCALIZE_WS_BASE_URL; + process.env.VITE_VOCALIZE_API_BASE_URL = "http://127.0.0.1:8000"; + delete process.env.VITE_VOCALIZE_WS_BASE_URL; try { expect( @@ -89,20 +89,20 @@ describe("ws frame codec", () => { trustedSessionWsUrl("ws://[::1]:8000/ws/sessions/abc", "abc") ).toBe("ws://[::1]:8000/ws/sessions/abc"); } finally { - delete process.env.NEXT_PUBLIC_VOCALIZE_API_BASE_URL; + delete process.env.VITE_VOCALIZE_API_BASE_URL; } }); it("rejects non-loopback host mismatches", () => { - process.env.NEXT_PUBLIC_VOCALIZE_API_BASE_URL = "http://127.0.0.1:8000"; - delete process.env.NEXT_PUBLIC_VOCALIZE_WS_BASE_URL; + process.env.VITE_VOCALIZE_API_BASE_URL = "http://127.0.0.1:8000"; + delete process.env.VITE_VOCALIZE_WS_BASE_URL; try { expect(() => trustedSessionWsUrl("ws://example.test:8000/ws/sessions/abc", "abc") ).toThrow("Invalid WebSocket URL"); } finally { - delete process.env.NEXT_PUBLIC_VOCALIZE_API_BASE_URL; + delete process.env.VITE_VOCALIZE_API_BASE_URL; } }); }); diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 7af32cb..a9c4b50 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -15,23 +15,23 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "preserve", + "jsx": "react-jsx", "incremental": true, + "baseUrl": ".", + "paths": { + "@/*": [ + "./*" + ] + }, "types": [ + "vite/client", "vitest/globals", "@testing-library/jest-dom" - ], - "plugins": [ - { - "name": "next" - } ] }, "include": [ - "next-env.d.ts", "**/*.ts", - "**/*.tsx", - ".next/types/**/*.ts" + "**/*.tsx" ], "exclude": [ "node_modules" diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..dd1eb92 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from "vite"; +import path from "node:path"; + +export default defineConfig({ + resolve: { + alias: { + "@": path.resolve(__dirname, "."), + }, + }, + server: { + host: "127.0.0.1", + port: 3000, + }, + preview: { + host: "127.0.0.1", + port: 3000, + }, + build: { + outDir: "dist", + emptyOutDir: true, + }, +}); diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 267c9bb..05248d6 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -1,6 +1,12 @@ import { defineConfig } from "vitest/config"; +import path from "node:path"; export default defineConfig({ + resolve: { + alias: { + "@": path.resolve(__dirname, ".") + } + }, test: { environment: "jsdom", globals: true, diff --git a/infra/README.md b/infra/README.md index 3b9548c..2b763d1 100644 --- a/infra/README.md +++ b/infra/README.md @@ -1,14 +1,12 @@ # infra/ -Deployable services and their configuration. Each subdirectory is the canonical -home of one runtime artifact: - -- `gpu-services/` — Docker Compose stack for SenseVoice (STT) and CosyVoice - (TTS) inference servers (`docker-compose.yml`, `healthcheck.sh`, model dirs). -- `orchestrator/` — Linux-host deployment for the FastAPI orchestrator, - with systemd + Cloudflare Tunnel wiring (`vocalize.service`, - `cloudflared-config.yml`, `deploy.sh`, `setup.sh`). Tested on Debian / - Ubuntu / Raspberry Pi OS; any modern Linux host with systemd works. - -Contrast with `scripts/` (maintainer-run utilities, not deployed) and `tools/` -(release tooling, currently empty). +Deployment support files and runtime configuration examples. + +The public `v0.1.0` user path is the packaged macOS artifact. Ordinary users do +not need anything in this directory. + +- `orchestrator/` — advanced source deployment reference for operators who are + not using the packaged macOS artifact. + +Contrast with `scripts/` (maintainer-run utilities) and `tools/` (release and +CI helpers). diff --git a/infra/gpu-services/.dockerignore b/infra/gpu-services/.dockerignore deleted file mode 100644 index 6b80c9b..0000000 --- a/infra/gpu-services/.dockerignore +++ /dev/null @@ -1,47 +0,0 @@ -# 注意:docker-compose 用 ./sensevoice 与 ./cosyvoice 作为各自 build context; -# 该 .dockerignore 同时被 docker compose build 应用到这两个 context 的祖先目录扫描, -# 但实际生效取决于 docker 版本——为了双保险,每个子目录可再放一个自己的 .dockerignore。 -# 当前我们的 build context 已经很小(只有 Dockerfile + server.py),无需额外排除; -# 这份顶层文件主要是给 `docker build .`(整目录)的用法兜底。 - -# 模型权重:5GB+,绝不能进 build context(导致 build 慢且 image 巨大) -models/ -**/models/ -*.bin -*.safetensors -*.pt -*.pth -*.onnx - -# 容器运行日志 -logs/ -**/logs/ -*.log - -# 用户自带 prompt wav(挂卷而非打进 image) -prompts/ -**/prompts/ - -# .env 含敏感信息,不进 image;compose 是通过 env_file 注入到容器,不用拷贝 -.env -.env.local - -# 开发时常见无用项 -.git/ -.gitignore -.gitkeep -.DS_Store -__pycache__/ -*.pyc -*.pyo -.pytest_cache/ -.ruff_cache/ -.mypy_cache/ -.venv*/ -node_modules/ - -# 文档/编辑器 -*.md -.idea/ -.vscode/ -*.swp diff --git a/infra/gpu-services/.env.example b/infra/gpu-services/.env.example deleted file mode 100644 index 2a0073c..0000000 --- a/infra/gpu-services/.env.example +++ /dev/null @@ -1,54 +0,0 @@ -# ========================================================================= -# VocalizeAI GPU 推理节点配置模板 -# -# 复制为 .env 并按部署目标(Windows / 云 GPU)调整。.env 已在 .gitignore,不提交。 -# 所有变量都通过 docker-compose 注入到容器;容器内代码完全 env-driven,无硬编码路径。 -# ========================================================================= - -# ------------------------------------------------------------------------- -# 网络绑定 -# ------------------------------------------------------------------------- -# 容器对宿主的端口映射 bind 地址。 -# - 0.0.0.0 = 监听所有接口(开发方便,但生产请配合宿主防火墙限制 LAN 暴露) -# - Windows 推荐:先填 0.0.0.0,待 Tailscale 装好后改成 Tailscale 接口 IP(100.x.x.x), -# 或者改成 127.0.0.1 然后用 Tailscale serve 转发,杜绝 LAN 直连 -# - 云 GPU 实例:填 0.0.0.0,由云 SG / LB 控制公网暴露 -GPU_HOST_BIND=0.0.0.0 - -# 客户端连接用的地址(Pi / Mac 端的 .env 用;本服务自己不读,只是文档化 single source) -# Tailscale 内网地址;本机调试可填 localhost -GPU_HOST=localhost - -# ------------------------------------------------------------------------- -# SenseVoice (STT) 服务 -# ------------------------------------------------------------------------- -# WebSocket 端口(流式音频上行 + 文本下行) -SENSEVOICE_PORT_WS=8000 -# HTTP 端口(/health, /metrics) -SENSEVOICE_PORT_HTTP=8080 -# 同时活跃的 WS 会话数(受 GPU 显存限制;5070 Ti 16GB 上 4-6 安全) -SENSEVOICE_MAX_SESSIONS=4 -# 哪块 GPU 跑 SenseVoice。多卡时可设 cuda:0、cuda:1 把负载分开 -SENSEVOICE_DEVICE=cuda:0 -# 输入音频采样率(Hz);客户端必须按此采样率发送 PCM int16 LE -AUDIO_SAMPLE_RATE=16000 - -# ------------------------------------------------------------------------- -# CosyVoice (TTS) 服务 -# ------------------------------------------------------------------------- -COSYVOICE_PORT_WS=8001 -COSYVOICE_PORT_HTTP=8081 -# CosyVoice2-0.5B 单实例 ~6GB 显存,5070 Ti 16GB 上 2 路安全;与 SenseVoice 共卡时 -# 总占 ~8-10GB,留 6GB+ 头寸 -COSYVOICE_MAX_SESSIONS=2 -COSYVOICE_DEVICE=cuda:0 -# CosyVoice2 输出 24kHz;CosyVoice-300M 输出 22050;改模型时同步修改 -COSYVOICE_OUTPUT_SAMPLE_RATE=24000 -# 模型本地路径(容器内);首次启动会把权重下到这里(modelscope/HF) -COSYVOICE_MODEL_DIR=/models/cosyvoice/CosyVoice2-0.5B - -# ------------------------------------------------------------------------- -# 通用 -# ------------------------------------------------------------------------- -# 日志级别:DEBUG / INFO / WARNING / ERROR -LOG_LEVEL=INFO diff --git a/infra/gpu-services/CLOUD_PORTABILITY.md b/infra/gpu-services/CLOUD_PORTABILITY.md deleted file mode 100644 index 36258bb..0000000 --- a/infra/gpu-services/CLOUD_PORTABILITY.md +++ /dev/null @@ -1,48 +0,0 @@ -# Cloud Portability Checklist - -本文件是 GPU 推理节点从 Windows → 云 GPU 实例迁移前的审查工具。每条原则给出代码/配置位置作为证据;维护者修改服务时如果某条变红应同时更新本表。 - -| # | 状态 | 原则(来自 plan) | 证据 | -|---|---|---|---| -| 1 | ✅ | 基础镜像用 `nvidia/cuda:12.x-runtime-ubuntu22.04`,不依赖 Windows / WSL 特性 | [`sensevoice/Dockerfile:6`](./sensevoice/Dockerfile)、[`cosyvoice/Dockerfile:8`](./cosyvoice/Dockerfile) — 都是 `nvidia/cuda:12.4.0-runtime-ubuntu22.04`;apt 装的是标准 Ubuntu 22.04 包,无 wsl-utilities 之类 | -| 2 | ✅ | 配置全部 env vars(路径/端口/日志/并发),无硬编码 | [`sensevoice/server.py:67-77`](./sensevoice/server.py)、[`cosyvoice/server.py:84-99`](./cosyvoice/server.py) — 顶层全部 `os.getenv(...)`;[`docker-compose.yml`](./docker-compose.yml) 通过 `${VAR:-default}` 注入;[`.env.example`](./.env.example) 是 single source of truth | -| 3 | ✅ | 数据挂载:模型 → `/models`,日志 → `/var/log/vocalize` | [`docker-compose.yml:39-40,77-79`](./docker-compose.yml) — 两服务都挂 `./models:/models` 与 `./logs/:/var/log/vocalize`;Dockerfile 内 `RUN mkdir -p /models /var/log/vocalize` 兜底;`HF_HOME` / `MODELSCOPE_CACHE` / `TORCH_HOME` 全指向 `/models` | -| 4 | ✅ | `/health` 暴露 GPU 状态、模型加载状态、queue depth | [`sensevoice/server.py:537-561`](./sensevoice/server.py)、[`cosyvoice/server.py:696-714`](./cosyvoice/server.py) — 返回 `model_loaded`、`gpu_available`、`active_sessions`、`queue_depth`、`shutting_down`;HTTP 200/503 与 docker HEALTHCHECK 协同 | -| 5 | ✅ | Prometheus `/metrics` 暴露请求数/延迟/错误率/GPU 显存 | [`sensevoice/server.py:140-159`](./sensevoice/server.py)、[`cosyvoice/server.py:131-163`](./cosyvoice/server.py) — Counters/Histograms/Gauges 完备:`*_inferences_total{outcome=ok\|error}`、`*_inference_latency_seconds`、`*_active_sessions`、`*_queue_depth`、`*_gpu_memory_allocated_bytes`;CosyVoice 多了 `cosyvoice_first_audio_latency_seconds` 作为 UX 关键指标 | -| 6 | ✅ | TLS-ready(服务自身纯 WS,由前置反向代理做 TLS termination) | 服务监听 `0.0.0.0` 的纯 ws://(无 TLS)— 见 [`sensevoice/server.py` `main()`](./sensevoice/server.py)、[`cosyvoice/server.py` `main()`](./cosyvoice/server.py);Tailscale Funnel / Caddy / nginx / 云 LB 在前置做 TLS。镜像内不打包证书,迁云无证书重新签发的脏路径 | -| 7 | ✅ | 优雅停机(SIGTERM 排空进行中请求) | [`sensevoice/server.py:495-510, 519-525`](./sensevoice/server.py)、[`cosyvoice/server.py:665-688`](./cosyvoice/server.py) — lifespan 注册 SIGTERM/SIGINT handler → set `shutdown_event` → `srv.should_exit=True` → 拒绝新 WS(`/health` 503,新 ws 1013 close)→ 等 `active_session_count==0` 或 `GRACEFUL_TIMEOUT_SEC`;docker-compose `stop_grace_period: 90s` 给足时间 | -| 8 | ✅ | 资源限制可配(`deploy.resources.reservations.devices` 声明 GPU) | [`docker-compose.yml:54-60, 95-101`](./docker-compose.yml) — 用 `driver: nvidia, count: 1, capabilities: [gpu]`;这套 schema 在 Windows nvidia-container-toolkit 与 Linux nvidia-docker 上语义一致,无需修改 | -| 9 | ✅ | 日志结构化 JSON 输出到 stdout | [`sensevoice/server.py:101-129`](./sensevoice/server.py)、[`cosyvoice/server.py:99-126`](./cosyvoice/server.py) — `JsonFormatter` 把 `level/ts/logger/msg + extra` 序列化成单行 JSON 写 stdout;container runtime 抓走(docker logs / k8s logs / journald 都兼容) | -| 10 | ✅ | 本文件维护"从 Windows 搬到云 GPU"的步骤清单 | 即下方 [迁移步骤](#迁移步骤) 节 | - ---- - -## 迁移步骤(Windows → 云 GPU) - -> 假设:仓库已 push 到云 GPU 实例可访问的 git remote;Tailscale 加入同一 tailnet。 - -| 步 | 改什么 | 改在哪 | 备注 | -|---|---|---|---| -| 1 | clone 仓库 | 云实例 | `git clone && cd VocalizeAI/infra/gpu-services` | -| 2 | `cp .env.example .env` | 云实例 | 配置项不需要新增,仅调整既有项 | -| 3 | `GPU_HOST_BIND` | `.env` | 云上保持 `0.0.0.0`,由 SG/防火墙控制公网;**不要**绑公网 IP,太敏感 | -| 4 | `SENSEVOICE_DEVICE` / `COSYVOICE_DEVICE` | `.env` | 多卡云实例可分别绑 cuda:0 / cuda:1 | -| 5 | `*_MAX_SESSIONS` | `.env` | 显存更大的卡(A100 80GB / H100)放宽到 8 / 4 | -| 6 | (可选)`COSYVOICE_MODEL_DIR` | `.env` | 想把模型放到挂载的对象存储/EBS 卷上时改路径;默认 `/models` 即可 | -| 7 | (可选)模型预下载 | 云实例 | 提前 `aws s3 sync s3://your-bucket/models ./models`,跳过容器内首次下载耗时 | -| 8 | `docker compose up -d --build` | 云实例 | 与 Windows 完全一样的命令 | -| 9 | Pi 应用 `.env` 改 `GPU_HOST=<云实例 Tailscale 100.x 地址>` | Pi | 应用层零代码改动 | -| 10 | `systemctl restart vocalize` | Pi | 验证连接:`bash infra/gpu-services/healthcheck.sh --gpu-host ` 全绿 | - -**关键不变量**:以上每一步都只动配置(`.env` / SG / DNS),不动 Dockerfile、不动 server.py、不动 docker-compose.yml;如果哪步发现需要改代码,意味着可移植性已被破坏,**应回 PR 修复后再迁**。 - ---- - -## 已知限制 / Phase 1+ 改进项 - -这些不是可移植性破坏,但记录在此让未来上云前评估: - -1. **CosyVoice prompt wav 默认值**:当前 Dockerfile 把 `/opt/CosyVoice/asset/zero_shot_prompt.wav` 复制到 `/app/prompts/default_zh.wav`。换语言(en)时建议挂卷覆盖,或 Phase 3 客户端发 `start` 时显式带 `prompt_wav` 路径。云上挂载 S3/OSS 卷做 prompt 库即可。 -2. **真正的流式 SenseVoice partial**:当前 partial 是周期性整段重跑(best-effort);Phase 1 评估 `funasr-onnx` 流式封装。切换不影响 WS 协议,是 server.py 内部的黑箱替换。 -3. **多卡显存隔离**:当前 `SENSEVOICE_DEVICE` / `COSYVOICE_DEVICE` 是字符串,单值;多 GPU 实例要做"每卡跑一份服务"的水平扩展时建议用 docker compose `--scale` 配 `device_ids: [...]`。这是"云上才需要"的扩展,不是 Phase 0.5 范围。 -4. **模型权重外置**:5GB 模型每次重建容器都要重下载,云上按秒计费很心疼。建议把 `./models` 挂到持久卷(云盘 / S3FS / EFS),或者 bake 到自定义 base image。 diff --git a/infra/gpu-services/README.md b/infra/gpu-services/README.md deleted file mode 100644 index 305cea6..0000000 --- a/infra/gpu-services/README.md +++ /dev/null @@ -1,335 +0,0 @@ -# VocalizeAI GPU 推理节点 - -两个 Docker 服务编排在一起: - -| 服务 | 模型 | WS 端口(默认) | HTTP 端口(默认) | 显存(每 session 估算) | -|---|---|---|---|---| -| `sensevoice` | `iic/SenseVoiceSmall` (FunAudioLLM) | 8000 | 8080 | ~1.5 GB | -| `cosyvoice` | `iic/CosyVoice2-0.5B` (FunAudioLLM) | 8001 | 8081 | ~6 GB | - -设计原则: -- **纯 Linux 容器**(基础镜像 `nvidia/cuda:12.4.0-runtime-ubuntu22.04`),不依赖任何 Windows / WSL2 特性 -- **配置全 env-driven**(见 `.env.example`),代码无硬编码路径 -- **协议层完整**:WebSocket + 健康端点 + Prometheus metrics + 优雅停机 + 结构化 JSON 日志 -- **同一份 docker-compose 跑 Windows 与云 GPU**——见下方 Section B - ---- - -## Section A: Windows 主机部署 - -> 目标:拿到一台 Windows 11 + RTX 5070 Ti 的主机,按本节走完后,从 Mac/Pi 通过 Tailscale 直连两个服务。 - -### 前置硬件软件 - -- Windows 10/11(64-bit),管理员权限 -- NVIDIA GPU 一块(5070 Ti / 4090 / 3090 等 12GB+ 显存推荐) -- ≥ 32 GB 系统内存(首次模型下载 + 编译会吃内存) -- ≥ 50 GB 可用磁盘(容器 image + 模型权重 ~10 GB;留 build cache 头寸) -- 稳定有线网络(首次下模型 5-8 GB,无线易断) - -### 步骤 1:装 NVIDIA 驱动 - -到 [nvidia.com/Download](https://www.nvidia.com/Download/index.aspx) 选 RTX 5070 Ti + Windows 11,下 Game Ready 或 Studio 驱动。装完重启。 - -验证(PowerShell): - -```powershell -nvidia-smi -# 应输出 GPU 表格 + Driver Version + CUDA Version (>= 12.4) -``` - -> 如果 CUDA Version 显示 < 12.4,升级驱动到最新——CUDA runtime 12.4 需要驱动 ≥ 550.x。 - -### 步骤 2:启用 WSL2 + 装 Ubuntu 22.04 - -PowerShell 管理员: - -```powershell -wsl --install -d Ubuntu-22.04 -wsl --set-default-version 2 -``` - -重启后,第一次启动 Ubuntu 22.04 会让你设 Linux 用户名 + 密码。 - -验证: - -```powershell -wsl --list --verbose -# NAME STATE VERSION -# Ubuntu-22.04 Running 2 -``` - -### 步骤 3:装 Docker Desktop(WSL2 backend) - -到 [docker.com/products/docker-desktop](https://www.docker.com/products/docker-desktop/) 下 Windows 版,装。 - -装完打开 Docker Desktop → Settings: - -1. **General** → 勾选 **Start Docker Desktop when you sign in to your computer**(开机自启) -2. **Resources → WSL Integration** → 启用 Ubuntu-22.04 的集成 -3. **Resources → Advanced** → CPU/Memory 给 Docker 至少 16 GB(CosyVoice build 时编译吃内存) - -应用并重启 Docker。 - -### 步骤 4:装 NVIDIA Container Toolkit(在 WSL2 Ubuntu 内) - -打开 WSL2 终端(`wsl -d Ubuntu-22.04`): - -```bash -distribution=$(. /etc/os-release; echo $ID$VERSION_ID) -curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \ - | sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg -curl -s -L https://nvidia.github.io/libnvidia-container/$distribution/libnvidia-container.list \ - | sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \ - | sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list -sudo apt-get update -sudo apt-get install -y nvidia-container-toolkit -``` - -回 Windows,重启 Docker Desktop(任务栏右键 → Restart)。 - -### 步骤 5:验证 GPU passthrough - -WSL2 Ubuntu 终端: - -```bash -docker run --rm --gpus all nvidia/cuda:12.4.0-runtime-ubuntu22.04 nvidia-smi -``` - -应该看到与 Windows `nvidia-smi` 一致的 GPU 表格。**这一步必须通过才能继续**。 - -> 卡在这步的常见原因:(a) Docker Desktop WSL Integration 没启用 Ubuntu-22.04;(b) NVIDIA 驱动版本太老;(c) Docker Desktop 没重启。 - -### 步骤 6:装 Tailscale(在 Windows 宿主,不在 WSL2 里) - -到 [tailscale.com/download/windows](https://tailscale.com/download/windows) 下载安装。登录到你的 tailnet,把这台机器命名为类似 `windows-gpu`。 - -确认从 Mac/Pi 上能 ping 通: - -```bash -# 在 Mac 上 -tailscale ip -4 windows-gpu # 应输出 100.x.x.x -ping 100.x.x.x -``` - -> 为何不在 WSL2 里装 Tailscale?因为 WSL2 用的是 Hyper-V 创建的虚拟网卡,跨 NAT 后 Tailscale 直连体验差。装在 Windows 宿主上,Docker Desktop 默认会把容器端口转发到 Windows 主机的 0.0.0.0,自动经过 Tailscale。 - -### 步骤 7:clone 仓库到 WSL2 home(不要放 Windows 文件系统) - -WSL2 Ubuntu 终端: - -```bash -cd ~ -git clone https://github.com/DGPisces/VocalizeAI.git -cd VocalizeAI/infra/gpu-services -``` - -> **不要** `cd /mnt/c/Users/...`!跨文件系统 IO 在 WSL2 上慢 5-10 倍,docker build 时会卡很久。 - -### 步骤 8:配置 .env - -```bash -cp .env.example .env -nano .env # 或用其他编辑器 -``` - -按需调整: -- 共卡时 `SENSEVOICE_MAX_SESSIONS=4`、`COSYVOICE_MAX_SESSIONS=2`(默认值合 5070 Ti 16GB) -- 多卡可设 `SENSEVOICE_DEVICE=cuda:0`、`COSYVOICE_DEVICE=cuda:1` 分卡 -- 公网风险敏感的话先保持 `GPU_HOST_BIND=0.0.0.0`,等 Tailscale 装好后改成 Tailscale 接口 IP - -### 步骤 9:启动服务 - -```bash -docker compose up -d --build -docker compose logs -f -``` - -首次启动: -- `docker build` 5-15 分钟(apt + pip + git clone CosyVoice) -- 模型下载:SenseVoice ~1 GB(30-60 秒),CosyVoice2-0.5B ~5 GB(5-15 分钟,看网络) -- 模型加载到 GPU:30-90 秒 - -期间 `/health` 会返回 `503 status=degraded model_loaded=false`;加载完后转 `200 status=ok`。 - -### 步骤 10:从 Mac 跑 healthcheck - -Mac 终端(仓库根目录): - -```bash -GPU_HOST=$(tailscale ip -4 windows-gpu) bash infra/gpu-services/healthcheck.sh -``` - -期望输出: - -``` -probing GPU host: 100.x.x.x - -OK sensevoice health: status=ok model_loaded=true -OK sensevoice metrics: 80+ lines (Prometheus format) - -OK cosyvoice health: status=ok model_loaded=true -OK cosyvoice metrics: 80+ lines (Prometheus format) - -all probes green -``` - -### 步骤 11:禁用睡眠 + 自启 - -PowerShell 管理员: - -```powershell -# AC(接电源)下永不待机 -powercfg /change standby-timeout-ac 0 -# AC 下永不休眠 -powercfg /change hibernate-timeout-ac 0 -# AC 下显示器 30 分钟可关(节能但不停止 GPU) -powercfg /change monitor-timeout-ac 30 -``` - -Docker Desktop 已在步骤 3 设为开机自启;启动后 docker compose 中带 `restart: unless-stopped` 的服务会自动起。 - -### 步骤 12:故障排查 - -```bash -# 查容器状态 -docker compose ps - -# 查实时日志(JSON 格式,可 jq 过滤) -docker compose logs -f sensevoice | jq . -docker compose logs -f cosyvoice | jq . - -# 查 GPU 占用 -docker exec vocalize-sensevoice nvidia-smi -docker exec vocalize-cosyvoice nvidia-smi - -# 重启单个服务 -docker compose restart sensevoice - -# 全量重建(依赖/Dockerfile 改了之后) -docker compose up -d --build --force-recreate - -# 优雅停(90s grace period) -docker compose down -``` - -常见问题: -- **CUDA out of memory**:`SENSEVOICE_MAX_SESSIONS` / `COSYVOICE_MAX_SESSIONS` 调小 -- **CosyVoice 启动 import 失败**:检查 `/opt/CosyVoice` 在容器里的 submodules 是否完整。重建:`docker compose build --no-cache cosyvoice` -- **CosyVoice build 时 pip install 失败**:上游 requirements.txt 经常引入需要本地编译的依赖;本镜像已装 `build-essential cmake`。如仍失败,检查具体哪个 pip 包,视情况固定版本 - ---- - -## Section B: 云 GPU 实例部署 - -同一份 `docker-compose.yml` 可直接搬到云。差异只在环境配置,**不需要改任何代码**。 - -### 推荐实例类型 - -| 云 | 实例族 | 卡 | 价格档位 | 备注 | -|---|---|---|---|---| -| **Aliyun** | GN6 (T4) / GN7 (V100/A100) / GN8I (H800) | T4 16GB / V100 32GB | $/小时 | 国内访问 ModelScope 快 | -| **AWS** | g5.xlarge / g5.2xlarge | A10G 24GB | $1-2/h | 全球可用 | -| **Lambda Labs** | gpu_1x_a10 / gpu_1x_a100 | A10 / A100 | $0.6-1.5/h | 性价比最高,按秒计费 | -| **RunPod** | 4090 / A6000 | 24-48GB | $0.4-0.8/h | 社区 GPU,体验最像本地 | - -最低规格:单卡 16GB 显存(同时跑 sensevoice + cosyvoice 各 2 路并发)。 - -### 通用步骤 - -1. 起一台 Ubuntu 22.04 LTS 实例 + GPU -2. 装 NVIDIA driver + Docker + NVIDIA Container Toolkit(云镜像通常预装) -3. 装 Tailscale,加入同一 tailnet - ```bash - curl -fsSL https://tailscale.com/install.sh | sh - sudo tailscale up - ``` -4. clone 仓库 - ```bash - git clone https://github.com/DGPisces/VocalizeAI.git - cd VocalizeAI/infra/gpu-services - cp .env.example .env - ``` -5. 调整 `.env`: - - `GPU_HOST_BIND=0.0.0.0`(云上由 SG/firewall 控制暴露) - - 国内云(Aliyun/腾讯)建议保持 ModelScope 默认下模型;海外云用 HuggingFace fallback -6. `docker compose up -d --build` -7. Pi 上的应用 `.env` 改 `GPU_HOST=<云实例的 Tailscale 100.x 地址>`,`systemctl restart vocalize` -8. **零代码改动完成迁移** - -### Aliyun GN6 特定提示 - -- 选 **CentOS 7.x** 镜像反而可能踩坑,强烈建议 **Ubuntu 22.04 LTS** -- 系统盘 ≥ 100 GB;模型 + image 共占 ~15 GB -- 第一次 `docker pull nvidia/cuda:...` 可能慢,配阿里 Docker 镜像加速器 - -### Lambda Labs / RunPod 特定提示 - -- 实例销毁后磁盘清空,所以模型权重要存到持久卷或 S3,下次启动 `aws s3 sync` 拉回 `./models` -- 或者:把模型权重 bake 到一个 base image 里(与本仓库 image 解耦),按需 build - -详细迁移检查清单见 [`CLOUD_PORTABILITY.md`](./CLOUD_PORTABILITY.md)。 - ---- - -## 协议参考 - -### `/ws/transcribe` (SenseVoice) - -``` -client → server - Binary frame: PCM int16 LE @ AUDIO_SAMPLE_RATE Hz, mono - Text frame: - {"event":"start","language":"auto","session_id":""} - {"event":"end_of_utterance"} # 触发 final 推理 - {"event":"stop"} # 关会话 - -server → client (text frames): - {"text":"...","is_final":true,"confidence":1.0, - "start_time":0.0,"end_time":1.5,"utterance_id":0,"language":"zh"} - {"error":"...","fatal":false} -``` - -### `/ws/synthesize` (CosyVoice) - -``` -client → server (text frames only): - {"event":"start","language":"zh","speed":1.0, - "prompt_wav":"","prompt_text":""} - {"event":"text","text":"你好","language":"zh","is_final_segment":false} - {"event":"text","text":"。","language":"zh","is_final_segment":true} - {"event":"stop"} - -server → client: - Text: {"event":"audio_start","sample_rate":24000,"encoding":"pcm_s16le","channels":1,...} - Binary frames: PCM int16 LE @ sample_rate Hz, mono - Text: {"event":"audio_end","utterance_id":0} - Text: {"error":"...","fatal":false} -``` - -### `/health` 字段 - -两个服务结构一致: - -```json -{ - "status": "ok" | "degraded", - "model_loaded": true, - "model_id": "iic/SenseVoiceSmall", - "gpu_available": true, - "active_sessions": 0, - "queue_depth": 0, - "max_concurrent_sessions": 4, - "shutting_down": false -} -``` - -`status="ok"` ⇔ `model_loaded=true && shutting_down=false`,HTTP 200。否则 503。 - -### `/metrics` 关键指标 - -- `sensevoice_inference_latency_seconds_bucket{kind="final"}` / `{kind="partial"}` — 延迟分位 -- `sensevoice_active_sessions` / `cosyvoice_active_sessions` — 当前 WS 数 -- `sensevoice_queue_depth` / `cosyvoice_queue_depth` — 等信号量的请求数 -- `sensevoice_gpu_memory_allocated_bytes` / `cosyvoice_gpu_memory_allocated_bytes` — GPU 显存 -- `cosyvoice_first_audio_latency_seconds_bucket` — 首音延迟(核心 UX 指标) diff --git a/infra/gpu-services/cosyvoice/Dockerfile b/infra/gpu-services/cosyvoice/Dockerfile deleted file mode 100644 index 094d038..0000000 --- a/infra/gpu-services/cosyvoice/Dockerfile +++ /dev/null @@ -1,133 +0,0 @@ -# CosyVoice 2 (FunAudioLLM) TTS 推理服务 — 多语流式 WebSocket -# -# CosyVoice **不是 pip 包**:必须从 GitHub 仓库 clone(含 git submodules)后把整个 -# 项目目录加到 PYTHONPATH。模型权重通过 modelscope/huggingface_hub 在首次运行时 -# 下载到 /models(外挂卷)。 -# -# 基础镜像:与 sensevoice 服务保持一致(云可移植性原则 #1)。 -FROM nvidia/cuda:12.4.0-runtime-ubuntu22.04 - -ENV DEBIAN_FRONTEND=noninteractive \ - PYTHONUNBUFFERED=1 \ - PYTHONDONTWRITEBYTECODE=1 \ - PIP_NO_CACHE_DIR=1 \ - PIP_DISABLE_PIP_VERSION_CHECK=1 - -# 系统依赖:除 sensevoice 那一套外多两个: -# - sox / libsox-dev:CosyVoice 上游 README 显式要求(torchaudio sox backend) -# - build-essential:requirements.txt 里有需要本地编译的依赖(如 pyworld、gdown 间接) -# - cmake / pkg-config:保险起见为某些 wheel fallback 编译留空间 -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - software-properties-common \ - ca-certificates \ - curl \ - git \ - ffmpeg \ - libsndfile1 \ - sox \ - libsox-dev \ - build-essential \ - cmake \ - pkg-config \ - && add-apt-repository -y ppa:deadsnakes/ppa \ - && apt-get update \ - && apt-get install -y --no-install-recommends \ - python3.11 \ - python3.11-venv \ - python3.11-dev \ - python3.11-distutils \ - && curl -sS https://bootstrap.pypa.io/get-pip.py | python3.11 \ - && ln -sf /usr/bin/python3.11 /usr/local/bin/python \ - && ln -sf /usr/bin/python3.11 /usr/local/bin/python3 \ - && rm -rf /var/lib/apt/lists/* - -# PyTorch CUDA 12.8 wheel(与 sensevoice 一致) -# 2.7.1+cu128 包含 sm_120 (Blackwell) 编译目标,5070 Ti 等 RTX 50 系卡运行 GPU kernel -# 必需。早期 cu124 wheel 只到 sm_90,PyTorch 会警告并 fallback CPU。 -RUN pip install --no-cache-dir \ - --index-url https://download.pytorch.org/whl/cu128 \ - torch==2.7.1 torchaudio==2.7.1 - -# Clone CosyVoice 仓库(含 submodules)。 -# 锁 commit:CosyVoice 上游不打 PyPI tag;用 commit SHA 钉死,以便后续可复现 build -# 与定向升级。`main` 是浮动引用——会让重建拿到不同上游代码,违反复现性。 -# 当前 SHA 来自 FunAudioLLM/CosyVoice main 分支(2026-04-30 验证通过)。 -# 升级流程:本地 `git ls-remote https://github.com/FunAudioLLM/CosyVoice main` 取新 -# SHA;改 ARG 默认值 + 验证 inference_zero_shot/inference_cross_lingual API 未变。 -ARG COSYVOICE_REF=ace7c47f41bbd303aa6bf1ea80e6f9fbd595cd40 -RUN git clone --recursive https://github.com/FunAudioLLM/CosyVoice.git /opt/CosyVoice \ - && cd /opt/CosyVoice \ - && git checkout "${COSYVOICE_REF}" \ - && git submodule update --init --recursive - -# CosyVoice 的 requirements.txt 是 Conda 用的,里面挑出 pip-only 的。但实际官方流程 -# 推荐 pip install -r。为了健壮性:先升级 pip 工具,然后装 requirements,但禁用 -# 它里面的 torch(我们已经装好 CUDA wheel,避免被覆盖成 CPU 版)。 -# 做法:先 grep 掉 torch/torchaudio 行再装。 -# -# 过滤 requirements.txt 里几个会触发问题的包,下一行单独处理: -# - torch / torchaudio:上一行已装 cu124 wheel,避免 PyPI 索引覆盖。 -# - openai-whisper:requirements 里钉的 20231117 锁了 triton<3,跟 torch 2.4.1 要求的 -# triton==3.0.0 互斥。注意:CosyVoice 2 frontend.py 在 module-level `import whisper`, -# inference 路径必须有此模块。下一个 RUN 里 --no-deps 装回。 -# - deepspeed:训练加速用。它 import 时调用 nvcc 检查 CUDA 版本,cuda:runtime base -# image 没有 nvcc(只 devel 有)。装上会让 transformers/modelscope 链式失败。 -# -# PIP_CONSTRAINT 钉死: -# - setuptools<70:保留 pkg_resources 给 wheel build 阶段(setuptools 78+ 已移除)。 -# - torch==2.4.1 / torchaudio==2.4.1:防止 transitive deps 触发 pip backtracking。 -RUN cd /opt/CosyVoice \ - && grep -Ev '^(torch|torchaudio|openai-whisper|deepspeed)([=<>]|$)' requirements.txt > /tmp/cosyvoice-reqs.txt \ - && printf 'setuptools<70\ntorch==2.7.1\ntorchaudio==2.7.1\n' > /tmp/build-constraints.txt \ - && PIP_CONSTRAINT=/tmp/build-constraints.txt pip install --no-cache-dir -r /tmp/cosyvoice-reqs.txt - -# openai-whisper 单装:CosyVoice frontend module-level import 它,inference 必需。 -# 用 20250625(最新):早期版本(如 20231117)锁了 triton<3,跟我们 torch 2.4.1 要求的 -# triton==3.0.0 互斥;20250625 已放宽到 triton>=2.0.0,兼容 triton 3。 -# 必须带依赖装,否则 tiktoken / numba 等运行时依赖缺失。 -# PIP_CONSTRAINT setuptools<70:openai-whisper 的 setup.py 在 build wheel 阶段 import -# pkg_resources,setuptools 78+ 已移除。 -RUN printf 'setuptools<70\n' > /tmp/whisper-constraints.txt \ - && PIP_CONSTRAINT=/tmp/whisper-constraints.txt pip install --no-cache-dir openai-whisper==20250625 - -# 服务层依赖(与 sensevoice 同一 stack) -RUN pip install --no-cache-dir \ - modelscope==1.18.1 \ - "huggingface_hub>=0.24,<1.0" \ - websockets==12.0 \ - "fastapi>=0.110,<1.0" \ - "uvicorn[standard]>=0.27,<1.0" \ - prometheus-client==0.20.0 - -# CosyVoice 项目目录加到 PYTHONPATH,让 ``from cosyvoice.cli.cosyvoice import CosyVoice2`` -# 可以 import 到。CosyVoice 的 third_party submodule(Matcha-TTS 等)也要在 path 上。 -ENV PYTHONPATH="/opt/CosyVoice:/opt/CosyVoice/third_party/Matcha-TTS:${PYTHONPATH}" \ - MODEL_CACHE_DIR=/models \ - HF_HOME=/models/huggingface \ - HUGGINGFACE_HUB_CACHE=/models/huggingface \ - MODELSCOPE_CACHE=/models/modelscope \ - TORCH_HOME=/models/torch \ - PORT_WS=8001 \ - PORT_HTTP=8081 \ - LOG_LEVEL=INFO \ - MAX_CONCURRENT_SESSIONS=2 \ - COSYVOICE_MODEL_ID=iic/CosyVoice2-0.5B \ - COSYVOICE_MODEL_DIR=/models/cosyvoice/CosyVoice2-0.5B \ - COSYVOICE_DEVICE=cuda:0 \ - COSYVOICE_OUTPUT_SAMPLE_RATE=24000 - -WORKDIR /app -COPY server.py /app/server.py -# 默认 prompt 音频(zero-shot 需要一个 3-30s 的参考音);用户可挂卷覆盖 -# /opt/CosyVoice/asset/zero_shot_prompt.wav 是 upstream 自带的中文示例 -RUN mkdir -p /models /var/log/vocalize /app/prompts \ - && cp /opt/CosyVoice/asset/zero_shot_prompt.wav /app/prompts/default_zh.wav 2>/dev/null \ - || echo "default prompt asset not present in this CosyVoice ref; user must mount /app/prompts" - -EXPOSE 8001 8081 - -HEALTHCHECK --interval=30s --timeout=10s --start-period=600s --retries=3 \ - CMD curl -fsS http://localhost:${PORT_HTTP}/health || exit 1 - -CMD ["python", "/app/server.py"] diff --git a/infra/gpu-services/cosyvoice/server.py b/infra/gpu-services/cosyvoice/server.py deleted file mode 100644 index c87ddf4..0000000 --- a/infra/gpu-services/cosyvoice/server.py +++ /dev/null @@ -1,1151 +0,0 @@ -"""CosyVoice 2 TTS 服务 — WebSocket 流式合成 + HTTP 健康/指标端点。 - -云端可移植:纯 Python,配置全部 env,不依赖 Windows / WSL2 任何特性。 - -WebSocket 协议 (`/ws/synthesize`) ---------------------------------- -客户端 → 服务端(JSON 文本帧): -- ``{"event": "start", "session_id": "", "language": "zh"|"en"|..., - "speed": 1.0, "prompt_wav": "", - "prompt_text": ""}`` - 开始一段合成会话;可指定参考声纹 wav(zero-shot 克隆);不传走默认 prompt。 -- ``{"event": "text", "text": "...", "language": "zh"|"en", "is_final_segment": bool}`` - 追加一段文本进合成队列。``is_final_segment=True`` 提示模型当前句末——本服务实现里 - 我们就在收到该帧后把内部 generator 关掉触发 flush。 -- ``{"event": "stop"}`` 结束当前会话。 - -服务端 → 客户端: -- 二进制帧:PCM int16 LE,单声道,采样率 = ``COSYVOICE_OUTPUT_SAMPLE_RATE``(默认 24kHz) -- JSON 文本帧(仅控制信号 / 错误): - - ``{"event": "audio_start", "sample_rate": 24000, "encoding": "pcm_s16le"}`` - - ``{"event": "audio_end", "utterance_id": int, "text": "<合成的全文回显>"}`` - - ``{"error": "", "fatal": bool}`` - -设计取舍(best-effort,Phase 3 客户端再补) -------------------------------------------- -CosyVoice 上游 ``inference_zero_shot`` 的 "text-streaming" 模式要求 ``tts_text`` 必须 -是真正的 ``typing.Generator`` 对象(``def ... yield ...`` 函数返回值)。上游用 -``isinstance(text, typing.Generator)`` gate 流式分支;自定义 ``__iter__/__next__`` 的 -Iterator 类不通过该检查(``typing.Generator`` 只匹配 generator function 产物 / generator -expression)。Gate 失败 fallthrough 到 ``text.strip()`` 即崩。 -因此本服务用 ``queue + 真 generator function`` 把 async 事件桥接成 sync generator: -- ``_TextStreamBridge`` 持有 ``queue.Queue``;async 侧 ``push_text()`` / ``close()`` 投递 -- ``_iter_bridge_text(bridge)`` 是真 generator function,喂给 inference 调用(满足 - ``isinstance(_, typing.Generator) is True``,触发 bistream 分支) -- 单独 thread 跑 ``inference_zero_shot``,generator 阻塞 ``queue.get()`` 等下一帧 -- 推理产出的 audio chunk 通过另一个 ``asyncio.Queue`` 回 event loop,由 WS 写出 - -并发由 ``asyncio.Semaphore(MAX_CONCURRENT_SESSIONS)`` 控制;MAX 默认 2(CosyVoice2-0.5B -显存 ~6GB / inference,5070 Ti 16GB 同时跑 2 路安全)。 - -跨语:CosyVoice 提供 ``inference_cross_lingual``——传不带 prompt_text 的 wav,模型自动 -跨语。本服务规则: -- ``language`` 与 prompt 隐含语言匹配 → ``inference_zero_shot``(带 prompt_text) -- ``language`` ≠ prompt 语言 → ``inference_cross_lingual``(不传 prompt_text) - -TODO(phase-3):客户端可在 ``start`` 帧里通过 ``prompt_lang`` 显式声明 prompt 语言; -当前实现简化处理为"如果没传 prompt_text 就走 cross_lingual"。 - -优雅停机、日志、Prometheus 指标设计与 sensevoice/server.py 对齐——这两个服务运维上同形态。 -""" -from __future__ import annotations - -import asyncio -import json -import logging -import os -import queue -import signal -import sys -import threading -import time -import uuid -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager -from dataclasses import dataclass, field -from typing import Any, Iterator - -import numpy as np -import uvicorn -from fastapi import FastAPI, Response -from fastapi.responses import JSONResponse -from prometheus_client import ( - CONTENT_TYPE_LATEST, - Counter, - Gauge, - Histogram, - generate_latest, -) -from starlette.websockets import WebSocket, WebSocketDisconnect, WebSocketState - -# --------------------------------------------------------------------------- -# 配置 -# --------------------------------------------------------------------------- -PORT_WS = int(os.getenv("PORT_WS", "8001")) -PORT_HTTP = int(os.getenv("PORT_HTTP", "8081")) -LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper() -MAX_CONCURRENT_SESSIONS = int(os.getenv("MAX_CONCURRENT_SESSIONS", "2")) -COSYVOICE_MODEL_ID = os.getenv("COSYVOICE_MODEL_ID", "iic/CosyVoice2-0.5B") -COSYVOICE_MODEL_DIR = os.getenv( - "COSYVOICE_MODEL_DIR", "/models/cosyvoice/CosyVoice2-0.5B" -) -COSYVOICE_DEVICE = os.getenv("COSYVOICE_DEVICE", "cuda:0") -COSYVOICE_OUTPUT_SAMPLE_RATE = int(os.getenv("COSYVOICE_OUTPUT_SAMPLE_RATE", "24000")) -DEFAULT_PROMPT_WAV = os.getenv("DEFAULT_PROMPT_WAV", "/app/prompts/default_zh.wav") -DEFAULT_PROMPT_TEXT = os.getenv( - "DEFAULT_PROMPT_TEXT", "希望你以后能够做的比我还好呦。" -) -GRACEFUL_TIMEOUT_SEC = float(os.getenv("GRACEFUL_TIMEOUT_SEC", "60")) - - -# --------------------------------------------------------------------------- -# JSON 日志(与 sensevoice 同形态;保持运维一致) -# --------------------------------------------------------------------------- -class JsonFormatter(logging.Formatter): - def format(self, record: logging.LogRecord) -> str: - payload: dict[str, Any] = { - "ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S.%fZ"), - "level": record.levelname, - "logger": record.name, - "msg": record.getMessage(), - } - if record.exc_info: - payload["exc"] = self.formatException(record.exc_info) - for k, v in record.__dict__.items(): - if k in payload or k.startswith("_") or k in ( - "name", "msg", "args", "levelname", "levelno", "pathname", "filename", - "module", "exc_info", "exc_text", "stack_info", "lineno", "funcName", - "created", "msecs", "relativeCreated", "thread", "threadName", - "processName", "process", "message", "taskName", - ): - continue - payload[k] = v - return json.dumps(payload, ensure_ascii=False, default=str) - - -def _setup_logging() -> logging.Logger: - handler = logging.StreamHandler(sys.stdout) - handler.setFormatter(JsonFormatter()) - root = logging.getLogger() - root.handlers = [handler] - root.setLevel(LOG_LEVEL) - return logging.getLogger("cosyvoice") - - -log = _setup_logging() - - -# --------------------------------------------------------------------------- -# Prometheus 指标 -# --------------------------------------------------------------------------- -SESSIONS_OPENED = Counter("cosyvoice_sessions_opened_total", "WS sessions opened") -SESSIONS_REJECTED = Counter( - "cosyvoice_sessions_rejected_total", - "WS sessions rejected (saturation / shutdown)", - ["reason"], -) -SYNTH_TOTAL = Counter( - "cosyvoice_syntheses_total", "Synthesis utterances", ["mode", "outcome"] -) -FIRST_AUDIO_LATENCY = Histogram( - "cosyvoice_first_audio_latency_seconds", - "Time from synthesize call to first audio chunk emitted", - ["mode"], - buckets=(0.1, 0.2, 0.4, 0.8, 1.5, 3.0, 6.0, 12.0), -) -# Phase 4 Wave 1 instrumentation (CONCERNS.md "Phase 3 Demo Findings" hyp #3): -# Leading-silence detection on the first emitted PCM chunk. CosyVoice 2 has -# been observed to emit ~hundreds of milliseconds of near-zero samples at the -# head of the first chunk, contributing to the instrumentation gap between -# server-side first_audio_latency and client-perceived t_first_audible. This -# histogram quantifies that gap so Wave 2 can validate any fix against it. -LEADING_SILENCE_MS = Histogram( - "cosyvoice_first_chunk_leading_silence_ms", - "Leading near-zero PCM samples at the head of the first emitted chunk", - ["mode"], - buckets=(0, 50, 100, 200, 400, 800, 1600), -) -TOTAL_SYNTH_LATENCY = Histogram( - "cosyvoice_total_synthesis_latency_seconds", - "End-to-end synthesis duration", - ["mode"], - buckets=(0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0), -) -ACTIVE_SESSIONS = Gauge("cosyvoice_active_sessions", "Currently open WS sessions") -QUEUE_DEPTH = Gauge( - "cosyvoice_queue_depth", "Synthesis requests waiting on the GPU semaphore" -) -GPU_MEM_BYTES = Gauge( - "cosyvoice_gpu_memory_allocated_bytes", "torch.cuda.memory_allocated() snapshot" -) -AUDIO_BYTES_OUT = Counter( - "cosyvoice_audio_bytes_total", "Total PCM bytes streamed to clients" -) - - -# --------------------------------------------------------------------------- -# 应用状态 -# --------------------------------------------------------------------------- -@dataclass -class AppState: - model: Any = None - model_loaded: bool = False - gpu_available: bool = False - shutdown_event: asyncio.Event = field(default_factory=asyncio.Event) - inference_sem: asyncio.Semaphore = field( - default_factory=lambda: asyncio.Semaphore(MAX_CONCURRENT_SESSIONS) - ) - active_session_count: int = 0 - queue_depth: int = 0 - servers: list[Any] = field(default_factory=list) - sample_rate: int = COSYVOICE_OUTPUT_SAMPLE_RATE - - -state = AppState() - - -def _check_gpu() -> bool: - try: - import torch - - return bool(torch.cuda.is_available()) - except Exception: - return False - - -def _update_gpu_metric() -> None: - try: - import torch - - if torch.cuda.is_available(): - GPU_MEM_BYTES.set(float(torch.cuda.memory_allocated())) - except Exception: - pass - - -def _ensure_model_dir() -> str: - """确保模型目录在 ``COSYVOICE_MODEL_DIR``;不存在则用 modelscope 下载。 - - 返回最终路径。这里不在镜像 build 阶段下,因为: - 1. 模型 ~5GB,包进 image 太大且无法多版本复用 - 2. 用户挂卷 -v ./models:/models 后下载持久化,重启零成本 - - fallback:如果 modelscope 下载失败(如内网受限),尝试 huggingface_hub 镜像。 - """ - if os.path.isdir(COSYVOICE_MODEL_DIR) and os.listdir(COSYVOICE_MODEL_DIR): - log.info("model dir present; skipping download", - extra={"path": COSYVOICE_MODEL_DIR}) - return COSYVOICE_MODEL_DIR - - log.info("downloading model via modelscope", - extra={"model_id": COSYVOICE_MODEL_ID, "dest": COSYVOICE_MODEL_DIR}) - try: - from modelscope import snapshot_download - - snapshot_download(COSYVOICE_MODEL_ID, local_dir=COSYVOICE_MODEL_DIR) - return COSYVOICE_MODEL_DIR - except Exception as exc: - log.warning("modelscope download failed; trying huggingface", - extra={"err": str(exc)}) - - # HF fallback:FunAudioLLM 在 HF 上也镜像了 - try: - from huggingface_hub import snapshot_download as hf_snapshot - - hf_id = COSYVOICE_MODEL_ID.replace("iic/", "FunAudioLLM/") - hf_snapshot(hf_id, local_dir=COSYVOICE_MODEL_DIR) - return COSYVOICE_MODEL_DIR - except Exception as exc: - log.exception("model download failed", extra={"err": str(exc)}) - raise - - -def _load_model() -> Any: - """同步加载 CosyVoice2 模型(启动时一次)。 - - CosyVoice 上游入口:``cosyvoice.cli.cosyvoice.CosyVoice2``。它接受 ``model_dir`` - 指向已下载的本地路径。``load_jit`` / ``load_trt`` / ``fp16`` 等优化在 best-effort - 实现里先关掉,跑通再说;Phase 3 评估开启 fp16 看是否提速。 - """ - model_dir = _ensure_model_dir() - - log.info("loading CosyVoice2 model", extra={"path": model_dir}) - t0 = time.perf_counter() - # 上游导出路径在不同 ref 下略有差异:CosyVoice2 直接走 cli.cosyvoice - from cosyvoice.cli.cosyvoice import CosyVoice2 - - model = CosyVoice2( - model_dir, - load_jit=False, - load_trt=False, - fp16=True, - ) - log.info("CosyVoice2 model loaded", extra={ - "load_seconds": round(time.perf_counter() - t0, 2), - "sample_rate": getattr(model, "sample_rate", COSYVOICE_OUTPUT_SAMPLE_RATE), - }) - # 用模型暴露的 sample_rate 校准 state(万一与 env 不一致以模型为准,避免误传给客户端) - actual_sr = int(getattr(model, "sample_rate", COSYVOICE_OUTPUT_SAMPLE_RATE)) - if actual_sr != state.sample_rate: - log.warning("output sample rate mismatch; using model value", - extra={"env": state.sample_rate, "model": actual_sr}) - state.sample_rate = actual_sr - - # Phase 4 Wave 2 Fix #3: cache the default zero-shot speaker so subsequent - # inference_zero_shot calls with zero_shot_spk_id="default" skip frontend - # tensor extraction (load_wav x3 + speech_token + spk_embedding + - # speech_feat). Per upstream cosyvoice/cli/cosyvoice.py:69-76 the API - # precomputes & stores in self.frontend.spk2info[spk_id]; subsequent - # inferences just spread the dict (frontend.py:166). - # - # Fault-tolerant: if the installed CosyVoice2 build doesn't expose - # add_zero_shot_spk (older fork / refactor), we log + continue. The - # branched _run_synth_thread call still works via the per-call fallback - # path (passing the full prompt_text + prompt_wav). - try: - log.info("caching default zero-shot speaker", - extra={"prompt_text": DEFAULT_PROMPT_TEXT, - "prompt_wav": DEFAULT_PROMPT_WAV}) - if not os.path.isfile(DEFAULT_PROMPT_WAV): - log.warning("default prompt wav missing; skipping speaker cache", - extra={"path": DEFAULT_PROMPT_WAV}) - elif not hasattr(model, "add_zero_shot_spk"): - log.warning("model has no add_zero_shot_spk; skipping speaker cache") - else: - model.add_zero_shot_spk( - DEFAULT_PROMPT_TEXT, DEFAULT_PROMPT_WAV, "default" - ) - log.info("default speaker cached") - except Exception as exc: - log.exception("failed to cache default speaker; falling back to per-call", - extra={"err": str(exc)}) - return model - - -# --------------------------------------------------------------------------- -# 桥接 generator:把异步 WS 事件映射成 sync generator 喂给 CosyVoice 推理 -# --------------------------------------------------------------------------- -_SENTINEL_END = object() - - -class _TextStreamBridge: - """文本桥队列。``push_text()`` / ``close()`` 由 async WS 侧调用;同步侧通过 - ``_iter_bridge_text(bridge)`` 真 generator 函数消费队列。 - - NOTE: 不直接实现 ``__iter__/__next__``。CosyVoice 上游 - (``cosyvoice/cli/frontend.py:text_normalize`` 与 ``cli/model.py:llm_job``) 用 - ``isinstance(text, typing.Generator)`` 判别流式输入,自定义 Iterator 类无法通过 - 该检查;必须传真正的 generator 函数返回值。 - """ - - def __init__(self) -> None: - self._q: queue.Queue[Any] = queue.Queue() - - def push_text(self, text: str) -> None: - self._q.put(text) - - def close(self) -> None: - self._q.put(_SENTINEL_END) - - -def _iter_bridge_text(bridge: _TextStreamBridge) -> Iterator[str]: - """真 generator function:阻塞 ``queue.get()`` 直到 close 哨兵。 - - 返回值是 ``typing.Generator`` 实例(``isinstance(_, typing.Generator) is True``), - 满足 CosyVoice 上游对流式文本输入的类型契约。 - """ - while True: - item = bridge._q.get() - if item is _SENTINEL_END: - return - assert isinstance(item, str) - yield item - - -# --------------------------------------------------------------------------- -# 单次合成:在 worker thread 里跑 CosyVoice 推理,把音频 chunk 推回 asyncio.Queue -# --------------------------------------------------------------------------- -def _audio_tensor_to_pcm_bytes(t: Any) -> bytes: - """torch.Tensor float32 → int16 LE bytes(mono)。 - - CosyVoice 输出 ``tts_speech`` 是 shape (1, N) 或 (N,) 的 float32 [-1, 1]。 - """ - arr = t.detach().cpu().numpy() if hasattr(t, "detach") else np.asarray(t) - if arr.ndim > 1: - arr = arr.reshape(-1) - arr = np.clip(arr, -1.0, 1.0) - pcm = (arr * 32767.0).astype(np.int16) - return pcm.tobytes() - - -def _run_synth_thread( - bridge: _TextStreamBridge, - audio_q: "asyncio.Queue[Any]", - loop: asyncio.AbstractEventLoop, - *, - mode: str, - prompt_wav: str, - prompt_text: str, - speed: float, - session_id: str = "", - utterance_id: int = 0, -) -> None: - """同步 worker:跑 CosyVoice,把每个 chunk 通过 audio_q 投回 event loop。 - - 任一异常 → 把异常对象 put 进 audio_q;async 侧识别后回错误帧。 - 完成后 put None 作为结束哨兵。 - - Phase 4 Wave 1: ``session_id`` and ``utterance_id`` are passed in only for - log context — they let the leading-silence log line be correlated with the - matching audio_end frame on the client side without changing the worker's - runtime behavior. - """ - def _put(item: Any) -> None: - # call_soon_threadsafe 不带 future;改用 run_coroutine_threadsafe + 完成后丢弃 - # 这里 audio_q.put 是 awaitable,要走 coroutine 路径 - fut = asyncio.run_coroutine_threadsafe(audio_q.put(item), loop) - try: - fut.result(timeout=10.0) - except Exception: # pragma: no cover - 防止 loop 已关闭时阻塞 - pass - - try: - if mode == "cross_lingual": - # 不带 prompt_text;inference_cross_lingual(tts_text_or_gen, prompt_wav) - # 必须传真 generator (_iter_bridge_text),不能直接传 bridge 实例 —— - # CosyVoice 用 isinstance(text, typing.Generator) gate 流式分支。 - iterator = state.model.inference_cross_lingual( - _iter_bridge_text(bridge), - _load_prompt_wav(prompt_wav), - stream=True, - speed=speed, - ) - else: - # Phase 4 Wave 2 Fix #3: when prompt matches the cached default, - # call inference_zero_shot with zero_shot_spk_id="default" to skip - # frontend tensor extraction (~0.1 s warm savings per RESEARCH). - if ( - prompt_wav == DEFAULT_PROMPT_WAV - and prompt_text == DEFAULT_PROMPT_TEXT - ): - iterator = state.model.inference_zero_shot( - _iter_bridge_text(bridge), - "", - "", - zero_shot_spk_id="default", - stream=True, - speed=speed, - ) - else: - iterator = state.model.inference_zero_shot( - _iter_bridge_text(bridge), - prompt_text, - _load_prompt_wav(prompt_wav), - stream=True, - speed=speed, - ) - first = True - t0 = time.perf_counter() - for output in iterator: - chunk = output.get("tts_speech") if isinstance(output, dict) else None - if chunk is None: - continue - pcm = _audio_tensor_to_pcm_bytes(chunk) - if first: - FIRST_AUDIO_LATENCY.labels(mode=mode).observe(time.perf_counter() - t0) - # Phase 4 Wave 1: leading-silence probe (CONCERNS.md hyp #3). - # Count near-zero int16 samples at the head of the first chunk - # (|s| < 32 ≈ 0.1% of int16 max — covers DC bias / model - # warmup noise without false positives on quiet speech). - # Duration is computed against state.sample_rate (model-reported - # SR set in _load_model — falls back to env default if missing). - try: - samples = np.frombuffer(pcm, dtype=np.int16) - leading = 0 - for s in samples: - if abs(int(s)) < 32: - leading += 1 - else: - break - sr_hz = state.sample_rate or COSYVOICE_OUTPUT_SAMPLE_RATE - leading_silence_ms = (leading * 1000.0) / sr_hz - log.info( - "first_chunk_leading_silence", - extra={ - "mode": mode, - "session_id": session_id, - "utterance_id": utterance_id, - "leading_samples": leading, - "leading_silence_ms": round(leading_silence_ms, 1), - }, - ) - LEADING_SILENCE_MS.labels(mode=mode).observe(leading_silence_ms) - except Exception: # pragma: no cover - probe must never abort synthesis - log.exception("leading-silence probe failed; continuing") - first = False - _put(pcm) - TOTAL_SYNTH_LATENCY.labels(mode=mode).observe(time.perf_counter() - t0) - SYNTH_TOTAL.labels(mode=mode, outcome="ok").inc() - except Exception as exc: - SYNTH_TOTAL.labels(mode=mode, outcome="error").inc() - log.exception("synthesis worker failed", extra={"err": str(exc), "mode": mode}) - msg = str(exc).lower() - if "out of memory" in msg or "cuda" in msg: - try: - import torch - - torch.cuda.empty_cache() - except Exception: - pass - _put(exc) - finally: - _put(None) - - -_VERIFIED_PROMPT_PATHS: set[str] = set() - - -def _load_prompt_wav(path: str) -> str: - """返回 prompt wav 文件路径(不预加载)。 - - 设计修正(2026-05-03):早期版本预加载为 tensor 缓存以省 IO。但 CosyVoice - upstream(pin ace7c47)的 frontend_zero_shot / frontend_cross_lingual - 会在 _extract_speech_token / _extract_spk_embedding / - _extract_speech_feat 三处分别调用 cosyvoice.utils.file_utils.load_wav, - 内部直接 torchaudio.load(wav, backend='soundfile')——不接受 tensor, - soundfile 会抛 TypeError("Invalid file: tensor([[...]])")。 - - 所以正确契约是:传**文件路径字符串**,让上游自己 torchaudio.load + resample。 - 我们这里只做一次存在性验证,不做缓存(CosyVoice 内部会重复读,省不掉)。 - """ - if path in _VERIFIED_PROMPT_PATHS: - return path - if not os.path.isfile(path): - raise FileNotFoundError(f"prompt wav not found: {path}") - _VERIFIED_PROMPT_PATHS.add(path) - return path - - -# --------------------------------------------------------------------------- -# WebSocket 处理 -# --------------------------------------------------------------------------- -@dataclass -class Session: - session_id: str - language: str = "zh" - prompt_wav: str = DEFAULT_PROMPT_WAV - prompt_text: str = DEFAULT_PROMPT_TEXT - speed: float = 1.0 - utterance_id: int = 0 - - -async def _emit_json(ws: WebSocket, payload: dict[str, Any]) -> None: - if ws.client_state != WebSocketState.CONNECTED: - return - await ws.send_text(json.dumps(payload, ensure_ascii=False)) - - -async def _emit_bytes(ws: WebSocket, data: bytes) -> None: - if ws.client_state != WebSocketState.CONNECTED: - return - AUDIO_BYTES_OUT.inc(len(data)) - await ws.send_bytes(data) - - -def _select_mode(sess: Session) -> str: - """决定走 zero_shot 还是 cross_lingual。 - - 简化规则: - - 有 prompt_text → zero_shot(要求 prompt_lang 与目标 language 同语种;客户端负责) - - 无 prompt_text → cross_lingual(CosyVoice 自动跨语,prompt 只提供声纹) - Phase 3 客户端可以更精细化。 - """ - return "zero_shot" if sess.prompt_text else "cross_lingual" - - -async def _run_synth_session( - ws: WebSocket, sess: Session, bridge: _TextStreamBridge -) -> None: - """协调 worker thread 与 WS: - - 启 worker(在信号量保护下) - - 监听 audio_q → 写 bytes / 错误处理 - - 退出条件:worker put None;或 ws 断开 - """ - audio_q: asyncio.Queue[Any] = asyncio.Queue(maxsize=64) - loop = asyncio.get_running_loop() - mode = _select_mode(sess) - - state.queue_depth += 1 - QUEUE_DEPTH.set(state.queue_depth) - decremented = False - try: - async with state.inference_sem: - state.queue_depth -= 1 - QUEUE_DEPTH.set(state.queue_depth) - decremented = True - - await _emit_json(ws, { - "event": "audio_start", - "sample_rate": state.sample_rate, - "encoding": "pcm_s16le", - "channels": 1, - "utterance_id": sess.utterance_id, - "mode": mode, - }) - - worker = threading.Thread( - target=_run_synth_thread, - kwargs={ - "bridge": bridge, - "audio_q": audio_q, - "loop": loop, - "mode": mode, - "prompt_wav": sess.prompt_wav, - "prompt_text": sess.prompt_text, - "speed": sess.speed, - # Phase 4 Wave 1: log context for leading-silence probe. - "session_id": sess.session_id, - "utterance_id": sess.utterance_id, - }, - daemon=True, - ) - worker.start() - - while True: - item = await audio_q.get() - if item is None: - break - if isinstance(item, BaseException): - await _emit_json(ws, { - "error": f"synthesis failed: {item}", - "fatal": False, - }) - break - if isinstance(item, (bytes, bytearray)): - await _emit_bytes(ws, bytes(item)) - - await _emit_json(ws, { - "event": "audio_end", - "utterance_id": sess.utterance_id, - }) - sess.utterance_id += 1 - _update_gpu_metric() - finally: - if not decremented: - # Cancelled / failed before semaphore acquire returned; ensure the - # queue counter doesn't drift upward across the lifetime of the - # process. Covers BaseException (CancelledError) which the bare - # try/except above could not catch. - state.queue_depth -= 1 - QUEUE_DEPTH.set(state.queue_depth) - - -# --------------------------------------------------------------------------- -# Phase 4 Wave 2 Fix #1: single-segment batch synthesis path -# --------------------------------------------------------------------------- -# Per .planning/debug/cosyvoice-ttft-too-slow.md: when DeepSeek emits a short -# reply (1-2 sentences, 20-30 chars) the bistream LLM stalls in -# "not enough text token" busy-loops because text arrives slower than the -# bistream interleave ratio (5 text → 15 speech tokens) demands. The entire -# LLM stream finishes before flow+hift can decode a single chunk → bistream -# degenerates to batch + adds bistream stall overhead. Bench delta vs true -# batch path: ~1.5 s warm. -# -# Solution: detect single-segment WS sessions (one event=="text" frame with -# is_final_segment=True and no prior text frames) and short-circuit to -# inference_zero_shot(text=str, stream=False). Multi-segment / streaming -# sessions still hit the existing _run_synth_session bistream path -# (preserved as fallback per Pitfall 5 in RESEARCH). -def _run_batch_synth_thread( - text: str, - audio_q: "asyncio.Queue[Any]", - loop: asyncio.AbstractEventLoop, - *, - mode: str, - prompt_wav: str, - prompt_text: str, - speed: float, - session_id: str = "", - utterance_id: int = 0, -) -> None: - """同步 worker — Fix #1 batch path. - - 与 ``_run_synth_thread`` 同形态(信号量 / 异常 / 哨兵 / leading-silence - probe),区别在于: - - ``text`` 是一段完整字符串(不是 generator)。CosyVoice 上游 - ``isinstance(text, typing.Generator)`` gate fail → 走 batch 分支。 - - ``stream=False`` 让上游一次返回所有 chunk(典型 1-2 个 chunk for - 短回复);流式 stall 不会发生。 - - mode 标签后缀 ``_batch``,便于 Prometheus 区分两路。 - """ - def _put(item: Any) -> None: - fut = asyncio.run_coroutine_threadsafe(audio_q.put(item), loop) - try: - fut.result(timeout=10.0) - except Exception: # pragma: no cover - pass - - metric_mode = f"{mode}_batch" - try: - if mode == "cross_lingual": - iterator = state.model.inference_cross_lingual( - text, - _load_prompt_wav(prompt_wav), - stream=False, - speed=speed, - ) - else: - # Same Fix #3 default-cache short-circuit as _run_synth_thread. - if ( - prompt_wav == DEFAULT_PROMPT_WAV - and prompt_text == DEFAULT_PROMPT_TEXT - ): - iterator = state.model.inference_zero_shot( - text, - "", - "", - zero_shot_spk_id="default", - stream=False, - speed=speed, - ) - else: - iterator = state.model.inference_zero_shot( - text, - prompt_text, - _load_prompt_wav(prompt_wav), - stream=False, - speed=speed, - ) - first = True - t0 = time.perf_counter() - for output in iterator: - chunk = output.get("tts_speech") if isinstance(output, dict) else None - if chunk is None: - continue - pcm = _audio_tensor_to_pcm_bytes(chunk) - if first: - FIRST_AUDIO_LATENCY.labels(mode=metric_mode).observe( - time.perf_counter() - t0 - ) - # Phase 4 Wave 1 leading-silence probe (mirrors _run_synth_thread). - # The batch path should produce ~zero leading silence (no - # bistream stall lead-in) — this metric will validate that. - try: - samples = np.frombuffer(pcm, dtype=np.int16) - leading = 0 - for s in samples: - if abs(int(s)) < 32: - leading += 1 - else: - break - sr_hz = state.sample_rate or COSYVOICE_OUTPUT_SAMPLE_RATE - leading_silence_ms = (leading * 1000.0) / sr_hz - log.info( - "first_chunk_leading_silence", - extra={ - "mode": metric_mode, - "session_id": session_id, - "utterance_id": utterance_id, - "leading_samples": leading, - "leading_silence_ms": round(leading_silence_ms, 1), - }, - ) - LEADING_SILENCE_MS.labels(mode=metric_mode).observe( - leading_silence_ms - ) - except Exception: # pragma: no cover - log.exception("leading-silence probe failed; continuing") - first = False - _put(pcm) - TOTAL_SYNTH_LATENCY.labels(mode=metric_mode).observe( - time.perf_counter() - t0 - ) - SYNTH_TOTAL.labels(mode=metric_mode, outcome="ok").inc() - except Exception as exc: - SYNTH_TOTAL.labels(mode=metric_mode, outcome="error").inc() - log.exception("batch synthesis worker failed", - extra={"err": str(exc), "mode": metric_mode}) - msg = str(exc).lower() - if "out of memory" in msg or "cuda" in msg: - try: - import torch - - torch.cuda.empty_cache() - except Exception: - pass - _put(exc) - finally: - _put(None) - - -async def _run_batch_synth_session( - ws: WebSocket, sess: Session, text: str -) -> None: - """Sibling of ``_run_synth_session`` for the batch (single-segment) path. - - No bridge — ``text`` is the full utterance. Reuses the existing - ``state.inference_sem`` + ``QUEUE_DEPTH`` accounting so concurrency caps - are preserved (T-04-03 mitigation per plan threat model). - """ - audio_q: asyncio.Queue[Any] = asyncio.Queue(maxsize=64) - loop = asyncio.get_running_loop() - mode = _select_mode(sess) - - state.queue_depth += 1 - QUEUE_DEPTH.set(state.queue_depth) - decremented = False - try: - async with state.inference_sem: - state.queue_depth -= 1 - QUEUE_DEPTH.set(state.queue_depth) - decremented = True - - await _emit_json(ws, { - "event": "audio_start", - "sample_rate": state.sample_rate, - "encoding": "pcm_s16le", - "channels": 1, - "utterance_id": sess.utterance_id, - "mode": f"{mode}_batch", - }) - - worker = threading.Thread( - target=_run_batch_synth_thread, - kwargs={ - "text": text, - "audio_q": audio_q, - "loop": loop, - "mode": mode, - "prompt_wav": sess.prompt_wav, - "prompt_text": sess.prompt_text, - "speed": sess.speed, - "session_id": sess.session_id, - "utterance_id": sess.utterance_id, - }, - daemon=True, - ) - worker.start() - - while True: - item = await audio_q.get() - if item is None: - break - if isinstance(item, BaseException): - await _emit_json(ws, { - "error": f"synthesis failed: {item}", - "fatal": False, - }) - break - if isinstance(item, (bytes, bytearray)): - await _emit_bytes(ws, bytes(item)) - - await _emit_json(ws, { - "event": "audio_end", - "utterance_id": sess.utterance_id, - }) - sess.utterance_id += 1 - _update_gpu_metric() - finally: - if not decremented: - state.queue_depth -= 1 - QUEUE_DEPTH.set(state.queue_depth) - - -async def _handle_ws(ws: WebSocket) -> None: - if state.shutdown_event.is_set(): - SESSIONS_REJECTED.labels(reason="shutdown").inc() - await ws.close(code=1013, reason="server shutting down") - return - if state.active_session_count >= MAX_CONCURRENT_SESSIONS * 2: - SESSIONS_REJECTED.labels(reason="saturation").inc() - await ws.close(code=1013, reason="server saturated") - return - - await ws.accept() - SESSIONS_OPENED.inc() - state.active_session_count += 1 - ACTIVE_SESSIONS.set(state.active_session_count) - sess = Session(session_id=str(uuid.uuid4())) - log.info("ws session opened", extra={"session_id": sess.session_id}) - - # 当前活动的合成 task / bridge;客户端可在一个 WS 内连续合成多句话 - current_task: asyncio.Task[None] | None = None - current_bridge: _TextStreamBridge | None = None - # Phase 4 Wave 2 Fix #1: per-utterance dispatch counters. Reset on each - # event=="start". The first event=="text" frame consults these to choose - # between batch path (single-segment short-circuit) and bistream path. - text_frame_count_for_session = 0 - batch_path_decided = False - started = False # whether the client sent event=="start" yet - - try: - while True: - try: - msg = await ws.receive() - except WebSocketDisconnect: - break - - if msg.get("type") != "websocket.receive": - break - - if "text" not in msg or msg["text"] is None: - await _emit_json(ws, { - "error": "binary frames not accepted by /ws/synthesize", - "fatal": False, - }) - continue - - try: - cmd = json.loads(msg["text"]) - except json.JSONDecodeError: - await _emit_json(ws, {"error": "invalid JSON", "fatal": False}) - continue - - event = cmd.get("event") - if event == "start": - # 中止上一段合成(如果还在跑);客户端要新开就要先 stop 上一段或我们这里强制 - if current_bridge is not None: - current_bridge.close() - if current_task is not None and not current_task.done(): - try: - await asyncio.wait_for(current_task, timeout=5) - except (TimeoutError, asyncio.TimeoutError): - log.warning("previous synth did not finish in time; cancelling") - current_task.cancel() - try: - await current_task - except (asyncio.CancelledError, Exception): - pass - sess.language = str(cmd.get("language", "zh")) - sess.speed = float(cmd.get("speed", 1.0)) - if "prompt_wav" in cmd and cmd["prompt_wav"]: - sess.prompt_wav = str(cmd["prompt_wav"]) - if "prompt_text" in cmd: - sess.prompt_text = str(cmd.get("prompt_text") or "") - if cmd.get("session_id"): - sess.session_id = str(cmd["session_id"]) - - # Fix #1: defer eager bridge/task creation. The first - # event=="text" frame decides whether to take batch (single- - # segment short-circuit) or bistream (lazy-init bridge) path. - current_bridge = None - current_task = None - text_frame_count_for_session = 0 - batch_path_decided = False - started = True - elif event == "text": - if not started: - await _emit_json(ws, { - "error": "must send {event:'start'} before text frames", - "fatal": False, - }) - continue - text = str(cmd.get("text", "")) - is_final = bool(cmd.get("is_final_segment")) - - # Fix #1 short-path: first AND final text frame in a session - # → batch synthesize (skip _TextStreamBridge bistream entirely). - if ( - not batch_path_decided - and is_final - and text_frame_count_for_session == 0 - and current_bridge is None - ): - batch_path_decided = True - current_task = asyncio.create_task( - _run_batch_synth_session(ws, sess, text) - ) - try: - await asyncio.wait_for(current_task, timeout=120) - except (TimeoutError, asyncio.TimeoutError): - await _emit_json(ws, { - "error": "synthesis timeout", - "fatal": False, - }) - current_task = None - continue - - # Otherwise: bistream path. Lazy-init bridge + task on first - # text frame; subsequent text frames push into the bridge. - batch_path_decided = True - text_frame_count_for_session += 1 - if current_bridge is None: - current_bridge = _TextStreamBridge() - current_task = asyncio.create_task( - _run_synth_session(ws, sess, current_bridge) - ) - if text: - current_bridge.push_text(text) - if is_final: - # 句末 → 关 generator 触发 inference flush - current_bridge.close() - current_bridge = None - if current_task is not None: - try: - await asyncio.wait_for(current_task, timeout=120) - except (TimeoutError, asyncio.TimeoutError): - await _emit_json(ws, { - "error": "synthesis timeout", - "fatal": False, - }) - current_task = None - elif event == "stop": - if current_bridge is not None: - current_bridge.close() - current_bridge = None - if current_task is not None: - try: - await asyncio.wait_for(current_task, timeout=30) - except (TimeoutError, asyncio.TimeoutError): - log.warning("synth did not drain on stop") - current_task = None - break - else: - await _emit_json(ws, { - "error": f"unknown event: {event!r}", - "fatal": False, - }) - except Exception as exc: - log.exception("ws session crashed", extra={ - "session_id": sess.session_id, "err": str(exc), - }) - finally: - # 兜底:把还没结束的 worker 推到结束 - if current_bridge is not None: - current_bridge.close() - if current_task is not None and not current_task.done(): - try: - await asyncio.wait_for(current_task, timeout=10) - except (TimeoutError, asyncio.TimeoutError): - current_task.cancel() - state.active_session_count -= 1 - ACTIVE_SESSIONS.set(state.active_session_count) - log.info("ws session closed", extra={"session_id": sess.session_id}) - if ws.client_state != WebSocketState.DISCONNECTED: - try: - await ws.close() - except Exception: - pass - - -# --------------------------------------------------------------------------- -# FastAPI / lifespan -# --------------------------------------------------------------------------- -@asynccontextmanager -async def lifespan(_app: FastAPI) -> AsyncIterator[None]: - state.gpu_available = _check_gpu() - if not state.gpu_available: - log.warning("CUDA not available; CosyVoice will run on CPU (very slow)") - try: - state.model = await asyncio.to_thread(_load_model) - state.model_loaded = True - except Exception as exc: - log.exception("model load failed", extra={"err": str(exc)}) - state.model_loaded = False - - def _handle_signal() -> None: - if state.shutdown_event.is_set(): - return - log.info("received signal; initiating graceful shutdown") - state.shutdown_event.set() - for srv in state.servers: - srv.should_exit = True - - loop = asyncio.get_running_loop() - for sig in (signal.SIGTERM, signal.SIGINT): - try: - loop.add_signal_handler(sig, _handle_signal) - except NotImplementedError: - pass - - yield - - log.info("shutdown initiated; draining sessions", - extra={"active": state.active_session_count}) - deadline = time.monotonic() + GRACEFUL_TIMEOUT_SEC - while state.active_session_count > 0 and time.monotonic() < deadline: - await asyncio.sleep(0.5) - if state.active_session_count > 0: - log.warning("graceful timeout; forcing exit", - extra={"remaining": state.active_session_count}) - - -app_ws = FastAPI(lifespan=lifespan) -app_http = FastAPI() - - -@app_http.get("/health") -async def health() -> JSONResponse: - is_shutting = state.shutdown_event.is_set() - ok = state.model_loaded and not is_shutting - payload = { - "status": "ok" if ok else "degraded", - "model_loaded": state.model_loaded, - "model_id": COSYVOICE_MODEL_ID, - "gpu_available": state.gpu_available, - "active_sessions": state.active_session_count, - "queue_depth": state.queue_depth, - "max_concurrent_sessions": MAX_CONCURRENT_SESSIONS, - "shutting_down": is_shutting, - "output_sample_rate": state.sample_rate, - "output_encoding": "pcm_s16le", - "output_channels": 1, - } - return JSONResponse(payload, status_code=200 if ok else 503) - - -@app_http.get("/metrics") -async def metrics() -> Response: - _update_gpu_metric() - return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST) - - -@app_ws.websocket("/ws/synthesize") -async def synthesize(ws: WebSocket) -> None: - await _handle_ws(ws) - - -def main() -> None: - config_http = uvicorn.Config( - app_http, - host="0.0.0.0", # noqa: S104 - port=PORT_HTTP, - log_config=None, - log_level=LOG_LEVEL.lower(), - access_log=False, - ) - config_ws = uvicorn.Config( - app_ws, - host="0.0.0.0", # noqa: S104 - port=PORT_WS, - log_config=None, - log_level=LOG_LEVEL.lower(), - access_log=False, - ws_ping_interval=20, - ws_ping_timeout=20, - ) - server_http = uvicorn.Server(config_http) - server_ws = uvicorn.Server(config_ws) - state.servers = [server_ws, server_http] - server_ws.install_signal_handlers = lambda: None # type: ignore[method-assign] - server_http.install_signal_handlers = lambda: None # type: ignore[method-assign] - - async def _run() -> None: - ws_task = asyncio.create_task(server_ws.serve()) - http_task = asyncio.create_task(server_http.serve()) - done, pending = await asyncio.wait( - {ws_task, http_task}, return_when=asyncio.FIRST_COMPLETED - ) - for srv in state.servers: - srv.should_exit = True - for t in pending: - try: - await t - except Exception: - log.exception("server task error during shutdown") - - asyncio.run(_run()) - - -if __name__ == "__main__": - main() diff --git a/infra/gpu-services/docker-compose.yml b/infra/gpu-services/docker-compose.yml deleted file mode 100644 index 9cfba4d..0000000 --- a/infra/gpu-services/docker-compose.yml +++ /dev/null @@ -1,112 +0,0 @@ -# VocalizeAI GPU 推理节点 — 编排两个服务(SenseVoice STT + CosyVoice TTS) -# -# 设计要点(云端可移植): -# - GPU 通过 deploy.resources.reservations.devices 声明(capabilities: [gpu]), -# 在 Windows WSL2 + NVIDIA Container Toolkit 与纯 Linux 云 GPU 实例上语义相同 -# - 模型缓存挂在 ./models(外部卷);删/重建容器不丢权重,节省 5-10 分钟下载 -# - 日志挂在 ./logs;容器内 /var/log/vocalize 由 server.py 选择写不写文件(默认只 stdout, -# 容器编排层抓走,符合 12-factor);该目录留作未来需要落盘的扩展点 -# - bind 0.0.0.0:容器内监听所有接口;宿主决定暴露面 -# - Windows:用 Tailscale,仅 100.x 路由可达;可在 Windows Firewall 限制 127.0.0.1 -# + Tailscale 接口 -# - 云:换成云 LB / Security Group 控制 -# - restart: unless-stopped:宿主重启或服务崩溃自动起;docker HEALTHCHECK 失败 → 重启 -# -# 用法: -# cp .env.example .env -# # 编辑 .env -# docker compose up -d -# docker compose logs -f -# -# 关停:docker compose down(会发 SIGTERM,server.py 内部 graceful drain) -version: "3.8" - -services: - sensevoice: - build: - context: ./sensevoice - dockerfile: Dockerfile - image: vocalize-sensevoice:latest - container_name: vocalize-sensevoice - restart: unless-stopped - env_file: - - .env - environment: - # 端口:默认值与 Dockerfile 内 ENV 一致;这里再显式声明以便从 .env 覆盖 - PORT_WS: ${SENSEVOICE_PORT_WS:-8000} - PORT_HTTP: ${SENSEVOICE_PORT_HTTP:-8080} - LOG_LEVEL: ${LOG_LEVEL:-INFO} - MAX_CONCURRENT_SESSIONS: ${SENSEVOICE_MAX_SESSIONS:-4} - MODEL_CACHE_DIR: /models - HF_HOME: /models/huggingface - HUGGINGFACE_HUB_CACHE: /models/huggingface - MODELSCOPE_CACHE: /models/modelscope - TORCH_HOME: /models/torch - SENSEVOICE_DEVICE: ${SENSEVOICE_DEVICE:-cuda:0} - AUDIO_SAMPLE_RATE: ${AUDIO_SAMPLE_RATE:-16000} - ports: - # 主机:容器;GPU_HOST_BIND 默认 0.0.0.0(监听所有),Windows 上建议改成 - # Tailscale 接口 IP(如 100.x.x.x)以避免 LAN 暴露。云 GPU 上由云 LB / SG 管理 - - "${GPU_HOST_BIND:-0.0.0.0}:${SENSEVOICE_PORT_WS:-8000}:${SENSEVOICE_PORT_WS:-8000}" - - "${GPU_HOST_BIND:-0.0.0.0}:${SENSEVOICE_PORT_HTTP:-8080}:${SENSEVOICE_PORT_HTTP:-8080}" - volumes: - - ./models:/models - - ./logs/sensevoice:/var/log/vocalize - deploy: - resources: - reservations: - devices: - - driver: nvidia - count: 1 - capabilities: [gpu] - # 容器内 HEALTHCHECK 已在 Dockerfile 里;这里不重复 - # 但显式 stop_grace_period 保证 graceful drain 完成(默认 10s 不够 SIGTERM 排空) - stop_grace_period: 90s - - cosyvoice: - build: - context: ./cosyvoice - dockerfile: Dockerfile - image: vocalize-cosyvoice:latest - container_name: vocalize-cosyvoice - restart: unless-stopped - env_file: - - .env - environment: - PORT_WS: ${COSYVOICE_PORT_WS:-8001} - PORT_HTTP: ${COSYVOICE_PORT_HTTP:-8081} - LOG_LEVEL: ${LOG_LEVEL:-INFO} - MAX_CONCURRENT_SESSIONS: ${COSYVOICE_MAX_SESSIONS:-2} - MODEL_CACHE_DIR: /models - HF_HOME: /models/huggingface - HUGGINGFACE_HUB_CACHE: /models/huggingface - MODELSCOPE_CACHE: /models/modelscope - TORCH_HOME: /models/torch - COSYVOICE_MODEL_DIR: ${COSYVOICE_MODEL_DIR:-/models/cosyvoice/CosyVoice2-0.5B} - COSYVOICE_DEVICE: ${COSYVOICE_DEVICE:-cuda:0} - COSYVOICE_OUTPUT_SAMPLE_RATE: ${COSYVOICE_OUTPUT_SAMPLE_RATE:-24000} - # Use the upstream bundled zero-shot prompt by default. The optional - # ./prompts mount can still override this, but Docker Desktop/WSL can - # occasionally present that bind as an empty read-only tmpfs. - DEFAULT_PROMPT_WAV: ${COSYVOICE_DEFAULT_PROMPT_WAV:-/opt/CosyVoice/asset/zero_shot_prompt.wav} - DEFAULT_PROMPT_TEXT: ${COSYVOICE_DEFAULT_PROMPT_TEXT:-希望你以后能够做的比我还好呦。} - ports: - - "${GPU_HOST_BIND:-0.0.0.0}:${COSYVOICE_PORT_WS:-8001}:${COSYVOICE_PORT_WS:-8001}" - - "${GPU_HOST_BIND:-0.0.0.0}:${COSYVOICE_PORT_HTTP:-8081}:${COSYVOICE_PORT_HTTP:-8081}" - volumes: - - ./models:/models - - ./logs/cosyvoice:/var/log/vocalize - # prompts 目录挂卷,便于用户自带声纹样本(覆盖 /app/prompts/default_zh.wav) - - ./prompts:/app/prompts:ro - deploy: - resources: - reservations: - devices: - - driver: nvidia - count: 1 - capabilities: [gpu] - stop_grace_period: 90s - -# 注:不显式定义 networks。docker compose 默认起一个 bridge 网络,两个服务可互相 -# DNS 解析(vocalize-sensevoice / vocalize-cosyvoice),但本服务对外暴露不依赖此点。 -# 客户端(Pi 编排器 / Mac demo)直接通过 ${GPU_HOST}:${PORT} 访问。 diff --git a/infra/gpu-services/healthcheck.sh b/infra/gpu-services/healthcheck.sh deleted file mode 100755 index a1e1b37..0000000 --- a/infra/gpu-services/healthcheck.sh +++ /dev/null @@ -1,129 +0,0 @@ -#!/usr/bin/env bash -# 远程探测 VocalizeAI GPU 服务的可用性 -# -# 用途:从 Mac / Pi 上跑这脚本探测 Windows GPU 主机(或云 GPU 实例)的两个服务是否 -# 健康。在 CI / 排障时也可用。 -# -# 用法: -# bash healthcheck.sh # 用 env $GPU_HOST 或 localhost -# bash healthcheck.sh --gpu-host 100.x.x.x # 显式指定 -# GPU_HOST=100.x.x.x bash healthcheck.sh # 通过 env -# -# 退出码: -# 0 = 全绿(两个服务的 /health 都返 status=ok) -# 1 = 任一探测失败(连接失败 / 状态非 ok / JSON 解析失败) -# -# 依赖:bash, curl;jq 可选(没装会回退到 grep) - -set -uo pipefail - -GPU_HOST_DEFAULT="${GPU_HOST:-localhost}" -SENSEVOICE_PORT_HTTP="${SENSEVOICE_PORT_HTTP:-8080}" -COSYVOICE_PORT_HTTP="${COSYVOICE_PORT_HTTP:-8081}" -TIMEOUT="${HEALTHCHECK_TIMEOUT:-5}" - -GPU_HOST="$GPU_HOST_DEFAULT" - -while [[ $# -gt 0 ]]; do - case "$1" in - --gpu-host) - GPU_HOST="$2"; shift 2 ;; - --gpu-host=*) - GPU_HOST="${1#--gpu-host=}"; shift ;; - -h|--help) - grep '^#' "$0" | sed 's/^# \?//' - exit 0 ;; - *) - echo "Unknown arg: $1" >&2; exit 2 ;; - esac -done - -# 颜色(仅 stderr 是 tty 时用) -if [[ -t 1 ]]; then - GREEN=$'\033[32m'; RED=$'\033[31m'; YELLOW=$'\033[33m'; BOLD=$'\033[1m'; RESET=$'\033[0m' -else - GREEN=""; RED=""; YELLOW=""; BOLD=""; RESET="" -fi - -have_jq=0 -command -v jq >/dev/null 2>&1 && have_jq=1 - -failures=0 - -probe_health() { - local name="$1" url="$2" - local body status_code body_status body_model_loaded - # 同时拿 body 与 status code;错误重定向防 stderr 污染 - local resp - resp=$(curl -sS -m "$TIMEOUT" -w "\n%{http_code}" "$url" 2>&1) || { - echo "${RED}FAIL${RESET} $name health: connect error → $url" - echo " $resp" - failures=$((failures + 1)) - return 1 - } - status_code=$(echo "$resp" | tail -n1) - body=$(echo "$resp" | sed '$d') - - if [[ "$status_code" != "200" ]]; then - echo "${RED}FAIL${RESET} $name health: HTTP $status_code → $url" - echo " $body" - failures=$((failures + 1)) - return 1 - fi - - if (( have_jq )); then - body_status=$(echo "$body" | jq -r '.status // "?"') - body_model_loaded=$(echo "$body" | jq -r '.model_loaded // false') - else - body_status=$(echo "$body" | grep -oE '"status"[ :]*"[^"]+"' | sed 's/.*"\([^"]*\)"$/\1/') - body_model_loaded=$(echo "$body" | grep -oE '"model_loaded"[ :]*[a-z]+' | awk -F: '{print $2}' | tr -d ' ') - fi - - if [[ "$body_status" == "ok" && "$body_model_loaded" == "true" ]]; then - echo "${GREEN}OK${RESET} $name health: status=ok model_loaded=true" - return 0 - else - echo "${YELLOW}WARN${RESET} $name health: status=$body_status model_loaded=$body_model_loaded" - echo " $body" - failures=$((failures + 1)) - return 1 - fi -} - -probe_metrics() { - local name="$1" url="$2" - local resp - resp=$(curl -sS -m "$TIMEOUT" "$url" 2>&1) || { - echo "${RED}FAIL${RESET} $name metrics: connect error → $url" - failures=$((failures + 1)) - return 1 - } - if echo "$resp" | head -1 | grep -q "^# HELP"; then - local lines - lines=$(echo "$resp" | wc -l | tr -d ' ') - echo "${GREEN}OK${RESET} $name metrics: $lines lines (Prometheus format)" - else - echo "${RED}FAIL${RESET} $name metrics: not Prometheus format" - echo "$resp" | head -5 - failures=$((failures + 1)) - return 1 - fi -} - -echo "${BOLD}probing GPU host: ${GPU_HOST}${RESET}" -echo - -probe_health "sensevoice" "http://${GPU_HOST}:${SENSEVOICE_PORT_HTTP}/health" -probe_metrics "sensevoice" "http://${GPU_HOST}:${SENSEVOICE_PORT_HTTP}/metrics" -echo -probe_health "cosyvoice " "http://${GPU_HOST}:${COSYVOICE_PORT_HTTP}/health" -probe_metrics "cosyvoice " "http://${GPU_HOST}:${COSYVOICE_PORT_HTTP}/metrics" - -echo -if (( failures == 0 )); then - echo "${GREEN}${BOLD}all probes green${RESET}" - exit 0 -else - echo "${RED}${BOLD}${failures} probe(s) failed${RESET}" - exit 1 -fi diff --git a/infra/gpu-services/prompts/NOTICE b/infra/gpu-services/prompts/NOTICE deleted file mode 100644 index 85b6e11..0000000 --- a/infra/gpu-services/prompts/NOTICE +++ /dev/null @@ -1,8 +0,0 @@ -default_zh.wav -============== - -Source : https://github.com/FunAudioLLM/CosyVoice/blob/main/asset/zero_shot_prompt.wav -License: Apache License 2.0 (https://github.com/FunAudioLLM/CosyVoice/blob/main/LICENSE) -Status : Placeholder voice reference for CosyVoice 2 zero-shot synthesis. - Will be replaced with a project-owned voice sample before public release. - When you replace it, delete this NOTICE alongside. diff --git a/infra/gpu-services/prompts/default_zh.wav b/infra/gpu-services/prompts/default_zh.wav deleted file mode 100644 index a7b9d95..0000000 Binary files a/infra/gpu-services/prompts/default_zh.wav and /dev/null differ diff --git a/infra/gpu-services/sensevoice/Dockerfile b/infra/gpu-services/sensevoice/Dockerfile deleted file mode 100644 index 3fdfdb4..0000000 --- a/infra/gpu-services/sensevoice/Dockerfile +++ /dev/null @@ -1,97 +0,0 @@ -# SenseVoice (FunAudioLLM) STT 推理服务 — 多语流式 WebSocket -# -# 基础镜像:nvidia/cuda 12.4 runtime on Ubuntu 22.04(纯 Linux,无 Windows/WSL 依赖) -# 该镜像在 Windows WSL2 + NVIDIA Container Toolkit 下可跑,在 Aliyun GN6 / AWS g5 / -# Lambda Labs 等纯 Linux 云 GPU 实例上同样可跑——这是云端可移植性原则 #1。 -FROM nvidia/cuda:12.4.0-runtime-ubuntu22.04 - -# 防止 apt 在 build 时阻塞交互式询问 -ENV DEBIAN_FRONTEND=noninteractive \ - PYTHONUNBUFFERED=1 \ - PYTHONDONTWRITEBYTECODE=1 \ - PIP_NO_CACHE_DIR=1 \ - PIP_DISABLE_PIP_VERSION_CHECK=1 - -# 系统依赖: -# - python3.11 + 工具链:funasr 与 torch wheel 都要 3.11 -# - ffmpeg / libsndfile1:音频解码(funasr 的 example 输入会走 soundfile/torchaudio) -# - curl:HEALTHCHECK 用 -# - git:modelscope/huggingface_hub 在 fallback 路径上可能 git-clone 模型仓库 -# - ca-certificates:HTTPS 访问 ModelScope/HF -# 注意:版本固定到 22.04 默认源即可,不锁版本以便安全补丁滚动 -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - software-properties-common \ - ca-certificates \ - curl \ - git \ - ffmpeg \ - libsndfile1 \ - && add-apt-repository -y ppa:deadsnakes/ppa \ - && apt-get update \ - && apt-get install -y --no-install-recommends \ - python3.11 \ - python3.11-venv \ - python3.11-dev \ - python3.11-distutils \ - && curl -sS https://bootstrap.pypa.io/get-pip.py | python3.11 \ - && ln -sf /usr/bin/python3.11 /usr/local/bin/python \ - && ln -sf /usr/bin/python3.11 /usr/local/bin/python3 \ - && rm -rf /var/lib/apt/lists/* - -# PyTorch:必须从 CUDA 12.x index 装,否则会装到 CPU wheel——funasr 启动时会假装能跑 -# 但 GPU device 拿不到,触发 `RuntimeError: CUDA not available`。 -# torch 2.7.1+cu128:cu128 wheel 编译时包含 sm_120 (Blackwell),5070 Ti 以及未来 -# RTX 50 系卡需要。早期 cu124 wheel 只到 sm_90,会报 "CUDA capability sm_120 is not -# compatible" 警告并 fallback 到 CPU,inference 时严重降速或崩溃。 -RUN pip install --no-cache-dir \ - --index-url https://download.pytorch.org/whl/cu128 \ - torch==2.7.1 torchaudio==2.7.1 - -# 应用依赖: -# - funasr:SenseVoice 的官方框架(modelscope/funasr) -# - modelscope:funasr 的默认模型 hub 客户端 -# - websockets / fastapi / uvicorn:WS + HTTP 协议层 -# - prometheus-client:/metrics 端点 -# - numpy / scipy:funasr 间接依赖;显式列出避免不同 funasr 版本间的隐式漂移 -RUN pip install --no-cache-dir \ - funasr==1.1.14 \ - modelscope==1.18.1 \ - "huggingface_hub>=0.24,<1.0" \ - websockets==12.0 \ - "fastapi>=0.110,<1.0" \ - "uvicorn[standard]>=0.27,<1.0" \ - prometheus-client==0.20.0 \ - "numpy>=1.26,<2.0" \ - "scipy>=1.12,<2.0" - -# 模型缓存目录:所有 hub 客户端都重定向到 /models -# 容器外挂 -v ./models:/models 让模型只下一次,重启/重建 image 不重下 -ENV MODEL_CACHE_DIR=/models \ - HF_HOME=/models/huggingface \ - HUGGINGFACE_HUB_CACHE=/models/huggingface \ - MODELSCOPE_CACHE=/models/modelscope \ - TORCH_HOME=/models/torch \ - PORT_WS=8000 \ - PORT_HTTP=8080 \ - LOG_LEVEL=INFO \ - MAX_CONCURRENT_SESSIONS=4 \ - SENSEVOICE_MODEL_ID=iic/SenseVoiceSmall \ - SENSEVOICE_DEVICE=cuda:0 \ - AUDIO_SAMPLE_RATE=16000 - -WORKDIR /app -COPY server.py /app/server.py - -# /models 由宿主挂卷接管;如果没挂,启动时 funasr 会在容器内下载到此目录(容器删除后丢失) -RUN mkdir -p /models /var/log/vocalize - -EXPOSE 8000 8080 - -# HEALTHCHECK:HTTP /health 必须 200;模型未加载完时返回 503,docker 会标 unhealthy -# start-period 给模型加载留 5 分钟(首次冷启会下 ~1GB 权重) -HEALTHCHECK --interval=30s --timeout=10s --start-period=300s --retries=3 \ - CMD curl -fsS http://localhost:${PORT_HTTP}/health || exit 1 - -# tini-style 信号转发:Python 进程作为 PID 1 接 SIGTERM;server.py 内 lifespan 处理 graceful drain -CMD ["python", "/app/server.py"] diff --git a/infra/gpu-services/sensevoice/server.py b/infra/gpu-services/sensevoice/server.py deleted file mode 100644 index 6a0c176..0000000 --- a/infra/gpu-services/sensevoice/server.py +++ /dev/null @@ -1,632 +0,0 @@ -"""SenseVoice STT 服务 — WebSocket 流式 ASR + HTTP 健康/指标端点。 - -云端可移植:纯 Python,配置全部来自 env,不依赖 Windows / WSL2 任何特性。 - -WebSocket 协议 (`/ws/transcribe`) ------------------------------------ -客户端 → 服务端: -- 二进制帧:原始 PCM int16 LE,单声道,16kHz(由 env AUDIO_SAMPLE_RATE 暴露) -- 文本帧(JSON): - - ``{"event": "start", "session_id": "", "language": "auto"|"zh"|"en"|...}`` - 会话开始;language 决定 SenseVoice 解码语言提示,默认 "auto" - - ``{"event": "end_of_utterance"}`` 客户端 VAD 判定本句话说完,触发 final 推理 - - ``{"event": "stop"}`` 结束会话;服务端触发剩余 buffer 的最后一次 final 后关闭 - -服务端 → 客户端(JSON 文本帧): -- ``{"text": "...", "is_final": bool, "confidence": float, "start_time": float, - "end_time": float, "utterance_id": int, "language": "zh"|"en"|...}`` - 其中 start_time/end_time 是相对会话开始的秒数。 -- 错误:``{"error": "", "fatal": bool}``;fatal=True 时服务端会关闭连接。 - -设计取舍(best-effort,Phase 1 再补强) ---------------------------------------- -SenseVoice native API 是非流式的——AutoModel.generate 会对一整段音频跑一次 forward。 -真正的"低延迟流式 partial"需要 ONNX 块推理或 cache-mode(funasr paraformer-streaming -那一套),SenseVoiceSmall 上游目前没有 ready-to-use 的流式 API。本服务的取舍: - -- final transcript:客户端发 ``end_of_utterance`` 时触发,对累积的 buffer 跑一次完整 - 推理。准确率与离线一致。 -- partial transcript:当 buffer 长度跨过 ``PARTIAL_INTERVAL_SEC`` 阈值(默认 1.5s) - 时跑一次 best-effort 推理,``is_final=False``。Phase 1 客户端可忽略 partial 直接 - 靠 final,端到端延迟由 VAD 决定(典型 200-500ms)。 -- partial 不参与 utterance_id 计数;同一 utterance 的所有 partial+final 共享同一 - ``utterance_id``。 - -TODO(phase-1):评估 funasr-onnx 的 SenseVoiceSmall 流式包装;若延迟可接受则替换 partial -路径以拿到 token-level 增量输出。 - -并发与资源 ----------- -- 全局 ``asyncio.Semaphore(MAX_CONCURRENT_SESSIONS)`` 限制同时进行的 WS 会话数。 -- 每次 inference 通过 ``asyncio.to_thread`` 调入 funasr(CPU/GPU 阻塞),避免阻塞 event loop。 -- ``torch.cuda.OutOfMemoryError``:捕获 → 清空 cache → 回客户端 fatal error。 - -优雅停机 --------- -SIGTERM → ``shutdown_event.set()``: -1. HTTP /health 返回 status="degraded";编排器健康检查会移除流量 -2. WS 服务拒绝新连接(直接 close 1013 try-again-later) -3. 已建立的 WS 会话被允许跑完当前 utterance 后正常关闭 -4. 最长等待 ``GRACEFUL_TIMEOUT_SEC``(默认 60s),超时则强制退出 - -日志 ----- -JSON line 格式输出到 stdout:``{"ts":"...","level":"INFO","msg":"...","..."}`` -""" -from __future__ import annotations - -import asyncio -import json -import logging -import os -import signal -import sys -import time -import uuid -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager -from dataclasses import dataclass, field -from typing import Any - -import numpy as np -import uvicorn -from fastapi import FastAPI, Response -from fastapi.responses import JSONResponse -from prometheus_client import ( - CONTENT_TYPE_LATEST, - Counter, - Gauge, - Histogram, - generate_latest, -) -from starlette.websockets import WebSocket, WebSocketDisconnect, WebSocketState - -# --------------------------------------------------------------------------- -# 配置(全部 env,无硬编码) -# --------------------------------------------------------------------------- -PORT_WS = int(os.getenv("PORT_WS", "8000")) -PORT_HTTP = int(os.getenv("PORT_HTTP", "8080")) -LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper() -MAX_CONCURRENT_SESSIONS = int(os.getenv("MAX_CONCURRENT_SESSIONS", "4")) -SENSEVOICE_MODEL_ID = os.getenv("SENSEVOICE_MODEL_ID", "iic/SenseVoiceSmall") -SENSEVOICE_DEVICE = os.getenv("SENSEVOICE_DEVICE", "cuda:0") -AUDIO_SAMPLE_RATE = int(os.getenv("AUDIO_SAMPLE_RATE", "16000")) -PARTIAL_INTERVAL_SEC = float(os.getenv("PARTIAL_INTERVAL_SEC", "1.5")) -MAX_UTTERANCE_SEC = float(os.getenv("MAX_UTTERANCE_SEC", "30.0")) -GRACEFUL_TIMEOUT_SEC = float(os.getenv("GRACEFUL_TIMEOUT_SEC", "60")) - - -# --------------------------------------------------------------------------- -# 结构化 JSON 日志:写到 stdout,符合容器编排标准 -# --------------------------------------------------------------------------- -class JsonFormatter(logging.Formatter): - def format(self, record: logging.LogRecord) -> str: - payload: dict[str, Any] = { - "ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S.%fZ"), - "level": record.levelname, - "logger": record.name, - "msg": record.getMessage(), - } - if record.exc_info: - payload["exc"] = self.formatException(record.exc_info) - # 任何额外字段(如 session_id、duration_ms)通过 logger.info(..., extra={...}) 注入 - for k, v in record.__dict__.items(): - if k in payload or k.startswith("_") or k in ( - "name", "msg", "args", "levelname", "levelno", "pathname", "filename", - "module", "exc_info", "exc_text", "stack_info", "lineno", "funcName", - "created", "msecs", "relativeCreated", "thread", "threadName", - "processName", "process", "message", "taskName", - ): - continue - payload[k] = v - return json.dumps(payload, ensure_ascii=False, default=str) - - -def _setup_logging() -> logging.Logger: - handler = logging.StreamHandler(sys.stdout) - handler.setFormatter(JsonFormatter()) - root = logging.getLogger() - root.handlers = [handler] - root.setLevel(LOG_LEVEL) - return logging.getLogger("sensevoice") - - -log = _setup_logging() - - -# --------------------------------------------------------------------------- -# Prometheus 指标 -# --------------------------------------------------------------------------- -SESSIONS_OPENED = Counter("sensevoice_sessions_opened_total", "WS sessions opened") -SESSIONS_REJECTED = Counter( - "sensevoice_sessions_rejected_total", - "WS sessions rejected (saturation / shutdown)", - ["reason"], -) -INFERENCES_TOTAL = Counter( - "sensevoice_inferences_total", "Inference calls", ["kind", "outcome"] -) -INFERENCE_LATENCY = Histogram( - "sensevoice_inference_latency_seconds", - "Inference latency (sec)", - ["kind"], - buckets=(0.05, 0.1, 0.2, 0.4, 0.8, 1.5, 3.0, 6.0), -) -ACTIVE_SESSIONS = Gauge("sensevoice_active_sessions", "Currently open WS sessions") -QUEUE_DEPTH = Gauge( - "sensevoice_queue_depth", "Inference requests waiting on the GPU semaphore" -) -GPU_MEM_BYTES = Gauge( - "sensevoice_gpu_memory_allocated_bytes", "torch.cuda.memory_allocated() snapshot" -) - - -# --------------------------------------------------------------------------- -# 模型与生命周期 -# --------------------------------------------------------------------------- -@dataclass -class AppState: - model: Any = None # funasr.AutoModel;用 Any 因为 funasr 没暴露稳定类型 - model_loaded: bool = False - gpu_available: bool = False - shutdown_event: asyncio.Event = field(default_factory=asyncio.Event) - inference_sem: asyncio.Semaphore = field( - default_factory=lambda: asyncio.Semaphore(MAX_CONCURRENT_SESSIONS) - ) - active_session_count: int = 0 - queue_depth: int = 0 - # 由 main() 注入;用于 SIGTERM handler 调用以触发 uvicorn graceful shutdown - servers: list[Any] = field(default_factory=list) - - -state = AppState() - - -def _load_model() -> Any: - """同步加载 SenseVoice 模型;只在启动调用一次。 - - funasr.AutoModel(...) 内部会做 ModelScope/HF snapshot_download,首次约 ~1GB。 - 设置 vad_model="fsmn-vad" 让 funasr 自带的 VAD 帮我们切长音频段(保险, - 本服务客户端协议自己也做了 end_of_utterance,但 fsmn-vad 兜底处理 30s+ 长 buffer)。 - """ - from funasr import AutoModel - - log.info("loading SenseVoice model", extra={ - "model_id": SENSEVOICE_MODEL_ID, - "device": SENSEVOICE_DEVICE, - }) - t0 = time.perf_counter() - model = AutoModel( - model=SENSEVOICE_MODEL_ID, - vad_model="fsmn-vad", - vad_kwargs={"max_single_segment_time": int(MAX_UTTERANCE_SEC * 1000)}, - device=SENSEVOICE_DEVICE, - disable_update=True, - ) - log.info("SenseVoice model loaded", extra={ - "model_id": SENSEVOICE_MODEL_ID, - "load_seconds": round(time.perf_counter() - t0, 2), - }) - return model - - -def _check_gpu() -> bool: - """探测 torch.cuda;用 try/except 覆盖 CPU-only 镜像(开发场景)""" - try: - import torch - - return bool(torch.cuda.is_available()) - except Exception: - return False - - -def _update_gpu_metric() -> None: - try: - import torch - - if torch.cuda.is_available(): - GPU_MEM_BYTES.set(float(torch.cuda.memory_allocated())) - except Exception: - pass - - -# --------------------------------------------------------------------------- -# 推理:funasr 返回 [{"key": ..., "text": "<|zh|><|EMO_NEUTRAL|>...实际文本..."}] -# 我们既要拿到检测到的语言(embedded tag),又要拿到 plain text。 -# --------------------------------------------------------------------------- -LANG_TAG_RE = None # 懒加载,import 不要花时间 - - -def _parse_language_and_clean_text(raw_text: str) -> tuple[str | None, str]: - """从 funasr 富标签输出里抽语言代码 + 干净文本。 - - funasr 的 SenseVoice 输出形如:``<|zh|><|EMO_NEUTRAL|><|Speech|><|withitn|>实际文本`` - rich_transcription_postprocess 会去掉 tag。我们想保留语言信息,所以自己解析。 - """ - global LANG_TAG_RE - if LANG_TAG_RE is None: - import re - LANG_TAG_RE = re.compile(r"<\|([^|]+)\|>") - # 已知语言 tag 集(来自 SenseVoice 文档) - lang_codes = {"zh", "en", "yue", "ja", "ko"} - detected: str | None = None - for m in LANG_TAG_RE.finditer(raw_text): - tag = m.group(1).lower() - if tag in lang_codes: - detected = tag - break - # 去掉所有 <|...|> 标签 - clean = LANG_TAG_RE.sub("", raw_text).strip() - return detected, clean - - -def _run_inference_sync( - model: Any, audio_pcm_int16: np.ndarray, language_hint: str -) -> tuple[str | None, str]: - """阻塞调 funasr;返回 (detected_language, plain_text)。 - - funasr.AutoModel.generate 接受 numpy float32 或文件路径;我们传 float32 mono - 16kHz。语言 hint 走 ``language=`` 参数,"auto" 让模型自检。 - """ - audio_f32 = audio_pcm_int16.astype(np.float32) / 32768.0 - res = model.generate( - input=audio_f32, - cache={}, - language=language_hint or "auto", - use_itn=True, - batch_size_s=60, - ) - if not res: - return None, "" - raw = res[0].get("text", "") - return _parse_language_and_clean_text(raw) - - -async def _run_inference( - audio_pcm_int16: np.ndarray, language_hint: str, kind: str -) -> tuple[str | None, str]: - """获信号量 → 在线程里跑 funasr → 返回结果;记录 metrics。""" - state.queue_depth += 1 - QUEUE_DEPTH.set(state.queue_depth) - decremented = False - try: - async with state.inference_sem: - state.queue_depth -= 1 - QUEUE_DEPTH.set(state.queue_depth) - decremented = True - t0 = time.perf_counter() - try: - lang, text = await asyncio.to_thread( - _run_inference_sync, state.model, audio_pcm_int16, language_hint - ) - INFERENCES_TOTAL.labels(kind=kind, outcome="ok").inc() - return lang, text - except Exception as exc: - INFERENCES_TOTAL.labels(kind=kind, outcome="error").inc() - # GPU OOM:清显存让后续请求有机会恢复 - msg = str(exc).lower() - if "out of memory" in msg or "cuda" in msg: - try: - import torch - - torch.cuda.empty_cache() - except Exception: - pass - log.error("inference failed", extra={"kind": kind, "err": str(exc)}) - raise - finally: - INFERENCE_LATENCY.labels(kind=kind).observe(time.perf_counter() - t0) - _update_gpu_metric() - finally: - if not decremented: - # Cancelled / failed before semaphore acquire returned. Cover - # BaseException (asyncio.CancelledError) too — bare `except Exception` - # would miss it and leak the counter forever. - state.queue_depth -= 1 - QUEUE_DEPTH.set(state.queue_depth) - - -# --------------------------------------------------------------------------- -# WebSocket 处理 -# --------------------------------------------------------------------------- -@dataclass -class Session: - session_id: str - language_hint: str = "auto" - utterance_id: int = 0 - started_at: float = field(default_factory=time.monotonic) - # 当前 utterance 累积 buffer:np.int16 一维数组拼接 - buffer: list[np.ndarray] = field(default_factory=list) - buffer_samples: int = 0 - last_partial_at_samples: int = 0 - - -def _utterance_window(sess: Session) -> tuple[float, float]: - """返回当前 utterance 的 (start_time, end_time) 自会话开始秒数。""" - end = time.monotonic() - sess.started_at - duration = sess.buffer_samples / AUDIO_SAMPLE_RATE - start = max(0.0, end - duration) - return start, end - - -async def _emit(ws: WebSocket, payload: dict[str, Any]) -> None: - if ws.client_state != WebSocketState.CONNECTED: - return - await ws.send_text(json.dumps(payload, ensure_ascii=False)) - - -async def _emit_error(ws: WebSocket, msg: str, fatal: bool = False) -> None: - await _emit(ws, {"error": msg, "fatal": fatal}) - - -async def _flush_inference( - ws: WebSocket, sess: Session, *, is_final: bool -) -> None: - if sess.buffer_samples == 0: - return - audio = np.concatenate(sess.buffer) if len(sess.buffer) > 1 else sess.buffer[0] - kind = "final" if is_final else "partial" - try: - lang, text = await _run_inference(audio, sess.language_hint, kind) - except Exception as exc: - await _emit_error(ws, f"inference failed: {exc}", fatal=False) - return - start_s, end_s = _utterance_window(sess) - # confidence:funasr SenseVoice 当前未暴露 token-level 置信度;用占位 1.0 - # TODO(phase-1):若切到 funasr-onnx 路径,可拿到真正的 logprob → 转 confidence - await _emit(ws, { - "text": text, - "is_final": is_final, - "confidence": 1.0, - "start_time": round(start_s, 3), - "end_time": round(end_s, 3), - "utterance_id": sess.utterance_id, - "language": lang, - }) - if is_final: - # 重置 buffer,递增 utterance_id - sess.buffer.clear() - sess.buffer_samples = 0 - sess.last_partial_at_samples = 0 - sess.utterance_id += 1 - else: - sess.last_partial_at_samples = sess.buffer_samples - - -async def _handle_ws(ws: WebSocket) -> None: - # 拒绝新连接:饱和或正在停机 - if state.shutdown_event.is_set(): - SESSIONS_REJECTED.labels(reason="shutdown").inc() - await ws.close(code=1013, reason="server shutting down") - return - if state.active_session_count >= MAX_CONCURRENT_SESSIONS * 2: - # 软上限:信号量限制 inference 并发,但 WS 连接数也设个上限避免资源耗尽 - SESSIONS_REJECTED.labels(reason="saturation").inc() - await ws.close(code=1013, reason="server saturated") - return - - await ws.accept() - SESSIONS_OPENED.inc() - state.active_session_count += 1 - ACTIVE_SESSIONS.set(state.active_session_count) - sess = Session(session_id=str(uuid.uuid4())) - log.info("ws session opened", extra={"session_id": sess.session_id}) - - try: - while True: - try: - msg = await ws.receive() - except WebSocketDisconnect: - break - - if msg.get("type") != "websocket.receive": - # disconnect / close - break - - if "text" in msg and msg["text"] is not None: - # 控制帧(JSON) - try: - cmd = json.loads(msg["text"]) - except json.JSONDecodeError: - await _emit_error(ws, "invalid JSON control frame") - continue - event = cmd.get("event") - if event == "start": - sess.language_hint = str(cmd.get("language", "auto")) - sid = cmd.get("session_id") - if sid: - sess.session_id = str(sid) - elif event == "end_of_utterance": - await _flush_inference(ws, sess, is_final=True) - elif event == "stop": - if sess.buffer_samples > 0: - await _flush_inference(ws, sess, is_final=True) - break - else: - await _emit_error(ws, f"unknown event: {event!r}") - elif "bytes" in msg and msg["bytes"] is not None: - # 二进制 PCM 帧 - pcm = np.frombuffer(msg["bytes"], dtype=np.int16) - if pcm.size == 0: - continue - sess.buffer.append(pcm) - sess.buffer_samples += pcm.size - - # 安全网:如果客户端没发 end_of_utterance 而 buffer 跨过 MAX,强制 flush - if sess.buffer_samples >= int(MAX_UTTERANCE_SEC * AUDIO_SAMPLE_RATE): - await _flush_inference(ws, sess, is_final=True) - continue - - # 周期性 partial(自上次 partial 起,每 PARTIAL_INTERVAL_SEC 跑一次) - advance = sess.buffer_samples - sess.last_partial_at_samples - if advance >= int(PARTIAL_INTERVAL_SEC * AUDIO_SAMPLE_RATE): - # partial 不阻塞下一次接收:开 task;但要避免 partial 风暴,所以在 - # 当前实现里直接 await(partial 间隔已是秒级,可接受的串行化) - await _flush_inference(ws, sess, is_final=False) - except Exception as exc: - log.exception("ws session crashed", extra={ - "session_id": sess.session_id, "err": str(exc), - }) - try: - await _emit_error(ws, "internal error", fatal=True) - except Exception: - pass - finally: - state.active_session_count -= 1 - ACTIVE_SESSIONS.set(state.active_session_count) - log.info("ws session closed", extra={"session_id": sess.session_id}) - if ws.client_state != WebSocketState.DISCONNECTED: - try: - await ws.close() - except Exception: - pass - - -# --------------------------------------------------------------------------- -# FastAPI 应用:lifespan 加载模型 + 注册信号 handler -# --------------------------------------------------------------------------- -@asynccontextmanager -async def lifespan(_app: FastAPI) -> AsyncIterator[None]: - state.gpu_available = _check_gpu() - if not state.gpu_available: - log.warning("CUDA not available; SenseVoice will run on CPU (slow)") - try: - state.model = await asyncio.to_thread(_load_model) - state.model_loaded = True - except Exception as exc: - log.exception("model load failed", extra={"err": str(exc)}) - state.model_loaded = False - # 让 /health 一直返 degraded;docker HEALTHCHECK 会标 unhealthy 触发重启 - - # SIGTERM / SIGINT: 既触发 shutdown_event(拒绝新 WS、health 转 degraded), - # 也要让 uvicorn 自己开始 graceful shutdown(停止 accept、等现有连接结束) - def _handle_signal() -> None: - if state.shutdown_event.is_set(): - return - log.info("received signal; initiating graceful shutdown") - state.shutdown_event.set() - for srv in state.servers: - srv.should_exit = True - - loop = asyncio.get_running_loop() - for sig in (signal.SIGTERM, signal.SIGINT): - try: - loop.add_signal_handler(sig, _handle_signal) - except NotImplementedError: - # 仅 Windows / 非主线程会走到;我们的镜像是 Linux + main thread 不会触发 - pass - - yield - - # 关停阶段:等所有 active sessions 自然结束 - log.info("shutdown initiated; draining sessions", - extra={"active": state.active_session_count}) - deadline = time.monotonic() + GRACEFUL_TIMEOUT_SEC - while state.active_session_count > 0 and time.monotonic() < deadline: - await asyncio.sleep(0.5) - if state.active_session_count > 0: - log.warning("graceful timeout; forcing exit", - extra={"remaining": state.active_session_count}) - - -# 两个 FastAPI app 共享 state: -# - app_ws:监听 PORT_WS(默认 8000),暴露 /ws/transcribe;持有 lifespan(加载模型) -# - app_http:监听 PORT_HTTP(默认 8080),暴露 /health /metrics(无 lifespan,只读 state) -# 拆两端口是 plan 要求;逻辑层面共用一个进程一份模型一份 state。 -app_ws = FastAPI(lifespan=lifespan) -app_http = FastAPI() - - -@app_http.get("/health") -async def health() -> JSONResponse: - """健康端点:状态 + 模型/GPU + 队列深度。 - - 返回值约定: - - status="ok":模型已加载,未在停机 - - status="degraded":模型未加载 或 正在停机(编排器应停发新流量) - HTTP code:ok=200,degraded=503(Docker HEALTHCHECK 用此判定) - """ - is_shutting = state.shutdown_event.is_set() - ok = state.model_loaded and not is_shutting - payload = { - "status": "ok" if ok else "degraded", - "model_loaded": state.model_loaded, - "model_id": SENSEVOICE_MODEL_ID, - "gpu_available": state.gpu_available, - "active_sessions": state.active_session_count, - "queue_depth": state.queue_depth, - "max_concurrent_sessions": MAX_CONCURRENT_SESSIONS, - "shutting_down": is_shutting, - "audio_sample_rate": AUDIO_SAMPLE_RATE, - "audio_encoding": "pcm_s16le", - } - return JSONResponse(payload, status_code=200 if ok else 503) - - -@app_http.get("/metrics") -async def metrics() -> Response: - _update_gpu_metric() - return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST) - - -@app_ws.websocket("/ws/transcribe") -async def transcribe(ws: WebSocket) -> None: - await _handle_ws(ws) - - -# --------------------------------------------------------------------------- -# 入口:起两个 uvicorn server 共享 event loop。 -# - PORT_WS(8000)跑 app_ws(lifespan 在此加载模型) -# - PORT_HTTP(8080)跑 app_http(健康/指标,立刻就绪不等模型加载) -# -# 这样 docker HEALTHCHECK 一启动就能拿到 503(model_loaded=False),加载完转 200。 -# 信号:lifespan 在 app_ws 上注册 SIGTERM handler;app_http 跟随退出(gather 任一退出 -# 即整体退)。 -# --------------------------------------------------------------------------- -def main() -> None: - config_http = uvicorn.Config( - app_http, - host="0.0.0.0", # noqa: S104 容器内 bind all;宿主决定暴露面 - port=PORT_HTTP, - log_config=None, - log_level=LOG_LEVEL.lower(), - access_log=False, - ) - config_ws = uvicorn.Config( - app_ws, - host="0.0.0.0", # noqa: S104 - port=PORT_WS, - log_config=None, - log_level=LOG_LEVEL.lower(), - access_log=False, - ws_ping_interval=20, - ws_ping_timeout=20, - ) - server_http = uvicorn.Server(config_http) - server_ws = uvicorn.Server(config_ws) - state.servers = [server_ws, server_http] - - # 关掉 uvicorn 自己的 signal handler 安装:我们在 lifespan 里统一接管 - server_ws.install_signal_handlers = lambda: None # type: ignore[method-assign] - server_http.install_signal_handlers = lambda: None # type: ignore[method-assign] - - async def _run() -> None: - ws_task = asyncio.create_task(server_ws.serve()) - http_task = asyncio.create_task(server_http.serve()) - # 任一 server 退出 → 标记另一 server 也退(保证整体进程退出) - done, pending = await asyncio.wait( - {ws_task, http_task}, return_when=asyncio.FIRST_COMPLETED - ) - for srv in state.servers: - srv.should_exit = True - for t in pending: - try: - await t - except Exception: - log.exception("server task error during shutdown") - - asyncio.run(_run()) - - -if __name__ == "__main__": - main() diff --git a/infra/orchestrator/.env.template b/infra/orchestrator/.env.template index dcf758e..6d280b4 100644 --- a/infra/orchestrator/.env.template +++ b/infra/orchestrator/.env.template @@ -1,10 +1,17 @@ -GPU_HOST= -SENSEVOICE_WS_PORT=8000 -COSYVOICE_WS_PORT=8001 -OPENAI_BASE_URL=https://api.deepseek.com OPENAI_API_KEY= +OPENAI_BASE_URL=https://api.deepseek.com/v1 +OPENAI_MODEL=deepseek-chat +VOCALIZE_STT_PROVIDER_URL=http://127.0.0.1:8765 +VOCALIZE_TTS_PROVIDER_URL=http://127.0.0.1:8765 +VOCALIZE_SPEECH_PROVIDER_AUTO_START=0 +VOCALIZE_SPEECH_PROVIDER_COMMAND= VOCALIZE_HOST=0.0.0.0 VOCALIZE_PORT=8080 +ORCHESTRATOR_LISTEN_PORT=8080 +DEFAULT_LANGUAGE=zh +LOG_DIR=logs +VITE_VOCALIZE_API_BASE_URL=https://api.example.com +VITE_VOCALIZE_WS_BASE_URL=wss://api.example.com # Required for non-localhost deployments — see D-11. # If unset on non-localhost host, create_app() raises RuntimeError and systemd will restart-loop. diff --git a/install/dev-install.sh b/install/dev-install.sh index 5cc17de..38d92c0 100755 --- a/install/dev-install.sh +++ b/install/dev-install.sh @@ -134,7 +134,7 @@ echo "=== Install complete ===" echo "" echo "Next steps:" echo " 1. Activate venv: source .venv/bin/activate" -echo " 2. Edit .env: set OPENAI_API_KEY (and GPU_HOST if using STT/TTS)" +echo " 2. Edit .env: set OPENAI_API_KEY" echo " 3. Start backend: uvicorn vocalize.main:app --host 127.0.0.1 --port 8000 --reload" echo " 4. Start frontend (second terminal):" echo " cd frontend && npm run dev" diff --git a/install/install.sh b/install/install.sh index f360b2d..c5e4e09 100755 --- a/install/install.sh +++ b/install/install.sh @@ -2,278 +2,174 @@ set -euo pipefail IFS=$'\n\t' -# --------------------------------------------------------------------------- -# VocalizeAI Pi installer -# -# Deploys the VocalizeAI orchestrator on a Raspberry Pi. -# Wraps the existing infra/orchestrator/ assets. -# -# Usage: -# bash install/install.sh [--dry-run] [--steps "1,3,5"] [--skip-tunnel] [--skip-gpu] -# -# Flags: -# --dry-run Print planned actions without performing any mutations. -# --steps "1,3" Run only the listed comma-separated step numbers (default: all). -# --skip-tunnel Skip step 5 (Cloudflare Tunnel setup instructions). -# --skip-gpu Skip GPU-reachability check inside step 7. -# --------------------------------------------------------------------------- +INSTALL_DIR="${PWD}/VocalizeAI" +ARTIFACT="" +CHECKSUMS="" +YES=false +DRY_RUN=false -# --------------------------------------------------------------------------- -# Defaults -# --------------------------------------------------------------------------- +verify_artifact_checksum() { + local checksums="$1" + local artifact="$2" -DRY_RUN=false -STEPS_FILTER="" -SKIP_TUNNEL=false -SKIP_GPU=false -REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -INSTALL_DIR="/opt/vocalize" + if [[ ! -f "$checksums" ]]; then + echo "ERROR: checksum file not found: ${checksums}" >&2 + exit 1 + fi + + local checksum_dir artifact_dir artifact_name tmp_file + checksum_dir="$(cd "$(dirname "$checksums")" && pwd)" + artifact_dir="$(cd "$(dirname "$artifact")" && pwd)" + artifact_name="$(basename "$artifact")" + if [[ "$artifact_dir" != "$checksum_dir" ]]; then + echo "ERROR: artifact must be in the same directory as SHA256SUMS: ${artifact}" >&2 + exit 1 + fi + + tmp_file="$(mktemp)" + if ! awk -v name="$artifact_name" '$2 == name { print }' "$checksums" > "$tmp_file"; then + rm -f "$tmp_file" + echo "ERROR: failed to read ${checksums}" >&2 + exit 1 + fi + if [[ ! -s "$tmp_file" ]]; then + rm -f "$tmp_file" + echo "ERROR: checksum entry not found for ${artifact_name}" >&2 + exit 1 + fi + + (cd "$checksum_dir" && shasum -a 256 -c "$tmp_file") + rm -f "$tmp_file" +} -# --------------------------------------------------------------------------- -# Parse flags -# --------------------------------------------------------------------------- +usage() { + cat <<'USAGE' +Usage: install/install.sh --artifact RELEASE.zip [options] + +Install VocalizeAI into ./VocalizeAI by default. + +Options: + --artifact PATH Local VocalizeAI macOS release zip. + --checksums PATH SHA256SUMS file used to verify the artifact. + --install-dir PATH Destination directory. Default: ./VocalizeAI. + --yes Do not prompt before installing. + --dry-run Print actions without changing files. +USAGE +} while [[ $# -gt 0 ]]; do case "$1" in - --dry-run) - DRY_RUN=true - shift + --artifact) + ARTIFACT="$2" + shift 2 + ;; + --checksums) + CHECKSUMS="$2" + shift 2 ;; - --steps) - STEPS_FILTER="$2" + --install-dir) + INSTALL_DIR="$2" shift 2 ;; - --skip-tunnel) - SKIP_TUNNEL=true + --yes) + YES=true shift ;; - --skip-gpu) - SKIP_GPU=true + --dry-run) + DRY_RUN=true shift ;; + -h|--help) + usage + exit 0 + ;; *) - echo "Unknown flag: $1" - echo "Usage: bash install/install.sh [--dry-run] [--steps '1,3,5'] [--skip-tunnel] [--skip-gpu]" - exit 1 + echo "Unknown option: $1" >&2 + usage >&2 + exit 2 ;; esac done -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -run_or_dry() { - # First arg is human description; remaining args are the command. - # Description is unused at runtime but printed in dry-run mode via $* - shift - if [ "$DRY_RUN" = true ]; then - echo "[DRY] would run: $*" - else - "$@" - fi -} - -should_run_step() { - local step_num="$1" - if [ -z "$STEPS_FILTER" ]; then - return 0 # run all steps - fi - # Check if step_num is in the comma-separated filter - echo "$STEPS_FILTER" | tr ',' '\n' | grep -qxF "$step_num" -} - -STEPS_RUN=() - -# --------------------------------------------------------------------------- -# Step 1: apt deps -# --------------------------------------------------------------------------- - -step1_apt_deps() { - echo "" - echo "[1/7] Installing system packages (apt)..." - run_or_dry "apt-get update" sudo apt-get update -y - run_or_dry "apt-get install python3.11 python3.11-venv python3-pip build-essential rsync" \ - sudo apt-get install -y python3.11 python3.11-venv python3-pip build-essential rsync - echo "[1/7] Done." -} - -# --------------------------------------------------------------------------- -# Step 2: Python venv + pip install -e . -# --------------------------------------------------------------------------- - -step2_venv() { - echo "" - echo "[2/7] Setting up Python virtual environment in ${INSTALL_DIR}..." - run_or_dry "mkdir -p ${INSTALL_DIR}" sudo mkdir -p "${INSTALL_DIR}" - run_or_dry "chown vocalize ${INSTALL_DIR} if user exists" \ - bash -c 'id vocalize &>/dev/null && sudo chown vocalize '"${INSTALL_DIR}"' || true' - if [ ! -d "${INSTALL_DIR}/.venv" ]; then - run_or_dry "python3 -m venv ${INSTALL_DIR}/.venv" \ - sudo -u "$(id -un)" python3 -m venv "${INSTALL_DIR}/.venv" - else - echo " .venv already exists — reusing." - fi - run_or_dry "pip install -e . in ${INSTALL_DIR}" \ - bash -c "${INSTALL_DIR}/.venv/bin/pip install -e ${REPO_ROOT} --quiet" - echo "[2/7] Done." -} - -# --------------------------------------------------------------------------- -# Step 3: GPU services note (v1 boundary — GPU lives on a separate host) -# --------------------------------------------------------------------------- - -step3_gpu_services() { - echo "" - echo "[3/7] GPU services setup..." - echo " GPU services (SenseVoice STT + CosyVoice TTS) run on a separate host." - echo " Ensure GPU_HOST in /opt/vocalize/.env points to that host's Tailscale IP." - echo " This installer does not configure the GPU host — see docs/deploy/linux.md for details." - echo "[3/7] Done (note only — no mutation performed)." -} +if [[ -z "$ARTIFACT" ]]; then + echo "ERROR: --artifact is required" >&2 + usage >&2 + exit 2 +fi -# --------------------------------------------------------------------------- -# Step 4: Tailscale check -# --------------------------------------------------------------------------- +if [[ ! -f "$ARTIFACT" ]]; then + echo "ERROR: artifact not found: ${ARTIFACT}" >&2 + exit 1 +fi -step4_tailscale_check() { - echo "" - echo "[4/7] Checking Tailscale..." - if [ "$DRY_RUN" = true ]; then - echo "[DRY] would run: command -v tailscale && tailscale status" - else - if command -v tailscale &>/dev/null; then - tailscale status || echo " WARNING: Tailscale is installed but not connected. Run: sudo tailscale up" - else - echo " WARNING: Tailscale not found. Install it with:" - echo " curl -fsSL https://tailscale.com/install.sh | sh && sudo tailscale up" - echo " Tailscale is required for this host to reach the GPU host." - fi +if [[ -e "$INSTALL_DIR" ]]; then + if [[ ! -f "${INSTALL_DIR}/.vocalize-install-root" ]]; then + echo "ERROR: destination exists but is not a VocalizeAI install: ${INSTALL_DIR}" >&2 + exit 1 fi - echo "[4/7] Done." -} - -# --------------------------------------------------------------------------- -# Step 5: Cloudflare Tunnel (informational — manual step required) -# --------------------------------------------------------------------------- - -step5_cloudflared_tunnel() { - echo "" - echo "[5/7] Cloudflare Tunnel setup..." - echo " To install the Cloudflare Tunnel service:" - echo " sudo cloudflared service install " - echo "" - echo " Get your TUNNEL_TOKEN from the Cloudflare dashboard:" - echo " Zero Trust -> Networks -> Tunnels -> [your tunnel] -> Configure" - echo " -> Install and run a connector -> Copy the token" - echo "" - echo " Reference ingress shape (docs only): infra/orchestrator/cloudflared-config.yml" - echo " (Actual ingress routing is configured in the Cloudflare dashboard, not in a file.)" - if [ "$DRY_RUN" = true ]; then - echo "[DRY] would run: sudo cloudflared service install (requires manual token)" + if [[ -n "$(find "$INSTALL_DIR" -mindepth 1 -maxdepth 1 ! -name config ! -name logs ! -name cache ! -name .vocalize-install-root -print -quit)" ]]; then + echo "ERROR: destination already contains an install. Use ./vocalize update instead." >&2 + exit 1 fi - echo "[5/7] Done (informational — run the cloudflared command above with your token)." -} - -# --------------------------------------------------------------------------- -# Step 6: systemd unit + .env -# --------------------------------------------------------------------------- - -step6_systemd_unit() { - echo "" - echo "[6/7] Installing systemd unit and environment file..." - - SERVICE_SRC="${REPO_ROOT}/infra/orchestrator/vocalize.service" - SERVICE_DST="/etc/systemd/system/vocalize.service" - ENV_SRC="${REPO_ROOT}/infra/orchestrator/.env.template" - ENV_DST="${INSTALL_DIR}/.env" - - # Install vocalize.service - run_or_dry "install -m 644 vocalize.service -> /etc/systemd/system/" \ - sudo install -m 644 "${SERVICE_SRC}" "${SERVICE_DST}" +fi - # Copy .env template only if destination does not exist - if [ ! -f "${ENV_DST}" ]; then - run_or_dry "copy .env.template -> ${INSTALL_DIR}/.env" \ - sudo cp "${ENV_SRC}" "${ENV_DST}" - echo " Created ${ENV_DST} from template — edit it before starting the service." - echo " At minimum, set: OPENAI_API_KEY, VOCALIZE_WS_BASE_URL, VOCALIZE_CORS_ORIGINS, GPU_HOST" - else - echo " ${ENV_DST} already exists — preserving." +if [[ "$YES" != true ]]; then + printf "Install VocalizeAI into %s? Type 'yes' to continue: " "$INSTALL_DIR" + read -r answer + normalized="$(printf '%s' "$answer" | tr '[:upper:]' '[:lower:]')" + if [[ "$normalized" != "yes" ]]; then + echo "Cancelled" + exit 1 fi +fi - run_or_dry "systemctl daemon-reload" sudo systemctl daemon-reload - run_or_dry "systemctl enable vocalize" sudo systemctl enable vocalize - - echo "[6/7] Done." -} - -# --------------------------------------------------------------------------- -# Step 7: Start and smoke verify -# --------------------------------------------------------------------------- - -step7_start_and_smoke() { - echo "" - echo "[7/7] Starting vocalize service and running smoke check..." - - run_or_dry "systemctl restart vocalize" sudo systemctl restart vocalize - - if [ "$DRY_RUN" = true ]; then - echo "[DRY] would run: sleep 3 && bash scripts/smoke.sh" +if [[ -n "$CHECKSUMS" ]]; then + if [[ "$DRY_RUN" == true ]]; then + echo "[DRY] verify ${ARTIFACT} against ${CHECKSUMS}" else - sleep 3 - if [ "$SKIP_GPU" = true ]; then - echo " --skip-gpu passed: smoke will accept gpu_reachable=false." - VOCALIZE_API_BASE="http://127.0.0.1:8080" bash "${REPO_ROOT}/scripts/smoke.sh" || true - else - VOCALIZE_API_BASE="http://127.0.0.1:8080" bash "${REPO_ROOT}/scripts/smoke.sh" - fi + verify_artifact_checksum "$CHECKSUMS" "$ARTIFACT" fi +fi - echo "[7/7] Done." +TMP_DIR="$(mktemp -d)" +cleanup() { + rm -rf "$TMP_DIR" } +trap cleanup EXIT -# --------------------------------------------------------------------------- -# Main dispatcher -# --------------------------------------------------------------------------- - -echo "=== VocalizeAI Linux Installer ===" -if [ "$DRY_RUN" = true ]; then - echo "(DRY RUN — no system changes will be made)" +if [[ "$DRY_RUN" == true ]]; then + echo "[DRY] unzip ${ARTIFACT}" + echo "[DRY] install into ${INSTALL_DIR}" + exit 0 fi -echo "" -STEP_FUNCTIONS=( - "" # placeholder so index 1..7 maps naturally - "step1_apt_deps" - "step2_venv" - "step3_gpu_services" - "step4_tailscale_check" - "step5_cloudflared_tunnel" - "step6_systemd_unit" - "step7_start_and_smoke" -) - -for i in 1 2 3 4 5 6 7; do - # Apply --skip-tunnel - if [ "$i" -eq 5 ] && [ "$SKIP_TUNNEL" = true ]; then - echo "[5/7] Skipping Cloudflare Tunnel setup (--skip-tunnel passed)." - continue - fi - - if ! should_run_step "$i"; then - echo "[${i}/7] Skipping (not in --steps filter)." - continue - fi - - fn="${STEP_FUNCTIONS[$i]}" - "$fn" - STEPS_RUN+=("$i") -done - -echo "" -echo "=== Install complete ===" -echo "Steps run: ${STEPS_RUN[*]:-none}" -if [ "$DRY_RUN" = true ]; then - echo "(DRY RUN — re-run without --dry-run to apply changes)" +unzip -q "$ARTIFACT" -d "$TMP_DIR" +bundle_count="$( + find "$TMP_DIR" -mindepth 1 -maxdepth 1 -type d ! -name __MACOSX | wc -l | tr -d ' ' +)" +if [[ "$bundle_count" != "1" ]]; then + echo "ERROR: release artifact must contain exactly one top-level directory" >&2 + exit 1 fi +BUNDLE_DIR="$(find "$TMP_DIR" -mindepth 1 -maxdepth 1 -type d ! -name __MACOSX | head -1)" + +mkdir -p "$INSTALL_DIR" +rsync -a --delete \ + --exclude config/.env \ + --exclude config/preferences.json \ + --exclude config/install.json \ + --exclude logs/ \ + --exclude cache/ \ + "${BUNDLE_DIR}/" "${INSTALL_DIR}/" + +mkdir -p "${INSTALL_DIR}/config" "${INSTALL_DIR}/logs" "${INSTALL_DIR}/cache" +printf 'VocalizeAI local install\n' > "${INSTALL_DIR}/.vocalize-install-root" +chmod 755 "${INSTALL_DIR}/vocalize" "${INSTALL_DIR}/bin/vocalize" "${INSTALL_DIR}/uninstall.sh" + +echo "Installed: ${INSTALL_DIR}" +echo "" +echo "Next:" +echo " cd '${INSTALL_DIR}'" +echo " ./vocalize setup" +echo " ./vocalize doctor" +echo " ./vocalize start" diff --git a/install/uninstall.sh b/install/uninstall.sh new file mode 100755 index 0000000..a53b50c --- /dev/null +++ b/install/uninstall.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +YES=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --yes) + YES=true + shift + ;; + -h|--help) + echo "Usage: ./uninstall.sh [--yes]" + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + exit 2 + ;; + esac +done + +if [[ ! -f "${ROOT_DIR}/.vocalize-install-root" ]]; then + echo "ERROR: refusing to uninstall unmarked directory: ${ROOT_DIR}" >&2 + exit 1 +fi + +if [[ "$YES" != true ]]; then + printf "Remove %s? Type 'yes' to continue: " "$ROOT_DIR" + read -r answer + normalized="$(printf '%s' "$answer" | tr '[:upper:]' '[:lower:]')" + if [[ "$normalized" != "yes" ]]; then + echo "Cancelled" + exit 1 + fi +fi + +if [[ -f "${ROOT_DIR}/config/install.json" ]]; then + symlink="$( + sed -n 's/.*"global_symlink": "\([^"]*\)".*/\1/p' "${ROOT_DIR}/config/install.json" | head -1 + )" + if [[ -n "${symlink:-}" && -L "$symlink" ]]; then + target="$(readlink "$symlink")" + if [[ "$target" == "${ROOT_DIR}/vocalize" ]]; then + rm -f "$symlink" + fi + fi +fi + +cd "$(dirname "$ROOT_DIR")" +rm -rf "$ROOT_DIR" +echo "Removed: ${ROOT_DIR}" diff --git a/install/verify-release.sh b/install/verify-release.sh new file mode 100755 index 0000000..070cdb1 --- /dev/null +++ b/install/verify-release.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' + +usage() { + cat <<'USAGE' +Usage: install/verify-release.sh SHA256SUMS ARTIFACT [ARTIFACT ...] + +Verify one or more downloaded release artifacts against a SHA256SUMS file before +unpacking or installing them. +USAGE +} + +if [[ $# -lt 2 ]]; then + usage >&2 + exit 2 +fi + +CHECKSUM_FILE="$1" +shift + +if [[ ! -f "$CHECKSUM_FILE" ]]; then + echo "ERROR: checksum file not found: ${CHECKSUM_FILE}" >&2 + exit 1 +fi + +CHECKSUM_DIR="$(cd "$(dirname "$CHECKSUM_FILE")" && pwd)" +TMP_FILE="$(mktemp)" +trap 'rm -f "$TMP_FILE"' EXIT + +for artifact in "$@"; do + if [[ ! -f "$artifact" ]]; then + echo "ERROR: artifact not found: ${artifact}" >&2 + exit 1 + fi + + artifact_dir="$(cd "$(dirname "$artifact")" && pwd)" + artifact_name="$(basename "$artifact")" + if [[ "$artifact_dir" != "$CHECKSUM_DIR" ]]; then + echo "ERROR: artifact must be in the same directory as SHA256SUMS: ${artifact}" >&2 + exit 1 + fi + + if ! awk -v name="$artifact_name" '$2 == name { print }' "$CHECKSUM_FILE" >> "$TMP_FILE"; then + echo "ERROR: failed to read ${CHECKSUM_FILE}" >&2 + exit 1 + fi + if ! awk -v name="$artifact_name" '$2 == name { found = 1 } END { exit found ? 0 : 1 }' "$CHECKSUM_FILE"; then + echo "ERROR: checksum entry not found for ${artifact_name}" >&2 + exit 1 + fi +done + +(cd "$CHECKSUM_DIR" && shasum -a 256 -c "$TMP_FILE") diff --git a/macos/VocalizeSpeechProvider/.gitignore b/macos/VocalizeSpeechProvider/.gitignore new file mode 100644 index 0000000..2d9f16e --- /dev/null +++ b/macos/VocalizeSpeechProvider/.gitignore @@ -0,0 +1,2 @@ +.build/ +.swiftpm/ diff --git a/macos/VocalizeSpeechProvider/Package.resolved b/macos/VocalizeSpeechProvider/Package.resolved new file mode 100644 index 0000000..e46f231 --- /dev/null +++ b/macos/VocalizeSpeechProvider/Package.resolved @@ -0,0 +1,266 @@ +{ + "pins" : [ + { + "identity" : "async-http-client", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/async-http-client.git", + "state" : { + "revision" : "3a5b74a58782c3b4c1f0bc75e9b67b10c2494e8f", + "version" : "1.33.1" + } + }, + { + "identity" : "async-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/async-kit.git", + "state" : { + "revision" : "6bbb83cbf9d886623a967a965c8fb1b73e6566f9", + "version" : "1.22.0" + } + }, + { + "identity" : "console-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/console-kit.git", + "state" : { + "revision" : "32ad16dfc7677b927b225595ed18f3debb32f577", + "version" : "4.16.0" + } + }, + { + "identity" : "multipart-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/multipart-kit.git", + "state" : { + "revision" : "3498e60218e6003894ff95192d756e238c01f44e", + "version" : "4.7.1" + } + }, + { + "identity" : "routing-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/routing-kit.git", + "state" : { + "revision" : "1a10ccea61e4248effd23b6e814999ce7bdf0ee0", + "version" : "4.9.3" + } + }, + { + "identity" : "swift-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-algorithms.git", + "state" : { + "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", + "version" : "1.2.1" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "eb50cbd14606a9161cbc5d452f18797c90ef0bab", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "d0b4a06d0f173a2f3be27d3ea21b3c3aa18db440", + "version" : "1.1.4" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-certificates", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-certificates.git", + "state" : { + "revision" : "bde8ca32a096825dfce37467137c903418c1893d", + "version" : "1.19.1" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "fea17c02d767f46b23070fdfdacc28a03a39232a", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-configuration", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-configuration.git", + "state" : { + "revision" : "be76c4ad929eb6c4bcaf3351799f2adf9e6848a9", + "version" : "1.2.0" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "1b6b2e274e85105bfa155183145a1dcfd63331f1", + "version" : "4.5.0" + } + }, + { + "identity" : "swift-distributed-tracing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-distributed-tracing.git", + "state" : { + "revision" : "dc4030184203ffafbb2ec614352487235d747fe0", + "version" : "1.4.1" + } + }, + { + "identity" : "swift-http-structured-headers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-structured-headers.git", + "state" : { + "revision" : "933538faa42c432d385f02e07df0ace7c5ecfc47", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types.git", + "state" : { + "revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "2aed77ae5ec9a86d8fe42c12275e4c2653a286ee", + "version" : "1.13.1" + } + }, + { + "identity" : "swift-metrics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-metrics.git", + "state" : { + "revision" : "087e8074afa97040c3b870c8664fe5482fb87cc4", + "version" : "2.11.0" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "57c0a08a331aaea9f5d7a932ad94ef43be942a95", + "version" : "2.100.0" + } + }, + { + "identity" : "swift-nio-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-extras.git", + "state" : { + "revision" : "d2eeec0339074034f11a040a74aa2a341a2c4506", + "version" : "1.34.1" + } + }, + { + "identity" : "swift-nio-http2", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-http2.git", + "state" : { + "revision" : "61d1b44f6e4e118792be1cff88ee2bc0267c6f9a", + "version" : "1.44.0" + } + }, + { + "identity" : "swift-nio-ssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-ssl.git", + "state" : { + "revision" : "3f337058ccd7243c4cac7911477d8ad4c598d4da", + "version" : "2.37.0" + } + }, + { + "identity" : "swift-nio-transport-services", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-transport-services.git", + "state" : { + "revision" : "67787bb645a5e67d2edcdfbe48a216cc549222d5", + "version" : "1.28.0" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics.git", + "state" : { + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-service-context", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-service-context.git", + "state" : { + "revision" : "d0997351b0c7779017f88e7a93bc30a1878d7f29", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-service-lifecycle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/swift-service-lifecycle.git", + "state" : { + "revision" : "9829955b385e5bb88128b73f1b8389e9b9c3191a", + "version" : "2.11.0" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", + "version" : "1.6.4" + } + }, + { + "identity" : "vapor", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/vapor.git", + "state" : { + "revision" : "cfd8f434843ac7850e2d97f46c1aa5ddb906cf1c", + "version" : "4.121.4" + } + }, + { + "identity" : "websocket-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/websocket-kit.git", + "state" : { + "revision" : "90bbbdab3ede12c803cfbe91646f291c092517a3", + "version" : "2.16.2" + } + } + ], + "version" : 2 +} diff --git a/macos/VocalizeSpeechProvider/Package.swift b/macos/VocalizeSpeechProvider/Package.swift new file mode 100644 index 0000000..593b0b7 --- /dev/null +++ b/macos/VocalizeSpeechProvider/Package.swift @@ -0,0 +1,23 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "VocalizeSpeechProvider", + platforms: [ + .macOS(.v13) + ], + products: [ + .executable(name: "VocalizeSpeechProvider", targets: ["VocalizeSpeechProvider"]) + ], + dependencies: [ + .package(url: "https://github.com/vapor/vapor.git", from: "4.115.0") + ], + targets: [ + .executableTarget( + name: "VocalizeSpeechProvider", + dependencies: [ + .product(name: "Vapor", package: "vapor") + ] + ) + ] +) diff --git a/macos/VocalizeSpeechProvider/Sources/VocalizeSpeechProvider/main.swift b/macos/VocalizeSpeechProvider/Sources/VocalizeSpeechProvider/main.swift new file mode 100644 index 0000000..a70f302 --- /dev/null +++ b/macos/VocalizeSpeechProvider/Sources/VocalizeSpeechProvider/main.swift @@ -0,0 +1,607 @@ +import AVFoundation +import Foundation +import NIOCore +import Speech +import Vapor + +let providerAPIVersion = "1.0" + +struct ProviderCapabilities: Content { + struct SpeechCapability: Content { + var realtime: Bool + var inputEncoding: String? + var inputSampleRate: Int? + var outputEncoding: String? + var outputSampleRate: Int? + var languages: [String] + + enum CodingKeys: String, CodingKey { + case realtime + case inputEncoding = "input_encoding" + case inputSampleRate = "input_sample_rate" + case outputEncoding = "output_encoding" + case outputSampleRate = "output_sample_rate" + case languages + } + } + + struct Permissions: Content { + var speechRecognition: String + var microphone: String + var ttsVoicesAvailable: Int + + enum CodingKeys: String, CodingKey { + case speechRecognition = "speech_recognition" + case microphone + case ttsVoicesAvailable = "tts_voices_available" + } + } + + var providerApiVersion: String + var provider: String + var realtime: Bool + var stt: SpeechCapability + var tts: SpeechCapability + var permissions: Permissions + + enum CodingKeys: String, CodingKey { + case providerApiVersion = "provider_api_version" + case provider + case realtime + case stt + case tts + case permissions + } +} + +final class ProviderState { + let encoder = JSONEncoder() + + func capabilities() -> ProviderCapabilities { + let speechStatus = SFSpeechRecognizer.authorizationStatus() + let micStatus = AVCaptureDevice.authorizationStatus(for: .audio) + let voices = AVSpeechSynthesisVoice.speechVoices() + let languageCodes = Array(Set(voices.map(\.language))).sorted() + + return ProviderCapabilities( + providerApiVersion: providerAPIVersion, + provider: "macos-native", + realtime: true, + stt: .init( + realtime: true, + inputEncoding: "pcm_s16le", + inputSampleRate: 16_000, + outputEncoding: nil, + outputSampleRate: nil, + languages: languageCodes + ), + tts: .init( + realtime: true, + inputEncoding: nil, + inputSampleRate: nil, + outputEncoding: "pcm_s16le", + outputSampleRate: 24_000, + languages: languageCodes + ), + permissions: .init( + speechRecognition: describeSpeechStatus(speechStatus), + microphone: describeAVStatus(micStatus), + ttsVoicesAvailable: voices.count + ) + ) + } + + func requestPermissions() async -> ProviderCapabilities.Permissions { + if SFSpeechRecognizer.authorizationStatus() == .notDetermined { + _ = await requestSpeechAuthorization() + } + if AVCaptureDevice.authorizationStatus(for: .audio) == .notDetermined { + _ = await requestMicrophoneAuthorization() + } + return capabilities().permissions + } + + func sendJSON(_ ws: WebSocket, _ value: [String: Any]) { + guard JSONSerialization.isValidJSONObject(value), + let data = try? JSONSerialization.data(withJSONObject: value) + else { + return + } + ws.send(String(decoding: data, as: UTF8.self)) + } + + func sendBinary(_ ws: WebSocket, _ bytes: [UInt8]) { + ws.send(raw: Data(bytes), opcode: .binary) + } +} + +func requestSpeechAuthorization() async -> SFSpeechRecognizerAuthorizationStatus { + await withCheckedContinuation { continuation in + SFSpeechRecognizer.requestAuthorization { status in + continuation.resume(returning: status) + } + } +} + +func requestMicrophoneAuthorization() async -> AVAuthorizationStatus { + await withCheckedContinuation { continuation in + AVCaptureDevice.requestAccess(for: .audio) { _ in + continuation.resume(returning: AVCaptureDevice.authorizationStatus(for: .audio)) + } + } +} + +final class SpeechRecognitionSession { + private let ws: WebSocket + private let state: ProviderState + private let request = SFSpeechAudioBufferRecognitionRequest() + private var task: SFSpeechRecognitionTask? + private var started = false + + init(ws: WebSocket, state: ProviderState) { + self.ws = ws + self.state = state + request.shouldReportPartialResults = true + } + + func start(language: String?) { + guard !started else { return } + started = true + + guard SFSpeechRecognizer.authorizationStatus() == .authorized else { + state.sendJSON(ws, [ + "type": "error", + "fatal": true, + "message": "Speech Recognition permission is not authorized" + ]) + return + } + + let locale = Locale(identifier: normalizeLanguage(language)) + guard let recognizer = SFSpeechRecognizer(locale: locale), + recognizer.isAvailable + else { + state.sendJSON(ws, [ + "type": "error", + "fatal": true, + "message": "Speech recognizer is unavailable for \(locale.identifier)" + ]) + return + } + + task = recognizer.recognitionTask(with: request) { [weak self] result, error in + guard let self else { return } + if let error { + self.state.sendJSON(self.ws, [ + "type": "error", + "fatal": true, + "message": error.localizedDescription + ]) + return + } + guard let result else { return } + self.state.sendJSON(self.ws, [ + "type": "transcript", + "text": result.bestTranscription.formattedString, + "is_final": result.isFinal, + "confidence": 0.0, + "start_time": 0.0, + "end_time": 0.0, + "utterance_id": 0, + "language": locale.identifier + ]) + } + } + + func appendPCM16Mono(_ data: ByteBuffer) { + guard started else { return } + var copy = data + guard let bytes = copy.readBytes(length: copy.readableBytes), !bytes.isEmpty else { + return + } + guard let buffer = makePCMBuffer(bytes: bytes, sampleRate: 16_000) else { + state.sendJSON(ws, [ + "type": "error", + "fatal": true, + "message": "invalid pcm_s16le audio frame" + ]) + return + } + request.append(buffer) + } + + func finishAudio() { + request.endAudio() + } + + func stop() { + request.endAudio() + task?.cancel() + task = nil + } + + private func normalizeLanguage(_ language: String?) -> String { + switch language?.lowercased() { + case "zh", "zh-cn", "cmn-hans-cn": + return "zh-CN" + case "en", "en-us": + return "en-US" + case let value? where !value.isEmpty && value != "auto": + return value + default: + return Locale.current.identifier + } + } +} + +final class TextToSpeechSession { + private let ws: WebSocket + private let state: ProviderState + private var started = false + private let lock = NSLock() + private var pendingUtterances = 0 + private var inputClosed = false + private var closeSent = false + + init(ws: WebSocket, state: ProviderState) { + self.ws = ws + self.state = state + } + + func start() { + started = true + } + + func synthesize(text: String, language: String?) { + guard started else { return } + addPending() + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + guard let self else { return } + do { + let pcm = try synthesizeWithSay(text: text, language: language) + self.state.sendJSON(self.ws, [ + "type": "audio_start", + "sample_rate": 24_000, + "encoding": "pcm_s16le", + "channels": 1 + ]) + for chunk in pcm.chunked(size: 8192) { + self.state.sendBinary(self.ws, Array(chunk)) + } + self.state.sendJSON(self.ws, ["type": "audio_end"]) + } catch { + self.state.sendJSON(self.ws, [ + "type": "error", + "fatal": true, + "message": error.localizedDescription + ]) + } + self.finishPending() + } + } + + func finishInput() { + lock.lock() + inputClosed = true + let shouldClose = pendingUtterances == 0 && !closeSent + if shouldClose { + closeSent = true + } + lock.unlock() + if shouldClose { + ws.close(promise: nil) + } + } + + func cancel() { + } + + private func addPending() { + lock.lock() + pendingUtterances += 1 + lock.unlock() + } + + private func finishPending() { + lock.lock() + pendingUtterances = max(0, pendingUtterances - 1) + let shouldClose = inputClosed && pendingUtterances == 0 && !closeSent + if shouldClose { + closeSent = true + } + lock.unlock() + if shouldClose { + ws.close(promise: nil) + } + } + + private func normalizeLanguage(_ language: String) -> String { + switch language.lowercased() { + case "zh", "zh-cn", "cmn-hans-cn": + return "zh-CN" + case "en", "en-us": + return "en-US" + default: + return language + } + } +} + +enum TTSError: LocalizedError { + case sayFailed(Int32) + case audioReadFailed + case audioConvertFailed + + var errorDescription: String? { + switch self { + case .sayFailed(let code): + return "macOS say failed with exit code \(code)" + case .audioReadFailed: + return "failed to read synthesized macOS speech audio" + case .audioConvertFailed: + return "failed to convert synthesized speech to pcm_s16le" + } + } +} + +func synthesizeWithSay(text: String, language: String?) throws -> [UInt8] { + let tempURL = FileManager.default.temporaryDirectory + .appendingPathComponent("vocalize-tts-\(UUID().uuidString).aiff") + defer { try? FileManager.default.removeItem(at: tempURL) } + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/say") + var args = ["-o", tempURL.path] + if let voice = voiceName(for: language) { + args += ["-v", voice] + } + args.append(text) + process.arguments = args + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + throw TTSError.sayFailed(process.terminationStatus) + } + return try readPCM16Mono(url: tempURL, targetSampleRate: 24_000) +} + +func voiceName(for language: String?) -> String? { + guard let language, !language.isEmpty, language != "auto" else { return nil } + let normalized: String + switch language.lowercased() { + case "zh", "zh-cn", "cmn-hans-cn": + normalized = "zh-CN" + case "en", "en-us": + normalized = "en-US" + default: + normalized = language + } + return AVSpeechSynthesisVoice.speechVoices() + .first(where: { $0.language == normalized })? + .name +} + +func readPCM16Mono(url: URL, targetSampleRate: Double) throws -> [UInt8] { + let file = try AVAudioFile(forReading: url) + guard let inputBuffer = AVAudioPCMBuffer( + pcmFormat: file.processingFormat, + frameCapacity: AVAudioFrameCount(file.length) + ) else { + throw TTSError.audioReadFailed + } + try file.read(into: inputBuffer) + + guard let outputFormat = AVAudioFormat( + commonFormat: .pcmFormatFloat32, + sampleRate: targetSampleRate, + channels: 1, + interleaved: false + ), + let converter = AVAudioConverter( + from: file.processingFormat, + to: outputFormat + ) + else { + throw TTSError.audioConvertFailed + } + + let ratio = targetSampleRate / file.processingFormat.sampleRate + let capacity = AVAudioFrameCount(Double(inputBuffer.frameLength) * ratio) + 1024 + guard let outputBuffer = AVAudioPCMBuffer( + pcmFormat: outputFormat, + frameCapacity: capacity + ) else { + throw TTSError.audioConvertFailed + } + + var didProvideInput = false + var conversionError: NSError? + converter.convert(to: outputBuffer, error: &conversionError) { _, status in + if didProvideInput { + status.pointee = .noDataNow + return nil + } + didProvideInput = true + status.pointee = .haveData + return inputBuffer + } + if conversionError != nil { + throw TTSError.audioConvertFailed + } + return convertToPCM16Mono(outputBuffer) +} + +func makePCMBuffer(bytes: [UInt8], sampleRate: Double) -> AVAudioPCMBuffer? { + let frameCount = bytes.count / MemoryLayout.size + guard frameCount > 0, + let format = AVAudioFormat( + commonFormat: .pcmFormatInt16, + sampleRate: sampleRate, + channels: 1, + interleaved: false + ), + let buffer = AVAudioPCMBuffer( + pcmFormat: format, + frameCapacity: AVAudioFrameCount(frameCount) + ), + let channel = buffer.int16ChannelData?.pointee + else { + return nil + } + + bytes.withUnsafeBytes { raw in + if let source = raw.baseAddress?.assumingMemoryBound(to: Int16.self) { + channel.update(from: source, count: frameCount) + } + } + buffer.frameLength = AVAudioFrameCount(frameCount) + return buffer +} + +func convertToPCM16Mono(_ buffer: AVAudioPCMBuffer) -> [UInt8] { + let frameCount = Int(buffer.frameLength) + guard frameCount > 0 else { return [] } + var out = [UInt8]() + out.reserveCapacity(frameCount * MemoryLayout.size) + + if let int16 = buffer.int16ChannelData?.pointee { + for idx in 0.. [ArraySlice] { + stride(from: 0, to: count, by: size).map { + self[$0.. [String: Any] { + guard let data = text.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data), + let dict = object as? [String: Any] + else { + return [:] + } + return dict +} + +func describeSpeechStatus(_ status: SFSpeechRecognizerAuthorizationStatus) -> String { + switch status { + case .authorized: + return "authorized" + case .denied: + return "denied" + case .restricted: + return "restricted" + case .notDetermined: + return "not_determined" + @unknown default: + return "unknown" + } +} + +func describeAVStatus(_ status: AVAuthorizationStatus) -> String { + switch status { + case .authorized: + return "authorized" + case .denied: + return "denied" + case .restricted: + return "restricted" + case .notDetermined: + return "not_determined" + @unknown default: + return "unknown" + } +} + +let env = Environment(name: "production", arguments: CommandLine.arguments) +let app = try await Application.make(env) + +let state = ProviderState() +let port = Int(Environment.get("VOCALIZE_SPEECH_PROVIDER_PORT") ?? "8765") ?? 8765 +app.http.server.configuration.hostname = "127.0.0.1" +app.http.server.configuration.port = port + +app.get("v1", "capabilities") { _ in + state.capabilities() +} + +app.post("v1", "permissions", "request") { _ async in + await state.requestPermissions() +} + +app.webSocket("v1", "stt", "stream") { _, ws in + let session = SpeechRecognitionSession(ws: ws, state: state) + + ws.onText { ws, text in + let message = parseJSON(text) + switch message["type"] as? String { + case "start": + session.start(language: message["language"] as? String) + case "end_of_utterance": + session.finishAudio() + case "stop": + session.stop() + ws.close(promise: nil) + default: + break + } + } + + ws.onBinary { _, bytes in + session.appendPCM16Mono(bytes) + } + + ws.onClose.whenComplete { _ in + session.stop() + } +} + +app.webSocket("v1", "tts", "stream") { _, ws in + let session = TextToSpeechSession(ws: ws, state: state) + + ws.onText { ws, text in + let message = parseJSON(text) + switch message["type"] as? String { + case "start": + session.start() + case "text": + session.synthesize( + text: message["text"] as? String ?? "", + language: message["language"] as? String + ) + case "stop": + session.finishInput() + default: + break + } + } + + ws.onClose.whenComplete { _ in + session.cancel() + } +} + +do { + try await app.execute() + try await app.asyncShutdown() +} catch { + try? await app.asyncShutdown() + throw error +} diff --git a/packaging/pyinstaller/vocalize.spec b/packaging/pyinstaller/vocalize.spec new file mode 100644 index 0000000..38c9ca9 --- /dev/null +++ b/packaging/pyinstaller/vocalize.spec @@ -0,0 +1,93 @@ +# -*- mode: python ; coding: utf-8 -*- +from __future__ import annotations + +import os +from pathlib import Path + +from PyInstaller.utils.hooks import collect_data_files, collect_submodules + + +ROOT = Path(os.environ.get("VOCALIZE_REPO_ROOT", Path.cwd())).resolve() +FRONTEND_DIST = Path( + os.environ.get("VOCALIZE_FRONTEND_DIST", ROOT / "frontend" / "dist") +).resolve() +MACOS_HELPER = Path( + os.environ.get( + "VOCALIZE_MACOS_HELPER", + ROOT + / "macos" + / "VocalizeSpeechProvider" + / ".build" + / "release" + / "vocalize-mac-speech-provider", + ) +).resolve() +ENV_TEMPLATE = ROOT / ".env.example" + +if not FRONTEND_DIST.joinpath("index.html").is_file(): + raise SystemExit(f"Vite frontend build not found: {FRONTEND_DIST}") +if not MACOS_HELPER.is_file(): + raise SystemExit(f"macOS speech provider helper not found: {MACOS_HELPER}") +if not ENV_TEMPLATE.is_file(): + raise SystemExit(f"config template not found: {ENV_TEMPLATE}") + +datas = collect_data_files("vocalize.dialogue.prompts") +datas += [ + (str(FRONTEND_DIST), "vocalize_runtime/frontend"), + (str(ENV_TEMPLATE), "vocalize_runtime/config"), +] + +binaries = [ + (str(MACOS_HELPER), "vocalize_runtime/bin"), +] + +hiddenimports = ( + collect_submodules("uvicorn") + + collect_submodules("websockets") + + [ + "httptools", + "uvloop", + "watchfiles", + ] +) + +a = Analysis( + [str(ROOT / "src" / "vocalize" / "__main__.py")], + pathex=[str(ROOT / "src")], + binaries=binaries, + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name="vocalize", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name="vocalize", +) diff --git a/pyproject.toml b/pyproject.toml index 77394c5..66fb7aa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,12 +26,16 @@ dependencies = [ "webrtcvad>=2.0.10", ] +[project.scripts] +vocalize = "vocalize.cli:main" + [project.optional-dependencies] dev = [ "pytest>=8.0", "pytest-asyncio>=0.23", "PyYAML>=6.0.3", "httpx>=0.27", + "pyinstaller>=6.10,<7", "ruff>=0.5", "mypy>=1.10", ] diff --git a/scripts/README.md b/scripts/README.md index 12f6f16..09daf5f 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,18 +1,14 @@ # scripts/ -Ad-hoc Python utilities run by maintainers, not part of the runtime call path. +Ad-hoc utilities run by maintainers, not part of the runtime call path. -Each script is standalone and invoked from the repo root. Current scripts: +Current public scripts: -- `build-public-filelist.py` — builds the filtered file list for the - `sync-private-to-public` skill (consumes `install/public-allowlist.md` - and `.public-sync-deny`). -- `stability-24h-driver.py` — drives the 24-hour orchestrator stability - rehearsal (Phase 4 DEPLOY-02 evidence harness). Hardware-agnostic; the - reference run was executed against a Raspberry Pi orchestrator. -The dead-code-scanner whitelist (`vulture-whitelist.py`) was relocated to -`.tooling/vulture-whitelist.py` in Phase 6 (maintainer-only scan tooling lives -under `.tooling/` and is excluded from the public mirror). +- `build-public-filelist.py` — builds the filtered file list for the public + export flow. +- `build-macos-release.sh` — builds the packaged macOS release artifact, + optional signing/notarization path, zip, and `SHA256SUMS`. +- `smoke.sh` — local backend smoke check for source development. -Contrast with `infra/` (deployable services) and `tools/` (reserved for release -tooling, currently empty pending future use). +Contrast with `install/` (user-facing install/update/uninstall scripts) and +`tools/` (release and CI helper modules). diff --git a/scripts/build-macos-release.sh b/scripts/build-macos-release.sh new file mode 100755 index 0000000..a5f38fa --- /dev/null +++ b/scripts/build-macos-release.sh @@ -0,0 +1,288 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PYTHON_BIN="${PYTHON_BIN:-${ROOT_DIR}/.venv/bin/python}" +VERSION="" +SIGNING_MODE="${VOCALIZE_SIGNING_MODE:-skip}" +DIST_DIR="${VOCALIZE_RELEASE_DIST:-${ROOT_DIR}/dist/release}" +WORK_DIR="${VOCALIZE_RELEASE_WORK:-${ROOT_DIR}/build/release}" +SKIP_FRONTEND_BUILD=false +SKIP_SWIFT_BUILD=false + +usage() { + cat <<'USAGE' +Usage: scripts/build-macos-release.sh [options] + +Build a self-contained macOS VocalizeAI release artifact. + +Options: + --version VERSION Override pyproject.toml version. + --signing-mode MODE skip | ad-hoc | developer-id. Default: skip. + --dist-dir DIR Release asset output directory. + --work-dir DIR Temporary build workspace. + --skip-frontend-build Reuse existing frontend/dist. + --skip-swift-build Reuse existing Swift release helper. + +Developer ID mode requires: + APPLE_DEVELOPER_ID_APPLICATION + APPLE_ID + APPLE_TEAM_ID + APPLE_APP_SPECIFIC_PASSWORD +USAGE +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --version) + VERSION="$2" + shift 2 + ;; + --signing-mode) + SIGNING_MODE="$2" + shift 2 + ;; + --dist-dir) + DIST_DIR="$2" + shift 2 + ;; + --work-dir) + WORK_DIR="$2" + shift 2 + ;; + --skip-frontend-build) + SKIP_FRONTEND_BUILD=true + shift + ;; + --skip-swift-build) + SKIP_SWIFT_BUILD=true + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "ERROR: macOS release artifacts must be built on macOS." >&2 + exit 1 +fi + +case "$SIGNING_MODE" in + skip|ad-hoc|developer-id) ;; + *) + echo "ERROR: --signing-mode must be skip, ad-hoc, or developer-id." >&2 + exit 2 + ;; +esac + +if [[ ! -x "$PYTHON_BIN" ]]; then + echo "ERROR: Python venv not found at ${PYTHON_BIN}." >&2 + echo "Run: bash install/dev-install.sh" >&2 + exit 1 +fi + +for command_name in npm swift ditto shasum xcrun codesign file; do + if ! command -v "$command_name" >/dev/null 2>&1; then + echo "ERROR: required command not found: ${command_name}" >&2 + exit 1 + fi +done + +if ! "$PYTHON_BIN" -m PyInstaller --version >/dev/null 2>&1; then + echo "ERROR: PyInstaller is not installed in the project venv." >&2 + echo "Run inside the venv: python -m pip install 'pyinstaller>=6.10,<7'" >&2 + exit 1 +fi + +if [[ -z "$VERSION" ]]; then + VERSION="$( + "$PYTHON_BIN" -m tools.release.artifacts version \ + --pyproject "${ROOT_DIR}/pyproject.toml" + )" +fi + +if [[ "$SIGNING_MODE" == "developer-id" ]]; then + : "${APPLE_DEVELOPER_ID_APPLICATION:?developer-id signing requires APPLE_DEVELOPER_ID_APPLICATION}" + : "${APPLE_ID:?developer-id signing requires APPLE_ID}" + : "${APPLE_TEAM_ID:?developer-id signing requires APPLE_TEAM_ID}" + : "${APPLE_APP_SPECIFIC_PASSWORD:?developer-id signing requires APPLE_APP_SPECIFIC_PASSWORD}" +fi + +ARCH="$(uname -m)" +ARTIFACT_NAME="VocalizeAI-${VERSION}-macos-${ARCH}" +BUNDLE_DIR="${WORK_DIR}/${ARTIFACT_NAME}" +PYINSTALLER_DIST="${WORK_DIR}/pyinstaller/dist" +PYINSTALLER_WORK="${WORK_DIR}/pyinstaller/work" +HELPER_STAGE="${WORK_DIR}/staged-helper/vocalize-mac-speech-provider" +FRONTEND_DIST="${ROOT_DIR}/frontend/dist" +SWIFT_HELPER="${ROOT_DIR}/macos/VocalizeSpeechProvider/.build/release/VocalizeSpeechProvider" +ZIP_PATH="${DIST_DIR}/${ARTIFACT_NAME}.zip" +CHECKSUM_PATH="${DIST_DIR}/SHA256SUMS" +INSTALLER_ASSET="${DIST_DIR}/install.sh" + +echo "=== VocalizeAI macOS release build ===" +echo "Version: ${VERSION}" +echo "Signing: ${SIGNING_MODE}" +echo "Artifact: ${ARTIFACT_NAME}" +echo "" + +if [[ "$SKIP_FRONTEND_BUILD" != true ]]; then + echo "[1/7] Building Vite frontend..." + (cd "${ROOT_DIR}/frontend" && npm ci && npm run build) +else + echo "[1/7] Reusing existing Vite frontend build." +fi + +if [[ ! -f "${FRONTEND_DIST}/index.html" ]]; then + echo "ERROR: frontend build missing: ${FRONTEND_DIST}/index.html" >&2 + exit 1 +fi + +if [[ "$SKIP_SWIFT_BUILD" != true ]]; then + echo "[2/7] Building macOS speech provider..." + swift build -c release --package-path "${ROOT_DIR}/macos/VocalizeSpeechProvider" +else + echo "[2/7] Reusing existing Swift release helper." +fi + +if [[ ! -f "$SWIFT_HELPER" ]]; then + echo "ERROR: Swift helper missing: ${SWIFT_HELPER}" >&2 + exit 1 +fi + +rm -rf "${WORK_DIR}/staged-helper" "$PYINSTALLER_DIST" "$PYINSTALLER_WORK" +mkdir -p "$(dirname "$HELPER_STAGE")" +cp "$SWIFT_HELPER" "$HELPER_STAGE" +chmod 755 "$HELPER_STAGE" + +echo "[3/7] Building PyInstaller one-folder backend..." +VOCALIZE_REPO_ROOT="$ROOT_DIR" \ +VOCALIZE_FRONTEND_DIST="$FRONTEND_DIST" \ +VOCALIZE_MACOS_HELPER="$HELPER_STAGE" \ + "$PYTHON_BIN" -m PyInstaller \ + --noconfirm \ + --clean \ + --distpath "$PYINSTALLER_DIST" \ + --workpath "$PYINSTALLER_WORK" \ + "${ROOT_DIR}/packaging/pyinstaller/vocalize.spec" + +if [[ ! -x "${PYINSTALLER_DIST}/vocalize/vocalize" ]]; then + echo "ERROR: PyInstaller backend missing: ${PYINSTALLER_DIST}/vocalize/vocalize" >&2 + exit 1 +fi + +echo "[4/7] Assembling release layout..." +rm -rf "$BUNDLE_DIR" +mkdir -p "${BUNDLE_DIR}/app" "${BUNDLE_DIR}/bin" "${BUNDLE_DIR}/config" \ + "${BUNDLE_DIR}/logs" "${BUNDLE_DIR}/cache" +cp -R "${PYINSTALLER_DIST}/vocalize" "${BUNDLE_DIR}/app/vocalize" +cp "$HELPER_STAGE" "${BUNDLE_DIR}/bin/vocalize-mac-speech-provider" +cp "${ROOT_DIR}/.env.example" "${BUNDLE_DIR}/config/.env.example" +cp "${ROOT_DIR}/install/uninstall.sh" "${BUNDLE_DIR}/uninstall.sh" +cp "${ROOT_DIR}/LICENSE" "${BUNDLE_DIR}/LICENSE" +cp "${ROOT_DIR}/README.md" "${BUNDLE_DIR}/README.md" +printf '%s\n' "$VERSION" > "${BUNDLE_DIR}/VERSION" +printf 'VocalizeAI local install\n' > "${BUNDLE_DIR}/.vocalize-install-root" + +cat > "${BUNDLE_DIR}/bin/vocalize" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +export VOCALIZE_HOST="${VOCALIZE_HOST:-127.0.0.1}" +export VOCALIZE_PORT="${VOCALIZE_PORT:-8080}" +export VOCALIZE_INSTALL_ROOT="${VOCALIZE_INSTALL_ROOT:-${ROOT_DIR}}" +if [ -f "${ROOT_DIR}/config/.env" ]; then + export VOCALIZE_ENV_FILE="${VOCALIZE_ENV_FILE:-${ROOT_DIR}/config/.env}" +fi +export VOCALIZE_STT_PROVIDER_URL="${VOCALIZE_STT_PROVIDER_URL:-http://127.0.0.1:8765}" +export VOCALIZE_TTS_PROVIDER_URL="${VOCALIZE_TTS_PROVIDER_URL:-http://127.0.0.1:8765}" +export VOCALIZE_SPEECH_PROVIDER_AUTO_START="${VOCALIZE_SPEECH_PROVIDER_AUTO_START:-1}" +export VOCALIZE_SPEECH_PROVIDER_COMMAND="${VOCALIZE_SPEECH_PROVIDER_COMMAND:-${ROOT_DIR}/bin/vocalize-mac-speech-provider}" +export VOCALIZE_FRONTEND_DIST="${VOCALIZE_FRONTEND_DIST:-${ROOT_DIR}/app/vocalize/_internal/vocalize_runtime/frontend}" +exec "${ROOT_DIR}/app/vocalize/vocalize" "$@" +SH +chmod 755 "${BUNDLE_DIR}/bin/vocalize" +chmod 755 "${BUNDLE_DIR}/uninstall.sh" + +cat > "${BUNDLE_DIR}/vocalize" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec "${ROOT_DIR}/bin/vocalize" "$@" +SH +chmod 755 "${BUNDLE_DIR}/vocalize" + +"$PYTHON_BIN" -m tools.release.artifacts manifest \ + --output "${BUNDLE_DIR}/manifest.json" \ + --version "$VERSION" \ + --artifact-name "$ARTIFACT_NAME" \ + --arch "$ARCH" \ + --signing-mode "$SIGNING_MODE" \ + --entrypoint "bin/vocalize" \ + --backend-executable "app/vocalize/vocalize" \ + --frontend-dist "app/vocalize/_internal/vocalize_runtime/frontend" \ + --speech-provider "bin/vocalize-mac-speech-provider" + +sign_executable() { + local target="$1" + if [[ "$SIGNING_MODE" == "skip" ]]; then + return 0 + fi + if [[ "$SIGNING_MODE" == "ad-hoc" ]]; then + codesign --force --sign - "$target" + else + codesign --force --options runtime --timestamp \ + --sign "$APPLE_DEVELOPER_ID_APPLICATION" "$target" + fi +} + +if [[ "$SIGNING_MODE" != "skip" ]]; then + echo "[5/7] Codesigning executables..." + while IFS= read -r -d '' candidate; do + if file "$candidate" | grep -q "Mach-O"; then + sign_executable "$candidate" + fi + done < <(find "$BUNDLE_DIR" -type f -print0) +else + echo "[5/7] Skipping codesign. This artifact is not public-release ready." +fi + +echo "[6/7] Creating GitHub Release zip..." +mkdir -p "$DIST_DIR" +rm -f "$ZIP_PATH" "$CHECKSUM_PATH" "$INSTALLER_ASSET" +(cd "$WORK_DIR" && ditto -c -k --norsrc --noextattr --keepParent "$ARTIFACT_NAME" "$ZIP_PATH") +cp "${ROOT_DIR}/install/install.sh" "$INSTALLER_ASSET" +chmod 755 "$INSTALLER_ASSET" + +if [[ "$SIGNING_MODE" == "developer-id" ]]; then + echo "[6/7] Submitting zip for notarization..." + xcrun notarytool submit "$ZIP_PATH" \ + --apple-id "$APPLE_ID" \ + --team-id "$APPLE_TEAM_ID" \ + --password "$APPLE_APP_SPECIFIC_PASSWORD" \ + --wait +fi + +echo "[7/7] Writing SHA256SUMS..." +"$PYTHON_BIN" -m tools.release.artifacts sha256 \ + --output "$CHECKSUM_PATH" \ + "$ZIP_PATH" \ + "$INSTALLER_ASSET" + +echo "" +echo "=== Release artifact ready ===" +echo "Asset: ${ZIP_PATH}" +echo "Installer: ${INSTALLER_ASSET}" +echo "Checksums: ${CHECKSUM_PATH}" +echo "" +echo "Verify:" +echo " bash install/verify-release.sh '${CHECKSUM_PATH}' '${ZIP_PATH}'" diff --git a/scripts/smoke.sh b/scripts/smoke.sh index e5cf46e..64d858a 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -65,12 +65,12 @@ if [ "$OK" != "true" ]; then fail "[1/5]" ".ok is not true in response: ${HEALTH_RESP}" fi -GPU=$(echo "$HEALTH_RESP" | jq -r '.gpu_reachable' 2>/dev/null) || GPU="unknown" -if [ "$GPU" = "false" ]; then - echo "[1/5] WARNING: gpu_reachable=false — GPU services may be offline (smoke continues)." +SPEECH=$(echo "$HEALTH_RESP" | jq -r '.speech_provider_reachable' 2>/dev/null) || SPEECH="unknown" +if [ "$SPEECH" = "false" ]; then + echo "[1/5] WARNING: speech_provider_reachable=false — speech provider may be offline (smoke continues)." fi -echo "[1/5] GET /health... PASS (gpu_reachable=${GPU})" +echo "[1/5] GET /health... PASS (speech_provider_reachable=${SPEECH})" # --------------------------------------------------------------------------- # Step 2: POST /api/sessions diff --git a/src/vocalize/__main__.py b/src/vocalize/__main__.py new file mode 100644 index 0000000..f78956b --- /dev/null +++ b/src/vocalize/__main__.py @@ -0,0 +1,3 @@ +from vocalize.cli import main + +raise SystemExit(main()) diff --git a/src/vocalize/cli.py b/src/vocalize/cli.py new file mode 100644 index 0000000..ea4d874 --- /dev/null +++ b/src/vocalize/cli.py @@ -0,0 +1,621 @@ +"""Command-line entry points for local VocalizeAI installs.""" +from __future__ import annotations + +import argparse +import getpass +import hashlib +import os +import shutil +import signal +import stat +import subprocess +import sys +import tempfile +import threading +import time +import urllib.error +import urllib.request +import webbrowser +import zipfile +from pathlib import Path + +from vocalize.config import Config, OpenAIThinkingMode +from vocalize.doctor import run_doctor +from vocalize.install_state import ( + InstallPaths, + detect_install_root, + ensure_install_dirs, + mark_install_root, + read_preferences, + record_global_symlink, + remove_install_root, + write_env_file, + write_preferences, + write_providers_yaml, +) + + +YES_VALUES = {"1", "yes", "y", "true", "on"} +NO_VALUES = {"0", "no", "n", "false", "off"} +THINKING_MODE_CHOICES: tuple[OpenAIThinkingMode, ...] = ("enabled", "disabled") +BROWSER_READY_TIMEOUT_S = 30.0 +BROWSER_READY_INTERVAL_S = 0.25 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="vocalize") + subcommands = parser.add_subparsers(dest="command", required=True) + + setup_parser = subcommands.add_parser("setup", help="configure this install") + setup_parser.add_argument("--llm-base-url") + setup_parser.add_argument("--llm-api-key") + setup_parser.add_argument("--llm-model") + setup_parser.add_argument("--llm-thinking-mode", choices=THINKING_MODE_CHOICES) + setup_parser.add_argument("--port", type=_arg_port) + setup_parser.add_argument("--global-command", choices=["yes", "no"]) + setup_parser.add_argument("--open-browser", choices=["yes", "no"]) + setup_parser.add_argument("--non-interactive", action="store_true") + + doctor_parser = subcommands.add_parser("doctor", help="check readiness") + doctor_parser.add_argument("--skip-llm-probe", action="store_true") + + start_parser = subcommands.add_parser("start", help="start VocalizeAI") + start_parser.add_argument("--background", action="store_true") + start_parser.add_argument("--no-browser", action="store_true") + + subcommands.add_parser("stop", help="stop background server") + subcommands.add_parser("status", help="show background server status") + + logs_parser = subcommands.add_parser("logs", help="print local logs") + logs_parser.add_argument("--lines", type=int, default=80) + logs_parser.add_argument("--follow", action="store_true") + + update_parser = subcommands.add_parser("update", help="update from artifact") + update_parser.add_argument("--artifact", type=Path, required=True) + update_parser.add_argument("--checksums", type=Path) + + uninstall_parser = subcommands.add_parser("uninstall", help="remove install") + uninstall_parser.add_argument("--yes", action="store_true") + + subcommands.add_parser("serve", help="run the backend server") + + args = parser.parse_args(argv) + paths = ensure_install_dirs(detect_install_root()) + + if args.command == "setup": + return _setup(args, paths) + if args.command == "doctor": + return _doctor(args, paths) + if args.command == "start": + return _start(args, paths) + if args.command == "stop": + return _stop(paths) + if args.command == "status": + return _status(paths) + if args.command == "logs": + return _logs(args, paths) + if args.command == "update": + return _update(args, paths) + if args.command == "uninstall": + return _uninstall(args, paths) + if args.command == "serve": + from vocalize.main import main as serve_main + + serve_main() + return 0 + + parser.error(f"unknown command: {args.command}") + + +def _setup(args: argparse.Namespace, paths: InstallPaths) -> int: + setup_env = _install_env(paths) + cfg = _config_from_env(setup_env) + base_url = _value_or_prompt( + args.llm_base_url, + "LLM base URL", + default=cfg.openai_base_url, + non_interactive=args.non_interactive, + ) + model = _value_or_prompt( + args.llm_model, + "LLM model", + default=cfg.openai_model, + non_interactive=args.non_interactive, + ) + thinking_mode = _choice_or_prompt( + args.llm_thinking_mode, + "LLM thinking mode", + choices=THINKING_MODE_CHOICES, + default=cfg.openai_thinking_mode, + non_interactive=args.non_interactive, + ) + port = _port_or_prompt( + args.port, + default=_port_from_env(setup_env), + non_interactive=args.non_interactive, + ) + api_key = args.llm_api_key + if not api_key and not args.non_interactive: + api_key = getpass.getpass("LLM API key: ") + if not api_key: + print("ERROR: LLM API key is required", file=sys.stderr) + return 2 + + open_browser = _bool_choice( + args.open_browser, + "Open browser automatically on start?", + default=True, + non_interactive=args.non_interactive, + ) + global_command = _bool_choice( + args.global_command, + "Install optional global `vocalize` command symlink?", + default=False, + non_interactive=args.non_interactive, + ) + + mark_install_root(paths.root) + write_env_file( + paths, + openai_api_key=api_key, + openai_base_url=base_url, + openai_model=model, + openai_thinking_mode=thinking_mode, + vocalize_port=port, + ) + write_providers_yaml(paths) + write_preferences(paths, {"open_browser": open_browser}) + if global_command: + symlink_path = _create_global_symlink(paths) + print(f"Global command: {symlink_path}") + else: + record_global_symlink(paths, None) + + print(f"Configured: {paths.root}") + print(f"Env: {paths.env_file}") + print(f"Providers: {paths.providers_file}") + return 0 + + +def _doctor(args: argparse.Namespace, paths: InstallPaths) -> int: + env = _install_env(paths) + previous = os.environ.copy() + os.environ.update(env) + try: + checks = run_doctor(skip_llm_probe=args.skip_llm_probe) + finally: + os.environ.clear() + os.environ.update(previous) + for check in checks: + status = "PASS" if check.ok else "FAIL" + print(f"{status} {check.name}: {check.detail}") + if check.remediation and not check.ok: + print(f" fix: {check.remediation}") + return 0 if all(check.ok for check in checks) else 1 + + +def _start(args: argparse.Namespace, paths: InstallPaths) -> int: + env = _install_env(paths) + preferences = read_preferences(paths) + open_browser = bool(preferences.get("open_browser", True)) and not args.no_browser + + if args.background: + if _is_pid_running(_read_pid(paths)): + print("VocalizeAI is already running") + return 0 + paths.logs_dir.mkdir(parents=True, exist_ok=True) + log_handle = paths.log_file.open("ab") + process = subprocess.Popen( # noqa: S603 - local packaged command. + _serve_command(), + env=env, + stdout=log_handle, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + paths.pid_file.write_text(str(process.pid), encoding="utf-8") + print(f"Started in background: pid {process.pid}") + if open_browser: + _open_browser_when_ready(env) + return 0 + + if open_browser: + _open_browser_when_ready_async(env) + from vocalize.main import main as serve_main + + previous = os.environ.copy() + os.environ.update(env) + try: + serve_main() + return 0 + finally: + os.environ.clear() + os.environ.update(previous) + + +def _stop(paths: InstallPaths) -> int: + pid = _read_pid(paths) + if not _is_pid_running(pid): + if paths.pid_file.exists(): + paths.pid_file.unlink() + print("VocalizeAI is not running") + return 0 + assert pid is not None + os.kill(pid, signal.SIGTERM) + for _ in range(20): + if not _is_pid_running(pid): + break + time.sleep(0.1) + if _is_pid_running(pid): + print(f"ERROR: process did not stop: {pid}", file=sys.stderr) + return 1 + paths.pid_file.unlink(missing_ok=True) + print("Stopped") + return 0 + + +def _status(paths: InstallPaths) -> int: + pid = _read_pid(paths) + if _is_pid_running(pid): + print(f"running pid={pid}") + return 0 + print("stopped") + return 1 + + +def _logs(args: argparse.Namespace, paths: InstallPaths) -> int: + if not paths.log_file.is_file(): + print(f"No log file yet: {paths.log_file}") + return 0 + if args.follow: + return subprocess.call(["tail", "-f", str(paths.log_file)]) # noqa: S603,S607 + lines = max(1, args.lines) + content = paths.log_file.read_text(encoding="utf-8", errors="replace").splitlines() + for line in content[-lines:]: + print(line) + return 0 + + +def _update(args: argparse.Namespace, paths: InstallPaths) -> int: + if args.checksums: + _verify_sha256sums( + args.checksums, + base_dir=args.artifact.parent, + artifact_names=[args.artifact.name], + ) + + with tempfile.TemporaryDirectory(prefix="vocalize-update-") as tmp: + extract_root = Path(tmp) + _extract_release_zip(args.artifact, extract_root) + bundle = _single_extracted_bundle(extract_root) + _copy_update_payload(bundle, paths.root) + print(f"Updated: {paths.root}") + return 0 + + +def _uninstall(args: argparse.Namespace, paths: InstallPaths) -> int: + if not args.yes: + answer = input(f"Remove {paths.root}? Type 'yes' to continue: ") + if answer.strip().lower() != "yes": + print("Cancelled") + return 1 + remove_install_root(paths) + print(f"Removed: {paths.root}") + return 0 + + +def _install_env(paths: InstallPaths) -> dict[str, str]: + env = os.environ.copy() + env["VOCALIZE_INSTALL_ROOT"] = str(paths.root) + env["LOG_DIR"] = str(paths.logs_dir) + if paths.env_file.is_file(): + for key, value in _read_env_file(paths.env_file).items(): + env[key] = value + env["VOCALIZE_ENV_FILE"] = str(paths.env_file) + provider = paths.bin_dir / "vocalize-mac-speech-provider" + if provider.is_file(): + env.setdefault("VOCALIZE_SPEECH_PROVIDER_AUTO_START", "1") + env.setdefault("VOCALIZE_SPEECH_PROVIDER_COMMAND", str(provider)) + frontend = paths.app_dir / "vocalize" / "_internal" / "vocalize_runtime" / "frontend" + if (frontend / "index.html").is_file(): + env.setdefault("VOCALIZE_FRONTEND_DIST", str(frontend)) + return env + + +def _read_env_file(path: Path) -> dict[str, str]: + values: dict[str, str] = {} + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + values[key.strip()] = value.strip().strip("'\"") + return values + + +def _config_from_env(env: dict[str, str]) -> Config: + previous = os.environ.copy() + os.environ.update(env) + try: + return Config.from_env() + finally: + os.environ.clear() + os.environ.update(previous) + + +def _port_from_env(env: dict[str, str]) -> int: + raw = env.get("VOCALIZE_PORT", "8080") + try: + return _validate_port(int(raw)) + except ValueError: + return 8080 + + +def _serve_command() -> list[str]: + if getattr(sys, "frozen", False): + return [sys.executable, "serve"] + return [sys.executable, "-m", "vocalize", "serve"] + + +def _local_url(env: dict[str, str]) -> str: + host = env.get("VOCALIZE_HOST", "127.0.0.1") + port = env.get("VOCALIZE_PORT", "8080") + if host in {"0.0.0.0", "::"}: + host = "127.0.0.1" + return f"http://{host}:{port}" + + +def _health_url(env: dict[str, str]) -> str: + return f"{_local_url(env)}/health" + + +def _open_browser_when_ready_async(env: dict[str, str]) -> None: + thread = threading.Thread( + target=_open_browser_when_ready, + args=(env.copy(),), + name="vocalize-browser-open", + daemon=True, + ) + thread.start() + + +def _open_browser_when_ready( + env: dict[str, str], + *, + timeout_s: float = BROWSER_READY_TIMEOUT_S, + interval_s: float = BROWSER_READY_INTERVAL_S, +) -> bool: + url = _local_url(env) + if _wait_for_http_ready(_health_url(env), timeout_s=timeout_s, interval_s=interval_s): + webbrowser.open(url) + return True + print( + f"Browser not opened; server was not ready within {timeout_s:.0f}s: {url}", + file=sys.stderr, + ) + return False + + +def _wait_for_http_ready(url: str, *, timeout_s: float, interval_s: float) -> bool: + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + try: + request = urllib.request.Request(url, headers={"Accept": "application/json"}) + with urllib.request.urlopen(request, timeout=1.0) as response: + if 200 <= response.status < 300: + return True + except (OSError, urllib.error.URLError): + pass + time.sleep(interval_s) + return False + + +def _value_or_prompt( + value: str | None, + prompt: str, + *, + default: str, + non_interactive: bool, +) -> str: + if value: + return value + if non_interactive: + return default + answer = input(f"{prompt} [{default}]: ").strip() + return answer or default + + +def _bool_choice( + value: str | None, + prompt: str, + *, + default: bool, + non_interactive: bool, +) -> bool: + if value: + return value in YES_VALUES + if non_interactive: + return default + suffix = "Y/n" if default else "y/N" + answer = input(f"{prompt} [{suffix}]: ").strip().lower() + if not answer: + return default + if answer in YES_VALUES: + return True + if answer in NO_VALUES: + return False + raise ValueError(f"invalid yes/no answer: {answer}") + + +def _port_or_prompt( + value: int | None, + *, + default: int, + non_interactive: bool, +) -> int: + if value is not None: + return _validate_port(value) + if non_interactive: + return default + while True: + answer = input(f"Local web port [{default}]: ").strip() + if not answer: + return default + try: + return _validate_port(int(answer)) + except ValueError: + print("ERROR: port must be an integer from 1 to 65535", file=sys.stderr) + + +def _validate_port(port: int) -> int: + if 1 <= port <= 65535: + return port + raise ValueError(f"invalid port: {port}") + + +def _arg_port(raw: str) -> int: + try: + return _validate_port(int(raw)) + except ValueError as exc: + raise argparse.ArgumentTypeError( + "port must be an integer from 1 to 65535" + ) from exc + + +def _choice_or_prompt( + value: str | None, + prompt: str, + *, + choices: tuple[OpenAIThinkingMode, ...], + default: OpenAIThinkingMode, + non_interactive: bool, +) -> OpenAIThinkingMode: + allowed = set(choices) + if value: + normalized = value.strip().lower().replace("_", "-") + if normalized in allowed: + return normalized + raise ValueError(f"invalid choice for {prompt}: {value}") + if non_interactive: + return default + choice_label = "/".join(choices) + while True: + answer = input(f"{prompt} ({choice_label}) [{default}]: ").strip().lower() + normalized = answer.replace("_", "-") + if not normalized: + return default + if normalized in allowed: + return normalized + print(f"ERROR: choose one of: {choice_label}", file=sys.stderr) + + +def _create_global_symlink(paths: InstallPaths) -> Path: + bin_dir = Path(os.getenv("VOCALIZE_GLOBAL_BIN_DIR", "/usr/local/bin")) + bin_dir.mkdir(parents=True, exist_ok=True) + symlink = bin_dir / "vocalize" + if symlink.exists() or symlink.is_symlink(): + if symlink.is_symlink() and symlink.resolve() == paths.local_cli.resolve(): + record_global_symlink(paths, symlink) + return symlink + raise RuntimeError(f"refusing to replace existing global command: {symlink}") + symlink.symlink_to(paths.local_cli) + record_global_symlink(paths, symlink) + return symlink + + +def _read_pid(paths: InstallPaths) -> int | None: + if not paths.pid_file.is_file(): + return None + try: + return int(paths.pid_file.read_text(encoding="utf-8").strip()) + except ValueError: + return None + + +def _is_pid_running(pid: int | None) -> bool: + if pid is None: + return False + try: + os.kill(pid, 0) + except OSError: + return False + return True + + +def _single_extracted_bundle(extract_root: Path) -> Path: + bundles = [path for path in extract_root.iterdir() if path.is_dir()] + if len(bundles) != 1: + raise RuntimeError("release artifact must contain exactly one bundle directory") + return bundles[0] + + +def _extract_release_zip(artifact: Path, extract_root: Path) -> None: + """Extract a release zip while preserving symlinks and executable bits.""" + with zipfile.ZipFile(artifact) as archive: + for member in archive.infolist(): + if member.filename.endswith("/"): + (extract_root / member.filename).mkdir(parents=True, exist_ok=True) + continue + target = extract_root / member.filename + resolved = target.resolve(strict=False) + if not resolved.is_relative_to(extract_root.resolve()): + raise RuntimeError(f"unsafe path in release artifact: {member.filename}") + target.parent.mkdir(parents=True, exist_ok=True) + mode = member.external_attr >> 16 + if stat.S_ISLNK(mode): + link_target = archive.read(member).decode("utf-8") + if target.exists() or target.is_symlink(): + target.unlink() + os.symlink(link_target, target) + continue + with archive.open(member) as source, target.open("wb") as destination: + shutil.copyfileobj(source, destination) + permissions = mode & 0o777 + if permissions: + target.chmod(permissions) + + +def _copy_update_payload(bundle: Path, install_root: Path) -> None: + preserve = {"config", "logs", "cache"} + for child in bundle.iterdir(): + if child.name in preserve: + continue + destination = install_root / child.name + if destination.is_dir(): + shutil.rmtree(destination) + elif destination.exists() or destination.is_symlink(): + destination.unlink() + if child.is_symlink(): + os.symlink(os.readlink(child), destination) + elif child.is_dir(): + shutil.copytree(child, destination, symlinks=True) + else: + shutil.copy2(child, destination) + + +def _verify_sha256sums( + checksum_file: Path, + *, + base_dir: Path, + artifact_names: list[str], +) -> None: + wanted = set(artifact_names) + verified: set[str] = set() + for raw_line in checksum_file.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line: + continue + expected, filename = line.split() + if filename not in wanted: + continue + path = base_dir / filename + digest = hashlib.sha256(path.read_bytes()).hexdigest() + if digest != expected: + raise RuntimeError(f"checksum mismatch for {filename}") + verified.add(filename) + missing = wanted - verified + if missing: + raise RuntimeError(f"missing checksum entries for: {', '.join(sorted(missing))}") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/vocalize/config.py b/src/vocalize/config.py index efccbed..0c041dc 100644 --- a/src/vocalize/config.py +++ b/src/vocalize/config.py @@ -7,7 +7,7 @@ import logging import os from dataclasses import dataclass -from typing import Literal +from typing import Literal, cast try: from dotenv import load_dotenv @@ -29,6 +29,66 @@ def _int_env(name: str, default: int) -> int: return default +def _float_env(name: str, default: float) -> float: + """读取浮点环境变量;空字符串或非法值时回退到 default。""" + raw = os.getenv(name) + if raw is None or raw.strip() == "": + return default + try: + return float(raw) + except ValueError: + logging.warning( + "环境变量 %s=%r 不是合法浮点数,使用默认值 %s", name, raw, default + ) + return default + + +OpenAIThinkingMode = Literal["enabled", "disabled"] + + +def _bool_env(name: str, default: bool) -> bool: + """读取布尔环境变量;接受 1/true/yes/on 和 0/false/no/off。""" + raw = os.getenv(name) + if raw is None or raw.strip() == "": + return default + normalized = raw.strip().lower() + if normalized in {"1", "true", "yes", "on"}: + return True + if normalized in {"0", "false", "no", "off"}: + return False + logging.warning("环境变量 %s=%r 不是合法布尔值,使用默认值 %s", name, raw, default) + return default + + +def _thinking_mode_env(name: str, default: OpenAIThinkingMode) -> OpenAIThinkingMode: + """读取 LLM thinking 模式;兼容常见 on/off 写法。""" + raw = os.getenv(name) + if raw is None or raw.strip() == "": + return default + normalized = raw.strip().lower().replace("_", "-") + aliases = { + "on": "enabled", + "true": "enabled", + "yes": "enabled", + "thinking": "enabled", + "off": "disabled", + "false": "disabled", + "no": "disabled", + "non-thinking": "disabled", + "nonthinking": "disabled", + } + normalized = aliases.get(normalized, normalized) + if normalized in {"enabled", "disabled"}: + return cast(OpenAIThinkingMode, normalized) + logging.warning( + "环境变量 %s=%r 不是合法 thinking 模式,使用默认值 %s", + name, + raw, + default, + ) + return default + + @dataclass class Config: """应用配置类。""" @@ -37,12 +97,16 @@ class Config: openai_api_key: str | None = None openai_base_url: str = "https://api.deepseek.com/v1" openai_model: str = "deepseek-chat" + openai_thinking_mode: OpenAIThinkingMode = "disabled" - # GPU 推理节点(Tailscale 内网地址)。空串=未配置;`localhost` 是 Phase 0.5 - # 同机部署的合法值,validate_for_phase("gpu") 不会把它判为缺失。 - gpu_host: str = "" - sensevoice_ws_port: int = 8000 - cosyvoice_ws_port: int = 8001 + # Speech Provider API. v0.1 public default expects the macOS native helper + # on loopback; setup/doctor may rewrite these in generated local config. + stt_provider_url: str = "http://127.0.0.1:8765" + tts_provider_url: str = "http://127.0.0.1:8765" + provider_connect_timeout_s: float = 5.0 + speech_provider_auto_start: bool = False + speech_provider_command: str | None = None + speech_provider_startup_timeout_s: float = 5.0 # Pi 生产服务(Phase 4.5) orchestrator_listen_port: int = 8080 @@ -57,15 +121,54 @@ class Config: def from_env(cls) -> "Config": """从环境变量和 .env 文件加载配置。""" if load_dotenv is not None: - load_dotenv() + env_file = os.getenv("VOCALIZE_ENV_FILE") + if env_file: + load_dotenv(env_file) + else: + load_dotenv() + + from vocalize.runtime_paths import bundled_speech_provider + + bundled_provider = bundled_speech_provider() + default_provider_command = ( + str(bundled_provider) + if bundled_provider is not None + else cls.speech_provider_command + ) + default_provider_auto_start = ( + cls.speech_provider_auto_start or bundled_provider is not None + ) return cls( openai_api_key=os.getenv("OPENAI_API_KEY"), openai_base_url=os.getenv("OPENAI_BASE_URL", cls.openai_base_url), openai_model=os.getenv("OPENAI_MODEL", cls.openai_model), - gpu_host=os.getenv("GPU_HOST", cls.gpu_host), - sensevoice_ws_port=_int_env("SENSEVOICE_WS_PORT", cls.sensevoice_ws_port), - cosyvoice_ws_port=_int_env("COSYVOICE_WS_PORT", cls.cosyvoice_ws_port), + openai_thinking_mode=_thinking_mode_env( + "OPENAI_THINKING_MODE", + cls.openai_thinking_mode, + ), + stt_provider_url=os.getenv( + "VOCALIZE_STT_PROVIDER_URL", cls.stt_provider_url + ), + tts_provider_url=os.getenv( + "VOCALIZE_TTS_PROVIDER_URL", cls.tts_provider_url + ), + provider_connect_timeout_s=_float_env( + "VOCALIZE_PROVIDER_CONNECT_TIMEOUT_S", + cls.provider_connect_timeout_s, + ), + speech_provider_auto_start=_bool_env( + "VOCALIZE_SPEECH_PROVIDER_AUTO_START", + default_provider_auto_start, + ), + speech_provider_command=os.getenv( + "VOCALIZE_SPEECH_PROVIDER_COMMAND", + default_provider_command, + ), + speech_provider_startup_timeout_s=_float_env( + "VOCALIZE_SPEECH_PROVIDER_STARTUP_TIMEOUT_S", + cls.speech_provider_startup_timeout_s, + ), orchestrator_listen_port=_int_env( "ORCHESTRATOR_LISTEN_PORT", cls.orchestrator_listen_port ), @@ -74,7 +177,7 @@ def from_env(cls) -> "Config": ) def validate_for_phase( - self, phase: Literal["llm", "gpu"] + self, phase: Literal["llm", "speech"] ) -> list[str]: """返回该 phase 缺失的环境变量名列表;空列表代表 OK。 @@ -85,9 +188,11 @@ def validate_for_phase( if phase == "llm": if not self.openai_api_key: missing.append("OPENAI_API_KEY") - elif phase == "gpu": - if not self.gpu_host: - missing.append("GPU_HOST") + elif phase == "speech": + if not self.stt_provider_url: + missing.append("VOCALIZE_STT_PROVIDER_URL") + if not self.tts_provider_url: + missing.append("VOCALIZE_TTS_PROVIDER_URL") return missing def get_missing_configs(self) -> list[str]: diff --git a/src/vocalize/dialogue/orchestrator.py b/src/vocalize/dialogue/orchestrator.py index 0632c8f..a357e41 100644 --- a/src/vocalize/dialogue/orchestrator.py +++ b/src/vocalize/dialogue/orchestrator.py @@ -1311,7 +1311,7 @@ async def _preflight_drive_turn(user_text: str, lang: str) -> None: # but the loop itself stays on the EXECUTION_ACTIVE stream. try: audio_in = self._merchant.pipeline._transport.input_stream() - # Plan 04-04: pass transport so SenseVoiceClient can register the + # Plan 04-04: pass transport so the STT provider can register the # client-side webrtcvad EOS handler. try: stt_iter = self._merchant.pipeline._stt.stream_transcribe( # type: ignore[call-arg] diff --git a/src/vocalize/dialogue/user_channel.py b/src/vocalize/dialogue/user_channel.py index 54ed0a2..874e149 100644 --- a/src/vocalize/dialogue/user_channel.py +++ b/src/vocalize/dialogue/user_channel.py @@ -1,7 +1,7 @@ """dialogue.user_channel — 抽象用户接入通道(mid-call clarification 路径用)。 Phase 4 only ships ``LocalMicUserChannel``(消费 ``MicrophoneTransport`` -+ ``SenseVoiceClient`` + ``CosyVoiceClient``,对应 demos/phase4_three_party_local.py ++ Provider API STT/TTS clients,对应本地 Mac speech helper 或用户自定义 provider 里的 ``--user-mic-device`` 链路)。Phase 5.5 会再补一个 ``WebSocketUserChannel`` 让前端浏览器接入;两者满足同一 ``UserChannel`` Protocol,所以编排器 (Plan 04-08 clarification + Plan 04-09 orchestrator)的代码无须根据 transport diff --git a/src/vocalize/doctor.py b/src/vocalize/doctor.py new file mode 100644 index 0000000..518b043 --- /dev/null +++ b/src/vocalize/doctor.py @@ -0,0 +1,311 @@ +"""Deployment readiness checks for local VocalizeAI installs.""" +from __future__ import annotations + +import asyncio +import json +import os +import platform +import urllib.error +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + +from openai import AsyncOpenAI + +from vocalize.config import Config +from vocalize.install_state import INSTALL_MARKER +from vocalize.llm.openai_compat import _thinking_extra_body +from vocalize.provider_runtime import ensure_speech_provider_started + + +@dataclass(frozen=True) +class DoctorCheck: + name: str + ok: bool + detail: str + remediation: str | None = None + + +def run_doctor( + cfg: Config | None = None, + *, + skip_llm_probe: bool = False, +) -> list[DoctorCheck]: + cfg = cfg or Config.from_env() + checks: list[DoctorCheck] = [ + _check_macos(), + _check_install_layout(), + _check_llm_config(cfg), + ] + if not cfg.validate_for_phase("llm"): + checks.append(_check_llm_probe(cfg, skip=skip_llm_probe)) + checks.append(_check_speech_provider(cfg)) + return checks + + +def _check_macos() -> DoctorCheck: + if platform.system() == "Darwin": + return DoctorCheck("macos", True, platform.platform()) + return DoctorCheck( + "macos", + False, + f"unsupported platform: {platform.system()}", + "v0.1.0 only supports macOS as the public local runtime", + ) + + +def _check_llm_config(cfg: Config) -> DoctorCheck: + missing = cfg.validate_for_phase("llm") + if not missing: + return DoctorCheck("llm_config", True, cfg.openai_model) + return DoctorCheck( + "llm_config", + False, + f"missing: {', '.join(missing)}", + "set OPENAI_API_KEY, OPENAI_BASE_URL, and OPENAI_MODEL in .env", + ) + + +def _check_install_layout() -> DoctorCheck: + raw_root = os.getenv("VOCALIZE_INSTALL_ROOT") + if not raw_root: + return DoctorCheck("install_layout", True, "source/dev mode") + + root = Path(raw_root) + required = [ + root / INSTALL_MARKER, + root / "vocalize", + root / "bin", + root / "app", + root / "config", + root / "logs", + root / "cache", + root / "VERSION", + ] + missing = [str(path.relative_to(root)) for path in required if not path.exists()] + if missing: + return DoctorCheck( + "install_layout", + False, + f"missing: {', '.join(missing)}", + f"repair or reinstall the local VocalizeAI directory: {root}", + ) + return DoctorCheck("install_layout", True, str(root)) + + +def _check_llm_probe(cfg: Config, *, skip: bool) -> DoctorCheck: + if skip: + return DoctorCheck("llm_probe", True, "skipped by --skip-llm-probe") + try: + result = asyncio.run(_run_llm_full_agent_probe(cfg)) + except Exception as exc: + return DoctorCheck( + "llm_probe", + False, + _classify_llm_probe_error(exc), + "rerun `./vocalize setup` to adjust thinking mode; verify endpoint, API key, model, streaming, tool calling, and JSON mode", + ) + return DoctorCheck("llm_probe", True, result) + + +async def _run_llm_full_agent_probe(cfg: Config) -> str: + client = AsyncOpenAI( + api_key=cfg.openai_api_key, + base_url=cfg.openai_base_url, + timeout=20.0, + max_retries=0, + ) + json_kwargs: dict[str, Any] = { + "model": cfg.openai_model, + "messages": [ + { + "role": "user", + "content": 'Return only this JSON object: {"ok": true}', + } + ], + "response_format": {"type": "json_object"}, + "stream": True, + } + extra_body = _thinking_extra_body(cfg.openai_thinking_mode) + if extra_body is not None: + json_kwargs["extra_body"] = extra_body + json_stream = await client.chat.completions.create(**json_kwargs) + json_text = "" + async for chunk in json_stream: + if not chunk.choices: + continue + content = chunk.choices[0].delta.content + if content: + json_text += content + parsed = json.loads(json_text) + if parsed.get("ok") is not True: + raise RuntimeError("schema adherence probe returned unexpected JSON") + + tool_kwargs: dict[str, Any] = { + "model": cfg.openai_model, + "messages": [ + { + "role": "user", + "content": "Call the readiness tool with ok=true.", + } + ], + "tools": [ + { + "type": "function", + "function": { + "name": "report_readiness", + "description": "Report readiness probe result.", + "parameters": { + "type": "object", + "properties": {"ok": {"type": "boolean"}}, + "required": ["ok"], + }, + }, + } + ], + "tool_choice": { + "type": "function", + "function": {"name": "report_readiness"}, + }, + "stream": True, + } + extra_body = _thinking_extra_body(cfg.openai_thinking_mode) + if extra_body is not None: + tool_kwargs["extra_body"] = extra_body + tool_stream = await client.chat.completions.create(**tool_kwargs) + saw_tool_call = False + async for chunk in tool_stream: + if not chunk.choices: + continue + delta = chunk.choices[0].delta + tool_calls: Any = getattr(delta, "tool_calls", None) + if tool_calls: + saw_tool_call = True + if not saw_tool_call: + raise RuntimeError("tool calling probe did not emit a tool call") + return "streaming, tool calling, and JSON mode passed" + + +def _classify_llm_probe_error(exc: Exception) -> str: + message = str(exc) + lowered = message.lower() + if "401" in message or "unauthorized" in lowered or "authentication" in lowered: + return f"authentication failed: {message}" + if "404" in message or "model" in lowered: + return f"model or endpoint failed: {message}" + if "response_format" in lowered or "json" in lowered: + return f"JSON/schema probe failed: {message}" + if "tool" in lowered: + return f"tool calling probe failed: {message}" + if "timeout" in lowered or "connect" in lowered or "network" in lowered: + return f"network reachability failed: {message}" + return f"LLM probe failed: {message}" + + +def _check_speech_provider(cfg: Config) -> DoctorCheck: + url = _capabilities_url(cfg.stt_provider_url) + process = None + try: + process = ensure_speech_provider_started(cfg) + body = _read_provider_capabilities(url, timeout_s=cfg.provider_connect_timeout_s) + speech_status, mic_status, voices = _extract_permission_summary(body) + if speech_status == "not_determined" or mic_status == "not_determined": + _request_provider_permissions( + cfg.stt_provider_url, + timeout_s=max(cfg.provider_connect_timeout_s, 30.0), + ) + body = _read_provider_capabilities( + url, + timeout_s=cfg.provider_connect_timeout_s, + ) + except ( + OSError, + RuntimeError, + TimeoutError, + urllib.error.URLError, + json.JSONDecodeError, + ) as exc: + return DoctorCheck( + "speech_provider", + False, + f"unreachable: {url} ({exc})", + "run the macOS speech provider helper or fix VOCALIZE_STT_PROVIDER_URL", + ) + finally: + if process is not None: + process.terminate() + + speech_status, mic_status, voices = _extract_permission_summary(body) + + problems: list[str] = [] + if speech_status not in {"authorized", ""}: + problems.append(f"speech permission is {speech_status}") + if mic_status not in {"authorized", ""}: + problems.append(f"microphone permission is {mic_status}") + if voices <= 0: + problems.append("no TTS voices available") + + if problems: + return DoctorCheck( + "speech_provider", + False, + "; ".join(problems), + "grant Speech Recognition permission and install at least one macOS voice", + ) + return DoctorCheck("speech_provider", True, "provider reachable") + + +def _capabilities_url(base_url: str) -> str: + parsed = urlparse(base_url) + scheme = {"ws": "http", "wss": "https"}.get(parsed.scheme, parsed.scheme) + return f"{scheme}://{parsed.netloc}/v1/capabilities" + + +def _permissions_request_url(base_url: str) -> str: + parsed = urlparse(base_url) + scheme = {"ws": "http", "wss": "https"}.get(parsed.scheme, parsed.scheme) + return f"{scheme}://{parsed.netloc}/v1/permissions/request" + + +def _read_provider_capabilities(url: str, *, timeout_s: float) -> dict[str, Any]: + with urllib.request.urlopen(url, timeout=timeout_s) as resp: + body = json.loads(resp.read().decode("utf-8")) + if not isinstance(body, dict): + raise json.JSONDecodeError("provider capabilities must be an object", "", 0) + return body + + +def _request_provider_permissions(base_url: str, *, timeout_s: float) -> None: + request = urllib.request.Request( + _permissions_request_url(base_url), + data=b"{}", + method="POST", + headers={"Content-Type": "application/json"}, + ) + with urllib.request.urlopen(request, timeout=timeout_s) as resp: + resp.read() + + +def _extract_permission_summary(body: dict[str, Any]) -> tuple[str, str, int]: + permissions = body.get("permissions") + speech_status = "" + mic_status = "" + voices = 0 + if isinstance(permissions, dict): + speech_status = str( + permissions.get("speech_recognition") + or permissions.get("speechRecognition") + or "" + ) + mic_status = str(permissions.get("microphone") or "") + try: + voices = int( + permissions.get("tts_voices_available") + or permissions.get("ttsVoicesAvailable") + or 0 + ) + except (TypeError, ValueError): + voices = 0 + return speech_status, mic_status, voices diff --git a/src/vocalize/errors.py b/src/vocalize/errors.py new file mode 100644 index 0000000..12b0628 --- /dev/null +++ b/src/vocalize/errors.py @@ -0,0 +1,9 @@ +"""Shared service error types.""" + + +class VoiceServiceError(RuntimeError): + """Base class for STT/TTS provider failures. + + VoicePipeline catches this type to end or abandon the current audio path + without depending on a concrete provider implementation. + """ diff --git a/src/vocalize/install_state.py b/src/vocalize/install_state.py new file mode 100644 index 0000000..a50bc6d --- /dev/null +++ b/src/vocalize/install_state.py @@ -0,0 +1,233 @@ +"""Local install layout and state helpers.""" +from __future__ import annotations + +import json +import os +import shutil +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any, cast + + +INSTALL_MARKER = ".vocalize-install-root" +INSTALL_STATE_FILE = "install.json" +PREFERENCES_FILE = "preferences.json" +PID_FILE = "vocalize.pid" +PROVIDERS_FILE = "providers.yaml" +ENV_FILE = ".env" +DEFAULT_INSTALL_DIRNAME = "VocalizeAI" +DEFAULT_PROVIDER_URL = "http://127.0.0.1:8765" + + +@dataclass(frozen=True) +class InstallPaths: + root: Path + + @property + def bin_dir(self) -> Path: + return self.root / "bin" + + @property + def app_dir(self) -> Path: + return self.root / "app" + + @property + def config_dir(self) -> Path: + return self.root / "config" + + @property + def logs_dir(self) -> Path: + return self.root / "logs" + + @property + def cache_dir(self) -> Path: + return self.root / "cache" + + @property + def env_file(self) -> Path: + return self.config_dir / ENV_FILE + + @property + def providers_file(self) -> Path: + return self.config_dir / PROVIDERS_FILE + + @property + def preferences_file(self) -> Path: + return self.config_dir / PREFERENCES_FILE + + @property + def install_state_file(self) -> Path: + return self.config_dir / INSTALL_STATE_FILE + + @property + def pid_file(self) -> Path: + return self.cache_dir / PID_FILE + + @property + def marker_file(self) -> Path: + return self.root / INSTALL_MARKER + + @property + def log_file(self) -> Path: + return self.logs_dir / "vocalize.log" + + @property + def local_cli(self) -> Path: + return self.root / "vocalize" + + +def default_install_root(base_dir: Path | None = None) -> Path: + """Return the default install root under the current folder.""" + return (base_dir or Path.cwd()) / DEFAULT_INSTALL_DIRNAME + + +def detect_install_root() -> Path: + """Infer install root from env, packaged layout, or current directory.""" + raw = os.getenv("VOCALIZE_INSTALL_ROOT") + if raw: + return Path(raw).expanduser().resolve() + + executable = Path(sys.executable).resolve() + for parent in [executable.parent, *executable.parents]: + if (parent / "manifest.json").is_file() and (parent / "VERSION").is_file(): + return parent + if (parent / INSTALL_MARKER).is_file(): + return parent + return Path.cwd().resolve() + + +def ensure_install_dirs(root: Path) -> InstallPaths: + """Create the self-contained install directories.""" + paths = InstallPaths(root.resolve()) + for directory in ( + paths.bin_dir, + paths.app_dir, + paths.config_dir, + paths.logs_dir, + paths.cache_dir, + ): + directory.mkdir(parents=True, exist_ok=True) + return paths + + +def mark_install_root(root: Path) -> None: + """Write the guard marker that makes uninstall safe.""" + paths = ensure_install_dirs(root) + paths.marker_file.write_text("VocalizeAI local install\n", encoding="utf-8") + + +def has_install_marker(root: Path) -> bool: + return (root / INSTALL_MARKER).is_file() + + +def write_env_file( + paths: InstallPaths, + *, + openai_api_key: str, + openai_base_url: str, + openai_model: str, + openai_thinking_mode: str, + vocalize_port: int, +) -> None: + """Write generated runtime env for ordinary local installs.""" + content = "\n".join( + [ + "# Generated by `vocalize setup`.", + "VOCALIZE_HOST=127.0.0.1", + f"VOCALIZE_PORT={vocalize_port}", + "VOCALIZE_STT_PROVIDER_URL=http://127.0.0.1:8765", + "VOCALIZE_TTS_PROVIDER_URL=http://127.0.0.1:8765", + "VOCALIZE_PROVIDER_CONNECT_TIMEOUT_S=5.0", + "VOCALIZE_SPEECH_PROVIDER_AUTO_START=1", + f"VOCALIZE_SPEECH_PROVIDER_COMMAND={paths.bin_dir / 'vocalize-mac-speech-provider'}", + "VOCALIZE_SPEECH_PROVIDER_STARTUP_TIMEOUT_S=5.0", + f"OPENAI_BASE_URL={openai_base_url}", + f"OPENAI_MODEL={openai_model}", + f"OPENAI_THINKING_MODE={openai_thinking_mode}", + f"OPENAI_API_KEY={openai_api_key}", + "DEFAULT_LANGUAGE=zh", + f"LOG_DIR={paths.logs_dir}", + "", + ] + ) + paths.config_dir.mkdir(parents=True, exist_ok=True) + paths.env_file.write_text(content, encoding="utf-8") + + +def write_providers_yaml(paths: InstallPaths) -> None: + """Write default macOS Provider API configuration.""" + content = "\n".join( + [ + "version: 1", + "stt:", + " provider: macos-native", + f" base_url: {DEFAULT_PROVIDER_URL}", + " input_encoding: pcm_s16le", + " input_sample_rate: 16000", + "tts:", + " provider: macos-native", + f" base_url: {DEFAULT_PROVIDER_URL}", + " output_encoding: pcm_s16le", + " output_sample_rate: 24000", + "", + ] + ) + paths.config_dir.mkdir(parents=True, exist_ok=True) + paths.providers_file.write_text(content, encoding="utf-8") + + +def read_preferences(paths: InstallPaths) -> dict[str, object]: + if not paths.preferences_file.is_file(): + return {} + data = json.loads(paths.preferences_file.read_text(encoding="utf-8")) + return cast(dict[str, Any], data) + + +def write_preferences(paths: InstallPaths, preferences: dict[str, object]) -> None: + paths.config_dir.mkdir(parents=True, exist_ok=True) + paths.preferences_file.write_text( + json.dumps(preferences, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + +def read_install_state(paths: InstallPaths) -> dict[str, object]: + if not paths.install_state_file.is_file(): + return {} + data = json.loads(paths.install_state_file.read_text(encoding="utf-8")) + return cast(dict[str, Any], data) + + +def write_install_state(paths: InstallPaths, state: dict[str, object]) -> None: + paths.config_dir.mkdir(parents=True, exist_ok=True) + paths.install_state_file.write_text( + json.dumps(state, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + +def record_global_symlink(paths: InstallPaths, symlink_path: Path | None) -> None: + state = read_install_state(paths) + state["global_symlink"] = str(symlink_path) if symlink_path is not None else None + write_install_state(paths, state) + + +def remove_recorded_global_symlink(paths: InstallPaths) -> None: + state = read_install_state(paths) + raw = state.get("global_symlink") + if not isinstance(raw, str) or not raw: + return + symlink = Path(raw) + if symlink.is_symlink() and symlink.resolve() == paths.local_cli.resolve(): + symlink.unlink() + state["global_symlink"] = None + write_install_state(paths, state) + + +def remove_install_root(paths: InstallPaths) -> None: + """Remove the install root after marker validation.""" + if not paths.marker_file.is_file(): + raise RuntimeError(f"refusing to remove unmarked install root: {paths.root}") + remove_recorded_global_symlink(paths) + shutil.rmtree(paths.root) diff --git a/src/vocalize/llm/openai_compat.py b/src/vocalize/llm/openai_compat.py index aaf631c..f45a0fe 100644 --- a/src/vocalize/llm/openai_compat.py +++ b/src/vocalize/llm/openai_compat.py @@ -35,7 +35,7 @@ from openai import AsyncOpenAI, AsyncStream from openai.types.chat import ChatCompletionChunk -from vocalize.config import Config +from vocalize.config import Config, OpenAIThinkingMode from vocalize.llm.base import ( ChatMessage, FinishChunk, @@ -56,22 +56,13 @@ _THINK_OPEN = "" _THINK_CLOSE = "" -# 模型前缀 → 在请求体附加 ``thinking: {type: "disabled"}``。 -# DeepSeek-V4 系列文档 (api-docs.deepseek.com/api/create-chat-completion) -# 明确写:``"thinking": {"type": "disabled"}`` 表示 use non-thinking model; -# 老别名 ``deepseek-chat`` 直接路由到 v4-flash 非思考模式,无需此字段,但加上 -# 也无副作用(OpenAI-compat 服务端忽略未知字段)。 -# MiniMax-M 系按文档此字段会被忽略(M2.7 server 永远会推理),故不加—— -# client-side ``_ThinkingStripper`` 兜底剥离 ...。 -_DISABLE_THINKING_PREFIXES: tuple[str, ...] = ( - "deepseek-v4-", - "deepseek-reasoner", -) - - -def _server_disable_thinking(model: str) -> bool: - """返回 True 表示该模型支持 server-side ``thinking:{type:disabled}`` 关闭。""" - return any(model.startswith(p) for p in _DISABLE_THINKING_PREFIXES) +# 用户显式选择非 thinking 时,在请求体附加 ``thinking: {type: "disabled"}``。 +# ``enabled`` 不发送额外字段,避免假设所有 OpenAI-compatible 端点都支持 +# ``thinking:{type:enabled}``。 +def _thinking_extra_body(mode: OpenAIThinkingMode) -> dict[str, Any] | None: + if mode == "disabled": + return {"thinking": {"type": "disabled"}} + return None def _potential_prefix_len(tail: str, target: str) -> int: @@ -197,6 +188,7 @@ class OpenAICompatConfig: api_key: str base_url: str model: str + thinking_mode: OpenAIThinkingMode = "disabled" request_timeout: float = 30.0 max_retries: int = 2 @@ -238,6 +230,7 @@ def from_app_config(cls, cfg: Config) -> "OpenAICompatClient": api_key=cfg.openai_api_key, base_url=cfg.openai_base_url, model=cfg.openai_model, + thinking_mode=cfg.openai_thinking_mode, ) ) @@ -319,8 +312,9 @@ async def health_check(self) -> bool: "max_tokens": 1, "stream": False, } - if _server_disable_thinking(self._config.model): - kwargs["extra_body"] = {"thinking": {"type": "disabled"}} + extra_body = _thinking_extra_body(self._config.thinking_mode) + if extra_body is not None: + kwargs["extra_body"] = extra_body await self._client.chat.completions.create(**kwargs) return True except openai.AuthenticationError: @@ -352,8 +346,9 @@ async def _create_stream_with_retry( } if oai_tools is not None: kwargs["tools"] = oai_tools - if _server_disable_thinking(self._config.model): - kwargs["extra_body"] = {"thinking": {"type": "disabled"}} + extra_body = _thinking_extra_body(self._config.thinking_mode) + if extra_body is not None: + kwargs["extra_body"] = extra_body stream = await self._client.chat.completions.create(**kwargs) return cast(AsyncStream[ChatCompletionChunk], stream) except openai.AuthenticationError as exc: diff --git a/src/vocalize/pipeline.py b/src/vocalize/pipeline.py index b083a87..98b59f1 100644 --- a/src/vocalize/pipeline.py +++ b/src/vocalize/pipeline.py @@ -15,7 +15,7 @@ - 每一轮内部并发:LLM 文本流和 TTS 合成 / 播音同步进行——LLM 还在 yield 的时候, TTS 已经能拿到首句开始合成,扬声器就能开始播第一段音频。这是 e2e<2.5s 的关键。 - 段切割发生在 LLM→TTS 之间:按 ``。!?.!?\n`` 切句子边界,最后一段标 - ``is_final_segment=True`` 触发 CosyVoice flush。 + ``is_final_segment=True`` 触发 provider flush。 - back-pressure 自然存在:TTS 输入是 asyncio.Queue(bounded),TTS 输出是 PortAudio buffer;任一侧慢都会反压回 LLM token 拉取速度。 @@ -106,7 +106,7 @@ class TurnTiming: def stt_finalize(self) -> float | None: """从 "用户停止说话"(last_partial)到 STT final 的延迟。 - 包含:SenseVoice 端点检测 + finalize + 网络回程。是 STT 部分的最大头。 + 包含:STT 端点检测 + finalize + 网络回程。是 STT 部分的最大头。 """ if self.last_partial_at is None: return None @@ -164,12 +164,12 @@ class _TurnRunState: finish_reason: Literal["stop", "tool_calls", "length", "content_filter"] | None = None tool_call_in_progress: bool = False # Phase 4 Plan 04-03 fix #1 dispatch repair (debug session - # cosyvoice-batch-dispatch-deadcode): + # provider-batch-dispatch-deadcode): # ``pending_first_segment`` stash 第一个 sentence-ender 切出的段;只在确定 # 后续还有内容时才作为 ``is_final=False`` flush。流末 (_handle_turn) 若发现 - # pending 仍非空 + tail 空 → 把它以 ``is_final=True`` 单帧发出,让 cosyvoice - # server.py:948-966 的 batch dispatch 路径可以在短回复("好的。" / "4 位") - # 上命中,节省 ~1.5s ttft。多句长回复因 _handle_llm_chunk 在第二个 sentence-ender + # pending 仍非空 + tail 空 → 把它以 ``is_final=True`` 单帧发出,让 provider + # 的 batch dispatch 路径可以在短回复("好的。" / "4 位")上命中, + # 节省 ~1.5s ttft。多句长回复因 _handle_llm_chunk 在第二个 sentence-ender # 来到时就把 pending flush 掉,行为与原来等价(仅单 LLM-chunk 间隔的延迟)。 pending_first_segment: TextChunk | None = None mid_segment_flushed: bool = False @@ -178,12 +178,9 @@ class _TurnRunState: class VoicePipeline: """组装 transport + STT + LLM + TTS 的语音对话管道。 - 服务接口在 ``run()`` / ``_handle_turn()`` 里仍以 Protocol 契约调用,但 - per-turn 的错误恢复路径目前直接 catch ``SenseVoiceError`` / - ``LLMServiceError`` / ``CosyVoiceError`` 这三个具体类,所以替换成其他 - 实现会绕过 per-turn 兜底。 - TODO(phase-4): introduce ``VoiceServiceError`` base class so pipeline can - catch generically and accept arbitrary service implementations. + 服务接口在 ``run()`` / ``_handle_turn()`` 里以 Protocol 契约调用;STT/TTS + 错误统一 catch ``VoiceServiceError``,所以默认 Provider API 客户端和遗留 + 本地客户端都能走同一条恢复路径。 Args: transport: 双向音频 transport(mic + speaker / telephony transport (v2))。 @@ -230,12 +227,12 @@ async def run(self) -> None: """主对话循环。STT final → LLM → TTS → 扬声器 → 下一轮。 错误恢复策略不对称: - - LLM/TTS 错误是 per-turn 的(GPU 偶发 OOM、某个 prompt 触发服务端 bug), + - LLM/TTS 错误是 per-turn 的(provider 短暂不可用、某个 prompt 触发服务端 bug), 下一轮用户输入仍可以正常走,所以放在 ``_handle_turn`` 里吞掉。 - STT 错误意味着拿不到下一句用户输入,整个会话失去意义,所以这里 break 走 finally 优雅关掉 transport,让上层(systemd / 主进程)决定是否重启。 """ - from vocalize.stt.sensevoice import SenseVoiceError + from vocalize.errors import VoiceServiceError audio_in = self._transport.input_stream() # Phase 4 Plan 04-04: pass transport reference into STT so it can @@ -243,7 +240,7 @@ async def run(self) -> None: # sends {"event": "end_of_utterance"} over WS the moment webrtcvad # detects 9-of-10 unvoiced frames. STTService Protocol still accepts # a single positional argument; the ``transport`` kwarg is concrete - # to SenseVoiceClient — we feature-detect it to keep alternative STT + # to provider clients — we feature-detect it to keep alternative STT # implementations (and tests using a fake STT) working without # changes. try: @@ -280,7 +277,7 @@ async def run(self) -> None: ) first_partial_at = None last_partial_at = None - except SenseVoiceError as exc: + except VoiceServiceError as exc: log.error("STT error; ending session: %s", exc) finally: # 实现是 async generator 一定带 aclose;Protocol 上声明的是 AsyncIterator, @@ -303,8 +300,8 @@ async def _handle_turn( last_partial_at: float | None = None, ) -> None: """跑一轮:LLM 流 → 段切 → TTS → 扬声器。""" + from vocalize.errors import VoiceServiceError from vocalize.llm.openai_compat import LLMServiceError - from vocalize.tts.cosyvoice import CosyVoiceError timing = TurnTiming( user_text=user_text, @@ -418,9 +415,9 @@ async def _safe_put(item: TextChunk | None, *, is_text_chunk: bool = True) -> No if pending is not None and not tail: # Phase 4 Plan 04-03 fix #1:短回复合并路径。唯一一个 # sentence-ender 切出的段就是整个回复 → 直接以 - # is_final=True 单帧发出。CosyVoice server.py:948-966 的 - # batch dispatch (text_frame_count==0 && is_final) 现在 - # 可以命中,省 ~1.5s ttft。 + # is_final=True 单帧发出。provider 的 batch dispatch + # (text_frame_count==0 && is_final) 现在可以命中, + # 省 ~1.5s ttft。 await _safe_put( TextChunk( text=pending.text, @@ -464,9 +461,9 @@ async def _safe_put(item: TextChunk | None, *, is_text_chunk: bool = True) -> No try: await tts_task state.tts_succeeded = True - except CosyVoiceError as exc: - # TTS 中途 fatal 错误(GPU OOM、服务端 fatal 帧):放弃本轮音频, - # 不让单次 GPU 抖动 kill 整个会话。注意 LLM-error 路径下 TTS 还活着、 + except VoiceServiceError as exc: + # TTS 中途 fatal 错误(provider outage、服务端 fatal 帧):放弃本轮音频, + # 不让单次 provider 抖动 kill 整个会话。注意 LLM-error 路径下 TTS 还活着、 # 可推 fallback;这里 TTS 任务已 dead,再 put text_q 也合不出音频, # 所以只 log,不重试发兜底。 # TODO(phase-4): relaunch TTS stream for fallback in TTS-error path diff --git a/src/vocalize/provider_runtime.py b/src/vocalize/provider_runtime.py new file mode 100644 index 0000000..22b8ef3 --- /dev/null +++ b/src/vocalize/provider_runtime.py @@ -0,0 +1,94 @@ +"""Lifecycle helpers for the local speech Provider API process.""" +from __future__ import annotations + +import os +import shlex +import subprocess +import time +import urllib.error +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from urllib.parse import urlparse + +from vocalize.config import Config + + +@dataclass +class SpeechProviderProcess: + process: subprocess.Popen + capabilities_url: str + + def terminate(self, *, timeout_s: float = 3.0) -> None: + if self.process.poll() is not None: + return + self.process.terminate() + try: + self.process.wait(timeout=timeout_s) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.wait(timeout=timeout_s) + + +def ensure_speech_provider_started(cfg: Config) -> SpeechProviderProcess | None: + """Start the configured local speech provider when auto-start is enabled.""" + if not cfg.speech_provider_auto_start: + return None + if not cfg.speech_provider_command: + raise RuntimeError( + "VOCALIZE_SPEECH_PROVIDER_AUTO_START=1 requires " + "VOCALIZE_SPEECH_PROVIDER_COMMAND" + ) + + capabilities_url = _capabilities_url(cfg.stt_provider_url) + if _capabilities_ready(capabilities_url, timeout_s=0.25): + return None + + env = os.environ.copy() + parsed = urlparse(cfg.stt_provider_url) + if parsed.port is not None: + env.setdefault("VOCALIZE_SPEECH_PROVIDER_PORT", str(parsed.port)) + + process = subprocess.Popen( # noqa: S603 - operator-provided local command. + _command_args(cfg.speech_provider_command), + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + deadline = time.monotonic() + cfg.speech_provider_startup_timeout_s + while time.monotonic() < deadline: + if process.poll() is not None: + raise RuntimeError( + "speech provider exited during startup " + f"(code={process.returncode})" + ) + if _capabilities_ready(capabilities_url, timeout_s=0.25): + return SpeechProviderProcess(process=process, capabilities_url=capabilities_url) + time.sleep(0.1) + + process.terminate() + raise RuntimeError( + "speech provider did not become ready at " + f"{capabilities_url} within {cfg.speech_provider_startup_timeout_s}s" + ) + + +def _capabilities_url(base_url: str) -> str: + parsed = urlparse(base_url) + scheme = {"ws": "http", "wss": "https"}.get(parsed.scheme, parsed.scheme) + return f"{scheme}://{parsed.netloc}/v1/capabilities" + + +def _command_args(command: str) -> list[str]: + if Path(command).exists(): + return [command] + return shlex.split(command) + + +def _capabilities_ready(url: str, *, timeout_s: float) -> bool: + try: + with urllib.request.urlopen(url, timeout=timeout_s) as response: + status = int(response.status) + return 200 <= status < 300 + except (OSError, urllib.error.URLError, TimeoutError): + return False diff --git a/src/vocalize/providers/__init__.py b/src/vocalize/providers/__init__.py new file mode 100644 index 0000000..b99d266 --- /dev/null +++ b/src/vocalize/providers/__init__.py @@ -0,0 +1,6 @@ +"""Speech provider clients for the Vocalize Provider API.""" + +from vocalize.providers.speech import ProviderSTTClient, ProviderTTSClient +from vocalize.providers.speech import SpeechProviderError + +__all__ = ["ProviderSTTClient", "ProviderTTSClient", "SpeechProviderError"] diff --git a/src/vocalize/providers/speech.py b/src/vocalize/providers/speech.py new file mode 100644 index 0000000..abf9a9a --- /dev/null +++ b/src/vocalize/providers/speech.py @@ -0,0 +1,420 @@ +"""Vocalize Provider API speech clients.""" +from __future__ import annotations + +import asyncio +import json +import logging +import sys +import time +from collections.abc import AsyncIterator +from dataclasses import dataclass, field +from typing import Any +from urllib.parse import urlparse, urlunparse + +import websockets +from websockets.asyncio.client import ClientConnection, connect + +from vocalize.config import Config +from vocalize.errors import VoiceServiceError +from vocalize.stt.base import Transcript, TranscriptSegment +from vocalize.transports.base import AudioEncoding +from vocalize.tts.base import TextChunk + +log = logging.getLogger(__name__) + +PROVIDER_API_VERSION = "1.0" + + +class SpeechProviderError(VoiceServiceError): + """Provider API connection, protocol, or fatal upstream error.""" + + +def _ws_url(base_url: str, path: str) -> str: + parsed = urlparse(base_url) + if parsed.scheme not in {"http", "https", "ws", "wss"}: + raise SpeechProviderError( + "provider URL scheme must be http, https, ws, or wss" + ) + scheme = {"http": "ws", "https": "wss"}.get(parsed.scheme, parsed.scheme) + clean_path = "/" + path.lstrip("/") + return urlunparse((scheme, parsed.netloc, clean_path, "", "", "")) + + +def _segment_from_payload(payload: dict[str, Any]) -> TranscriptSegment: + return TranscriptSegment( + text=str(payload.get("text", "")), + language=str(payload.get("language", "")), + start_time=float(payload.get("start_time", 0.0)), + end_time=float(payload.get("end_time", 0.0)), + ) + + +def _transcript_from_payload(payload: dict[str, Any]) -> Transcript: + segments_raw = payload.get("segments") + segments = None + if isinstance(segments_raw, list): + segments = [ + _segment_from_payload(item) + for item in segments_raw + if isinstance(item, dict) + ] + language = payload.get("language") + return Transcript( + text=str(payload.get("text", "")), + is_final=bool(payload.get("is_final")), + confidence=float(payload.get("confidence", 0.0)), + start_time=float(payload.get("start_time", 0.0)), + end_time=float(payload.get("end_time", 0.0)), + utterance_id=int(payload.get("utterance_id", 0)), + language=str(language) if language is not None else None, + segments=segments, + ) + + +async def _safe_close(ws: ClientConnection) -> None: + try: + await ws.close() + except Exception: + log.debug("provider websocket close failed", exc_info=True) + + +@dataclass +class ProviderSTTClient: + """STT client for the Vocalize Provider API.""" + + base_url: str + path: str = "/v1/stt/stream" + language_hint: str = "auto" + session_id: str | None = None + connect_timeout_s: float = 5.0 + open_timeout_s: float = 5.0 + ping_interval_s: float = 20.0 + last_eos_wall_clock: float | None = field(default=None, init=False) + + @classmethod + def from_app_config(cls, cfg: Config) -> "ProviderSTTClient": + return cls( + base_url=cfg.stt_provider_url, + language_hint=cfg.default_language, + connect_timeout_s=cfg.provider_connect_timeout_s, + open_timeout_s=cfg.provider_connect_timeout_s, + ) + + @property + def ws_url(self) -> str: + return _ws_url(self.base_url, self.path) + + async def stream_transcribe( + self, + audio_chunks: AsyncIterator[bytes], + *, + transport: Any = None, + ) -> AsyncIterator[Transcript]: + try: + ws = await asyncio.wait_for( + connect( + self.ws_url, + open_timeout=self.open_timeout_s, + ping_interval=self.ping_interval_s, + ), + timeout=self.connect_timeout_s, + ) + except (TimeoutError, OSError, websockets.exceptions.WebSocketException) as exc: + raise SpeechProviderError( + f"failed to connect to STT provider {self.ws_url}: {exc}" + ) from exc + + async def _handle_eos() -> None: + self.last_eos_wall_clock = time.monotonic() + try: + await ws.send(json.dumps({"type": "end_of_utterance"})) + except websockets.exceptions.ConnectionClosed: + log.debug("provider EOS send dropped: ws already closed") + + if transport is not None and hasattr(transport, "_on_eos"): + transport._on_eos = _handle_eos + + async for transcript in self._run_session(ws, audio_chunks): + yield transcript + + async def _run_session( + self, + ws: ClientConnection, + audio_chunks: AsyncIterator[bytes], + ) -> AsyncIterator[Transcript]: + start_msg: dict[str, object] = { + "type": "start", + "provider_api_version": PROVIDER_API_VERSION, + "language": self.language_hint, + } + if self.session_id is not None: + start_msg["session_id"] = self.session_id + await ws.send(json.dumps(start_msg)) + + sender_done = asyncio.Event() + close_initiated_by_us = False + sender_task = asyncio.create_task( + self._send_audio(ws, audio_chunks, sender_done) + ) + + def _close_ws_on_sender_failure(task: asyncio.Task[None]) -> None: + nonlocal close_initiated_by_us + if task.cancelled(): + return + exc = task.exception() + if exc is None: + return + close_initiated_by_us = True + asyncio.create_task(_safe_close(ws)) + + sender_task.add_done_callback(_close_ws_on_sender_failure) + + try: + async for raw in ws: + if isinstance(raw, bytes): + continue + try: + msg = json.loads(raw) + except json.JSONDecodeError: + log.warning("ignoring non-JSON STT provider frame") + continue + + msg_type = msg.get("type") + if msg_type == "error" or "error" in msg: + fatal = bool(msg.get("fatal", True)) + message = str(msg.get("message") or msg.get("error") or "error") + if fatal: + raise SpeechProviderError(message) + log.warning("STT provider non-fatal error: %s", message) + continue + if msg_type == "transcript": + yield _transcript_from_payload(msg) + + if not sender_done.is_set() and not close_initiated_by_us: + raise SpeechProviderError( + "STT provider closed before audio sender finished" + ) + except websockets.exceptions.ConnectionClosed as exc: + if not sender_done.is_set() and not close_initiated_by_us: + raise SpeechProviderError( + f"STT provider connection closed mid-stream: {exc}" + ) from exc + finally: + if not sender_task.done(): + sender_task.cancel() + sender_exc: BaseException | None = None + try: + await sender_task + except asyncio.CancelledError: + pass + except Exception as exc: + sender_exc = exc + + await _safe_close(ws) + + if sender_exc is not None: + if sys.exc_info()[1] is None: + raise SpeechProviderError( + f"STT provider audio sender failed: {sender_exc}" + ) from sender_exc + log.warning("STT sender also failed: %s", sender_exc) + + async def _send_audio( + self, + ws: ClientConnection, + audio_chunks: AsyncIterator[bytes], + sender_done: asyncio.Event, + ) -> None: + try: + async for chunk in audio_chunks: + await ws.send(chunk) + await ws.send(json.dumps({"type": "end_of_utterance"})) + await ws.send(json.dumps({"type": "stop"})) + finally: + sender_done.set() + + +@dataclass +class ProviderTTSClient: + """TTS client for the Vocalize Provider API.""" + + base_url: str + path: str = "/v1/tts/stream" + default_language: str = "zh" + session_id: str | None = None + connect_timeout_s: float = 5.0 + open_timeout_s: float = 5.0 + ping_interval_s: float = 20.0 + output_sample_rate: int = 24_000 + output_encoding: AudioEncoding = field(default="pcm_s16le") + + @classmethod + def from_app_config(cls, cfg: Config) -> "ProviderTTSClient": + return cls( + base_url=cfg.tts_provider_url, + default_language=cfg.default_language, + connect_timeout_s=cfg.provider_connect_timeout_s, + open_timeout_s=cfg.provider_connect_timeout_s, + ) + + @property + def ws_url(self) -> str: + return _ws_url(self.base_url, self.path) + + async def stream_synthesize( + self, + text_chunks: AsyncIterator[TextChunk], + ) -> AsyncIterator[bytes]: + try: + ws = await asyncio.wait_for( + connect( + self.ws_url, + open_timeout=self.open_timeout_s, + ping_interval=self.ping_interval_s, + ), + timeout=self.connect_timeout_s, + ) + except (TimeoutError, OSError, websockets.exceptions.WebSocketException) as exc: + raise SpeechProviderError( + f"failed to connect to TTS provider {self.ws_url}: {exc}" + ) from exc + + async for audio in self._run_session(ws, text_chunks): + yield audio + + async def health_check(self) -> bool: + try: + ws = await asyncio.wait_for( + connect( + self.ws_url, + open_timeout=self.open_timeout_s, + ping_interval=self.ping_interval_s, + ), + timeout=self.connect_timeout_s, + ) + except (TimeoutError, OSError, websockets.exceptions.WebSocketException): + return False + await _safe_close(ws) + return True + + async def _run_session( + self, + ws: ClientConnection, + text_chunks: AsyncIterator[TextChunk], + ) -> AsyncIterator[bytes]: + start_msg: dict[str, object] = { + "type": "start", + "provider_api_version": PROVIDER_API_VERSION, + "language": self.default_language, + } + if self.session_id is not None: + start_msg["session_id"] = self.session_id + await ws.send(json.dumps(start_msg)) + + sender_done = asyncio.Event() + close_initiated_by_us = False + sender_task = asyncio.create_task( + self._send_text(ws, text_chunks, sender_done) + ) + + def _close_ws_on_sender_failure(task: asyncio.Task[None]) -> None: + nonlocal close_initiated_by_us + if task.cancelled(): + return + exc = task.exception() + if exc is None: + return + close_initiated_by_us = True + asyncio.create_task(_safe_close(ws)) + + sender_task.add_done_callback(_close_ws_on_sender_failure) + + try: + async for raw in ws: + if isinstance(raw, bytes): + yield raw + continue + try: + msg = json.loads(raw) + except json.JSONDecodeError: + log.warning("ignoring non-JSON TTS provider frame") + continue + + msg_type = msg.get("type") + if msg_type == "error" or "error" in msg: + fatal = bool(msg.get("fatal", True)) + message = str(msg.get("message") or msg.get("error") or "error") + if fatal: + raise SpeechProviderError(message) + log.warning("TTS provider non-fatal error: %s", message) + continue + if msg_type == "audio_start": + self._warn_if_audio_mismatch(msg) + continue + if msg_type == "audio_end": + continue + + if not sender_done.is_set() and not close_initiated_by_us: + raise SpeechProviderError( + "TTS provider closed before text sender finished" + ) + except websockets.exceptions.ConnectionClosed as exc: + if not sender_done.is_set() and not close_initiated_by_us: + raise SpeechProviderError( + f"TTS provider connection closed mid-stream: {exc}" + ) from exc + finally: + if not sender_task.done(): + sender_task.cancel() + sender_exc: BaseException | None = None + try: + await sender_task + except asyncio.CancelledError: + pass + except Exception as exc: + sender_exc = exc + + await _safe_close(ws) + + if sender_exc is not None: + if sys.exc_info()[1] is None: + raise SpeechProviderError( + f"TTS provider text sender failed: {sender_exc}" + ) from sender_exc + log.warning("TTS sender also failed: %s", sender_exc) + + async def _send_text( + self, + ws: ClientConnection, + text_chunks: AsyncIterator[TextChunk], + sender_done: asyncio.Event, + ) -> None: + try: + async for chunk in text_chunks: + await ws.send( + json.dumps( + { + "type": "text", + "text": chunk.text, + "language": chunk.language, + "is_final_segment": chunk.is_final_segment, + } + ) + ) + await ws.send(json.dumps({"type": "stop"})) + finally: + sender_done.set() + + def _warn_if_audio_mismatch(self, msg: dict[str, Any]) -> None: + sample_rate = msg.get("sample_rate") + encoding = msg.get("encoding") + if sample_rate is not None and int(sample_rate) != self.output_sample_rate: + log.warning( + "TTS provider sample_rate=%s differs from configured %s", + sample_rate, self.output_sample_rate, + ) + if encoding is not None and encoding != self.output_encoding: + log.warning( + "TTS provider encoding=%s differs from configured %s", + encoding, self.output_encoding, + ) diff --git a/src/vocalize/runtime_paths.py b/src/vocalize/runtime_paths.py new file mode 100644 index 0000000..a5b0a1a --- /dev/null +++ b/src/vocalize/runtime_paths.py @@ -0,0 +1,56 @@ +"""Runtime resource discovery for source and packaged builds.""" +from __future__ import annotations + +import sys +from pathlib import Path + + +RUNTIME_RESOURCE_DIRNAME = "vocalize_runtime" +FRONTEND_DIRNAME = "frontend" +CONFIG_DIRNAME = "config" +BIN_DIRNAME = "bin" +MACOS_SPEECH_PROVIDER_NAME = "vocalize-mac-speech-provider" + + +def bundled_resource_root() -> Path | None: + """Return the PyInstaller runtime resource root when present.""" + bundle_root = getattr(sys, "_MEIPASS", None) + if not bundle_root: + return None + candidate = Path(bundle_root) / RUNTIME_RESOURCE_DIRNAME + if candidate.is_dir(): + return candidate + return None + + +def bundled_frontend_dist() -> Path | None: + """Return the bundled Vite ``dist`` directory when available.""" + root = bundled_resource_root() + if root is None: + return None + candidate = root / FRONTEND_DIRNAME + if (candidate / "index.html").is_file(): + return candidate + return None + + +def bundled_config_template() -> Path | None: + """Return the packaged ``.env.example`` template when available.""" + root = bundled_resource_root() + if root is None: + return None + candidate = root / CONFIG_DIRNAME / ".env.example" + if candidate.is_file(): + return candidate + return None + + +def bundled_speech_provider() -> Path | None: + """Return the bundled macOS speech provider helper when available.""" + root = bundled_resource_root() + if root is None: + return None + candidate = root / BIN_DIRNAME / MACOS_SPEECH_PROVIDER_NAME + if candidate.is_file(): + return candidate + return None diff --git a/src/vocalize/server/__init__.py b/src/vocalize/server/__init__.py index 8e0de69..cda1e3c 100644 --- a/src/vocalize/server/__init__.py +++ b/src/vocalize/server/__init__.py @@ -7,12 +7,16 @@ from __future__ import annotations import os +from contextlib import asynccontextmanager +from pathlib import Path -from fastapi import FastAPI, Request, Response +from fastapi import FastAPI, HTTPException, Request, Response from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles from prometheus_fastapi_instrumentator import Instrumentator -from vocalize.server.health import make_default_gpu_probe, register_health_routes +from vocalize.server.health import make_default_speech_provider_probe, register_health_routes from vocalize.server.metrics import install_error_counter, refresh_runtime_gauges from vocalize.server.runner import DialogueOrchestratorRunner from vocalize.server.sessions import register_session_routes @@ -34,21 +38,72 @@ def _default_user_pipeline_factory(transport): from vocalize.config import get_config from vocalize.llm.openai_compat import OpenAICompatClient from vocalize.pipeline import VoicePipeline - from vocalize.stt.sensevoice import SenseVoiceClient - from vocalize.tts.cosyvoice import CosyVoiceClient + from vocalize.providers import ProviderSTTClient, ProviderTTSClient config = get_config() return VoicePipeline( transport=transport, system_prompt="", - stt=SenseVoiceClient.from_app_config(config), + stt=ProviderSTTClient.from_app_config(config), llm=OpenAICompatClient.from_app_config(config), - tts=CosyVoiceClient.from_app_config(config), + tts=ProviderTTSClient.from_app_config(config), ) _DEFAULT_PROD_ORIGINS: list[str] = [] _DEFAULT_DEV_ORIGINS = ["http://localhost:3000", "http://127.0.0.1:3000"] +_RESERVED_FRONTEND_FALLBACK_SEGMENTS = { + "api", + "ws", + "health", + "metrics", + "docs", + "redoc", + "openapi.json", +} + + +def _frontend_dist_dir() -> Path: + raw = os.getenv("VOCALIZE_FRONTEND_DIST") + if raw: + return Path(raw).expanduser() + from vocalize.runtime_paths import bundled_frontend_dist + + bundled = bundled_frontend_dist() + if bundled is not None: + return bundled + return Path(__file__).resolve().parents[3] / "frontend" / "dist" + + +def register_frontend_routes(app: FastAPI) -> None: + """Serve the built Vite console when ``frontend/dist`` is present.""" + dist_dir = _frontend_dist_dir() + index_file = dist_dir / "index.html" + if not index_file.is_file(): + return + + assets_dir = dist_dir / "assets" + if assets_dir.is_dir(): + app.mount( + "/assets", + StaticFiles(directory=assets_dir), + name="frontend-assets", + ) + + @app.get("/", include_in_schema=False) + async def _frontend_index() -> FileResponse: + return FileResponse(index_file) + + @app.get("/{path:path}", include_in_schema=False) + async def _frontend_spa(path: str) -> FileResponse: + first_segment = path.split("/", 1)[0] + if first_segment in _RESERVED_FRONTEND_FALLBACK_SEGMENTS: + raise HTTPException(status_code=404) + + requested_file = dist_dir / path + if requested_file.is_file(): + return FileResponse(requested_file) + return FileResponse(index_file) def create_app() -> FastAPI: @@ -64,9 +119,17 @@ def create_app() -> FastAPI: Raises RuntimeError at startup when absent in non-localhost mode (closes Host-header spoofing vector D-11 — see CONCERNS.md). In localhost-dev mode the WS URL is derived from the request base_url. - GPU_HOST / SENSEVOICE_WS_PORT / COSYVOICE_WS_PORT — GPU service targets. + VOCALIZE_STT_PROVIDER_URL / VOCALIZE_TTS_PROVIDER_URL — Provider API + endpoints for speech recognition and speech synthesis. """ - app = FastAPI(title="VocalizeAI", version="0.1.0") + @asynccontextmanager + async def _lifespan(app: FastAPI): + yield + process = getattr(app.state, "speech_provider_process", None) + if process is not None: + process.terminate() + + app = FastAPI(title="VocalizeAI", version="0.1.0", lifespan=_lifespan) # --- Prometheus metrics (/metrics endpoint) --- # Mount BEFORE CORS middleware so the instrumentator middleware sees all @@ -127,8 +190,15 @@ async def _refresh_runtime_gauges_on_metrics_scrape( "Example: wss://api.example.com" ) + from vocalize.config import get_config + from vocalize.provider_runtime import ensure_speech_provider_started + + config = get_config() + speech_provider_process = ensure_speech_provider_started(config) + app.state.speech_provider_process = speech_provider_process + register_session_routes(app, registry=registry) - register_health_routes(app, gpu_probe=make_default_gpu_probe()) + register_health_routes(app, provider_probe=make_default_speech_provider_probe()) register_ws_routes( app, registry=registry, @@ -138,7 +208,8 @@ async def _refresh_runtime_gauges_on_metrics_scrape( merchant_pipeline_factory=_default_user_pipeline_factory, ), ) + register_frontend_routes(app) return app -__all__ = ["create_app"] +__all__ = ["create_app", "register_frontend_routes"] diff --git a/src/vocalize/server/frames.py b/src/vocalize/server/frames.py index 0501405..c97df4a 100644 --- a/src/vocalize/server/frames.py +++ b/src/vocalize/server/frames.py @@ -8,7 +8,7 @@ - **Audio frames** are raw binary WS messages. Inbound (client→server) is raw PCM int16 LE 16 kHz mono. Outbound (server→client) prefixes a single ASCII role byte (``b'U'`` = ai-to-user, ``b'M'`` = ai-to-merchant) before the same - PCM payload at 24 kHz (matching the CosyVoice2 default sample rate). + PCM payload at 24 kHz (the default Provider API output sample rate). Server→client frames + binary helpers are added in Tasks 2–3. """ @@ -383,7 +383,7 @@ class OutboundAudioChunk: """Decoded payload of a server→client binary audio WS frame. The role tag is stored alongside so callers can route to the correct UI - visualiser. Sample format: PCM int16 LE 24 kHz mono (CosyVoice2 default). + visualiser. Sample format: PCM int16 LE 24 kHz mono by default. """ role: AudioOutboundRole diff --git a/src/vocalize/server/health.py b/src/vocalize/server/health.py index d821580..115d9ae 100644 --- a/src/vocalize/server/health.py +++ b/src/vocalize/server/health.py @@ -4,16 +4,16 @@ - ``ok``: always True when the server is reachable (the request itself proves it). -- ``gpu_reachable``: True if TCP connects to the configured GPU service ports - succeeded within a short timeout. The default probe is provided by - ``make_default_gpu_probe()`` reading the same app config as STT/TTS clients; - tests inject a fake probe to avoid network. +- ``speech_provider_reachable``: True if TCP connects to the configured STT/TTS + Provider API endpoints within a short timeout. Tests inject a fake probe to + avoid network. """ from __future__ import annotations import asyncio import logging from typing import Awaitable, Callable +from urllib.parse import urlparse from fastapi import FastAPI @@ -21,20 +21,26 @@ log = logging.getLogger(__name__) -GpuProbe = Callable[[], Awaitable[bool]] +ProviderProbe = Callable[[], Awaitable[bool]] -def make_default_gpu_probe( +def _host_port_from_url(url: str) -> tuple[str, int]: + parsed = urlparse(url) + if not parsed.hostname: + raise ValueError(f"provider URL must include a host: {url!r}") + if parsed.port is not None: + return parsed.hostname, parsed.port + if parsed.scheme in {"https", "wss"}: + return parsed.hostname, 443 + return parsed.hostname, 80 + + +def make_default_speech_provider_probe( *, timeout_s: float = 1.5, -) -> GpuProbe: - """Return a probe that TCP-connects to configured STT and TTS services. - - ``Config`` is the source of truth: ``GPU_HOST``, ``SENSEVOICE_WS_PORT``, - and ``COSYVOICE_WS_PORT``. If ``GPU_HOST`` is unset, the probe returns - False without a network call and treats "no GPU configured" as - "GPU unreachable" rather than crashing. - """ +) -> ProviderProbe: + """Return a probe that TCP-connects to configured speech providers.""" + async def _can_connect(host: str, port: int) -> bool: try: _reader, writer = await asyncio.wait_for( @@ -45,7 +51,7 @@ async def _can_connect(host: str, port: int) -> bool: return False # Close immediately; the probe is liveness only. If we leave the # writer open, every /health call leaks a TCP session against the - # GPU node and eventually exhausts file descriptors / endpoint + # speech provider and eventually exhausts file descriptors / endpoint # capacity. writer.close() try: @@ -56,26 +62,41 @@ async def _can_connect(host: str, port: int) -> bool: async def probe() -> bool: cfg = Config.from_env() - if not cfg.gpu_host: + try: + endpoints = list( + dict.fromkeys( + [ + _host_port_from_url(cfg.stt_provider_url), + _host_port_from_url(cfg.tts_provider_url), + ] + ) + ) + except ValueError: return False - ports = (cfg.sensevoice_ws_port, cfg.cosyvoice_ws_port) - for port in ports: - if not await _can_connect(cfg.gpu_host, port): + for host, port in endpoints: + if not await _can_connect(host, port): return False return True return probe -def register_health_routes(app: FastAPI, *, gpu_probe: GpuProbe) -> None: +def register_health_routes(app: FastAPI, *, provider_probe: ProviderProbe) -> None: @app.get("/health") async def health() -> dict: try: - reachable = await gpu_probe() + reachable = await provider_probe() except Exception: - log.warning("health: gpu_probe raised; reporting unreachable", exc_info=True) + log.warning( + "health: provider_probe raised; reporting unreachable", + exc_info=True, + ) reachable = False - return {"ok": True, "gpu_reachable": reachable} + return {"ok": True, "speech_provider_reachable": reachable} -__all__ = ["GpuProbe", "make_default_gpu_probe", "register_health_routes"] +__all__ = [ + "ProviderProbe", + "make_default_speech_provider_probe", + "register_health_routes", +] diff --git a/src/vocalize/server/metrics.py b/src/vocalize/server/metrics.py index a2d6ec0..81d9f8d 100644 --- a/src/vocalize/server/metrics.py +++ b/src/vocalize/server/metrics.py @@ -10,7 +10,7 @@ ``install_error_counter`` must be called once at app startup. Reference: prometheus_client pattern from -``infra/gpu-services/sensevoice/server.py:75-161``. +the Provider API speech runtime. """ from __future__ import annotations diff --git a/src/vocalize/server/runner.py b/src/vocalize/server/runner.py index 9ef6639..3675035 100644 --- a/src/vocalize/server/runner.py +++ b/src/vocalize/server/runner.py @@ -867,6 +867,9 @@ async def _handle_clarification_timeout( reason="clarification timeout", channel=channel, ) + task = getattr(self, "_orchestrator_task", None) + if task is not None and not task.done(): + task.cancel() async def _handle_set_auto_translate( self, @@ -1397,12 +1400,34 @@ async def forward_events() -> None: ) if forward_task in done: - pass + current_state = self._session.task_state + if ( + current_state is not None + and current_state.phase == TaskPhase.POST_CALL_REVIEW + and not orchestrator_task.done() + ): + orchestrator_task.cancel() + try: + await orchestrator_task + except (asyncio.CancelledError, Exception): + pass elif orchestrator_task in done: - try: - await forward_task - except (asyncio.CancelledError, Exception): - pass + current_state = self._session.task_state + if ( + current_state is not None + and current_state.phase == TaskPhase.POST_CALL_REVIEW + ): + if not forward_task.done(): + forward_task.cancel() + try: + await forward_task + except (asyncio.CancelledError, Exception): + pass + else: + try: + await forward_task + except (asyncio.CancelledError, Exception): + pass else: # External stop fired (hangup frame, WS closed). # Cancel the orchestrator immediately — the user has diff --git a/src/vocalize/stt/base.py b/src/vocalize/stt/base.py index c4b0853..2288a3e 100644 --- a/src/vocalize/stt/base.py +++ b/src/vocalize/stt/base.py @@ -16,8 +16,8 @@ cancellation: - 调用方通过对返回的 AsyncIterator 调用 ``aclose()`` 触发 STT 服务停止识别。 -- 实现是否真的关闭上游 WebSocket / 释放 GPU session 由实现类负责;建议在 ``aclose`` - 路径上发送 finalize 信号、然后关闭 socket,避免 GPU 节点继续占用。 +- 实现是否真的关闭上游 WebSocket / 释放 provider session 由实现类负责;建议在 + ``aclose`` 路径上发送 finalize 信号、然后关闭 socket,避免上游会话继续占用。 实现类应额外提供 ``async health_check() -> bool`` 方法供 Phase 6 编排器监控。 """ diff --git a/src/vocalize/stt/sensevoice.py b/src/vocalize/stt/sensevoice.py deleted file mode 100644 index 4301efb..0000000 --- a/src/vocalize/stt/sensevoice.py +++ /dev/null @@ -1,318 +0,0 @@ -"""SenseVoice WebSocket 客户端 — 流式 STT (Phase 1)。 - -连远端 SenseVoice 推理服务(``infra/gpu-services/sensevoice/server.py``), -按其协议发送音频 + 控制帧,把服务端 partial/final 结果转换成 ``Transcript``。 - -协议要点(详见 server 模块 docstring): -- 服务端 endpoint:``ws://:/ws/transcribe`` -- 客户端 → 服务端: - - 二进制:原始 PCM int16 LE,16 kHz mono - - 文本(JSON):``{"event":"start"|"end_of_utterance"|"stop", ...}`` -- 服务端 → 客户端:JSON 文本帧,要么是 transcript 要么是 ``{"error":..., "fatal":bool}`` - -设计取舍: -- Phase 1 内部不做 VAD:调用方负责通过 ``end_of_utterance`` 控制 utterance 边界; - 若没有外部 VAD,本客户端会在音频流结束(AsyncIterator 耗尽)时自动发送一次 - ``end_of_utterance`` + ``stop``,让服务端 flush 最后的 final。 -- 错误帧(``fatal=False``)转化为 yield 一条空 ``Transcript`` 并设 ``confidence=0``, - 让上层选择忽略或回退;``fatal=True`` 则抛 ``SenseVoiceError`` 终止流。 -- cancellation:调用方对返回的 AsyncIterator 调 ``aclose()``,本客户端在 finally - 里发送 ``stop`` 并关闭 socket,避免 GPU 端继续占用。 -""" -from __future__ import annotations - -import asyncio -import json -import logging -import sys -import time -from collections.abc import AsyncIterator -from dataclasses import dataclass, field -from typing import Any - -import websockets -from websockets.asyncio.client import ClientConnection, connect - -from vocalize.config import Config -from vocalize.stt.base import Transcript - -log = logging.getLogger(__name__) - - -class SenseVoiceError(RuntimeError): - """Fatal 服务端错误(``error.fatal=True``)或协议级故障。""" - - -@dataclass -class SenseVoiceClient: - """SenseVoice WebSocket 流式客户端。 - - Args: - host: GPU 节点主机名 / IP(来自 ``Config.gpu_host``)。 - port: WebSocket 端口(``Config.sensevoice_ws_port``,默认 8000)。 - path: WebSocket 路径,与服务端 endpoint 对齐。 - language_hint: ``"auto"`` / ``"zh"`` / ``"en"`` / ...;``auto`` 让模型自检。 - session_id: 可选会话 ID(透传给服务端,便于日志关联)。 - connect_timeout_s: TCP/WS 握手超时。 - open_timeout_s: ``websockets`` 库 open_timeout。 - ping_interval_s: 心跳间隔;与服务端 ``ws_ping_interval=20`` 对齐。 - """ - - host: str - port: int = 8000 - path: str = "/ws/transcribe" - language_hint: str = "auto" - session_id: str | None = None - connect_timeout_s: float = 5.0 - open_timeout_s: float = 5.0 - ping_interval_s: float = 20.0 - # Phase 4 Plan 04-04: stamped the moment the client sends the - # client-side VAD EOS frame ({"event": "end_of_utterance"}) over WS. - # Pipeline reads this in TurnTiming.last_speech_end_real to bypass the - # ~1.5s server-side fsmn-vad fallback latency. - last_eos_wall_clock: float | None = field(default=None, init=False) - - @property - def ws_url(self) -> str: - return f"ws://{self.host}:{self.port}{self.path}" - - @classmethod - def from_app_config(cls, cfg: Config) -> "SenseVoiceClient": - """Build from global app config; require ``GPU_HOST``.""" - missing = cfg.validate_for_phase("gpu") - if missing: - raise SenseVoiceError( - f"missing required env vars: {', '.join(missing)}" - ) - return cls( - host=cfg.gpu_host, - port=cfg.sensevoice_ws_port, - language_hint=cfg.default_language, - ) - - async def stream_transcribe( - self, audio_chunks: AsyncIterator[bytes], *, transport: Any = None, - ) -> AsyncIterator[Transcript]: - """流式转写。 - - 发送顺序:``start`` → 二进制 PCM 帧 * N → ``end_of_utterance`` → ``stop``。 - 服务端在 buffer 跨过 partial 阈值时主动推 partial,``end_of_utterance`` - / ``stop`` 触发 final。 - """ - try: - ws = await asyncio.wait_for( - connect( - self.ws_url, - open_timeout=self.open_timeout_s, - ping_interval=self.ping_interval_s, - ), - timeout=self.connect_timeout_s, - ) - except (TimeoutError, OSError, websockets.exceptions.WebSocketException) as exc: - raise SenseVoiceError( - f"failed to connect to {self.ws_url}: {exc}" - ) from exc - - # Phase 4 Plan 04-04 — register client-side VAD EOS handler on the - # transport (if it exposes the ``_on_eos`` slot). On a VAD-detected - # 9-of-10 unvoiced ring, MicrophoneTransport's input_stream consumer - # awaits this handler, which sends ``{"event": "end_of_utterance"}`` - # over WS. This preempts the ~1.5s server-side fsmn-vad finalize - # fallback, closing the second-largest dominant latency gap from - # CONCERNS.md. - # - # Race-tolerance: handler is registered AFTER ws-open but BEFORE we - # iterate audio_chunks. MicrophoneTransport guards the call with - # ``if self._on_eos is not None``, so any frames consumed before this - # registration completes simply fall through to the normal yield path - # (no crash). Once registered, the next TRIGGERED→NOTTRIGGERED - # transition fires the handler. - async def _handle_eos() -> None: - self.last_eos_wall_clock = time.monotonic() - try: - await ws.send(json.dumps({"event": "end_of_utterance"})) - log.debug("client VAD EOS sent over WS") - except websockets.exceptions.ConnectionClosed: - # Server closed before we could push EOS — sender path will - # surface the error via the existing close-mid-stream gate. - log.debug("EOS send dropped: ws already closed") - - if transport is not None and hasattr(transport, "_on_eos"): - transport._on_eos = _handle_eos - - async for transcript in self._run_session(ws, audio_chunks): - yield transcript - - async def _run_session( - self, ws: ClientConnection, audio_chunks: AsyncIterator[bytes] - ) -> AsyncIterator[Transcript]: - """已建连的会话循环:起 sender task,主协程读响应。""" - start_msg: dict[str, object] = { - "event": "start", - "language": self.language_hint, - } - if self.session_id is not None: - start_msg["session_id"] = self.session_id - await ws.send(json.dumps(start_msg)) - - sender_done = asyncio.Event() - # 标记是否是客户端侧主动发起关闭(sender 失败时 done-callback 触发)。 - # 为 True 时接收 loop 的正常退出不是"server-initiated graceful close mid-stream" - # 而是客户端自己关掉 ws 的正常结果——后者的错误由 sender_exc 在 finally 里 - # 表达,不应再抛 "connection closed mid-stream"。 - close_initiated_by_us = False - - sender_task = asyncio.create_task( - self._send_audio(ws, audio_chunks, sender_done) - ) - - # 如果 sender 因异常提前结束(比如上游音频源 raise),主动关掉 ws, - # 否则下面的 `async for raw in ws` 会永远等服务端不会到的帧。 - # (sender 正常完成时也会 schedule 关闭 → 服务端回应 ConnectionClosedOK, - # 主接收 loop 自然退出。) - def _close_ws_on_sender_failure(task: asyncio.Task[None]) -> None: - nonlocal close_initiated_by_us - if task.cancelled(): - return - exc = task.exception() - if exc is None: - return - # 调度 ws.close();不能在 done callback 里 await - close_initiated_by_us = True - asyncio.create_task(_safe_close(ws)) - - sender_task.add_done_callback(_close_ws_on_sender_failure) - - try: - async for raw in ws: - # ws 既可能 yield str 也可能 yield bytes;服务端只发文本 - if isinstance(raw, bytes): - raw = raw.decode("utf-8", errors="replace") - try: - msg = json.loads(raw) - except json.JSONDecodeError: - log.warning("ignoring non-JSON frame: %r", raw[:200]) - continue - - if "error" in msg: - fatal = bool(msg.get("fatal")) - err_text = str(msg.get("error", "unknown server error")) - if fatal: - raise SenseVoiceError(err_text) - log.warning("sensevoice non-fatal error: %s", err_text) - continue - - if "text" in msg and "is_final" in msg: - yield _msg_to_transcript(msg) - # 单 utterance 模式:收到 final 后让 sender 结束并关闭 - # 注意:流式模式下服务端可能继续推下一个 utterance 的 partial, - # 不能在这里 break。是否结束由音频流耗尽决定。 - - # 受到 graceful close(code=1000/1001)时 websockets 的 async-for 迭代器 - # 静默退出而不抛异常。只在 sender 还未干净完成 AND 关闭不是我们自己发起的 - # 情况下才认定为 server-initiated close mid-stream 故障。 - # (close_initiated_by_us=True 意味着 sender 失败 → done-callback 触发了 - # _safe_close;sender 的异常会在 finally 里通过 sender_exc 表达。) - if not sender_done.is_set() and not close_initiated_by_us: - raise SenseVoiceError( - "connection closed mid-stream: server closed before sender finished" - ) - except websockets.exceptions.ConnectionClosed as exc: - # 非 graceful close(code≠1000/1001)由此路径捕获。ConnectionClosedOK - # 是 ConnectionClosed 的子类;在 async-for 之外调用 recv() 时才出现, - # 此处作为防御性兜底,统一用 sender_done + close_initiated_by_us gate。 - if not sender_done.is_set() and not close_initiated_by_us: - raise SenseVoiceError( - f"connection closed mid-stream: {exc}" - ) from exc - finally: - # 必须永远 await sender_task 来回收它的异常,避免: - # 1) "Task exception was never retrieved" warning; - # 2) 上游音频源(麦克风/文件)失败被静默吞掉,调用方收不到任何信号, - # 主接收 loop 还在等永远不会到的服务端帧。 - if not sender_task.done(): - sender_task.cancel() - sender_exc: BaseException | None = None - try: - await sender_task - except asyncio.CancelledError: - # 我们自己 cancel 的清理路径,预期内 - pass - except Exception as exc: - sender_exc = exc - - try: - await ws.close() - except Exception: - log.exception("error closing websocket") - - # sender 失败时只在没有 in-flight 异常的情况下抛出,否则会盖掉 - # finally 之外正在传播的真正错误(比如 fatal server error)。 - if sender_exc is not None: - if sys.exc_info()[1] is None: - raise SenseVoiceError( - f"audio sender failed: {sender_exc}" - ) from sender_exc - log.warning( - "sender_task also failed (suppressed in favor of in-flight " - "exception): %s", sender_exc, - ) - - async def _send_audio( - self, - ws: ClientConnection, - audio_chunks: AsyncIterator[bytes], - sender_done: asyncio.Event, - ) -> None: - """把上游音频 chunk 推到 ws,结束时发 ``end_of_utterance`` + ``stop``。 - - ``sender_done`` 仅在 *正常* 完成路径(送完 stop 之后)置位; - ``ConnectionClosed`` 等被 swallow 的失败路径下保持 unset,让接收侧把 - 提前关连接判为故障并抛 ``SenseVoiceError``,而不是误判为 clean close。 - """ - sender_clean = False - try: - async for chunk in audio_chunks: - if not chunk: - continue - await ws.send(chunk) - # 输入流自然结束 → flush 最后一个 utterance 并关闭会话 - await ws.send(json.dumps({"event": "end_of_utterance"})) - await ws.send(json.dumps({"event": "stop"})) - sender_clean = True - except asyncio.CancelledError: - # cancellation 路径:尽量发 stop,避免 GPU 端继续占用。 - # 不是 "clean close",保持 sender_done unset。 - try: - await ws.send(json.dumps({"event": "stop"})) - except Exception: - pass - raise - except websockets.exceptions.ConnectionClosed: - # 服务端先关了;不 set sender_done,让接收侧(同样会看到 - # ConnectionClosed)抛 SenseVoiceError 而不是误判为 clean close。 - pass - finally: - if sender_clean: - sender_done.set() - - -async def _safe_close(ws: ClientConnection) -> None: - """Best-effort ws close used from a done-callback path.""" - try: - await ws.close() - except Exception: # pragma: no cover - defensive - log.debug("error in _safe_close", exc_info=True) - - -def _msg_to_transcript(msg: dict[str, object]) -> Transcript: - """服务端 transcript JSON → ``Transcript`` dataclass。""" - return Transcript( - text=str(msg.get("text", "")), - is_final=bool(msg.get("is_final", False)), - confidence=float(msg.get("confidence", 0.0) or 0.0), - start_time=float(msg.get("start_time", 0.0) or 0.0), - end_time=float(msg.get("end_time", 0.0) or 0.0), - utterance_id=int(msg.get("utterance_id", 0) or 0), - language=msg.get("language") if isinstance(msg.get("language"), str) else None, - ) diff --git a/src/vocalize/transports/microphone.py b/src/vocalize/transports/microphone.py index 3060391..d5e9fef 100644 --- a/src/vocalize/transports/microphone.py +++ b/src/vocalize/transports/microphone.py @@ -39,13 +39,13 @@ log = logging.getLogger(__name__) -# SenseVoice 服务端硬编码 16 kHz mono PCM int16;这里跟它对齐。 +# Provider API 输入默认使用 16 kHz mono PCM int16。 DEFAULT_SAMPLE_RATE = 16_000 DEFAULT_CHANNELS = 1 # 30 ms 帧 → 480 samples × 2 bytes = 960 bytes,常见 STT 流式块大小 DEFAULT_BLOCK_SIZE = 480 -# CosyVoice2 默认 24 kHz mono PCM int16;输出端默认与之对齐。 +# Provider API 输出默认使用 24 kHz mono PCM int16。 DEFAULT_OUTPUT_SAMPLE_RATE = 24_000 # 20 ms 帧 @ 24kHz = 480 samples × 2 bytes = 960 bytes。块越小启动延迟越低, # 但 PortAudio underrun 风险越高;20 ms 是常见 voice agent 折中。 @@ -59,11 +59,11 @@ class MicrophoneTransport: Args: device: ``sounddevice`` 输入设备名或索引;``None`` = 系统默认输入。 - sample_rate: 输入采样率,默认 16 kHz(与 SenseVoice 服务端约定一致)。 + sample_rate: 输入采样率,默认 16 kHz(Provider API 默认输入格式)。 block_size: 输入回调每次返回的样本数;30 ms @ 16 kHz = 480。 queue_maxsize: 输入 queue 容量;满了会丢最老帧并告警(避免无界增长)。 output_device: ``sounddevice`` 输出设备名或索引;``None`` = 系统默认输出。 - output_sample_rate: 输出采样率,默认 24 kHz(与 CosyVoice2 服务端约定一致)。 + output_sample_rate: 输出采样率,默认 24 kHz(Provider API 默认输出格式)。 output_block_size: 输出回调每次消费的样本数;20 ms @ 24 kHz = 480。 块越小启动延迟越低,但 PortAudio underrun 风险越高。 output_queue_maxsize: 输出 queue 容量;默认 200(约 2 秒缓冲 @ 20ms 块)。 @@ -139,7 +139,7 @@ def __init__( # State machine: NOTTRIGGERED → (≥9 voiced in last 10) → TRIGGERED → # (≥9 unvoiced in last 10) → NOTTRIGGERED + fire _on_eos. # ``_on_eos`` is None at construct time so the input_stream consumer - # is born tolerant of late registration (the SenseVoiceClient hooks it + # is born tolerant of late registration (the STT provider hooks it # up at the start of stream_transcribe, BEFORE iterating input_stream; # the guard `if self._on_eos is not None` is a defensive backstop). self._vad = webrtcvad.Vad(mode=2) @@ -243,7 +243,7 @@ def _callback( # 9-of-10 unvoiced → user stopped speaking. Stamp wall- # clock for pipeline.TurnTiming.last_speech_end_real # (closes the 11s instrumentation gap), invoke _on_eos - # callback if registered (SenseVoiceClient sends + # callback if registered (the STT provider sends # {"event": "end_of_utterance"} over WS), reset. if sum(1 for b in self._vad_buffer if not b) >= 9: self._vad_state = "NOTTRIGGERED" @@ -329,7 +329,7 @@ def _callback( outdata[:got] = leftover[:] # Same check on partial-leftover writes — even underruns # can carry the first audible sample if leftover is short - # but non-zero (e.g. CosyVoice emitted 200 bytes total). + # but non-zero (e.g. provider emitted 200 bytes total). wrote_audio = any(leftover[:got]) leftover.clear() outdata[got:need] = b"\x00" * (need - got) diff --git a/src/vocalize/tts/cosyvoice.py b/src/vocalize/tts/cosyvoice.py deleted file mode 100644 index e41a114..0000000 --- a/src/vocalize/tts/cosyvoice.py +++ /dev/null @@ -1,393 +0,0 @@ -"""CosyVoice2 WebSocket 客户端 — 流式 TTS (Phase 3)。 - -连远端 CosyVoice2 推理服务(``infra/gpu-services/cosyvoice/server.py``),把 -``AsyncIterator[TextChunk]`` 翻译成服务端协议帧,并把服务端推回的 PCM 音频字节 -原样作为 ``AsyncIterator[bytes]`` yield 给上层 transport。 - -协议要点(详见 server 模块 docstring): -- 服务端 endpoint:``ws://:/ws/synthesize`` -- 客户端 → 服务端(JSON 文本帧): - - ``{"event":"start","session_id":..,"language":..,"speed":.., - "prompt_wav":,"prompt_text":}`` - - ``{"event":"text","text":..,"language":..,"is_final_segment":bool}`` * N - - ``{"event":"stop"}`` -- 服务端 → 客户端: - - 二进制:PCM int16 LE,mono,sample_rate = ``audio_start.sample_rate`` - - JSON 文本:``audio_start`` / ``audio_end`` / ``{"error":..,"fatal":bool}`` - -设计取舍(与 ``stt.sensevoice`` 对齐): -- 单 sender task 推 text 帧、主协程 receive 二进制 + JSON 控制帧。任一侧异常即收尾, - 避免 receive loop 永远等不会到的帧;sender 失败时通过 done-callback 主动关 ws。 -- ``is_final_segment=True`` 直接转发给服务端:服务端用它触发 inference flush, - 这是流式 TTS 拿到完整尾音的硬性条件。 -- ``output_sample_rate`` / ``output_encoding`` 是硬性客户端配置:服务端当前固定 - ``pcm_s16le`` @ 24 kHz(``infra/gpu-services/cosyvoice/server.py``)。若服务端 - ``audio_start`` 报告不一致只 log warning 不 mutate——下游 transport 已经按客户端 - 配置开了 PortAudio output stream,运行时改 SR 会导致 pitch-shift。 -- cancellation:caller 对返回的 AsyncIterator ``aclose()`` / ``break`` → - ``finally`` 里 best-effort 发 ``stop`` + 关 socket。这是 Phase 5 barge-in 的硬性 - 前置:用户打断时必须立刻让 GPU 端停止合成。 -- 错误:``fatal=True`` → ``CosyVoiceError``;非 fatal → log + 继续(与 STT 对齐, - 让上层有机会忽略瞬时故障)。 -""" -from __future__ import annotations - -import asyncio -import json -import logging -import sys -from collections.abc import AsyncIterator -from dataclasses import dataclass, field -from typing import Any - -import websockets -from websockets.asyncio.client import ClientConnection, connect - -from vocalize.config import Config -from vocalize.transports.base import AudioEncoding -from vocalize.tts.base import TextChunk - -log = logging.getLogger(__name__) - - -# TODO(phase-4): introduce a common ``VoiceServiceError`` base in -# ``vocalize/errors.py`` so STT/LLM/TTS errors can be caught generically by -# pipeline glue. Currently ``SenseVoiceError``/``LLMServiceError``/ -# ``CosyVoiceError`` are three independent ``RuntimeError`` subclasses with -# duplicated shape (message + optional ``upstream_status``). -class CosyVoiceError(RuntimeError): - """Fatal 服务端错误(``error.fatal=True``)或协议级故障。 - - ``upstream_status`` 镜像 ``LLMServiceError`` 的形状方便 pipeline 统一处理; - CosyVoice 用 WS + JSON ``{error, fatal}`` 表达错误,没有 HTTP 状态码可填, - 所以当前所有调用点传 ``None``,字段为未来用(也方便测试断言)。 - """ - - def __init__(self, message: str, upstream_status: int | None = None) -> None: - super().__init__(message) - self.upstream_status: int | None = upstream_status - - -@dataclass -class CosyVoiceClient: - """CosyVoice2 WebSocket 流式客户端。 - - 实现 ``TTSService`` Protocol:``stream_synthesize`` + 暴露 ``output_sample_rate`` - / ``output_encoding`` 让上层 transport 知道字节流格式。 - - Args: - host: GPU 节点主机名 / IP(``Config.gpu_host``)。 - port: WebSocket 端口(``Config.cosyvoice_ws_port``,默认 8001)。 - path: WebSocket 路径,与服务端 endpoint 对齐。 - default_language: ``start`` 帧默认 language;后续 per-chunk 可覆盖。 - speed: 合成语速(CosyVoice 1.0=正常)。 - prompt_wav: 可选参考声纹 wav(容器内路径,由服务端解析)。 - prompt_text: 可选参考声纹对应的文本;与 ``prompt_wav`` 同属 zero-shot 克隆。 - session_id: 可选会话 ID(透传给服务端日志关联)。 - connect_timeout_s: TCP/WS 握手超时。 - open_timeout_s: ``websockets`` 库 open_timeout。 - ping_interval_s: 心跳间隔;与服务端 ``ws_ping_interval=20`` 对齐。 - output_sample_rate: 默认输出 SR;若服务端 ``audio_start`` 不一致则被覆盖。 - output_encoding: 默认输出编码;当前服务端固定 ``pcm_s16le``。 - """ - - host: str - port: int = 8001 - path: str = "/ws/synthesize" - default_language: str = "zh" - speed: float = 1.0 - prompt_wav: str | None = None - prompt_text: str | None = None - session_id: str | None = None - connect_timeout_s: float = 5.0 - open_timeout_s: float = 5.0 - ping_interval_s: float = 20.0 - output_sample_rate: int = 24_000 - output_encoding: AudioEncoding = field(default="pcm_s16le") - - def __post_init__(self) -> None: - if not (1 <= self.port <= 65535): - raise CosyVoiceError( - f"port must be in [1, 65535], got {self.port}" - ) - if not self.path.startswith("/"): - raise CosyVoiceError( - f"path must start with '/', got {self.path!r}" - ) - if self.speed <= 0: - raise CosyVoiceError(f"speed must be > 0, got {self.speed}") - if self.connect_timeout_s <= 0: - raise CosyVoiceError( - f"connect_timeout_s must be > 0, got {self.connect_timeout_s}" - ) - if self.open_timeout_s <= 0: - raise CosyVoiceError( - f"open_timeout_s must be > 0, got {self.open_timeout_s}" - ) - if self.ping_interval_s <= 0: - raise CosyVoiceError( - f"ping_interval_s must be > 0, got {self.ping_interval_s}" - ) - if self.output_sample_rate <= 0: - raise CosyVoiceError( - f"output_sample_rate must be > 0, got {self.output_sample_rate}" - ) - # zero-shot 声纹克隆需要 wav + 对应文本同时给出,缺一不可 - if (self.prompt_wav is None) != (self.prompt_text is None): - raise CosyVoiceError( - "prompt_wav and prompt_text must be set together " - "(both or neither)" - ) - - @property - def ws_url(self) -> str: - return f"ws://{self.host}:{self.port}{self.path}" - - @classmethod - def from_app_config(cls, cfg: Config) -> "CosyVoiceClient": - """从全局 ``Config`` 构造;缺 ``GPU_HOST`` 时报错。""" - missing = cfg.validate_for_phase("gpu") - if missing: - raise CosyVoiceError( - f"missing required env vars: {', '.join(missing)}" - ) - return cls( - host=cfg.gpu_host, - port=cfg.cosyvoice_ws_port, - default_language=cfg.default_language, - ) - - async def stream_synthesize( - self, text_chunks: AsyncIterator[TextChunk] - ) -> AsyncIterator[bytes]: - """流式合成。 - - 发送顺序:``start`` → ``text`` * N(每个 chunk 携带 language / - is_final_segment)→ ``stop``。服务端在每个 ``is_final_segment=True`` - 关 generator 触发 flush;二进制 PCM 帧按到达顺序透传。 - """ - try: - ws = await asyncio.wait_for( - connect( - self.ws_url, - open_timeout=self.open_timeout_s, - ping_interval=self.ping_interval_s, - ), - timeout=self.connect_timeout_s, - ) - except (TimeoutError, OSError, websockets.exceptions.WebSocketException) as exc: - raise CosyVoiceError( - f"failed to connect to {self.ws_url}: {exc}" - ) from exc - - async for audio in self._run_session(ws, text_chunks): - yield audio - - async def health_check(self) -> bool: - """轻量握手检查:能 connect + 立刻关闭即视为健康。 - - 与 SenseVoice 对齐:不发送 ``start`` 以免占用一个 GPU 推理 session。 - """ - try: - ws = await asyncio.wait_for( - connect( - self.ws_url, - open_timeout=self.open_timeout_s, - ping_interval=self.ping_interval_s, - ), - timeout=self.connect_timeout_s, - ) - except (TimeoutError, OSError, websockets.exceptions.WebSocketException) as exc: - log.warning("cosyvoice health_check failed: %s", exc) - return False - try: - await ws.close() - except Exception: # pragma: no cover - defensive - log.debug("error closing health-check ws", exc_info=True) - return True - - async def _run_session( - self, - ws: ClientConnection, - text_chunks: AsyncIterator[TextChunk], - ) -> AsyncIterator[bytes]: - """已建连的会话循环:起 sender task 推 text 帧,主协程读音频/控制帧。""" - start_msg: dict[str, Any] = { - "event": "start", - "language": self.default_language, - "speed": self.speed, - } - if self.prompt_wav is not None: - start_msg["prompt_wav"] = self.prompt_wav - if self.prompt_text is not None: - start_msg["prompt_text"] = self.prompt_text - if self.session_id is not None: - start_msg["session_id"] = self.session_id - await ws.send(json.dumps(start_msg)) - - sender_done = asyncio.Event() - # 标记是否是客户端侧主动发起关闭(sender 失败时 done-callback 触发)。 - # 为 True 时接收 loop 的正常退出不是"server-initiated graceful close mid-stream" - # 而是客户端自己关掉 ws 的正常结果——后者的错误由 sender_exc 在 finally 里 - # 表达,不应再抛 "connection closed mid-stream"。 - close_initiated_by_us = False - - sender_task = asyncio.create_task( - self._send_text(ws, text_chunks, sender_done) - ) - - # sender 异常提前结束 → 主动关 ws,避免接收 loop 永远等服务端帧。 - def _close_ws_on_sender_failure(task: asyncio.Task[None]) -> None: - nonlocal close_initiated_by_us - if task.cancelled(): - return - exc = task.exception() - if exc is None: - return - close_initiated_by_us = True - asyncio.create_task(_safe_close(ws)) - - sender_task.add_done_callback(_close_ws_on_sender_failure) - - try: - async for raw in ws: - if isinstance(raw, bytes): - # 二进制 = PCM 音频帧,原样透传 - yield raw - continue - - try: - msg = json.loads(raw) - except json.JSONDecodeError: - log.warning("ignoring non-JSON text frame: %r", raw[:200]) - continue - - if "error" in msg: - fatal = bool(msg.get("fatal")) - err_text = str(msg.get("error", "unknown server error")) - if fatal: - raise CosyVoiceError(err_text) - log.warning("cosyvoice non-fatal error: %s", err_text) - continue - - event = msg.get("event") - if event == "audio_start": - # 不要在运行时 mutate output_sample_rate / output_encoding: - # 下游 transport 已经按客户端配置的 SR 打开了 PortAudio - # output stream,运行时改 SR 会导致 pitch-shift。服务端 - # 当前固定 24 kHz pcm_s16le;不一致只 log warning,让客户端 - # 配置说了算。Phase 4 若需要服务端动态选 SR,得在握手阶段 - # 协商,不能在 audio_start 帧。 - sr = msg.get("sample_rate") - if isinstance(sr, int) and sr > 0 and sr != self.output_sample_rate: - log.warning( - "server reports sample_rate=%d but client configured " - "%d; downstream transport may pitch-shift. " - "Trusting client config.", - sr, self.output_sample_rate, - ) - enc = msg.get("encoding") - if isinstance(enc, str) and enc != self.output_encoding: - log.warning( - "server reports encoding=%r but client configured " - "%r; trusting client config.", - enc, self.output_encoding, - ) - # audio_end 当前不需要客户端动作;服务端会继续等下一个 text 段或 stop - - # 受到 graceful close(code=1000/1001)时 websockets 的 async-for 迭代器 - # 静默退出而不抛异常。只在 sender 还未干净完成 AND 关闭不是我们自己发起的 - # 情况下才认定为 server-initiated close mid-stream 故障。 - # (close_initiated_by_us=True 意味着 sender 失败 → done-callback 触发了 - # _safe_close;sender 的异常会在 finally 里通过 sender_exc 表达。) - if not sender_done.is_set() and not close_initiated_by_us: - raise CosyVoiceError( - "connection closed mid-stream: server closed before sender finished" - ) - except websockets.exceptions.ConnectionClosed as exc: - # 非 graceful close(code≠1000/1001)由此路径捕获。ConnectionClosedOK - # 是 ConnectionClosed 的子类;在 async-for 之外调用 recv() 时才出现, - # 此处作为防御性兜底,统一用 sender_done + close_initiated_by_us gate。 - if not sender_done.is_set() and not close_initiated_by_us: - raise CosyVoiceError( - f"connection closed mid-stream: {exc}" - ) from exc - finally: - # 必须 await sender 回收异常(避免 "Task exception was never retrieved" - # warning,并让上游 text iterator 失败时 caller 能感知)。 - if not sender_task.done(): - sender_task.cancel() - sender_exc: BaseException | None = None - try: - await sender_task - except asyncio.CancelledError: - pass - except Exception as exc: - sender_exc = exc - - try: - await ws.close() - except Exception: - log.exception("error closing websocket") - - if sender_exc is not None: - if sys.exc_info()[1] is None: - raise CosyVoiceError( - f"text sender failed: {sender_exc}" - ) from sender_exc - log.warning( - "sender_task also failed (suppressed in favor of in-flight " - "exception): %s", sender_exc, - ) - - async def _send_text( - self, - ws: ClientConnection, - text_chunks: AsyncIterator[TextChunk], - sender_done: asyncio.Event, - ) -> None: - """把上游 ``TextChunk`` 推到 ws,结束时发 ``stop``。 - - ``sender_done`` 仅在 *正常* 完成路径(送出 ``stop`` 之后)置位; - ConnectionClosed 等被 swallow 的失败路径下保持 unset,让接收侧能把 - 提前关连接判定为故障并抛 ``CosyVoiceError``,而不是误判为 clean close。 - """ - sender_clean = False - try: - async for chunk in text_chunks: - # 空文本一般跳过,但 ``is_final_segment=True`` 的"哨兵 chunk" - # 必须发出去——服务端用它触发 inference flush,不发就丢尾音。 - if not chunk.text and not chunk.is_final_segment: - continue - await ws.send(json.dumps({ - "event": "text", - "text": chunk.text, - "language": chunk.language, - "is_final_segment": chunk.is_final_segment, - }, ensure_ascii=False)) - await ws.send(json.dumps({"event": "stop"})) - sender_clean = True - except asyncio.CancelledError: - # cancellation 路径:尽量发 stop,让 GPU 端立刻停合成(barge-in)。 - # 注意:cancel 不算 "clean close from sender 视角"——保持 sender_done - # unset 让接收侧也能感知。 - try: - await ws.send(json.dumps({"event": "stop"})) - except Exception: - pass - raise - except websockets.exceptions.ConnectionClosed: - # 服务端先关了;不 set sender_done,让接收侧(也会看到 ConnectionClosed) - # 抛 CosyVoiceError 而不是当成正常 close。 - pass - finally: - if sender_clean: - sender_done.set() - - -async def _safe_close(ws: ClientConnection) -> None: - """Best-effort ws close used from a done-callback path.""" - try: - await ws.close() - except Exception: # pragma: no cover - defensive - log.debug("error in _safe_close", exc_info=True) diff --git a/tests/integration/ai_merchant.py b/tests/integration/ai_merchant.py index eeb3c7f..1a8e173 100644 --- a/tests/integration/ai_merchant.py +++ b/tests/integration/ai_merchant.py @@ -568,8 +568,9 @@ class _ReleaseAudioSettings(BaseModel): def _release_audio_settings() -> _ReleaseAudioSettings: - backend_url = os.getenv("VOCALIZE_RELEASE_AUDIO_BACKEND_URL") or os.getenv( - "NEXT_PUBLIC_VOCALIZE_API_BASE_URL" + backend_url = ( + os.getenv("VOCALIZE_RELEASE_AUDIO_BACKEND_URL") + or os.getenv("VITE_VOCALIZE_API_BASE_URL") ) input_label = os.getenv("VOCALIZE_RELEASE_AUDIO_INPUT_LABEL") play_cmd = os.getenv("VOCALIZE_RELEASE_AUDIO_PLAY_CMD") diff --git a/tests/integration/b2_loopback_server.py b/tests/integration/b2_loopback_server.py index 2304e9b..481c9ac 100644 --- a/tests/integration/b2_loopback_server.py +++ b/tests/integration/b2_loopback_server.py @@ -253,7 +253,7 @@ def build_app() -> FastAPI: ) registry = SessionRegistry() register_session_routes(app, registry=registry) - register_health_routes(app, gpu_probe=lambda: asyncio.sleep(0, result=False)) + register_health_routes(app, provider_probe=lambda: asyncio.sleep(0, result=False)) register_ws_routes( app, registry=registry, diff --git a/tests/integration/laptop-loopback.spec.ts b/tests/integration/laptop-loopback.spec.ts index 2077a91..80ff8e2 100644 --- a/tests/integration/laptop-loopback.spec.ts +++ b/tests/integration/laptop-loopback.spec.ts @@ -8,7 +8,7 @@ * * Requires: * - Backend: uvicorn vocalize.main:app --port 8000 - * - Frontend: NEXT_PUBLIC_VOCALIZE_API_BASE_URL=http://127.0.0.1:8000 npm run dev --port 3000 + * - Frontend: VITE_VOCALIZE_API_BASE_URL=http://127.0.0.1:8000 npm run dev -- --port 3000 * * The playwright.config.ts in frontend/ boots both servers automatically * via the webServer configuration. diff --git a/tests/integration/post-call-callback.spec.ts b/tests/integration/post-call-callback.spec.ts index 8aa3e3c..08265a4 100644 --- a/tests/integration/post-call-callback.spec.ts +++ b/tests/integration/post-call-callback.spec.ts @@ -7,7 +7,7 @@ * * Requires: * - Backend: VOCALIZE_DEBUG=1 uvicorn vocalize.main:app --port 8000 - * - Frontend: NEXT_PUBLIC_VOCALIZE_API_BASE_URL=http://127.0.0.1:8000 npm run dev --port 3000 + * - Frontend: VITE_VOCALIZE_API_BASE_URL=http://127.0.0.1:8000 npm run dev -- --port 3000 * * Backend debug knob: * When VOCALIZE_DEBUG=1 is set, the backend accepts a query parameter diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..676e0c2 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,313 @@ +"""CLI tests.""" +from __future__ import annotations + +import json +import os +import stat +import sys +import types +import zipfile +from pathlib import Path + +import vocalize.cli as cli +from vocalize.cli import main +from vocalize.doctor import DoctorCheck + + +def test_cli_doctor_returns_zero_when_all_checks_pass(monkeypatch, capsys) -> None: + monkeypatch.setattr( + "vocalize.cli.run_doctor", + lambda *, skip_llm_probe=False: [DoctorCheck("macos", True, "ok")], + ) + + assert main(["doctor"]) == 0 + assert "PASS macos: ok" in capsys.readouterr().out + + +def test_cli_doctor_returns_one_when_check_fails(monkeypatch, capsys) -> None: + monkeypatch.setattr( + "vocalize.cli.run_doctor", + lambda *, skip_llm_probe=False: [ + DoctorCheck( + "speech_provider", + False, + "speech permission is denied", + "grant permission", + ) + ], + ) + + assert main(["doctor"]) == 1 + out = capsys.readouterr().out + assert "FAIL speech_provider: speech permission is denied" in out + assert "fix: grant permission" in out + + +def test_cli_doctor_passes_skip_llm_probe(monkeypatch) -> None: + seen: list[bool] = [] + + def fake_run_doctor(*, skip_llm_probe: bool = False): + seen.append(skip_llm_probe) + return [DoctorCheck("llm_probe", True, "skipped")] + + monkeypatch.setattr("vocalize.cli.run_doctor", fake_run_doctor) + + assert main(["doctor", "--skip-llm-probe"]) == 0 + assert seen == [True] + + +def test_cli_setup_writes_env_providers_and_preferences( + monkeypatch, + tmp_path, + capsys, +) -> None: + monkeypatch.setenv("VOCALIZE_INSTALL_ROOT", str(tmp_path)) + monkeypatch.setattr("vocalize.config.load_dotenv", lambda *args, **kwargs: None) + + result = main( + [ + "setup", + "--non-interactive", + "--llm-api-key", + "sk-test", + "--llm-base-url", + "https://llm.example/v1", + "--llm-model", + "test-model", + "--llm-thinking-mode", + "enabled", + "--port", + "9090", + "--global-command", + "no", + "--open-browser", + "no", + ] + ) + + assert result == 0 + assert (tmp_path / ".vocalize-install-root").is_file() + env_text = (tmp_path / "config" / ".env").read_text(encoding="utf-8") + assert "OPENAI_API_KEY=sk-test" in env_text + assert "OPENAI_BASE_URL=https://llm.example/v1" in env_text + assert "OPENAI_MODEL=test-model" in env_text + assert "OPENAI_THINKING_MODE=enabled" in env_text + assert "VOCALIZE_PORT=9090" in env_text + providers = (tmp_path / "config" / "providers.yaml").read_text(encoding="utf-8") + assert "provider: macos-native" in providers + preferences = json.loads( + (tmp_path / "config" / "preferences.json").read_text(encoding="utf-8") + ) + assert preferences == {"open_browser": False} + assert "Configured:" in capsys.readouterr().out + + +def test_cli_setup_can_create_removable_global_symlink( + monkeypatch, + tmp_path, +) -> None: + install_root = tmp_path / "VocalizeAI" + global_bin = tmp_path / "global-bin" + install_root.mkdir() + (install_root / "vocalize").write_text("#!/bin/sh\n", encoding="utf-8") + monkeypatch.setenv("VOCALIZE_INSTALL_ROOT", str(install_root)) + monkeypatch.setenv("VOCALIZE_GLOBAL_BIN_DIR", str(global_bin)) + monkeypatch.setattr("vocalize.config.load_dotenv", lambda *args, **kwargs: None) + + assert ( + main( + [ + "setup", + "--non-interactive", + "--llm-api-key", + "sk-test", + "--global-command", + "yes", + "--open-browser", + "yes", + ] + ) + == 0 + ) + + symlink = global_bin / "vocalize" + assert symlink.is_symlink() + assert symlink.resolve() == install_root / "vocalize" + install_state = json.loads( + (install_root / "config" / "install.json").read_text(encoding="utf-8") + ) + assert install_state["global_symlink"] == str(symlink) + + +def test_cli_uninstall_removes_marked_install_and_symlink(monkeypatch, tmp_path) -> None: + install_root = tmp_path / "VocalizeAI" + global_bin = tmp_path / "global-bin" + install_root.mkdir() + (install_root / "vocalize").write_text("#!/bin/sh\n", encoding="utf-8") + monkeypatch.setenv("VOCALIZE_INSTALL_ROOT", str(install_root)) + monkeypatch.setenv("VOCALIZE_GLOBAL_BIN_DIR", str(global_bin)) + monkeypatch.setattr("vocalize.config.load_dotenv", lambda *args, **kwargs: None) + + assert ( + main( + [ + "setup", + "--non-interactive", + "--llm-api-key", + "sk-test", + "--global-command", + "yes", + "--open-browser", + "no", + ] + ) + == 0 + ) + symlink = global_bin / "vocalize" + assert symlink.is_symlink() + + assert main(["uninstall", "--yes"]) == 0 + + assert not install_root.exists() + assert not symlink.exists() + + +def test_cli_update_preserves_config_logs_and_cache(monkeypatch, tmp_path) -> None: + install_root = tmp_path / "VocalizeAI" + bundle = tmp_path / "bundle" / "VocalizeAI-0.1.1-macos-arm64" + artifact = tmp_path / "update.zip" + install_root.mkdir() + for name in ["config", "logs", "cache"]: + (install_root / name).mkdir() + (install_root / "config" / ".env").write_text("OPENAI_API_KEY=old\n") + (install_root / "logs" / "vocalize.log").write_text("old log\n") + (install_root / ".vocalize-install-root").write_text("marker\n") + (install_root / "VERSION").write_text("0.1.0\n") + + (bundle / "bin").mkdir(parents=True) + (bundle / "app").mkdir() + (bundle / "config").mkdir() + (bundle / "logs").mkdir() + (bundle / "cache").mkdir() + (bundle / "VERSION").write_text("0.1.1\n") + (bundle / "vocalize").write_text("#!/bin/sh\n") + (bundle / "vocalize").chmod(0o755) + (bundle / "target").write_text("target\n") + os.symlink("target", bundle / "link") + _zip_dir(bundle, artifact) + + monkeypatch.setenv("VOCALIZE_INSTALL_ROOT", str(install_root)) + + assert main(["update", "--artifact", str(artifact)]) == 0 + + assert (install_root / "VERSION").read_text(encoding="utf-8") == "0.1.1\n" + assert (install_root / "config" / ".env").read_text() == "OPENAI_API_KEY=old\n" + assert (install_root / "logs" / "vocalize.log").read_text() == "old log\n" + assert os.access(install_root / "vocalize", os.X_OK) + assert (install_root / "link").is_symlink() + assert os.readlink(install_root / "link") == "target" + + +def test_cli_start_foreground_applies_install_env(monkeypatch, tmp_path) -> None: + install_root = tmp_path / "VocalizeAI" + (install_root / "config").mkdir(parents=True) + (install_root / "logs").mkdir() + (install_root / "cache").mkdir() + (install_root / "config" / ".env").write_text( + "OPENAI_API_KEY=sk-test\nVOCALIZE_PORT=9090\n" + ) + monkeypatch.setenv("VOCALIZE_INSTALL_ROOT", str(install_root)) + monkeypatch.setenv("VOCALIZE_PORT", "8080") + seen_env: list[tuple[str | None, str | None]] = [] + monkeypatch.setitem( + sys.modules, + "vocalize.main", + types.SimpleNamespace( + main=lambda: seen_env.append( + (os.getenv("VOCALIZE_ENV_FILE"), os.getenv("VOCALIZE_PORT")) + ) + ), + ) + + assert main(["start", "--no-browser"]) == 0 + + assert seen_env == [(str(install_root / "config" / ".env"), "9090")] + + +def test_cli_start_foreground_delays_browser_until_server_ready( + monkeypatch, + tmp_path, +) -> None: + install_root = tmp_path / "VocalizeAI" + (install_root / "config").mkdir(parents=True) + (install_root / "logs").mkdir() + (install_root / "cache").mkdir() + (install_root / "config" / ".env").write_text("OPENAI_API_KEY=sk-test\n") + monkeypatch.setenv("VOCALIZE_INSTALL_ROOT", str(install_root)) + events: list[tuple[str, str | None]] = [] + monkeypatch.setattr( + "vocalize.cli._open_browser_when_ready_async", + lambda env: events.append(("browser", env.get("VOCALIZE_ENV_FILE"))), + ) + monkeypatch.setitem( + sys.modules, + "vocalize.main", + types.SimpleNamespace( + main=lambda: events.append(("serve", os.getenv("VOCALIZE_ENV_FILE"))) + ), + ) + + assert main(["start"]) == 0 + + assert events == [ + ("browser", str(install_root / "config" / ".env")), + ("serve", str(install_root / "config" / ".env")), + ] + + +def test_open_browser_when_ready_waits_for_health(monkeypatch) -> None: + calls: list[str] = [] + opened: list[str] = [] + + class _ReadyResponse: + status = 200 + + def __enter__(self): + return self + + def __exit__(self, *_args) -> None: + return None + + def fake_urlopen(request, timeout: float): + calls.append(request.full_url) + if len(calls) == 1: + raise OSError("not ready") + return _ReadyResponse() + + monkeypatch.setattr("urllib.request.urlopen", fake_urlopen) + monkeypatch.setattr("time.sleep", lambda _seconds: None) + monkeypatch.setattr("webbrowser.open", lambda url: opened.append(url)) + + assert cli._open_browser_when_ready( + {"VOCALIZE_HOST": "127.0.0.1", "VOCALIZE_PORT": "1234"}, + timeout_s=1.0, + interval_s=0.0, + ) + + assert calls == [ + "http://127.0.0.1:1234/health", + "http://127.0.0.1:1234/health", + ] + assert opened == ["http://127.0.0.1:1234"] + + +def _zip_dir(source: Path, destination: Path) -> None: + with zipfile.ZipFile(destination, "w") as archive: + for path in source.rglob("*"): + arcname = path.relative_to(source.parent).as_posix() + if path.is_symlink(): + info = zipfile.ZipInfo(arcname) + info.external_attr = (stat.S_IFLNK | 0o755) << 16 + archive.writestr(info, os.readlink(path)) + else: + archive.write(path, arcname) diff --git a/tests/test_dialogue_orchestrator.py b/tests/test_dialogue_orchestrator.py index 98fe29f..90dbcc2 100644 --- a/tests/test_dialogue_orchestrator.py +++ b/tests/test_dialogue_orchestrator.py @@ -1293,7 +1293,7 @@ async def test_relay_to_user_bidirectional() -> None: # --------------------------------------------------------------------------- async def test_orchestrator_passes_transport_kwarg_to_merchant_stt() -> None: """orchestrator.run() must call ``merchant_pipeline._stt.stream_transcribe`` - with ``transport=merchant_pipeline._transport`` so SenseVoiceClient can + with ``transport=merchant_pipeline._transport`` so the STT provider can register its client-side webrtcvad EOS handler. """ state = TaskState(session_id="test-transport") diff --git a/tests/test_doctor.py b/tests/test_doctor.py new file mode 100644 index 0000000..0e26d46 --- /dev/null +++ b/tests/test_doctor.py @@ -0,0 +1,170 @@ +"""Doctor checks.""" +from __future__ import annotations + +import json + +from vocalize.config import Config +from vocalize.doctor import run_doctor + + +class _Response: + status = 200 + + def __enter__(self): + return self + + def __exit__(self, *_args) -> None: + return None + + def read(self) -> bytes: + return json.dumps( + { + "providerApiVersion": "1.0", + "permissions": { + "speech_recognition": "authorized", + "microphone": "authorized", + "tts_voices_available": 5, + }, + } + ).encode("utf-8") + + +def test_doctor_passes_for_macos_llm_and_ready_provider(monkeypatch) -> None: + opened: list[str] = [] + + def fake_urlopen(url: str, timeout: float): + opened.append(url) + return _Response() + + monkeypatch.setattr("platform.system", lambda: "Darwin") + monkeypatch.setattr("platform.platform", lambda: "macOS-26.5-arm64") + monkeypatch.setattr("urllib.request.urlopen", fake_urlopen) + + checks = run_doctor( + Config( + openai_api_key="sk-test", + openai_model="test-model", + stt_provider_url="http://127.0.0.1:8766", + ), + skip_llm_probe=True, + ) + + assert all(check.ok for check in checks) + assert opened == ["http://127.0.0.1:8766/v1/capabilities"] + assert {check.name for check in checks} >= {"install_layout", "llm_probe"} + assert {check.name: check.detail for check in checks}["llm_probe"] == ( + "skipped by --skip-llm-probe" + ) + + +def test_doctor_fails_for_missing_llm_and_speech_permission(monkeypatch) -> None: + class _DeniedResponse(_Response): + def read(self) -> bytes: + return json.dumps( + { + "permissions": { + "speech_recognition": "denied", + "microphone": "denied", + "tts_voices_available": 0, + } + } + ).encode("utf-8") + + monkeypatch.setattr("platform.system", lambda: "Darwin") + monkeypatch.setattr("platform.platform", lambda: "macOS-26.5-arm64") + monkeypatch.setattr("urllib.request.urlopen", lambda *_args, **_kwargs: _DeniedResponse()) + + checks = run_doctor(Config(openai_api_key=None)) + by_name = {check.name: check for check in checks} + + assert by_name["llm_config"].ok is False + assert by_name["speech_provider"].ok is False + assert "speech permission is denied" in by_name["speech_provider"].detail + assert "microphone permission is denied" in by_name["speech_provider"].detail + + +def test_doctor_requests_not_determined_speech_permissions(monkeypatch) -> None: + calls: list[str] = [] + + class _NotDeterminedResponse(_Response): + def read(self) -> bytes: + return json.dumps( + { + "permissions": { + "speech_recognition": "not_determined", + "microphone": "not_determined", + "tts_voices_available": 5, + } + } + ).encode("utf-8") + + def fake_urlopen(request, timeout: float): + url = getattr(request, "full_url", request) + calls.append(str(url)) + if str(url).endswith("/v1/permissions/request"): + return _Response() + return _NotDeterminedResponse() if len(calls) == 1 else _Response() + + monkeypatch.setattr("platform.system", lambda: "Darwin") + monkeypatch.setattr("platform.platform", lambda: "macOS-26.5-arm64") + monkeypatch.setattr("urllib.request.urlopen", fake_urlopen) + + checks = run_doctor( + Config( + openai_api_key="sk-test", + openai_model="test-model", + stt_provider_url="http://127.0.0.1:8766", + ), + skip_llm_probe=True, + ) + + assert all(check.ok for check in checks) + assert calls == [ + "http://127.0.0.1:8766/v1/capabilities", + "http://127.0.0.1:8766/v1/permissions/request", + "http://127.0.0.1:8766/v1/capabilities", + ] + + +def test_doctor_probe_thinking_extra_body_modes() -> None: + from vocalize.llm.openai_compat import _thinking_extra_body + + assert _thinking_extra_body("disabled") == {"thinking": {"type": "disabled"}} + assert _thinking_extra_body("enabled") is None + + +def test_doctor_validates_packaged_install_layout(monkeypatch, tmp_path) -> None: + root = tmp_path / "VocalizeAI" + for path in [ + root / "bin", + root / "app", + root / "config", + root / "logs", + root / "cache", + ]: + path.mkdir(parents=True) + (root / ".vocalize-install-root").write_text("marker\n", encoding="utf-8") + (root / "vocalize").write_text("#!/bin/sh\n", encoding="utf-8") + (root / "VERSION").write_text("0.1.0\n", encoding="utf-8") + monkeypatch.setenv("VOCALIZE_INSTALL_ROOT", str(root)) + monkeypatch.setattr("platform.system", lambda: "Darwin") + monkeypatch.setattr("urllib.request.urlopen", lambda *_args, **_kwargs: _Response()) + + checks = run_doctor(Config(openai_api_key=None)) + by_name = {check.name: check for check in checks} + + assert by_name["install_layout"].ok is True + + +def test_doctor_reports_missing_install_layout_files(monkeypatch, tmp_path) -> None: + root = tmp_path / "VocalizeAI" + root.mkdir() + monkeypatch.setenv("VOCALIZE_INSTALL_ROOT", str(root)) + monkeypatch.setattr("platform.system", lambda: "Darwin") + monkeypatch.setattr("urllib.request.urlopen", lambda *_args, **_kwargs: _Response()) + + checks = run_doctor(Config(openai_api_key=None)) + by_name = {check.name: check for check in checks} + + assert by_name["install_layout"].ok is False + assert ".vocalize-install-root" in by_name["install_layout"].detail diff --git a/tests/test_install_scripts.py b/tests/test_install_scripts.py new file mode 100644 index 0000000..309dd65 --- /dev/null +++ b/tests/test_install_scripts.py @@ -0,0 +1,102 @@ +"""Installer script smoke tests.""" +from __future__ import annotations + +import hashlib +import subprocess +import zipfile +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def test_install_and_uninstall_scripts_parse() -> None: + result = subprocess.run( + ["bash", "-n", "install/install.sh", "install/uninstall.sh"], + cwd=REPO_ROOT, + check=False, + text=True, + capture_output=True, + ) + + assert result.returncode == 0, result.stderr + + +def test_installer_creates_self_contained_local_install(tmp_path) -> None: + artifact = tmp_path / "VocalizeAI-0.1.0-macos-arm64.zip" + checksums = tmp_path / "SHA256SUMS" + install_dir = tmp_path / "VocalizeAI" + bundle = tmp_path / "bundle" / "VocalizeAI-0.1.0-macos-arm64" + _write_fake_bundle(bundle) + _zip_dir(bundle, artifact) + digest = hashlib.sha256(artifact.read_bytes()).hexdigest() + checksums.write_text(f"{digest} {artifact.name}\n", encoding="utf-8") + + result = subprocess.run( + [ + "bash", + str(REPO_ROOT / "install" / "install.sh"), + "--artifact", + str(artifact), + "--checksums", + str(checksums), + "--install-dir", + str(install_dir), + "--yes", + ], + cwd=tmp_path, + check=False, + text=True, + capture_output=True, + ) + + assert result.returncode == 0, result.stderr + assert (install_dir / ".vocalize-install-root").is_file() + assert (install_dir / "vocalize").is_file() + assert (install_dir / "uninstall.sh").is_file() + assert (install_dir / "bin").is_dir() + assert (install_dir / "app").is_dir() + assert (install_dir / "config").is_dir() + assert (install_dir / "logs").is_dir() + assert (install_dir / "cache").is_dir() + + uninstall = subprocess.run( + ["bash", str(install_dir / "uninstall.sh"), "--yes"], + check=False, + text=True, + capture_output=True, + ) + + assert uninstall.returncode == 0, uninstall.stderr + assert not install_dir.exists() + + +def _write_fake_bundle(bundle: Path) -> None: + for directory in [ + bundle / "bin", + bundle / "app" / "vocalize", + bundle / "config", + bundle / "logs", + bundle / "cache", + ]: + directory.mkdir(parents=True) + for script in [ + bundle / "vocalize", + bundle / "bin" / "vocalize", + bundle / "bin" / "vocalize-mac-speech-provider", + ]: + script.write_text("#!/usr/bin/env bash\nexit 0\n", encoding="utf-8") + (bundle / "uninstall.sh").write_text( + (REPO_ROOT / "install" / "uninstall.sh").read_text(encoding="utf-8"), + encoding="utf-8", + ) + (bundle / ".vocalize-install-root").write_text("marker\n", encoding="utf-8") + (bundle / "VERSION").write_text("0.1.0\n", encoding="utf-8") + (bundle / "manifest.json").write_text("{}\n", encoding="utf-8") + (bundle / "config" / ".env.example").write_text("", encoding="utf-8") + + +def _zip_dir(source: Path, destination: Path) -> None: + with zipfile.ZipFile(destination, "w") as archive: + for path in source.rglob("*"): + archive.write(path, path.relative_to(source.parent)) diff --git a/tests/test_llm_openai_compat.py b/tests/test_llm_openai_compat.py index ea1e017..f0dbeba 100644 --- a/tests/test_llm_openai_compat.py +++ b/tests/test_llm_openai_compat.py @@ -373,13 +373,14 @@ async def test_health_check_auth_error_propagates() -> None: await client.health_check() -async def test_extra_body_thinking_disabled_for_deepseek_v4() -> None: - """deepseek-v4-flash 流式请求自动附加 ``thinking:{type:disabled}``。""" +async def test_extra_body_thinking_disabled_mode_for_stream() -> None: + """非 thinking 模式下,流式请求附加 ``thinking:{type:disabled}``。""" client = OpenAICompatClient( OpenAICompatConfig( api_key="sk-test", base_url="https://api.deepseek.com", - model="deepseek-v4-flash", + model="test-model", + thinking_mode="disabled", ) ) create = AsyncMock(return_value=FakeStream([])) @@ -390,13 +391,14 @@ async def test_extra_body_thinking_disabled_for_deepseek_v4() -> None: assert kwargs["extra_body"] == {"thinking": {"type": "disabled"}} -async def test_extra_body_thinking_disabled_health_check_deepseek_v4() -> None: - """health_check 在 deepseek-v4 上也带 disable thinking flag。""" +async def test_extra_body_thinking_disabled_mode_for_health_check() -> None: + """health_check 在非 thinking 模式下也带 disable thinking flag。""" client = OpenAICompatClient( OpenAICompatConfig( api_key="sk-test", base_url="https://api.deepseek.com", - model="deepseek-v4-pro", + model="test-model", + thinking_mode="disabled", ) ) create = AsyncMock(return_value=MagicMock()) @@ -406,13 +408,14 @@ async def test_extra_body_thinking_disabled_health_check_deepseek_v4() -> None: assert kwargs["extra_body"] == {"thinking": {"type": "disabled"}} -async def test_no_extra_body_for_minimax() -> None: - """MiniMax 服务端忽略 thinking 参数;client 不发,避免误导。""" +async def test_no_extra_body_when_thinking_enabled() -> None: + """thinking enabled 表示不发送关闭 thinking 的额外字段。""" client = OpenAICompatClient( OpenAICompatConfig( api_key="sk-test", base_url="https://api.minimaxi.com/v1", model="MiniMax-M2.7", + thinking_mode="enabled", ) ) create = AsyncMock(return_value=FakeStream([])) @@ -423,13 +426,14 @@ async def test_no_extra_body_for_minimax() -> None: assert "extra_body" not in kwargs -async def test_no_extra_body_for_legacy_deepseek_chat() -> None: - """``deepseek-chat`` 老别名服务端默认非思考模式;不需要 extra_body 也无副作用。""" +async def test_enabled_mode_omits_extra_body_for_deepseek_v4() -> None: + """即使是 DeepSeek V4,用户选择 enabled 时也不强行关闭 thinking。""" client = OpenAICompatClient( OpenAICompatConfig( api_key="sk-test", base_url="https://api.deepseek.com/v1", - model="deepseek-chat", + model="deepseek-v4-flash", + thinking_mode="enabled", ) ) create = AsyncMock(return_value=FakeStream([])) @@ -437,7 +441,6 @@ async def test_no_extra_body_for_legacy_deepseek_chat() -> None: async for _ in client.stream_chat([ChatMessage(role="user", content="hi")]): pass kwargs = create.await_args.kwargs - # ``deepseek-chat`` 没在 _DISABLE_THINKING_PREFIXES 列表里 → 不发 extra_body assert "extra_body" not in kwargs @@ -461,6 +464,7 @@ async def test_from_app_config_ok() -> None: assert client._config.api_key == "sk-x" assert client._config.base_url == "https://example.test/v1" assert client._config.model == "m" + assert client._config.thinking_mode == "disabled" async def test_tools_passed_to_create() -> None: diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 1349e4f..b61596b 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -25,10 +25,10 @@ ) from vocalize.llm.openai_compat import LLMServiceError from vocalize.pipeline import VoicePipeline +from vocalize.providers.speech import SpeechProviderError from vocalize.stt.base import Transcript from vocalize.transports.base import AudioEncoding from vocalize.tts.base import TextChunk -from vocalize.tts.cosyvoice import CosyVoiceError class FakeTransport: @@ -257,7 +257,7 @@ async def test_llm_error_does_not_crash_pipeline() -> None: async def test_tts_error_does_not_crash_pipeline() -> None: - """C2 回归:TTS 第一轮抛 CosyVoiceError,第二轮正常;pipeline 必须跑完两轮。""" + """C2 回归:TTS 第一轮抛 SpeechProviderError,第二轮正常;pipeline 必须跑完两轮。""" transport = FakeTransport() stt = FakeSTT([ Transcript(text="你好", is_final=True, confidence=1, start_time=0, @@ -287,7 +287,7 @@ async def stream_synthesize( per_call.append(c) self.received_chunks.append(per_call) if self.calls == 1: - raise CosyVoiceError("simulated GPU OOM") + raise SpeechProviderError("simulated provider outage") yield b"second-turn-audio" tts = FlakyTTS() @@ -353,7 +353,7 @@ async def test_tts_dies_during_llm_error_does_not_deadlock() -> None: """I3 回归:TTS 先死、LLM 后报错时,fallback put 和 None 哨兵不应永久阻塞。 text_q maxsize=32;FakeLLM 推 60 个 TextDelta(超过队列容量)后抛 - LLMServiceError。FlakyTTS 在第一个 chunk 消费前直接抛 CosyVoiceError, + LLMServiceError。FlakyTTS 在第一个 chunk 消费前直接抛 SpeechProviderError, 让 TTS task 在 LLM 还没报错时已 dead。若 fix 缺失,两次 await text_q.put() 在 queue 满时永久阻塞,asyncio.wait_for 超时即为回归。 """ @@ -376,14 +376,14 @@ async def stream_chat( raise LLMServiceError("boom after many deltas") class FlakyTTSImmediate: - """立即抛 CosyVoiceError,不消费任何 text chunk。""" + """立即抛 SpeechProviderError,不消费任何 text chunk。""" output_sample_rate: int = 24000 output_encoding: AudioEncoding = "pcm_s16le" async def stream_synthesize( self, text_chunks: AsyncIterator[TextChunk] ) -> AsyncIterator[bytes]: - raise CosyVoiceError("simulated immediate TTS death") + raise SpeechProviderError("simulated immediate TTS death") yield b"" # type: ignore[unreachable] pipeline = VoicePipeline( @@ -435,7 +435,7 @@ class FlakyTTSImmediate: async def stream_synthesize( self, text_chunks: AsyncIterator[TextChunk] ) -> AsyncIterator[bytes]: - raise CosyVoiceError("simulated immediate TTS death") + raise SpeechProviderError("simulated immediate TTS death") yield b"" # type: ignore[unreachable] pipeline = VoicePipeline( @@ -458,7 +458,7 @@ async def run_and_close() -> None: async def test_tts_failure_does_not_pollute_history() -> None: - """B3 回归:TTS 抛 CosyVoiceError 时 assistant 文本不应入历史——用户什么都没听到, + """B3 回归:TTS 抛 SpeechProviderError 时 assistant 文本不应入历史——用户什么都没听到, 持久化"虚假回复"会让下一轮 LLM 对话状态发散。 """ transport = FakeTransport() @@ -479,7 +479,7 @@ async def stream_synthesize( ) -> AsyncIterator[bytes]: async for _ in text_chunks: pass - raise CosyVoiceError("simulated TTS death") + raise SpeechProviderError("simulated TTS death") yield b"" # type: ignore[unreachable] pipeline = VoicePipeline( @@ -503,9 +503,7 @@ async def stream_synthesize( async def test_stt_error_ends_session_cleanly() -> None: - """I7 回归:STT 抛 SenseVoiceError 应 end-session-cleanly——不外抛、关 transport。""" - from vocalize.stt.sensevoice import SenseVoiceError - + """I7 回归:STT 抛 SpeechProviderError 应 end-session-cleanly——不外抛、关 transport。""" transport = FakeTransport() class BoomSTT: @@ -515,7 +513,7 @@ async def stream_transcribe( yield Transcript(text="hi", is_final=True, confidence=1, start_time=0, end_time=1, utterance_id=0, language="en") - raise SenseVoiceError("STT GPU died") + raise SpeechProviderError("STT provider died") llm = FakeLLM([[_td("ok."), _fin()]]) tts = FakeTTS([[b"x"]]) @@ -738,18 +736,17 @@ async def test_mixed_text_then_tool_call_gates_post_tool_text() -> None: # --------------------------------------------------------------------------- -# Phase 4 Plan 04-03 fix #1 dispatch repair (debug session -# cosyvoice-batch-dispatch-deadcode) +# Phase 4 Plan 04-03 fix #1 dispatch repair. # --------------------------------------------------------------------------- async def test_batch_dispatch_short_reply_single_frame() -> None: """Phase 4 Plan 04-03 fix #1 dispatch repair regression. A short reply that completes in a single sentence-ender ("好的。") MUST be sent to the TTS service as ONE TextChunk with is_final_segment=True — that - is the precondition for cosyvoice server.py:948-966 batch dispatch path - (text_frame_count_for_session==0 AND is_final) to ever fire. Pre-fix, the - pipeline emitted 2 frames (mid is_final=False + empty is_final=True - sentinel), making the batch path mathematically unreachable. + is the precondition for provider batch dispatch + (text_frame_count_for_session==0 AND is_final) to ever fire. Pre-fix, + the pipeline emitted 2 frames (mid is_final=False + empty is_final=True + sentinel), making the batch path unreachable. """ transport = FakeTransport() stt = FakeSTT([ @@ -779,7 +776,7 @@ async def test_batch_dispatch_short_reply_single_frame() -> None: # Exactly ONE chunk with is_final_segment=True and the full reply text. assert len(chunks) == 1, ( f"short reply must compact into a single TTS frame to enable " - f"cosyvoice batch dispatch; got {len(chunks)} frames: " + f"provider batch dispatch; got {len(chunks)} frames: " f"{[(c.text, c.is_final_segment) for c in chunks]}" ) assert chunks[0].is_final_segment is True @@ -790,7 +787,7 @@ async def test_bistream_dispatch_multi_segment_unchanged() -> None: """Companion to test_batch_dispatch_short_reply_single_frame. Multi-sentence replies MUST still stream as multiple chunks (mid + final) - so that cosyvoice bistream path keeps working — the batch dispatch repair + so that streaming provider paths keep working — the batch dispatch repair must not regress long-reply ttft. With "好的。明天给你确认。" the patched pipeline: 1. stash 第一段 "好的。" 进 pending(is_final=False) diff --git a/tests/test_provider_api_clients.py b/tests/test_provider_api_clients.py new file mode 100644 index 0000000..9d711bb --- /dev/null +++ b/tests/test_provider_api_clients.py @@ -0,0 +1,201 @@ +"""Provider API speech client protocol tests.""" +from __future__ import annotations + +import json +from collections.abc import AsyncIterator +from typing import Any + +import pytest +import websockets +from websockets.asyncio.server import ServerConnection, serve + +from vocalize.config import Config +from vocalize.providers import ProviderSTTClient, ProviderTTSClient +from vocalize.stt.base import Transcript +from vocalize.tts.base import TextChunk + + +class _FakeProviderServer: + def __init__(self, mode: str) -> None: + self.mode = mode + self.received_text: list[dict[str, Any]] = [] + self.received_audio: list[bytes] = [] + self._server: Any = None + self.port = 0 + + async def start(self) -> None: + self._server = await serve(self._handler, "127.0.0.1", 0) + self.port = self._server.sockets[0].getsockname()[1] + + async def stop(self) -> None: + if self._server is not None: + self._server.close() + await self._server.wait_closed() + + async def _handler(self, ws: ServerConnection) -> None: + try: + async for msg in ws: + if isinstance(msg, bytes): + self.received_audio.append(bytes(msg)) + continue + + parsed = json.loads(msg) + self.received_text.append(parsed) + msg_type = parsed.get("type") + if self.mode == "stt" and msg_type == "end_of_utterance": + await ws.send( + json.dumps( + { + "type": "transcript", + "text": "你好", + "is_final": True, + "confidence": 0.92, + "start_time": 0.0, + "end_time": 0.4, + "utterance_id": 7, + "language": "zh", + "segments": [ + { + "text": "你好", + "language": "zh", + "start_time": 0.0, + "end_time": 0.4, + } + ], + } + ) + ) + if self.mode == "tts" and msg_type == "start": + await ws.send( + json.dumps( + { + "type": "audio_start", + "sample_rate": 24000, + "encoding": "pcm_s16le", + } + ) + ) + if self.mode == "tts" and msg_type == "text": + await ws.send(b"\x01\x02" * 8) + await ws.send(json.dumps({"type": "audio_end"})) + if msg_type == "stop": + await ws.close() + return + except websockets.exceptions.ConnectionClosed: + pass + + +@pytest.fixture +async def stt_server() -> AsyncIterator[_FakeProviderServer]: + server = _FakeProviderServer("stt") + await server.start() + try: + yield server + finally: + await server.stop() + + +@pytest.fixture +async def tts_server() -> AsyncIterator[_FakeProviderServer]: + server = _FakeProviderServer("tts") + await server.start() + try: + yield server + finally: + await server.stop() + + +async def _audio_iter(chunks: list[bytes]) -> AsyncIterator[bytes]: + for chunk in chunks: + yield chunk + + +async def _text_iter(chunks: list[TextChunk]) -> AsyncIterator[TextChunk]: + for chunk in chunks: + yield chunk + + +async def test_provider_stt_streams_audio_and_parses_transcript( + stt_server: _FakeProviderServer, +) -> None: + client = ProviderSTTClient( + base_url=f"http://127.0.0.1:{stt_server.port}", + language_hint="zh", + session_id="sess-1", + ) + + out = [ + transcript + async for transcript in client.stream_transcribe( + _audio_iter([b"\x00\x01" * 16]) + ) + ] + + events = [item["type"] for item in stt_server.received_text] + assert events == ["start", "end_of_utterance", "stop"] + assert stt_server.received_text[0]["provider_api_version"] == "1.0" + assert stt_server.received_text[0]["language"] == "zh" + assert stt_server.received_text[0]["session_id"] == "sess-1" + assert stt_server.received_audio == [b"\x00\x01" * 16] + assert len(out) == 1 + assert isinstance(out[0], Transcript) + assert out[0].text == "你好" + assert out[0].is_final is True + assert out[0].utterance_id == 7 + assert out[0].segments is not None + assert out[0].segments[0].language == "zh" + + +async def test_provider_tts_streams_text_and_yields_audio( + tts_server: _FakeProviderServer, +) -> None: + client = ProviderTTSClient( + base_url=f"http://127.0.0.1:{tts_server.port}", + default_language="en", + session_id="sess-2", + ) + + out = [ + chunk + async for chunk in client.stream_synthesize( + _text_iter([TextChunk(text="hello", language="en", is_final_segment=True)]) + ) + ] + + events = [item["type"] for item in tts_server.received_text] + assert events == ["start", "text", "stop"] + assert tts_server.received_text[0]["provider_api_version"] == "1.0" + assert tts_server.received_text[0]["language"] == "en" + assert tts_server.received_text[0]["session_id"] == "sess-2" + assert tts_server.received_text[1] == { + "type": "text", + "text": "hello", + "language": "en", + "is_final_segment": True, + } + assert out == [b"\x01\x02" * 8] + + +async def test_provider_tts_health_check(tts_server: _FakeProviderServer) -> None: + client = ProviderTTSClient(base_url=f"http://127.0.0.1:{tts_server.port}") + + assert await client.health_check() is True + + +def test_provider_clients_from_app_config() -> None: + cfg = Config( + stt_provider_url="http://127.0.0.1:18000", + tts_provider_url="http://127.0.0.1:18001", + default_language="zh", + provider_connect_timeout_s=1.25, + ) + + stt = ProviderSTTClient.from_app_config(cfg) + tts = ProviderTTSClient.from_app_config(cfg) + + assert stt.base_url == "http://127.0.0.1:18000" + assert stt.language_hint == "zh" + assert stt.connect_timeout_s == 1.25 + assert tts.base_url == "http://127.0.0.1:18001" + assert tts.default_language == "zh" + assert tts.connect_timeout_s == 1.25 diff --git a/tests/test_provider_runtime.py b/tests/test_provider_runtime.py new file mode 100644 index 0000000..7b88a24 --- /dev/null +++ b/tests/test_provider_runtime.py @@ -0,0 +1,112 @@ +"""Speech provider process lifecycle tests.""" +from __future__ import annotations + +from typing import Any + +import pytest + +from vocalize.config import Config +from vocalize.provider_runtime import ensure_speech_provider_started + + +def test_provider_runtime_noops_when_auto_start_disabled() -> None: + cfg = Config(speech_provider_auto_start=False) + + assert ensure_speech_provider_started(cfg) is None + + +def test_provider_runtime_requires_command_when_enabled() -> None: + cfg = Config(speech_provider_auto_start=True, speech_provider_command=None) + + with pytest.raises(RuntimeError, match="VOCALIZE_SPEECH_PROVIDER_COMMAND"): + ensure_speech_provider_started(cfg) + + +def test_provider_runtime_starts_command_with_provider_port(monkeypatch) -> None: + ready_calls = iter([False, True]) + popen_calls: list[dict[str, Any]] = [] + + class _Process: + returncode = None + + def poll(self) -> int | None: + return None + + def terminate(self) -> None: + pass + + def wait(self, timeout: float | None = None) -> int: + return 0 + + def kill(self) -> None: + pass + + def fake_ready(url: str, *, timeout_s: float) -> bool: + assert url == "http://127.0.0.1:8766/v1/capabilities" + return next(ready_calls) + + def fake_popen(args, **kwargs): + popen_calls.append({"args": args, "env": kwargs["env"]}) + return _Process() + + monkeypatch.setattr("vocalize.provider_runtime._capabilities_ready", fake_ready) + monkeypatch.setattr("subprocess.Popen", fake_popen) + + cfg = Config( + stt_provider_url="http://127.0.0.1:8766", + speech_provider_auto_start=True, + speech_provider_command="/tmp/VocalizeSpeechProvider --flag", + ) + + process = ensure_speech_provider_started(cfg) + + assert process is not None + assert process.capabilities_url == "http://127.0.0.1:8766/v1/capabilities" + assert popen_calls[0]["args"] == ["/tmp/VocalizeSpeechProvider", "--flag"] + assert popen_calls[0]["env"]["VOCALIZE_SPEECH_PROVIDER_PORT"] == "8766" + + +def test_provider_runtime_preserves_existing_command_path_with_spaces( + monkeypatch, + tmp_path, +) -> None: + ready_calls = iter([False, True]) + command = tmp_path / "Folder With Spaces" / "VocalizeSpeechProvider" + command.parent.mkdir() + command.write_text("#!/bin/sh\n") + popen_args: list[list[str]] = [] + + class _Process: + returncode = None + + def poll(self) -> int | None: + return None + + def terminate(self) -> None: + pass + + def wait(self, timeout: float | None = None) -> int: + return 0 + + def kill(self) -> None: + pass + + monkeypatch.setattr( + "vocalize.provider_runtime._capabilities_ready", + lambda *_args, **_kwargs: next(ready_calls), + ) + monkeypatch.setattr( + "subprocess.Popen", + lambda args, **_kwargs: popen_args.append(args) or _Process(), + ) + + process = ensure_speech_provider_started( + Config( + stt_provider_url="http://127.0.0.1:8766", + speech_provider_auto_start=True, + speech_provider_command=str(command), + ) + ) + + assert process is not None + assert popen_args == [[str(command)]] diff --git a/tests/test_public_tree_audit.py b/tests/test_public_tree_audit.py new file mode 100644 index 0000000..7295ed6 --- /dev/null +++ b/tests/test_public_tree_audit.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from pathlib import Path + +from tools.ci.public_tree_audit import audit, main + + +def test_audit_rejects_private_paths(tmp_path: Path) -> None: + (tmp_path / "AGENTS.md").write_text("private\n") + (tmp_path / "src").mkdir() + (tmp_path / "src" / "app.py").write_text("print('ok')\n") + + findings = audit(tmp_path, ["AGENTS.md", "src/app.py"]) + + assert any("AGENTS.md" in finding.format() for finding in findings) + + +def test_audit_rejects_old_gpu_default_content(tmp_path: Path) -> None: + readme = tmp_path / "README.md" + readme.write_text("Set GPU_HOST before deploy.\n") + + findings = audit(tmp_path, ["README.md"]) + + assert any("old GPU default deployment variable" in finding.format() for finding in findings) + + +def test_audit_scans_dot_directories(tmp_path: Path) -> None: + issue_template = tmp_path / ".github" / "ISSUE_TEMPLATE" / "bug.yml" + issue_template.parent.mkdir(parents=True) + issue_template.write_text("OS: Raspberry Pi OS\n") + + findings = audit(tmp_path, [".github/ISSUE_TEMPLATE/bug.yml"]) + + assert any("old Pi deployment reference" in finding.format() for finding in findings) + + +def test_audit_allows_mac_first_public_content(tmp_path: Path) -> None: + (tmp_path / "README.md").write_text( + "Install VocalizeAI on macOS and configure your LLM endpoint.\n" + ) + (tmp_path / ".env.example").write_text("LLM_API_KEY=your_api_key\n") + + findings = audit(tmp_path, ["README.md", ".env.example"]) + + assert findings == [] + + +def test_main_accepts_file_list(tmp_path: Path, capsys) -> None: # type: ignore[no-untyped-def] + (tmp_path / "README.md").write_text("Mac-first local installer.\n") + file_list = tmp_path / "files.txt" + file_list.write_text("README.md\n") + + result = main(["--root", str(tmp_path), "--file-list", str(file_list)]) + + assert result == 0 + assert "Public tree audit passed" in capsys.readouterr().out diff --git a/tests/test_release_artifacts.py b/tests/test_release_artifacts.py new file mode 100644 index 0000000..5714e8b --- /dev/null +++ b/tests/test_release_artifacts.py @@ -0,0 +1,90 @@ +"""Release artifact helper tests.""" +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +from tools.release.artifacts import ( + read_project_version, + verify_sha256sums, + write_release_manifest, + write_sha256sums, +) + + +def test_read_project_version_from_pyproject(tmp_path) -> None: + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + '[project]\nname = "vocalize-ai"\nversion = "0.1.0"\n', + encoding="utf-8", + ) + + assert read_project_version(pyproject) == "0.1.0" + + +def test_sha256sums_round_trip_and_detects_tamper(tmp_path) -> None: + asset = tmp_path / "VocalizeAI-0.1.0-macos-arm64.zip" + asset.write_bytes(b"artifact bytes") + checksum_file = tmp_path / "SHA256SUMS" + + lines = write_sha256sums([asset], checksum_file) + + assert len(lines) == 1 + assert verify_sha256sums(checksum_file) == [asset.name] + + asset.write_bytes(b"tampered") + with pytest.raises(ValueError, match="checksum mismatch"): + verify_sha256sums(checksum_file) + + +def test_release_manifest_records_versioned_layout(tmp_path) -> None: + manifest_path = tmp_path / "manifest.json" + + manifest = write_release_manifest( + manifest_path, + version="0.1.0", + artifact_name="VocalizeAI-0.1.0-macos-arm64", + arch="arm64", + signing_mode="ad-hoc", + entrypoint="bin/vocalize", + backend_executable="app/vocalize/vocalize", + frontend_dist="app/vocalize/_internal/vocalize_runtime/frontend", + speech_provider="bin/vocalize-mac-speech-provider", + ) + + saved = json.loads(manifest_path.read_text(encoding="utf-8")) + assert saved == manifest + assert saved["layout_version"] == "v0.1-macos-onefolder" + assert saved["entrypoints"]["cli"] == "bin/vocalize" + assert saved["resources"]["frontend_dist"].endswith("vocalize_runtime/frontend") + assert saved["signing"]["notarization_required_for_public_release"] is True + + +def test_install_verify_release_script_accepts_matching_artifact(tmp_path) -> None: + if shutil.which("shasum") is None: + pytest.skip("shasum is not installed") + + asset = tmp_path / "VocalizeAI-0.1.0-macos-arm64.zip" + asset.write_bytes(b"artifact bytes") + checksum_file = tmp_path / "SHA256SUMS" + write_sha256sums([asset], checksum_file) + + result = subprocess.run( + [ + "bash", + "install/verify-release.sh", + str(checksum_file), + str(asset), + ], + check=False, + cwd=Path(__file__).resolve().parents[1], + text=True, + capture_output=True, + ) + + assert result.returncode == 0, result.stderr + assert f"{asset.name}: OK" in result.stdout diff --git a/tests/test_runtime_paths.py b/tests/test_runtime_paths.py new file mode 100644 index 0000000..a2cada2 --- /dev/null +++ b/tests/test_runtime_paths.py @@ -0,0 +1,79 @@ +"""Packaged runtime resource path tests.""" +from __future__ import annotations + +import sys + +from vocalize.config import Config +from vocalize.runtime_paths import ( + bundled_config_template, + bundled_frontend_dist, + bundled_resource_root, + bundled_speech_provider, +) +from vocalize.server import _frontend_dist_dir + + +def test_bundled_runtime_paths_use_pyinstaller_meipass(monkeypatch, tmp_path) -> None: + runtime_root = tmp_path / "vocalize_runtime" + frontend = runtime_root / "frontend" + config = runtime_root / "config" + provider = runtime_root / "bin" / "vocalize-mac-speech-provider" + frontend.mkdir(parents=True) + config.mkdir() + provider.parent.mkdir() + (frontend / "index.html").write_text("
VocalizeAI
", encoding="utf-8") + (config / ".env.example").write_text("OPENAI_API_KEY=\n", encoding="utf-8") + provider.write_text("#!/bin/sh\n", encoding="utf-8") + + monkeypatch.setattr(sys, "_MEIPASS", str(tmp_path), raising=False) + + assert bundled_resource_root() == runtime_root + assert bundled_frontend_dist() == frontend + assert bundled_config_template() == config / ".env.example" + assert bundled_speech_provider() == provider + + +def test_server_frontend_dist_prefers_bundled_dist_when_env_absent( + monkeypatch, + tmp_path, +) -> None: + frontend = tmp_path / "vocalize_runtime" / "frontend" + frontend.mkdir(parents=True) + (frontend / "index.html").write_text("
VocalizeAI
", encoding="utf-8") + + monkeypatch.delenv("VOCALIZE_FRONTEND_DIST", raising=False) + monkeypatch.setattr(sys, "_MEIPASS", str(tmp_path), raising=False) + + assert _frontend_dist_dir() == frontend + + +def test_config_auto_starts_bundled_speech_provider(monkeypatch, tmp_path) -> None: + provider = tmp_path / "vocalize_runtime" / "bin" / "vocalize-mac-speech-provider" + provider.parent.mkdir(parents=True) + provider.write_text("#!/bin/sh\n", encoding="utf-8") + + monkeypatch.setattr(sys, "_MEIPASS", str(tmp_path), raising=False) + monkeypatch.setattr("vocalize.config.load_dotenv", lambda *args, **kwargs: None) + monkeypatch.delenv("VOCALIZE_SPEECH_PROVIDER_AUTO_START", raising=False) + monkeypatch.delenv("VOCALIZE_SPEECH_PROVIDER_COMMAND", raising=False) + + cfg = Config.from_env() + + assert cfg.speech_provider_auto_start is True + assert cfg.speech_provider_command == str(provider) + + +def test_config_env_can_disable_bundled_speech_provider(monkeypatch, tmp_path) -> None: + provider = tmp_path / "vocalize_runtime" / "bin" / "vocalize-mac-speech-provider" + provider.parent.mkdir(parents=True) + provider.write_text("#!/bin/sh\n", encoding="utf-8") + + monkeypatch.setattr(sys, "_MEIPASS", str(tmp_path), raising=False) + monkeypatch.setattr("vocalize.config.load_dotenv", lambda *args, **kwargs: None) + monkeypatch.setenv("VOCALIZE_SPEECH_PROVIDER_AUTO_START", "0") + monkeypatch.delenv("VOCALIZE_SPEECH_PROVIDER_COMMAND", raising=False) + + cfg = Config.from_env() + + assert cfg.speech_provider_auto_start is False + assert cfg.speech_provider_command == str(provider) diff --git a/tests/test_server_app.py b/tests/test_server_app.py index caed10e..9bc1816 100644 --- a/tests/test_server_app.py +++ b/tests/test_server_app.py @@ -5,11 +5,10 @@ from vocalize.config import reset_config from vocalize.llm.openai_compat import OpenAICompatClient +from vocalize.providers import ProviderSTTClient, ProviderTTSClient +from vocalize.providers.speech import SpeechProviderError from vocalize.server import _default_user_pipeline_factory from vocalize.server import create_app -from vocalize.stt.sensevoice import SenseVoiceClient -from vocalize.stt.sensevoice import SenseVoiceError -from vocalize.tts.cosyvoice import CosyVoiceClient class _FakeTransport: @@ -22,36 +21,31 @@ def test_default_pipeline_factory_builds_clients_from_app_config(monkeypatch) -> monkeypatch.setenv("OPENAI_API_KEY", "sk-test") monkeypatch.setenv("OPENAI_BASE_URL", "https://example.test/v1") monkeypatch.setenv("OPENAI_MODEL", "test-model") - monkeypatch.setenv("GPU_HOST", "127.0.0.1") - monkeypatch.setenv("SENSEVOICE_WS_PORT", "18000") - monkeypatch.setenv("COSYVOICE_WS_PORT", "18001") + monkeypatch.setenv("VOCALIZE_STT_PROVIDER_URL", "http://127.0.0.1:18000") + monkeypatch.setenv("VOCALIZE_TTS_PROVIDER_URL", "http://127.0.0.1:18001") monkeypatch.setenv("DEFAULT_LANGUAGE", "zh") reset_config() pipeline = _default_user_pipeline_factory(_FakeTransport()) - assert isinstance(pipeline._stt, SenseVoiceClient) - assert pipeline._stt.host == "127.0.0.1" - assert pipeline._stt.port == 18000 + assert isinstance(pipeline._stt, ProviderSTTClient) + assert pipeline._stt.base_url == "http://127.0.0.1:18000" + assert pipeline._stt.ws_url == "ws://127.0.0.1:18000/v1/stt/stream" assert pipeline._stt.language_hint == "zh" assert isinstance(pipeline._llm, OpenAICompatClient) - assert isinstance(pipeline._tts, CosyVoiceClient) - assert pipeline._tts.host == "127.0.0.1" - assert pipeline._tts.port == 18001 + assert isinstance(pipeline._tts, ProviderTTSClient) + assert pipeline._tts.base_url == "http://127.0.0.1:18001" + assert pipeline._tts.ws_url == "ws://127.0.0.1:18001/v1/tts/stream" -def test_sensevoice_from_app_config_requires_gpu_host(monkeypatch) -> None: - from vocalize.config import Config - - monkeypatch.delenv("GPU_HOST", raising=False) - reset_config() - +def test_provider_url_validation_rejects_unknown_scheme() -> None: + client = ProviderSTTClient(base_url="ftp://127.0.0.1:18000") try: - SenseVoiceClient.from_app_config(Config.from_env()) - except SenseVoiceError as exc: - assert "GPU_HOST" in str(exc) + _ = client.ws_url + except SpeechProviderError as exc: + assert "scheme" in str(exc) else: # pragma: no cover - assertion clarity - raise AssertionError("SenseVoiceClient.from_app_config accepted missing GPU_HOST") + raise AssertionError("ProviderSTTClient accepted unsupported URL scheme") def test_create_app_allows_readme_127_frontend_origin(monkeypatch) -> None: @@ -75,3 +69,32 @@ def test_create_app_allows_readme_127_frontend_origin(monkeypatch) -> None: assert response.status_code == 200 assert response.headers["access-control-allow-origin"] == "http://127.0.0.1:3000" + + +def test_create_app_serves_built_vite_frontend(monkeypatch, tmp_path) -> None: + dist = tmp_path / "dist" + assets = dist / "assets" + assets.mkdir(parents=True) + (dist / "index.html").write_text("
VocalizeAI console
", encoding="utf-8") + (assets / "app.js").write_text("console.log('vocalize')", encoding="utf-8") + + monkeypatch.setenv("VOCALIZE_HOST", "127.0.0.1") + monkeypatch.setenv("VOCALIZE_FRONTEND_DIST", str(dist)) + monkeypatch.delenv("VOCALIZE_WS_BASE_URL", raising=False) + reset_config() + + app = create_app() + + with TestClient(app) as client: + index = client.get("/") + spa_route = client.get("/zh/live/session-1") + asset = client.get("/assets/app.js") + api_miss = client.get("/api/not-found") + + assert index.status_code == 200 + assert "VocalizeAI console" in index.text + assert spa_route.status_code == 200 + assert "VocalizeAI console" in spa_route.text + assert asset.status_code == 200 + assert "vocalize" in asset.text + assert api_miss.status_code == 404 diff --git a/tests/test_server_health.py b/tests/test_server_health.py index c8f06da..0a5e30c 100644 --- a/tests/test_server_health.py +++ b/tests/test_server_health.py @@ -1,21 +1,18 @@ -"""/health endpoint tests. - -The endpoint reports both server liveness and GPU node reachability -(``gpu_reachable``). Reachability is probed by attempting a TCP connect to -the GPU host:port from env. We monkey-patch the probe so tests don't depend -on real network state. -""" +"""/health endpoint tests.""" from __future__ import annotations from fastapi import FastAPI from httpx import ASGITransport, AsyncClient -from vocalize.server.health import make_default_gpu_probe, register_health_routes +from vocalize.server.health import ( + make_default_speech_provider_probe, + register_health_routes, +) def _app(probe) -> FastAPI: app = FastAPI() - register_health_routes(app, gpu_probe=probe) + register_health_routes(app, provider_probe=probe) return app @@ -27,20 +24,20 @@ async def _request(app: FastAPI) -> dict: return resp.json() -async def test_health_reports_ok_and_gpu_reachable() -> None: +async def test_health_reports_ok_and_speech_provider_reachable() -> None: async def probe() -> bool: return True body = await _request(_app(probe)) - assert body == {"ok": True, "gpu_reachable": True} + assert body == {"ok": True, "speech_provider_reachable": True} -async def test_health_reports_gpu_unreachable() -> None: +async def test_health_reports_speech_provider_unreachable() -> None: async def probe() -> bool: return False body = await _request(_app(probe)) - assert body == {"ok": True, "gpu_reachable": False} + assert body == {"ok": True, "speech_provider_reachable": False} async def test_health_swallows_probe_exception() -> None: @@ -51,11 +48,11 @@ async def probe() -> bool: raise RuntimeError("DNS down") body = await _request(_app(probe)) - assert body == {"ok": True, "gpu_reachable": False} + assert body == {"ok": True, "speech_provider_reachable": False} -async def test_default_gpu_probe_uses_app_config_env(monkeypatch) -> None: - """The default probe must use the same GPU env namespace as real clients.""" +async def test_default_provider_probe_uses_app_config_env(monkeypatch) -> None: + """The default probe must use the same provider URLs as real clients.""" opened: list[tuple[str, int]] = [] class _Writer: @@ -69,29 +66,26 @@ async def fake_open_connection(host: str, port: int): opened.append((host, port)) return object(), _Writer() - monkeypatch.setenv("GPU_HOST", "100.64.0.8") - monkeypatch.setenv("SENSEVOICE_WS_PORT", "18080") - monkeypatch.setenv("COSYVOICE_WS_PORT", "18081") - monkeypatch.delenv("VOCALIZE_GPU_HOST", raising=False) - monkeypatch.delenv("VOCALIZE_GPU_PORT", raising=False) + monkeypatch.setenv("VOCALIZE_STT_PROVIDER_URL", "http://100.64.0.8:18080") + monkeypatch.setenv("VOCALIZE_TTS_PROVIDER_URL", "http://100.64.0.8:18081") monkeypatch.setattr("asyncio.open_connection", fake_open_connection) - reachable = await make_default_gpu_probe()() + reachable = await make_default_speech_provider_probe()() assert reachable is True assert opened == [("100.64.0.8", 18080), ("100.64.0.8", 18081)] -async def test_default_gpu_probe_reports_false_without_gpu_host(monkeypatch) -> None: - monkeypatch.delenv("GPU_HOST", raising=False) - monkeypatch.delenv("VOCALIZE_GPU_HOST", raising=False) +async def test_default_provider_probe_reports_false_for_invalid_url(monkeypatch) -> None: + monkeypatch.setenv("VOCALIZE_STT_PROVIDER_URL", "not-a-url") + monkeypatch.setenv("VOCALIZE_TTS_PROVIDER_URL", "http://127.0.0.1:18081") - reachable = await make_default_gpu_probe()() + reachable = await make_default_speech_provider_probe()() assert reachable is False -async def test_default_gpu_probe_short_circuits_on_first_failed_port( +async def test_default_provider_probe_short_circuits_on_first_failed_port( monkeypatch, ) -> None: opened: list[tuple[str, int]] = [] @@ -100,12 +94,11 @@ async def fake_open_connection(host: str, port: int): opened.append((host, port)) raise OSError("first service down") - monkeypatch.setenv("GPU_HOST", "100.64.0.8") - monkeypatch.setenv("SENSEVOICE_WS_PORT", "18080") - monkeypatch.setenv("COSYVOICE_WS_PORT", "18081") + monkeypatch.setenv("VOCALIZE_STT_PROVIDER_URL", "http://100.64.0.8:18080") + monkeypatch.setenv("VOCALIZE_TTS_PROVIDER_URL", "http://100.64.0.8:18081") monkeypatch.setattr("asyncio.open_connection", fake_open_connection) - reachable = await make_default_gpu_probe()() + reachable = await make_default_speech_provider_probe()() assert reachable is False assert opened == [("100.64.0.8", 18080)] diff --git a/tests/test_server_ws_integration.py b/tests/test_server_ws_integration.py index 3bf675e..24b8755 100644 --- a/tests/test_server_ws_integration.py +++ b/tests/test_server_ws_integration.py @@ -2,7 +2,7 @@ Task 13 covers the framing layer in isolation (a fake orchestrator runner). Task 14 swaps the fake out for the real DialogueOrchestrator wiring. -Task 17 adds a real-GPU integration variant gated on env vars. +Provider API integration remains testable through the scripted pipeline fakes. """ from __future__ import annotations @@ -949,10 +949,6 @@ def _planner_tool_names(tools: Any) -> set[str]: return {getattr(tool, "name", "") for tool in (tools or [])} -@pytest.mark.skipif( - "REAL_GPU" in os.environ, - reason="REAL_GPU mode runs the variant in test_real_gpu_smoke", -) def test_runner_drives_orchestrator_through_one_text_turn( fake_voice_pipeline_factory, monkeypatch, @@ -1016,7 +1012,7 @@ def test_runner_waits_for_call_listening_before_merchant_execution() -> None: from fastapi import FastAPI from tests.conftest import make_scripted_llm - from tests.test_dialogue_orchestrator import _task_planner_script + from tests.test_dialogue_orchestrator import _task_planner_script, _text_chunks from tests.test_pipeline import FakeSTT, FakeTTS from vocalize.pipeline import VoicePipeline from vocalize.server.state import SessionRegistry @@ -1026,7 +1022,7 @@ def test_runner_waits_for_call_listening_before_merchant_execution() -> None: s = registry.create() registry.set_task(s.session_id, "帮我订海底捞") - llm = make_scripted_llm(_task_planner_script()) + llm = make_scripted_llm(_task_planner_script(), _text_chunks("")) def user_pipeline_factory(transport): return VoicePipeline( @@ -1098,7 +1094,7 @@ def test_runner_drops_pre_handover_audio_before_merchant_stt() -> None: from fastapi import FastAPI from tests.conftest import make_scripted_llm - from tests.test_dialogue_orchestrator import _task_planner_script + from tests.test_dialogue_orchestrator import _task_planner_script, _text_chunks from tests.test_pipeline import FakeSTT, FakeTTS from vocalize.pipeline import VoicePipeline from vocalize.server.state import SessionRegistry @@ -1121,7 +1117,7 @@ async def stream_transcribe(self, audio_chunks, **_kwargs): s = registry.create() registry.set_task(s.session_id, "帮我订海底捞") - llm = make_scripted_llm(_task_planner_script()) + llm = make_scripted_llm(_task_planner_script(), _text_chunks("")) merchant_stt = FirstBlockMerchantSTT() def user_pipeline_factory(transport): @@ -1188,7 +1184,7 @@ def test_runner_ignores_call_listening_before_readiness_passes() -> None: from fastapi import FastAPI from tests.conftest import make_scripted_llm - from tests.test_dialogue_orchestrator import _task_planner_script + from tests.test_dialogue_orchestrator import _task_planner_script, _text_chunks from tests.test_pipeline import FakeSTT, FakeTTS from vocalize.pipeline import VoicePipeline from vocalize.server.state import SessionRegistry @@ -1211,7 +1207,7 @@ async def stream_transcribe(self, audio_chunks, **_kwargs): s = registry.create() registry.set_task(s.session_id, "帮我订海底捞") - llm = make_scripted_llm(_task_planner_script()) + llm = make_scripted_llm(_task_planner_script(), _text_chunks("")) merchant_stt = FirstBlockMerchantSTT() def user_pipeline_factory(transport): @@ -1484,16 +1480,17 @@ def test_ws_integration_same_lang_no_translation() -> None: from tests.test_dialogue_orchestrator import _task_planner_script from vocalize.llm.base import FinishChunk, TextDelta - non_planner_calls = 0 + merchant_calls = 0 class _CountingLLM: async def stream_chat(self, messages=None, tools=None, **kwargs): - nonlocal non_planner_calls + nonlocal merchant_calls if "emit_task_schema" in _planner_tool_names(tools): for chunk in _task_planner_script(): yield chunk return - non_planner_calls += 1 + if "request_user_clarification" in _planner_tool_names(tools): + merchant_calls += 1 yield TextDelta(text="ok") yield FinishChunk(reason="stop") @@ -1567,12 +1564,16 @@ async def stream_chat(self, messages=None, tools=None, **kwargs): if frame.get("subtype") == "translation" ] assert translations == [] - assert non_planner_calls == 1 + assert merchant_calls == 1 def test_ws_integration_full_callback_chain(monkeypatch: pytest.MonkeyPatch) -> None: from tests.conftest import make_scripted_llm - from tests.test_dialogue_orchestrator import _task_planner_script, _tool_call_chunks + from tests.test_dialogue_orchestrator import ( + _task_planner_script, + _text_chunks, + _tool_call_chunks, + ) from vocalize.llm.base import FinishChunk, TextDelta, ToolCallDelta async def instant_timeout( @@ -1591,6 +1592,7 @@ async def instant_timeout( llm = make_scripted_llm( _task_planner_script(), + _text_chunks(""), _tool_call_chunks( 0, "call_clarify", @@ -1672,16 +1674,34 @@ async def instant_timeout( ) assumption_id = assumption["assumption"]["id"] - ws.send_text(json.dumps({"type": "hangup"})) - received.extend(_drain_mixed_until( - recv, - target=lambda item: ( + def is_post_call_review(item: dict[str, Any]) -> bool: + return ( item.get("kind") == "json" and item["frame"].get("type") == "phase_change" and item["frame"].get("current") == "post_call_review" - ), - timeout_s=2.0, - )) + ) + + import time as _t + + post_call_review_seen = any(is_post_call_review(item) for item in received) + deadline = _t.monotonic() + 0.5 + while not post_call_review_seen and _t.monotonic() < deadline: + msg = recv(timeout=0.05) + if msg is None: + continue + payload = _ws_message_payload(msg) + if payload is None: + continue + received.append(payload) + post_call_review_seen = is_post_call_review(payload) + + if not post_call_review_seen: + ws.send_text(json.dumps({"type": "hangup"})) + received.extend(_drain_mixed_until( + recv, + target=is_post_call_review, + timeout_s=2.0, + )) ws.send_text(json.dumps({ "type": "confirm_assumption", @@ -1690,20 +1710,59 @@ async def instant_timeout( "correction": "6", "note": None, })) + + def callback_correction_ready(item: dict[str, Any]) -> bool: + if item.get("kind") != "json": + return False + frame = item["frame"] + if frame.get("type") == "pending_callback_added": + callback = frame.get("callback") or {} + return ( + callback.get("assumption_id") == assumption_id + and callback.get("correction") == "6" + ) + if frame.get("type") != "state_update": + return False + callbacks = (frame.get("diff") or {}).get("pending_callbacks") or [] + return any( + callback.get("assumption_id") == assumption_id + and callback.get("correction") == "6" + for callback in callbacks + ) + received.extend(_drain_mixed_until( recv, - target=lambda item: ( - item.get("kind") == "json" - and item["frame"].get("type") == "pending_callback_added" - ), + target=callback_correction_ready, timeout_s=2.0, )) - callback_frame = next( - item["frame"] for item in received - if item.get("kind") == "json" - and item["frame"].get("type") == "pending_callback_added" - ) - callback_id = callback_frame["callback"]["id"] + + callback_id = None + for item in reversed(received): + if item.get("kind") != "json": + continue + frame = item["frame"] + if frame.get("type") == "pending_callback_added": + callback = frame["callback"] + if ( + callback.get("assumption_id") == assumption_id + and callback.get("correction") == "6" + ): + callback_id = callback["id"] + break + if frame.get("type") == "state_update": + callbacks = ( + (frame.get("diff") or {}).get("pending_callbacks") or [] + ) + for callback in callbacks: + if ( + callback.get("assumption_id") == assumption_id + and callback.get("correction") == "6" + ): + callback_id = callback["id"] + break + if callback_id is not None: + break + assert callback_id is not None ws.send_text(json.dumps({ "type": "trigger_callback", @@ -1772,11 +1831,12 @@ def test_ws_integration_merchant_ai_audio_suppressed_during_user_takeover() -> N import time as _t from tests.conftest import make_scripted_llm - from tests.test_dialogue_orchestrator import _task_planner_script + from tests.test_dialogue_orchestrator import _task_planner_script, _text_chunks from vocalize.llm.base import FinishChunk, TextDelta llm = make_scripted_llm( _task_planner_script(), + _text_chunks(""), [TextDelta(text="AI-QUEUED-DURING-TAKEOVER"), FinishChunk(reason="stop")], [TextDelta(text="AI-FRESH-AFTER-TAKEOVER"), FinishChunk(reason="stop")], ) @@ -2219,7 +2279,7 @@ def test_runner_spoken_preflight_ignores_ws_binary_audio_and_uses_text_input() - from fastapi import FastAPI from tests.conftest import make_scripted_llm - from tests.test_dialogue_orchestrator import _task_planner_script + from tests.test_dialogue_orchestrator import _task_planner_script, _text_chunks from tests.test_pipeline import FakeTTS from vocalize.pipeline import VoicePipeline from vocalize.server.state import SessionRegistry @@ -2253,7 +2313,7 @@ async def stream_transcribe(self, audio_chunks, **_kwargs): yield None user_stt = TransportDrivenSTT() - llm = make_scripted_llm(_task_planner_script()) + llm = make_scripted_llm(_task_planner_script(), _text_chunks("")) def user_pipeline_factory(transport): return VoicePipeline( @@ -2429,70 +2489,3 @@ def _pump() -> None: for msg in seen_texts ) assert seen_user_audio is True - - -# -- Task 17: Real-GPU smoke test ---------------------------------------------- - - -@pytest.mark.skipif( - "GPU_HOST" not in os.environ, - reason="real-GPU smoke is opt-in via GPU_HOST", -) -def test_real_gpu_smoke_through_ws() -> None: - """Connect WS, post a task, send a preflight text input, then observe at - least one ``transcript_update`` from the real SenseVoice + DeepSeek + - CosyVoice stack within an overall deadline. - - Without an explicit ``text_input``, the orchestrator can sit waiting on - user input forever — the receive loop would block on - ``ws.receive_json`` and the test session would hang. We send one text - line right after the WS opens to drive preflight forward, and we cap - the whole observation phase at ``DEADLINE_S`` so a stuck stack fails - the test instead of stalling CI. - - Run with: - GPU_HOST=100.x.y.z SENSEVOICE_WS_PORT=8000 COSYVOICE_WS_PORT=8001 pytest -k real_gpu_smoke - """ - import time as _t - - from vocalize.server import create_app - - # Real DeepSeek + task-planner + preflight can exceed 2 minutes even when - # all services are healthy. This smoke is opt-in and not part of normal CI, - # so prefer a realistic default over a flaky short gate. - DEADLINE_S = float(os.getenv("VOCALIZE_REAL_GPU_SMOKE_DEADLINE_S", "180")) - - app = create_app() - with TestClient(app) as tc: - resp = tc.post("/api/sessions") - assert resp.status_code == 200 - sid = resp.json()["session_id"] - - resp = tc.post( - f"/api/sessions/{sid}/task", - json={"task": "帮我订海底捞"}, - ) - assert resp.status_code == 200 - - with tc.websocket_connect(f"/ws/sessions/{sid}") as ws: - ws.send_text(json.dumps({ - "type": "text_input", - "text": "晚上7点4个人", - "lang_hint": "zh", - })) - - recv = _make_bounded_receiver(ws) - deadline = _t.monotonic() + DEADLINE_S - seen_transcript = False - while _t.monotonic() < deadline: - remaining = max(0.05, deadline - _t.monotonic()) - msg = recv(timeout=remaining) - if msg is None: - break - if msg.get("type") == "transcript_update": - seen_transcript = True - break - assert seen_transcript, ( - "no transcript_update within " - f"{DEADLINE_S}s — GPU stack may be unresponsive" - ) diff --git a/tests/test_stt_sensevoice.py b/tests/test_stt_sensevoice.py deleted file mode 100644 index ffbd8df..0000000 --- a/tests/test_stt_sensevoice.py +++ /dev/null @@ -1,480 +0,0 @@ -"""SenseVoiceClient 协议层测试。 - -策略:起一个真实的 ``websockets`` server,按 SenseVoice 协议手写期望的服务端 -行为(accept start、收 PCM、按指令推 partial/final/error),覆盖: - -- start 帧字段正确(language hint、可选 session_id) -- 二进制 PCM 帧透传不变 -- partial / final 解析为正确的 ``Transcript`` 字段,含 ``language`` -- 非 fatal error 帧不中断流 -- fatal error 帧抛 ``SenseVoiceError`` -- 调用方 cancel 时客户端发出 ``stop`` 并关闭 socket -""" -from __future__ import annotations - -import asyncio -import json -from collections.abc import AsyncIterator - -import pytest -import websockets -from websockets.asyncio.server import ServerConnection, serve - -from vocalize.stt.base import Transcript -from vocalize.stt.sensevoice import SenseVoiceClient, SenseVoiceError - - -# --------------------------------------------------------------------------- -# Test fake server -# --------------------------------------------------------------------------- -class FakeServer: - """脚本化的假 SenseVoice 服务端。 - - 每个 handler 用 self.script 决定收到什么再发什么;同时记录从客户端收到的事件 - 供断言。 - """ - - def __init__(self) -> None: - self.received_text: list[dict] = [] # 解析后的 JSON 控制帧 - self.received_audio: list[bytes] = [] # 原样二进制帧 - self.script: list[dict] | None = None # 收到第一个 PCM 后向客户端发的消息序列 - self.fatal_after_start: dict | None = None # 一收到 start 就发的 fatal - self.send_on_end_of_utterance: list[dict] | None = None - self._server = None - self.port: int = 0 - - async def start(self) -> None: - self._server = await serve(self._handler, "127.0.0.1", 0) - # websockets 15+: server.sockets 是绑定的 socket 列表 - self.port = self._server.sockets[0].getsockname()[1] - - async def stop(self) -> None: - if self._server is not None: - self._server.close() - await self._server.wait_closed() - - async def _handler(self, ws: ServerConnection) -> None: - sent_script = False - try: - async for msg in ws: - if isinstance(msg, str): - parsed = json.loads(msg) - self.received_text.append(parsed) - if parsed.get("event") == "start" and self.fatal_after_start: - await ws.send(json.dumps(self.fatal_after_start)) - await ws.close() - return - if parsed.get("event") == "end_of_utterance" and \ - self.send_on_end_of_utterance: - for item in self.send_on_end_of_utterance: - await ws.send(json.dumps(item)) - if parsed.get("event") == "stop": - await ws.close() - return - else: - self.received_audio.append(bytes(msg)) - if not sent_script and self.script: - sent_script = True - for item in self.script: - await ws.send(json.dumps(item)) - except websockets.exceptions.ConnectionClosed: - pass - - -@pytest.fixture -async def fake_server() -> AsyncIterator[FakeServer]: - srv = FakeServer() - await srv.start() - try: - yield srv - finally: - await srv.stop() - - -async def _audio_iter(chunks: list[bytes], delay: float = 0.0) -> AsyncIterator[bytes]: - for c in chunks: - if delay: - await asyncio.sleep(delay) - yield c - - -# --------------------------------------------------------------------------- -# Tests -# --------------------------------------------------------------------------- -async def test_sends_start_then_audio_then_stop(fake_server: FakeServer) -> None: - fake_server.script = [ - {"text": "你好", "is_final": True, "confidence": 0.9, - "start_time": 0.0, "end_time": 0.5, "utterance_id": 0, "language": "zh"}, - ] - client = SenseVoiceClient( - host="127.0.0.1", port=fake_server.port, - language_hint="zh", session_id="sess-1", - ) - - audio = _audio_iter([b"\x00\x01" * 480, b"\x02\x03" * 480]) - out = [t async for t in client.stream_transcribe(audio)] - - # control frames: start (first), end_of_utterance (after audio), stop - events = [m.get("event") for m in fake_server.received_text] - assert events[0] == "start" - assert "end_of_utterance" in events - assert events[-1] == "stop" - - start_msg = fake_server.received_text[0] - assert start_msg["language"] == "zh" - assert start_msg["session_id"] == "sess-1" - - assert fake_server.received_audio == [b"\x00\x01" * 480, b"\x02\x03" * 480] - assert len(out) == 1 - t = out[0] - assert isinstance(t, Transcript) - assert t.text == "你好" - assert t.is_final is True - assert t.language == "zh" - assert t.utterance_id == 0 - - -async def test_parses_partial_then_final_with_language( - fake_server: FakeServer, -) -> None: - fake_server.script = [ - {"text": "book a", "is_final": False, "confidence": 0.7, - "start_time": 0.0, "end_time": 0.4, "utterance_id": 0, "language": None}, - {"text": "book a table for four", "is_final": True, "confidence": 0.95, - "start_time": 0.0, "end_time": 1.2, "utterance_id": 0, "language": "en"}, - ] - client = SenseVoiceClient(host="127.0.0.1", port=fake_server.port) - - audio = _audio_iter([b"\x00" * 960]) - out = [t async for t in client.stream_transcribe(audio)] - - assert len(out) == 2 - assert out[0].is_final is False - assert out[0].language is None - assert out[1].is_final is True - assert out[1].language == "en" - assert out[1].text == "book a table for four" - assert out[0].utterance_id == out[1].utterance_id == 0 - - -async def test_non_fatal_error_does_not_break_stream( - fake_server: FakeServer, -) -> None: - fake_server.script = [ - {"error": "transient inference glitch", "fatal": False}, - {"text": "ok", "is_final": True, "confidence": 1.0, - "start_time": 0.0, "end_time": 0.3, "utterance_id": 0, "language": "en"}, - ] - client = SenseVoiceClient(host="127.0.0.1", port=fake_server.port) - out = [t async for t in client.stream_transcribe(_audio_iter([b"\x00" * 100]))] - assert len(out) == 1 - assert out[0].text == "ok" - - -async def test_fatal_error_raises(fake_server: FakeServer) -> None: - fake_server.fatal_after_start = {"error": "oom", "fatal": True} - client = SenseVoiceClient(host="127.0.0.1", port=fake_server.port) - - with pytest.raises(SenseVoiceError, match="oom"): - async for _ in client.stream_transcribe(_audio_iter([b"\x00" * 100])): - pass - - -async def test_connection_refused_raises() -> None: - # 端口 1 几乎肯定连不上 - client = SenseVoiceClient( - host="127.0.0.1", port=1, connect_timeout_s=1.0, open_timeout_s=1.0, - ) - with pytest.raises(SenseVoiceError): - async for _ in client.stream_transcribe(_audio_iter([b"\x00" * 10])): - pass - - -async def test_cancellation_sends_stop(fake_server: FakeServer) -> None: - """调用方 break / aclose() 时客户端应通知服务端 stop,不能泄漏 socket。""" - # 服务端不主动发任何东西,让客户端等 - fake_server.script = [] - client = SenseVoiceClient(host="127.0.0.1", port=fake_server.port) - - async def slow_audio() -> AsyncIterator[bytes]: - for _ in range(100): - await asyncio.sleep(0.05) - yield b"\x00" * 320 - - async def drain() -> None: - async for _ in client.stream_transcribe(slow_audio()): - pass - - task = asyncio.create_task(drain()) - # 等到第一个 PCM 到达服务端 - for _ in range(40): - if fake_server.received_audio: - break - await asyncio.sleep(0.05) - assert fake_server.received_audio, "server never received audio" - - task.cancel() - try: - await task - except (asyncio.CancelledError, Exception): - pass - - # 给 server handler 一点时间把最后的 frame 收完 - for _ in range(20): - events = [m.get("event") for m in fake_server.received_text] - if "stop" in events: - break - await asyncio.sleep(0.05) - events = [m.get("event") for m in fake_server.received_text] - assert "stop" in events, f"expected stop in {events}" - - -async def _drain_one(gen: AsyncIterator[Transcript]) -> Transcript | None: - async for t in gen: - return t - return None - - -# --------------------------------------------------------------------------- -# Audio-side failure handling -# --------------------------------------------------------------------------- -class _AudioCaptureError(RuntimeError): - """Stand-in for a real device read / file decode failure.""" - - -async def test_audio_iterator_failure_surfaces_to_caller( - fake_server: FakeServer, -) -> None: - """If the upstream audio iterator raises, the caller MUST see a failure. - - Regression for Codex P2: previously the ``finally`` block only awaited - ``sender_task`` when not done; an already-failed sender task had its - exception silently dropped, leaving the receive loop hung waiting for - server frames that would never come, and emitting a - ``Task exception was never retrieved`` warning. - """ - # Server stays silent on PCM so the receive loop has nothing to yield; - # only the audio-side failure should be the trigger. - fake_server.script = [] - client = SenseVoiceClient(host="127.0.0.1", port=fake_server.port) - - async def boom() -> AsyncIterator[bytes]: - # First chunk goes through fine, then the device blows up. - yield b"\x00" * 320 - await asyncio.sleep(0.05) - raise _AudioCaptureError("microphone read failed") - - # Should NOT hang — the audio failure must propagate as SenseVoiceError. - with pytest.raises(SenseVoiceError, match="audio sender failed"): - async with asyncio.timeout(3.0): - async for _ in client.stream_transcribe(boom()): - pass - - -async def test_audio_iterator_failure_no_orphan_task_warning( - fake_server: FakeServer, - caplog: pytest.LogCaptureFixture, -) -> None: - """No 'Task exception was never retrieved' should leak from cleanup.""" - fake_server.script = [] - client = SenseVoiceClient(host="127.0.0.1", port=fake_server.port) - - captured: list[str] = [] - loop = asyncio.get_running_loop() - prev_handler = loop.get_exception_handler() - - def _handler(_loop, context): # type: ignore[no-untyped-def] - captured.append(str(context.get("message", ""))) - - loop.set_exception_handler(_handler) - try: - async def boom() -> AsyncIterator[bytes]: - yield b"\x00" * 320 - await asyncio.sleep(0.05) - raise _AudioCaptureError("device gone") - - with pytest.raises(SenseVoiceError): - async with asyncio.timeout(3.0): - async for _ in client.stream_transcribe(boom()): - pass - - # Give the loop a tick to surface any orphan task warnings. - await asyncio.sleep(0.05) - finally: - loop.set_exception_handler(prev_handler) - - leaked = [m for m in captured if "never retrieved" in m] - assert not leaked, f"orphan task exception leaked: {leaked}" - - -async def test_fatal_server_error_not_masked_by_sender_failure( - fake_server: FakeServer, -) -> None: - """A fatal server error must surface even if the sender also fails. - - Edge case: server sends fatal AFTER receiving start, then closes the - connection. The sender then hits ``ConnectionClosed`` while writing PCM, - which is handled internally — but if the sender had a different failure - mode, the original fatal error must win, not the sender's. - """ - fake_server.fatal_after_start = {"error": "oom", "fatal": True} - client = SenseVoiceClient(host="127.0.0.1", port=fake_server.port) - - async def slow_audio() -> AsyncIterator[bytes]: - for _ in range(50): - await asyncio.sleep(0.02) - yield b"\x00" * 320 - - # Original fatal "oom" must be what the caller sees. - with pytest.raises(SenseVoiceError, match="oom"): - async with asyncio.timeout(3.0): - async for _ in client.stream_transcribe(slow_audio()): - pass - - -async def test_graceful_close_mid_stream_raises() -> None: - """Fix 1 回归:服务端 code=1000 graceful close 在 sender 还未完成时必须抛错。 - - ConnectionClosedOK 是 ConnectionClosed 的子类;修复前它被单独 swallow, - 导致 client 返回 0 条 transcript、没有异常(silent failure)。 - 修复后:collapsed except 统一用 sender_done gate,code=1000 也抛 SenseVoiceError。 - """ - from websockets.asyncio.server import serve as ws_serve - - async def handler(ws: ServerConnection) -> None: - try: - async for msg in ws: - if isinstance(msg, str): - parsed = json.loads(msg) - if parsed.get("event") == "start": - # 等一小段让 sender 送出至少一个音频帧再关 - await asyncio.sleep(0.05) - await ws.close(code=1000, reason="going away") - return - except websockets.exceptions.ConnectionClosed: - pass - - server = await ws_serve(handler, "127.0.0.1", 0) - try: - port = server.sockets[0].getsockname()[1] - client = SenseVoiceClient(host="127.0.0.1", port=port) - - async def slow_audio() -> AsyncIterator[bytes]: - for _ in range(50): - await asyncio.sleep(0.02) - yield b"\x00" * 320 - - with pytest.raises(SenseVoiceError, match="mid-stream"): - async with asyncio.timeout(3.0): - async for _ in client.stream_transcribe(slow_audio()): - pass - finally: - server.close() - await server.wait_closed() - - -# --------------------------------------------------------------------------- -# Phase 4 Plan 04-04 — client-side VAD EOS handshake -# --------------------------------------------------------------------------- -class _FakeVADTransport: - """Stand-in for MicrophoneTransport that exposes ``_on_eos`` and tracks - when the SenseVoiceClient registers a handler on it.""" - - def __init__(self) -> None: - self._on_eos = None - - -async def test_eos_handler_sends_end_of_utterance_json( - fake_server: FakeServer, -) -> None: - """SenseVoiceClient must register transport._on_eos at the start of - stream_transcribe and, when invoked, send the JSON - {"event": "end_of_utterance"} over the WS — preempting the server-side - fsmn-vad fallback (Plan 04-04 Task 3).""" - # Server replies with a final on receiving end_of_utterance. - fake_server.send_on_end_of_utterance = [ - {"text": "hi", "is_final": True, "confidence": 0.9, - "start_time": 0.0, "end_time": 0.4, "utterance_id": 0, "language": "en"}, - ] - client = SenseVoiceClient(host="127.0.0.1", port=fake_server.port) - transport = _FakeVADTransport() - - async def slow_audio() -> AsyncIterator[bytes]: - # Keep the WS open while we drive an EOS via the registered handler. - for i in range(20): - await asyncio.sleep(0.02) - yield b"\x00" * 320 - # After we've sent a couple of audio frames, fire the VAD EOS - # callback that the client should have registered on us. - if i == 2 and transport._on_eos is not None: - await transport._on_eos() - - transcripts = [] - async for t in client.stream_transcribe(slow_audio(), transport=transport): - transcripts.append(t) - # Once we got the EOS-triggered final, break to wind down. - if t.is_final: - break - - # Server must have received an end_of_utterance event from the client. - events = [m.get("event") for m in fake_server.received_text] - assert events.count("end_of_utterance") >= 1, ( - f"expected at least one client-sent end_of_utterance, got {events}" - ) - assert transcripts and transcripts[-1].text == "hi" - # last_eos_wall_clock stamped on EOS send. - assert client.last_eos_wall_clock is not None - - -async def test_eos_handler_no_transport_no_registration( - fake_server: FakeServer, -) -> None: - """If transport is None / lacks ``_on_eos``, stream_transcribe must not - crash and must not register anything (legacy path).""" - fake_server.script = [ - {"text": "ok", "is_final": True, "confidence": 0.9, - "start_time": 0.0, "end_time": 0.4, "utterance_id": 0, "language": "en"}, - ] - client = SenseVoiceClient(host="127.0.0.1", port=fake_server.port) - - audio = _audio_iter([b"\x00" * 320, b"\x00" * 320]) - out = [t async for t in client.stream_transcribe(audio)] # no transport kwarg - assert len(out) == 1 - assert client.last_eos_wall_clock is None - - -async def test_server_crash_mid_send_is_not_silently_swallowed() -> None: - """B2 回归:服务端在客户端还在推 PCM 时强制关连接,``_send_audio`` 不能把 - ``ConnectionClosed`` 当成 clean close(之前会无条件 set sender_done, - 导致接收侧也把 close 判为 OK,pipeline 拿到截断转写却没有错误)。 - """ - from websockets.asyncio.server import serve - - async def handler(ws): # type: ignore[no-untyped-def] - try: - async for msg in ws: - if isinstance(msg, str): - parsed = json.loads(msg) - if parsed.get("event") == "start": - await ws.close(code=1011, reason="boom") - return - except websockets.exceptions.ConnectionClosed: - pass - - server = await serve(handler, "127.0.0.1", 0) - try: - port = server.sockets[0].getsockname()[1] - client = SenseVoiceClient(host="127.0.0.1", port=port) - - async def slow_audio() -> AsyncIterator[bytes]: - for _ in range(50): - await asyncio.sleep(0.02) - yield b"\x00" * 320 - - with pytest.raises(SenseVoiceError): - async with asyncio.timeout(3.0): - async for _ in client.stream_transcribe(slow_audio()): - pass - finally: - server.close() - await server.wait_closed() diff --git a/tests/test_tts_cosyvoice.py b/tests/test_tts_cosyvoice.py deleted file mode 100644 index c526613..0000000 --- a/tests/test_tts_cosyvoice.py +++ /dev/null @@ -1,403 +0,0 @@ -"""CosyVoiceClient 协议层测试。 - -策略:起一个真实的 ``websockets`` server,按 CosyVoice 协议手写期望的服务端 -行为(accept start、收 text 帧、按指令推 audio_start / 二进制 PCM / audio_end / -error),覆盖: - -- start 帧字段正确(language、speed、可选 prompt_*、可选 session_id) -- 多个 text 帧 + 最后一个 is_final_segment 完整 forward -- 二进制 PCM 帧按顺序透传 -- audio_start.sample_rate 覆盖 client.output_sample_rate -- 非 fatal error 不中断流;fatal error → CosyVoiceError -- caller break / aclose() → server 收到 stop -- health_check 在健康/故障时分别返回 True/False -""" -from __future__ import annotations - -import asyncio -import contextlib -import json -from collections.abc import AsyncIterator -from typing import Any - -import pytest -import websockets -from websockets.asyncio.server import ServerConnection, serve - -from vocalize.tts.base import TextChunk -from vocalize.tts.cosyvoice import CosyVoiceClient, CosyVoiceError - - -class FakeServer: - """脚本化假 CosyVoice 服务端。 - - 收到 start → 立即发 audio_start(如果脚本指定);之后每收一个 text 帧, - 按 ``per_text_script`` 的策略推音频/控制帧;收到 stop 关闭。 - """ - - def __init__(self) -> None: - self.received_text: list[dict] = [] - self.audio_start: dict | None = None - # 收到第 i 个 text 帧后要发的 list[bytes | dict](dict 是 JSON 控制帧) - self.per_text_script: list[list] = [] - self.fatal_after_start: dict | None = None - self._server: Any = None - self.port: int = 0 - - async def start(self) -> None: - self._server = await serve(self._handler, "127.0.0.1", 0) - self.port = self._server.sockets[0].getsockname()[1] - - async def stop(self) -> None: - if self._server is not None: - self._server.close() - await self._server.wait_closed() - - async def _handler(self, ws: ServerConnection) -> None: - text_idx = 0 - try: - async for msg in ws: - if not isinstance(msg, str): - continue - parsed = json.loads(msg) - self.received_text.append(parsed) - event = parsed.get("event") - if event == "start": - if self.fatal_after_start: - await ws.send(json.dumps(self.fatal_after_start)) - await ws.close() - return - if self.audio_start is not None: - await ws.send(json.dumps(self.audio_start)) - elif event == "text": - if text_idx < len(self.per_text_script): - for item in self.per_text_script[text_idx]: - if isinstance(item, (bytes, bytearray)): - await ws.send(bytes(item)) - else: - await ws.send(json.dumps(item)) - text_idx += 1 - elif event == "stop": - await ws.close() - return - except websockets.exceptions.ConnectionClosed: - pass - - -@pytest.fixture -async def fake_server() -> AsyncIterator[FakeServer]: - srv = FakeServer() - await srv.start() - try: - yield srv - finally: - await srv.stop() - - -async def _text_iter(chunks: list[TextChunk]) -> AsyncIterator[TextChunk]: - for c in chunks: - yield c - - -# --------------------------------------------------------------------------- -# Tests -# --------------------------------------------------------------------------- -async def test_start_frame_fields(fake_server: FakeServer) -> None: - fake_server.audio_start = { - "event": "audio_start", "sample_rate": 24000, "encoding": "pcm_s16le", - "channels": 1, "utterance_id": 0, "mode": "zero_shot", - } - fake_server.per_text_script = [ - [b"\x01\x02" * 10, {"event": "audio_end", "utterance_id": 0}], - ] - client = CosyVoiceClient( - host="127.0.0.1", port=fake_server.port, - default_language="en", speed=1.2, - prompt_wav="/tmp/x.wav", prompt_text="hi", - session_id="sess-99", - ) - out = [b async for b in client.stream_synthesize(_text_iter([ - TextChunk(text="hello", language="en", is_final_segment=True), - ]))] - - assert out == [b"\x01\x02" * 10] - start = fake_server.received_text[0] - assert start["event"] == "start" - assert start["language"] == "en" - assert start["speed"] == 1.2 - assert start["prompt_wav"] == "/tmp/x.wav" - assert start["prompt_text"] == "hi" - assert start["session_id"] == "sess-99" - - -async def test_multiple_text_frames_with_final_flush( - fake_server: FakeServer, -) -> None: - fake_server.audio_start = { - "event": "audio_start", "sample_rate": 24000, - "encoding": "pcm_s16le", "channels": 1, "utterance_id": 0, - "mode": "zero_shot", - } - fake_server.per_text_script = [ - [b"\xaa" * 4], - [b"\xbb" * 4, {"event": "audio_end", "utterance_id": 0}], - ] - client = CosyVoiceClient(host="127.0.0.1", port=fake_server.port) - out = [b async for b in client.stream_synthesize(_text_iter([ - TextChunk(text="hi.", language="en", is_final_segment=False), - TextChunk(text="bye.", language="en", is_final_segment=True), - ]))] - assert out == [b"\xaa" * 4, b"\xbb" * 4] - text_events = [m for m in fake_server.received_text if m.get("event") == "text"] - assert len(text_events) == 2 - assert text_events[0]["is_final_segment"] is False - assert text_events[1]["is_final_segment"] is True - # stop 应在最后 - assert fake_server.received_text[-1]["event"] == "stop" - - -async def test_pcm_bytes_pass_through_in_order(fake_server: FakeServer) -> None: - blocks = [bytes([i]) * 8 for i in range(5)] - fake_server.audio_start = { - "event": "audio_start", "sample_rate": 24000, - "encoding": "pcm_s16le", "channels": 1, "utterance_id": 0, - "mode": "zero_shot", - } - fake_server.per_text_script = [list(blocks)] - client = CosyVoiceClient(host="127.0.0.1", port=fake_server.port) - out = [b async for b in client.stream_synthesize(_text_iter([ - TextChunk(text="x", language="zh", is_final_segment=True), - ]))] - assert out == blocks - - -async def test_audio_start_mismatch_logs_warning_no_mutation( - fake_server: FakeServer, caplog: pytest.LogCaptureFixture, -) -> None: - """I3 回归:服务端 audio_start.sample_rate 与客户端配置不一致时,只 log - warning,不 mutate ``output_sample_rate``。Mutation 会让已经按客户端 SR 打开 - 的下游 PortAudio output stream 出现 pitch-shift bug。 - """ - fake_server.audio_start = { - "event": "audio_start", "sample_rate": 22050, - "encoding": "pcm_s16le", "channels": 1, "utterance_id": 0, - "mode": "zero_shot", - } - fake_server.per_text_script = [[b"\x00\x00"]] - client = CosyVoiceClient( - host="127.0.0.1", port=fake_server.port, output_sample_rate=24000, - ) - with caplog.at_level("WARNING", logger="vocalize.tts.cosyvoice"): - async for _ in client.stream_synthesize(_text_iter([ - TextChunk(text="x", language="zh", is_final_segment=True), - ])): - pass - assert client.output_sample_rate == 24000 # client config wins - assert any("sample_rate" in r.message for r in caplog.records) - - -async def test_non_fatal_error_does_not_break_stream( - fake_server: FakeServer, -) -> None: - fake_server.audio_start = { - "event": "audio_start", "sample_rate": 24000, - "encoding": "pcm_s16le", "channels": 1, "utterance_id": 0, - "mode": "zero_shot", - } - fake_server.per_text_script = [ - [{"error": "transient glitch", "fatal": False}, b"\x11" * 4], - ] - client = CosyVoiceClient(host="127.0.0.1", port=fake_server.port) - out = [b async for b in client.stream_synthesize(_text_iter([ - TextChunk(text="x", language="zh", is_final_segment=True), - ]))] - assert out == [b"\x11" * 4] - - -async def test_fatal_error_raises(fake_server: FakeServer) -> None: - fake_server.fatal_after_start = {"error": "oom", "fatal": True} - client = CosyVoiceClient(host="127.0.0.1", port=fake_server.port) - with pytest.raises(CosyVoiceError, match="oom"): - async for _ in client.stream_synthesize(_text_iter([ - TextChunk(text="x", language="zh", is_final_segment=True), - ])): - pass - - -async def test_connection_refused_returns_health_false() -> None: - client = CosyVoiceClient( - host="127.0.0.1", port=1, connect_timeout_s=1.0, open_timeout_s=1.0, - ) - assert await client.health_check() is False - - -async def test_health_check_ok(fake_server: FakeServer) -> None: - client = CosyVoiceClient(host="127.0.0.1", port=fake_server.port) - assert await client.health_check() is True - - -async def test_break_triggers_stop(fake_server: FakeServer) -> None: - """caller 用 ``break`` 提前结束 → 客户端 finally 发 stop。 - - 用 ``contextlib.aclosing`` 保证确定性触发(async-for 自动 GC 时机不可靠)。 - """ - # 服务端发完 audio_start 后无限拖时间,让 caller 有机会 break - fake_server.audio_start = { - "event": "audio_start", "sample_rate": 24000, - "encoding": "pcm_s16le", "channels": 1, "utterance_id": 0, - "mode": "zero_shot", - } - fake_server.per_text_script = [[b"\x01" * 4, b"\x02" * 4, b"\x03" * 4]] - - client = CosyVoiceClient(host="127.0.0.1", port=fake_server.port) - async with contextlib.aclosing(client.stream_synthesize(_text_iter([ # type: ignore[type-var] - TextChunk(text="hi", language="zh", is_final_segment=True), - ]))) as it: - async for _audio in it: - break - - # 给 server 一点时间收完 - for _ in range(20): - events = [m.get("event") for m in fake_server.received_text] - if "stop" in events: - break - await asyncio.sleep(0.05) - events = [m.get("event") for m in fake_server.received_text] - assert "stop" in events, f"expected stop in {events}" - - -async def test_empty_final_segment_chunk_is_forwarded( - fake_server: FakeServer, -) -> None: - """B4 回归:``TextChunk(text="", is_final_segment=True)`` 是 pipeline 触发服务端 - flush 的哨兵;``_send_text`` 不能因 ``not chunk.text`` 把它过滤掉,否则尾音被吞。 - """ - fake_server.audio_start = { - "event": "audio_start", "sample_rate": 24000, - "encoding": "pcm_s16le", "channels": 1, "utterance_id": 0, - "mode": "zero_shot", - } - fake_server.per_text_script = [ - [b"\x33" * 4], - [b"\x44" * 4, {"event": "audio_end", "utterance_id": 0}], - ] - client = CosyVoiceClient(host="127.0.0.1", port=fake_server.port) - out = [b async for b in client.stream_synthesize(_text_iter([ - TextChunk(text="hi.", language="en", is_final_segment=False), - TextChunk(text="", language="en", is_final_segment=True), - ]))] - assert out == [b"\x33" * 4, b"\x44" * 4] - text_events = [m for m in fake_server.received_text if m.get("event") == "text"] - assert len(text_events) == 2 - # 第二个 text 帧必须送达且带 is_final_segment=True - assert text_events[1]["text"] == "" - assert text_events[1]["is_final_segment"] is True - - -async def test_server_crash_mid_send_is_not_silently_swallowed() -> None: - """B2 回归:服务端在客户端还在推 text 时强制关连接,``_send_text`` 不能把 - ``ConnectionClosed`` 当成 clean close(之前会无条件 set sender_done, - 导致接收侧把 close 也判为 OK,pipeline 拿到截断音频却没有错误)。 - """ - crashing_server: Any = None - - async def handler(ws: ServerConnection) -> None: - # 收到 start 后立即关 socket(不发任何 audio_end / audio_start) - try: - async for msg in ws: - if isinstance(msg, str): - parsed = json.loads(msg) - if parsed.get("event") == "start": - await ws.close(code=1011, reason="boom") - return - except websockets.exceptions.ConnectionClosed: - pass - - crashing_server = await serve(handler, "127.0.0.1", 0) - try: - port = crashing_server.sockets[0].getsockname()[1] - client = CosyVoiceClient(host="127.0.0.1", port=port) - - async def slow_text() -> AsyncIterator[TextChunk]: - for i in range(20): - await asyncio.sleep(0.02) - yield TextChunk(text=f"chunk{i}.", language="en", - is_final_segment=False) - - with pytest.raises(CosyVoiceError): - async with asyncio.timeout(3.0): - async for _ in client.stream_synthesize(slow_text()): - pass - finally: - crashing_server.close() - await crashing_server.wait_closed() - - -async def test_graceful_close_mid_stream_raises() -> None: - """Fix 1 回归:服务端 code=1000 graceful close 在 sender 还未完成时必须抛错。 - - ConnectionClosedOK 是 ConnectionClosed 的子类;修复前它被单独 swallow, - 导致 client 返回 0 字节、没有异常(silent failure)。 - 修复后:collapsed except 统一用 sender_done gate,code=1000 也抛 CosyVoiceError。 - """ - async def handler(ws: ServerConnection) -> None: - try: - async for msg in ws: - if not isinstance(msg, str): - continue - parsed = json.loads(msg) - if parsed.get("event") == "start": - # 等一小段让 sender 送出至少一个 text 帧再关 - await asyncio.sleep(0.05) - await ws.close(code=1000, reason="going away") - return - except websockets.exceptions.ConnectionClosed: - pass - - server = await serve(handler, "127.0.0.1", 0) - try: - port = server.sockets[0].getsockname()[1] - client = CosyVoiceClient(host="127.0.0.1", port=port) - - async def slow_text() -> AsyncIterator[TextChunk]: - for i in range(20): - await asyncio.sleep(0.02) - yield TextChunk(text=f"chunk{i}.", language="zh", - is_final_segment=False) - - with pytest.raises(CosyVoiceError, match="mid-stream"): - async with asyncio.timeout(3.0): - async for _ in client.stream_synthesize(slow_text()): - pass - finally: - server.close() - await server.wait_closed() - - -def test_post_init_rejects_invalid_port() -> None: - """I1 回归:``CosyVoiceClient.__post_init__`` 校验 port 范围。""" - with pytest.raises(CosyVoiceError, match="port"): - CosyVoiceClient(host="x", port=0) - - -def test_post_init_rejects_prompt_wav_without_text() -> None: - """I1 回归:zero-shot 克隆需要 wav + 对应文本同时给出。""" - with pytest.raises(CosyVoiceError, match="prompt_wav"): - CosyVoiceClient(host="x", prompt_wav="/tmp/x.wav", prompt_text=None) - - -async def test_from_app_config_missing_gpu_host() -> None: - from vocalize.config import Config - cfg = Config(gpu_host="") - with pytest.raises(CosyVoiceError, match="GPU_HOST"): - CosyVoiceClient.from_app_config(cfg) - - -async def test_from_app_config_ok() -> None: - from vocalize.config import Config - cfg = Config(gpu_host="example.test", cosyvoice_ws_port=9000, - default_language="en") - client = CosyVoiceClient.from_app_config(cfg) - assert client.host == "example.test" - assert client.port == 9000 - assert client.default_language == "en" diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 0000000..825e6ca --- /dev/null +++ b/tools/__init__.py @@ -0,0 +1 @@ +"""Repository-local maintenance tools.""" diff --git a/tools/ci/__init__.py b/tools/ci/__init__.py new file mode 100644 index 0000000..d4e34f6 --- /dev/null +++ b/tools/ci/__init__.py @@ -0,0 +1 @@ +"""CI helpers for VocalizeAI release gates.""" diff --git a/tools/ci/public_tree_audit.py b/tools/ci/public_tree_audit.py new file mode 100644 index 0000000..cabc292 --- /dev/null +++ b/tools/ci/public_tree_audit.py @@ -0,0 +1,221 @@ +"""Audit the public VocalizeAI tree candidate. + +The private development tree can contain planning files before the public reset. +CI should audit the exported public candidate file list, not blindly assume the +current private checkout is already publishable. +""" +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + + +FORBIDDEN_PATH_RULES = ( + ".planning/", + "AGENTS.md", + "CLAUDE.md", + ".env", + ".env.local", + ".env.production", + "infra/gpu-services/", + "src/vocalize/stt/sensevoice.py", + "src/vocalize/tts/cosyvoice.py", + "docs/release/24h-stability-evidence.md", + "docs/release/24h-stability-evidence-runs/", +) + +CONTENT_BLOCKERS = ( + (re.compile(r"\bGPU_HOST\b"), "old GPU default deployment variable"), + (re.compile(r"\bSenseVoice\b"), "old model-specific STT reference"), + (re.compile(r"\bCosyVoice\b"), "old model-specific TTS reference"), + (re.compile(r"\binfra/gpu-services\b"), "old GPU services path"), + (re.compile(r"\bv1\.0\.0\b"), "old public release reference"), + (re.compile(r"\b24h-stability\b"), "old release evidence reference"), + (re.compile(r"\bPi orchestrator\b|\bRaspberry Pi\b"), "old Pi deployment reference"), + (re.compile(r"\bTailscale\b"), "old tunnel deployment reference"), +) + +SECRET_PATTERNS = ( + ( + re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b"), + "possible OpenAI-compatible API key", + ), + ( + re.compile( + r"(?i)\b(api[_-]?key|secret|token|password)\s*[:=]\s*" + r"['\"]?(?!your_|example|placeholder|changeme|test|dummy|none|\$\{)" + r"[A-Za-z0-9_/+=-]{12,}" + ), + "possible hard-coded secret", + ), +) + +TEXT_SUFFIXES = { + ".bash", + ".css", + ".env", + ".example", + ".html", + ".js", + ".json", + ".md", + ".mjs", + ".py", + ".sh", + ".swift", + ".toml", + ".ts", + ".tsx", + ".txt", + ".yaml", + ".yml", +} + +BINARY_OR_LOCK_SUFFIXES = {".DS_Store", ".lock", ".png", ".wav"} + + +@dataclass(frozen=True) +class Finding: + path: str + message: str + line: int | None = None + + def format(self) -> str: + location = self.path if self.line is None else f"{self.path}:{self.line}" + return f"{location}: {self.message}" + + +def _normalize(path: str) -> str: + normalized = path.strip().replace("\\", "/") + if normalized.startswith("./"): + return normalized[2:] + return normalized + + +def _matches_path_rule(path: str, rule: str) -> bool: + if rule.endswith("/"): + return path == rule[:-1] or path.startswith(rule) + return path == rule + + +def _read_file_list(path: Path) -> list[str]: + files = [] + for line in path.read_text().splitlines(): + normalized = _normalize(line) + if normalized: + files.append(normalized) + return sorted(dict.fromkeys(files)) + + +def _git_tracked_files(root: Path) -> list[str]: + result = subprocess.run( + ["git", "ls-files", "-z", "--full-name"], + cwd=root, + check=True, + stdout=subprocess.PIPE, + ) + return sorted( + p.decode() + for p in result.stdout.split(b"\x00") + if p and not p.decode().startswith(".git/") + ) + + +def _walk_files(root: Path) -> list[str]: + out: list[str] = [] + for path in root.rglob("*"): + if path.is_dir(): + continue + rel = path.relative_to(root).as_posix() + if rel.startswith(".git/"): + continue + out.append(rel) + return sorted(out) + + +def _is_text_candidate(path: str) -> bool: + suffix = Path(path).suffix + if suffix in BINARY_OR_LOCK_SUFFIXES: + return False + if Path(path).name == ".env.example": + return True + return suffix in TEXT_SUFFIXES + + +def _audit_paths(paths: list[str]) -> list[Finding]: + findings: list[Finding] = [] + for path in paths: + for rule in FORBIDDEN_PATH_RULES: + if _matches_path_rule(path, rule): + findings.append(Finding(path, f"forbidden public path: {rule}")) + return findings + + +def _audit_contents(root: Path, paths: list[str]) -> list[Finding]: + findings: list[Finding] = [] + for path in paths: + if not _is_text_candidate(path): + continue + full_path = root / path + if not full_path.exists() or full_path.is_dir(): + continue + try: + lines = full_path.read_text(errors="replace").splitlines() + except OSError as exc: + findings.append(Finding(path, f"could not read file: {exc}")) + continue + for number, line in enumerate(lines, 1): + if not path.startswith("tests/"): + for pattern, message in CONTENT_BLOCKERS: + if pattern.search(line): + findings.append(Finding(path, message, number)) + for pattern, message in SECRET_PATTERNS: + if pattern.search(line): + findings.append(Finding(path, message, number)) + return findings + + +def audit(root: Path, paths: list[str]) -> list[Finding]: + normalized = sorted(dict.fromkeys(_normalize(path) for path in paths if _normalize(path))) + return [*_audit_paths(normalized), *_audit_contents(root, normalized)] + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", default=".", help="repository or export root") + parser.add_argument( + "--file-list", + help="newline-delimited public candidate files, relative to --root", + ) + parser.add_argument( + "--tracked", + action="store_true", + help="audit git tracked files under --root", + ) + args = parser.parse_args(argv) + + root = Path(args.root).resolve() + if args.file_list: + paths = _read_file_list(Path(args.file_list)) + elif args.tracked: + paths = _git_tracked_files(root) + else: + paths = _walk_files(root) + + findings = audit(root, paths) + if findings: + print("Public tree audit failed:", file=sys.stderr) + for finding in findings: + print(f"- {finding.format()}", file=sys.stderr) + return 1 + + print(f"Public tree audit passed: {len(paths)} files checked.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/release/__init__.py b/tools/release/__init__.py new file mode 100644 index 0000000..f604abb --- /dev/null +++ b/tools/release/__init__.py @@ -0,0 +1 @@ +"""Release artifact helpers.""" diff --git a/tools/release/artifacts.py b/tools/release/artifacts.py new file mode 100644 index 0000000..483f4c2 --- /dev/null +++ b/tools/release/artifacts.py @@ -0,0 +1,214 @@ +"""Release artifact manifest and checksum utilities.""" +from __future__ import annotations + +import argparse +import hashlib +import json +import platform +import sys +import tomllib +from datetime import UTC, datetime +from pathlib import Path +from typing import Callable, Iterable, cast + + +LAYOUT_VERSION = "v0.1-macos-onefolder" + + +def read_project_version(pyproject_path: Path) -> str: + """Read the project version from ``pyproject.toml``.""" + data = tomllib.loads(pyproject_path.read_text(encoding="utf-8")) + version = data.get("project", {}).get("version") + if not isinstance(version, str) or not version: + raise ValueError(f"missing [project].version in {pyproject_path}") + return version + + +def sha256_file(path: Path) -> str: + """Return the hex SHA-256 digest for a file.""" + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def write_sha256sums(assets: Iterable[Path], output: Path) -> list[str]: + """Write a GitHub Release-compatible ``SHA256SUMS`` file.""" + lines: list[str] = [] + for asset in assets: + asset = asset.resolve() + if not asset.is_file(): + raise FileNotFoundError(asset) + lines.append(f"{sha256_file(asset)} {asset.name}") + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text("\n".join(lines) + "\n", encoding="utf-8") + return lines + + +def verify_sha256sums( + checksum_file: Path, + *, + base_dir: Path | None = None, + artifact_names: Iterable[str] | None = None, +) -> list[str]: + """Verify entries in ``SHA256SUMS`` and return verified artifact names.""" + if base_dir is None: + base_dir = checksum_file.parent + wanted = set(artifact_names or []) + verified: list[str] = [] + + for line_number, raw_line in enumerate( + checksum_file.read_text(encoding="utf-8").splitlines(), + start=1, + ): + line = raw_line.strip() + if not line: + continue + parts = line.split() + if len(parts) != 2: + raise ValueError(f"invalid checksum line {line_number}: {raw_line!r}") + expected, filename = parts + if wanted and filename not in wanted: + continue + path = base_dir / filename + if not path.is_file(): + raise FileNotFoundError(path) + actual = sha256_file(path) + if actual != expected: + raise ValueError( + f"checksum mismatch for {filename}: expected {expected}, got {actual}" + ) + verified.append(filename) + + if wanted and wanted != set(verified): + missing = ", ".join(sorted(wanted - set(verified))) + raise ValueError(f"missing checksum entries for: {missing}") + return verified + + +def write_release_manifest( + output: Path, + *, + version: str, + artifact_name: str, + arch: str, + signing_mode: str, + entrypoint: str, + backend_executable: str, + frontend_dist: str, + speech_provider: str, +) -> dict[str, object]: + """Write the versioned release artifact manifest.""" + manifest: dict[str, object] = { + "schema_version": 1, + "layout_version": LAYOUT_VERSION, + "app_version": version, + "artifact_name": artifact_name, + "created_at": datetime.now(UTC).replace(microsecond=0).isoformat(), + "platform": { + "os": "macos", + "arch": arch, + "builder": platform.platform(), + }, + "entrypoints": { + "cli": entrypoint, + "backend": backend_executable, + "speech_provider": speech_provider, + }, + "resources": { + "frontend_dist": frontend_dist, + "config_template": "config/.env.example", + }, + "signing": { + "mode": signing_mode, + "developer_id_required_for_public_release": True, + "notarization_required_for_public_release": True, + }, + "preserved_user_state": [ + "config/", + "logs/", + "cache/", + ], + } + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n") + return manifest + + +def _version_command(args: argparse.Namespace) -> int: + print(read_project_version(args.pyproject)) + return 0 + + +def _manifest_command(args: argparse.Namespace) -> int: + write_release_manifest( + args.output, + version=args.version, + artifact_name=args.artifact_name, + arch=args.arch, + signing_mode=args.signing_mode, + entrypoint=args.entrypoint, + backend_executable=args.backend_executable, + frontend_dist=args.frontend_dist, + speech_provider=args.speech_provider, + ) + return 0 + + +def _sha256_command(args: argparse.Namespace) -> int: + write_sha256sums(args.assets, args.output) + return 0 + + +def _verify_command(args: argparse.Namespace) -> int: + verify_sha256sums( + args.checksums, + base_dir=args.base_dir, + artifact_names=args.artifact_name, + ) + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="python -m tools.release.artifacts") + subcommands = parser.add_subparsers(dest="command", required=True) + + version_parser = subcommands.add_parser("version") + version_parser.add_argument("--pyproject", type=Path, required=True) + version_parser.set_defaults(func=_version_command) + + manifest_parser = subcommands.add_parser("manifest") + manifest_parser.add_argument("--output", type=Path, required=True) + manifest_parser.add_argument("--version", required=True) + manifest_parser.add_argument("--artifact-name", required=True) + manifest_parser.add_argument("--arch", required=True) + manifest_parser.add_argument("--signing-mode", required=True) + manifest_parser.add_argument("--entrypoint", required=True) + manifest_parser.add_argument("--backend-executable", required=True) + manifest_parser.add_argument("--frontend-dist", required=True) + manifest_parser.add_argument("--speech-provider", required=True) + manifest_parser.set_defaults(func=_manifest_command) + + sha_parser = subcommands.add_parser("sha256") + sha_parser.add_argument("--output", type=Path, required=True) + sha_parser.add_argument("assets", type=Path, nargs="+") + sha_parser.set_defaults(func=_sha256_command) + + verify_parser = subcommands.add_parser("verify") + verify_parser.add_argument("--checksums", type=Path, required=True) + verify_parser.add_argument("--base-dir", type=Path) + verify_parser.add_argument("--artifact-name", action="append") + verify_parser.set_defaults(func=_verify_command) + + args = parser.parse_args(argv) + try: + command = cast(Callable[[argparse.Namespace], int], args.func) + return command(args) + except Exception as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/uv.lock b/uv.lock index 0310d8a..a9d0ee6 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,9 @@ # This file was autogenerated by uv via the following command: # uv pip compile pyproject.toml --extra dev -o uv.lock +altgraph==0.17.5 + # via + # macholib + # pyinstaller annotated-doc==0.0.4 # via fastapi annotated-types==0.7.0 @@ -46,6 +50,8 @@ jiter==0.14.0 # via openai librt==0.11.0 # via mypy +macholib==1.16.4 + # via pyinstaller mypy==2.1.0 # via vocalize-ai (pyproject.toml) mypy-extensions==1.1.0 @@ -53,7 +59,10 @@ mypy-extensions==1.1.0 openai==2.37.0 # via vocalize-ai (pyproject.toml) packaging==26.2 - # via pytest + # via + # pyinstaller + # pyinstaller-hooks-contrib + # pytest pathspec==1.1.1 # via mypy pluggy==1.6.0 @@ -75,6 +84,10 @@ pydantic-core==2.46.4 # via pydantic pygments==2.20.0 # via pytest +pyinstaller==6.20.0 + # via vocalize-ai (pyproject.toml) +pyinstaller-hooks-contrib==2026.5 + # via pyinstaller pytest==9.0.3 # via # vocalize-ai (pyproject.toml) @@ -91,6 +104,10 @@ pyyaml==6.0.3 # uvicorn ruff==0.15.13 # via vocalize-ai (pyproject.toml) +setuptools==82.0.1 + # via + # pyinstaller + # pyinstaller-hooks-contrib sniffio==1.3.1 # via openai sounddevice==0.5.5