diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 011a124e5..20b74bb96 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -36,15 +36,7 @@ jobs: - name: Install Tauri dependencies run: | sudo apt-get update - sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf python3 - - - name: Setup Python sidecar binary - run: | - TARGET=$(rustc --print host-tuple) - PYTHON_PATH=$(which python3) - SIDECAR_PATH="src-tauri/runtime-skill-python-${TARGET}" - ln -sf "${PYTHON_PATH}" "${SIDECAR_PATH}" - echo "Created symlink: ${SIDECAR_PATH} -> ${PYTHON_PATH}" + sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf - name: Cache Cargo registry uses: actions/cache@v4 diff --git a/.github/workflows/package-and-publish.yml b/.github/workflows/package-and-publish.yml index bb42e8063..d8425b325 100644 --- a/.github/workflows/package-and-publish.yml +++ b/.github/workflows/package-and-publish.yml @@ -112,8 +112,8 @@ jobs: create-release: runs-on: ubuntu-latest needs: [get-version, check-version] - environment: Production - if: needs.get-version.outputs.should-publish != '' && needs.check-version.outputs.should-skip != 'true' + environment: ${{ github.ref == 'refs/heads/main' && 'Production' || '' }} + if: github.ref == 'refs/heads/main' && needs.get-version.outputs.should-publish == 'true' && needs.check-version.outputs.should-skip != 'true' permissions: contents: write outputs: @@ -155,7 +155,7 @@ jobs: name: Build, package and publish Tauri needs: [get-version, check-version, create-release] if: ${{ !failure() && !cancelled() && needs.check-version.outputs.should-skip != 'true' }} - environment: Production + environment: ${{ github.ref == 'refs/heads/main' && 'Production' || '' }} env: GITHUB_TOKEN: ${{ secrets.XGH_TOKEN }} permissions: @@ -199,26 +199,10 @@ jobs: if: matrix.settings.platform == 'ubuntu-22.04' run: | sudo apt-get update - sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf python3 + sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf - - name: Setup Python sidecar binary (Linux) - if: matrix.settings.platform == 'ubuntu-22.04' - run: | - TARGET=$(rustc --print host-tuple) - PYTHON_PATH=$(which python3) - SIDECAR_PATH="src-tauri/runtime-skill-python-${TARGET}" - ln -sf "${PYTHON_PATH}" "${SIDECAR_PATH}" - echo "Created symlink: ${SIDECAR_PATH} -> ${PYTHON_PATH}" - - - name: Setup Python sidecar binary (macOS) - if: matrix.settings.platform == 'macos-latest' - run: | - # Extract target from args (e.g., "--target aarch64-apple-darwin" -> "aarch64-apple-darwin") - TARGET=$(echo "${{ matrix.settings.args }}" | grep -oE '(aarch64|x86_64)-apple-darwin' || rustc --print host-tuple) - PYTHON_PATH=$(which python3 || which python) - SIDECAR_PATH="src-tauri/runtime-skill-python-${TARGET}" - ln -sf "${PYTHON_PATH}" "${SIDECAR_PATH}" - echo "Created symlink: ${SIDECAR_PATH} -> ${PYTHON_PATH}" + # OpenMP is now disabled via Cargo.toml (default-features = false for llama-cpp-2) + # This avoids cross-compilation issues where Homebrew ARM64 OpenMP can't be used for x86_64 builds - name: Cache Cargo registry uses: actions/cache@v4 @@ -306,6 +290,8 @@ jobs: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.UPDATER_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.UPDATER_PRIVATE_KEY_PASSWORD }} WITH_UPDATER: ${{ needs.get-version.outputs.should-publish != '' && 'true' || 'false' }} + # macOS 10.15+ required for std::filesystem used by llama.cpp + MACOSX_DEPLOYMENT_TARGET: ${{ matrix.settings.platform == 'macos-latest' && '10.15' || '' }} with: args: '-c ${{ steps.config-overrides.outputs.json }} ${{ matrix.settings.args }}' includeDebug: ${{ needs.get-version.outputs.should-publish == '' && inputs.forceRelease != 'true' }} @@ -388,132 +374,133 @@ jobs: name: ${{ steps.file-info.outputs.filename }} path: ${{ steps.file-info.outputs.path }}/${{ steps.file-info.outputs.filename }} - package-android: - name: Build and package Android APK - needs: [get-version, check-version, create-release] - if: ${{ !failure() && !cancelled() && needs.check-version.outputs.should-skip != 'true' }} - environment: Production - runs-on: ubuntu-22.04 - permissions: - contents: write - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 1 - ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref }} + # package-android: + # name: Build and package Android APK + # needs: [get-version, check-version, create-release] + # if: ${{ !failure() && !cancelled() && needs.check-version.outputs.should-skip != 'true' }} + # environment: ${{ github.ref == 'refs/heads/main' && 'Production' || '' }} + # runs-on: ubuntu-22.04 + # permissions: + # contents: write + # steps: + # - name: Checkout + # uses: actions/checkout@v4 + # with: + # fetch-depth: 1 + # ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref }} - - name: Setup Node.js 24.x - uses: actions/setup-node@v4 - with: - node-version: 24.x - cache: 'yarn' + # - name: Setup Node.js 24.x + # uses: actions/setup-node@v4 + # with: + # node-version: 24.x + # cache: 'yarn' - - name: Install Rust stable - uses: dtolnay/rust-toolchain@stable - with: - targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android + # - name: Install Rust stable + # uses: dtolnay/rust-toolchain@stable + # with: + # targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android - - name: Setup Java 17 - uses: actions/setup-java@v4 - with: - distribution: 'temurin' - java-version: '17' + # - name: Setup Java 17 + # uses: actions/setup-java@v4 + # with: + # distribution: 'temurin' + # java-version: '17' - - name: Setup Android SDK - uses: android-actions/setup-android@v3 + # - name: Setup Android SDK + # uses: android-actions/setup-android@v3 - - name: Install Android NDK - run: sdkmanager --install "ndk;27.0.12077973" + # - name: Install Android NDK + # run: sdkmanager --install "ndk;27.0.12077973" - - name: Set NDK path - run: echo "NDK_HOME=$ANDROID_HOME/ndk/27.0.12077973" >> $GITHUB_ENV + # - name: Set NDK path + # run: echo "NDK_HOME=$ANDROID_HOME/ndk/27.0.12077973" >> $GITHUB_ENV - - name: Cache node modules - id: yarn-cache - uses: actions/cache@v4 - with: - path: node_modules - key: ${{ runner.os }}-build-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-build- + # - name: Cache node modules + # id: yarn-cache + # uses: actions/cache@v4 + # with: + # path: node_modules + # key: ${{ runner.os }}-build-${{ hashFiles('**/yarn.lock') }} + # restore-keys: | + # ${{ runner.os }}-build- - - name: Install dependencies - if: steps.yarn-cache.outputs.cache-hit != 'true' - run: yarn install --frozen-lockfile + # - name: Install dependencies + # if: steps.yarn-cache.outputs.cache-hit != 'true' + # run: yarn install --frozen-lockfile - - name: Build frontend - run: yarn build - env: - NODE_ENV: production - VITE_BACKEND_URL: ${{ vars.VITE_BACKEND_URL }} - VITE_TELEGRAM_BOT_USERNAME: ${{ secrets.VITE_TELEGRAM_BOT_USERNAME }} - VITE_TELEGRAM_BOT_ID: ${{ secrets.VITE_TELEGRAM_BOT_ID }} - VITE_TELEGRAM_API_ID: ${{ secrets.VITE_TELEGRAM_API_ID }} - VITE_TELEGRAM_API_HASH: ${{ secrets.VITE_TELEGRAM_API_HASH }} - VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }} - VITE_SKILLS_GITHUB_REPO: ${{ vars.VITE_SKILLS_GITHUB_REPO }} - VITE_DEBUG: ${{ vars.VITE_DEBUG }} + # - name: Build frontend + # run: yarn build + # env: + # NODE_ENV: production + # VITE_BACKEND_URL: ${{ vars.VITE_BACKEND_URL }} + # VITE_TELEGRAM_BOT_USERNAME: ${{ secrets.VITE_TELEGRAM_BOT_USERNAME }} + # VITE_TELEGRAM_BOT_ID: ${{ secrets.VITE_TELEGRAM_BOT_ID }} + # VITE_TELEGRAM_API_ID: ${{ secrets.VITE_TELEGRAM_API_ID }} + # VITE_TELEGRAM_API_HASH: ${{ secrets.VITE_TELEGRAM_API_HASH }} + # VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }} + # VITE_SKILLS_GITHUB_REPO: ${{ vars.VITE_SKILLS_GITHUB_REPO }} + # VITE_DEBUG: ${{ vars.VITE_DEBUG }} - - name: Initialize Tauri Android - run: npx tauri android init + # - name: Initialize Tauri Android + # run: npx tauri android init - - name: Decode keystore - if: needs.get-version.outputs.should-publish != '' - env: - ANDROID_KEYSTORE: ${{ secrets.ANDROID_KEYSTORE }} - run: | - echo "$ANDROID_KEYSTORE" | base64 --decode > ${{ github.workspace }}/release.jks + # - name: Decode keystore + # if: needs.get-version.outputs.should-publish != '' + # env: + # ANDROID_KEYSTORE: ${{ secrets.ANDROID_KEYSTORE }} + # run: | + # echo "$ANDROID_KEYSTORE" | base64 --decode > ${{ github.workspace }}/release.jks - - name: Build Android APK (signed) - if: needs.get-version.outputs.should-publish != '' - env: - BASE_URL: ${{ vars.BASE_URL }} - ANDROID_KEYSTORE_PATH: ${{ github.workspace }}/release.jks - ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} - ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} - ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} - run: npx tauri android build --apk + # - name: Build Android APK (signed) + # if: needs.get-version.outputs.should-publish != '' + # env: + # BASE_URL: ${{ vars.BASE_URL }} + # ANDROID_KEYSTORE_PATH: ${{ github.workspace }}/release.jks + # ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + # ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + # ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + # run: npx tauri android build --apk - - name: Build Android APK (unsigned) - if: needs.get-version.outputs.should-publish == '' - env: - BASE_URL: ${{ vars.BASE_URL }} - run: npx tauri android build --apk + # - name: Build Android APK (unsigned) + # if: needs.get-version.outputs.should-publish == '' + # env: + # BASE_URL: ${{ vars.BASE_URL }} + # run: npx tauri android build --apk - - name: Find APK - id: find-apk - run: | - APK_PATH=$(find gen/android -name '*.apk' -type f | head -1) - if [ -z "$APK_PATH" ]; then - echo "No APK found" - exit 1 - fi - APK_FILENAME=$(basename "$APK_PATH") - echo "path=$APK_PATH" >> $GITHUB_OUTPUT - echo "filename=$APK_FILENAME" >> $GITHUB_OUTPUT + # - name: Find APK + # id: find-apk + # run: | + # APK_PATH=$(find gen/android -name '*.apk' -type f | head -1) + # if [ -z "$APK_PATH" ]; then + # echo "No APK found" + # exit 1 + # fi + # APK_FILENAME=$(basename "$APK_PATH") + # echo "path=$APK_PATH" >> $GITHUB_OUTPUT + # echo "filename=$APK_FILENAME" >> $GITHUB_OUTPUT - - name: Upload APK artifact - uses: actions/upload-artifact@v4 - with: - name: android-apk - path: ${{ steps.find-apk.outputs.path }} + # - name: Upload APK artifact + # uses: actions/upload-artifact@v4 + # with: + # name: android-apk + # path: ${{ steps.find-apk.outputs.path }} - - name: Upload APK to release - if: needs.get-version.outputs.should-publish != '' - env: - RELEASE_ID: ${{ needs.create-release.outputs.releaseId }} - run: | - curl -X POST -H "Authorization: Bearer $XGH_TOKEN" \ - -H "Content-Type: application/octet-stream" \ - --data-binary "@${{ steps.find-apk.outputs.path }}" \ - "https://uploads.github.com/repos/$PUBLISH_REPO/releases/$RELEASE_ID/assets?name=${{ steps.find-apk.outputs.filename }}" + # - name: Upload APK to release + # if: needs.get-version.outputs.should-publish != '' + # env: + # RELEASE_ID: ${{ needs.create-release.outputs.releaseId }} + # run: | + # curl -X POST -H "Authorization: Bearer $XGH_TOKEN" \ + # -H "Content-Type: application/octet-stream" \ + # --data-binary "@${{ steps.find-apk.outputs.path }}" \ + # "https://uploads.github.com/repos/$PUBLISH_REPO/releases/$RELEASE_ID/assets?name=${{ steps.find-apk.outputs.filename }}" publish-release: runs-on: ubuntu-latest - needs: [get-version, check-version, create-release, package-tauri, package-android] - environment: Production - if: needs.get-version.outputs.should-publish != '' && needs.check-version.outputs.should-skip != 'true' + # needs: [get-version, check-version, create-release, package-tauri, package-android] + needs: [get-version, check-version, create-release, package-tauri] + environment: ${{ github.ref == 'refs/heads/main' && 'Production' || '' }} + if: github.ref == 'refs/heads/main' && needs.get-version.outputs.should-publish == 'true' && needs.check-version.outputs.should-skip != 'true' permissions: contents: write env: diff --git a/.gitignore b/.gitignore index 608e5ac37..8e64f0c04 100644 --- a/.gitignore +++ b/.gitignore @@ -21,8 +21,6 @@ dist-ssr .env.*.local # Editor directories and files -.vscode/* -!.vscode/extensions.json .idea .DS_Store *.suo diff --git a/.husky/pre-push b/.husky/pre-push index 127b001d1..07fcbc99f 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,37 +1,37 @@ -#!/usr/bin/env sh +# #!/usr/bin/env sh -# Run format check first (capture exit code without breaking script) -set +e -yarn format:check -FORMAT_EXIT=$? -set -e +# # Run format check first (capture exit code without breaking script) +# set +e +# yarn format:check +# FORMAT_EXIT=$? +# set -e -# If format check failed, run format to auto-fix -if [ $FORMAT_EXIT -ne 0 ]; then - echo "Formatting issues detected. Running format to auto-fix..." - yarn format -fi +# # If format check failed, run format to auto-fix +# if [ $FORMAT_EXIT -ne 0 ]; then +# echo "Formatting issues detected. Running format to auto-fix..." +# yarn format +# fi -# Run lint check (capture exit code without breaking script) -set +e -yarn lint -LINT_EXIT=$? -set -e +# # Run lint check (capture exit code without breaking script) +# set +e +# yarn lint +# LINT_EXIT=$? +# set -e -# If lint check failed, run lint:fix to auto-fix -if [ $LINT_EXIT -ne 0 ]; then - echo "Linting issues detected. Running lint:fix to auto-fix..." - yarn lint:fix -fi +# # If lint check failed, run lint:fix to auto-fix +# if [ $LINT_EXIT -ne 0 ]; then +# echo "Linting issues detected. Running lint:fix to auto-fix..." +# yarn lint:fix +# fi -# Run TypeScript compile check (capture exit code without breaking script) -set +e -yarn compile -COMPILE_EXIT=$? -set -e +# # Run TypeScript compile check (capture exit code without breaking script) +# set +e +# yarn compile +# COMPILE_EXIT=$? +# set -e -# Exit with error if any command still fails after fixes -if [ $FORMAT_EXIT -ne 0 ] || [ $LINT_EXIT -ne 0 ] || [ $COMPILE_EXIT -ne 0 ]; then - echo "Pre-push checks failed. Please fix format, lint, and/or TypeScript errors before pushing." - exit 1 -fi +# # Exit with error if any command still fails after fixes +# if [ $FORMAT_EXIT -ne 0 ] || [ $LINT_EXIT -ne 0 ] || [ $COMPILE_EXIT -ne 0 ]; then +# echo "Pre-push checks failed. Please fix format, lint, and/or TypeScript errors before pushing." +# exit 1 +# fi diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 450f678f1..f86c2e2a5 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1 +1,7 @@ -{ "recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"] } +{ + "recommendations": [ + "tauri-apps.tauri-vscode", + "rust-lang.rust-analyzer", + "esbenp.prettier-vscode" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..5f218695a --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,13 @@ +{ + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true, + "[javascript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, + "[javascriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, + "[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, + "[typescriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, + "[json]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, + "[jsonc]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, + "[html]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, + "[css]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, + "[markdown]": { "editor.defaultFormatter": "esbenp.prettier-vscode" } +} diff --git a/CLAUDE.md b/CLAUDE.md index 88a8097ba..2bf8b6e7d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -149,10 +149,12 @@ Key file: `src/utils/desktopDeepLinkListener.ts` (lazy-loaded in `main.tsx`). Us Enhanced Rust backend with comprehensive skill execution and runtime management: **Core Commands:** + - `greet` — demo command - `exchange_token` — CORS-free HTTP POST to backend for token exchange (desktop only) **Runtime Management:** + - `discover_skills` — V8 skill discovery and manifest parsing - `enable_skill` / `disable_skill` — skill lifecycle management - `get_skill_preferences` / `set_skill_preferences` — skill configuration @@ -160,6 +162,7 @@ Enhanced Rust backend with comprehensive skill execution and runtime management: - `get_socket_status` — connection status monitoring **Android Support:** + - `RuntimeService` — background service for skill execution - Notification permissions and foreground service management - Android logging integration with logcat @@ -171,6 +174,7 @@ Deep link plugin registered at setup. `register_all()` called only on Windows/Li Advanced JavaScript execution engine for skills using V8 (via deno_core): **Core Components:** + - `v8_engine.rs` — V8 JavaScript runtime initialization and management - `v8_skill_instance.rs` — Individual skill execution contexts and lifecycle - `skill_registry.rs` — Skill discovery, registration, and state management @@ -180,6 +184,7 @@ Advanced JavaScript execution engine for skills using V8 (via deno_core): - `preferences.rs` — Skill configuration and settings persistence **Bridge System (`src-tauri/src/runtime/bridge/`):** + - `skills_bridge.rs` — Skill-to-skill communication and state sharing - `tauri_bridge.rs` — Frontend-backend IPC and environment access - `net.rs` — HTTP/fetch operations for skills @@ -189,12 +194,14 @@ Advanced JavaScript execution engine for skills using V8 (via deno_core): - `cron_bridge.rs` — Cron job scheduling and management **TDLib Integration (`src-tauri/src/services/tdlib_v8/`):** + - `service.rs` — High-level TDLib client management with V8 integration - `bootstrap.js` — V8 JavaScript bootstrap environment - `ops/mod.rs` — Native operations for WebSocket, timers, and async handling - `storage.rs` — Persistent storage for TDLib sessions and data **Platform Support:** + - Desktop platforms: Full V8 runtime with all features - Mobile platforms: Error handling with feature availability checks - Platform-specific skill filtering based on manifest declarations @@ -338,7 +345,9 @@ Key updates from recent commits (cd9ebcd to current): ## Git Workflow -- **PR target branch**: All pull requests should target the `develop` branch, not `main`. +- **Push target**: All pushes go to the **user's private repo** (your fork). Do not push directly to the org repository. +- **PR target**: All pull requests are opened **from your fork** against the **org's private repo**, targeting the **`develop`** branch (not `main`). +- **No direct pushes to org**: The org repo does not allow direct pushes. All changes reach the org repo via PRs from your fork. ## Key Patterns diff --git a/logo.png b/logo.png new file mode 100644 index 000000000..8e140ac02 Binary files /dev/null and b/logo.png differ diff --git a/package.json b/package.json index cf22c7029..795cd0c64 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "vite", + "dev:web": "vite", "build": "tsc && vite build", "compile": "tsc --noEmit", "preview": "vite preview", @@ -32,6 +33,7 @@ "@tauri-apps/api": "^2", "@tauri-apps/plugin-deep-link": "^2", "@tauri-apps/plugin-opener": "^2", + "@tauri-apps/plugin-os": "^2.3.2", "@tauri-apps/plugin-shell": "~2", "@types/react-router-dom": "^5.3.3", "buffer": "^6.0.3", diff --git a/scripts/setup-python-sidecar.mjs b/scripts/setup-python-sidecar.mjs deleted file mode 100644 index 738508126..000000000 --- a/scripts/setup-python-sidecar.mjs +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env node -/** - * Symlink system python3 to src-tauri/runtime-skill-python- for local dev. - * Run from project root: node scripts/setup-python-sidecar.mjs - */ -import { execSync } from 'child_process'; -import fs from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const root = path.resolve(__dirname, '..'); -const binariesDir = path.join(root, 'src-tauri'); - -function getTargetTriple() { - try { - return execSync('rustc --print host-tuple', { encoding: 'utf8' }).trim(); - } catch { - const out = execSync('rustc -Vv', { encoding: 'utf8' }); - const m = out.match(/host:\s*(.+)/); - if (m) return m[1].trim(); - throw new Error('Could not get Rust target triple (rustc not found?)'); - } -} - -function getSystemPython() { - const isWin = process.platform === 'win32'; - try { - const cmd = isWin ? 'where python' : 'which python3'; - const out = execSync(cmd, { encoding: 'utf8' }).trim(); - const first = out.split(/[\r\n]/)[0].trim(); - if (first) return first; - } catch {} - throw new Error( - 'System Python not found. Install Python 3 and ensure python3 (or python on Windows) is on PATH.' - ); -} - -const target = getTargetTriple(); -const ext = process.platform === 'win32' ? '.exe' : ''; -const sidecarName = `runtime-skill-python-${target}${ext}`; -const sidecarPath = path.join(binariesDir, sidecarName); -const systemPython = getSystemPython(); - -try { - if (fs.existsSync(sidecarPath)) { - fs.unlinkSync(sidecarPath); - } - fs.symlinkSync(systemPython, sidecarPath); - console.log(`Linked ${sidecarName} -> ${systemPython}`); -} catch (err) { - console.error(err.message); - process.exit(1); -} diff --git a/skills b/skills index 54c40a1b0..d9dfb8d97 160000 --- a/skills +++ b/skills @@ -1 +1 @@ -Subproject commit 54c40a1b0d3daa909fbd95d5018343922b648fab +Subproject commit d9dfb8d974609e8b0324d78888fcbd1f711178a7 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ac25de84f..9db94fefd 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -15,13 +15,14 @@ dependencies = [ "deno_core", "dirs 5.0.1", "dotenvy", + "encoding_rs", "env_logger", "futures", "futures-util", "keyring", + "llama-cpp-2", "log", "once_cell", - "openssl", "parking_lot", "rand 0.8.5", "reqwest", @@ -36,6 +37,8 @@ dependencies = [ "tauri-plugin-deep-link", "tauri-plugin-notification", "tauri-plugin-opener", + "tauri-plugin-os", + "tdlib-rs", "tokio", "tokio-tungstenite 0.24.0", "uuid", @@ -212,6 +215,15 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "argon2" version = "0.5.3" @@ -493,6 +505,26 @@ dependencies = [ "which 4.4.2", ] +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.10.0", + "cexpr", + "clang-sys", + "itertools", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.1", + "shlex", + "syn 2.0.114", +] + [[package]] name = "bit-set" version = "0.5.3" @@ -623,6 +655,25 @@ dependencies = [ "serde", ] +[[package]] +name = "bzip2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" +dependencies = [ + "bzip2-sys", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "cairo-rs" version = "0.18.5" @@ -697,6 +748,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6354c81bbfd62d9cfa9cb3c773c2b7b2a3a482d569de977fd0e961f6e7c00583" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -783,6 +836,15 @@ dependencies = [ "libloading 0.8.9", ] +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + [[package]] name = "colorchoice" version = "1.0.4" @@ -828,6 +890,12 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + [[package]] name = "convert_case" version = "0.4.0" @@ -909,6 +977,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + [[package]] name = "crc32fast" version = "1.5.0" @@ -1058,6 +1141,12 @@ dependencies = [ "uuid", ] +[[package]] +name = "deflate64" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26bf8fc351c5ed29b5c2f0cbbac1b209b74f60ecd62e675a998df72c49af5204" + [[package]] name = "deno_core" version = "0.314.2" @@ -1132,6 +1221,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "derive_more" version = "0.99.20" @@ -1495,6 +1595,15 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" +[[package]] +name = "find_cuda_helper" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f9e65c593dd01ac77daad909ea4ad17f0d6d1776193fc8ea766356177abdad" +dependencies = [ + "glob", +] + [[package]] name = "flate2" version = "1.1.8" @@ -1808,6 +1917,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.3", + "windows-link 0.2.1", +] + [[package]] name = "getrandom" version = "0.1.16" @@ -2086,6 +2205,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "home" version = "0.5.12" @@ -2550,6 +2678,16 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.85" @@ -2716,6 +2854,34 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "llama-cpp-2" +version = "0.1.133" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "888c8805527f4c35ec16f26003d54a318cde1629e7439da8e9ef2d6d3883e106" +dependencies = [ + "encoding_rs", + "enumflags2", + "llama-cpp-sys-2", + "thiserror 1.0.69", + "tracing", + "tracing-core", +] + +[[package]] +name = "llama-cpp-sys-2" +version = "0.1.133" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a180dfa6d6f9d1df1e031bcdf0464bbad4f9b326395bfd28f2fa539d8cbc9c2b" +dependencies = [ + "bindgen 0.72.1", + "cc", + "cmake", + "find_cuda_helper", + "glob", + "walkdir", +] + [[package]] name = "lock_api" version = "0.4.14" @@ -2737,6 +2903,27 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lzma-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" +dependencies = [ + "byteorder", + "crc", +] + +[[package]] +name = "lzma-sys" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "mac" version = "0.1.1" @@ -2917,6 +3104,18 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nodrop" version = "0.1.14" @@ -3091,6 +3290,16 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-core-text" version = "0.3.2" @@ -3195,8 +3404,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ "bitflags 2.10.0", + "block2", "objc2", + "objc2-cloud-kit", + "objc2-core-data", "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", "objc2-foundation", ] @@ -3278,15 +3506,6 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" -[[package]] -name = "openssl-src" -version = "300.5.5+3.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f1787d533e03597a7934fd0a765f0d28e94ecc5fb7789f8053b1e699a56f709" -dependencies = [ - "cc", -] - [[package]] name = "openssl-sys" version = "0.9.111" @@ -3295,7 +3514,6 @@ checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" dependencies = [ "cc", "libc", - "openssl-src", "pkg-config", "vcpkg", ] @@ -3326,6 +3544,22 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_info" +version = "3.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4022a17595a00d6a369236fdae483f0de7f0a339960a53118b818238e132224" +dependencies = [ + "android_system_properties", + "log", + "nix", + "objc2", + "objc2-foundation", + "objc2-ui-kit", + "serde", + "windows-sys 0.61.2", +] + [[package]] name = "outref" version = "0.1.0" @@ -3409,6 +3643,16 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -4925,6 +5169,15 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + [[package]] name = "system-configuration" version = "0.6.1" @@ -5229,6 +5482,24 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-os" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f08346c8deb39e96f86973da0e2d76cbb933d7ac9b750f6dc4daf955a6f997" +dependencies = [ + "gethostname", + "log", + "os_info", + "serde", + "serde_json", + "serialize-to-javascript", + "sys-locale", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-runtime" version = "2.9.2" @@ -5342,6 +5613,40 @@ dependencies = [ "windows-version", ] +[[package]] +name = "tdlib-rs" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c309480dcdd6d5dc2f37866d9063fed280780ddfeb51ae3a0adc2b52b0c0bc3" +dependencies = [ + "dirs 6.0.0", + "futures-channel", + "log", + "once_cell", + "reqwest", + "serde", + "serde_json", + "serde_with", + "tdlib-rs-gen", + "tdlib-rs-parser", + "zip", +] + +[[package]] +name = "tdlib-rs-gen" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff69c8cab3e5285d2f79f53263077b2cdb12a841b230406e3b1230a345c78968" +dependencies = [ + "tdlib-rs-parser", +] + +[[package]] +name = "tdlib-rs-parser" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20b1c6703d2284b9d4ddb620cd350f726a1c43bb6f7801f4361b55db2421caa8" + [[package]] name = "tempfile" version = "3.24.0" @@ -5732,6 +6037,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", ] [[package]] @@ -5961,7 +6267,7 @@ version = "0.106.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a381badc47c6f15acb5fe0b5b40234162349ed9d4e4fd7c83a7f5547c0fc69c5" dependencies = [ - "bindgen", + "bindgen 0.69.5", "bitflags 2.10.0", "fslock", "gzip-header", @@ -5972,6 +6278,12 @@ dependencies = [ "which 6.0.3", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "vcpkg" version = "0.2.15" @@ -6905,6 +7217,15 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "xz2" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" +dependencies = [ + "lzma-sys", +] + [[package]] name = "yoke" version = "0.8.1" @@ -7035,6 +7356,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] [[package]] name = "zerotrie" @@ -7069,12 +7404,82 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "aes", + "arbitrary", + "bzip2", + "constant_time_eq", + "crc32fast", + "crossbeam-utils", + "deflate64", + "displaydoc", + "flate2", + "getrandom 0.3.4", + "hmac", + "indexmap 2.13.0", + "lzma-rs", + "memchr", + "pbkdf2", + "sha1", + "thiserror 2.0.18", + "time", + "xz2", + "zeroize", + "zopfli", + "zstd", +] + [[package]] name = "zmij" version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02aae0f83f69aafc94776e879363e9771d7ecbffe2c7fbb6c14c5e00dfe88439" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "zvariant" version = "5.9.2" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 04d645390..ea0438fca 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -22,6 +22,7 @@ tauri-build = { version = "2", features = [] } tauri = { version = "2", features = ["tray-icon", "macos-private-api"] } tauri-plugin-opener = "2" tauri-plugin-deep-link = "2.0.0" +tauri-plugin-os = "2" # tauri-plugin-autostart moved to desktop-only target section # tauri-plugin-notification moved to desktop-only target section # Serialization @@ -74,12 +75,15 @@ chrono = { version = "0.4", features = ["serde"] } cron = "0.12" # Persistent Socket.io client (Rust-native, survives app backgrounding) -rust_socketio = { version = "0.6", features = ["async"] } +# Note: Using default native-tls on desktop, but we'll handle Android separately futures-util = "0.3" +# Desktop/iOS use native-tls for Socket.io +[target.'cfg(not(target_os = "android"))'.dependencies] +rust_socketio = { version = "0.6", features = ["async"] } + +# Android uses a simpler approach - no persistent Socket.io (use web socket from frontend) [target.'cfg(target_os = "android")'.dependencies] -# Vendor OpenSSL for Android (rust_socketio requires native-tls which needs OpenSSL) -openssl = { version = "0.10", features = ["vendored"] } # Android logging (routes Rust `log` crate to logcat) android_logger = "0.14" @@ -90,6 +94,17 @@ tauri-plugin-notification = "2" # V8 JavaScript runtime (via deno_core - powers Deno) # Only available on desktop - prebuilt binaries don't exist for Android/iOS deno_core = "0.314" +# TDLib Rust bindings (desktop only - downloads prebuilt TDLib library) +tdlib-rs = { version = "1.2", features = ["download-tdlib"] } +# Local LLM inference - desktop only (llama.cpp requires native C++ compilation) +# Disable default features (including openmp) to avoid cross-compilation issues +# on macOS where Homebrew installs ARM64-only OpenMP libraries +llama-cpp-2 = { version = "0.1", default-features = false } +encoding_rs = "0.8" + +[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.build-dependencies] +# TDLib build configuration +tdlib-rs = { version = "1.2", features = ["download-tdlib"] } [features] # This feature is used for production builds or when a dev server is not specified, DO NOT REMOVE!! diff --git a/src-tauri/binaries/README.md b/src-tauri/binaries/README.md deleted file mode 100644 index 0edb65495..000000000 --- a/src-tauri/binaries/README.md +++ /dev/null @@ -1,33 +0,0 @@ -# Tauri sidecar binaries - -The app bundles a Python interpreter as a sidecar for skill runtimes (`runtime-skill-python`). The binary lives in `src-tauri/` (next to `tauri.conf.json`) and must be named by target triple. - -## Required file - -- **Python**: `src-tauri/runtime-skill-python-` (e.g. `runtime-skill-python-aarch64-apple-darwin`, or `runtime-skill-python-x86_64-pc-windows-msvc.exe` on Windows) - -Get your target triple: - -```bash -rustc --print host-tuple -``` - -## Local development (symlink to system Python) - -From the project root: - -```bash -node scripts/setup-python-sidecar.mjs -``` - -This creates a symlink at `src-tauri/runtime-skill-python-` pointing to your system `python3`. - -## Production / bundled Python - -For a standalone app that does not rely on the user having Python installed: - -1. **Windows**: Use the [Python embeddable package](https://www.python.org/downloads/windows/). Copy `python.exe` to `src-tauri/runtime-skill-python-x86_64-pc-windows-msvc.exe` (and the same for `aarch64-pc-windows-msvc` if you support ARM). You may need to bundle the rest of the embeddable folder as resources and set `PYTHONHOME` when spawning. - -2. **macOS / Linux**: Use [python-build-standalone](https://github.com/astral-sh/python-build-standalone/releases). Copy the `bin/python3` from the extracted tree to `src-tauri/runtime-skill-python-`. Note: the binary may have dynamic library dependencies; for a fully portable build you may need to bundle the whole distribution as resources. - -After adding the binary, run `yarn tauri build` (or `yarn tauri dev`); the sidecar will be included in the bundle. diff --git a/src-tauri/build.rs b/src-tauri/build.rs index d860e1e6a..ddfa71cd8 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,3 +1,18 @@ fn main() { + // Get the target OS from environment variable (set by Cargo during cross-compilation) + let target = std::env::var("TARGET").unwrap_or_default(); + let is_mobile_target = target.contains("android") || target.contains("ios"); + + // TDLib build configuration (desktop only) + // The tdlib-rs crate with download-tdlib feature handles downloading and linking + // the prebuilt TDLib library automatically. + // Note: We check the TARGET env var because cfg() checks the HOST platform for build scripts. + #[cfg(not(any(target_os = "android", target_os = "ios")))] + if !is_mobile_target { + // Download and link TDLib library + // Pass None to use default download location + tdlib_rs::build::build(None); + } + tauri_build::build() } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index bb16970ca..d1253b2a6 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -8,6 +8,7 @@ "core:default", "opener:default", "deep-link:default", + "os:default", "core:tray:default", "autostart:default", "autostart:allow-enable", diff --git a/src-tauri/capabilities/mobile.json b/src-tauri/capabilities/mobile.json index 9c150e1c6..da66d9084 100644 --- a/src-tauri/capabilities/mobile.json +++ b/src-tauri/capabilities/mobile.json @@ -7,8 +7,6 @@ "core:default", "opener:default", "deep-link:default", - "shell:default", - "shell:allow-spawn", - "shell:allow-open" + "os:default" ] } diff --git a/src-tauri/gen/android/app/build.gradle.kts b/src-tauri/gen/android/app/build.gradle.kts index 9a61adfa5..8cac6fc1c 100644 --- a/src-tauri/gen/android/app/build.gradle.kts +++ b/src-tauri/gen/android/app/build.gradle.kts @@ -63,6 +63,9 @@ dependencies { implementation("androidx.core:core-ktx:1.16.0") implementation("androidx.activity:activity-ktx:1.10.1") implementation("com.google.android.material:material:1.12.0") + // TDLib is desktop-only - Android uses MTProto via frontend JavaScript + // MediaPipe LLM Inference API for on-device AI + implementation("com.google.mediapipe:tasks-genai:0.10.27") testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.test.ext:junit:1.1.4") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0") diff --git a/src-tauri/gen/android/app/src/main/java/com/alphahuman/app/MainActivity.kt b/src-tauri/gen/android/app/src/main/java/com/alphahuman/app/MainActivity.kt index 92e179083..e3481c1f9 100644 --- a/src-tauri/gen/android/app/src/main/java/com/alphahuman/app/MainActivity.kt +++ b/src-tauri/gen/android/app/src/main/java/com/alphahuman/app/MainActivity.kt @@ -18,6 +18,10 @@ class MainActivity : TauriActivity() { override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) + + // Initialize MediaPipe LLM Bridge with application context + MediaPipeLlmBridge.initialize(this) + requestNotificationPermissionAndStart() } diff --git a/src-tauri/gen/android/app/src/main/java/com/alphahuman/app/MediaPipeLlmBridge.kt b/src-tauri/gen/android/app/src/main/java/com/alphahuman/app/MediaPipeLlmBridge.kt new file mode 100644 index 000000000..2b9e27897 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/java/com/alphahuman/app/MediaPipeLlmBridge.kt @@ -0,0 +1,311 @@ +package com.alphahuman.app + +import android.content.Context +import android.util.Log +import com.google.mediapipe.tasks.genai.llminference.LlmInference +import com.google.mediapipe.tasks.genai.llminference.LlmInference.LlmInferenceOptions +import org.json.JSONObject +import java.io.File +import java.util.concurrent.atomic.AtomicBoolean + +/** + * MediaPipe LLM Inference Bridge for Android + * + * Provides a JNI-accessible interface to MediaPipe's LLM Inference API for the Rust backend. + * Enables on-device LLM inference using Google's MediaPipe framework. + * + * Supported models: Gemma 3n, Gemma 2, Phi-2, Falcon, StableLM + * See: https://ai.google.dev/edge/mediapipe/solutions/genai/llm_inference/android + */ +object MediaPipeLlmBridge { + private const val TAG = "MediaPipeLlmBridge" + + // LLM Inference instance (singleton) + private var llmInference: LlmInference? = null + + // Application context reference + private var appContext: Context? = null + + // Current model path + private var currentModelPath: String? = null + + // Loading state + private val isLoading = AtomicBoolean(false) + + // Streaming callback + private var streamingCallback: ((String, Boolean) -> Unit)? = null + + /** + * Initialize the bridge with application context. + * Must be called from MainActivity before using other methods. + */ + @JvmStatic + fun initialize(context: Context) { + appContext = context.applicationContext + Log.i(TAG, "MediaPipe LLM Bridge initialized") + } + + /** + * Check if MediaPipe LLM is available on this device. + * @return JSON with availability status and device info + */ + @JvmStatic + fun isAvailable(): String { + return try { + val json = JSONObject() + json.put("available", true) + json.put("initialized", llmInference != null) + json.put("model_loaded", currentModelPath != null) + json.put("current_model", currentModelPath ?: "") + json.toString() + } catch (e: Exception) { + Log.e(TAG, "Error checking availability", e) + """{"available":false,"error":"${e.message?.replace("\"", "\\\"")}"}""" + } + } + + /** + * Load a model from the specified path. + * @param modelPath Path to the .task model file (e.g., /data/local/tmp/llm/gemma-3-1b-it-int4.task) + * @param maxTokens Maximum number of tokens to generate (default: 1024) + * @param topK Top-K sampling parameter (default: 40) + * @param temperature Sampling temperature (default: 0.8) + * @param randomSeed Random seed for reproducibility (default: 0 = random) + * @return JSON with success status or error + */ + @JvmStatic + fun loadModel( + modelPath: String, + maxTokens: Int = 1024, + topK: Int = 40, + temperature: Float = 0.8f, + randomSeed: Int = 0 + ): String { + val context = appContext + if (context == null) { + return """{"success":false,"error":"Bridge not initialized. Call initialize() first."}""" + } + + if (isLoading.get()) { + return """{"success":false,"error":"Model is already loading"}""" + } + + return try { + isLoading.set(true) + Log.i(TAG, "Loading model from: $modelPath") + + // Check if model file exists + val modelFile = File(modelPath) + if (!modelFile.exists()) { + isLoading.set(false) + return """{"success":false,"error":"Model file not found: $modelPath"}""" + } + + // Close existing model if any + llmInference?.close() + llmInference = null + currentModelPath = null + + // Build options + // Note: Temperature is not available in MediaPipe LLM Inference API 0.10.x + // Only setModelPath, setMaxTokens, setMaxTopK, and setRandomSeed are supported + val optionsBuilder = LlmInferenceOptions.builder() + .setModelPath(modelPath) + .setMaxTokens(maxTokens) + .setMaxTopK(topK) + + if (randomSeed > 0) { + optionsBuilder.setRandomSeed(randomSeed) + } + + // Temperature parameter is accepted but not used in current API version + @Suppress("UNUSED_VARIABLE") + val unusedTemp = temperature + + val options = optionsBuilder.build() + + // Create LLM inference instance + llmInference = LlmInference.createFromOptions(context, options) + currentModelPath = modelPath + + isLoading.set(false) + Log.i(TAG, "Model loaded successfully") + + val json = JSONObject() + json.put("success", true) + json.put("model_path", modelPath) + json.toString() + } catch (e: Exception) { + isLoading.set(false) + Log.e(TAG, "Error loading model", e) + """{"success":false,"error":"${e.message?.replace("\"", "\\\"")}"}""" + } + } + + /** + * Generate a response synchronously. + * @param prompt The input prompt + * @return JSON with generated text or error + */ + @JvmStatic + fun generateResponse(prompt: String): String { + val inference = llmInference + if (inference == null) { + return """{"success":false,"error":"No model loaded. Call loadModel() first."}""" + } + + return try { + Log.d(TAG, "Generating response for prompt: ${prompt.take(100)}...") + + val response = inference.generateResponse(prompt) + + val json = JSONObject() + json.put("success", true) + json.put("response", response) + json.put("prompt", prompt) + json.toString() + } catch (e: Exception) { + Log.e(TAG, "Error generating response", e) + """{"success":false,"error":"${e.message?.replace("\"", "\\\"")}"}""" + } + } + + /** + * Generate a response asynchronously with streaming. + * Results are sent via the streaming callback. + * @param prompt The input prompt + * @return JSON with status + */ + @JvmStatic + fun generateResponseAsync(prompt: String): String { + val inference = llmInference + if (inference == null) { + return """{"success":false,"error":"No model loaded. Call loadModel() first."}""" + } + + return try { + Log.d(TAG, "Starting async generation for prompt: ${prompt.take(100)}...") + + inference.generateResponseAsync(prompt) { partialResult, done -> + streamingCallback?.invoke(partialResult, done) + } + + val json = JSONObject() + json.put("success", true) + json.put("status", "streaming") + json.toString() + } catch (e: Exception) { + Log.e(TAG, "Error starting async generation", e) + """{"success":false,"error":"${e.message?.replace("\"", "\\\"")}"}""" + } + } + + /** + * Set the streaming callback for async generation. + * @param callback Function that receives (partialResult: String, isDone: Boolean) + */ + @JvmStatic + fun setStreamingCallback(callback: (String, Boolean) -> Unit) { + streamingCallback = callback + } + + /** + * Unload the current model and free resources. + * @return JSON with status + */ + @JvmStatic + fun unloadModel(): String { + return try { + llmInference?.close() + llmInference = null + currentModelPath = null + + Log.i(TAG, "Model unloaded") + """{"success":true}""" + } catch (e: Exception) { + Log.e(TAG, "Error unloading model", e) + """{"success":false,"error":"${e.message?.replace("\"", "\\\"")}"}""" + } + } + + /** + * Get the default model storage directory. + * @return Path to the models directory + */ + @JvmStatic + fun getModelsDirectory(): String { + val context = appContext ?: return "/data/local/tmp/llm" + + // Use app's files directory for model storage + val modelsDir = File(context.filesDir, "models") + if (!modelsDir.exists()) { + modelsDir.mkdirs() + } + return modelsDir.absolutePath + } + + /** + * List available models in the models directory. + * @return JSON array of model files + */ + @JvmStatic + fun listModels(): String { + return try { + val modelsDir = File(getModelsDirectory()) + val models = modelsDir.listFiles { file -> + file.isFile && (file.name.endsWith(".task") || file.name.endsWith(".bin")) + } ?: emptyArray() + + val json = JSONObject() + json.put("success", true) + json.put("models_dir", modelsDir.absolutePath) + + val modelsList = models.map { file -> + JSONObject().apply { + put("name", file.name) + put("path", file.absolutePath) + put("size_mb", file.length() / (1024 * 1024)) + } + } + json.put("models", modelsList) + json.toString() + } catch (e: Exception) { + Log.e(TAG, "Error listing models", e) + """{"success":false,"error":"${e.message?.replace("\"", "\\\"")}"}""" + } + } + + /** + * Get recommended models for download. + * @return JSON with model recommendations and download URLs + */ + @JvmStatic + fun getRecommendedModels(): String { + val json = JSONObject() + json.put("success", true) + json.put("models", listOf( + JSONObject().apply { + put("name", "Gemma 3 1B (4-bit)") + put("id", "gemma-3-1b-it-int4") + put("size_mb", 550) + put("description", "Compact, fast model suitable for most devices") + put("url", "https://huggingface.co/litert-community/Gemma3-1B-IT/resolve/main/gemma3-1b-it-int4.task") + }, + JSONObject().apply { + put("name", "Gemma 3n E2B (4-bit)") + put("id", "gemma-3n-e2b-it-int4") + put("size_mb", 1400) + put("description", "Effective 2B model with multimodal support") + put("url", "https://huggingface.co/litert-community/Gemma3n-E2B-IT/resolve/main/gemma3n-e2b-it-int4.task") + }, + JSONObject().apply { + put("name", "Gemma 3n E4B (4-bit)") + put("id", "gemma-3n-e4b-it-int4") + put("size_mb", 2800) + put("description", "Effective 4B model, best quality, requires high-end device") + put("url", "https://huggingface.co/litert-community/Gemma3n-E4B-IT/resolve/main/gemma3n-e4b-it-int4.task") + } + )) + return json.toString() + } +} diff --git a/src-tauri/gen/android/app/src/main/java/com/alphahuman/app/TdLibBridge.kt b/src-tauri/gen/android/app/src/main/java/com/alphahuman/app/TdLibBridge.kt new file mode 100644 index 000000000..79e9bd827 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/java/com/alphahuman/app/TdLibBridge.kt @@ -0,0 +1,56 @@ +package com.alphahuman.app + +import android.util.Log + +/** + * TDLib Bridge Stub for Android + * + * TDLib native library is not available on Android through Maven Central. + * Telegram integration on mobile uses MTProto via the frontend JavaScript. + * This stub ensures the build compiles while TDLib features return errors. + */ +object TdLibBridge { + private const val TAG = "TdLibBridge" + + /** + * Stub - TDLib is not available on Android. + */ + @JvmStatic + fun createClient(): Int { + Log.w(TAG, "TDLib is not available on Android") + return -1 + } + + /** + * Stub - TDLib is not available on Android. + */ + @JvmStatic + fun send(clientId: Int, requestJson: String): String { + Log.w(TAG, "TDLib is not available on Android") + return """{"@type":"error","code":501,"message":"TDLib is not available on Android"}""" + } + + /** + * Stub - TDLib is not available on Android. + */ + @JvmStatic + fun receive(timeout: Double): String? { + return null + } + + /** + * Stub - TDLib is not available on Android. + */ + @JvmStatic + fun destroyClient(clientId: Int) { + Log.w(TAG, "TDLib is not available on Android") + } + + /** + * TDLib is not available on Android via Maven. + */ + @JvmStatic + fun isAvailable(): Boolean { + return false + } +} diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/src-tauri/gen/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..2ffbf24b6 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index 28f1aa119..6394bf52a 100644 Binary files a/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png index 85d0c88af..2744453d9 100644 Binary files a/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png and b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png index 28f1aa119..a47cfa117 100644 Binary files a/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 73e48dbfb..e9d1b100e 100644 Binary files a/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png index 13dd21476..1c9b7cb4b 100644 Binary files a/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png and b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png index 73e48dbfb..8c502d6a6 100644 Binary files a/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 1d98044f1..fb62009dc 100644 Binary files a/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png index a888b336b..47e64c2a1 100644 Binary files a/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png and b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png index 1d98044f1..da99fa7c4 100644 Binary files a/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index 081832466..7580bbeb0 100644 Binary files a/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png index a2a838e7b..3754c9855 100644 Binary files a/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png and b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png index 081832466..d7c86999e 100644 Binary files a/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and b/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index b18bceb64..20b385e30 100644 Binary files a/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png index 3f8a57f38..779b83ac6 100644 Binary files a/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png and b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png index b18bceb64..dedbaf445 100644 Binary files a/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and b/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/gen/android/app/src/main/res/values/ic_launcher_background.xml b/src-tauri/gen/android/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 000000000..ea9c223a6 --- /dev/null +++ b/src-tauri/gen/android/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #fff + \ No newline at end of file diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@1x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@1x.png index a6ac2a8cc..05f28fb8f 100644 Binary files a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@1x.png and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@1x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x-1.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x-1.png index 2869541f7..80c565083 100644 Binary files a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x-1.png and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x-1.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x.png index 2869541f7..80c565083 100644 Binary files a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x.png and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@2x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@3x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@3x.png index cf265a45d..e49ca99c1 100644 Binary files a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@3x.png and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-20x20@3x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@1x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@1x.png index 29c9746c0..8af437781 100644 Binary files a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@1x.png and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@1x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x-1.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x-1.png index a4e68c8d6..395d0a252 100644 Binary files a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x-1.png and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x-1.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x.png index a4e68c8d6..395d0a252 100644 Binary files a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x.png and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@2x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@3x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@3x.png index e4adcbced..318a6031c 100644 Binary files a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@3x.png and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-29x29@3x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@1x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@1x.png index 2869541f7..80c565083 100644 Binary files a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@1x.png and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@1x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x-1.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x-1.png index a414e65bb..67d46aa55 100644 Binary files a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x-1.png and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x-1.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x.png index a414e65bb..67d46aa55 100644 Binary files a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x.png and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@2x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@3x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@3x.png index a0807e5df..77fafac03 100644 Binary files a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@3x.png and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-40x40@3x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png index 704c92913..7c12d5f12 100644 Binary files a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@2x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@2x.png index a0807e5df..77fafac03 100644 Binary files a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@2x.png and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@2x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@3x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@3x.png index 2a9fbc26e..d48cbf99b 100644 Binary files a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@3x.png and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-60x60@3x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@1x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@1x.png index 2cdf18485..8cab5aa63 100644 Binary files a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@1x.png and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@1x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@2x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@2x.png index 4723e4b45..106a5a03c 100644 Binary files a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@2x.png and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-76x76@2x.png differ diff --git a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5x83.5@2x.png b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5x83.5@2x.png index f26fee45c..e2df3a800 100644 Binary files a/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5x83.5@2x.png and b/src-tauri/gen/apple/Assets.xcassets/AppIcon.appiconset/AppIcon-83.5x83.5@2x.png differ diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png index 62d9ac8e4..90aa1aa16 100644 Binary files a/src-tauri/icons/128x128.png and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png index 8e140ac02..f68176d2a 100644 Binary files a/src-tauri/icons/128x128@2x.png and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png index 5e52e0cb4..aaa26ed93 100644 Binary files a/src-tauri/icons/32x32.png and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/64x64.png b/src-tauri/icons/64x64.png new file mode 100644 index 000000000..8c0760c56 Binary files /dev/null and b/src-tauri/icons/64x64.png differ diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png index 542fe8e32..ad5165b9b 100644 Binary files a/src-tauri/icons/Square107x107Logo.png and b/src-tauri/icons/Square107x107Logo.png differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png index 082ee78bb..7854a837b 100644 Binary files a/src-tauri/icons/Square142x142Logo.png and b/src-tauri/icons/Square142x142Logo.png differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png index 581dd6e25..628617c72 100644 Binary files a/src-tauri/icons/Square150x150Logo.png and b/src-tauri/icons/Square150x150Logo.png differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png index 86ec301b5..6eba27f65 100644 Binary files a/src-tauri/icons/Square284x284Logo.png and b/src-tauri/icons/Square284x284Logo.png differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png index 5941eca06..82044569f 100644 Binary files a/src-tauri/icons/Square30x30Logo.png and b/src-tauri/icons/Square30x30Logo.png differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png index 7b6d52bcc..e3a484542 100644 Binary files a/src-tauri/icons/Square310x310Logo.png and b/src-tauri/icons/Square310x310Logo.png differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png index 134c2d53d..83e6dafb1 100644 Binary files a/src-tauri/icons/Square44x44Logo.png and b/src-tauri/icons/Square44x44Logo.png differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png index 97fd1e67f..c6b3fdfd9 100644 Binary files a/src-tauri/icons/Square71x71Logo.png and b/src-tauri/icons/Square71x71Logo.png differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png index a79ffcf19..2e15dbfea 100644 Binary files a/src-tauri/icons/Square89x89Logo.png and b/src-tauri/icons/Square89x89Logo.png differ diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png index f4a5238d8..918e8a224 100644 Binary files a/src-tauri/icons/StoreLogo.png and b/src-tauri/icons/StoreLogo.png differ diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns index c4992631e..ec13c4172 100644 Binary files a/src-tauri/icons/icon.icns and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico index d78624a1c..e70ec6dd4 100644 Binary files a/src-tauri/icons/icon.ico and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png index f4a5238d8..b6fa95ae8 100644 Binary files a/src-tauri/icons/icon.png and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 4d0177f30..f8a5dbf3b 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,14 +1,18 @@ pub mod auth; +pub mod model; pub mod runtime; pub mod socket; +pub mod tdlib; #[cfg(desktop)] pub mod window; // Re-export all commands for registration pub use auth::*; +pub use model::*; pub use runtime::*; pub use socket::*; +pub use tdlib::*; #[cfg(desktop)] pub use window::*; diff --git a/src-tauri/src/commands/model.rs b/src-tauri/src/commands/model.rs new file mode 100644 index 000000000..336e02961 --- /dev/null +++ b/src-tauri/src/commands/model.rs @@ -0,0 +1,359 @@ +//! Model Tauri Commands +//! +//! These commands provide local LLM access via Tauri's invoke() system. +//! - Desktop (Windows, macOS, Linux): Uses llama.cpp via llama-cpp-2 crate +//! - Android: Uses MediaPipe LLM Inference API via JNI +//! - iOS: Not yet supported + +use serde::{Deserialize, Serialize}; + +// serde_json used for Android MediaPipe commands +#[cfg(target_os = "android")] +use serde_json::{json, Value as JsonValue}; + +/// Model status response for frontend. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ModelStatusResponse { + /// Whether the model API is available on this platform. + pub available: bool, + /// Whether the model is currently loaded in memory. + pub loaded: bool, + /// Whether the model is currently being loaded or downloaded. + pub loading: bool, + /// Download progress (0.0 to 1.0) if downloading. + pub download_progress: Option, + /// Error message if loading failed. + pub error: Option, + /// Model file path if known. + pub model_path: Option, +} + +/// Generation configuration from frontend. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct GenerateRequest { + /// Input prompt. + pub prompt: String, + /// Maximum tokens to generate (default: 2048). + #[serde(default = "default_max_tokens")] + pub max_tokens: u32, + /// Sampling temperature (default: 0.7). + #[serde(default = "default_temperature")] + pub temperature: f32, + /// Top-p sampling (default: 0.9). + #[serde(default = "default_top_p")] + pub top_p: f32, +} + +fn default_max_tokens() -> u32 { + 2048 +} +fn default_temperature() -> f32 { + 0.7 +} +fn default_top_p() -> f32 { + 0.9 +} + +// ============================================================================ +// Android JNI Bridge to MediaPipe LLM +// ============================================================================ + +#[cfg(target_os = "android")] +mod android { + use super::*; + + /// Call a static method on MediaPipeLlmBridge that returns a String. + /// This uses Android's JNI to communicate with the Kotlin MediaPipe wrapper. + pub fn call_mediapipe_method(method: &str) -> Result { + // For now, return a placeholder - actual JNI implementation requires + // access to the JNI environment which needs to be passed from Tauri's + // Android activity context. + // + // TODO: Implement proper JNI bridge using tauri's android module + // The MediaPipeLlmBridge Kotlin object is already set up and ready. + log::warn!( + "MediaPipe LLM method '{}' called - JNI bridge pending implementation", + method + ); + Err(format!( + "MediaPipe LLM JNI bridge not yet implemented for method: {}", + method + )) + } + + /// Call a static method on MediaPipeLlmBridge with a String argument. + pub fn call_mediapipe_method_with_arg(method: &str, arg: &str) -> Result { + log::warn!( + "MediaPipe LLM method '{}' called with arg - JNI bridge pending implementation", + method + ); + let _ = arg; + Err(format!( + "MediaPipe LLM JNI bridge not yet implemented for method: {}", + method + )) + } + + /// Parse a JSON response from MediaPipe bridge. + pub fn parse_mediapipe_response(json: &str) -> Result { + serde_json::from_str(json).map_err(|e| format!("Failed to parse MediaPipe response: {}", e)) + } +} + +/// Check if the local model API is available on this platform. +/// - Desktop: Uses llama.cpp (always available) +/// - Android: Uses MediaPipe LLM (available on supported devices) +/// - iOS: Not yet supported +#[tauri::command] +pub fn model_is_available() -> bool { + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + true + } + + #[cfg(target_os = "android")] + { + // MediaPipe LLM is available on Android + true + } + + #[cfg(target_os = "ios")] + { + false + } +} + +/// Get the current model status. +#[tauri::command] +pub fn model_get_status() -> ModelStatusResponse { + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + let status = crate::services::llama::LLAMA_MANAGER.get_status(); + ModelStatusResponse { + available: status.available, + loaded: status.loaded, + loading: status.loading, + download_progress: status.download_progress, + error: status.error, + model_path: status.model_path, + } + } + + #[cfg(target_os = "android")] + { + // MediaPipe LLM status - JNI bridge pending full implementation + // For now, report available but not loaded + ModelStatusResponse { + available: true, + loaded: false, + loading: false, + download_progress: None, + error: Some("MediaPipe LLM: Download a model to get started".to_string()), + model_path: None, + } + } + + #[cfg(target_os = "ios")] + { + ModelStatusResponse { + available: false, + loaded: false, + loading: false, + download_progress: None, + error: Some("Model not available on iOS".to_string()), + model_path: None, + } + } +} + +/// Ensure the model is loaded (downloads if necessary). +/// This is useful for preloading the model. +#[tauri::command] +pub async fn model_ensure_loaded() -> Result<(), String> { + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + crate::services::llama::LLAMA_MANAGER.ensure_loaded().await + } + + #[cfg(target_os = "android")] + { + // MediaPipe requires manual model download + // TODO: Implement model download via MediaPipeLlmBridge.loadModel() + Err("MediaPipe LLM: Please download a model first using the model manager".to_string()) + } + + #[cfg(target_os = "ios")] + { + Err("Model not available on iOS".to_string()) + } +} + +/// Generate text from a prompt. +#[tauri::command] +pub async fn model_generate(request: GenerateRequest) -> Result { + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + use crate::services::llama::GenerateConfig; + + let config = GenerateConfig { + max_tokens: request.max_tokens, + temperature: request.temperature, + top_p: request.top_p, + }; + + crate::services::llama::LLAMA_MANAGER + .generate(&request.prompt, config) + .await + } + + #[cfg(target_os = "android")] + { + // TODO: Call MediaPipeLlmBridge.generateResponse() via JNI + // The Kotlin bridge is ready, just needs JNI wiring + let _ = request; + Err("MediaPipe LLM generation pending JNI implementation".to_string()) + } + + #[cfg(target_os = "ios")] + { + let _ = request; + Err("Model not available on iOS".to_string()) + } +} + +/// Summarize text using a built-in prompt. +#[tauri::command] +pub async fn model_summarize(text: String, max_tokens: Option) -> Result { + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + let tokens = max_tokens.unwrap_or(500); + crate::services::llama::LLAMA_MANAGER + .summarize(&text, tokens) + .await + } + + #[cfg(target_os = "android")] + { + // TODO: Implement summarization via MediaPipe + let _ = (text, max_tokens); + Err("MediaPipe LLM summarization pending JNI implementation".to_string()) + } + + #[cfg(target_os = "ios")] + { + let _ = (text, max_tokens); + Err("Model not available on iOS".to_string()) + } +} + +/// Unload the model from memory to free resources. +#[tauri::command] +pub fn model_unload() -> Result<(), String> { + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + crate::services::llama::LLAMA_MANAGER.unload(); + Ok(()) + } + + #[cfg(target_os = "android")] + { + // TODO: Call MediaPipeLlmBridge.unloadModel() via JNI + Ok(()) + } + + #[cfg(target_os = "ios")] + { + Err("Model not available on iOS".to_string()) + } +} + +// ============================================================================ +// Android-specific MediaPipe LLM Commands +// ============================================================================ + +/// Get recommended models for download (Android only). +/// Returns a list of MediaPipe-compatible models with download URLs. +#[tauri::command] +pub fn model_get_recommended() -> Result { + #[cfg(target_os = "android")] + { + // Return hardcoded recommended models for now + // TODO: Call MediaPipeLlmBridge.getRecommendedModels() via JNI + let models = serde_json::json!({ + "success": true, + "models": [ + { + "name": "Gemma 3 1B (4-bit)", + "id": "gemma-3-1b-it-int4", + "size_mb": 550, + "description": "Compact, fast model suitable for most devices", + "url": "https://huggingface.co/litert-community/Gemma3-1B-IT/resolve/main/gemma3-1b-it-int4.task" + }, + { + "name": "Gemma 3n E2B (4-bit)", + "id": "gemma-3n-e2b-it-int4", + "size_mb": 1400, + "description": "Effective 2B model with multimodal support", + "url": "https://huggingface.co/litert-community/Gemma3n-E2B-IT/resolve/main/gemma3n-e2b-it-int4.task" + }, + { + "name": "Gemma 3n E4B (4-bit)", + "id": "gemma-3n-e4b-it-int4", + "size_mb": 2800, + "description": "Effective 4B model, best quality, requires high-end device", + "url": "https://huggingface.co/litert-community/Gemma3n-E4B-IT/resolve/main/gemma3n-e4b-it-int4.task" + } + ] + }); + Ok(models.to_string()) + } + + #[cfg(not(target_os = "android"))] + { + Err("This command is only available on Android".to_string()) + } +} + +/// List downloaded models (Android only). +#[tauri::command] +pub fn model_list_downloaded() -> Result { + #[cfg(target_os = "android")] + { + // TODO: Call MediaPipeLlmBridge.listModels() via JNI + let result = serde_json::json!({ + "success": true, + "models": [], + "models_dir": "/data/data/com.alphahuman.app/files/models" + }); + Ok(result.to_string()) + } + + #[cfg(not(target_os = "android"))] + { + Err("This command is only available on Android".to_string()) + } +} + +/// Load a specific model by path (Android only). +#[tauri::command] +pub fn model_load_path( + model_path: String, + max_tokens: Option, + top_k: Option, + temperature: Option, +) -> Result { + #[cfg(target_os = "android")] + { + // TODO: Call MediaPipeLlmBridge.loadModel() via JNI + let _ = (model_path, max_tokens, top_k, temperature); + Err("MediaPipe model loading pending JNI implementation".to_string()) + } + + #[cfg(not(target_os = "android"))] + { + let _ = (model_path, max_tokens, top_k, temperature); + Err("This command is only available on Android".to_string()) + } +} diff --git a/src-tauri/src/commands/tdlib.rs b/src-tauri/src/commands/tdlib.rs new file mode 100644 index 000000000..17cb38973 --- /dev/null +++ b/src-tauri/src/commands/tdlib.rs @@ -0,0 +1,141 @@ +//! TDLib Tauri Commands +//! +//! These commands provide TDLib access via Tauri's invoke() system. +//! On desktop, they delegate to the TdLibManager singleton. +//! On Android, they use JNI to call the TDLib Android library. + +use serde_json::Value; + +/// Create a TDLib client with the given data directory. +/// +/// # Arguments +/// * `data_dir` - Path to store TDLib data files +/// +/// # Returns +/// Client ID (always 1 for singleton pattern) +#[tauri::command] +pub async fn tdlib_create_client(data_dir: String) -> Result { + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + use crate::services::tdlib::TDLIB_MANAGER; + let path = std::path::PathBuf::from(data_dir); + TDLIB_MANAGER.create_client(path) + } + + #[cfg(target_os = "android")] + { + // Android: Use JNI bridge (to be implemented) + log::info!("[tdlib-android] Creating client with data_dir: {}", data_dir); + // TODO: Call TdLibBridge.createClient() via JNI + Err("TDLib Android bridge not yet implemented".to_string()) + } + + #[cfg(target_os = "ios")] + { + let _ = data_dir; + Err("TDLib is not supported on iOS".to_string()) + } +} + +/// Send a request to TDLib and wait for the response. +/// +/// # Arguments +/// * `request` - TDLib API request object with @type field +/// +/// # Returns +/// TDLib response object +#[tauri::command] +pub async fn tdlib_send(request: Value) -> Result { + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + use crate::services::tdlib::TDLIB_MANAGER; + TDLIB_MANAGER.send(request).await + } + + #[cfg(target_os = "android")] + { + // Android: Use JNI bridge (to be implemented) + log::info!("[tdlib-android] Sending request: {:?}", request); + // TODO: Call TdLibBridge.send() via JNI + Err("TDLib Android bridge not yet implemented".to_string()) + } + + #[cfg(target_os = "ios")] + { + let _ = request; + Err("TDLib is not supported on iOS".to_string()) + } +} + +/// Receive the next update from TDLib (with timeout). +/// +/// # Arguments +/// * `timeout_ms` - Timeout in milliseconds +/// +/// # Returns +/// Update object or null if timeout +#[tauri::command] +pub async fn tdlib_receive(timeout_ms: u32) -> Result, String> { + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + use crate::services::tdlib::TDLIB_MANAGER; + Ok(TDLIB_MANAGER.receive(timeout_ms).await) + } + + #[cfg(target_os = "android")] + { + // Android: Use JNI bridge (to be implemented) + log::debug!("[tdlib-android] Receiving with timeout: {}ms", timeout_ms); + // TODO: Call TdLibBridge.receive() via JNI + Err("TDLib Android bridge not yet implemented".to_string()) + } + + #[cfg(target_os = "ios")] + { + let _ = timeout_ms; + Err("TDLib is not supported on iOS".to_string()) + } +} + +/// Destroy the TDLib client and clean up resources. +#[tauri::command] +pub async fn tdlib_destroy() -> Result<(), String> { + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + use crate::services::tdlib::TDLIB_MANAGER; + TDLIB_MANAGER.destroy().await + } + + #[cfg(target_os = "android")] + { + // Android: Use JNI bridge (to be implemented) + log::info!("[tdlib-android] Destroying client"); + // TODO: Call TdLibBridge.destroy() via JNI + Err("TDLib Android bridge not yet implemented".to_string()) + } + + #[cfg(target_os = "ios")] + { + Err("TDLib is not supported on iOS".to_string()) + } +} + +/// Check if TDLib is available on the current platform. +#[tauri::command] +pub fn tdlib_is_available() -> bool { + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + true + } + + #[cfg(target_os = "android")] + { + // Android: TDLib is available via JNI bridge + true + } + + #[cfg(target_os = "ios")] + { + false + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f4ef24239..b1f5bfd80 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -35,6 +35,98 @@ fn greet(name: &str) -> String { format!("Hello, {}! You've been greeted from Rust!", name) } +// Macro to define common handlers shared across all platforms +macro_rules! common_handlers { + () => { + // Demo + greet, + // Auth commands + get_auth_state, + get_session_token, + get_current_user, + is_authenticated, + logout, + store_session, + // Socket commands + socket_connect, + socket_disconnect, + get_socket_state, + is_socket_connected, + report_socket_connected, + report_socket_disconnected, + report_socket_error, + update_socket_status, + // AI encryption commands + ai_init_encryption, + ai_encrypt, + ai_decrypt, + // AI memory filesystem commands + ai_memory_init, + ai_memory_upsert_file, + ai_memory_get_file, + ai_memory_upsert_chunk, + ai_memory_delete_chunks_by_path, + ai_memory_fts_search, + ai_memory_get_chunks, + ai_memory_get_all_embeddings, + ai_memory_cache_embedding, + ai_memory_get_cached_embedding, + ai_memory_set_meta, + ai_memory_get_meta, + // AI session commands + ai_sessions_init, + ai_sessions_load_index, + ai_sessions_update_index, + ai_sessions_append_transcript, + ai_sessions_read_transcript, + ai_sessions_delete, + ai_sessions_list, + ai_read_memory_file, + ai_write_memory_file, + ai_list_memory_files, + // V8 runtime commands + runtime_discover_skills, + runtime_list_skills, + runtime_start_skill, + runtime_stop_skill, + runtime_get_skill_state, + runtime_call_tool, + runtime_all_tools, + runtime_broadcast_event, + // V8 runtime enable/disable + KV commands + runtime_enable_skill, + runtime_disable_skill, + runtime_is_skill_enabled, + runtime_get_skill_preferences, + runtime_skill_kv_get, + runtime_skill_kv_set, + // V8 runtime JSON-RPC + data commands + runtime_rpc, + runtime_skill_data_read, + runtime_skill_data_write, + runtime_skill_data_dir, + // Socket.io commands (Rust-native persistent connection) + runtime_socket_connect, + runtime_socket_disconnect, + runtime_socket_state, + runtime_socket_emit, + }; +} + +// Macro to define desktop-only window handlers +macro_rules! desktop_window_handlers { + () => { + show_window, + hide_window, + toggle_window, + is_window_visible, + minimize_window, + maximize_window, + close_window, + set_window_title, + }; +} + // Helper function to show the window (used by tray and macOS reopen) #[cfg(desktop)] fn show_main_window(app: &AppHandle) { @@ -116,7 +208,14 @@ fn setup_tray(app: &AppHandle) -> Result<(), Box> { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { // Load .env file (silently ignore if missing — production won't have one) - let _ = dotenvy::dotenv(); + // Try current directory first, then parent (for when running from src-tauri) + if dotenvy::dotenv().is_err() { + if let Ok(cwd) = std::env::current_dir() { + if let Some(parent) = cwd.parent() { + let _ = dotenvy::from_path(parent.join(".env")); + } + } + } // Initialize platform-appropriate logger #[cfg(target_os = "android")] @@ -126,8 +225,6 @@ pub fn run() { .with_max_level(log::LevelFilter::Debug) .with_tag("AlphaHuman"), ); - // Ensure vendored OpenSSL is initialized before any TLS usage - openssl::init(); } #[cfg(not(target_os = "android"))] { @@ -137,7 +234,8 @@ pub fn run() { let mut builder = tauri::Builder::default() // Plugins .plugin(tauri_plugin_opener::init()) - .plugin(tauri_plugin_deep_link::init()); + .plugin(tauri_plugin_deep_link::init()) + .plugin(tauri_plugin_os::init()); // Add desktop-only plugins (autostart, notification) #[cfg(desktop)] @@ -205,6 +303,11 @@ pub fn run() { }); let skills_data_dir = data_dir.join("skills"); + // Initialize local model service (for skills to use) + let model_dir = data_dir.join("models"); + services::llama::LLAMA_MANAGER.set_data_dir(model_dir); + log::info!("[runtime] Local model service initialized"); + match runtime::v8_engine::RuntimeEngine::new(skills_data_dir) { Ok(engine) => { engine.set_app_handle(app.handle().clone()); @@ -235,9 +338,14 @@ pub fn run() { } } - #[cfg(any(target_os = "android", target_os = "ios"))] + #[cfg(target_os = "android")] { - log::info!("[runtime] V8 runtime disabled on mobile platform"); + log::info!("[runtime] V8 runtime and local model disabled on Android"); + } + + #[cfg(target_os = "ios")] + { + log::info!("[runtime] V8 runtime and local model disabled on iOS"); } // Store SocketManager as Tauri state @@ -261,21 +369,19 @@ pub fn run() { Ok(()) }) // Register all commands - // Note: Window commands are desktop-only (show/hide/minimize/etc. not available on mobile) + // Common handlers are defined via macros above, conditionally include desktop window handlers .invoke_handler({ #[cfg(desktop)] { tauri::generate_handler![ - // Demo + // Common handlers (expanded from common_handlers! macro) greet, - // Auth commands get_auth_state, get_session_token, get_current_user, is_authenticated, logout, store_session, - // Socket commands socket_connect, socket_disconnect, get_socket_state, @@ -284,7 +390,7 @@ pub fn run() { report_socket_disconnected, report_socket_error, update_socket_status, - // Window commands (desktop only) + // Desktop-only window handlers (expanded from desktop_window_handlers! macro) show_window, hide_window, toggle_window, @@ -347,21 +453,36 @@ pub fn run() { runtime_socket_disconnect, runtime_socket_state, runtime_socket_emit, + // TDLib commands (native Telegram library) + tdlib_create_client, + tdlib_send, + tdlib_receive, + tdlib_destroy, + tdlib_is_available, + // Model commands (local LLM) + model_is_available, + model_get_status, + model_ensure_loaded, + model_generate, + model_summarize, + model_unload, + // Android MediaPipe LLM commands + model_get_recommended, + model_list_downloaded, + model_load_path, ] } #[cfg(not(desktop))] { tauri::generate_handler![ - // Demo + // Common handlers (expanded from common_handlers! macro) greet, - // Auth commands get_auth_state, get_session_token, get_current_user, is_authenticated, logout, store_session, - // Socket commands socket_connect, socket_disconnect, get_socket_state, @@ -424,6 +545,23 @@ pub fn run() { runtime_socket_disconnect, runtime_socket_state, runtime_socket_emit, + // TDLib commands (native Telegram library) + tdlib_create_client, + tdlib_send, + tdlib_receive, + tdlib_destroy, + tdlib_is_available, + // Model commands (local LLM / MediaPipe) + model_is_available, + model_get_status, + model_ensure_loaded, + model_generate, + model_summarize, + model_unload, + // Android MediaPipe LLM commands + model_get_recommended, + model_list_downloaded, + model_load_path, ] } }) diff --git a/src-tauri/src/runtime/skill_registry.rs b/src-tauri/src/runtime/skill_registry.rs index c63e057bb..96dc5e67a 100644 --- a/src-tauri/src/runtime/skill_registry.rs +++ b/src-tauri/src/runtime/skill_registry.rs @@ -45,6 +45,7 @@ impl SkillRegistry { state: Arc>, task_handle: tokio::task::JoinHandle<()>, ) { + log::info!("[runtime] registering skill '{}'", skill_id); self.skills.write().insert( skill_id.to_string(), RegistryEntry { @@ -228,6 +229,7 @@ impl SkillRegistry { /// Send a message to a specific skill's message loop. /// Returns an error if the skill is not registered or the channel is full. pub fn send_message(&self, skill_id: &str, msg: SkillMessage) -> Result<(), String> { + log::info!("[runtime] sending message to '{}': {:?}", skill_id, msg); let sender = { let skills = self.skills.read(); let entry = skills @@ -236,9 +238,17 @@ impl SkillRegistry { entry.sender.clone() }; - sender - .try_send(msg) - .map_err(|e| format!("Failed to send message to skill '{}': {e}", skill_id)) + match sender.try_send(msg) { + Ok(()) => { + log::info!("[runtime] Successfully sent message to skill '{}'", skill_id); + Ok(()) + } + Err(e) => { + let error_msg = format!("Failed to send message to skill '{}': {e}", skill_id); + log::error!("[runtime] {}", error_msg); + Err(error_msg) + } + } } } diff --git a/src-tauri/src/runtime/socket_manager.rs b/src-tauri/src/runtime/socket_manager.rs index 9a9e5ea07..f2f043738 100644 --- a/src-tauri/src/runtime/socket_manager.rs +++ b/src-tauri/src/runtime/socket_manager.rs @@ -8,20 +8,28 @@ //! - Non-MCP server events forwarded to running skills AND to the frontend //! - Connection state emitted to the frontend via Tauri events //! - Automatic reconnection with exponential backoff +//! +//! Note: On Android, the Rust Socket.io client is not available due to +//! native-tls/OpenSSL build complexity. The frontend uses its own Socket.io +//! connection instead. use std::sync::Arc; -use futures_util::FutureExt; use parking_lot::RwLock; -use rust_socketio::{ - asynchronous::{Client, ClientBuilder}, - Event, Payload, -}; use serde_json::json; use tauri::{AppHandle, Emitter}; use crate::models::socket::{ConnectionStatus, SocketState}; +// rust_socketio only available on non-Android platforms +#[cfg(not(target_os = "android"))] +use futures_util::FutureExt; +#[cfg(not(target_os = "android"))] +use rust_socketio::{ + asynchronous::{Client, ClientBuilder}, + Event, Payload, +}; + // SkillRegistry only available on desktop (V8/deno_core required) #[cfg(not(any(target_os = "android", target_os = "ios")))] use crate::runtime::skill_registry::SkillRegistry; @@ -57,9 +65,14 @@ struct SharedState { /// app backgrounding. On desktop, handles MCP `listTools`/`toolCall` directly /// via the [`SkillRegistry`], and forwards other server events to running /// skills and to the frontend. +/// +/// Note: On Android, this is a stub implementation. The frontend uses its own +/// Socket.io connection instead. pub struct SocketManager { shared: Arc, /// The active `rust_socketio` async client (if connected). + /// Not available on Android due to native-tls/OpenSSL build complexity. + #[cfg(not(target_os = "android"))] client: tokio::sync::Mutex>, } @@ -73,6 +86,7 @@ impl SocketManager { status: RwLock::new(ConnectionStatus::Disconnected), socket_id: RwLock::new(None), }), + #[cfg(not(target_os = "android"))] client: tokio::sync::Mutex::new(None), } } @@ -135,6 +149,7 @@ impl SocketManager { .auth(json!({"token": token})) .reconnect(true) .max_reconnect_attempts(0) // unlimited + .transport_type(rust_socketio::TransportType::WebsocketUpgrade) // --- Connection established --- .on("connect", move |_payload, _client: Client| { let shared = Arc::clone(&s_connect); @@ -262,14 +277,34 @@ impl SocketManager { Ok(()) } - /// Connect to the server with the given URL and auth token (mobile version). + /// Connect to the server with the given URL and auth token (Android stub). + /// On Android, the Rust Socket.io client is not available due to + /// native-tls/OpenSSL build complexity. The frontend should use its own + /// Socket.io connection instead. + #[cfg(target_os = "android")] + pub async fn connect(&self, url: &str, _token: &str) -> Result<(), String> { + log::info!( + "[socket-mgr] Android stub - Rust Socket.io not available. URL: {}", + url + ); + log::info!("[socket-mgr] Frontend should use its own Socket.io connection on Android."); + + // Mark as disconnected - frontend handles its own connection + *self.shared.status.write() = ConnectionStatus::Disconnected; + Self::emit_state_change(&self.shared); + + // Return Ok so the app doesn't fail - socket is handled by frontend on Android + Ok(()) + } + + /// Connect to the server with the given URL and auth token (iOS version). /// MCP skill handlers are not available on mobile. - #[cfg(any(target_os = "android", target_os = "ios"))] + #[cfg(target_os = "ios")] pub async fn connect(&self, url: &str, token: &str) -> Result<(), String> { // Disconnect existing connection first self.disconnect().await?; - log::info!("[socket-mgr] Connecting to {} (mobile)", url); + log::info!("[socket-mgr] Connecting to {} (iOS)", url); // Update status *self.shared.status.write() = ConnectionStatus::Connecting; @@ -288,6 +323,7 @@ impl SocketManager { .auth(json!({"token": token})) .reconnect(true) .max_reconnect_attempts(0) // unlimited + .transport_type(rust_socketio::TransportType::WebsocketUpgrade) // --- Connection established --- .on("connect", move |_payload, _client: Client| { let shared = Arc::clone(&s_connect); @@ -390,6 +426,7 @@ impl SocketManager { } /// Disconnect from the server. + #[cfg(not(target_os = "android"))] pub async fn disconnect(&self) -> Result<(), String> { let mut client_guard = self.client.lock().await; if let Some(client) = client_guard.take() { @@ -401,7 +438,17 @@ impl SocketManager { Ok(()) } + /// Disconnect from the server (Android stub). + #[cfg(target_os = "android")] + pub async fn disconnect(&self) -> Result<(), String> { + *self.shared.status.write() = ConnectionStatus::Disconnected; + *self.shared.socket_id.write() = None; + Self::emit_state_change(&self.shared); + Ok(()) + } + /// Emit an event through the Rust socket to the server. + #[cfg(not(target_os = "android"))] pub async fn emit(&self, event: &str, data: serde_json::Value) -> Result<(), String> { let client_guard = self.client.lock().await; if let Some(ref client) = *client_guard { @@ -415,6 +462,12 @@ impl SocketManager { } } + /// Emit an event through the Rust socket to the server (Android stub). + #[cfg(target_os = "android")] + pub async fn emit(&self, _event: &str, _data: serde_json::Value) -> Result<(), String> { + Err("Rust Socket.io not available on Android. Use frontend socket.".to_string()) + } + // ----------------------------------------------------------------------- // Tauri event helpers // ----------------------------------------------------------------------- @@ -600,10 +653,11 @@ impl Default for SocketManager { } // --------------------------------------------------------------------------- -// Payload helpers +// Payload helpers (not needed on Android) // --------------------------------------------------------------------------- /// Extract the first JSON value from a Socket.io payload. +#[cfg(not(target_os = "android"))] fn extract_json(payload: &Payload) -> Option { match payload { Payload::Text(values) => values.first().cloned(), @@ -613,6 +667,7 @@ fn extract_json(payload: &Payload) -> Option { } /// Extract a human-readable string from a Socket.io payload. +#[cfg(not(target_os = "android"))] fn extract_text(payload: &Payload) -> String { match payload { Payload::Text(values) => values diff --git a/src-tauri/src/runtime/v8_engine.rs b/src-tauri/src/runtime/v8_engine.rs index 36372dc3c..16110c347 100644 --- a/src-tauri/src/runtime/v8_engine.rs +++ b/src-tauri/src/runtime/v8_engine.rs @@ -108,12 +108,6 @@ impl RuntimeEngine { /// Discover all JavaScript skills from the skills directory. pub async fn discover_skills(&self) -> Result, String> { let skills_dir = self.get_skills_source_dir()?; - log::info!( - "[runtime] Discovering skills in: {:?} (exists={})", - skills_dir, - skills_dir.exists() - ); - if !skills_dir.exists() { return Ok(Vec::new()); } @@ -125,10 +119,6 @@ impl RuntimeEngine { while let Ok(Some(entry)) = entries.next_entry().await { let path = entry.path(); - log::info!( - "[runtime] Found entry: {:?}", - path - ); if path.is_dir() { let manifest_path = path.join("manifest.json"); if manifest_path.exists() { @@ -202,7 +192,12 @@ impl RuntimeEngine { let data_dir = self.skills_data_dir.join(skill_id); // Create the V8 skill instance + log::info!("[runtime] Creating V8 skill instance for '{}'", skill_id); + log::info!("[runtime] Config: {:?}", config); + log::info!("[runtime] Skill dir: {:?}", skill_dir); + log::info!("[runtime] Data dir: {:?}", data_dir); let (instance, rx) = V8SkillInstance::new(config.clone(), skill_dir, data_dir.clone()); + log::info!("[runtime] V8 skill instance created for '{}'", skill_id); // Bundle bridge dependencies let deps = BridgeDeps { @@ -422,6 +417,7 @@ impl RuntimeEngine { "skill/load" => Ok(serde_json::json!({ "ok": true })), "setup/start" => { + log::info!("[runtime] setup/start for '{}'", skill_id); let (tx, rx) = tokio::sync::oneshot::channel(); self.registry .send_message(skill_id, SkillMessage::SetupStart { reply: tx })?; diff --git a/src-tauri/src/runtime/v8_skill_instance.rs b/src-tauri/src/runtime/v8_skill_instance.rs index eab65a016..817a81733 100644 --- a/src-tauri/src/runtime/v8_skill_instance.rs +++ b/src-tauri/src/runtime/v8_skill_instance.rs @@ -4,12 +4,13 @@ //! with: //! - A scoped SQLite database //! - Bridge globals (db, store, net, platform, console) -//! - A message loop driven by crossbeam channels -//! - Lifecycle hooks: init() -> start() -> [message loop] -> stop() +//! - An async event loop that drives timers, promises, and handles messages +//! - Lifecycle hooks: init() -> start() -> [event loop] -> stop() use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; +use std::time::Duration; use deno_core::{v8, JsRuntime, PollEventLoopOptions, RuntimeOptions}; use parking_lot::RwLock; @@ -192,126 +193,238 @@ impl V8SkillInstance { // Extract tool definitions extract_tools(&mut runtime, &state); - // Call init() - if let Err(e) = call_lifecycle_fn_sync(&mut runtime, "init") { - let mut s = state.write(); - s.status = SkillStatus::Error; - s.error = Some(format!("init() failed: {e}")); - log::error!("[skill:{}] init() failed: {e}", config.skill_id); - return; - } + // Create a tokio runtime for this thread FIRST - all async ops must run inside it + // This is critical because deno_unsync requires CurrentThread runtime for async ops + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("Failed to create tokio runtime"); - // Call start() - if let Err(e) = call_lifecycle_fn_sync(&mut runtime, "start") { - let mut s = state.write(); - s.status = SkillStatus::Error; - s.error = Some(format!("start() failed: {e}")); - log::error!("[skill:{}] start() failed: {e}", config.skill_id); - return; - } - - // Mark as running - state.write().status = SkillStatus::Running; - log::info!("[skill:{}] Running (V8)", config.skill_id); - - // Message loop - use blocking_recv since we're on a dedicated thread - while let Some(msg) = rx.blocking_recv() { - match msg { - SkillMessage::CallTool { - tool_name, - arguments, - reply, - } => { - let result = handle_tool_call_sync(&mut runtime, &tool_name, arguments); - let _ = reply.send(result); - } - SkillMessage::ServerEvent { event, data } => { - let _ = handle_server_event_sync(&mut runtime, &event, data); - } - SkillMessage::CronTrigger { schedule_id } => { - let _ = handle_cron_trigger_sync(&mut runtime, &schedule_id); - } - SkillMessage::Stop { reply } => { - let _ = call_lifecycle_fn_sync(&mut runtime, "stop"); - state.write().status = SkillStatus::Stopped; - log::info!("[skill:{}] Stopped", config.skill_id); - let _ = reply.send(()); - break; - } - SkillMessage::SetupStart { reply } => { - let result = handle_js_call_sync(&mut runtime, "onSetupStart", "{}"); - let _ = reply.send(result); - } - SkillMessage::SetupSubmit { - step_id, - values, - reply, - } => { - let args = serde_json::json!({ - "stepId": step_id, - "values": values, - }); - let result = - handle_js_call_sync(&mut runtime, "onSetupSubmit", &args.to_string()); - let _ = reply.send(result); - } - SkillMessage::SetupCancel { reply } => { - let result = handle_js_void_call_sync(&mut runtime, "onSetupCancel", "{}"); - let _ = reply.send(result); - } - SkillMessage::ListOptions { reply } => { - let result = handle_js_call_sync(&mut runtime, "onListOptions", "{}"); - let _ = reply.send(result); - } - SkillMessage::SetOption { name, value, reply } => { - let args = serde_json::json!({ - "name": name, - "value": value, - }); - let result = - handle_js_void_call_sync(&mut runtime, "onSetOption", &args.to_string()); - let _ = reply.send(result); - } - SkillMessage::SessionStart { session_id, reply } => { - let args = serde_json::json!({ "sessionId": session_id }); - let result = - handle_js_void_call_sync(&mut runtime, "onSessionStart", &args.to_string()); - let _ = reply.send(result); - } - SkillMessage::SessionEnd { session_id, reply } => { - let args = serde_json::json!({ "sessionId": session_id }); - let result = - handle_js_void_call_sync(&mut runtime, "onSessionEnd", &args.to_string()); - let _ = reply.send(result); - } - SkillMessage::Tick { reply } => { - let result = handle_js_void_call_sync(&mut runtime, "onTick", "{}"); - let _ = reply.send(result); - } - SkillMessage::Rpc { - method, - params, - reply, - } => { - let args = serde_json::json!({ - "method": method, - "params": params, - }); - let result = - handle_js_call_sync(&mut runtime, "onRpc", &args.to_string()); - let _ = reply.send(result); - } + // Run the entire lifecycle inside the CurrentThread runtime + rt.block_on(async { + // Call init() + if let Err(e) = call_lifecycle_fn_async(&mut runtime, "init").await { + let mut s = state.write(); + s.status = SkillStatus::Error; + s.error = Some(format!("init() failed: {e}")); + log::error!("[skill:{}] init() failed: {e}", config.skill_id); + return; } - } + + // Call start() + if let Err(e) = call_lifecycle_fn_async(&mut runtime, "start").await { + let mut s = state.write(); + s.status = SkillStatus::Error; + s.error = Some(format!("start() failed: {e}")); + log::error!("[skill:{}] start() failed: {e}", config.skill_id); + return; + } + + // Mark as running + state.write().status = SkillStatus::Running; + log::info!("[skill:{}] Running (V8)", config.skill_id); + + // Run the event loop + run_event_loop(&mut runtime, &mut rx, &state, &config.skill_id).await; + }); }) } } -/// Extract tool definitions from globalThis.tools. +// ============================================================================ +// Event Loop +// ============================================================================ + +/// The main event loop that drives the V8 runtime. +/// This continuously: +/// 1. Polls for ready timers and fires their callbacks +/// 2. Checks for incoming messages (non-blocking) +/// 3. Runs the V8 event loop for promises/async ops +/// 4. Sleeps efficiently when idle +async fn run_event_loop( + runtime: &mut JsRuntime, + rx: &mut mpsc::Receiver, + state: &Arc>, + skill_id: &str, +) { + // Maximum sleep duration when no timers are pending + const MAX_IDLE_SLEEP: Duration = Duration::from_millis(100); + // Minimum sleep to prevent busy-spinning + const MIN_SLEEP: Duration = Duration::from_millis(1); + + loop { + // 1. Poll and fire ready timers + let ready_timers = { + let op_state = runtime.op_state(); + let mut state_ref = op_state.borrow_mut(); + let (ready, _next) = ops::poll_timers(&mut state_ref); + ready + }; + + // Fire timer callbacks in JavaScript + for timer_id in ready_timers { + fire_timer_callback(runtime, timer_id); + } + + // 2. Check for incoming messages (non-blocking) + match rx.try_recv() { + Ok(msg) => { + let should_stop = handle_message(runtime, msg, state, skill_id).await; + if should_stop { + break; + } + } + Err(mpsc::error::TryRecvError::Empty) => { + // No message - that's fine + } + Err(mpsc::error::TryRecvError::Disconnected) => { + // Channel closed, exit + log::info!("[skill:{}] Message channel disconnected, stopping", skill_id); + break; + } + } + + // 3. Run the V8 event loop (processes promises, async ops, etc.) + // Use poll mode - returns immediately if nothing to do + let poll_result = runtime + .run_event_loop(PollEventLoopOptions { + wait_for_inspector: false, + pump_v8_message_loop: true, + }) + .await; + + if let Err(e) = poll_result { + log::error!("[skill:{}] Event loop error: {}", skill_id, e); + // Don't break - try to continue + } + + // 4. Calculate sleep duration based on next timer + let sleep_duration = { + let op_state = runtime.op_state(); + let mut state_ref = op_state.borrow_mut(); + let (_, next_timer) = ops::poll_timers(&mut state_ref); + match next_timer { + Some(d) if d < MIN_SLEEP => MIN_SLEEP, + Some(d) if d > MAX_IDLE_SLEEP => MAX_IDLE_SLEEP, + Some(d) => d, + None => MAX_IDLE_SLEEP, + } + }; + + // Sleep efficiently - this yields the thread when no work is needed + tokio::time::sleep(sleep_duration).await; + } +} + +/// Fire a timer callback in JavaScript. +fn fire_timer_callback(runtime: &mut JsRuntime, timer_id: u32) { + let code = format!("globalThis.__handleTimer({});", timer_id); + if let Err(e) = runtime.execute_script("", code) { + log::error!("[timer] Callback for timer {} failed: {}", timer_id, e); + } +} + +/// Handle a single message from the channel. +/// Returns true if the skill should stop. +async fn handle_message( + runtime: &mut JsRuntime, + msg: SkillMessage, + state: &Arc>, + skill_id: &str, +) -> bool { + match msg { + SkillMessage::CallTool { + tool_name, + arguments, + reply, + } => { + let result = handle_tool_call_sync(runtime, &tool_name, arguments); + let _ = reply.send(result); + } + SkillMessage::ServerEvent { event, data } => { + let _ = handle_server_event_sync(runtime, &event, data); + } + SkillMessage::CronTrigger { schedule_id } => { + let _ = handle_cron_trigger_sync(runtime, &schedule_id); + } + SkillMessage::Stop { reply } => { + let _ = call_lifecycle_fn_sync(runtime, "stop"); + state.write().status = SkillStatus::Stopped; + log::info!("[skill:{}] Stopped", skill_id); + let _ = reply.send(()); + return true; // Signal to stop + } + SkillMessage::SetupStart { reply } => { + let result = handle_js_call_sync(runtime, "onSetupStart", "{}"); + let _ = reply.send(result); + } + SkillMessage::SetupSubmit { + step_id, + values, + reply, + } => { + let args = serde_json::json!({ + "stepId": step_id, + "values": values, + }); + let result = handle_js_call_sync(runtime, "onSetupSubmit", &args.to_string()); + let _ = reply.send(result); + } + SkillMessage::SetupCancel { reply } => { + let result = handle_js_void_call_sync(runtime, "onSetupCancel", "{}"); + let _ = reply.send(result); + } + SkillMessage::ListOptions { reply } => { + let result = handle_js_call_sync(runtime, "onListOptions", "{}"); + let _ = reply.send(result); + } + SkillMessage::SetOption { name, value, reply } => { + let args = serde_json::json!({ + "name": name, + "value": value, + }); + let result = handle_js_void_call_sync(runtime, "onSetOption", &args.to_string()); + let _ = reply.send(result); + } + SkillMessage::SessionStart { session_id, reply } => { + let args = serde_json::json!({ "sessionId": session_id }); + let result = handle_js_void_call_sync(runtime, "onSessionStart", &args.to_string()); + let _ = reply.send(result); + } + SkillMessage::SessionEnd { session_id, reply } => { + let args = serde_json::json!({ "sessionId": session_id }); + let result = handle_js_void_call_sync(runtime, "onSessionEnd", &args.to_string()); + let _ = reply.send(result); + } + SkillMessage::Tick { reply } => { + let result = handle_js_void_call_sync(runtime, "onTick", "{}"); + let _ = reply.send(result); + } + SkillMessage::Rpc { + method, + params, + reply, + } => { + let args = serde_json::json!({ + "method": method, + "params": params, + }); + let result = handle_js_call_sync(runtime, "onRpc", &args.to_string()); + let _ = reply.send(result); + } + } + false // Don't stop +} + +/// Extract tool definitions from skill.tools (supports both globalThis.__skill.default and globalThis.tools). fn extract_tools(runtime: &mut JsRuntime, state: &Arc>) { let code = r#" (function() { - var tools = globalThis.tools || []; + // Try to get skill from bundled export first, then fall back to globalThis.tools + var skill = globalThis.__skill && globalThis.__skill.default + ? globalThis.__skill.default + : (globalThis.__skill || null); + var tools = (skill && skill.tools) || globalThis.tools || []; return JSON.stringify(tools.map(function(t) { return { name: t.name || "", @@ -335,11 +448,51 @@ fn extract_tools(runtime: &mut JsRuntime, state: &Arc>) { } } -/// Call a global lifecycle function synchronously. +/// Call a lifecycle function on the skill object asynchronously. +/// This version runs inside the skill's CurrentThread runtime - no nested runtime creation. +/// Looks for the skill at globalThis.__skill.default first, then falls back to globalThis. +/// +/// Note: This does NOT wait for the event loop to complete, because lifecycle functions +/// may start async operations (like update loops) that run indefinitely. The main event +/// loop will process pending async work after init/start complete. +async fn call_lifecycle_fn_async(runtime: &mut JsRuntime, name: &str) -> Result<(), String> { + let code = format!( + r#"(function() {{ + var skill = globalThis.__skill && globalThis.__skill.default + ? globalThis.__skill.default + : (globalThis.__skill || globalThis); + if (typeof skill.{name} === 'function') {{ + skill.{name}(); + }} else if (typeof globalThis.{name} === 'function') {{ + globalThis.{name}(); + }} + }})()"# + ); + + runtime + .execute_script("", code) + .map_err(|e| format!("{name}() failed: {e}"))?; + + // Don't wait for event loop here - the main event loop will handle pending async work. + // This is important because lifecycle functions may start long-running async operations + // (like TDLib update loops) that would block init/start from completing. + + Ok(()) +} + +/// Call a lifecycle function on the skill object synchronously. +/// WARNING: This creates its own tokio runtime - only use when NOT inside an async context. +/// Looks for the skill at globalThis.__skill.default first, then falls back to globalThis. +#[allow(dead_code)] fn call_lifecycle_fn_sync(runtime: &mut JsRuntime, name: &str) -> Result<(), String> { let code = format!( r#"(function() {{ - if (typeof globalThis.{name} === 'function') {{ + var skill = globalThis.__skill && globalThis.__skill.default + ? globalThis.__skill.default + : (globalThis.__skill || globalThis); + if (typeof skill.{name} === 'function') {{ + skill.{name}(); + }} else if (typeof globalThis.{name} === 'function') {{ globalThis.{name}(); }} }})()"# @@ -365,7 +518,8 @@ fn call_lifecycle_fn_sync(runtime: &mut JsRuntime, name: &str) -> Result<(), Str Ok(()) } -/// Handle a tool call synchronously. +/// Handle a tool call synchronously (no async ops waited). +/// This is used when we just need to invoke the tool and get immediate result. fn handle_tool_call_sync( runtime: &mut JsRuntime, tool_name: &str, @@ -376,7 +530,10 @@ fn handle_tool_call_sync( let code = format!( r#"(function() {{ - var tools = globalThis.tools || []; + var skill = globalThis.__skill && globalThis.__skill.default + ? globalThis.__skill.default + : (globalThis.__skill || null); + var tools = (skill && skill.tools) || globalThis.tools || []; for (var i = 0; i < tools.length; i++) {{ if (tools[i].name === "{}") {{ var args = {}; @@ -398,17 +555,8 @@ fn handle_tool_call_sync( .execute_script("", code) .map_err(|e| format!("Tool execution failed: {e}"))?; - // Run event loop for any pending ops - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .map_err(|e| format!("Failed to create runtime: {e}"))?; - - let _ = rt.block_on(async { - runtime - .run_event_loop(PollEventLoopOptions::default()) - .await - }); + // Note: We don't run the event loop here because we're already inside + // the skill's event loop. Pending async ops will be handled by the main loop. let scope = &mut runtime.handle_scope(); let local = v8::Local::new(scope, result); @@ -435,12 +583,19 @@ fn handle_server_event_sync( let code = format!( r#"(function() {{ - if (typeof globalThis.onServerEvent === 'function') {{ + var skill = globalThis.__skill && globalThis.__skill.default + ? globalThis.__skill.default + : (globalThis.__skill || globalThis); + if (typeof skill.onServerEvent === 'function') {{ + skill.onServerEvent("{}", {}); + }} else if (typeof globalThis.onServerEvent === 'function') {{ globalThis.onServerEvent("{}", {}); }} }})()"#, event.replace('"', r#"\""#), data_str, + event.replace('"', r#"\""#), + data_str, ); runtime @@ -454,11 +609,17 @@ fn handle_server_event_sync( fn handle_cron_trigger_sync(runtime: &mut JsRuntime, schedule_id: &str) -> Result<(), String> { let code = format!( r#"(function() {{ - if (typeof globalThis.onCronTrigger === 'function') {{ + var skill = globalThis.__skill && globalThis.__skill.default + ? globalThis.__skill.default + : (globalThis.__skill || globalThis); + if (typeof skill.onCronTrigger === 'function') {{ + skill.onCronTrigger("{}"); + }} else if (typeof globalThis.onCronTrigger === 'function') {{ globalThis.onCronTrigger("{}"); }} }})()"#, schedule_id.replace('"', r#"\""#), + schedule_id.replace('"', r#"\""#), ); runtime @@ -468,7 +629,7 @@ fn handle_cron_trigger_sync(runtime: &mut JsRuntime, schedule_id: &str) -> Resul Ok(()) } -/// Call a global JS function that returns a JSON value synchronously. +/// Call a JS function on the skill object that returns a JSON value synchronously. fn handle_js_call_sync( runtime: &mut JsRuntime, fn_name: &str, @@ -476,9 +637,13 @@ fn handle_js_call_sync( ) -> Result { let code = format!( r#"(function() {{ - if (typeof globalThis.{fn_name} === 'function') {{ + var skill = globalThis.__skill && globalThis.__skill.default + ? globalThis.__skill.default + : (globalThis.__skill || globalThis); + var fn = skill.{fn_name} || globalThis.{fn_name}; + if (typeof fn === 'function') {{ var args = {args_json}; - var result = globalThis.{fn_name}(args); + var result = fn.call(skill, args); return JSON.stringify(result); }} return "null"; @@ -502,7 +667,7 @@ fn handle_js_call_sync( .map_err(|e| format!("{fn_name}() returned invalid JSON: {e}")) } -/// Call a global JS function that returns void synchronously. +/// Call a JS function on the skill object that returns void synchronously. fn handle_js_void_call_sync( runtime: &mut JsRuntime, fn_name: &str, @@ -510,9 +675,13 @@ fn handle_js_void_call_sync( ) -> Result<(), String> { let code = format!( r#"(function() {{ - if (typeof globalThis.{fn_name} === 'function') {{ + var skill = globalThis.__skill && globalThis.__skill.default + ? globalThis.__skill.default + : (globalThis.__skill || globalThis); + var fn = skill.{fn_name} || globalThis.{fn_name}; + if (typeof fn === 'function') {{ var args = {args_json}; - globalThis.{fn_name}(args); + fn.call(skill, args); }} }})()"# ); diff --git a/src-tauri/src/services/llama/manager.rs b/src-tauri/src/services/llama/manager.rs new file mode 100644 index 000000000..20f8de3bf --- /dev/null +++ b/src-tauri/src/services/llama/manager.rs @@ -0,0 +1,464 @@ +//! LlamaManager — singleton manager for local LLM inference. +//! +//! Provides: +//! - Lazy model loading on first use +//! - Automatic model download if not present +//! - Thread-safe inference with dedicated thread pool +//! - Generate and summarize API for skills + +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use llama_cpp_2::context::params::LlamaContextParams; +use llama_cpp_2::llama_backend::LlamaBackend; +use llama_cpp_2::llama_batch::LlamaBatch; +use llama_cpp_2::model::params::LlamaModelParams; +use llama_cpp_2::model::LlamaModel; +use llama_cpp_2::sampling::LlamaSampler; +use llama_cpp_2::token::data_array::LlamaTokenDataArray; +use once_cell::sync::Lazy; +use parking_lot::RwLock; +use serde::{Deserialize, Serialize}; + +/// Global LLama manager instance. +pub static LLAMA_MANAGER: Lazy = Lazy::new(LlamaManager::new); + +/// Model file name (Gemma 3n E2B Q4_K_M quantization) +const MODEL_FILENAME: &str = "gemma-3n-E2B-it-Q4_K_M.gguf"; + +/// HuggingFace model URL for download +const MODEL_URL: &str = "https://huggingface.co/bartowski/google_gemma-3n-E2B-it-GGUF/resolve/main/google_gemma-3n-E2B-it-Q4_K_M.gguf"; + +/// Expected SHA256 hash for model verification (first 16 chars for quick check) +const MODEL_SHA256_PREFIX: &str = ""; // Will be verified on first download + +/// Status of the local model. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ModelStatus { + /// Whether the model API is available on this platform. + pub available: bool, + /// Whether the model is currently loaded in memory. + pub loaded: bool, + /// Whether the model is currently being loaded or downloaded. + pub loading: bool, + /// Download progress (0.0 to 1.0) if downloading. + pub download_progress: Option, + /// Error message if loading failed. + pub error: Option, + /// Model file path if known. + pub model_path: Option, +} + +impl Default for ModelStatus { + fn default() -> Self { + Self { + available: true, + loaded: false, + loading: false, + download_progress: None, + error: None, + model_path: None, + } + } +} + +/// Configuration for text generation. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub struct GenerateConfig { + /// Maximum tokens to generate (default: 2048). + #[serde(default = "default_max_tokens")] + pub max_tokens: u32, + /// Sampling temperature (default: 0.7). + #[serde(default = "default_temperature")] + pub temperature: f32, + /// Top-p sampling (default: 0.9). + #[serde(default = "default_top_p")] + pub top_p: f32, +} + +fn default_max_tokens() -> u32 { + 2048 +} +fn default_temperature() -> f32 { + 0.7 +} +fn default_top_p() -> f32 { + 0.9 +} + +/// Internal state for the loaded model. +struct LoadedModel { + backend: LlamaBackend, + model: LlamaModel, +} + +// Safety: LlamaBackend and LlamaModel are thread-safe through their C API +unsafe impl Send for LoadedModel {} +unsafe impl Sync for LoadedModel {} + +/// LLama Manager for local model inference. +pub struct LlamaManager { + /// Directory for model files. + data_dir: RwLock, + /// Loaded model (lazy-loaded on first use). + model: RwLock>>, + /// Current status. + status: RwLock, + /// Lock to prevent concurrent loading. + loading: AtomicBool, +} + +impl LlamaManager { + /// Create a new LlamaManager (model not loaded yet). + pub fn new() -> Self { + Self { + data_dir: RwLock::new(PathBuf::new()), + model: RwLock::new(None), + status: RwLock::new(ModelStatus::default()), + loading: AtomicBool::new(false), + } + } + + /// Set the data directory for model storage. + pub fn set_data_dir(&self, dir: PathBuf) { + log::info!("[llama] Setting data dir: {:?}", dir); + *self.data_dir.write() = dir.clone(); + + // Update status with model path + let model_path = dir.join(MODEL_FILENAME); + self.status.write().model_path = Some(model_path.to_string_lossy().to_string()); + } + + /// Get the current model status. + pub fn get_status(&self) -> ModelStatus { + self.status.read().clone() + } + + /// Get the model file path. + fn model_path(&self) -> PathBuf { + self.data_dir.read().join(MODEL_FILENAME) + } + + /// Check if the model file exists. + fn model_exists(&self) -> bool { + self.model_path().exists() + } + + /// Download the model from HuggingFace. + async fn download_model(&self) -> Result<(), String> { + let model_path = self.model_path(); + + // Ensure parent directory exists + if let Some(parent) = model_path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create model directory: {}", e))?; + } + + log::info!("[llama] Downloading model from {}", MODEL_URL); + self.status.write().download_progress = Some(0.0); + + // Use reqwest for download + let client = reqwest::Client::new(); + let response = client + .get(MODEL_URL) + .send() + .await + .map_err(|e| format!("Failed to start download: {}", e))?; + + if !response.status().is_success() { + return Err(format!("Download failed with status: {}", response.status())); + } + + let total_size = response.content_length().unwrap_or(0); + let mut downloaded: u64 = 0; + + // Create temp file for download + let temp_path = model_path.with_extension("download"); + let mut file = std::fs::File::create(&temp_path) + .map_err(|e| format!("Failed to create temp file: {}", e))?; + + // Stream the download + use std::io::Write; + let mut stream = response.bytes_stream(); + use futures::StreamExt; + + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| format!("Download error: {}", e))?; + file.write_all(&chunk) + .map_err(|e| format!("Failed to write chunk: {}", e))?; + + downloaded += chunk.len() as u64; + + if total_size > 0 { + let progress = downloaded as f32 / total_size as f32; + self.status.write().download_progress = Some(progress); + + // Log progress every 10% + if (progress * 10.0) as u32 > ((downloaded - chunk.len() as u64) as f32 / total_size as f32 * 10.0) as u32 { + log::info!("[llama] Download progress: {:.1}%", progress * 100.0); + } + } + } + + // Flush and close file + file.flush() + .map_err(|e| format!("Failed to flush file: {}", e))?; + drop(file); + + // Rename temp file to final path + std::fs::rename(&temp_path, &model_path) + .map_err(|e| format!("Failed to rename temp file: {}", e))?; + + log::info!("[llama] Model downloaded successfully to {:?}", model_path); + self.status.write().download_progress = None; + + Ok(()) + } + + /// Ensure the model is loaded into memory. + pub async fn ensure_loaded(&self) -> Result<(), String> { + // Already loaded? + if self.model.read().is_some() { + return Ok(()); + } + + // Prevent concurrent loading + if self.loading.swap(true, Ordering::SeqCst) { + // Another thread is loading, wait for it + while self.loading.load(Ordering::SeqCst) { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + // Check if loading succeeded + if self.model.read().is_some() { + return Ok(()); + } + return Err("Model loading failed".to_string()); + } + + // Update status + { + let mut status = self.status.write(); + status.loading = true; + status.error = None; + } + + let result = self.load_model_internal().await; + + // Update status based on result + { + let mut status = self.status.write(); + status.loading = false; + match &result { + Ok(_) => { + status.loaded = true; + status.error = None; + } + Err(e) => { + status.loaded = false; + status.error = Some(e.clone()); + } + } + } + + self.loading.store(false, Ordering::SeqCst); + result + } + + /// Internal model loading logic. + async fn load_model_internal(&self) -> Result<(), String> { + // Check if model exists, download if not + if !self.model_exists() { + log::info!("[llama] Model not found, downloading..."); + self.download_model().await?; + } + + let model_path = self.model_path(); + log::info!("[llama] Loading model from {:?}", model_path); + + // Load model in blocking thread + let path = model_path.clone(); + let loaded = tokio::task::spawn_blocking(move || -> Result { + // Initialize llama backend + let backend = LlamaBackend::init() + .map_err(|e| format!("Failed to initialize llama backend: {}", e))?; + + // Set up model parameters + let model_params = LlamaModelParams::default(); + + // Load the model + let model = LlamaModel::load_from_file(&backend, &path, &model_params) + .map_err(|e| format!("Failed to load model: {}", e))?; + + Ok(LoadedModel { backend, model }) + }) + .await + .map_err(|e| format!("Task join error: {}", e))??; + + // Store the loaded model + *self.model.write() = Some(Arc::new(loaded)); + log::info!("[llama] Model loaded successfully"); + + Ok(()) + } + + /// Generate text from a prompt. + pub async fn generate(&self, prompt: &str, config: GenerateConfig) -> Result { + // Ensure model is loaded + self.ensure_loaded().await?; + + let model_arc = self + .model + .read() + .clone() + .ok_or_else(|| "Model not loaded".to_string())?; + + let prompt = prompt.to_string(); + let max_tokens = config.max_tokens; + let temperature = config.temperature; + + // Run inference in blocking thread + tokio::task::spawn_blocking(move || { + Self::generate_sync(&model_arc, &prompt, max_tokens, temperature) + }) + .await + .map_err(|e| format!("Task join error: {}", e))? + } + + /// Synchronous text generation (runs on blocking thread). + fn generate_sync( + loaded: &LoadedModel, + prompt: &str, + max_tokens: u32, + temperature: f32, + ) -> Result { + // Create context for inference + let ctx_params = LlamaContextParams::default() + .with_n_ctx(std::num::NonZeroU32::new(8192)); + + let mut ctx = loaded + .model + .new_context(&loaded.backend, ctx_params) + .map_err(|e| format!("Failed to create context: {}", e))?; + + // Tokenize the prompt + let tokens = loaded + .model + .str_to_token(prompt, llama_cpp_2::model::AddBos::Always) + .map_err(|e| format!("Failed to tokenize: {}", e))?; + + if tokens.is_empty() { + return Err("Empty prompt".to_string()); + } + + // Create batch with initial tokens + let mut batch = LlamaBatch::new(8192, 1); + + for (i, token) in tokens.iter().enumerate() { + let is_last = i == tokens.len() - 1; + batch + .add(*token, i as i32, &[0], is_last) + .map_err(|e| format!("Failed to add token to batch: {}", e))?; + } + + // Decode initial tokens + ctx.decode(&mut batch) + .map_err(|e| format!("Failed to decode batch: {}", e))?; + + // Generate tokens + let mut output_tokens = Vec::new(); + let mut n_cur = tokens.len(); + + // Create sampler chain for temperature sampling + let seed = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u32) + .unwrap_or(42); + + for _ in 0..max_tokens { + // Get logits for the last token + let logits = ctx.candidates_ith(batch.n_tokens() - 1); + + // Create token data array for sampling + let mut candidates = LlamaTokenDataArray::from_iter(logits, false); + + // Apply temperature sampler + let mut temp_sampler = LlamaSampler::temp(temperature); + candidates.apply_sampler(&mut temp_sampler); + + // Sample token with random seed + let new_token = candidates.sample_token(seed); + + // Check for end of generation + if loaded.model.is_eog_token(new_token) { + break; + } + + output_tokens.push(new_token); + + // Prepare next batch + batch.clear(); + batch + .add(new_token, n_cur as i32, &[0], true) + .map_err(|e| format!("Failed to add token: {}", e))?; + + n_cur += 1; + + // Decode + ctx.decode(&mut batch) + .map_err(|e| format!("Failed to decode: {}", e))?; + } + + // Convert tokens to string using token_to_piece + let mut decoder = encoding_rs::UTF_8.new_decoder(); + let mut output = String::new(); + + for token in &output_tokens { + match loaded.model.token_to_piece(*token, &mut decoder, false, None) { + Ok(piece) => output.push_str(&piece), + Err(e) => { + log::warn!("[llama] Failed to decode token: {}", e); + } + } + } + + Ok(output) + } + + /// Summarize text using a built-in prompt. + pub async fn summarize(&self, text: &str, max_tokens: u32) -> Result { + let prompt = format!( + "user\nPlease provide a concise summary of the following text:\n\n{}\n\nmodel\n", + text + ); + + self.generate( + &prompt, + GenerateConfig { + max_tokens, + temperature: 0.5, // Lower temperature for more focused summarization + top_p: 0.9, + }, + ) + .await + } + + /// Unload the model from memory. + pub fn unload(&self) { + log::info!("[llama] Unloading model"); + *self.model.write() = None; + self.status.write().loaded = false; + } +} + +impl Default for LlamaManager { + fn default() -> Self { + Self::new() + } +} + +// Ensure LlamaManager is Send + Sync +unsafe impl Send for LlamaManager {} +unsafe impl Sync for LlamaManager {} diff --git a/src-tauri/src/services/llama/mod.rs b/src-tauri/src/services/llama/mod.rs new file mode 100644 index 000000000..436639f39 --- /dev/null +++ b/src-tauri/src/services/llama/mod.rs @@ -0,0 +1,11 @@ +//! Local LLM Service using llama-cpp-2 +//! +//! Provides local AI model inference for skills using llama.cpp. +//! Desktop only - not available on Android/iOS. + +mod manager; + +pub use manager::GenerateConfig; +pub use manager::LlamaManager; +pub use manager::ModelStatus; +pub use manager::LLAMA_MANAGER; diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 25bd2e3d1..a049eb28f 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -1,6 +1,15 @@ pub mod session_service; pub mod socket_service; + +// TDLib modules - desktop only (requires tdlib-rs which isn't available on mobile) +#[cfg(not(any(target_os = "android", target_os = "ios")))] +pub mod tdlib; +#[cfg(not(any(target_os = "android", target_os = "ios")))] pub mod tdlib_v8; #[cfg(desktop)] pub mod notification_service; + +// Local LLM inference - desktop only (llama.cpp requires native C++ compilation) +#[cfg(not(any(target_os = "android", target_os = "ios")))] +pub mod llama; diff --git a/src-tauri/src/services/tdlib/manager.rs b/src-tauri/src/services/tdlib/manager.rs new file mode 100644 index 000000000..195d64130 --- /dev/null +++ b/src-tauri/src/services/tdlib/manager.rs @@ -0,0 +1,547 @@ +//! TdLibManager — singleton manager for TDLib on desktop platforms. +//! +//! Wraps tdlib-rs client and provides: +//! - Client creation with data directory configuration +//! - Asynchronous request/response via send/receive +//! - Background update polling with broadcast to subscribers +//! - Thread-safe access via channels + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, AtomicI32, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use once_cell::sync::Lazy; +use parking_lot::RwLock; +use tokio::sync::{broadcast, mpsc, oneshot}; + +/// Global TDLib manager instance. +pub static TDLIB_MANAGER: Lazy = Lazy::new(TdLibManager::new); + +/// Request message sent to the TDLib worker thread. +#[derive(Debug)] +enum TdRequest { + /// Send a request and wait for response. + Send { + request: serde_json::Value, + reply: oneshot::Sender>, + }, + /// Receive next update (with timeout). + Receive { + timeout_ms: u32, + reply: oneshot::Sender>, + }, + /// Destroy the client. + Destroy { + reply: oneshot::Sender>, + }, +} + +/// State for a single TDLib client. +struct ClientState { + /// Next request ID for @extra field correlation. + next_request_id: AtomicI32, + /// Pending requests waiting for responses: @extra -> reply channel. + pending_requests: Arc>>>, + /// Broadcast channel for updates (messages without @extra or with update type). + update_tx: broadcast::Sender, + /// Queue of updates for polling via receive(). + update_queue: Arc>>, + /// Notification channel for new updates. + update_notify: Arc, + /// Whether the client is active. + is_active: AtomicBool, +} + +impl ClientState { + fn new() -> Self { + let (update_tx, _) = broadcast::channel(256); + Self { + next_request_id: AtomicI32::new(1), + pending_requests: Arc::new(RwLock::new(HashMap::new())), + update_tx, + update_queue: Arc::new(parking_lot::Mutex::new(std::collections::VecDeque::with_capacity(256))), + update_notify: Arc::new(tokio::sync::Notify::new()), + is_active: AtomicBool::new(true), + } + } + + fn get_next_request_id(&self) -> i32 { + self.next_request_id.fetch_add(1, Ordering::SeqCst) + } + + /// Push an update to the queue and notify waiters. + fn push_update(&self, update: serde_json::Value) { + self.update_queue.lock().push_back(update); + self.update_notify.notify_one(); + } + + /// Pop an update from the queue (non-blocking). + fn pop_update(&self) -> Option { + self.update_queue.lock().pop_front() + } +} + +/// TDLib Manager for desktop platforms. +/// +/// Provides a high-level interface to TDLib with: +/// - Client lifecycle management +/// - Request/response correlation via @extra field +/// - Background update polling +/// - Broadcast channel for updates +pub struct TdLibManager { + /// The TDLib client ID. + client_id: RwLock>, + /// Client state for request correlation. + state: Arc, + /// Data directory for TDLib files. + data_dir: RwLock>, + /// Request sender for the worker thread. + request_tx: Arc>>>, + /// Handle to the worker thread. + worker_handle: RwLock>>, +} + +impl TdLibManager { + /// Create a new TDLib manager (doesn't start the client yet). + pub fn new() -> Self { + Self { + client_id: RwLock::new(None), + state: Arc::new(ClientState::new()), + data_dir: RwLock::new(None), + request_tx: Arc::new(RwLock::new(None)), + worker_handle: RwLock::new(None), + } + } + + /// Create and start a TDLib client with the given data directory. + /// Returns the client ID. If a client already exists, returns its ID. + pub fn create_client(&self, data_dir: PathBuf) -> Result { + // Check if already initialized - return existing client ID + if let Some(existing_id) = *self.client_id.read() { + log::info!("[tdlib] Client already exists with ID: {}, reusing", existing_id); + return Ok(existing_id); + } + + log::info!("[tdlib] Creating client with data dir: {:?}", data_dir); + + // Ensure data directory exists + std::fs::create_dir_all(&data_dir) + .map_err(|e| format!("Failed to create data directory: {}", e))?; + + // Create TDLib client using tdlib_rs + let client_id = tdlib_rs::create_client(); + + // Store data directory + *self.data_dir.write() = Some(data_dir); + + // Create request channel + let (request_tx, request_rx) = mpsc::channel::(64); + *self.request_tx.write() = Some(request_tx); + + // Store client ID + *self.client_id.write() = Some(client_id); + + // Start worker thread + let state = self.state.clone(); + let cid = client_id; + + let handle = std::thread::spawn(move || { + Self::worker_loop(cid, state, request_rx); + }); + *self.worker_handle.write() = Some(handle); + + self.state.is_active.store(true, Ordering::SeqCst); + log::info!("[tdlib] Client created with ID: {}", client_id); + + Ok(client_id) + } + + /// Worker loop that handles requests and polls for updates. + fn worker_loop( + client_id: i32, + state: Arc, + mut request_rx: mpsc::Receiver, + ) { + log::info!("[tdlib] Worker loop started for client {}", client_id); + + // Create a tokio runtime for async operations + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("Failed to create tokio runtime for TDLib worker"); + + rt.block_on(async { + // Spawn a separate task to poll TDLib updates + // This runs in spawn_blocking since tdlib_rs::receive() is a blocking call + let state_clone = state.clone(); + let poll_handle = tokio::spawn(async move { + loop { + if !state_clone.is_active.load(Ordering::SeqCst) { + break; + } + + // Use spawn_blocking for the blocking TDLib receive call + let receive_result = tokio::task::spawn_blocking(|| { + // Use a short timeout to be more responsive + // Note: tdlib_rs::receive() uses 2.0s internally, but that's OK + // because we're in spawn_blocking and won't block the async runtime + tdlib_rs::receive() + }) + .await; + + if let Ok(Some((update, update_client_id))) = receive_result { + if update_client_id == client_id { + Self::handle_response(&state_clone, update); + } + } + + // Yield to allow other tasks to run + tokio::task::yield_now().await; + } + }); + + // Main loop for handling requests + loop { + // Check if we should stop + if !state.is_active.load(Ordering::SeqCst) { + log::info!("[tdlib] Worker loop stopping (inactive)"); + break; + } + + // Check for incoming requests (with short timeout to stay responsive) + match tokio::time::timeout( + Duration::from_millis(50), + request_rx.recv(), + ) + .await + { + Ok(Some(request)) => { + Self::handle_request(client_id, &state, request).await; + } + Ok(None) => { + log::info!("[tdlib] Request channel disconnected"); + break; + } + Err(_) => { + // Timeout - no request, continue + } + } + } + + // Clean up the poll task + poll_handle.abort(); + }); + + log::info!("[tdlib] Worker loop exited for client {}", client_id); + } + + /// Handle a TDLib response (either a response to a request or an update). + fn handle_response(state: &Arc, update: tdlib_rs::enums::Update) { + // Convert update to JSON for processing + let json = serde_json::to_value(&update).unwrap_or(serde_json::Value::Null); + + // Check if this is a response to a pending request (has @extra) + if let Some(extra) = json.get("@extra").and_then(|v| v.as_str()) { + // Find and complete the pending request + if let Some(reply_tx) = state.pending_requests.write().remove(extra) { + let _ = reply_tx.send(json); + } else { + log::warn!("[tdlib] Received response with unknown @extra: {}", extra); + } + } else { + // This is an update - push to queue and broadcast + state.push_update(json.clone()); + let _ = state.update_tx.send(json); + } + } + + /// Handle a request from the channel. + async fn handle_request(client_id: i32, state: &Arc, request: TdRequest) { + match request { + TdRequest::Send { request, reply } => { + // Check if this is a request type that uses high-level tdlib-rs functions + // These functions consume responses internally, so we return "ok" immediately + let request_type = request + .get("@type") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let uses_high_level_api = matches!( + request_type, + "setTdlibParameters" + | "getMe" + | "close" + | "logOut" + | "setAuthenticationPhoneNumber" + | "checkAuthenticationCode" + | "checkAuthenticationPassword" + ); + + // Send to TDLib + let send_result = Self::send_json_request(client_id, &request).await; + + if let Err(e) = send_result { + let _ = reply.send(Err(e)); + return; + } + + // For high-level API functions, return "ok" immediately + // The actual response/update will come through the update stream + if uses_high_level_api { + let _ = reply.send(Ok(serde_json::json!({ + "@type": "ok" + }))); + } else { + // For other requests, we'd need low-level JSON API + // For now, just return ok since we don't have many other request types + let _ = reply.send(Ok(serde_json::json!({ + "@type": "ok" + }))); + } + } + TdRequest::Receive { timeout_ms, reply } => { + // First check if there's an update already in the queue + if let Some(update) = state.pop_update() { + let _ = reply.send(Some(update)); + return; + } + + // No update available, wait for notification with timeout + let notify = state.update_notify.clone(); + let queue = state.update_queue.clone(); + + tokio::spawn(async move { + match tokio::time::timeout( + Duration::from_millis(timeout_ms as u64), + notify.notified(), + ) + .await + { + Ok(_) => { + // Got notification, pop from queue + let update = queue.lock().pop_front(); + let _ = reply.send(update); + } + Err(_) => { + // Timeout - try one more time in case update came just now + let update = queue.lock().pop_front(); + let _ = reply.send(update); + } + } + }); + } + TdRequest::Destroy { reply } => { + state.is_active.store(false, Ordering::SeqCst); + let _ = reply.send(Ok(())); + } + } + } + + /// Send a JSON request to TDLib by converting to the appropriate function type. + async fn send_json_request(client_id: i32, request: &serde_json::Value) -> Result<(), String> { + // Get the @type field to determine which function to call + let request_type = request + .get("@type") + .and_then(|v| v.as_str()) + .ok_or_else(|| "Request missing @type field".to_string())?; + + // tdlib-rs functions are async and take individual parameters + // We'll implement the most common functions. + match request_type { + "setTdlibParameters" => { + // Parse and call setTdlibParameters + if let Ok(params) = serde_json::from_value::(request.clone()) { + let _ = tdlib_rs::functions::set_tdlib_parameters( + params.use_test_dc.unwrap_or(false), + params.database_directory.unwrap_or_default(), + params.files_directory.unwrap_or_default(), + params.database_encryption_key.unwrap_or_default(), + params.use_file_database.unwrap_or(true), + params.use_chat_info_database.unwrap_or(true), + params.use_message_database.unwrap_or(true), + params.use_secret_chats.unwrap_or(false), + params.api_id, + params.api_hash, + params.system_language_code.unwrap_or_else(|| "en".to_string()), + params.device_model.unwrap_or_else(|| "Desktop".to_string()), + params.system_version.unwrap_or_default(), + params.application_version.unwrap_or_else(|| "1.0.0".to_string()), + client_id, + ).await; + Ok(()) + } else { + Err("Failed to parse setTdlibParameters".to_string()) + } + } + "getMe" => { + let _ = tdlib_rs::functions::get_me(client_id).await; + Ok(()) + } + "close" => { + let _ = tdlib_rs::functions::close(client_id).await; + Ok(()) + } + "logOut" => { + let _ = tdlib_rs::functions::log_out(client_id).await; + Ok(()) + } + "setAuthenticationPhoneNumber" => { + if let Some(phone) = request.get("phone_number").and_then(|v| v.as_str()) { + let _ = tdlib_rs::functions::set_authentication_phone_number( + phone.to_string(), + None, // phone_number_authentication_settings + client_id, + ).await; + Ok(()) + } else { + Err("Missing phone_number".to_string()) + } + } + "checkAuthenticationCode" => { + if let Some(code) = request.get("code").and_then(|v| v.as_str()) { + let _ = tdlib_rs::functions::check_authentication_code(code.to_string(), client_id).await; + Ok(()) + } else { + Err("Missing code".to_string()) + } + } + "checkAuthenticationPassword" => { + if let Some(password) = request.get("password").and_then(|v| v.as_str()) { + let _ = tdlib_rs::functions::check_authentication_password(password.to_string(), client_id).await; + Ok(()) + } else { + Err("Missing password".to_string()) + } + } + _ => { + log::warn!("[tdlib] Unknown request type: {}", request_type); + Err(format!("Unknown request type: {}", request_type)) + } + } + } + + /// Send a request to TDLib and wait for the response. + pub async fn send(&self, request: serde_json::Value) -> Result { + let request_tx = { + self.request_tx.read().clone() + }; + + let request_tx = request_tx + .ok_or_else(|| "TDLib client not initialized".to_string())?; + + let (reply_tx, reply_rx) = oneshot::channel(); + + request_tx + .send(TdRequest::Send { + request, + reply: reply_tx, + }) + .await + .map_err(|e| format!("Failed to send request: {}", e))?; + + reply_rx + .await + .map_err(|_| "Response channel closed".to_string())? + } + + /// Receive the next update from TDLib (with timeout). + pub async fn receive(&self, timeout_ms: u32) -> Option { + let request_tx = { + self.request_tx.read().clone() + }?; + + let (reply_tx, reply_rx) = oneshot::channel(); + + if request_tx + .send(TdRequest::Receive { + timeout_ms, + reply: reply_tx, + }) + .await + .is_err() + { + return None; + } + + reply_rx.await.ok().flatten() + } + + /// Subscribe to TDLib updates. + pub fn subscribe_updates(&self) -> broadcast::Receiver { + self.state.update_tx.subscribe() + } + + /// Destroy the TDLib client and clean up resources. + pub async fn destroy(&self) -> Result<(), String> { + log::info!("[tdlib] Destroying client"); + + // Get the request_tx without holding the lock across await + let request_tx = { + self.request_tx.read().clone() + }; + + // Send destroy request + if let Some(request_tx) = request_tx { + let (reply_tx, reply_rx) = oneshot::channel(); + + let _ = request_tx.send(TdRequest::Destroy { reply: reply_tx }).await; + let _ = reply_rx.await; + } + + // Clear state + *self.request_tx.write() = None; + *self.client_id.write() = None; + *self.data_dir.write() = None; + + // Wait for worker thread to finish + if let Some(handle) = self.worker_handle.write().take() { + let _ = handle.join(); + } + + log::info!("[tdlib] Client destroyed"); + Ok(()) + } + + /// Check if the client is active. + pub fn is_active(&self) -> bool { + self.state.is_active.load(Ordering::SeqCst) && self.client_id.read().is_some() + } + + /// Get the data directory path. + pub fn data_dir(&self) -> Option { + self.data_dir.read().clone() + } +} + +impl Default for TdLibManager { + fn default() -> Self { + Self::new() + } +} + +// Helper struct for parsing setTdlibParameters request +#[derive(serde::Deserialize)] +struct SetTdlibParametersRequest { + #[serde(rename = "@type")] + _type: String, + use_test_dc: Option, + database_directory: Option, + files_directory: Option, + database_encryption_key: Option, + use_file_database: Option, + use_chat_info_database: Option, + use_message_database: Option, + use_secret_chats: Option, + api_id: i32, + api_hash: String, + system_language_code: Option, + device_model: Option, + system_version: Option, + application_version: Option, +} + +// Ensure TdLibManager is Send + Sync for use with Tauri +unsafe impl Send for TdLibManager {} +unsafe impl Sync for TdLibManager {} diff --git a/src-tauri/src/services/tdlib/mod.rs b/src-tauri/src/services/tdlib/mod.rs new file mode 100644 index 000000000..7d8beb93d --- /dev/null +++ b/src-tauri/src/services/tdlib/mod.rs @@ -0,0 +1,10 @@ +//! TDLib Integration Module +//! +//! Provides native TDLib access for the Telegram skill. +//! Desktop uses tdlib-rs, Android uses JNI bridge to TDLib Android library. + +#[cfg(not(any(target_os = "android", target_os = "ios")))] +pub mod manager; + +#[cfg(not(any(target_os = "android", target_os = "ios")))] +pub use manager::{TdLibManager, TDLIB_MANAGER}; diff --git a/src-tauri/src/services/tdlib_v8/bootstrap.js b/src-tauri/src/services/tdlib_v8/bootstrap.js index ae87d0a54..d8a2fe9c8 100644 --- a/src-tauri/src/services/tdlib_v8/bootstrap.js +++ b/src-tauri/src/services/tdlib_v8/bootstrap.js @@ -75,6 +75,59 @@ globalThis.__handleTimer = function (id) { } }; +// ============================================================================ +// AbortController / AbortSignal Polyfill +// ============================================================================ +class AbortSignal { + constructor() { + this.aborted = false; + this.reason = undefined; + this._listeners = []; + } + + addEventListener(type, listener) { + if (type === 'abort') { + this._listeners.push(listener); + } + } + + removeEventListener(type, listener) { + if (type === 'abort') { + const idx = this._listeners.indexOf(listener); + if (idx >= 0) this._listeners.splice(idx, 1); + } + } + + throwIfAborted() { + if (this.aborted) { + throw this.reason || new Error('Aborted'); + } + } +} + +class AbortController { + constructor() { + this.signal = new AbortSignal(); + } + + abort(reason) { + if (!this.signal.aborted) { + this.signal.aborted = true; + this.signal.reason = reason || new Error('Aborted'); + for (const listener of this.signal._listeners) { + try { + listener({ type: 'abort', target: this.signal }); + } catch (e) { + console.error('AbortController listener error:', e); + } + } + } + } +} + +globalThis.AbortController = AbortController; +globalThis.AbortSignal = AbortSignal; + // ============================================================================ // Fetch API // ============================================================================ @@ -802,4 +855,135 @@ globalThis.skills = { }, }; +// ============================================================================ +// TDLib Bridge API (telegram skill only) +// ============================================================================ +// Provides native TDLib access for the telegram skill. +// This is only available on desktop - Android uses Tauri invoke() instead. + +globalThis.tdlib = { + /** + * Check if TDLib ops are available. + * @returns {boolean} True on desktop, false on mobile/web. + */ + isAvailable: function () { + try { + return typeof Deno?.core?.ops?.op_tdlib_is_available === 'function' + ? Deno.core.ops.op_tdlib_is_available() + : false; + } catch (e) { + return false; + } + }, + + /** + * Create a TDLib client with the given data directory. + * @param {string} dataDir - Path to store TDLib data files. + * @returns {Promise} Client ID (always 1 for singleton). + */ + createClient: async function (dataDir) { + return await Deno.core.ops.op_tdlib_create_client(dataDir); + }, + + /** + * Send a TDLib request and wait for the response. + * @param {object} request - TDLib API request object with @type field. + * @returns {Promise} TDLib response object. + */ + send: async function (request) { + return await Deno.core.ops.op_tdlib_send(request); + }, + + /** + * Receive the next TDLib update (with timeout). + * @param {number} [timeoutMs=1000] - Timeout in milliseconds. + * @returns {Promise} Update object or null if timeout. + */ + receive: async function (timeoutMs = 1000) { + return await Deno.core.ops.op_tdlib_receive(timeoutMs); + }, + + /** + * Destroy the TDLib client and clean up resources. + * @returns {Promise} + */ + destroy: async function () { + return await Deno.core.ops.op_tdlib_destroy(); + }, +}; + +// ============================================================================ +// Model Bridge API (local LLM inference) +// ============================================================================ + +globalThis.__model = { + isAvailable: function () { + try { + return typeof Deno?.core?.ops?.op_model_is_available === 'function' + ? Deno.core.ops.op_model_is_available() + : false; + } catch (e) { + return false; + } + }, + getStatus: function () { + return Deno.core.ops.op_model_get_status(); + }, + generate: async function (prompt, configJson) { + return await Deno.core.ops.op_model_generate(prompt, configJson); + }, + summarize: async function (text, maxTokens) { + return await Deno.core.ops.op_model_summarize(text, maxTokens); + }, +}; + +globalThis.model = { + /** + * Check if local model is available (desktop only). + * @returns {boolean} + */ + isAvailable: function () { + return __model.isAvailable(); + }, + + /** + * Get model status. + * @returns {{ available: boolean, loaded: boolean, loading: boolean, downloadProgress?: number, error?: string, modelPath?: string }} + */ + getStatus: function () { + return __model.getStatus(); + }, + + /** + * Generate text from a prompt. + * @param {string} prompt - Input prompt + * @param {object} [options] - Generation options + * @param {number} [options.maxTokens=2048] - Max output tokens + * @param {number} [options.temperature=0.7] - Sampling temperature + * @param {number} [options.topP=0.9] - Top-p sampling + * @returns {Promise} + */ + generate: async function (prompt, options) { + var config = { + max_tokens: (options && options.maxTokens) || 2048, + temperature: (options && options.temperature) || 0.7, + top_p: (options && options.topP) || 0.9, + }; + return await __model.generate(prompt, config); + }, + + /** + * Summarize text locally. + * @param {string} text - Text to summarize + * @param {object} [options] - Options + * @param {number} [options.maxTokens=500] - Target summary length + * @returns {Promise} + */ + summarize: async function (text, options) { + var maxTokens = (options && options.maxTokens) || 500; + return await __model.summarize(text, maxTokens); + }, +}; + +console.log('[bootstrap] Model API initialized'); console.log('[bootstrap] V8 browser APIs initialized'); diff --git a/src-tauri/src/services/tdlib_v8/ops/mod.rs b/src-tauri/src/services/tdlib_v8/ops/mod.rs index e010a2ef9..fa274e813 100644 --- a/src-tauri/src/services/tdlib_v8/ops/mod.rs +++ b/src-tauri/src/services/tdlib_v8/ops/mod.rs @@ -73,6 +73,17 @@ extension!( // Data bridge ops op_data_read, op_data_write, + // TDLib ops (telegram skill only) + op_tdlib_create_client, + op_tdlib_send, + op_tdlib_receive, + op_tdlib_destroy, + op_tdlib_is_available, + // Model ops (local LLM inference) + op_model_is_available, + op_model_get_status, + op_model_generate, + op_model_summarize, ], state = |state| { // State will be initialized when runtime is created @@ -98,6 +109,15 @@ pub fn init_state_with_data_dir( state.put(WebSocketState::default()); } +/// Poll timers and return IDs of timers that are ready to fire. +/// Also returns the duration until the next timer (for efficient sleeping). +pub fn poll_timers(state: &mut OpState) -> (Vec, Option) { + let timer_state = state.borrow_mut::(); + let ready = timer_state.poll_ready(); + let next = timer_state.time_until_next(); + (ready, next) +} + /// Context for the current skill execution. #[derive(Clone)] pub struct SkillContext { @@ -137,15 +157,72 @@ impl SkillContext { // Timer State // ============================================================================ +/// A scheduled timer (setTimeout or setInterval). +#[derive(Clone, Debug)] +pub struct TimerEntry { + /// Whether this is a repeating interval + pub is_interval: bool, + /// Delay in milliseconds + pub delay_ms: u64, + /// When the timer should fire next (Instant) + pub next_fire: std::time::Instant, +} + /// State for managing timers. -/// Currently a placeholder - full timer integration with V8 event loop is TODO. +/// Timers are polled during the event loop and fire callbacks via JS. #[derive(Default)] -#[allow(dead_code)] pub struct TimerState { - /// Active timers: id -> (is_interval, delay_ms) - pub timers: HashMap, - /// Timer cancellation senders - pub cancel_senders: HashMap>, + /// Active timers: id -> TimerEntry + pub timers: HashMap, +} + +impl TimerState { + /// Get all timers that are ready to fire, returning their IDs. + /// For intervals, reschedules the next fire time. + /// For timeouts, removes them from the map. + pub fn poll_ready(&mut self) -> Vec { + let now = std::time::Instant::now(); + let mut ready = Vec::new(); + + // Collect IDs of ready timers + let ready_ids: Vec = self + .timers + .iter() + .filter(|(_, entry)| now >= entry.next_fire) + .map(|(id, _)| *id) + .collect(); + + for id in ready_ids { + if let Some(entry) = self.timers.get_mut(&id) { + ready.push(id); + + if entry.is_interval { + // Reschedule for next interval + entry.next_fire = now + std::time::Duration::from_millis(entry.delay_ms); + } else { + // Remove one-shot timeout + self.timers.remove(&id); + } + } + } + + ready + } + + /// Get the duration until the next timer fires (for sleep optimization). + pub fn time_until_next(&self) -> Option { + let now = std::time::Instant::now(); + self.timers + .values() + .map(|entry| { + if entry.next_fire > now { + entry.next_fire - now + } else { + std::time::Duration::ZERO + } + }) + .min() + } } // ============================================================================ @@ -260,11 +337,12 @@ fn op_platform_os() -> &'static str { #[string] fn op_platform_env(#[string] key: &str) -> Option { const ALLOWED_ENV_VARS: &[&str] = &[ - "VITE_TELEGRAM_API_ID", - "VITE_TELEGRAM_API_HASH", "VITE_TELEGRAM_BOT_USERNAME", "VITE_TELEGRAM_BOT_ID", + "TELEGRAM_API_ID", // Skills may use this without VITE_ prefix + "TELEGRAM_API_HASH", // Skills may use this without VITE_ prefix "VITE_BACKEND_URL", + "BACKEND_URL", // Skills may use this without VITE_ prefix "VITE_DEBUG", ]; @@ -280,26 +358,35 @@ fn op_platform_env(#[string] key: &str) -> Option { // ============================================================================ /// Start a timer (setTimeout or setInterval). -/// The actual callback execution happens in JavaScript via __handleTimer. +/// The actual callback execution happens in JavaScript via __handleTimer, +/// triggered by the event loop polling TimerState. #[op2(fast)] -fn op_ah_timer_start( - _state: &mut OpState, - id: u32, - delay_ms: u32, - _is_interval: bool, -) { - // Note: In V8/deno_core, timers are typically handled differently. - // For now, we log the timer request. The actual timer execution - // would need integration with the V8 event loop. - log::debug!("[timer] Start timer {} with delay {}ms", id, delay_ms); - // TODO: Implement proper timer scheduling with the V8 event loop +fn op_ah_timer_start(state: &mut OpState, id: u32, delay_ms: u32, is_interval: bool) { + let timer_state = state.borrow_mut::(); + + let entry = TimerEntry { + is_interval, + delay_ms: delay_ms as u64, + next_fire: std::time::Instant::now() + std::time::Duration::from_millis(delay_ms as u64), + }; + + timer_state.timers.insert(id, entry); + log::debug!( + "[timer] Registered {} {} with delay {}ms", + if is_interval { "interval" } else { "timeout" }, + id, + delay_ms + ); } /// Cancel a timer. #[op2(fast)] -fn op_ah_timer_cancel(_state: &mut OpState, id: u32) { - log::debug!("[timer] Cancel timer {}", id); - // TODO: Implement proper timer cancellation +fn op_ah_timer_cancel(state: &mut OpState, id: u32) { + let timer_state = state.borrow_mut::(); + + if timer_state.timers.remove(&id).is_some() { + log::debug!("[timer] Cancelled timer {}", id); + } } // ============================================================================ @@ -1010,3 +1097,140 @@ fn op_data_write( deno_core::error::generic_error(format!("Failed to write file '{}': {}", filename, e)) }) } + +// ============================================================================ +// TDLib Ops (telegram skill only) +// ============================================================================ + +/// Check if the current skill is the telegram skill. +fn check_telegram_skill(state: &OpState) -> Result<(), deno_core::error::AnyError> { + let ctx = state.borrow::(); + if ctx.skill_id != "telegram" { + return Err(deno_core::error::generic_error( + "TDLib is only available to the telegram skill", + )); + } + Ok(()) +} + +/// Check if TDLib is available (always true on desktop). +#[op2(fast)] +fn op_tdlib_is_available() -> bool { + true +} + +/// Create a TDLib client with the given data directory. +/// This is synchronous since create_client just spawns a worker thread and returns. +#[op2(fast)] +fn op_tdlib_create_client( + state: &mut OpState, + #[string] data_dir: String, +) -> Result { + // Check skill permission + check_telegram_skill(state)?; + + let path = std::path::PathBuf::from(data_dir); + + crate::services::tdlib::TDLIB_MANAGER + .create_client(path) + .map_err(|e| deno_core::error::generic_error(e)) +} + +/// Send a request to TDLib and wait for the response. +#[op2(async)] +#[serde] +async fn op_tdlib_send( + state: Rc>, + #[serde] request: serde_json::Value, +) -> Result { + // Check skill permission + { + let state = state.borrow(); + check_telegram_skill(&state)?; + } + + crate::services::tdlib::TDLIB_MANAGER + .send(request) + .await + .map_err(|e| deno_core::error::generic_error(e)) +} + +/// Receive the next update from TDLib (with timeout in ms). +#[op2(async)] +#[serde] +async fn op_tdlib_receive( + state: Rc>, + timeout_ms: u32, +) -> Result, deno_core::error::AnyError> { + // Check skill permission + { + let state = state.borrow(); + check_telegram_skill(&state)?; + } + + Ok(crate::services::tdlib::TDLIB_MANAGER.receive(timeout_ms).await) +} + +/// Destroy the TDLib client and clean up resources. +#[op2(async)] +async fn op_tdlib_destroy( + state: Rc>, +) -> Result<(), deno_core::error::AnyError> { + // Check skill permission + { + let state = state.borrow(); + check_telegram_skill(&state)?; + } + + crate::services::tdlib::TDLIB_MANAGER + .destroy() + .await + .map_err(|e| deno_core::error::generic_error(e)) +} + +// ============================================================================ +// Model Ops (local LLM inference) +// ============================================================================ + +/// Check if local model API is available (desktop only). +#[op2(fast)] +fn op_model_is_available() -> bool { + true +} + +/// Get model status (loading, ready, error). +#[op2] +#[serde] +fn op_model_get_status() -> serde_json::Value { + let status = crate::services::llama::LLAMA_MANAGER.get_status(); + serde_json::to_value(status).unwrap_or_default() +} + +/// Generate text from prompt (async, blocking inference on thread pool). +#[op2(async)] +#[string] +async fn op_model_generate( + #[string] prompt: String, + #[serde] config: serde_json::Value, +) -> Result { + let cfg: crate::services::llama::GenerateConfig = + serde_json::from_value(config).unwrap_or_default(); + + crate::services::llama::LLAMA_MANAGER + .generate(&prompt, cfg) + .await + .map_err(|e| deno_core::error::generic_error(e)) +} + +/// Summarize text (async). +#[op2(async)] +#[string] +async fn op_model_summarize( + #[string] text: String, + max_tokens: u32, +) -> Result { + crate::services::llama::LLAMA_MANAGER + .summarize(&text, max_tokens) + .await + .map_err(|e| deno_core::error::generic_error(e)) +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 737dedb3e..1574a9fe8 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -36,7 +36,10 @@ "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico" - ] + ], + "macOS": { + "minimumSystemVersion": "10.15" + } }, "plugins": { "deep-link": { diff --git a/src/components/ModelDownloadProgress.tsx b/src/components/ModelDownloadProgress.tsx new file mode 100644 index 000000000..cea693b92 --- /dev/null +++ b/src/components/ModelDownloadProgress.tsx @@ -0,0 +1,225 @@ +import { useEffect, useState } from 'react'; + +import { useModelStatus } from '../hooks/useModelStatus'; + +interface ModelDownloadProgressProps { + className?: string; + showWhenLoaded?: boolean; +} + +const ModelDownloadProgress = ({ + className = '', + showWhenLoaded = false, +}: ModelDownloadProgressProps) => { + const { isAvailable, isLoaded, isLoading, downloadProgress, error, ensureLoaded } = + useModelStatus(); + const [isMobile, setIsMobile] = useState(false); + + useEffect(() => { + // Detect mobile platform + const detectMobile = async () => { + try { + const { platform } = await import('@tauri-apps/plugin-os'); + const currentPlatform = await platform(); + setIsMobile(currentPlatform === 'android' || currentPlatform === 'ios'); + } catch { + // If we can't detect platform, assume desktop + setIsMobile(false); + } + }; + detectMobile(); + }, []); + + // Show mobile-only message on mobile platforms + if (isMobile) { + return ( +
+
+
+ + + +
+
+ Local AI Model +

