Merge pull request #35 from vezuresdotxyz/develop
chore: merge develop into main (v0.23.0)
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -21,8 +21,6 @@ dist-ssr
|
||||
.env.*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1 +1,7 @@
|
||||
{ "recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"] }
|
||||
{
|
||||
"recommendations": [
|
||||
"tauri-apps.tauri-vscode",
|
||||
"rust-lang.rust-analyzer",
|
||||
"esbenp.prettier-vscode"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Symlink system python3 to src-tauri/runtime-skill-python-<target> 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);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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!!
|
||||
|
||||
@@ -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-<TARGET_TRIPLE>` (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-<target>` 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-<target>`. 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.
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"core:default",
|
||||
"opener:default",
|
||||
"deep-link:default",
|
||||
"os:default",
|
||||
"core:tray:default",
|
||||
"autostart:default",
|
||||
"autostart:allow-enable",
|
||||
|
||||
@@ -7,8 +7,6 @@
|
||||
"core:default",
|
||||
"opener:default",
|
||||
"deep-link:default",
|
||||
"shell:default",
|
||||
"shell:allow-spawn",
|
||||
"shell:allow-open"
|
||||
"os:default"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 8.9 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 6.6 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 634 B |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 894 B After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 816 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 9.5 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 32 KiB |
@@ -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::*;
|
||||
|
||||
@@ -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<f32>,
|
||||
/// Error message if loading failed.
|
||||
pub error: Option<String>,
|
||||
/// Model file path if known.
|
||||
pub model_path: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<String, String> {
|
||||
// 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<String, String> {
|
||||
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<JsonValue, String> {
|
||||
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<String, String> {
|
||||
#[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<u32>) -> Result<String, String> {
|
||||
#[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<String, String> {
|
||||
#[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<String, String> {
|
||||
#[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<i32>,
|
||||
top_k: Option<i32>,
|
||||
temperature: Option<f32>,
|
||||
) -> Result<String, String> {
|
||||
#[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())
|
||||
}
|
||||
}
|
||||
@@ -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<i32, String> {
|
||||
#[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<Value, String> {
|
||||
#[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<Option<Value>, 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
|
||||
}
|
||||
}
|
||||
@@ -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<dyn std::error::Error>> {
|
||||
#[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,
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
@@ -45,6 +45,7 @@ impl SkillRegistry {
|
||||
state: Arc<RwLock<SkillState>>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<SharedState>,
|
||||
/// 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<Option<Client>>,
|
||||
}
|
||||
|
||||
@@ -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<serde_json::Value> {
|
||||
match payload {
|
||||
Payload::Text(values) => values.first().cloned(),
|
||||
@@ -613,6 +667,7 @@ fn extract_json(payload: &Payload) -> Option<serde_json::Value> {
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
@@ -108,12 +108,6 @@ impl RuntimeEngine {
|
||||
/// Discover all JavaScript skills from the skills directory.
|
||||
pub async fn discover_skills(&self) -> Result<Vec<SkillManifest>, 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 })?;
|
||||
|
||||
@@ -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<SkillMessage>,
|
||||
state: &Arc<RwLock<SkillState>>,
|
||||
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("<timer-callback>", 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<RwLock<SkillState>>,
|
||||
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<RwLock<SkillState>>) {
|
||||
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<RwLock<SkillState>>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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("<lifecycle>", 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("<tool-call>", 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<serde_json::Value, 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};
|
||||
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);
|
||||
}}
|
||||
}})()"#
|
||||
);
|
||||
|
||||
@@ -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<LlamaManager> = 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<f32>,
|
||||
/// Error message if loading failed.
|
||||
pub error: Option<String>,
|
||||
/// Model file path if known.
|
||||
pub model_path: Option<String>,
|
||||
}
|
||||
|
||||
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<PathBuf>,
|
||||
/// Loaded model (lazy-loaded on first use).
|
||||
model: RwLock<Option<Arc<LoadedModel>>>,
|
||||
/// Current status.
|
||||
status: RwLock<ModelStatus>,
|
||||
/// 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<LoadedModel, String> {
|
||||
// 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<String, String> {
|
||||
// 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<String, String> {
|
||||
// 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<String, String> {
|
||||
let prompt = format!(
|
||||
"<start_of_turn>user\nPlease provide a concise summary of the following text:\n\n{}\n<end_of_turn>\n<start_of_turn>model\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 {}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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<TdLibManager> = 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<Result<serde_json::Value, String>>,
|
||||
},
|
||||
/// Receive next update (with timeout).
|
||||
Receive {
|
||||
timeout_ms: u32,
|
||||
reply: oneshot::Sender<Option<serde_json::Value>>,
|
||||
},
|
||||
/// Destroy the client.
|
||||
Destroy {
|
||||
reply: oneshot::Sender<Result<(), String>>,
|
||||
},
|
||||
}
|
||||
|
||||
/// 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<RwLock<HashMap<String, oneshot::Sender<serde_json::Value>>>>,
|
||||
/// Broadcast channel for updates (messages without @extra or with update type).
|
||||
update_tx: broadcast::Sender<serde_json::Value>,
|
||||
/// Queue of updates for polling via receive().
|
||||
update_queue: Arc<parking_lot::Mutex<std::collections::VecDeque<serde_json::Value>>>,
|
||||
/// Notification channel for new updates.
|
||||
update_notify: Arc<tokio::sync::Notify>,
|
||||
/// 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<serde_json::Value> {
|
||||
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<Option<i32>>,
|
||||
/// Client state for request correlation.
|
||||
state: Arc<ClientState>,
|
||||
/// Data directory for TDLib files.
|
||||
data_dir: RwLock<Option<PathBuf>>,
|
||||
/// Request sender for the worker thread.
|
||||
request_tx: Arc<RwLock<Option<mpsc::Sender<TdRequest>>>>,
|
||||
/// Handle to the worker thread.
|
||||
worker_handle: RwLock<Option<std::thread::JoinHandle<()>>>,
|
||||
}
|
||||
|
||||
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<i32, String> {
|
||||
// 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::<TdRequest>(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<ClientState>,
|
||||
mut request_rx: mpsc::Receiver<TdRequest>,
|
||||
) {
|
||||
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<ClientState>, 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<ClientState>, 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::<SetTdlibParametersRequest>(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<serde_json::Value, String> {
|
||||
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<serde_json::Value> {
|
||||
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<serde_json::Value> {
|
||||
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<PathBuf> {
|
||||
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<bool>,
|
||||
database_directory: Option<String>,
|
||||
files_directory: Option<String>,
|
||||
database_encryption_key: Option<String>,
|
||||
use_file_database: Option<bool>,
|
||||
use_chat_info_database: Option<bool>,
|
||||
use_message_database: Option<bool>,
|
||||
use_secret_chats: Option<bool>,
|
||||
api_id: i32,
|
||||
api_hash: String,
|
||||
system_language_code: Option<String>,
|
||||
device_model: Option<String>,
|
||||
system_version: Option<String>,
|
||||
application_version: Option<String>,
|
||||
}
|
||||
|
||||
// Ensure TdLibManager is Send + Sync for use with Tauri
|
||||
unsafe impl Send for TdLibManager {}
|
||||
unsafe impl Sync for TdLibManager {}
|
||||
@@ -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};
|
||||
@@ -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<number>} 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<object>} 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<object|null>} 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<void>}
|
||||
*/
|
||||
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<string>}
|
||||
*/
|
||||
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<string>}
|
||||
*/
|
||||
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');
|
||||
|
||||
@@ -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<u32>, Option<std::time::Duration>) {
|
||||
let timer_state = state.borrow_mut::<TimerState>();
|
||||
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<u32, (bool, u64)>,
|
||||
/// Timer cancellation senders
|
||||
pub cancel_senders: HashMap<u32, tokio::sync::oneshot::Sender<()>>,
|
||||
/// Active timers: id -> TimerEntry
|
||||
pub timers: HashMap<u32, TimerEntry>,
|
||||
}
|
||||
|
||||
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<u32> {
|
||||
let now = std::time::Instant::now();
|
||||
let mut ready = Vec::new();
|
||||
|
||||
// Collect IDs of ready timers
|
||||
let ready_ids: Vec<u32> = 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<std::time::Duration> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
// ============================================================================
|
||||
|
||||
/// 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::<TimerState>();
|
||||
|
||||
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::<TimerState>();
|
||||
|
||||
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::<SkillContext>();
|
||||
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<i32, deno_core::error::AnyError> {
|
||||
// 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<RefCell<OpState>>,
|
||||
#[serde] request: serde_json::Value,
|
||||
) -> Result<serde_json::Value, deno_core::error::AnyError> {
|
||||
// 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<RefCell<OpState>>,
|
||||
timeout_ms: u32,
|
||||
) -> Result<Option<serde_json::Value>, 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<RefCell<OpState>>,
|
||||
) -> 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<String, deno_core::error::AnyError> {
|
||||
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<String, deno_core::error::AnyError> {
|
||||
crate::services::llama::LLAMA_MANAGER
|
||||
.summarize(&text, max_tokens)
|
||||
.await
|
||||
.map_err(|e| deno_core::error::generic_error(e))
|
||||
}
|
||||
|
||||
@@ -36,7 +36,10 @@
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
],
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "10.15"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"deep-link": {
|
||||
|
||||
@@ -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 (
|
||||
<div className={`glass rounded-2xl p-3 shadow-large animate-fade-up ${className}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-shrink-0">
|
||||
<svg
|
||||
className="w-5 h-5 text-stone-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="font-medium text-sm">Local AI Model</span>
|
||||
<p className="text-xs opacity-60">Available on desktop only</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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: (
|
||||
<svg
|
||||
className="w-5 h-5 text-coral-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
title: 'Model Error',
|
||||
description: error,
|
||||
color: 'coral',
|
||||
};
|
||||
}
|
||||
|
||||
if (isLoading && downloadProgress !== null) {
|
||||
return {
|
||||
icon: (
|
||||
<svg
|
||||
className="w-5 h-5 text-primary-500 animate-pulse"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
title: 'Downloading Local AI Model',
|
||||
description: `${progressPercent}% complete (~1.2 GB).`,
|
||||
color: 'primary',
|
||||
};
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return {
|
||||
icon: (
|
||||
<svg
|
||||
className="w-5 h-5 text-primary-500 animate-spin"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
title: 'Loading Local AI Model',
|
||||
description: 'Initializing local inference engine...',
|
||||
color: 'primary',
|
||||
};
|
||||
}
|
||||
|
||||
if (isLoaded) {
|
||||
return {
|
||||
icon: (
|
||||
<svg
|
||||
className="w-5 h-5 text-sage-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
),
|
||||
title: 'AI Model Ready',
|
||||
description: 'Local inference available',
|
||||
color: 'sage',
|
||||
};
|
||||
}
|
||||
|
||||
// Not loaded, show download prompt
|
||||
return {
|
||||
icon: (
|
||||
<svg
|
||||
className="w-5 h-5 text-stone-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
title: 'Local AI Model',
|
||||
description: 'Download for offline summarization',
|
||||
color: 'stone',
|
||||
};
|
||||
};
|
||||
|
||||
const statusDisplay = getStatusDisplay();
|
||||
|
||||
return (
|
||||
<div className={`glass rounded-2xl p-3 shadow-large animate-fade-up ${className}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Icon */}
|
||||
<div className="flex-shrink-0">{statusDisplay.icon}</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="font-medium text-sm">{statusDisplay.title}</span>
|
||||
{!isLoaded && !isLoading && !error && (
|
||||
<button
|
||||
onClick={ensureLoaded}
|
||||
className="text-xs text-primary-500 hover:text-primary-400 transition-colors">
|
||||
Download
|
||||
</button>
|
||||
)}
|
||||
{error && (
|
||||
<button
|
||||
onClick={ensureLoaded}
|
||||
className="text-xs text-coral-500 hover:text-coral-400 transition-colors">
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs opacity-60 truncate">{statusDisplay.description}</p>
|
||||
|
||||
{/* Progress bar */}
|
||||
{isLoading && downloadProgress !== null && (
|
||||
<div className="mt-2 w-full bg-stone-700/50 rounded-full h-1.5 overflow-hidden">
|
||||
<div
|
||||
className="bg-primary-500 h-full rounded-full transition-all duration-300 ease-out"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelDownloadProgress;
|
||||
@@ -276,6 +276,7 @@ function SkillActionButton({
|
||||
export default function SkillsGrid() {
|
||||
const [skillsList, setSkillsList] = useState<SkillListEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [setupModalOpen, setSetupModalOpen] = useState(false);
|
||||
const [managementModalOpen, setManagementModalOpen] = useState(false);
|
||||
const [activeSkillId, setActiveSkillId] = useState<string | null>(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 (
|
||||
<div className="animate-fade-up mt-4 mb-8 relative">
|
||||
<h3 className="text-sm font-semibold text-white mb-3 px-1 opacity-80 text-center">
|
||||
Skills
|
||||
</h3>
|
||||
<div className="glass rounded-xl p-4 text-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<svg
|
||||
className="w-8 h-8 text-stone-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-sm text-stone-400">Skills are available on desktop only</p>
|
||||
<p className="text-xs text-stone-500">
|
||||
Use the desktop app to configure and run skills
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// If loading or no skills on desktop, don't render
|
||||
if (loading || skillsList.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ const SettingsHeader = ({
|
||||
const { closeSettings } = useSettingsNavigation();
|
||||
|
||||
return (
|
||||
<div className={`bg-black/30 border-b border-stone-700 py-3 px-4 relative ${className}`}>
|
||||
<div className={`bg-black/30 border-b border-stone-700 p-6 relative ${className}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
{/* Back button */}
|
||||
|
||||
@@ -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({
|
||||
<SetupFormRenderer
|
||||
step={state.step}
|
||||
loading={true}
|
||||
onSubmit={() => {}}
|
||||
onCancel={() => {}}
|
||||
onSubmit={() => { }}
|
||||
onCancel={() => { }}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -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<ModelStatus>(DEFAULT_STATUS);
|
||||
const [isPolling, setIsPolling] = useState(false);
|
||||
|
||||
// Fetch current status from backend
|
||||
const fetchStatus = useCallback(async () => {
|
||||
try {
|
||||
const result = await invoke<ModelStatus>('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<boolean>('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,
|
||||
};
|
||||
};
|
||||
@@ -128,14 +128,17 @@ class SkillManager {
|
||||
* Start the setup flow for a skill. Returns the first step.
|
||||
*/
|
||||
async startSetup(skillId: string): Promise<SetupStep> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -71,9 +71,11 @@ export class SkillRuntime {
|
||||
* Start the setup flow. Returns the first SetupStep.
|
||||
*/
|
||||
async setupStart(): Promise<SetupStep> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 */}
|
||||
<SkillsGrid />
|
||||
|
||||
<ModelDownloadProgress className="mb-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||