Merge pull request #35 from vezuresdotxyz/develop

chore: merge develop into main (v0.23.0)
This commit is contained in:
Steven Enamakel
2026-02-04 12:31:27 +05:30
committed by GitHub
100 changed files with 3988 additions and 480 deletions
+1 -9
View File
@@ -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
+116 -129
View File
@@ -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:
-2
View File
@@ -21,8 +21,6 @@ dist-ssr
.env.*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
+31 -31
View File
@@ -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
+7 -1
View File
@@ -1 +1,7 @@
{ "recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"] }
{
"recommendations": [
"tauri-apps.tauri-vscode",
"rust-lang.rust-analyzer",
"esbenp.prettier-vscode"
]
}
+13
View File
@@ -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" }
}
+10 -1
View File
@@ -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
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

+2
View File
@@ -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",
-54
View File
@@ -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);
}
+1 -1
Submodule skills updated: 54c40a1b0d...d9dfb8d974
+417 -12
View File
@@ -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"
+18 -3
View File
@@ -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!!
-33
View File
@@ -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.
+15
View File
@@ -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()
}
+1
View File
@@ -8,6 +8,7 @@
"core:default",
"opener:default",
"deep-link:default",
"os:default",
"core:tray:default",
"autostart:default",
"autostart:allow-enable",
+1 -3
View File
@@ -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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 634 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 894 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 816 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 32 KiB

+4
View File
@@ -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::*;
+359
View File
@@ -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())
}
}
+141
View File
@@ -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
}
}
+152 -14
View File
@@ -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,
]
}
})
+13 -3
View File
@@ -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)
}
}
}
}
+64 -9
View File
@@ -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
+6 -10
View File
@@ -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 })?;
+305 -136
View File
@@ -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);
}}
}})()"#
);
+464
View File
@@ -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 {}
+11
View File
@@ -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;
+9
View File
@@ -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;
+547
View File
@@ -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 {}
+10
View File
@@ -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};
+184
View File
@@ -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');
+247 -23
View File
@@ -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))
}
+4 -1
View File
@@ -36,7 +36,10 @@
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
],
"macOS": {
"minimumSystemVersion": "10.15"
}
},
"plugins": {
"deep-link": {
+225
View File
@@ -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;
+46 -1
View File
@@ -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 */}
+11 -2
View File
@@ -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={() => { }}
/>
);
+124
View File
@@ -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,
};
};
+9 -1
View File
@@ -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;
}
+2
View File
@@ -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;
}
+3
View File
@@ -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>
+22
View File
@@ -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) {
+2
View File
@@ -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;
+7
View File
@@ -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"