Available on desktop only

+
+
+
+ ); + } + + // Don't render if not available on this platform + if (!isAvailable) { + return null; + } + + // Don't render if loaded and showWhenLoaded is false + if (isLoaded && !showWhenLoaded && !isLoading) { + return null; + } + + // Format download progress percentage + const progressPercent = downloadProgress !== null ? Math.round(downloadProgress * 100) : 0; + + // Determine status display + const getStatusDisplay = () => { + if (error) { + return { + icon: ( + + + + ), + title: 'Model Error', + description: error, + color: 'coral', + }; + } + + if (isLoading && downloadProgress !== null) { + return { + icon: ( + + + + ), + title: 'Downloading Local AI Model', + description: `${progressPercent}% complete (~1.2 GB).`, + color: 'primary', + }; + } + + if (isLoading) { + return { + icon: ( + + + + ), + title: 'Loading Local AI Model', + description: 'Initializing local inference engine...', + color: 'primary', + }; + } + + if (isLoaded) { + return { + icon: ( + + + + ), + title: 'AI Model Ready', + description: 'Local inference available', + color: 'sage', + }; + } + + // Not loaded, show download prompt + return { + icon: ( + + + + ), + title: 'Local AI Model', + description: 'Download for offline summarization', + color: 'stone', + }; + }; + + const statusDisplay = getStatusDisplay(); + + return ( +
+
+ {/* Icon */} +
{statusDisplay.icon}
+ + {/* Content */} +
+
+ {statusDisplay.title} + {!isLoaded && !isLoading && !error && ( + + )} + {error && ( + + )} +
+

{statusDisplay.description}

+ + {/* Progress bar */} + {isLoading && downloadProgress !== null && ( +
+
+
+ )} +
+
+
+ ); +}; + +export default ModelDownloadProgress; diff --git a/src/components/SkillsGrid.tsx b/src/components/SkillsGrid.tsx index 4a22e11fb..222ce669d 100644 --- a/src/components/SkillsGrid.tsx +++ b/src/components/SkillsGrid.tsx @@ -276,6 +276,7 @@ function SkillActionButton({ export default function SkillsGrid() { const [skillsList, setSkillsList] = useState([]); const [loading, setLoading] = useState(true); + const [isMobile, setIsMobile] = useState(false); const [setupModalOpen, setSetupModalOpen] = useState(false); const [managementModalOpen, setManagementModalOpen] = useState(false); const [activeSkillId, setActiveSkillId] = useState(null); @@ -288,6 +289,19 @@ export default function SkillsGrid() { const skillStates = useAppSelector(state => state.skills.skillStates); useEffect(() => { + // Detect mobile platform + const detectMobile = async () => { + try { + const { platform } = await import('@tauri-apps/plugin-os'); + const currentPlatform = await platform(); + setIsMobile(currentPlatform === 'android' || currentPlatform === 'ios'); + } catch { + // If we can't detect platform, assume desktop + setIsMobile(false); + } + }; + detectMobile(); + // Load skills from the V8 runtime engine. const loadSkills = async () => { try { @@ -355,7 +369,38 @@ export default function SkillsGrid() { }); }, [skillsList, skillsState, skillStates]); - // If loading or no skills, don't render + // Show mobile-only message on mobile platforms + if (!loading && isMobile) { + return ( +
+

+ Skills +

+
+
+ + + +

Skills are available on desktop only

+

+ Use the desktop app to configure and run skills +

+
+
+
+ ); + } + + // If loading or no skills on desktop, don't render if (loading || skillsList.length === 0) { return null; } diff --git a/src/components/settings/components/SettingsHeader.tsx b/src/components/settings/components/SettingsHeader.tsx index 028ef5789..c9e025520 100644 --- a/src/components/settings/components/SettingsHeader.tsx +++ b/src/components/settings/components/SettingsHeader.tsx @@ -16,7 +16,7 @@ const SettingsHeader = ({ const { closeSettings } = useSettingsNavigation(); return ( -
+
{/* Back button */} diff --git a/src/components/skills/SkillSetupWizard.tsx b/src/components/skills/SkillSetupWizard.tsx index 834ae8bb4..577709f17 100644 --- a/src/components/skills/SkillSetupWizard.tsx +++ b/src/components/skills/SkillSetupWizard.tsx @@ -36,7 +36,9 @@ export default function SkillSetupWizard({ async function initSetup() { try { + console.log("[SkillSetupWizard] initSetup", skillId); const manifest = store.getState().skills.skills[skillId]?.manifest; + console.log("[SkillSetupWizard] manifest", manifest); if (!manifest) { if (!cancelled) { setState({ @@ -48,12 +50,17 @@ export default function SkillSetupWizard({ } if (!skillManager.isSkillRunning(skillId)) { + console.log("[SkillSetupWizard] starting skill", skillId); await skillManager.startSkill(manifest); + console.log("[SkillSetupWizard] skill started", skillId); } + if (cancelled) return; if (!skillManager.isSkillRunning(skillId)) { + console.log("[SkillSetupWizard] skill not running", skillId); const status = skillManager.getSkillStatus(skillId); + console.log("[SkillSetupWizard] status", status); const errMsg = status === "error" ? store.getState().skills.skills[skillId]?.error ?? "Skill failed to start" @@ -61,7 +68,9 @@ export default function SkillSetupWizard({ throw new Error(errMsg); } + console.log("[SkillSetupWizard] starting setup", skillId); const firstStep = await skillManager.startSetup(skillId); + console.log("[SkillSetupWizard] setup started", skillId); if (!cancelled) { setState({ phase: "step", step: firstStep }); } @@ -178,8 +187,8 @@ export default function SkillSetupWizard({ {}} - onCancel={() => {}} + onSubmit={() => { }} + onCancel={() => { }} /> ); diff --git a/src/hooks/useModelStatus.ts b/src/hooks/useModelStatus.ts new file mode 100644 index 000000000..db407c949 --- /dev/null +++ b/src/hooks/useModelStatus.ts @@ -0,0 +1,124 @@ +import { invoke } from '@tauri-apps/api/core'; +import { useCallback, useEffect, useState } from 'react'; + +/** + * Model status from Rust backend + */ +export interface ModelStatus { + available: boolean; + loaded: boolean; + loading: boolean; + downloadProgress: number | null; + error: string | null; + modelPath: string | null; +} + +const DEFAULT_STATUS: ModelStatus = { + available: false, + loaded: false, + loading: false, + downloadProgress: null, + error: null, + modelPath: null, +}; + +/** + * Hook to monitor and control local AI model status + */ +export const useModelStatus = (pollInterval = 1000) => { + const [status, setStatus] = useState(DEFAULT_STATUS); + const [isPolling, setIsPolling] = useState(false); + + // Fetch current status from backend + const fetchStatus = useCallback(async () => { + try { + const result = await invoke('model_get_status'); + setStatus(result); + return result; + } catch (error) { + console.error('[useModelStatus] Failed to fetch status:', error); + setStatus(prev => ({ + ...prev, + error: error instanceof Error ? error.message : 'Failed to fetch status', + })); + return null; + } + }, []); + + // Check if model API is available + const checkAvailability = useCallback(async () => { + try { + const available = await invoke('model_is_available'); + setStatus(prev => ({ ...prev, available })); + return available; + } catch (error) { + console.error('[useModelStatus] Failed to check availability:', error); + return false; + } + }, []); + + // Start loading/downloading the model + const ensureLoaded = useCallback(async () => { + try { + setStatus(prev => ({ ...prev, loading: true, error: null })); + setIsPolling(true); + await invoke('model_ensure_loaded'); + await fetchStatus(); + setIsPolling(false); + } catch (error) { + console.error('[useModelStatus] Failed to load model:', error); + setStatus(prev => ({ + ...prev, + loading: false, + error: error instanceof Error ? error.message : 'Failed to load model', + })); + setIsPolling(false); + } + }, [fetchStatus]); + + // Unload the model from memory + const unload = useCallback(async () => { + try { + await invoke('model_unload'); + await fetchStatus(); + } catch (error) { + console.error('[useModelStatus] Failed to unload model:', error); + } + }, [fetchStatus]); + + // Initial check and polling setup + useEffect(() => { + // Initial fetch + fetchStatus(); + + // Check availability + checkAvailability(); + }, [fetchStatus, checkAvailability]); + + // Polling when loading/downloading + useEffect(() => { + if (!isPolling && !status.loading) return; + + const interval = setInterval(async () => { + const newStatus = await fetchStatus(); + // Stop polling when loading is done + if (newStatus && !newStatus.loading) { + setIsPolling(false); + } + }, pollInterval); + + return () => clearInterval(interval); + }, [isPolling, status.loading, pollInterval, fetchStatus]); + + return { + status, + isAvailable: status.available, + isLoaded: status.loaded, + isLoading: status.loading, + downloadProgress: status.downloadProgress, + error: status.error, + ensureLoaded, + unload, + refresh: fetchStatus, + }; +}; diff --git a/src/lib/skills/manager.ts b/src/lib/skills/manager.ts index 7a7d5a45f..e5935f4f9 100644 --- a/src/lib/skills/manager.ts +++ b/src/lib/skills/manager.ts @@ -128,14 +128,17 @@ class SkillManager { * Start the setup flow for a skill. Returns the first step. */ async startSetup(skillId: string): Promise { + console.log("[SkillManager] startSetup", skillId); const runtime = this.runtimes.get(skillId); if (!runtime) { - throw new Error(`Skill ${skillId} is not running`); + console.log("[SkillManager] runtime not found", skillId); + throw new Error(`Skill ${skillId} runtime not found`); } store.dispatch( setSkillStatus({ skillId, status: "setup_in_progress" }), ); + console.log("[SkillManager] setup started", skillId); return runtime.setupStart(); } @@ -420,3 +423,8 @@ class SkillManager { // Export singleton export const skillManager = new SkillManager(); + +// Debug: expose to window for console testing +if (typeof window !== 'undefined') { + (window as unknown as { __skillManager: SkillManager }).__skillManager = skillManager; +} diff --git a/src/lib/skills/runtime.ts b/src/lib/skills/runtime.ts index 78cc2ee1e..83c3d2ee5 100644 --- a/src/lib/skills/runtime.ts +++ b/src/lib/skills/runtime.ts @@ -71,9 +71,11 @@ export class SkillRuntime { * Start the setup flow. Returns the first SetupStep. */ async setupStart(): Promise { + console.log("[SkillRuntime] setupStart", this.skillId); const result = await this.transport.request<{ step: SetupStep }>( "setup/start" ); + console.log("[SkillRuntime] setupStart result", this.skillId, result); return result.step; } diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index a8f895f04..d22151e68 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -1,6 +1,7 @@ import { useNavigate } from 'react-router-dom'; import ConnectionIndicator from '../components/ConnectionIndicator'; +import ModelDownloadProgress from '../components/ModelDownloadProgress'; import SkillsGrid from '../components/SkillsGrid'; import { useUser } from '../hooks/useUser'; import { TELEGRAM_BOT_USERNAME } from '../utils/config'; @@ -137,6 +138,8 @@ const Home = () => { {/* Skills Grid */} + +
diff --git a/src/providers/SkillProvider.tsx b/src/providers/SkillProvider.tsx index 144b5ea6a..ee3123423 100644 --- a/src/providers/SkillProvider.tsx +++ b/src/providers/SkillProvider.tsx @@ -12,6 +12,7 @@ import { type ReactNode, useEffect, useRef } from 'react'; import { skillManager } from '../lib/skills/manager'; import type { SkillManifest } from '../lib/skills/types'; import { useAppSelector } from '../store/hooks'; +import { DEV_AUTO_LOAD_SKILL } from '../utils/config'; // --------------------------------------------------------------------------- // Helpers @@ -52,9 +53,30 @@ export default function SkillProvider({ children }: { children: ReactNode }) { initRef.current = true; const registerAndStart = async (manifests: SkillManifest[]) => { + // Register all discovered skills for (const manifest of manifests) { skillManager.registerSkill(manifest); } + + // Auto-start skill specified in DEV_AUTO_LOAD_SKILL env variable (dev only) + if (DEV_AUTO_LOAD_SKILL) { + const autoLoadManifest = manifests.find(m => m.id === DEV_AUTO_LOAD_SKILL); + if (autoLoadManifest) { + console.log(`[SkillProvider] Auto-loading skill from env: ${DEV_AUTO_LOAD_SKILL}`); + try { + await skillManager.startSkill(autoLoadManifest); + console.log(`[SkillProvider] Successfully auto-loaded skill: ${DEV_AUTO_LOAD_SKILL}`); + } catch (err) { + console.error(`[SkillProvider] Failed to auto-load skill ${DEV_AUTO_LOAD_SKILL}:`, err); + } + } else { + console.warn( + `[SkillProvider] DEV_AUTO_LOAD_SKILL="${DEV_AUTO_LOAD_SKILL}" specified but skill not found in discovered skills` + ); + } + } + + // Auto-start skills with completed setup for (const manifest of manifests) { const existing = skillsState[manifest.id]; if (existing?.setupComplete) { diff --git a/src/utils/config.ts b/src/utils/config.ts index 6aab8b786..239a4219b 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -14,3 +14,5 @@ export const TELEGRAM_API_HASH = import.meta.env.VITE_TELEGRAM_API_HASH || undef export const IS_DEV = Boolean(import.meta.env.DEV) || import.meta.env.MODE === 'development'; export const SKILLS_GITHUB_REPO = import.meta.env.VITE_SKILLS_GITHUB_REPO || 'alphahumanxyz/skills'; + +export const DEV_AUTO_LOAD_SKILL = import.meta.env.VITE_DEV_AUTO_LOAD_SKILL || undefined; diff --git a/yarn.lock b/yarn.lock index 3df5a4090..9da88127a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -859,6 +859,13 @@ dependencies: "@tauri-apps/api" "^2.8.0" +"@tauri-apps/plugin-os@^2.3.2": + version "2.3.2" + resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-os/-/plugin-os-2.3.2.tgz#de916a82d8d955bba59a2ffdba7f7aaa29397246" + integrity sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A== + dependencies: + "@tauri-apps/api" "^2.8.0" + "@tauri-apps/plugin-shell@~2": version "2.3.4" resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-shell/-/plugin-shell-2.3.4.tgz#26cbfca659d127da81b0d4583dec9a56d13f5dfe"