mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Fix/prod build (#51)
* chore: add CI secrets management and local testing script - Added ci-secrets.example.json to provide a template for CI secrets configuration. - Introduced test-ci-local.sh script to facilitate local testing of the package-and-publish workflow using act. - Updated .gitignore to exclude the actual ci-secrets.json file containing sensitive tokens. * chore: enhance local testing and environment loading scripts - Added scripts to load environment variables from .env and JSON files, improving local development setup. - Introduced ci-event.json to simulate GitHub event payloads for local CI testing. - Updated test-ci-local.sh to utilize the new event JSON for better integration with local testing workflows. - Modified .gitignore to include ci-secrets.local.json for local secret management. * chore: enhance environment loading and improve V8 memory management - Updated load-env.sh to conditionally load ci-secrets.local.json and set APPLE_PASSWORD for notarization. - Introduced a delay in auto-starting skills to prevent memory spikes during initialization. - Adjusted V8 runtime memory settings to minimize initial heap allocation, reducing the risk of OOM errors. * chore: update version bump workflow to modify tauri.conf.json - Changed the version update process to reflect changes in tauri.conf.json instead of Cargo.toml. - Adjusted the commit message to indicate the inclusion of tauri.conf.json in the version bump process. * chore: migrate from V8 to QuickJS runtime and update related configurations - Replaced V8 runtime references with QuickJS throughout the codebase, including updates to initialization and error handling. - Modified package.json to change the build command for macOS to source environment variables correctly. - Updated Cargo.toml to remove deno_core dependency and include rquickjs with appropriate features. - Cleaned up unused V8-related code and comments in the runtime modules. - Adjusted skill manifest checks to support QuickJS compatibility. * refactor: implement QuickJS runtime and remove V8 references - Replaced V8 engine with QuickJS in the runtime module, updating related imports and initialization logic. - Introduced new `qjs_engine.rs` and `qjs_skill_instance.rs` files to handle QuickJS-specific functionality. - Removed the deprecated `v8_skill_instance.rs` and associated V8-related code. - Updated JavaScript bootstrap code to align with QuickJS operations and APIs. - Adjusted documentation and comments to reflect the transition to QuickJS. * refactor: update IdbStorage to use parking_lot::Mutex for improved concurrency - Changed the connection management in IdbStorage from RwLock to parking_lot::Mutex for better performance. - Updated related methods to reflect the new locking mechanism, enhancing the efficiency of database operations. - Adjusted the IdbOpenResult struct to include serde::Serialize for potential serialization needs. * refactor: update QuickJS skill instance and timer management - Modified memory limit and stack size settings in QjsSkillInstance to be asynchronous, improving performance. - Cleaned up imports in qjs_ops.rs by removing unused dependencies and enhancing function definitions for clarity. - Refactored timer management functions for better readability and efficiency, including renaming and restructuring comments. * chore: update package.json scripts and clean up build.rs - Added a new script for running the Tauri app with environment variable loading. - Simplified the macOS development command to use a dedicated build script. - Removed unnecessary environment variable logging from build.rs to streamline the build process. - Cleaned up commented-out sections in the GitHub Actions workflow for Android packaging.
This commit is contained in:
@@ -402,127 +402,6 @@ jobs:
|
|||||||
name: ${{ steps.file-info.outputs.filename }}
|
name: ${{ steps.file-info.outputs.filename }}
|
||||||
path: ${{ steps.file-info.outputs.path }}/${{ 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: ${{ 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: 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 Android SDK
|
|
||||||
# uses: android-actions/setup-android@v3
|
|
||||||
|
|
||||||
# - 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: 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: 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: 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 (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: 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 }}"
|
|
||||||
|
|
||||||
publish-release:
|
publish-release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
# needs: [get-version, check-version, create-release, package-tauri, package-android]
|
# needs: [get-version, check-version, create-release, package-tauri, package-android]
|
||||||
|
|||||||
@@ -75,11 +75,15 @@ jobs:
|
|||||||
require('fs').writeFileSync('package.json', JSON.stringify(p, null, 2) + '\n');
|
require('fs').writeFileSync('package.json', JSON.stringify(p, null, 2) + '\n');
|
||||||
"
|
"
|
||||||
|
|
||||||
# Update Cargo.toml to match
|
# Update tauri.conf.json to match
|
||||||
sed -i 's/^version = .*/version = "'"$NEW_VERSION"'"/' src-tauri/Cargo.toml
|
node -e "
|
||||||
|
const c = require('./src-tauri/tauri.conf.json');
|
||||||
|
c.version = process.env.NEW_VERSION;
|
||||||
|
require('fs').writeFileSync('src-tauri/tauri.conf.json', JSON.stringify(c, null, 2) + '\n');
|
||||||
|
"
|
||||||
|
|
||||||
# Single commit and tag for both npm and Cargo
|
# Single commit and tag for package.json and tauri.conf.json
|
||||||
git add package.json src-tauri/Cargo.toml
|
git add package.json src-tauri/tauri.conf.json
|
||||||
git commit -m "chore: bump version to $NEW_VERSION [skip ci]"
|
git commit -m "chore: bump version to $NEW_VERSION [skip ci]"
|
||||||
git tag "v$NEW_VERSION"
|
git tag "v$NEW_VERSION"
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ dist-ssr
|
|||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
|
||||||
|
# CI secrets for local testing (contains real tokens)
|
||||||
|
scripts/ci-secrets.json
|
||||||
|
scripts/ci-secrets.local.json
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.idea
|
.idea
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
+5
-4
@@ -6,18 +6,19 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"dev:web": "vite",
|
"dev:web": "vite",
|
||||||
|
"dev:app": "source scripts/load-dotenv.sh && tauri dev",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"build:app": "yarn skills:build && tsc && vite build",
|
"build:app": "yarn skills:build && tsc && vite build",
|
||||||
"compile": "tsc --noEmit",
|
"compile": "tsc --noEmit",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
"macos:build": "tauri build --debug --bundles app && ./src-tauri/scripts/bundle-tdlib-macos.sh debug",
|
"macos:build:debug": "yarn macos:build:release --debug",
|
||||||
"macos:build:release": "tauri build --bundles app dmg && ./src-tauri/scripts/bundle-tdlib-macos.sh release",
|
"macos:build:release": "source scripts/load-dotenv.sh && tauri build --bundles app dmg",
|
||||||
|
"macos:build:sign:release": "source scripts/load-env.sh && tauri build --bundles app dmg",
|
||||||
"macos:run": "open 'src-tauri/target/debug/bundle/macos/AlphaHuman.app'",
|
"macos:run": "open 'src-tauri/target/debug/bundle/macos/AlphaHuman.app'",
|
||||||
"macos:dev": "tauri build --debug --bundles app && ./src-tauri/scripts/bundle-tdlib-macos.sh debug && open 'src-tauri/target/debug/bundle/macos/AlphaHuman.app'",
|
"macos:dev": "yarn macos:build:debug && open 'src-tauri/target/debug/bundle/macos/AlphaHuman.app'",
|
||||||
"android:dev": "tauri android dev",
|
"android:dev": "tauri android dev",
|
||||||
"android:build": "tauri android build",
|
"android:build": "tauri android build",
|
||||||
"dev:app": "RUST_BACKTRACE=1 RUST_LOG=info tauri dev",
|
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"ref": "refs/heads/develop",
|
||||||
|
"before": "0000000000000000000000000000000000000000",
|
||||||
|
"after": "19281e16457e7c8ff8e6bda6ceda77f0880d10d2",
|
||||||
|
"repository": {
|
||||||
|
"full_name": "vezuresdotxyz/alphahuman-frontend-runner",
|
||||||
|
"default_branch": "main",
|
||||||
|
"name": "alphahuman-frontend-runner",
|
||||||
|
"owner": { "login": "vezuresdotxyz" }
|
||||||
|
},
|
||||||
|
"head_commit": {
|
||||||
|
"id": "19281e16457e7c8ff8e6bda6ceda77f0880d10d2",
|
||||||
|
"message": "local test build"
|
||||||
|
},
|
||||||
|
"sender": { "login": "local-dev" }
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"secrets": {
|
||||||
|
"XGH_TOKEN": "",
|
||||||
|
"GITHUB_TOKEN": "",
|
||||||
|
"UPDATER_GIST_URL": "",
|
||||||
|
"UPDATER_GIST_ID": "",
|
||||||
|
"UPDATER_PUBLIC_KEY": "",
|
||||||
|
"UPDATER_PRIVATE_KEY": "",
|
||||||
|
"UPDATER_PRIVATE_KEY_PASSWORD": "",
|
||||||
|
"APPLE_CERTIFICATE_BASE64": "",
|
||||||
|
"APPLE_CERTIFICATE_PASSWORD": "",
|
||||||
|
"APPLE_SIGNING_IDENTITY": "",
|
||||||
|
"APPLE_ID": "",
|
||||||
|
"APPLE_APP_SPECIFIC_PASSWORD": "",
|
||||||
|
"APPLE_TEAM_ID": "",
|
||||||
|
"VITE_TELEGRAM_BOT_USERNAME": "",
|
||||||
|
"VITE_TELEGRAM_BOT_ID": "",
|
||||||
|
"VITE_TELEGRAM_API_ID": "",
|
||||||
|
"VITE_TELEGRAM_API_HASH": "",
|
||||||
|
"VITE_SENTRY_DSN": ""
|
||||||
|
},
|
||||||
|
"vars": {
|
||||||
|
"BASE_URL": "https://localhost",
|
||||||
|
"VITE_BACKEND_URL": "https://localhost:5005",
|
||||||
|
"VITE_SKILLS_GITHUB_REPO": "",
|
||||||
|
"VITE_DEBUG": "true"
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+47
@@ -0,0 +1,47 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Load .env file into environment variables.
|
||||||
|
# Usage:
|
||||||
|
# source scripts/load-dotenv.sh [path/to/.env]
|
||||||
|
# eval "$(scripts/load-dotenv.sh [path/to/.env])"
|
||||||
|
# Default path: .env (project root when run from repo root)
|
||||||
|
|
||||||
|
set -e
|
||||||
|
FILE="${1:-.env}"
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
|
||||||
|
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
RESOLVED="${1:+$1}"
|
||||||
|
RESOLVED="${RESOLVED:-$ROOT_DIR/.env}"
|
||||||
|
|
||||||
|
if [[ ! -f "$RESOLVED" ]]; then
|
||||||
|
echo "File not found: $RESOLVED" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
exports=()
|
||||||
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||||
|
line="${line%%#*}"
|
||||||
|
line="${line#"${line%%[![:space:]]*}"}"
|
||||||
|
line="${line%"${line##*[![:space:]]}"}"
|
||||||
|
[[ -z "$line" ]] && continue
|
||||||
|
if [[ "$line" == export\ * ]]; then
|
||||||
|
line="${line#export }"
|
||||||
|
fi
|
||||||
|
if [[ "$line" == *"="* ]]; then
|
||||||
|
key="${line%%=*}"
|
||||||
|
key="${key%"${key##*[![:space:]]}"}"
|
||||||
|
value="${line#*=}"
|
||||||
|
value="${value#\"}"
|
||||||
|
value="${value%\"}"
|
||||||
|
value="${value#\'}"
|
||||||
|
value="${value%\'}"
|
||||||
|
[[ -n "$key" ]] && exports+=("$(printf 'export %s=%q' "$key" "$value")")
|
||||||
|
fi
|
||||||
|
done < "$RESOLVED"
|
||||||
|
|
||||||
|
joined=$(printf '%s\n' "${exports[@]}")
|
||||||
|
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
echo "$joined"
|
||||||
|
else
|
||||||
|
eval "$joined"
|
||||||
|
fi
|
||||||
Executable
+29
@@ -0,0 +1,29 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Load key-value JSON into environment variables.
|
||||||
|
# Usage:
|
||||||
|
# source scripts/load-env-json.sh path/to/file.json
|
||||||
|
# eval "$(scripts/load-env-json.sh path/to/file.json)"
|
||||||
|
# Optional jq filter to select object (default: .):
|
||||||
|
# source scripts/load-env-json.sh ci-secrets.json '.secrets + .vars'
|
||||||
|
|
||||||
|
set -e
|
||||||
|
FILE="${1:?Usage: $0 <file.json> [jq-filter]}"
|
||||||
|
FILTER="${2:-.}"
|
||||||
|
|
||||||
|
if [[ ! -f "$FILE" ]]; then
|
||||||
|
echo "File not found: $FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v jq &>/dev/null; then
|
||||||
|
echo "jq is required" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
exports=$(jq -r "${FILTER} | to_entries | .[] | \"export \(.key)=\(.value | @sh)\"" "$FILE")
|
||||||
|
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
echo "$exports"
|
||||||
|
else
|
||||||
|
eval "$exports"
|
||||||
|
fi
|
||||||
Executable
+17
@@ -0,0 +1,17 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Load .env file into environment variables and optional ci-secrets for signing/notarization.
|
||||||
|
# Usage:
|
||||||
|
# source scripts/load-env.sh
|
||||||
|
# eval "$(scripts/load-env.sh)"
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
source scripts/load-dotenv.sh
|
||||||
|
|
||||||
|
if [[ -f scripts/ci-secrets.local.json ]]; then
|
||||||
|
source scripts/load-env-json.sh scripts/ci-secrets.local.json
|
||||||
|
# Tauri notarization expects APPLE_PASSWORD; secrets file uses APPLE_APP_SPECIFIC_PASSWORD
|
||||||
|
if [[ -z "${APPLE_PASSWORD:-}" && -n "${APPLE_APP_SPECIFIC_PASSWORD:-}" ]]; then
|
||||||
|
export APPLE_PASSWORD="$APPLE_APP_SPECIFIC_PASSWORD"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
Executable
+152
@@ -0,0 +1,152 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Test the package-and-publish workflow locally using `act`.
|
||||||
|
#
|
||||||
|
# Prerequisites:
|
||||||
|
# brew install act jq
|
||||||
|
#
|
||||||
|
# Setup:
|
||||||
|
# cp scripts/ci-secrets.example.json scripts/ci-secrets.json
|
||||||
|
# # Edit scripts/ci-secrets.json with your real values
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/test-ci-local.sh # Run full workflow via act
|
||||||
|
# ./scripts/test-ci-local.sh --manual # Run build steps natively on macOS (recommended)
|
||||||
|
# ./scripts/test-ci-local.sh --list # List available jobs
|
||||||
|
# ./scripts/test-ci-local.sh --dryrun # Dry-run (show what would execute)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(git rev-parse --show-toplevel)"
|
||||||
|
|
||||||
|
# ── Configuration ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
WORKFLOW=".github/workflows/package-and-publish.yml"
|
||||||
|
SECRETS_JSON="scripts/ci-secrets.json"
|
||||||
|
EVENT_JSON="scripts/ci-event.json"
|
||||||
|
|
||||||
|
if [[ ! -f "$SECRETS_JSON" ]]; then
|
||||||
|
echo "ERROR: $SECRETS_JSON not found."
|
||||||
|
echo ""
|
||||||
|
echo "Create it from the example:"
|
||||||
|
echo " cp scripts/ci-secrets.example.json scripts/ci-secrets.json"
|
||||||
|
echo " # then fill in your values"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Generate event payload with current HEAD ──────────────────────────────────
|
||||||
|
|
||||||
|
CURRENT_REF=$(git rev-parse HEAD)
|
||||||
|
cat > "$EVENT_JSON" <<EOF
|
||||||
|
{
|
||||||
|
"ref": "refs/heads/develop",
|
||||||
|
"before": "0000000000000000000000000000000000000000",
|
||||||
|
"after": "$CURRENT_REF",
|
||||||
|
"repository": {
|
||||||
|
"full_name": "vezuresdotxyz/alphahuman-frontend-runner",
|
||||||
|
"default_branch": "main",
|
||||||
|
"name": "alphahuman-frontend-runner",
|
||||||
|
"owner": { "login": "vezuresdotxyz" }
|
||||||
|
},
|
||||||
|
"head_commit": {
|
||||||
|
"id": "$CURRENT_REF",
|
||||||
|
"message": "local test build"
|
||||||
|
},
|
||||||
|
"sender": { "login": "local-dev" }
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# ── Convert JSON to act-compatible KEY=VALUE files ────────────────────────────
|
||||||
|
|
||||||
|
SECRETS_FILE=$(mktemp)
|
||||||
|
VARS_FILE=$(mktemp)
|
||||||
|
trap 'rm -f "$SECRETS_FILE" "$VARS_FILE"' EXIT
|
||||||
|
|
||||||
|
# Extract "secrets" object → KEY=VALUE
|
||||||
|
jq -r '.secrets // {} | to_entries[] | "\(.key)=\(.value)"' "$SECRETS_JSON" > "$SECRETS_FILE"
|
||||||
|
|
||||||
|
# Extract "vars" object → KEY=VALUE
|
||||||
|
jq -r '.vars // {} | to_entries[] | "\(.key)=\(.value)"' "$SECRETS_JSON" > "$VARS_FILE"
|
||||||
|
|
||||||
|
echo "Loaded $(wc -l < "$SECRETS_FILE" | tr -d ' ') secrets and $(wc -l < "$VARS_FILE" | tr -d ' ') vars from $SECRETS_JSON"
|
||||||
|
|
||||||
|
# ── Common act arguments ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
ACT_ARGS=(
|
||||||
|
-W "$WORKFLOW"
|
||||||
|
--secret-file "$SECRETS_FILE"
|
||||||
|
--var-file "$VARS_FILE"
|
||||||
|
--eventpath "$EVENT_JSON"
|
||||||
|
-P ubuntu-latest=catthehacker/ubuntu:act-latest
|
||||||
|
-P macos-latest=-self-hosted
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Handle CLI flags ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "--list" ]]; then
|
||||||
|
echo "Available jobs in $WORKFLOW:"
|
||||||
|
act -W "$WORKFLOW" --list
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "--dryrun" ]]; then
|
||||||
|
echo "Dry-run of workflow:"
|
||||||
|
act push "${ACT_ARGS[@]}" -n
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Manual macOS-native build (recommended) ───────────────────────────────────
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "--manual" ]]; then
|
||||||
|
echo "=== Running build steps manually on macOS host ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Export VITE_* vars from the JSON so the frontend build picks them up
|
||||||
|
eval "$(jq -r '
|
||||||
|
(.secrets // {}) + (.vars // {})
|
||||||
|
| to_entries[]
|
||||||
|
| select(.key | startswith("VITE_"))
|
||||||
|
| "export \(.key)=\(.value | @sh)"
|
||||||
|
' "$SECRETS_JSON")"
|
||||||
|
|
||||||
|
# Step 1: Ensure OpenSSL is installed
|
||||||
|
echo ">>> Step 1: Ensure OpenSSL is installed"
|
||||||
|
brew install openssl@3 2>/dev/null || true
|
||||||
|
|
||||||
|
# Step 2: Install Node dependencies
|
||||||
|
echo ">>> Step 2: Install Node dependencies"
|
||||||
|
yarn install --frozen-lockfile
|
||||||
|
|
||||||
|
# Step 3: Install skills dependencies and build
|
||||||
|
echo ">>> Step 3: Build skills"
|
||||||
|
(cd skills && yarn install --frozen-lockfile && yarn build)
|
||||||
|
|
||||||
|
# Step 4: Build frontend
|
||||||
|
echo ">>> Step 4: Build frontend"
|
||||||
|
NODE_ENV=production yarn build
|
||||||
|
|
||||||
|
# Step 5: Build Tauri (aarch64)
|
||||||
|
echo ">>> Step 5: Build Tauri app (aarch64-apple-darwin)"
|
||||||
|
yarn tauri build --target aarch64-apple-darwin
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Build complete ==="
|
||||||
|
echo "Check src-tauri/target/aarch64-apple-darwin/release/bundle/ for output"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Default: run full workflow with act ────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# We run the full workflow (not -j single-job) so act executes the dependency
|
||||||
|
# chain: get-version → check-version → create-release (skipped) → package-tauri.
|
||||||
|
# Using -j package-tauri alone fails because act can't resolve outputs from
|
||||||
|
# skipped `needs` jobs.
|
||||||
|
|
||||||
|
echo "=== Testing package-and-publish workflow locally ==="
|
||||||
|
echo ""
|
||||||
|
echo "Workflow: $WORKFLOW"
|
||||||
|
echo "Event: push to develop (from $EVENT_JSON)"
|
||||||
|
echo ""
|
||||||
|
echo "NOTE: act uses Docker containers — macOS-specific steps won't work."
|
||||||
|
echo "For a native macOS build, use: $0 --manual"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
act push "${ACT_ARGS[@]}" --verbose
|
||||||
+1
-1
Submodule skills updated: d9dfb8d974...f78b3319d8
Generated
+104
-498
File diff suppressed because it is too large
Load Diff
@@ -93,7 +93,7 @@ tauri-plugin-autostart = "2"
|
|||||||
tauri-plugin-notification = "2"
|
tauri-plugin-notification = "2"
|
||||||
# V8 JavaScript runtime (via deno_core - powers Deno)
|
# V8 JavaScript runtime (via deno_core - powers Deno)
|
||||||
# Only available on desktop - prebuilt binaries don't exist for Android/iOS
|
# Only available on desktop - prebuilt binaries don't exist for Android/iOS
|
||||||
deno_core = "0.314"
|
rquickjs = { version = "0.11", features = ["futures", "macro", "loader", "parallel"] }
|
||||||
# TDLib Rust bindings (desktop only - downloads prebuilt TDLib library)
|
# TDLib Rust bindings (desktop only - downloads prebuilt TDLib library)
|
||||||
tdlib-rs = { version = "1.2", features = ["download-tdlib"] }
|
tdlib-rs = { version = "1.2", features = ["download-tdlib"] }
|
||||||
# Local LLM inference - desktop only (llama.cpp requires native C++ compilation)
|
# Local LLM inference - desktop only (llama.cpp requires native C++ compilation)
|
||||||
|
|||||||
+1
-192
@@ -1,195 +1,4 @@
|
|||||||
fn main() {
|
fn main() {
|
||||||
// Get the target OS from environment variable (set by Cargo during cross-compilation)
|
tdlib_rs::build::build(None);
|
||||||
let target = std::env::var("TARGET").unwrap_or_default();
|
|
||||||
let is_mobile_target = target.contains("android") || target.contains("ios");
|
|
||||||
let is_macos_target = target.contains("apple") && !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);
|
|
||||||
|
|
||||||
// On macOS, copy TDLib and its dependencies to libraries/ so that
|
|
||||||
// tauri.conf.json > bundle > macOS > frameworks can bundle them into
|
|
||||||
// the .app's Contents/Frameworks/ directory.
|
|
||||||
if is_macos_target {
|
|
||||||
prepare_macos_libraries();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add @executable_path/../Frameworks to rpath so the binary can find
|
|
||||||
// bundled dylibs at runtime (macOS only).
|
|
||||||
if is_macos_target && !is_mobile_target {
|
|
||||||
println!("cargo:rustc-link-arg=-Wl,-rpath,@executable_path/../Frameworks");
|
|
||||||
}
|
|
||||||
|
|
||||||
tauri_build::build()
|
tauri_build::build()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Copy TDLib dylib and its non-system dependencies (OpenSSL) to src-tauri/libraries/
|
|
||||||
/// and rewrite their install names to use @rpath so they work inside Contents/Frameworks/.
|
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
|
||||||
fn prepare_macos_libraries() {
|
|
||||||
use std::fs;
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR not set");
|
|
||||||
let tdlib_lib_dir = PathBuf::from(&out_dir).join("tdlib").join("lib");
|
|
||||||
let manifest_dir =
|
|
||||||
PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set"));
|
|
||||||
let dest_dir = manifest_dir.join("libraries");
|
|
||||||
|
|
||||||
// Create libraries/ directory
|
|
||||||
fs::create_dir_all(&dest_dir).expect("Failed to create libraries directory");
|
|
||||||
|
|
||||||
let tdlib_dylib = "libtdjson.1.8.29.dylib";
|
|
||||||
let tdlib_src = tdlib_lib_dir.join(tdlib_dylib);
|
|
||||||
|
|
||||||
if !tdlib_src.exists() {
|
|
||||||
println!(
|
|
||||||
"cargo:warning=TDLib dylib not found at {}, skipping library preparation",
|
|
||||||
tdlib_src.display()
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy TDLib dylib
|
|
||||||
let tdlib_dest = dest_dir.join(tdlib_dylib);
|
|
||||||
fs::copy(&tdlib_src, &tdlib_dest).expect("Failed to copy TDLib dylib");
|
|
||||||
make_writable(&tdlib_dest);
|
|
||||||
|
|
||||||
// Fix TDLib's install name
|
|
||||||
run_install_name_tool(&["-id", &format!("@rpath/{tdlib_dylib}"), tdlib_dest.to_str().unwrap()]);
|
|
||||||
|
|
||||||
// Find and bundle non-system dependencies (e.g. OpenSSL from Homebrew)
|
|
||||||
let deps = get_non_system_deps(&tdlib_dest);
|
|
||||||
for dep_path in &deps {
|
|
||||||
let dep_name = Path::new(dep_path)
|
|
||||||
.file_name()
|
|
||||||
.unwrap()
|
|
||||||
.to_str()
|
|
||||||
.unwrap();
|
|
||||||
let dep_dest = dest_dir.join(dep_name);
|
|
||||||
|
|
||||||
if Path::new(dep_path).exists() {
|
|
||||||
fs::copy(dep_path, &dep_dest).unwrap_or_else(|e| panic!("Failed to copy {dep_name}: {e}"));
|
|
||||||
make_writable(&dep_dest);
|
|
||||||
|
|
||||||
// Fix the dependency's install name
|
|
||||||
run_install_name_tool(&[
|
|
||||||
"-id",
|
|
||||||
&format!("@rpath/{dep_name}"),
|
|
||||||
dep_dest.to_str().unwrap(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Update TDLib's reference to this dependency
|
|
||||||
run_install_name_tool(&[
|
|
||||||
"-change",
|
|
||||||
dep_path,
|
|
||||||
&format!("@rpath/{dep_name}"),
|
|
||||||
tdlib_dest.to_str().unwrap(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
println!("cargo:warning=Bundled dependency: {dep_name}");
|
|
||||||
} else {
|
|
||||||
println!("cargo:warning=Dependency not found: {dep_path}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fix cross-references between dependencies (e.g. libssl -> libcrypto)
|
|
||||||
let bundled_libs: Vec<PathBuf> = fs::read_dir(&dest_dir)
|
|
||||||
.unwrap()
|
|
||||||
.filter_map(|e| e.ok())
|
|
||||||
.map(|e| e.path())
|
|
||||||
.filter(|p| p.extension().is_some_and(|ext| ext == "dylib"))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
for lib in &bundled_libs {
|
|
||||||
let lib_deps = get_non_system_deps(lib);
|
|
||||||
for dep_path in &lib_deps {
|
|
||||||
let dep_name = Path::new(dep_path)
|
|
||||||
.file_name()
|
|
||||||
.unwrap()
|
|
||||||
.to_str()
|
|
||||||
.unwrap();
|
|
||||||
// Only fix if the dep is one we bundled
|
|
||||||
if dest_dir.join(dep_name).exists() {
|
|
||||||
run_install_name_tool(&[
|
|
||||||
"-change",
|
|
||||||
dep_path,
|
|
||||||
&format!("@rpath/{dep_name}"),
|
|
||||||
lib.to_str().unwrap(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("cargo:warning=TDLib libraries prepared in libraries/");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get non-system dependencies of a dylib using otool
|
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
|
||||||
fn get_non_system_deps(dylib_path: &std::path::Path) -> Vec<String> {
|
|
||||||
use std::process::Command;
|
|
||||||
|
|
||||||
let output = Command::new("otool")
|
|
||||||
.args(["-L", dylib_path.to_str().unwrap()])
|
|
||||||
.output()
|
|
||||||
.expect("Failed to run otool");
|
|
||||||
|
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
||||||
stdout
|
|
||||||
.lines()
|
|
||||||
.skip(1) // First line is the dylib itself
|
|
||||||
.filter_map(|line| {
|
|
||||||
let path = line.trim().split_whitespace().next()?;
|
|
||||||
// Keep only non-system, non-rpath paths (e.g. Homebrew libs)
|
|
||||||
if path.starts_with("/usr/lib/")
|
|
||||||
|| path.starts_with("/System/")
|
|
||||||
|| path.starts_with("@rpath/")
|
|
||||||
|| path.starts_with("@executable_path/")
|
|
||||||
{
|
|
||||||
None
|
|
||||||
} else if path.starts_with("/opt/homebrew/") || path.starts_with("/usr/local/") {
|
|
||||||
Some(path.to_string())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Make a file writable (Homebrew libs are read-only)
|
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
|
||||||
fn make_writable(path: &std::path::Path) {
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
let mut perms = std::fs::metadata(path)
|
|
||||||
.expect("Failed to read file metadata")
|
|
||||||
.permissions();
|
|
||||||
perms.set_mode(0o755);
|
|
||||||
std::fs::set_permissions(path, perms).expect("Failed to set file permissions");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Run install_name_tool with the given arguments
|
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
|
||||||
fn run_install_name_tool(args: &[&str]) {
|
|
||||||
use std::process::Command;
|
|
||||||
|
|
||||||
let status = Command::new("install_name_tool")
|
|
||||||
.args(args)
|
|
||||||
.status()
|
|
||||||
.expect("Failed to run install_name_tool");
|
|
||||||
|
|
||||||
if !status.success() {
|
|
||||||
println!(
|
|
||||||
"cargo:warning=install_name_tool failed with args: {}",
|
|
||||||
args.join(" ")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 268 KiB After Width: | Height: | Size: 275 KiB |
@@ -14,7 +14,7 @@ use tauri::State;
|
|||||||
|
|
||||||
// Desktop-only imports
|
// Desktop-only imports
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
use crate::runtime::v8_engine::RuntimeEngine;
|
use crate::runtime::qjs_engine::RuntimeEngine;
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
use crate::runtime::types::{SkillSnapshot, ToolResult};
|
use crate::runtime::types::{SkillSnapshot, ToolResult};
|
||||||
|
|
||||||
|
|||||||
+17
-25
@@ -84,7 +84,7 @@ macro_rules! common_handlers {
|
|||||||
ai_read_memory_file,
|
ai_read_memory_file,
|
||||||
ai_write_memory_file,
|
ai_write_memory_file,
|
||||||
ai_list_memory_files,
|
ai_list_memory_files,
|
||||||
// V8 runtime commands
|
// Runtime commands
|
||||||
runtime_discover_skills,
|
runtime_discover_skills,
|
||||||
runtime_list_skills,
|
runtime_list_skills,
|
||||||
runtime_start_skill,
|
runtime_start_skill,
|
||||||
@@ -93,14 +93,14 @@ macro_rules! common_handlers {
|
|||||||
runtime_call_tool,
|
runtime_call_tool,
|
||||||
runtime_all_tools,
|
runtime_all_tools,
|
||||||
runtime_broadcast_event,
|
runtime_broadcast_event,
|
||||||
// V8 runtime enable/disable + KV commands
|
// Runtime enable/disable + KV commands
|
||||||
runtime_enable_skill,
|
runtime_enable_skill,
|
||||||
runtime_disable_skill,
|
runtime_disable_skill,
|
||||||
runtime_is_skill_enabled,
|
runtime_is_skill_enabled,
|
||||||
runtime_get_skill_preferences,
|
runtime_get_skill_preferences,
|
||||||
runtime_skill_kv_get,
|
runtime_skill_kv_get,
|
||||||
runtime_skill_kv_set,
|
runtime_skill_kv_set,
|
||||||
// V8 runtime JSON-RPC + data commands
|
// Runtime JSON-RPC + data commands
|
||||||
runtime_rpc,
|
runtime_rpc,
|
||||||
runtime_skill_data_read,
|
runtime_skill_data_read,
|
||||||
runtime_skill_data_write,
|
runtime_skill_data_write,
|
||||||
@@ -207,15 +207,6 @@ fn setup_tray(app: &AppHandle) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
// Load .env file (silently ignore if missing — production won't have one)
|
|
||||||
// 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
|
// Initialize platform-appropriate logger
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
@@ -289,7 +280,7 @@ pub fn run() {
|
|||||||
);
|
);
|
||||||
socket_mgr.set_app_handle(app.handle().clone());
|
socket_mgr.set_app_handle(app.handle().clone());
|
||||||
|
|
||||||
// Initialize V8 Runtime Engine (desktop only - V8 not available on Android/iOS)
|
// Initialize QuickJS Runtime Engine (desktop only - not available on Android/iOS)
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
{
|
{
|
||||||
let data_dir = app
|
let data_dir = app
|
||||||
@@ -308,7 +299,7 @@ pub fn run() {
|
|||||||
services::llama::LLAMA_MANAGER.set_data_dir(model_dir);
|
services::llama::LLAMA_MANAGER.set_data_dir(model_dir);
|
||||||
log::info!("[runtime] Local model service initialized");
|
log::info!("[runtime] Local model service initialized");
|
||||||
|
|
||||||
match runtime::v8_engine::RuntimeEngine::new(skills_data_dir) {
|
match runtime::qjs_engine::RuntimeEngine::new(skills_data_dir) {
|
||||||
Ok(engine) => {
|
Ok(engine) => {
|
||||||
engine.set_app_handle(app.handle().clone());
|
engine.set_app_handle(app.handle().clone());
|
||||||
|
|
||||||
@@ -330,28 +321,29 @@ pub fn run() {
|
|||||||
cron.start();
|
cron.start();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-start skills in background
|
// Auto-start skills in background (no delay needed for QuickJS -
|
||||||
|
// lightweight contexts don't have V8's memory reservation issue)
|
||||||
let engine_clone = engine.clone();
|
let engine_clone = engine.clone();
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
engine_clone.auto_start_skills().await;
|
engine_clone.auto_start_skills().await;
|
||||||
});
|
});
|
||||||
|
|
||||||
log::info!("[runtime] V8 runtime engine initialized");
|
log::info!("[runtime] QuickJS runtime engine initialized");
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("[runtime] Failed to initialize V8 runtime: {e}");
|
log::error!("[runtime] Failed to initialize QuickJS runtime: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
{
|
{
|
||||||
log::info!("[runtime] V8 runtime and local model disabled on Android");
|
log::info!("[runtime] QuickJS runtime and local model disabled on Android");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "ios")]
|
#[cfg(target_os = "ios")]
|
||||||
{
|
{
|
||||||
log::info!("[runtime] V8 runtime and local model disabled on iOS");
|
log::info!("[runtime] QuickJS runtime and local model disabled on iOS");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store SocketManager as Tauri state
|
// Store SocketManager as Tauri state
|
||||||
@@ -433,7 +425,7 @@ pub fn run() {
|
|||||||
ai_read_memory_file,
|
ai_read_memory_file,
|
||||||
ai_write_memory_file,
|
ai_write_memory_file,
|
||||||
ai_list_memory_files,
|
ai_list_memory_files,
|
||||||
// V8 runtime commands
|
// Runtime commands
|
||||||
runtime_discover_skills,
|
runtime_discover_skills,
|
||||||
runtime_list_skills,
|
runtime_list_skills,
|
||||||
runtime_start_skill,
|
runtime_start_skill,
|
||||||
@@ -442,14 +434,14 @@ pub fn run() {
|
|||||||
runtime_call_tool,
|
runtime_call_tool,
|
||||||
runtime_all_tools,
|
runtime_all_tools,
|
||||||
runtime_broadcast_event,
|
runtime_broadcast_event,
|
||||||
// V8 runtime enable/disable + KV commands
|
// Runtime enable/disable + KV commands
|
||||||
runtime_enable_skill,
|
runtime_enable_skill,
|
||||||
runtime_disable_skill,
|
runtime_disable_skill,
|
||||||
runtime_is_skill_enabled,
|
runtime_is_skill_enabled,
|
||||||
runtime_get_skill_preferences,
|
runtime_get_skill_preferences,
|
||||||
runtime_skill_kv_get,
|
runtime_skill_kv_get,
|
||||||
runtime_skill_kv_set,
|
runtime_skill_kv_set,
|
||||||
// V8 runtime JSON-RPC + data commands
|
// Runtime JSON-RPC + data commands
|
||||||
runtime_rpc,
|
runtime_rpc,
|
||||||
runtime_skill_data_read,
|
runtime_skill_data_read,
|
||||||
runtime_skill_data_write,
|
runtime_skill_data_write,
|
||||||
@@ -525,7 +517,7 @@ pub fn run() {
|
|||||||
ai_read_memory_file,
|
ai_read_memory_file,
|
||||||
ai_write_memory_file,
|
ai_write_memory_file,
|
||||||
ai_list_memory_files,
|
ai_list_memory_files,
|
||||||
// V8 runtime commands
|
// Runtime commands
|
||||||
runtime_discover_skills,
|
runtime_discover_skills,
|
||||||
runtime_list_skills,
|
runtime_list_skills,
|
||||||
runtime_start_skill,
|
runtime_start_skill,
|
||||||
@@ -534,14 +526,14 @@ pub fn run() {
|
|||||||
runtime_call_tool,
|
runtime_call_tool,
|
||||||
runtime_all_tools,
|
runtime_all_tools,
|
||||||
runtime_broadcast_event,
|
runtime_broadcast_event,
|
||||||
// V8 runtime enable/disable + KV commands
|
// Runtime enable/disable + KV commands
|
||||||
runtime_enable_skill,
|
runtime_enable_skill,
|
||||||
runtime_disable_skill,
|
runtime_disable_skill,
|
||||||
runtime_is_skill_enabled,
|
runtime_is_skill_enabled,
|
||||||
runtime_get_skill_preferences,
|
runtime_get_skill_preferences,
|
||||||
runtime_skill_kv_get,
|
runtime_skill_kv_get,
|
||||||
runtime_skill_kv_set,
|
runtime_skill_kv_set,
|
||||||
// V8 runtime JSON-RPC + data commands
|
// Runtime JSON-RPC + data commands
|
||||||
runtime_rpc,
|
runtime_rpc,
|
||||||
runtime_skill_data_read,
|
runtime_skill_data_read,
|
||||||
runtime_skill_data_write,
|
runtime_skill_data_write,
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
//! Custom V8 module resolver and loader for `@alphahuman/*` imports.
|
//! Custom module resolver and loader for `@alphahuman/*` imports.
|
||||||
//!
|
//!
|
||||||
//! NOTE: Currently unused. Skills access bridge APIs via globals (db, store, console)
|
//! NOTE: Currently unused. Skills access bridge APIs via globals (db, store, console)
|
||||||
//! injected by v8_skill_instance.rs. This module is reserved for future ES module
|
//! injected by qjs_skill_instance.rs. This module is reserved for future ES module
|
||||||
//! import support (e.g., `import { db } from '@alphahuman/db'`).
|
//! import support (e.g., `import { db } from '@alphahuman/db'`).
|
||||||
//!
|
//!
|
||||||
//! The globals-based approach was chosen because:
|
//! The globals-based approach was chosen because:
|
||||||
//! - V8/deno_core shares the module loader across all contexts in the same runtime
|
|
||||||
//! - Per-skill module loaders would require separate runtime instances
|
|
||||||
//! - Globals are simpler and sufficient for the initial implementation
|
//! - Globals are simpler and sufficient for the initial implementation
|
||||||
|
//! - Per-skill module loaders can be added later if needed
|
||||||
|
|||||||
@@ -86,10 +86,10 @@ impl SkillManifest {
|
|||||||
Self::from_json(&content)
|
Self::from_json(&content)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether this manifest declares a JavaScript runtime (V8).
|
/// Whether this manifest declares a JavaScript runtime (QuickJS).
|
||||||
/// Accepts "v8", "javascript" for compatibility.
|
/// Accepts "v8", "javascript", "quickjs" for compatibility.
|
||||||
pub fn is_javascript(&self) -> bool {
|
pub fn is_javascript(&self) -> bool {
|
||||||
self.runtime == "v8" || self.runtime == "javascript"
|
matches!(self.runtime.as_str(), "v8" | "javascript" | "quickjs")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the skill is available on the current platform.
|
/// Whether the skill is available on the current platform.
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
//! V8 skill runtime module.
|
//! QuickJS skill runtime module.
|
||||||
//!
|
//!
|
||||||
//! Provides a persistent JavaScript execution environment for skills
|
//! Provides a persistent JavaScript execution environment for skills
|
||||||
//! using the V8 engine via `deno_core`.
|
//! using the QuickJS engine via `rquickjs`.
|
||||||
//!
|
//!
|
||||||
//! Note: V8/deno_core is only available on desktop platforms.
|
//! Note: The skill runtime is only available on desktop platforms.
|
||||||
//! On mobile (Android/iOS), the skill runtime is disabled.
|
//! On mobile (Android/iOS), the skill runtime is disabled.
|
||||||
|
|
||||||
// Platform-agnostic modules
|
// Platform-agnostic modules
|
||||||
@@ -13,7 +13,7 @@ pub mod preferences;
|
|||||||
pub mod socket_manager;
|
pub mod socket_manager;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
|
||||||
// V8/deno_core modules - desktop only (no prebuilt binaries for Android/iOS)
|
// QuickJS modules - desktop only (not available on Android/iOS)
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
pub mod bridge;
|
pub mod bridge;
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
@@ -21,6 +21,6 @@ pub mod cron_scheduler;
|
|||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
pub mod skill_registry;
|
pub mod skill_registry;
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
pub mod v8_engine;
|
pub mod qjs_engine;
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
pub mod v8_skill_instance;
|
pub mod qjs_skill_instance;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! RuntimeEngine — top-level orchestrator for the V8 skill runtime.
|
//! RuntimeEngine — top-level orchestrator for the QuickJS skill runtime.
|
||||||
//!
|
//!
|
||||||
//! Manages skill lifecycle and provides the public API consumed by Tauri commands.
|
//! Manages skill lifecycle and provides the public API consumed by Tauri commands.
|
||||||
//! Uses V8 (via deno_core) for JavaScript execution.
|
//! Uses QuickJS (via rquickjs) for JavaScript execution.
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -14,10 +14,10 @@ use crate::runtime::manifest::SkillManifest;
|
|||||||
use crate::runtime::preferences::PreferencesStore;
|
use crate::runtime::preferences::PreferencesStore;
|
||||||
use crate::runtime::skill_registry::SkillRegistry;
|
use crate::runtime::skill_registry::SkillRegistry;
|
||||||
use crate::runtime::types::{events, SkillSnapshot, SkillStatus, ToolResult};
|
use crate::runtime::types::{events, SkillSnapshot, SkillStatus, ToolResult};
|
||||||
use crate::runtime::v8_skill_instance::{BridgeDeps, V8SkillInstance};
|
use crate::runtime::qjs_skill_instance::{BridgeDeps, QjsSkillInstance};
|
||||||
use crate::services::tdlib_v8::storage::IdbStorage;
|
use crate::services::tdlib_v8::storage::IdbStorage;
|
||||||
|
|
||||||
/// The central runtime engine using V8.
|
/// The central runtime engine using QuickJS.
|
||||||
pub struct RuntimeEngine {
|
pub struct RuntimeEngine {
|
||||||
/// Registry of all skills.
|
/// Registry of all skills.
|
||||||
registry: Arc<SkillRegistry>,
|
registry: Arc<SkillRegistry>,
|
||||||
@@ -43,7 +43,7 @@ impl RuntimeEngine {
|
|||||||
cron_scheduler.set_registry(Arc::clone(®istry));
|
cron_scheduler.set_registry(Arc::clone(®istry));
|
||||||
let preferences = Arc::new(PreferencesStore::new(&skills_data_dir));
|
let preferences = Arc::new(PreferencesStore::new(&skills_data_dir));
|
||||||
|
|
||||||
log::info!("[runtime] V8 RuntimeEngine created");
|
log::info!("[runtime] QuickJS RuntimeEngine created");
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
registry,
|
registry,
|
||||||
@@ -111,9 +111,7 @@ impl RuntimeEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4. Production: bundled resources
|
// 4. Production: bundled resources
|
||||||
// Tauri converts "../" to "_up_/" when bundling resources with array notation
|
|
||||||
if let Some(resource_dir) = self.resource_dir.read().as_ref() {
|
if let Some(resource_dir) = self.resource_dir.read().as_ref() {
|
||||||
// Try: $RESOURCE/_up_/skills/skills/ (array notation with ../)
|
|
||||||
let bundled_skills = resource_dir.join("_up_").join("skills").join("skills");
|
let bundled_skills = resource_dir.join("_up_").join("skills").join("skills");
|
||||||
if bundled_skills.exists() {
|
if bundled_skills.exists() {
|
||||||
log::info!(
|
log::info!(
|
||||||
@@ -123,7 +121,6 @@ impl RuntimeEngine {
|
|||||||
return Ok(bundled_skills);
|
return Ok(bundled_skills);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try: $RESOURCE/skills/ (map notation)
|
|
||||||
let bundled_skills_alt = resource_dir.join("skills");
|
let bundled_skills_alt = resource_dir.join("skills");
|
||||||
if bundled_skills_alt.exists() {
|
if bundled_skills_alt.exists() {
|
||||||
log::info!(
|
log::info!(
|
||||||
@@ -140,7 +137,7 @@ impl RuntimeEngine {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Final fallback: app data dir (for user-installed skills)
|
// 5. Final fallback: app data dir
|
||||||
let prod_dir = self.skills_data_dir.clone();
|
let prod_dir = self.skills_data_dir.clone();
|
||||||
log::info!(
|
log::info!(
|
||||||
"[runtime] Skills source dir (data dir fallback): {:?}",
|
"[runtime] Skills source dir (data dir fallback): {:?}",
|
||||||
@@ -184,7 +181,6 @@ impl RuntimeEngine {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
// Not a JavaScript skill, skip
|
|
||||||
log::info!(
|
log::info!(
|
||||||
"[runtime] Skipping skill '{}': not a JavaScript skill",
|
"[runtime] Skipping skill '{}': not a JavaScript skill",
|
||||||
manifest_path.display()
|
manifest_path.display()
|
||||||
@@ -227,7 +223,7 @@ impl RuntimeEngine {
|
|||||||
let manifest = SkillManifest::from_path(&manifest_path).await?;
|
let manifest = SkillManifest::from_path(&manifest_path).await?;
|
||||||
if !manifest.is_javascript() {
|
if !manifest.is_javascript() {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Skill '{}' uses runtime '{}', not 'v8' or 'javascript'",
|
"Skill '{}' uses runtime '{}', not a supported JavaScript runtime",
|
||||||
skill_id, manifest.runtime
|
skill_id, manifest.runtime
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -235,15 +231,15 @@ impl RuntimeEngine {
|
|||||||
let config = manifest.to_config();
|
let config = manifest.to_config();
|
||||||
let data_dir = self.skills_data_dir.join(skill_id);
|
let data_dir = self.skills_data_dir.join(skill_id);
|
||||||
|
|
||||||
// Create the V8 skill instance
|
// Create the QuickJS skill instance
|
||||||
log::info!("[runtime] Creating V8 skill instance for '{}'", skill_id);
|
log::info!("[runtime] Creating QuickJS skill instance for '{}'", skill_id);
|
||||||
log::info!("[runtime] Config: {:?}", config);
|
log::info!("[runtime] Config: {:?}", config);
|
||||||
log::info!("[runtime] Skill dir: {:?}", skill_dir);
|
log::info!("[runtime] Skill dir: {:?}", skill_dir);
|
||||||
log::info!("[runtime] Data dir: {:?}", data_dir);
|
log::info!("[runtime] Data dir: {:?}", data_dir);
|
||||||
let (instance, rx) = V8SkillInstance::new(config.clone(), skill_dir, data_dir.clone());
|
let (instance, rx) = QjsSkillInstance::new(config.clone(), skill_dir, data_dir.clone());
|
||||||
log::info!("[runtime] V8 skill instance created for '{}'", skill_id);
|
log::info!("[runtime] QuickJS skill instance created for '{}'", skill_id);
|
||||||
|
|
||||||
// Bundle bridge dependencies
|
// Bundle bridge dependencies (no creation lock needed for QuickJS)
|
||||||
let deps = BridgeDeps {
|
let deps = BridgeDeps {
|
||||||
cron_scheduler: self.cron_scheduler.clone(),
|
cron_scheduler: self.cron_scheduler.clone(),
|
||||||
skill_registry: self.registry.clone(),
|
skill_registry: self.registry.clone(),
|
||||||
@@ -265,8 +261,7 @@ impl RuntimeEngine {
|
|||||||
|
|
||||||
self.emit_status_change(skill_id);
|
self.emit_status_change(skill_id);
|
||||||
|
|
||||||
// Wait for initialization to complete (Running or Error status)
|
// Wait for initialization to complete
|
||||||
// with a reasonable timeout
|
|
||||||
let state = instance.state.clone();
|
let state = instance.state.clone();
|
||||||
let skill_id_owned = skill_id.to_string();
|
let skill_id_owned = skill_id.to_string();
|
||||||
let max_wait = std::time::Duration::from_secs(10);
|
let max_wait = std::time::Duration::from_secs(10);
|
||||||
@@ -278,12 +273,10 @@ impl RuntimeEngine {
|
|||||||
|
|
||||||
match current_status {
|
match current_status {
|
||||||
SkillStatus::Running => {
|
SkillStatus::Running => {
|
||||||
// Successfully started
|
|
||||||
self.emit_status_change(&skill_id_owned);
|
self.emit_status_change(&skill_id_owned);
|
||||||
return Ok(instance.snapshot());
|
return Ok(instance.snapshot());
|
||||||
}
|
}
|
||||||
SkillStatus::Error => {
|
SkillStatus::Error => {
|
||||||
// Initialization failed - unregister and return error
|
|
||||||
let error_msg = state
|
let error_msg = state
|
||||||
.read()
|
.read()
|
||||||
.error
|
.error
|
||||||
@@ -297,7 +290,6 @@ impl RuntimeEngine {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
SkillStatus::Stopped => {
|
SkillStatus::Stopped => {
|
||||||
// Skill stopped unexpectedly during init
|
|
||||||
self.registry.unregister(&skill_id_owned);
|
self.registry.unregister(&skill_id_owned);
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Skill '{}' stopped unexpectedly during initialization",
|
"Skill '{}' stopped unexpectedly during initialization",
|
||||||
@@ -305,9 +297,7 @@ impl RuntimeEngine {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
SkillStatus::Initializing | SkillStatus::Pending => {
|
SkillStatus::Initializing | SkillStatus::Pending => {
|
||||||
// Still initializing, continue waiting
|
|
||||||
if start.elapsed() > max_wait {
|
if start.elapsed() > max_wait {
|
||||||
// Timeout - skill is taking too long
|
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"[runtime] Skill '{}' initialization timeout, returning current state",
|
"[runtime] Skill '{}' initialization timeout, returning current state",
|
||||||
skill_id_owned
|
skill_id_owned
|
||||||
@@ -317,7 +307,6 @@ impl RuntimeEngine {
|
|||||||
tokio::time::sleep(poll_interval).await;
|
tokio::time::sleep(poll_interval).await;
|
||||||
}
|
}
|
||||||
SkillStatus::Stopping => {
|
SkillStatus::Stopping => {
|
||||||
// Unexpected state during startup
|
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Skill '{}' is in unexpected Stopping state during startup",
|
"Skill '{}' is in unexpected Stopping state during startup",
|
||||||
skill_id_owned
|
skill_id_owned
|
||||||
@@ -375,6 +364,7 @@ impl RuntimeEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Auto-start skills based on user preferences.
|
/// Auto-start skills based on user preferences.
|
||||||
|
/// No stagger delay needed for QuickJS (lightweight contexts).
|
||||||
pub async fn auto_start_skills(&self) {
|
pub async fn auto_start_skills(&self) {
|
||||||
match self.discover_skills().await {
|
match self.discover_skills().await {
|
||||||
Ok(manifests) => {
|
Ok(manifests) => {
|
||||||
@@ -0,0 +1,649 @@
|
|||||||
|
//! QjsSkillInstance — manages one QuickJS context per skill.
|
||||||
|
//!
|
||||||
|
//! Key differences from V8 version:
|
||||||
|
//! - QuickJS contexts are Send+Sync with `parallel` feature, so we use regular tokio::spawn (not spawn_blocking)
|
||||||
|
//! - No V8 creation lock needed (QuickJS contexts are lightweight ~1-2MB)
|
||||||
|
//! - No stagger delay needed between skill starts
|
||||||
|
//! - Direct memory limits via `rt.set_memory_limit()`
|
||||||
|
//! - Uses `ctx.eval::<T, _>(code)` instead of `runtime.execute_script()`
|
||||||
|
//! - Simplified error handling with rquickjs::Error
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use parking_lot::RwLock;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
use crate::runtime::cron_scheduler::CronScheduler;
|
||||||
|
use crate::runtime::skill_registry::SkillRegistry;
|
||||||
|
use crate::runtime::types::{
|
||||||
|
SkillConfig, SkillMessage, SkillSnapshot, SkillStatus, ToolContent, ToolDefinition, ToolResult,
|
||||||
|
};
|
||||||
|
use crate::services::tdlib_v8::{qjs_ops, IdbStorage};
|
||||||
|
|
||||||
|
/// Dependencies passed to a skill instance for bridge installation.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct BridgeDeps {
|
||||||
|
pub cron_scheduler: Arc<CronScheduler>,
|
||||||
|
pub skill_registry: Arc<SkillRegistry>,
|
||||||
|
pub app_handle: Option<tauri::AppHandle>,
|
||||||
|
pub data_dir: PathBuf,
|
||||||
|
// NOTE: No v8_creation_lock - QuickJS doesn't need it
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared mutable state for a skill instance.
|
||||||
|
pub struct SkillState {
|
||||||
|
pub status: SkillStatus,
|
||||||
|
pub tools: Vec<ToolDefinition>,
|
||||||
|
pub error: Option<String>,
|
||||||
|
pub published_state: HashMap<String, serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SkillState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
status: SkillStatus::Pending,
|
||||||
|
tools: Vec::new(),
|
||||||
|
error: None,
|
||||||
|
published_state: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A running skill instance using QuickJS.
|
||||||
|
pub struct QjsSkillInstance {
|
||||||
|
pub config: SkillConfig,
|
||||||
|
pub state: Arc<RwLock<SkillState>>,
|
||||||
|
pub sender: mpsc::Sender<SkillMessage>,
|
||||||
|
pub skill_dir: PathBuf,
|
||||||
|
pub data_dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QjsSkillInstance {
|
||||||
|
/// Create a new QuickJS skill instance.
|
||||||
|
pub fn new(
|
||||||
|
config: SkillConfig,
|
||||||
|
skill_dir: PathBuf,
|
||||||
|
data_dir: PathBuf,
|
||||||
|
) -> (Self, mpsc::Receiver<SkillMessage>) {
|
||||||
|
let (tx, rx) = mpsc::channel(64);
|
||||||
|
let instance = Self {
|
||||||
|
config,
|
||||||
|
state: Arc::new(RwLock::new(SkillState::default())),
|
||||||
|
sender: tx,
|
||||||
|
skill_dir,
|
||||||
|
data_dir,
|
||||||
|
};
|
||||||
|
(instance, rx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take a snapshot of the current skill state.
|
||||||
|
pub fn snapshot(&self) -> SkillSnapshot {
|
||||||
|
let state = self.state.read();
|
||||||
|
SkillSnapshot {
|
||||||
|
skill_id: self.config.skill_id.clone(),
|
||||||
|
name: self.config.name.clone(),
|
||||||
|
status: state.status,
|
||||||
|
tools: state.tools.clone(),
|
||||||
|
error: state.error.clone(),
|
||||||
|
state: state.published_state.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn the skill's execution loop as a tokio task.
|
||||||
|
/// Unlike V8 (which needed spawn_blocking), QuickJS contexts are Send.
|
||||||
|
pub fn spawn(
|
||||||
|
&self,
|
||||||
|
mut rx: mpsc::Receiver<SkillMessage>,
|
||||||
|
_deps: BridgeDeps,
|
||||||
|
) -> tokio::task::JoinHandle<()> {
|
||||||
|
let config = self.config.clone();
|
||||||
|
let state = self.state.clone();
|
||||||
|
let skill_dir = self.skill_dir.clone();
|
||||||
|
let data_dir = self.data_dir.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
// Update status
|
||||||
|
state.write().status = SkillStatus::Initializing;
|
||||||
|
|
||||||
|
// Create storage
|
||||||
|
let storage = match IdbStorage::new(&data_dir) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
let mut s = state.write();
|
||||||
|
s.status = SkillStatus::Error;
|
||||||
|
s.error = Some(format!("Failed to create storage: {e}"));
|
||||||
|
log::error!("[skill:{}] Storage creation failed: {e}", config.skill_id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Read the entry point JS file
|
||||||
|
let entry_path = skill_dir.join(&config.entry_point);
|
||||||
|
let js_source = match tokio::fs::read_to_string(&entry_path).await {
|
||||||
|
Ok(src) => src,
|
||||||
|
Err(e) => {
|
||||||
|
let mut s = state.write();
|
||||||
|
s.status = SkillStatus::Error;
|
||||||
|
s.error = Some(format!("Failed to read {}: {e}", config.entry_point));
|
||||||
|
log::error!("[skill:{}] Failed to read entry point: {e}", config.skill_id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create QuickJS runtime with memory limits
|
||||||
|
let rt = match rquickjs::AsyncRuntime::new() {
|
||||||
|
Ok(rt) => rt,
|
||||||
|
Err(e) => {
|
||||||
|
let mut s = state.write();
|
||||||
|
s.status = SkillStatus::Error;
|
||||||
|
s.error = Some(format!("Failed to create QuickJS runtime: {e}"));
|
||||||
|
log::error!("[skill:{}] Failed to create QuickJS runtime: {e}", config.skill_id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set memory limit (config.memory_limit is in bytes)
|
||||||
|
rt.set_memory_limit(config.memory_limit).await;
|
||||||
|
rt.set_max_stack_size(512 * 1024).await; // 512KB stack
|
||||||
|
|
||||||
|
// Create context with full standard library
|
||||||
|
let ctx = match rquickjs::AsyncContext::full(&rt).await {
|
||||||
|
Ok(ctx) => ctx,
|
||||||
|
Err(e) => {
|
||||||
|
let mut s = state.write();
|
||||||
|
s.status = SkillStatus::Error;
|
||||||
|
s.error = Some(format!("Failed to create QuickJS context: {e}"));
|
||||||
|
log::error!("[skill:{}] Failed to create context: {e}", config.skill_id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create shared timer state
|
||||||
|
let timer_state = Arc::new(RwLock::new(qjs_ops::TimerState::default()));
|
||||||
|
|
||||||
|
// Create WebSocket state
|
||||||
|
let ws_state = Arc::new(RwLock::new(qjs_ops::WebSocketState::default()));
|
||||||
|
|
||||||
|
// Create published skill state (different from SkillState above)
|
||||||
|
let published_state = Arc::new(RwLock::new(qjs_ops::SkillState::default()));
|
||||||
|
|
||||||
|
// Register ops and run bootstrap + skill code
|
||||||
|
let skill_id = config.skill_id.clone();
|
||||||
|
let init_result = ctx.with(|js_ctx| {
|
||||||
|
// Register native ops as __ops global
|
||||||
|
let skill_context = qjs_ops::SkillContext {
|
||||||
|
skill_id: skill_id.clone(),
|
||||||
|
data_dir: data_dir.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = qjs_ops::register_ops(
|
||||||
|
&js_ctx,
|
||||||
|
storage.clone(),
|
||||||
|
skill_context,
|
||||||
|
published_state.clone(),
|
||||||
|
timer_state.clone(),
|
||||||
|
ws_state.clone(),
|
||||||
|
) {
|
||||||
|
return Err(format!("Failed to register ops: {e}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load bootstrap
|
||||||
|
let bootstrap_code = include_str!("../services/tdlib_v8/bootstrap.js");
|
||||||
|
if let Err(e) = js_ctx.eval::<rquickjs::Value, _>(bootstrap_code) {
|
||||||
|
let err_str = format!("Bootstrap failed: {e}");
|
||||||
|
return Err(err_str);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set skill ID
|
||||||
|
let bridge_code = format!(
|
||||||
|
r#"globalThis.__skillId = "{}";"#,
|
||||||
|
skill_id.replace('"', r#"\""#)
|
||||||
|
);
|
||||||
|
if let Err(e) = js_ctx.eval::<rquickjs::Value, _>(bridge_code.as_bytes()) {
|
||||||
|
return Err(format!("Skill init failed: {e}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the skill's entry point
|
||||||
|
if let Err(e) = js_ctx.eval::<rquickjs::Value, _>(js_source.as_bytes()) {
|
||||||
|
return Err(format!("Skill load failed: {e}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract tool definitions
|
||||||
|
extract_tools(&js_ctx, &state);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}).await;
|
||||||
|
|
||||||
|
if let Err(e) = init_result {
|
||||||
|
let mut s = state.write();
|
||||||
|
s.status = SkillStatus::Error;
|
||||||
|
s.error = Some(e.clone());
|
||||||
|
log::error!("[skill:{}] {e}", config.skill_id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call init() lifecycle
|
||||||
|
if let Err(e) = call_lifecycle(&ctx, "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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute pending jobs after init (process promises)
|
||||||
|
drive_jobs(&rt).await;
|
||||||
|
|
||||||
|
// Call start() lifecycle
|
||||||
|
if let Err(e) = call_lifecycle(&ctx, "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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute pending jobs after start
|
||||||
|
drive_jobs(&rt).await;
|
||||||
|
|
||||||
|
// Mark as running
|
||||||
|
state.write().status = SkillStatus::Running;
|
||||||
|
log::info!("[skill:{}] Running (QuickJS)", config.skill_id);
|
||||||
|
|
||||||
|
// Run the event loop
|
||||||
|
run_event_loop(&rt, &ctx, &mut rx, &state, &config.skill_id, &timer_state).await;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Event Loop
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// The main event loop that drives the QuickJS runtime.
|
||||||
|
/// This continuously:
|
||||||
|
/// 1. Polls for ready timers and fires their callbacks
|
||||||
|
/// 2. Checks for incoming messages (non-blocking)
|
||||||
|
/// 3. Runs the QuickJS job queue for promises/async ops
|
||||||
|
/// 4. Sleeps efficiently when idle
|
||||||
|
async fn run_event_loop(
|
||||||
|
rt: &rquickjs::AsyncRuntime,
|
||||||
|
ctx: &rquickjs::AsyncContext,
|
||||||
|
rx: &mut mpsc::Receiver<SkillMessage>,
|
||||||
|
state: &Arc<RwLock<SkillState>>,
|
||||||
|
skill_id: &str,
|
||||||
|
timer_state: &Arc<RwLock<qjs_ops::TimerState>>,
|
||||||
|
) {
|
||||||
|
// 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 (ready, _next) = qjs_ops::poll_timers(timer_state);
|
||||||
|
ready
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fire timer callbacks in JavaScript
|
||||||
|
for timer_id in ready_timers {
|
||||||
|
fire_timer_callback(ctx, timer_id).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check for incoming messages (non-blocking)
|
||||||
|
match rx.try_recv() {
|
||||||
|
Ok(msg) => {
|
||||||
|
let should_stop = handle_message(ctx, 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. Drive QuickJS job queue (process pending promises)
|
||||||
|
drive_jobs(rt).await;
|
||||||
|
|
||||||
|
// 4. Calculate sleep duration based on next timer
|
||||||
|
let sleep_duration = {
|
||||||
|
let (_, next_timer) = qjs_ops::poll_timers(timer_state);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drive the QuickJS job queue until no more jobs are pending.
|
||||||
|
async fn drive_jobs(rt: &rquickjs::AsyncRuntime) {
|
||||||
|
// idle() runs all pending futures and jobs
|
||||||
|
rt.idle().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fire a timer callback in JavaScript.
|
||||||
|
async fn fire_timer_callback(ctx: &rquickjs::AsyncContext, timer_id: u32) {
|
||||||
|
let code = format!("globalThis.__handleTimer({});", timer_id);
|
||||||
|
ctx.with(|js_ctx| {
|
||||||
|
if let Err(e) = js_ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
|
||||||
|
log::error!("[timer] Callback for timer {} failed: {}", timer_id, e);
|
||||||
|
}
|
||||||
|
}).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle a single message from the channel.
|
||||||
|
/// Returns true if the skill should stop.
|
||||||
|
async fn handle_message(
|
||||||
|
ctx: &rquickjs::AsyncContext,
|
||||||
|
msg: SkillMessage,
|
||||||
|
state: &Arc<RwLock<SkillState>>,
|
||||||
|
skill_id: &str,
|
||||||
|
) -> bool {
|
||||||
|
match msg {
|
||||||
|
SkillMessage::CallTool { tool_name, arguments, reply } => {
|
||||||
|
let result = handle_tool_call(ctx, &tool_name, arguments).await;
|
||||||
|
let _ = reply.send(result);
|
||||||
|
}
|
||||||
|
SkillMessage::ServerEvent { event, data } => {
|
||||||
|
let _ = handle_server_event(ctx, &event, data).await;
|
||||||
|
}
|
||||||
|
SkillMessage::CronTrigger { schedule_id } => {
|
||||||
|
let _ = handle_cron_trigger(ctx, &schedule_id).await;
|
||||||
|
}
|
||||||
|
SkillMessage::Stop { reply } => {
|
||||||
|
let _ = call_lifecycle(ctx, "stop").await;
|
||||||
|
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(ctx, "onSetupStart", "{}").await;
|
||||||
|
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(ctx, "onSetupSubmit", &args.to_string()).await;
|
||||||
|
let _ = reply.send(result);
|
||||||
|
}
|
||||||
|
SkillMessage::SetupCancel { reply } => {
|
||||||
|
let result = handle_js_void_call(ctx, "onSetupCancel", "{}").await;
|
||||||
|
let _ = reply.send(result);
|
||||||
|
}
|
||||||
|
SkillMessage::ListOptions { reply } => {
|
||||||
|
let result = handle_js_call(ctx, "onListOptions", "{}").await;
|
||||||
|
let _ = reply.send(result);
|
||||||
|
}
|
||||||
|
SkillMessage::SetOption { name, value, reply } => {
|
||||||
|
let args = serde_json::json!({ "name": name, "value": value });
|
||||||
|
let result = handle_js_void_call(ctx, "onSetOption", &args.to_string()).await;
|
||||||
|
let _ = reply.send(result);
|
||||||
|
}
|
||||||
|
SkillMessage::SessionStart { session_id, reply } => {
|
||||||
|
let args = serde_json::json!({ "sessionId": session_id });
|
||||||
|
let result = handle_js_void_call(ctx, "onSessionStart", &args.to_string()).await;
|
||||||
|
let _ = reply.send(result);
|
||||||
|
}
|
||||||
|
SkillMessage::SessionEnd { session_id, reply } => {
|
||||||
|
let args = serde_json::json!({ "sessionId": session_id });
|
||||||
|
let result = handle_js_void_call(ctx, "onSessionEnd", &args.to_string()).await;
|
||||||
|
let _ = reply.send(result);
|
||||||
|
}
|
||||||
|
SkillMessage::Tick { reply } => {
|
||||||
|
let result = handle_js_void_call(ctx, "onTick", "{}").await;
|
||||||
|
let _ = reply.send(result);
|
||||||
|
}
|
||||||
|
SkillMessage::Rpc { method, params, reply } => {
|
||||||
|
let args = serde_json::json!({ "method": method, "params": params });
|
||||||
|
let result = handle_js_call(ctx, "onRpc", &args.to_string()).await;
|
||||||
|
let _ = reply.send(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false // Don't stop
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helper Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Call a lifecycle function on the skill object.
|
||||||
|
/// Looks for the skill at globalThis.__skill.default first, then falls back to globalThis.
|
||||||
|
async fn call_lifecycle(ctx: &rquickjs::AsyncContext, name: &str) -> Result<(), String> {
|
||||||
|
let name = name.to_string();
|
||||||
|
ctx.with(|js_ctx| {
|
||||||
|
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}();
|
||||||
|
}}
|
||||||
|
}})()"#
|
||||||
|
);
|
||||||
|
js_ctx.eval::<rquickjs::Value, _>(code.as_bytes())
|
||||||
|
.map_err(|e| format!("{name}() failed: {e}"))?;
|
||||||
|
Ok(())
|
||||||
|
}).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract tool definitions from the skill.
|
||||||
|
fn extract_tools(js_ctx: &rquickjs::Ctx<'_>, state: &Arc<RwLock<SkillState>>) {
|
||||||
|
let code = r#"
|
||||||
|
(function() {
|
||||||
|
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 || "",
|
||||||
|
description: t.description || "",
|
||||||
|
input_schema: t.inputSchema || t.input_schema || {}
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
})()
|
||||||
|
"#;
|
||||||
|
|
||||||
|
// eval with String type hint tells rquickjs to convert the result to a Rust String
|
||||||
|
match js_ctx.eval::<String, _>(code) {
|
||||||
|
Ok(json_str) => {
|
||||||
|
match serde_json::from_str::<Vec<ToolDefinition>>(&json_str) {
|
||||||
|
Ok(tools) => {
|
||||||
|
state.write().tools = tools;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("[tools] Failed to parse tools JSON: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("[tools] Failed to extract tools: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle a tool call.
|
||||||
|
async fn handle_tool_call(
|
||||||
|
ctx: &rquickjs::AsyncContext,
|
||||||
|
tool_name: &str,
|
||||||
|
arguments: serde_json::Value,
|
||||||
|
) -> Result<ToolResult, String> {
|
||||||
|
let args_str = serde_json::to_string(&arguments)
|
||||||
|
.map_err(|e| format!("Failed to serialize args: {e}"))?;
|
||||||
|
let tool_name = tool_name.to_string();
|
||||||
|
|
||||||
|
let result_text = ctx.with(|js_ctx| {
|
||||||
|
let code = format!(
|
||||||
|
r#"(function() {{
|
||||||
|
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 = {};
|
||||||
|
var result = tools[i].execute(args);
|
||||||
|
if (result && typeof result === 'object') {{
|
||||||
|
return JSON.stringify(result);
|
||||||
|
}}
|
||||||
|
return String(result);
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
throw new Error("Tool '{}' not found");
|
||||||
|
}})()"#,
|
||||||
|
tool_name.replace('"', r#"\""#),
|
||||||
|
args_str,
|
||||||
|
tool_name.replace('"', r#"\""#),
|
||||||
|
);
|
||||||
|
|
||||||
|
match js_ctx.eval::<String, _>(code.as_bytes()) {
|
||||||
|
Ok(s) => Ok(s),
|
||||||
|
Err(e) => Err(format!("Tool execution failed: {e}"))
|
||||||
|
}
|
||||||
|
}).await?;
|
||||||
|
|
||||||
|
Ok(ToolResult {
|
||||||
|
content: vec![ToolContent::Text { text: result_text }],
|
||||||
|
is_error: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle a server event.
|
||||||
|
async fn handle_server_event(
|
||||||
|
ctx: &rquickjs::AsyncContext,
|
||||||
|
event: &str,
|
||||||
|
data: serde_json::Value,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let data_str = serde_json::to_string(&data).unwrap_or_else(|_| "null".to_string());
|
||||||
|
let event = event.to_string();
|
||||||
|
|
||||||
|
ctx.with(|js_ctx| {
|
||||||
|
let code = format!(
|
||||||
|
r#"(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,
|
||||||
|
);
|
||||||
|
|
||||||
|
js_ctx.eval::<rquickjs::Value, _>(code.as_bytes())
|
||||||
|
.map_err(|e| format!("Event handler failed: {e}"))?;
|
||||||
|
Ok(())
|
||||||
|
}).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle a cron trigger.
|
||||||
|
async fn handle_cron_trigger(ctx: &rquickjs::AsyncContext, schedule_id: &str) -> Result<(), String> {
|
||||||
|
let schedule_id = schedule_id.to_string();
|
||||||
|
ctx.with(|js_ctx| {
|
||||||
|
let code = format!(
|
||||||
|
r#"(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#"\""#),
|
||||||
|
);
|
||||||
|
js_ctx.eval::<rquickjs::Value, _>(code.as_bytes())
|
||||||
|
.map_err(|e| format!("Cron trigger failed: {e}"))
|
||||||
|
.map(|_| ())
|
||||||
|
}).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Call a JS function on the skill object that returns a JSON value.
|
||||||
|
async fn handle_js_call(
|
||||||
|
ctx: &rquickjs::AsyncContext,
|
||||||
|
fn_name: &str,
|
||||||
|
args_json: &str,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
let fn_name = fn_name.to_string();
|
||||||
|
let args_json = args_json.to_string();
|
||||||
|
|
||||||
|
let result_text = ctx.with(|js_ctx| {
|
||||||
|
let code = format!(
|
||||||
|
r#"(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 = fn.call(skill, args);
|
||||||
|
return JSON.stringify(result);
|
||||||
|
}}
|
||||||
|
return "null";
|
||||||
|
}})()"#
|
||||||
|
);
|
||||||
|
|
||||||
|
match js_ctx.eval::<String, _>(code.as_bytes()) {
|
||||||
|
Ok(s) => Ok(s),
|
||||||
|
Err(e) => Err(format!("{fn_name}() failed: {e}"))
|
||||||
|
}
|
||||||
|
}).await?;
|
||||||
|
|
||||||
|
serde_json::from_str(&result_text)
|
||||||
|
.map_err(|e| format!("{fn_name}() returned invalid JSON: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Call a JS function on the skill object that returns void.
|
||||||
|
async fn handle_js_void_call(
|
||||||
|
ctx: &rquickjs::AsyncContext,
|
||||||
|
fn_name: &str,
|
||||||
|
args_json: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let fn_name = fn_name.to_string();
|
||||||
|
let args_json = args_json.to_string();
|
||||||
|
|
||||||
|
ctx.with(|js_ctx| {
|
||||||
|
let code = format!(
|
||||||
|
r#"(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};
|
||||||
|
fn.call(skill, args);
|
||||||
|
}}
|
||||||
|
}})()"#
|
||||||
|
);
|
||||||
|
|
||||||
|
js_ctx.eval::<rquickjs::Value, _>(code.as_bytes())
|
||||||
|
.map_err(|e| format!("{fn_name}() failed: {e}"))
|
||||||
|
.map(|_| ())
|
||||||
|
}).await
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ use std::sync::Arc;
|
|||||||
use parking_lot::RwLock;
|
use parking_lot::RwLock;
|
||||||
use tokio::sync::{mpsc, oneshot};
|
use tokio::sync::{mpsc, oneshot};
|
||||||
|
|
||||||
use crate::runtime::v8_skill_instance::SkillState;
|
use crate::runtime::qjs_skill_instance::SkillState;
|
||||||
use crate::runtime::types::{
|
use crate::runtime::types::{
|
||||||
SkillConfig, SkillMessage, SkillSnapshot, SkillStatus, ToolDefinition, ToolResult,
|
SkillConfig, SkillMessage, SkillSnapshot, SkillStatus, ToolDefinition, ToolResult,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ use rust_socketio::{
|
|||||||
Event, Payload,
|
Event, Payload,
|
||||||
};
|
};
|
||||||
|
|
||||||
// SkillRegistry only available on desktop (V8/deno_core required)
|
// SkillRegistry only available on desktop (QuickJS required)
|
||||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
use crate::runtime::skill_registry::SkillRegistry;
|
use crate::runtime::skill_registry::SkillRegistry;
|
||||||
|
|
||||||
|
|||||||
@@ -1,712 +0,0 @@
|
|||||||
//! V8SkillInstance — manages one V8 context per skill.
|
|
||||||
//!
|
|
||||||
//! Each skill runs on its own dedicated thread (V8's JsRuntime is not Send)
|
|
||||||
//! with:
|
|
||||||
//! - A scoped SQLite database
|
|
||||||
//! - Bridge globals (db, store, net, platform, console)
|
|
||||||
//! - 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;
|
|
||||||
use tokio::sync::mpsc;
|
|
||||||
|
|
||||||
use crate::runtime::cron_scheduler::CronScheduler;
|
|
||||||
use crate::runtime::skill_registry::SkillRegistry;
|
|
||||||
use crate::runtime::types::{
|
|
||||||
SkillConfig, SkillMessage, SkillSnapshot, SkillStatus, ToolContent, ToolDefinition, ToolResult,
|
|
||||||
};
|
|
||||||
use crate::services::tdlib_v8::{ops, IdbStorage};
|
|
||||||
|
|
||||||
/// Dependencies passed to a skill instance for bridge installation.
|
|
||||||
/// Currently not all fields are used, but they're kept for future feature parity.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub struct BridgeDeps {
|
|
||||||
pub cron_scheduler: Arc<CronScheduler>,
|
|
||||||
pub skill_registry: Arc<SkillRegistry>,
|
|
||||||
pub app_handle: Option<tauri::AppHandle>,
|
|
||||||
pub data_dir: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Shared mutable state for a skill instance.
|
|
||||||
pub struct SkillState {
|
|
||||||
pub status: SkillStatus,
|
|
||||||
pub tools: Vec<ToolDefinition>,
|
|
||||||
pub error: Option<String>,
|
|
||||||
pub published_state: HashMap<String, serde_json::Value>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for SkillState {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
status: SkillStatus::Pending,
|
|
||||||
tools: Vec::new(),
|
|
||||||
error: None,
|
|
||||||
published_state: HashMap::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A running skill instance using V8.
|
|
||||||
pub struct V8SkillInstance {
|
|
||||||
pub config: SkillConfig,
|
|
||||||
pub state: Arc<RwLock<SkillState>>,
|
|
||||||
pub sender: mpsc::Sender<SkillMessage>,
|
|
||||||
pub skill_dir: PathBuf,
|
|
||||||
pub data_dir: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl V8SkillInstance {
|
|
||||||
/// Create a new V8 skill instance.
|
|
||||||
pub fn new(
|
|
||||||
config: SkillConfig,
|
|
||||||
skill_dir: PathBuf,
|
|
||||||
data_dir: PathBuf,
|
|
||||||
) -> (Self, mpsc::Receiver<SkillMessage>) {
|
|
||||||
let (tx, rx) = mpsc::channel(64);
|
|
||||||
let instance = Self {
|
|
||||||
config,
|
|
||||||
state: Arc::new(RwLock::new(SkillState::default())),
|
|
||||||
sender: tx,
|
|
||||||
skill_dir,
|
|
||||||
data_dir,
|
|
||||||
};
|
|
||||||
(instance, rx)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Take a snapshot of the current skill state.
|
|
||||||
pub fn snapshot(&self) -> SkillSnapshot {
|
|
||||||
let state = self.state.read();
|
|
||||||
SkillSnapshot {
|
|
||||||
skill_id: self.config.skill_id.clone(),
|
|
||||||
name: self.config.name.clone(),
|
|
||||||
status: state.status,
|
|
||||||
tools: state.tools.clone(),
|
|
||||||
error: state.error.clone(),
|
|
||||||
state: state.published_state.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Spawn the skill's execution loop in a dedicated thread.
|
|
||||||
/// Returns a JoinHandle wrapped in a tokio task for compatibility.
|
|
||||||
pub fn spawn(
|
|
||||||
&self,
|
|
||||||
mut rx: mpsc::Receiver<SkillMessage>,
|
|
||||||
_deps: BridgeDeps,
|
|
||||||
) -> tokio::task::JoinHandle<()> {
|
|
||||||
let config = self.config.clone();
|
|
||||||
let state = self.state.clone();
|
|
||||||
let skill_dir = self.skill_dir.clone();
|
|
||||||
let data_dir = self.data_dir.clone();
|
|
||||||
|
|
||||||
// Use std::thread::spawn since JsRuntime is not Send
|
|
||||||
// Wrap in tokio task for API compatibility
|
|
||||||
tokio::task::spawn_blocking(move || {
|
|
||||||
// Update status
|
|
||||||
state.write().status = SkillStatus::Initializing;
|
|
||||||
|
|
||||||
// Create storage
|
|
||||||
let storage = match IdbStorage::new(&data_dir) {
|
|
||||||
Ok(s) => s,
|
|
||||||
Err(e) => {
|
|
||||||
let mut s = state.write();
|
|
||||||
s.status = SkillStatus::Error;
|
|
||||||
s.error = Some(format!("Failed to create storage: {e}"));
|
|
||||||
log::error!("[skill:{}] Storage creation failed: {e}", config.skill_id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Read the entry point JS file synchronously
|
|
||||||
let entry_path = skill_dir.join(&config.entry_point);
|
|
||||||
let js_source = match std::fs::read_to_string(&entry_path) {
|
|
||||||
Ok(src) => src,
|
|
||||||
Err(e) => {
|
|
||||||
let mut s = state.write();
|
|
||||||
s.status = SkillStatus::Error;
|
|
||||||
s.error = Some(format!("Failed to read {}: {e}", config.entry_point));
|
|
||||||
log::error!("[skill:{}] Failed to read entry point: {e}", config.skill_id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create V8 runtime with memory limits to prevent OOM crashes.
|
|
||||||
// Each skill gets its own V8 isolate; without limits V8 tries to reserve
|
|
||||||
// ~1.4 GB per isolate which can exhaust memory when multiple skills start.
|
|
||||||
let extension = ops::build_extension(storage.clone());
|
|
||||||
let create_params = v8::CreateParams::default()
|
|
||||||
.heap_limits(0, config.memory_limit);
|
|
||||||
let mut runtime = match JsRuntime::try_new(RuntimeOptions {
|
|
||||||
extensions: vec![extension],
|
|
||||||
create_params: Some(create_params),
|
|
||||||
..Default::default()
|
|
||||||
}) {
|
|
||||||
Ok(rt) => rt,
|
|
||||||
Err(e) => {
|
|
||||||
let mut s = state.write();
|
|
||||||
s.status = SkillStatus::Error;
|
|
||||||
s.error = Some(format!("Failed to create V8 runtime: {e}"));
|
|
||||||
log::error!(
|
|
||||||
"[skill:{}] Failed to create V8 runtime (memory_limit={}): {e}",
|
|
||||||
config.skill_id,
|
|
||||||
config.memory_limit
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Set skill context in op state
|
|
||||||
{
|
|
||||||
let op_state = runtime.op_state();
|
|
||||||
let mut state_ref = op_state.borrow_mut();
|
|
||||||
ops::init_state_with_data_dir(
|
|
||||||
&mut state_ref,
|
|
||||||
storage,
|
|
||||||
config.skill_id.clone(),
|
|
||||||
data_dir.clone(),
|
|
||||||
state.clone(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load bootstrap
|
|
||||||
let bootstrap_code = include_str!("../services/tdlib_v8/bootstrap.js");
|
|
||||||
if let Err(e) = runtime.execute_script("<bootstrap>", bootstrap_code.to_string()) {
|
|
||||||
let mut s = state.write();
|
|
||||||
s.status = SkillStatus::Error;
|
|
||||||
s.error = Some(format!("Bootstrap failed: {e}"));
|
|
||||||
log::error!("[skill:{}] Bootstrap failed: {e}", config.skill_id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Install skill-specific bridges
|
|
||||||
let skill_id = config.skill_id.clone();
|
|
||||||
let bridge_code = format!(
|
|
||||||
r#"globalThis.__skillId = "{}";"#,
|
|
||||||
skill_id.replace('"', r#"\""#)
|
|
||||||
);
|
|
||||||
|
|
||||||
if let Err(e) = runtime.execute_script("<skill-init>", bridge_code) {
|
|
||||||
let mut s = state.write();
|
|
||||||
s.status = SkillStatus::Error;
|
|
||||||
s.error = Some(format!("Skill init failed: {e}"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute the skill's entry point
|
|
||||||
// Use a static string for the filename
|
|
||||||
let filename: &'static str = Box::leak(format!("<skill:{}>", config.skill_id).into_boxed_str());
|
|
||||||
if let Err(e) = runtime.execute_script(filename, js_source) {
|
|
||||||
let mut s = state.write();
|
|
||||||
s.status = SkillStatus::Error;
|
|
||||||
s.error = Some(format!("Skill load failed: {e}"));
|
|
||||||
log::error!("[skill:{}] Load failed: {e}", config.skill_id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract tool definitions
|
|
||||||
extract_tools(&mut runtime, &state);
|
|
||||||
|
|
||||||
// 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");
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
});
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// 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() {
|
|
||||||
// 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 || "",
|
|
||||||
description: t.description || "",
|
|
||||||
input_schema: t.inputSchema || t.input_schema || {}
|
|
||||||
};
|
|
||||||
}));
|
|
||||||
})()
|
|
||||||
"#;
|
|
||||||
|
|
||||||
if let Ok(result) = runtime.execute_script("<extract-tools>", code.to_string()) {
|
|
||||||
let scope = &mut runtime.handle_scope();
|
|
||||||
let local = v8::Local::new(scope, result);
|
|
||||||
|
|
||||||
if let Some(s) = local.to_string(scope) {
|
|
||||||
let json_str = s.to_rust_string_lossy(scope);
|
|
||||||
if let Ok(tools) = serde_json::from_str::<Vec<ToolDefinition>>(&json_str) {
|
|
||||||
state.write().tools = tools;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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() {{
|
|
||||||
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}"))?;
|
|
||||||
|
|
||||||
// Run event loop synchronously to handle any pending ops
|
|
||||||
let rt = tokio::runtime::Builder::new_current_thread()
|
|
||||||
.enable_all()
|
|
||||||
.build()
|
|
||||||
.map_err(|e| format!("Failed to create runtime: {e}"))?;
|
|
||||||
|
|
||||||
rt.block_on(async {
|
|
||||||
runtime
|
|
||||||
.run_event_loop(PollEventLoopOptions::default())
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Event loop error: {e}"))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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,
|
|
||||||
arguments: serde_json::Value,
|
|
||||||
) -> Result<ToolResult, String> {
|
|
||||||
let args_str =
|
|
||||||
serde_json::to_string(&arguments).map_err(|e| format!("Failed to serialize args: {e}"))?;
|
|
||||||
|
|
||||||
let code = format!(
|
|
||||||
r#"(function() {{
|
|
||||||
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 = {};
|
|
||||||
var result = tools[i].execute(args);
|
|
||||||
if (result && typeof result === 'object') {{
|
|
||||||
return JSON.stringify(result);
|
|
||||||
}}
|
|
||||||
return String(result);
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
throw new Error("Tool '{}' not found");
|
|
||||||
}})()"#,
|
|
||||||
tool_name.replace('"', r#"\""#),
|
|
||||||
args_str,
|
|
||||||
tool_name.replace('"', r#"\""#),
|
|
||||||
);
|
|
||||||
|
|
||||||
let result = runtime
|
|
||||||
.execute_script("<tool-call>", code)
|
|
||||||
.map_err(|e| format!("Tool execution failed: {e}"))?;
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
|
|
||||||
let result_text = if let Some(s) = local.to_string(scope) {
|
|
||||||
s.to_rust_string_lossy(scope)
|
|
||||||
} else {
|
|
||||||
"null".to_string()
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(ToolResult {
|
|
||||||
content: vec![ToolContent::Text { text: result_text }],
|
|
||||||
is_error: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle a server event synchronously.
|
|
||||||
fn handle_server_event_sync(
|
|
||||||
runtime: &mut JsRuntime,
|
|
||||||
event: &str,
|
|
||||||
data: serde_json::Value,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let data_str = serde_json::to_string(&data).unwrap_or_else(|_| "null".to_string());
|
|
||||||
|
|
||||||
let code = format!(
|
|
||||||
r#"(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
|
|
||||||
.execute_script("<server-event>", code)
|
|
||||||
.map_err(|e| format!("Event handler failed: {e}"))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle a cron trigger synchronously.
|
|
||||||
fn handle_cron_trigger_sync(runtime: &mut JsRuntime, schedule_id: &str) -> Result<(), String> {
|
|
||||||
let code = format!(
|
|
||||||
r#"(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
|
|
||||||
.execute_script("<cron-trigger>", code)
|
|
||||||
.map_err(|e| format!("Cron trigger failed: {e}"))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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,
|
|
||||||
args_json: &str,
|
|
||||||
) -> Result<serde_json::Value, String> {
|
|
||||||
let code = format!(
|
|
||||||
r#"(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 = fn.call(skill, args);
|
|
||||||
return JSON.stringify(result);
|
|
||||||
}}
|
|
||||||
return "null";
|
|
||||||
}})()"#
|
|
||||||
);
|
|
||||||
|
|
||||||
let result = runtime
|
|
||||||
.execute_script("<js-call>", code)
|
|
||||||
.map_err(|e| format!("{fn_name}() failed: {e}"))?;
|
|
||||||
|
|
||||||
let scope = &mut runtime.handle_scope();
|
|
||||||
let local = v8::Local::new(scope, result);
|
|
||||||
|
|
||||||
let result_text = if let Some(s) = local.to_string(scope) {
|
|
||||||
s.to_rust_string_lossy(scope)
|
|
||||||
} else {
|
|
||||||
"null".to_string()
|
|
||||||
};
|
|
||||||
|
|
||||||
serde_json::from_str(&result_text)
|
|
||||||
.map_err(|e| format!("{fn_name}() returned invalid JSON: {e}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Call a JS function on the skill object that returns void synchronously.
|
|
||||||
fn handle_js_void_call_sync(
|
|
||||||
runtime: &mut JsRuntime,
|
|
||||||
fn_name: &str,
|
|
||||||
args_json: &str,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let code = format!(
|
|
||||||
r#"(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};
|
|
||||||
fn.call(skill, args);
|
|
||||||
}}
|
|
||||||
}})()"#
|
|
||||||
);
|
|
||||||
|
|
||||||
runtime
|
|
||||||
.execute_script("<js-void-call>", code)
|
|
||||||
.map_err(|e| format!("{fn_name}() failed: {e}"))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
+67
-67
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Bootstrap JavaScript for V8 Runtime
|
* Bootstrap JavaScript for QuickJS Runtime
|
||||||
*
|
*
|
||||||
* Provides browser-like API shims that tdweb expects.
|
* Provides browser-like API shims that tdweb expects.
|
||||||
* These shims call Rust "ops" for actual I/O.
|
* These shims call Rust "ops" for actual I/O.
|
||||||
@@ -14,19 +14,19 @@ globalThis.window = globalThis;
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
globalThis.console = {
|
globalThis.console = {
|
||||||
log: function (...args) {
|
log: function (...args) {
|
||||||
Deno.core.ops.op_console_log(args.map(String).join(' '));
|
__ops.console_log(args.map(String).join(' '));
|
||||||
},
|
},
|
||||||
info: function (...args) {
|
info: function (...args) {
|
||||||
Deno.core.ops.op_console_log(args.map(String).join(' '));
|
__ops.console_log(args.map(String).join(' '));
|
||||||
},
|
},
|
||||||
warn: function (...args) {
|
warn: function (...args) {
|
||||||
Deno.core.ops.op_console_warn(args.map(String).join(' '));
|
__ops.console_warn(args.map(String).join(' '));
|
||||||
},
|
},
|
||||||
error: function (...args) {
|
error: function (...args) {
|
||||||
Deno.core.ops.op_console_error(args.map(String).join(' '));
|
__ops.console_error(args.map(String).join(' '));
|
||||||
},
|
},
|
||||||
debug: function (...args) {
|
debug: function (...args) {
|
||||||
Deno.core.ops.op_console_log('[DEBUG] ' + args.map(String).join(' '));
|
__ops.console_log('[DEBUG] ' + args.map(String).join(' '));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -39,25 +39,25 @@ let nextTimerId = 1;
|
|||||||
globalThis.setTimeout = function (callback, delay, ...args) {
|
globalThis.setTimeout = function (callback, delay, ...args) {
|
||||||
const id = nextTimerId++;
|
const id = nextTimerId++;
|
||||||
timerCallbacks.set(id, { callback, args, type: 'timeout' });
|
timerCallbacks.set(id, { callback, args, type: 'timeout' });
|
||||||
Deno.core.ops.op_ah_timer_start(id, delay || 0, false);
|
__ops.timer_start(id, delay || 0, false);
|
||||||
return id;
|
return id;
|
||||||
};
|
};
|
||||||
|
|
||||||
globalThis.setInterval = function (callback, delay, ...args) {
|
globalThis.setInterval = function (callback, delay, ...args) {
|
||||||
const id = nextTimerId++;
|
const id = nextTimerId++;
|
||||||
timerCallbacks.set(id, { callback, args, type: 'interval' });
|
timerCallbacks.set(id, { callback, args, type: 'interval' });
|
||||||
Deno.core.ops.op_ah_timer_start(id, delay || 0, true);
|
__ops.timer_start(id, delay || 0, true);
|
||||||
return id;
|
return id;
|
||||||
};
|
};
|
||||||
|
|
||||||
globalThis.clearTimeout = function (id) {
|
globalThis.clearTimeout = function (id) {
|
||||||
timerCallbacks.delete(id);
|
timerCallbacks.delete(id);
|
||||||
Deno.core.ops.op_ah_timer_cancel(id);
|
__ops.timer_cancel(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
globalThis.clearInterval = function (id) {
|
globalThis.clearInterval = function (id) {
|
||||||
timerCallbacks.delete(id);
|
timerCallbacks.delete(id);
|
||||||
Deno.core.ops.op_ah_timer_cancel(id);
|
__ops.timer_cancel(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Timer callback handler (called from Rust)
|
// Timer callback handler (called from Rust)
|
||||||
@@ -146,7 +146,7 @@ globalThis.fetch = async function (url, options = {}) {
|
|||||||
headersObj = headers;
|
headersObj = headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await Deno.core.ops.op_fetch(url.toString(), {
|
const result = await __ops.fetch(url.toString(), {
|
||||||
method,
|
method,
|
||||||
headers: headersObj,
|
headers: headersObj,
|
||||||
body: typeof body === 'string' ? body : body ? JSON.stringify(body) : null,
|
body: typeof body === 'string' ? body : body ? JSON.stringify(body) : null,
|
||||||
@@ -259,7 +259,7 @@ class WebSocket {
|
|||||||
|
|
||||||
async _connect() {
|
async _connect() {
|
||||||
try {
|
try {
|
||||||
this._id = await Deno.core.ops.op_ws_connect(this.url);
|
this._id = await __ops.ws_connect(this.url);
|
||||||
this.readyState = WebSocket.OPEN;
|
this.readyState = WebSocket.OPEN;
|
||||||
|
|
||||||
// Register for message callbacks
|
// Register for message callbacks
|
||||||
@@ -282,7 +282,7 @@ class WebSocket {
|
|||||||
async _startReceiving() {
|
async _startReceiving() {
|
||||||
while (this.readyState === WebSocket.OPEN) {
|
while (this.readyState === WebSocket.OPEN) {
|
||||||
try {
|
try {
|
||||||
const message = await Deno.core.ops.op_ws_recv(this._id);
|
const message = await __ops.ws_recv(this._id);
|
||||||
if (message === null) {
|
if (message === null) {
|
||||||
// Connection closed
|
// Connection closed
|
||||||
this._handleClose(1000, '');
|
this._handleClose(1000, '');
|
||||||
@@ -322,7 +322,7 @@ class WebSocket {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const dataStr = typeof data === 'string' ? data : JSON.stringify(data);
|
const dataStr = typeof data === 'string' ? data : JSON.stringify(data);
|
||||||
Deno.core.ops.op_ws_send(this._id, dataStr);
|
__ops.ws_send(this._id, dataStr);
|
||||||
}
|
}
|
||||||
|
|
||||||
close(code = 1000, reason = '') {
|
close(code = 1000, reason = '') {
|
||||||
@@ -331,7 +331,7 @@ class WebSocket {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.readyState = WebSocket.CLOSING;
|
this.readyState = WebSocket.CLOSING;
|
||||||
Deno.core.ops.op_ws_close(this._id, code, reason);
|
__ops.ws_close(this._id, code, reason);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,7 +350,7 @@ class IDBFactory {
|
|||||||
const request = new IDBRequest();
|
const request = new IDBRequest();
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
await Deno.core.ops.op_idb_delete_database(name);
|
await __ops.idb_delete_database(name);
|
||||||
request._success(undefined);
|
request._success(undefined);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
request._error(e);
|
request._error(e);
|
||||||
@@ -398,7 +398,7 @@ class IDBOpenDBRequest extends IDBRequest {
|
|||||||
|
|
||||||
async _open() {
|
async _open() {
|
||||||
try {
|
try {
|
||||||
const info = await Deno.core.ops.op_idb_open(this._name, this._version);
|
const info = await __ops.idb_open(this._name, this._version);
|
||||||
const db = new IDBDatabase(this._name, this._version, info.objectStores || []);
|
const db = new IDBDatabase(this._name, this._version, info.objectStores || []);
|
||||||
|
|
||||||
if (info.needsUpgrade) {
|
if (info.needsUpgrade) {
|
||||||
@@ -433,13 +433,13 @@ class IDBDatabase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createObjectStore(name, options = {}) {
|
createObjectStore(name, options = {}) {
|
||||||
Deno.core.ops.op_idb_create_object_store(this.name, name, options);
|
__ops.idb_create_object_store(this.name, name, options);
|
||||||
this.objectStoreNames.push(name);
|
this.objectStoreNames.push(name);
|
||||||
return new IDBObjectStore(this, name);
|
return new IDBObjectStore(this, name);
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteObjectStore(name) {
|
deleteObjectStore(name) {
|
||||||
Deno.core.ops.op_idb_delete_object_store(this.name, name);
|
__ops.idb_delete_object_store(this.name, name);
|
||||||
const idx = this.objectStoreNames.indexOf(name);
|
const idx = this.objectStoreNames.indexOf(name);
|
||||||
if (idx >= 0) this.objectStoreNames.splice(idx, 1);
|
if (idx >= 0) this.objectStoreNames.splice(idx, 1);
|
||||||
}
|
}
|
||||||
@@ -449,7 +449,7 @@ class IDBDatabase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
Deno.core.ops.op_idb_close(this.name);
|
__ops.idb_close(this.name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,7 +495,7 @@ class IDBObjectStore {
|
|||||||
const request = new IDBRequest();
|
const request = new IDBRequest();
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const value = await Deno.core.ops.op_idb_get(this._db.name, this.name, key);
|
const value = await __ops.idb_get(this._db.name, this.name, key);
|
||||||
request._success(value);
|
request._success(value);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
request._error(e);
|
request._error(e);
|
||||||
@@ -508,7 +508,7 @@ class IDBObjectStore {
|
|||||||
const request = new IDBRequest();
|
const request = new IDBRequest();
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
await Deno.core.ops.op_idb_put(this._db.name, this.name, key, value);
|
await __ops.idb_put(this._db.name, this.name, key, value);
|
||||||
request._success(key);
|
request._success(key);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
request._error(e);
|
request._error(e);
|
||||||
@@ -525,7 +525,7 @@ class IDBObjectStore {
|
|||||||
const request = new IDBRequest();
|
const request = new IDBRequest();
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
await Deno.core.ops.op_idb_delete(this._db.name, this.name, key);
|
await __ops.idb_delete(this._db.name, this.name, key);
|
||||||
request._success(undefined);
|
request._success(undefined);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
request._error(e);
|
request._error(e);
|
||||||
@@ -538,7 +538,7 @@ class IDBObjectStore {
|
|||||||
const request = new IDBRequest();
|
const request = new IDBRequest();
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
await Deno.core.ops.op_idb_clear(this._db.name, this.name);
|
await __ops.idb_clear(this._db.name, this.name);
|
||||||
request._success(undefined);
|
request._success(undefined);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
request._error(e);
|
request._error(e);
|
||||||
@@ -551,7 +551,7 @@ class IDBObjectStore {
|
|||||||
const request = new IDBRequest();
|
const request = new IDBRequest();
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const values = await Deno.core.ops.op_idb_get_all(this._db.name, this.name, count);
|
const values = await __ops.idb_get_all(this._db.name, this.name, count);
|
||||||
request._success(values);
|
request._success(values);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
request._error(e);
|
request._error(e);
|
||||||
@@ -564,7 +564,7 @@ class IDBObjectStore {
|
|||||||
const request = new IDBRequest();
|
const request = new IDBRequest();
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const keys = await Deno.core.ops.op_idb_get_all_keys(this._db.name, this.name, count);
|
const keys = await __ops.idb_get_all_keys(this._db.name, this.name, count);
|
||||||
request._success(keys);
|
request._success(keys);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
request._error(e);
|
request._error(e);
|
||||||
@@ -577,7 +577,7 @@ class IDBObjectStore {
|
|||||||
const request = new IDBRequest();
|
const request = new IDBRequest();
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const count = await Deno.core.ops.op_idb_count(this._db.name, this.name);
|
const count = await __ops.idb_count(this._db.name, this.name);
|
||||||
request._success(count);
|
request._success(count);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
request._error(e);
|
request._error(e);
|
||||||
@@ -636,13 +636,13 @@ if (typeof globalThis.TextDecoder === 'undefined') {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
if (typeof globalThis.atob === 'undefined') {
|
if (typeof globalThis.atob === 'undefined') {
|
||||||
globalThis.atob = function (str) {
|
globalThis.atob = function (str) {
|
||||||
return Deno.core.ops.op_atob(str);
|
return __ops.atob(str);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof globalThis.btoa === 'undefined') {
|
if (typeof globalThis.btoa === 'undefined') {
|
||||||
globalThis.btoa = function (str) {
|
globalThis.btoa = function (str) {
|
||||||
return Deno.core.ops.op_btoa(str);
|
return __ops.btoa(str);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -651,7 +651,7 @@ if (typeof globalThis.btoa === 'undefined') {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
globalThis.crypto = {
|
globalThis.crypto = {
|
||||||
getRandomValues: function (array) {
|
getRandomValues: function (array) {
|
||||||
const bytes = Deno.core.ops.op_crypto_random(array.length);
|
const bytes = __ops.crypto_random(array.length);
|
||||||
array.set(bytes);
|
array.set(bytes);
|
||||||
return array;
|
return array;
|
||||||
},
|
},
|
||||||
@@ -662,7 +662,7 @@ globalThis.crypto = {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
globalThis.performance = {
|
globalThis.performance = {
|
||||||
now: function () {
|
now: function () {
|
||||||
return Deno.core.ops.op_performance_now();
|
return __ops.performance_now();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -673,53 +673,53 @@ globalThis.performance = {
|
|||||||
|
|
||||||
globalThis.__db = {
|
globalThis.__db = {
|
||||||
exec: function (sql, paramsJson) {
|
exec: function (sql, paramsJson) {
|
||||||
return Deno.core.ops.op_db_exec(sql, paramsJson);
|
return __ops.db_exec(sql, paramsJson);
|
||||||
},
|
},
|
||||||
get: function (sql, paramsJson) {
|
get: function (sql, paramsJson) {
|
||||||
return Deno.core.ops.op_db_get(sql, paramsJson);
|
return __ops.db_get(sql, paramsJson);
|
||||||
},
|
},
|
||||||
all: function (sql, paramsJson) {
|
all: function (sql, paramsJson) {
|
||||||
return Deno.core.ops.op_db_all(sql, paramsJson);
|
return __ops.db_all(sql, paramsJson);
|
||||||
},
|
},
|
||||||
kvGet: function (key) {
|
kvGet: function (key) {
|
||||||
return Deno.core.ops.op_db_kv_get(key);
|
return __ops.db_kv_get(key);
|
||||||
},
|
},
|
||||||
kvSet: function (key, valueJson) {
|
kvSet: function (key, valueJson) {
|
||||||
return Deno.core.ops.op_db_kv_set(key, valueJson);
|
return __ops.db_kv_set(key, valueJson);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
globalThis.__store = {
|
globalThis.__store = {
|
||||||
get: function (key) {
|
get: function (key) {
|
||||||
return Deno.core.ops.op_store_get(key);
|
return __ops.store_get(key);
|
||||||
},
|
},
|
||||||
set: function (key, valueJson) {
|
set: function (key, valueJson) {
|
||||||
return Deno.core.ops.op_store_set(key, valueJson);
|
return __ops.store_set(key, valueJson);
|
||||||
},
|
},
|
||||||
delete: function (key) {
|
delete: function (key) {
|
||||||
return Deno.core.ops.op_store_delete(key);
|
return __ops.store_delete(key);
|
||||||
},
|
},
|
||||||
keys: function () {
|
keys: function () {
|
||||||
return Deno.core.ops.op_store_keys();
|
return __ops.store_keys();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
globalThis.__net = {
|
globalThis.__net = {
|
||||||
fetch: function (url, optionsJson) {
|
fetch: function (url, optionsJson) {
|
||||||
return Deno.core.ops.op_net_fetch(url, optionsJson);
|
return __ops.net_fetch(url, optionsJson);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
globalThis.__platform = {
|
globalThis.__platform = {
|
||||||
os: function () {
|
os: function () {
|
||||||
return Deno.core.ops.op_platform_os();
|
return __ops.platform_os();
|
||||||
},
|
},
|
||||||
env: function (key) {
|
env: function (key) {
|
||||||
return Deno.core.ops.op_platform_env(key);
|
return __ops.platform_env(key);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// High-level wrappers for skills (V8 bridge)
|
// High-level wrappers for skills (QuickJS bridge)
|
||||||
globalThis.db = {
|
globalThis.db = {
|
||||||
exec: function (sql, params) {
|
exec: function (sql, params) {
|
||||||
return __db.exec(sql, params ? JSON.stringify(params) : undefined);
|
return __db.exec(sql, params ? JSON.stringify(params) : undefined);
|
||||||
@@ -779,13 +779,13 @@ globalThis.platform = {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
globalThis.__state = {
|
globalThis.__state = {
|
||||||
get: function (key) {
|
get: function (key) {
|
||||||
return Deno.core.ops.op_state_get(key);
|
return __ops.state_get(key);
|
||||||
},
|
},
|
||||||
set: function (key, valueJson) {
|
set: function (key, valueJson) {
|
||||||
return Deno.core.ops.op_state_set(key, valueJson);
|
return __ops.state_set(key, valueJson);
|
||||||
},
|
},
|
||||||
setPartial: function (partialJson) {
|
setPartial: function (partialJson) {
|
||||||
return Deno.core.ops.op_state_set_partial(partialJson);
|
return __ops.state_set_partial(partialJson);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -807,10 +807,10 @@ globalThis.state = {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
globalThis.__data = {
|
globalThis.__data = {
|
||||||
read: function (filename) {
|
read: function (filename) {
|
||||||
return Deno.core.ops.op_data_read(filename);
|
return __ops.data_read(filename);
|
||||||
},
|
},
|
||||||
write: function (filename, content) {
|
write: function (filename, content) {
|
||||||
return Deno.core.ops.op_data_write(filename, content);
|
return __ops.data_write(filename, content);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -828,15 +828,15 @@ globalThis.data = {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
globalThis.cron = {
|
globalThis.cron = {
|
||||||
register: function (scheduleId, cronExpr) {
|
register: function (scheduleId, cronExpr) {
|
||||||
console.warn('[cron] register not implemented in V8 runtime yet');
|
console.warn('[cron] register not implemented in QuickJS runtime yet');
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
unregister: function (scheduleId) {
|
unregister: function (scheduleId) {
|
||||||
console.warn('[cron] unregister not implemented in V8 runtime yet');
|
console.warn('[cron] unregister not implemented in QuickJS runtime yet');
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
list: function () {
|
list: function () {
|
||||||
console.warn('[cron] list not implemented in V8 runtime yet');
|
console.warn('[cron] list not implemented in QuickJS runtime yet');
|
||||||
return [];
|
return [];
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -846,11 +846,11 @@ globalThis.cron = {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
globalThis.skills = {
|
globalThis.skills = {
|
||||||
list: function () {
|
list: function () {
|
||||||
console.warn('[skills] list not implemented in V8 runtime yet');
|
console.warn('[skills] list not implemented in QuickJS runtime yet');
|
||||||
return [];
|
return [];
|
||||||
},
|
},
|
||||||
callTool: function (skillId, toolName, args) {
|
callTool: function (skillId, toolName, args) {
|
||||||
console.warn('[skills] callTool not implemented in V8 runtime yet');
|
console.warn('[skills] callTool not implemented in QuickJS runtime yet');
|
||||||
return { error: 'Not implemented' };
|
return { error: 'Not implemented' };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -868,8 +868,8 @@ globalThis.tdlib = {
|
|||||||
*/
|
*/
|
||||||
isAvailable: function () {
|
isAvailable: function () {
|
||||||
try {
|
try {
|
||||||
return typeof Deno?.core?.ops?.op_tdlib_is_available === 'function'
|
return typeof __ops?.tdlib_is_available === 'function'
|
||||||
? Deno.core.ops.op_tdlib_is_available()
|
? __ops.tdlib_is_available()
|
||||||
: false;
|
: false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return false;
|
return false;
|
||||||
@@ -881,8 +881,8 @@ globalThis.tdlib = {
|
|||||||
* @param {string} dataDir - Path to store TDLib data files.
|
* @param {string} dataDir - Path to store TDLib data files.
|
||||||
* @returns {Promise<number>} Client ID (always 1 for singleton).
|
* @returns {Promise<number>} Client ID (always 1 for singleton).
|
||||||
*/
|
*/
|
||||||
createClient: async function (dataDir) {
|
createClient: function (dataDir) {
|
||||||
return await Deno.core.ops.op_tdlib_create_client(dataDir);
|
return __ops.tdlib_create_client(dataDir);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -891,7 +891,7 @@ globalThis.tdlib = {
|
|||||||
* @returns {Promise<object>} TDLib response object.
|
* @returns {Promise<object>} TDLib response object.
|
||||||
*/
|
*/
|
||||||
send: async function (request) {
|
send: async function (request) {
|
||||||
return await Deno.core.ops.op_tdlib_send(request);
|
return await __ops.tdlib_send(request);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -900,7 +900,7 @@ globalThis.tdlib = {
|
|||||||
* @returns {Promise<object|null>} Update object or null if timeout.
|
* @returns {Promise<object|null>} Update object or null if timeout.
|
||||||
*/
|
*/
|
||||||
receive: async function (timeoutMs = 1000) {
|
receive: async function (timeoutMs = 1000) {
|
||||||
return await Deno.core.ops.op_tdlib_receive(timeoutMs);
|
return await __ops.tdlib_receive(timeoutMs);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -908,7 +908,7 @@ globalThis.tdlib = {
|
|||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
destroy: async function () {
|
destroy: async function () {
|
||||||
return await Deno.core.ops.op_tdlib_destroy();
|
return await __ops.tdlib_destroy();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -919,21 +919,21 @@ globalThis.tdlib = {
|
|||||||
globalThis.__model = {
|
globalThis.__model = {
|
||||||
isAvailable: function () {
|
isAvailable: function () {
|
||||||
try {
|
try {
|
||||||
return typeof Deno?.core?.ops?.op_model_is_available === 'function'
|
return typeof __ops?.model_is_available === 'function'
|
||||||
? Deno.core.ops.op_model_is_available()
|
? __ops.model_is_available()
|
||||||
: false;
|
: false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
getStatus: function () {
|
getStatus: function () {
|
||||||
return Deno.core.ops.op_model_get_status();
|
return __ops.model_get_status();
|
||||||
},
|
},
|
||||||
generate: async function (prompt, configJson) {
|
generate: async function (prompt, configJson) {
|
||||||
return await Deno.core.ops.op_model_generate(prompt, configJson);
|
return await __ops.model_generate(prompt, configJson);
|
||||||
},
|
},
|
||||||
summarize: async function (text, maxTokens) {
|
summarize: async function (text, maxTokens) {
|
||||||
return await Deno.core.ops.op_model_summarize(text, maxTokens);
|
return await __ops.model_summarize(text, maxTokens);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -986,4 +986,4 @@ globalThis.model = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
console.log('[bootstrap] Model API initialized');
|
console.log('[bootstrap] Model API initialized');
|
||||||
console.log('[bootstrap] V8 browser APIs initialized');
|
console.log('[bootstrap] QuickJS browser APIs initialized');
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
//! TDLib V8 Runtime Module
|
//! TDLib Runtime Module
|
||||||
//!
|
//!
|
||||||
//! Provides a V8 JavaScript runtime (via deno_core) for running
|
//! Provides a QuickJS JavaScript runtime (via rquickjs) for running
|
||||||
//! skill JavaScript code and TDLib via tdweb. Provides a browser-like
|
//! skill JavaScript code and TDLib integration. Provides a browser-like
|
||||||
//! environment that supports WASM natively.
|
//! environment for skill execution.
|
||||||
|
|
||||||
pub mod ops;
|
pub mod qjs_ops;
|
||||||
pub mod service;
|
pub mod service;
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,799 @@
|
|||||||
|
use parking_lot::RwLock;
|
||||||
|
use rquickjs::{function::Async, Ctx, Function, Object, Result as JsResult};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use super::storage::IdbStorage;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Timer State
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct TimerEntry {
|
||||||
|
pub deadline: Instant,
|
||||||
|
pub delay_ms: u32,
|
||||||
|
pub is_interval: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct TimerState {
|
||||||
|
pub timers: HashMap<u32, TimerEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TimerState {
|
||||||
|
pub fn poll_ready(&mut self) -> Vec<u32> {
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut ready = Vec::new();
|
||||||
|
let mut to_remove = Vec::new();
|
||||||
|
|
||||||
|
for (&id, entry) in &self.timers {
|
||||||
|
if now >= entry.deadline {
|
||||||
|
ready.push(id);
|
||||||
|
if !entry.is_interval {
|
||||||
|
to_remove.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for id in to_remove {
|
||||||
|
self.timers.remove(&id);
|
||||||
|
}
|
||||||
|
|
||||||
|
for &id in &ready {
|
||||||
|
if let Some(entry) = self.timers.get_mut(&id) {
|
||||||
|
if entry.is_interval {
|
||||||
|
entry.deadline = now + Duration::from_millis(entry.delay_ms as u64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ready
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn time_until_next(&self) -> Option<Duration> {
|
||||||
|
let now = Instant::now();
|
||||||
|
self.timers
|
||||||
|
.values()
|
||||||
|
.map(|e| e.deadline.saturating_duration_since(now))
|
||||||
|
.min()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn poll_timers(timer_state: &RwLock<TimerState>) -> (Vec<u32>, Option<Duration>) {
|
||||||
|
let mut ts = timer_state.write();
|
||||||
|
let ready = ts.poll_ready();
|
||||||
|
let next = ts.time_until_next();
|
||||||
|
(ready, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Skill Context
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SkillContext {
|
||||||
|
pub skill_id: String,
|
||||||
|
pub data_dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Skill State (shared published state)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SkillState {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub data: serde_json::Map<String, serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SkillState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
data: serde_json::Map::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// WebSocket State (placeholder)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct WebSocketConnection {
|
||||||
|
pub url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct WebSocketState {
|
||||||
|
pub connections: HashMap<u32, WebSocketConnection>,
|
||||||
|
pub next_id: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Allowed Environment Variables
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const ALLOWED_ENV_VARS: &[&str] = &[
|
||||||
|
"VITE_BACKEND_URL",
|
||||||
|
"VITE_TELEGRAM_API_ID",
|
||||||
|
"VITE_TELEGRAM_API_HASH",
|
||||||
|
"VITE_TELEGRAM_BOT_USERNAME",
|
||||||
|
"VITE_TELEGRAM_BOT_ID",
|
||||||
|
"NODE_ENV",
|
||||||
|
];
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
fn check_telegram_skill(skill_id: &str) -> Result<(), String> {
|
||||||
|
if skill_id != "telegram" {
|
||||||
|
Err("TDLib operations only available in telegram skill".to_string())
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn js_err(msg: String) -> rquickjs::Error {
|
||||||
|
rquickjs::Error::new_from_js_message("ops", "Error", msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Main Registration Function
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
pub fn register_ops(
|
||||||
|
ctx: &Ctx<'_>,
|
||||||
|
storage: IdbStorage,
|
||||||
|
skill_context: SkillContext,
|
||||||
|
skill_state: Arc<RwLock<SkillState>>,
|
||||||
|
timer_state: Arc<RwLock<TimerState>>,
|
||||||
|
ws_state: Arc<RwLock<WebSocketState>>,
|
||||||
|
) -> JsResult<()> {
|
||||||
|
let globals = ctx.globals();
|
||||||
|
let ops = Object::new(ctx.clone())?;
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Console (3)
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
ops.set("console_log", Function::new(ctx.clone(), |msg: String| {
|
||||||
|
log::info!("[js] {}", msg);
|
||||||
|
}))?;
|
||||||
|
|
||||||
|
ops.set("console_warn", Function::new(ctx.clone(), |msg: String| {
|
||||||
|
log::warn!("[js] {}", msg);
|
||||||
|
}))?;
|
||||||
|
|
||||||
|
ops.set("console_error", Function::new(ctx.clone(), |msg: String| {
|
||||||
|
log::error!("[js] {}", msg);
|
||||||
|
}))?;
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Crypto (3)
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
ops.set("crypto_random", Function::new(ctx.clone(), |len: usize| -> Vec<u8> {
|
||||||
|
use rand::RngCore;
|
||||||
|
let mut buf = vec![0u8; len];
|
||||||
|
rand::thread_rng().fill_bytes(&mut buf);
|
||||||
|
buf
|
||||||
|
}))?;
|
||||||
|
|
||||||
|
ops.set("atob", Function::new(ctx.clone(), |input: String| -> rquickjs::Result<String> {
|
||||||
|
use base64::Engine;
|
||||||
|
let bytes = base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(&input)
|
||||||
|
.map_err(|e| js_err(e.to_string()))?;
|
||||||
|
String::from_utf8(bytes).map_err(|e| js_err(e.to_string()))
|
||||||
|
}))?;
|
||||||
|
|
||||||
|
ops.set("btoa", Function::new(ctx.clone(), |input: String| -> String {
|
||||||
|
use base64::Engine;
|
||||||
|
base64::engine::general_purpose::STANDARD.encode(input.as_bytes())
|
||||||
|
}))?;
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Performance (1)
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
ops.set("performance_now", Function::new(ctx.clone(), || -> f64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs_f64()
|
||||||
|
* 1000.0
|
||||||
|
}))?;
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Platform (2)
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
ops.set("platform_os", Function::new(ctx.clone(), || -> &'static str {
|
||||||
|
if cfg!(target_os = "windows") { "windows" }
|
||||||
|
else if cfg!(target_os = "macos") { "macos" }
|
||||||
|
else if cfg!(target_os = "linux") { "linux" }
|
||||||
|
else if cfg!(target_os = "android") { "android" }
|
||||||
|
else if cfg!(target_os = "ios") { "ios" }
|
||||||
|
else { "unknown" }
|
||||||
|
}))?;
|
||||||
|
|
||||||
|
ops.set("platform_env", Function::new(ctx.clone(), |key: String| -> Option<String> {
|
||||||
|
if ALLOWED_ENV_VARS.contains(&key.as_str()) {
|
||||||
|
std::env::var(&key).ok()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}))?;
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Timers (2)
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
{
|
||||||
|
let ts = timer_state.clone();
|
||||||
|
ops.set("timer_start", Function::new(ctx.clone(),
|
||||||
|
move |id: u32, delay_ms: u32, is_interval: bool| {
|
||||||
|
let mut state = ts.write();
|
||||||
|
state.timers.insert(id, TimerEntry {
|
||||||
|
deadline: Instant::now() + Duration::from_millis(delay_ms as u64),
|
||||||
|
delay_ms,
|
||||||
|
is_interval,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let ts = timer_state.clone();
|
||||||
|
ops.set("timer_cancel", Function::new(ctx.clone(), move |id: u32| {
|
||||||
|
let mut state = ts.write();
|
||||||
|
state.timers.remove(&id);
|
||||||
|
}))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Fetch (1) - ASYNC
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
ops.set("fetch", Function::new(ctx.clone(),
|
||||||
|
Async(move |url: String, options: String| async move {
|
||||||
|
let opts: serde_json::Value =
|
||||||
|
serde_json::from_str(&options).map_err(|e| js_err(e.to_string()))?;
|
||||||
|
|
||||||
|
let method = opts["method"].as_str().unwrap_or("GET");
|
||||||
|
let headers_obj = opts["headers"].as_object();
|
||||||
|
let body = opts["body"].as_str();
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let mut req = match method {
|
||||||
|
"GET" => client.get(&url),
|
||||||
|
"POST" => client.post(&url),
|
||||||
|
"PUT" => client.put(&url),
|
||||||
|
"DELETE" => client.delete(&url),
|
||||||
|
_ => client.get(&url),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(h) = headers_obj {
|
||||||
|
for (k, v) in h {
|
||||||
|
if let Some(val_str) = v.as_str() {
|
||||||
|
req = req.header(k, val_str);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(b) = body {
|
||||||
|
req = req.body(b.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = req.send().await.map_err(|e| js_err(e.to_string()))?;
|
||||||
|
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
let status_text = response.status().canonical_reason().unwrap_or("").to_string();
|
||||||
|
let headers: HashMap<String, String> = response
|
||||||
|
.headers()
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
|
||||||
|
.collect();
|
||||||
|
let body_text = response.text().await.map_err(|e| js_err(e.to_string()))?;
|
||||||
|
|
||||||
|
let result = serde_json::json!({
|
||||||
|
"status": status,
|
||||||
|
"statusText": status_text,
|
||||||
|
"headers": headers,
|
||||||
|
"body": body_text,
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok::<String, rquickjs::Error>(result.to_string())
|
||||||
|
}),
|
||||||
|
))?;
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// WebSocket (4) - placeholders
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
{
|
||||||
|
let ws = ws_state.clone();
|
||||||
|
ops.set("ws_connect", Function::new(ctx.clone(),
|
||||||
|
Async(move |url: String| {
|
||||||
|
let ws = ws.clone();
|
||||||
|
async move {
|
||||||
|
let mut state = ws.write();
|
||||||
|
let id = state.next_id;
|
||||||
|
state.next_id += 1;
|
||||||
|
state.connections.insert(id, WebSocketConnection { url });
|
||||||
|
Ok::<u32, rquickjs::Error>(id)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let ws = ws_state.clone();
|
||||||
|
ops.set("ws_send", Function::new(ctx.clone(), move |_id: u32, _data: String| {
|
||||||
|
let _state = ws.read();
|
||||||
|
}))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let ws = ws_state.clone();
|
||||||
|
ops.set("ws_recv", Function::new(ctx.clone(),
|
||||||
|
Async(move |_id: u32| {
|
||||||
|
let _ws = ws.clone();
|
||||||
|
async move { Ok::<Option<String>, rquickjs::Error>(None) }
|
||||||
|
}),
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let ws = ws_state.clone();
|
||||||
|
ops.set("ws_close", Function::new(ctx.clone(), move |id: u32, _code: u16, _reason: String| {
|
||||||
|
let mut state = ws.write();
|
||||||
|
state.connections.remove(&id);
|
||||||
|
}))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// IndexedDB (11) - all sync
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
{
|
||||||
|
let s = storage.clone();
|
||||||
|
ops.set("idb_open", Function::new(ctx.clone(),
|
||||||
|
move |name: String, version: u32| -> rquickjs::Result<String> {
|
||||||
|
let result = s.open_database(&name, version).map_err(|e| js_err(e))?;
|
||||||
|
serde_json::to_string(&result).map_err(|e| js_err(e.to_string()))
|
||||||
|
},
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let s = storage.clone();
|
||||||
|
ops.set("idb_close", Function::new(ctx.clone(), move |name: String| {
|
||||||
|
s.close_database(&name);
|
||||||
|
}))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let s = storage.clone();
|
||||||
|
ops.set("idb_delete_database", Function::new(ctx.clone(),
|
||||||
|
move |name: String| -> rquickjs::Result<()> {
|
||||||
|
s.delete_database(&name).map_err(|e| js_err(e))
|
||||||
|
},
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let s = storage.clone();
|
||||||
|
ops.set("idb_create_object_store", Function::new(ctx.clone(),
|
||||||
|
move |db_name: String, store_name: String, options: String| -> rquickjs::Result<()> {
|
||||||
|
let opts: serde_json::Value =
|
||||||
|
serde_json::from_str(&options).map_err(|e| js_err(e.to_string()))?;
|
||||||
|
let key_path = opts["keyPath"].as_str();
|
||||||
|
let auto_increment = opts["autoIncrement"].as_bool().unwrap_or(false);
|
||||||
|
s.create_object_store(&db_name, &store_name, key_path, auto_increment)
|
||||||
|
.map_err(|e| js_err(e))
|
||||||
|
},
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let s = storage.clone();
|
||||||
|
ops.set("idb_delete_object_store", Function::new(ctx.clone(),
|
||||||
|
move |db_name: String, store_name: String| -> rquickjs::Result<()> {
|
||||||
|
s.delete_object_store(&db_name, &store_name).map_err(|e| js_err(e))
|
||||||
|
},
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let s = storage.clone();
|
||||||
|
ops.set("idb_get", Function::new(ctx.clone(),
|
||||||
|
move |db_name: String, store_name: String, key: String| -> rquickjs::Result<String> {
|
||||||
|
let key_val: serde_json::Value =
|
||||||
|
serde_json::from_str(&key).map_err(|e| js_err(e.to_string()))?;
|
||||||
|
let result = s.get(&db_name, &store_name, &key_val).map_err(|e| js_err(e))?;
|
||||||
|
serde_json::to_string(&result).map_err(|e| js_err(e.to_string()))
|
||||||
|
},
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let s = storage.clone();
|
||||||
|
ops.set("idb_put", Function::new(ctx.clone(),
|
||||||
|
move |db_name: String, store_name: String, key: String, value: String| -> rquickjs::Result<()> {
|
||||||
|
let key_val: serde_json::Value =
|
||||||
|
serde_json::from_str(&key).map_err(|e| js_err(e.to_string()))?;
|
||||||
|
let value_val: serde_json::Value =
|
||||||
|
serde_json::from_str(&value).map_err(|e| js_err(e.to_string()))?;
|
||||||
|
s.put(&db_name, &store_name, &key_val, &value_val).map_err(|e| js_err(e))
|
||||||
|
},
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let s = storage.clone();
|
||||||
|
ops.set("idb_delete", Function::new(ctx.clone(),
|
||||||
|
move |db_name: String, store_name: String, key: String| -> rquickjs::Result<()> {
|
||||||
|
let key_val: serde_json::Value =
|
||||||
|
serde_json::from_str(&key).map_err(|e| js_err(e.to_string()))?;
|
||||||
|
s.delete(&db_name, &store_name, &key_val).map_err(|e| js_err(e))
|
||||||
|
},
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let s = storage.clone();
|
||||||
|
ops.set("idb_clear", Function::new(ctx.clone(),
|
||||||
|
move |db_name: String, store_name: String| -> rquickjs::Result<()> {
|
||||||
|
s.clear(&db_name, &store_name).map_err(|e| js_err(e))
|
||||||
|
},
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let s = storage.clone();
|
||||||
|
ops.set("idb_get_all", Function::new(ctx.clone(),
|
||||||
|
move |db_name: String, store_name: String, count: Option<u32>| -> rquickjs::Result<String> {
|
||||||
|
let result = s.get_all(&db_name, &store_name, count).map_err(|e| js_err(e))?;
|
||||||
|
serde_json::to_string(&result).map_err(|e| js_err(e.to_string()))
|
||||||
|
},
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let s = storage.clone();
|
||||||
|
ops.set("idb_get_all_keys", Function::new(ctx.clone(),
|
||||||
|
move |db_name: String, store_name: String, count: Option<u32>| -> rquickjs::Result<String> {
|
||||||
|
let result = s.get_all_keys(&db_name, &store_name, count).map_err(|e| js_err(e))?;
|
||||||
|
serde_json::to_string(&result).map_err(|e| js_err(e.to_string()))
|
||||||
|
},
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let s = storage.clone();
|
||||||
|
ops.set("idb_count", Function::new(ctx.clone(),
|
||||||
|
move |db_name: String, store_name: String| -> rquickjs::Result<u32> {
|
||||||
|
s.count(&db_name, &store_name).map_err(|e| js_err(e))
|
||||||
|
},
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// DB Bridge (5)
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
{
|
||||||
|
let s = storage.clone();
|
||||||
|
let sc = skill_context.clone();
|
||||||
|
ops.set("db_exec", Function::new(ctx.clone(),
|
||||||
|
move |sql: String, params_json: Option<String>| -> rquickjs::Result<i64> {
|
||||||
|
let params: Vec<serde_json::Value> = if let Some(p) = params_json {
|
||||||
|
serde_json::from_str(&p).map_err(|e| js_err(e.to_string()))?
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
let rows = s.skill_db_exec(&sc.skill_id, &sql, ¶ms).map_err(|e| js_err(e))?;
|
||||||
|
Ok(rows as i64)
|
||||||
|
},
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let s = storage.clone();
|
||||||
|
let sc = skill_context.clone();
|
||||||
|
ops.set("db_get", Function::new(ctx.clone(),
|
||||||
|
move |sql: String, params_json: Option<String>| -> rquickjs::Result<String> {
|
||||||
|
let params: Vec<serde_json::Value> = if let Some(p) = params_json {
|
||||||
|
serde_json::from_str(&p).map_err(|e| js_err(e.to_string()))?
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
let result = s.skill_db_get(&sc.skill_id, &sql, ¶ms).map_err(|e| js_err(e))?;
|
||||||
|
serde_json::to_string(&result).map_err(|e| js_err(e.to_string()))
|
||||||
|
},
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let s = storage.clone();
|
||||||
|
let sc = skill_context.clone();
|
||||||
|
ops.set("db_all", Function::new(ctx.clone(),
|
||||||
|
move |sql: String, params_json: Option<String>| -> rquickjs::Result<String> {
|
||||||
|
let params: Vec<serde_json::Value> = if let Some(p) = params_json {
|
||||||
|
serde_json::from_str(&p).map_err(|e| js_err(e.to_string()))?
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
let result = s.skill_db_all(&sc.skill_id, &sql, ¶ms).map_err(|e| js_err(e))?;
|
||||||
|
serde_json::to_string(&result).map_err(|e| js_err(e.to_string()))
|
||||||
|
},
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let s = storage.clone();
|
||||||
|
let sc = skill_context.clone();
|
||||||
|
ops.set("db_kv_get", Function::new(ctx.clone(),
|
||||||
|
move |key: String| -> rquickjs::Result<String> {
|
||||||
|
let result = s.skill_kv_get(&sc.skill_id, &key).map_err(|e| js_err(e))?;
|
||||||
|
serde_json::to_string(&result).map_err(|e| js_err(e.to_string()))
|
||||||
|
},
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let s = storage.clone();
|
||||||
|
let sc = skill_context.clone();
|
||||||
|
ops.set("db_kv_set", Function::new(ctx.clone(),
|
||||||
|
move |key: String, value_json: String| -> rquickjs::Result<()> {
|
||||||
|
let value: serde_json::Value =
|
||||||
|
serde_json::from_str(&value_json).map_err(|e| js_err(e.to_string()))?;
|
||||||
|
s.skill_kv_set(&sc.skill_id, &key, &value).map_err(|e| js_err(e))
|
||||||
|
},
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Store Bridge (4)
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
{
|
||||||
|
let s = storage.clone();
|
||||||
|
let sc = skill_context.clone();
|
||||||
|
ops.set("store_get", Function::new(ctx.clone(),
|
||||||
|
move |key: String| -> rquickjs::Result<String> {
|
||||||
|
let result = s.skill_store_get(&sc.skill_id, &key).map_err(|e| js_err(e))?;
|
||||||
|
serde_json::to_string(&result).map_err(|e| js_err(e.to_string()))
|
||||||
|
},
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let s = storage.clone();
|
||||||
|
let sc = skill_context.clone();
|
||||||
|
ops.set("store_set", Function::new(ctx.clone(),
|
||||||
|
move |key: String, value_json: String| -> rquickjs::Result<()> {
|
||||||
|
let value: serde_json::Value =
|
||||||
|
serde_json::from_str(&value_json).map_err(|e| js_err(e.to_string()))?;
|
||||||
|
s.skill_store_set(&sc.skill_id, &key, &value).map_err(|e| js_err(e))
|
||||||
|
},
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let s = storage.clone();
|
||||||
|
let sc = skill_context.clone();
|
||||||
|
ops.set("store_delete", Function::new(ctx.clone(),
|
||||||
|
move |key: String| -> rquickjs::Result<()> {
|
||||||
|
s.skill_store_delete(&sc.skill_id, &key).map_err(|e| js_err(e))
|
||||||
|
},
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let s = storage.clone();
|
||||||
|
let sc = skill_context.clone();
|
||||||
|
ops.set("store_keys", Function::new(ctx.clone(),
|
||||||
|
move || -> rquickjs::Result<String> {
|
||||||
|
let keys = s.skill_store_keys(&sc.skill_id).map_err(|e| js_err(e))?;
|
||||||
|
serde_json::to_string(&keys).map_err(|e| js_err(e.to_string()))
|
||||||
|
},
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Net (1)
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
ops.set("net_fetch", Function::new(ctx.clone(),
|
||||||
|
|url: String, options_json: String| -> rquickjs::Result<String> {
|
||||||
|
crate::runtime::bridge::net::http_fetch(&url, &options_json).map_err(|e| js_err(e))
|
||||||
|
},
|
||||||
|
))?;
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// State Bridge (3)
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
{
|
||||||
|
let ss = skill_state.clone();
|
||||||
|
ops.set("state_get", Function::new(ctx.clone(),
|
||||||
|
move |key: String| -> rquickjs::Result<String> {
|
||||||
|
let state = ss.read();
|
||||||
|
let value = state.data.get(&key).cloned().unwrap_or(serde_json::Value::Null);
|
||||||
|
serde_json::to_string(&value).map_err(|e| js_err(e.to_string()))
|
||||||
|
},
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let ss = skill_state.clone();
|
||||||
|
ops.set("state_set", Function::new(ctx.clone(),
|
||||||
|
move |key: String, value_json: String| -> rquickjs::Result<()> {
|
||||||
|
let value: serde_json::Value =
|
||||||
|
serde_json::from_str(&value_json).map_err(|e| js_err(e.to_string()))?;
|
||||||
|
let mut state = ss.write();
|
||||||
|
state.data.insert(key, value);
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let ss = skill_state.clone();
|
||||||
|
ops.set("state_set_partial", Function::new(ctx.clone(),
|
||||||
|
move |partial_json: String| -> rquickjs::Result<()> {
|
||||||
|
let partial: serde_json::Map<String, serde_json::Value> =
|
||||||
|
serde_json::from_str(&partial_json).map_err(|e| js_err(e.to_string()))?;
|
||||||
|
let mut state = ss.write();
|
||||||
|
for (k, v) in partial {
|
||||||
|
state.data.insert(k, v);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Data Bridge (2)
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
{
|
||||||
|
let sc = skill_context.clone();
|
||||||
|
ops.set("data_read", Function::new(ctx.clone(),
|
||||||
|
move |filename: String| -> rquickjs::Result<String> {
|
||||||
|
let path = sc.data_dir.join(&filename);
|
||||||
|
std::fs::read_to_string(&path).map_err(|e| js_err(e.to_string()))
|
||||||
|
},
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let sc = skill_context.clone();
|
||||||
|
ops.set("data_write", Function::new(ctx.clone(),
|
||||||
|
move |filename: String, content: String| -> rquickjs::Result<()> {
|
||||||
|
let path = sc.data_dir.join(&filename);
|
||||||
|
std::fs::write(&path, content).map_err(|e| js_err(e.to_string()))
|
||||||
|
},
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// TDLib (5) - gated on skill_id == "telegram"
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
{
|
||||||
|
let sc = skill_context.clone();
|
||||||
|
ops.set("tdlib_is_available", Function::new(ctx.clone(),
|
||||||
|
move || -> bool { sc.skill_id == "telegram" },
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let sc = skill_context.clone();
|
||||||
|
ops.set("tdlib_create_client", Function::new(ctx.clone(),
|
||||||
|
move |data_dir: String| -> rquickjs::Result<i32> {
|
||||||
|
check_telegram_skill(&sc.skill_id).map_err(|e| js_err(e))?;
|
||||||
|
crate::services::tdlib::TDLIB_MANAGER
|
||||||
|
.create_client(PathBuf::from(data_dir))
|
||||||
|
.map_err(|e| js_err(e))
|
||||||
|
},
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let sc = skill_context.clone();
|
||||||
|
ops.set("tdlib_send", Function::new(ctx.clone(),
|
||||||
|
Async(move |request_json: String| {
|
||||||
|
let skill_id = sc.skill_id.clone();
|
||||||
|
async move {
|
||||||
|
check_telegram_skill(&skill_id).map_err(|e| js_err(e))?;
|
||||||
|
let request: serde_json::Value =
|
||||||
|
serde_json::from_str(&request_json).map_err(|e| js_err(e.to_string()))?;
|
||||||
|
let result = crate::services::tdlib::TDLIB_MANAGER
|
||||||
|
.send(request)
|
||||||
|
.await
|
||||||
|
.map_err(|e| js_err(e))?;
|
||||||
|
serde_json::to_string(&result).map_err(|e| js_err(e.to_string()))
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let sc = skill_context.clone();
|
||||||
|
ops.set("tdlib_receive", Function::new(ctx.clone(),
|
||||||
|
Async(move |timeout_ms: u32| {
|
||||||
|
let skill_id = sc.skill_id.clone();
|
||||||
|
async move {
|
||||||
|
check_telegram_skill(&skill_id).map_err(|e| js_err(e))?;
|
||||||
|
let result = crate::services::tdlib::TDLIB_MANAGER.receive(timeout_ms).await;
|
||||||
|
if let Some(val) = result {
|
||||||
|
let json = serde_json::to_string(&val).map_err(|e| js_err(e.to_string()))?;
|
||||||
|
Ok::<Option<String>, rquickjs::Error>(Some(json))
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let sc = skill_context.clone();
|
||||||
|
ops.set("tdlib_destroy", Function::new(ctx.clone(),
|
||||||
|
Async(move || {
|
||||||
|
let skill_id = sc.skill_id.clone();
|
||||||
|
async move {
|
||||||
|
check_telegram_skill(&skill_id).map_err(|e| js_err(e))?;
|
||||||
|
crate::services::tdlib::TDLIB_MANAGER.destroy().await.map_err(|e| js_err(e))
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Model (4) - local LLM
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
ops.set("model_is_available", Function::new(ctx.clone(), || -> bool { false }))?;
|
||||||
|
|
||||||
|
ops.set("model_get_status", Function::new(ctx.clone(), || -> rquickjs::Result<String> {
|
||||||
|
let status = crate::services::llama::LLAMA_MANAGER.get_status();
|
||||||
|
serde_json::to_string(&status).map_err(|e| js_err(e.to_string()))
|
||||||
|
}))?;
|
||||||
|
|
||||||
|
ops.set("model_generate", Function::new(ctx.clone(),
|
||||||
|
Async(move |prompt: String, config_json: String| async move {
|
||||||
|
let config: crate::services::llama::GenerateConfig =
|
||||||
|
serde_json::from_str(&config_json).map_err(|e| js_err(e.to_string()))?;
|
||||||
|
crate::services::llama::LLAMA_MANAGER
|
||||||
|
.generate(&prompt, config)
|
||||||
|
.await
|
||||||
|
.map_err(|e| js_err(e))
|
||||||
|
}),
|
||||||
|
))?;
|
||||||
|
|
||||||
|
ops.set("model_summarize", Function::new(ctx.clone(),
|
||||||
|
Async(move |text: String, max_tokens: u32| async move {
|
||||||
|
crate::services::llama::LLAMA_MANAGER
|
||||||
|
.summarize(&text, max_tokens)
|
||||||
|
.await
|
||||||
|
.map_err(|e| js_err(e))
|
||||||
|
}),
|
||||||
|
))?;
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Register on globalThis
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
globals.set("__ops", ops)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
/// Result of opening an IndexedDB database.
|
/// Result of opening an IndexedDB database.
|
||||||
/// Used by the IndexedDB emulation layer for tdweb.
|
/// Used by the IndexedDB emulation layer for tdweb.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
pub struct IdbOpenResult {
|
pub struct IdbOpenResult {
|
||||||
/// Whether a version upgrade is needed.
|
/// Whether a version upgrade is needed.
|
||||||
pub needs_upgrade: bool,
|
pub needs_upgrade: bool,
|
||||||
@@ -29,7 +29,7 @@ pub struct IdbStorage {
|
|||||||
/// Base directory for all databases.
|
/// Base directory for all databases.
|
||||||
data_dir: PathBuf,
|
data_dir: PathBuf,
|
||||||
/// Open database connections (db_name -> connection).
|
/// Open database connections (db_name -> connection).
|
||||||
connections: Arc<RwLock<HashMap<String, Arc<RwLock<Connection>>>>>,
|
connections: Arc<RwLock<HashMap<String, Arc<parking_lot::Mutex<Connection>>>>>,
|
||||||
/// Database version info (used by IndexedDB emulation).
|
/// Database version info (used by IndexedDB emulation).
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
versions: Arc<RwLock<HashMap<String, u32>>>,
|
versions: Arc<RwLock<HashMap<String, u32>>>,
|
||||||
@@ -49,7 +49,7 @@ impl IdbStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get or create a database connection.
|
/// Get or create a database connection.
|
||||||
fn get_connection(&self, db_name: &str) -> Result<Arc<RwLock<Connection>>, String> {
|
fn get_connection(&self, db_name: &str) -> Result<Arc<parking_lot::Mutex<Connection>>, String> {
|
||||||
// Check if already open
|
// Check if already open
|
||||||
if let Some(conn) = self.connections.read().get(db_name) {
|
if let Some(conn) = self.connections.read().get(db_name) {
|
||||||
return Ok(conn.clone());
|
return Ok(conn.clone());
|
||||||
@@ -81,7 +81,7 @@ impl IdbStorage {
|
|||||||
)
|
)
|
||||||
.map_err(|e| format!("Failed to initialize database schema: {e}"))?;
|
.map_err(|e| format!("Failed to initialize database schema: {e}"))?;
|
||||||
|
|
||||||
let conn = Arc::new(RwLock::new(conn));
|
let conn = Arc::new(parking_lot::Mutex::new(conn));
|
||||||
self.connections
|
self.connections
|
||||||
.write()
|
.write()
|
||||||
.insert(db_name.to_string(), conn.clone());
|
.insert(db_name.to_string(), conn.clone());
|
||||||
@@ -90,9 +90,9 @@ impl IdbStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Open or create an IndexedDB database.
|
/// Open or create an IndexedDB database.
|
||||||
pub async fn open_database(&self, name: &str, version: u32) -> Result<IdbOpenResult, String> {
|
pub fn open_database(&self, name: &str, version: u32) -> Result<IdbOpenResult, String> {
|
||||||
let conn = self.get_connection(name)?;
|
let conn = self.get_connection(name)?;
|
||||||
let conn_guard = conn.read();
|
let conn_guard = conn.lock();
|
||||||
|
|
||||||
// Get current version
|
// Get current version
|
||||||
let current_version: u32 = conn_guard
|
let current_version: u32 = conn_guard
|
||||||
@@ -145,7 +145,7 @@ impl IdbStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a database.
|
/// Delete a database.
|
||||||
pub async fn delete_database(&self, name: &str) -> Result<(), String> {
|
pub fn delete_database(&self, name: &str) -> Result<(), String> {
|
||||||
self.close_database(name);
|
self.close_database(name);
|
||||||
let db_path = self.data_dir.join(format!("{}.sqlite", name));
|
let db_path = self.data_dir.join(format!("{}.sqlite", name));
|
||||||
if db_path.exists() {
|
if db_path.exists() {
|
||||||
@@ -164,7 +164,7 @@ impl IdbStorage {
|
|||||||
auto_increment: bool,
|
auto_increment: bool,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let conn = self.get_connection(db_name)?;
|
let conn = self.get_connection(db_name)?;
|
||||||
let conn_guard = conn.read();
|
let conn_guard = conn.lock();
|
||||||
|
|
||||||
// Register the store
|
// Register the store
|
||||||
conn_guard
|
conn_guard
|
||||||
@@ -197,7 +197,7 @@ impl IdbStorage {
|
|||||||
/// Delete an object store.
|
/// Delete an object store.
|
||||||
pub fn delete_object_store(&self, db_name: &str, store_name: &str) -> Result<(), String> {
|
pub fn delete_object_store(&self, db_name: &str, store_name: &str) -> Result<(), String> {
|
||||||
let conn = self.get_connection(db_name)?;
|
let conn = self.get_connection(db_name)?;
|
||||||
let conn_guard = conn.read();
|
let conn_guard = conn.lock();
|
||||||
|
|
||||||
// Remove from registry
|
// Remove from registry
|
||||||
conn_guard
|
conn_guard
|
||||||
@@ -214,14 +214,14 @@ impl IdbStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get a value from an object store.
|
/// Get a value from an object store.
|
||||||
pub async fn get(
|
pub fn get(
|
||||||
&self,
|
&self,
|
||||||
db_name: &str,
|
db_name: &str,
|
||||||
store_name: &str,
|
store_name: &str,
|
||||||
key: &serde_json::Value,
|
key: &serde_json::Value,
|
||||||
) -> Result<Option<serde_json::Value>, String> {
|
) -> Result<Option<serde_json::Value>, String> {
|
||||||
let conn = self.get_connection(db_name)?;
|
let conn = self.get_connection(db_name)?;
|
||||||
let conn_guard = conn.read();
|
let conn_guard = conn.lock();
|
||||||
|
|
||||||
let table_name = format!("store_{}", sanitize_name(store_name));
|
let table_name = format!("store_{}", sanitize_name(store_name));
|
||||||
let key_str = serde_json::to_string(key).unwrap_or_else(|_| "null".to_string());
|
let key_str = serde_json::to_string(key).unwrap_or_else(|_| "null".to_string());
|
||||||
@@ -245,7 +245,7 @@ impl IdbStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Put a value into an object store.
|
/// Put a value into an object store.
|
||||||
pub async fn put(
|
pub fn put(
|
||||||
&self,
|
&self,
|
||||||
db_name: &str,
|
db_name: &str,
|
||||||
store_name: &str,
|
store_name: &str,
|
||||||
@@ -253,7 +253,7 @@ impl IdbStorage {
|
|||||||
value: &serde_json::Value,
|
value: &serde_json::Value,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let conn = self.get_connection(db_name)?;
|
let conn = self.get_connection(db_name)?;
|
||||||
let conn_guard = conn.read();
|
let conn_guard = conn.lock();
|
||||||
|
|
||||||
let table_name = format!("store_{}", sanitize_name(store_name));
|
let table_name = format!("store_{}", sanitize_name(store_name));
|
||||||
let key_str = serde_json::to_string(key).unwrap_or_else(|_| "null".to_string());
|
let key_str = serde_json::to_string(key).unwrap_or_else(|_| "null".to_string());
|
||||||
@@ -273,14 +273,14 @@ impl IdbStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a value from an object store.
|
/// Delete a value from an object store.
|
||||||
pub async fn delete(
|
pub fn delete(
|
||||||
&self,
|
&self,
|
||||||
db_name: &str,
|
db_name: &str,
|
||||||
store_name: &str,
|
store_name: &str,
|
||||||
key: &serde_json::Value,
|
key: &serde_json::Value,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let conn = self.get_connection(db_name)?;
|
let conn = self.get_connection(db_name)?;
|
||||||
let conn_guard = conn.read();
|
let conn_guard = conn.lock();
|
||||||
|
|
||||||
let table_name = format!("store_{}", sanitize_name(store_name));
|
let table_name = format!("store_{}", sanitize_name(store_name));
|
||||||
let key_str = serde_json::to_string(key).unwrap_or_else(|_| "null".to_string());
|
let key_str = serde_json::to_string(key).unwrap_or_else(|_| "null".to_string());
|
||||||
@@ -296,9 +296,9 @@ impl IdbStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Clear all values from an object store.
|
/// Clear all values from an object store.
|
||||||
pub async fn clear(&self, db_name: &str, store_name: &str) -> Result<(), String> {
|
pub fn clear(&self, db_name: &str, store_name: &str) -> Result<(), String> {
|
||||||
let conn = self.get_connection(db_name)?;
|
let conn = self.get_connection(db_name)?;
|
||||||
let conn_guard = conn.read();
|
let conn_guard = conn.lock();
|
||||||
|
|
||||||
let table_name = format!("store_{}", sanitize_name(store_name));
|
let table_name = format!("store_{}", sanitize_name(store_name));
|
||||||
|
|
||||||
@@ -310,14 +310,14 @@ impl IdbStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get all values from an object store.
|
/// Get all values from an object store.
|
||||||
pub async fn get_all(
|
pub fn get_all(
|
||||||
&self,
|
&self,
|
||||||
db_name: &str,
|
db_name: &str,
|
||||||
store_name: &str,
|
store_name: &str,
|
||||||
count: Option<u32>,
|
count: Option<u32>,
|
||||||
) -> Result<Vec<serde_json::Value>, String> {
|
) -> Result<Vec<serde_json::Value>, String> {
|
||||||
let conn = self.get_connection(db_name)?;
|
let conn = self.get_connection(db_name)?;
|
||||||
let conn_guard = conn.read();
|
let conn_guard = conn.lock();
|
||||||
|
|
||||||
let table_name = format!("store_{}", sanitize_name(store_name));
|
let table_name = format!("store_{}", sanitize_name(store_name));
|
||||||
let limit = count.map(|c| format!(" LIMIT {}", c)).unwrap_or_default();
|
let limit = count.map(|c| format!(" LIMIT {}", c)).unwrap_or_default();
|
||||||
@@ -337,14 +337,14 @@ impl IdbStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get all keys from an object store.
|
/// Get all keys from an object store.
|
||||||
pub async fn get_all_keys(
|
pub fn get_all_keys(
|
||||||
&self,
|
&self,
|
||||||
db_name: &str,
|
db_name: &str,
|
||||||
store_name: &str,
|
store_name: &str,
|
||||||
count: Option<u32>,
|
count: Option<u32>,
|
||||||
) -> Result<Vec<serde_json::Value>, String> {
|
) -> Result<Vec<serde_json::Value>, String> {
|
||||||
let conn = self.get_connection(db_name)?;
|
let conn = self.get_connection(db_name)?;
|
||||||
let conn_guard = conn.read();
|
let conn_guard = conn.lock();
|
||||||
|
|
||||||
let table_name = format!("store_{}", sanitize_name(store_name));
|
let table_name = format!("store_{}", sanitize_name(store_name));
|
||||||
let limit = count.map(|c| format!(" LIMIT {}", c)).unwrap_or_default();
|
let limit = count.map(|c| format!(" LIMIT {}", c)).unwrap_or_default();
|
||||||
@@ -364,9 +364,9 @@ impl IdbStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Count values in an object store.
|
/// Count values in an object store.
|
||||||
pub async fn count(&self, db_name: &str, store_name: &str) -> Result<u32, String> {
|
pub fn count(&self, db_name: &str, store_name: &str) -> Result<u32, String> {
|
||||||
let conn = self.get_connection(db_name)?;
|
let conn = self.get_connection(db_name)?;
|
||||||
let conn_guard = conn.read();
|
let conn_guard = conn.lock();
|
||||||
|
|
||||||
let table_name = format!("store_{}", sanitize_name(store_name));
|
let table_name = format!("store_{}", sanitize_name(store_name));
|
||||||
|
|
||||||
@@ -394,7 +394,7 @@ impl IdbStorage {
|
|||||||
) -> Result<usize, String> {
|
) -> Result<usize, String> {
|
||||||
let db_name = format!("skill_{}", skill_id);
|
let db_name = format!("skill_{}", skill_id);
|
||||||
let conn = self.get_connection(&db_name)?;
|
let conn = self.get_connection(&db_name)?;
|
||||||
let conn_guard = conn.read();
|
let conn_guard = conn.lock();
|
||||||
|
|
||||||
let params: Vec<Box<dyn rusqlite::ToSql>> = params
|
let params: Vec<Box<dyn rusqlite::ToSql>> = params
|
||||||
.iter()
|
.iter()
|
||||||
@@ -418,7 +418,7 @@ impl IdbStorage {
|
|||||||
) -> Result<serde_json::Value, String> {
|
) -> Result<serde_json::Value, String> {
|
||||||
let db_name = format!("skill_{}", skill_id);
|
let db_name = format!("skill_{}", skill_id);
|
||||||
let conn = self.get_connection(&db_name)?;
|
let conn = self.get_connection(&db_name)?;
|
||||||
let conn_guard = conn.read();
|
let conn_guard = conn.lock();
|
||||||
|
|
||||||
let params: Vec<Box<dyn rusqlite::ToSql>> = params
|
let params: Vec<Box<dyn rusqlite::ToSql>> = params
|
||||||
.iter()
|
.iter()
|
||||||
@@ -463,7 +463,7 @@ impl IdbStorage {
|
|||||||
) -> Result<serde_json::Value, String> {
|
) -> Result<serde_json::Value, String> {
|
||||||
let db_name = format!("skill_{}", skill_id);
|
let db_name = format!("skill_{}", skill_id);
|
||||||
let conn = self.get_connection(&db_name)?;
|
let conn = self.get_connection(&db_name)?;
|
||||||
let conn_guard = conn.read();
|
let conn_guard = conn.lock();
|
||||||
|
|
||||||
let params: Vec<Box<dyn rusqlite::ToSql>> = params
|
let params: Vec<Box<dyn rusqlite::ToSql>> = params
|
||||||
.iter()
|
.iter()
|
||||||
@@ -502,7 +502,7 @@ impl IdbStorage {
|
|||||||
pub fn skill_kv_get(&self, skill_id: &str, key: &str) -> Result<serde_json::Value, String> {
|
pub fn skill_kv_get(&self, skill_id: &str, key: &str) -> Result<serde_json::Value, String> {
|
||||||
let db_name = format!("skill_{}", skill_id);
|
let db_name = format!("skill_{}", skill_id);
|
||||||
let conn = self.get_connection(&db_name)?;
|
let conn = self.get_connection(&db_name)?;
|
||||||
let conn_guard = conn.read();
|
let conn_guard = conn.lock();
|
||||||
|
|
||||||
// Ensure KV table exists
|
// Ensure KV table exists
|
||||||
conn_guard
|
conn_guard
|
||||||
@@ -535,7 +535,7 @@ impl IdbStorage {
|
|||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let db_name = format!("skill_{}", skill_id);
|
let db_name = format!("skill_{}", skill_id);
|
||||||
let conn = self.get_connection(&db_name)?;
|
let conn = self.get_connection(&db_name)?;
|
||||||
let conn_guard = conn.read();
|
let conn_guard = conn.lock();
|
||||||
|
|
||||||
// Ensure KV table exists
|
// Ensure KV table exists
|
||||||
conn_guard
|
conn_guard
|
||||||
@@ -580,7 +580,7 @@ impl IdbStorage {
|
|||||||
pub fn skill_store_delete(&self, skill_id: &str, key: &str) -> Result<(), String> {
|
pub fn skill_store_delete(&self, skill_id: &str, key: &str) -> Result<(), String> {
|
||||||
let db_name = format!("skill_{}", skill_id);
|
let db_name = format!("skill_{}", skill_id);
|
||||||
let conn = self.get_connection(&db_name)?;
|
let conn = self.get_connection(&db_name)?;
|
||||||
let conn_guard = conn.read();
|
let conn_guard = conn.lock();
|
||||||
|
|
||||||
conn_guard
|
conn_guard
|
||||||
.execute(
|
.execute(
|
||||||
@@ -596,7 +596,7 @@ impl IdbStorage {
|
|||||||
pub fn skill_store_keys(&self, skill_id: &str) -> Result<Vec<String>, String> {
|
pub fn skill_store_keys(&self, skill_id: &str) -> Result<Vec<String>, String> {
|
||||||
let db_name = format!("skill_{}", skill_id);
|
let db_name = format!("skill_{}", skill_id);
|
||||||
let conn = self.get_connection(&db_name)?;
|
let conn = self.get_connection(&db_name)?;
|
||||||
let conn_guard = conn.read();
|
let conn_guard = conn.lock();
|
||||||
|
|
||||||
// Ensure KV table exists
|
// Ensure KV table exists
|
||||||
conn_guard
|
conn_guard
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
/// This module provides configuration values that can be
|
/// This module provides configuration values that can be
|
||||||
/// overridden via environment variables at runtime.
|
/// overridden via environment variables at runtime.
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::Once;
|
|
||||||
|
|
||||||
/// Default backend URL (can be overridden via BACKEND_URL env var)
|
/// Default backend URL (can be overridden via BACKEND_URL env var)
|
||||||
pub const DEFAULT_BACKEND_URL: &str = "https://api.alphahuman.xyz";
|
pub const DEFAULT_BACKEND_URL: &str = "https://api.alphahuman.xyz";
|
||||||
@@ -15,55 +13,9 @@ pub const APP_IDENTIFIER: &str = "com.alphahuman.app";
|
|||||||
/// Service name for keychain
|
/// Service name for keychain
|
||||||
pub const KEYCHAIN_SERVICE: &str = "AlphaHuman";
|
pub const KEYCHAIN_SERVICE: &str = "AlphaHuman";
|
||||||
|
|
||||||
/// Ensure .env is loaded once
|
|
||||||
static DOTENV_INIT: Once = Once::new();
|
|
||||||
|
|
||||||
/// Try to find and load .env file from various locations
|
|
||||||
fn ensure_dotenv_loaded() {
|
|
||||||
DOTENV_INIT.call_once(|| {
|
|
||||||
// Try current directory first (project root when running `tauri dev`)
|
|
||||||
if dotenvy::dotenv().is_ok() {
|
|
||||||
log::debug!("[config] Loaded .env from current directory");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try parent directory (when cwd is src-tauri)
|
|
||||||
if let Ok(cwd) = env::current_dir() {
|
|
||||||
let parent_env = cwd.parent().map(|p| p.join(".env"));
|
|
||||||
if let Some(path) = parent_env {
|
|
||||||
if path.exists() {
|
|
||||||
if dotenvy::from_path(&path).is_ok() {
|
|
||||||
log::debug!("[config] Loaded .env from parent directory: {:?}", path);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try CARGO_MANIFEST_DIR (available during development builds)
|
|
||||||
if let Ok(manifest_dir) = env::var("CARGO_MANIFEST_DIR") {
|
|
||||||
let manifest_path = PathBuf::from(&manifest_dir);
|
|
||||||
// .env is one level up from src-tauri
|
|
||||||
let project_root_env = manifest_path.parent().map(|p| p.join(".env"));
|
|
||||||
if let Some(path) = project_root_env {
|
|
||||||
if path.exists() {
|
|
||||||
if dotenvy::from_path(&path).is_ok() {
|
|
||||||
log::debug!("[config] Loaded .env from project root: {:?}", path);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log::debug!("[config] No .env file found, using defaults/environment");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the backend URL from environment or use default
|
/// Get the backend URL from environment or use default
|
||||||
/// Checks VITE_BACKEND_URL first, then BACKEND_URL, then defaults
|
/// Checks VITE_BACKEND_URL first, then BACKEND_URL, then defaults
|
||||||
pub fn get_backend_url() -> String {
|
pub fn get_backend_url() -> String {
|
||||||
ensure_dotenv_loaded();
|
|
||||||
|
|
||||||
let url = env::var("VITE_BACKEND_URL")
|
let url = env::var("VITE_BACKEND_URL")
|
||||||
.or_else(|_| env::var("BACKEND_URL"))
|
.or_else(|_| env::var("BACKEND_URL"))
|
||||||
.unwrap_or_else(|_| DEFAULT_BACKEND_URL.to_string());
|
.unwrap_or_else(|_| DEFAULT_BACKEND_URL.to_string());
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "AlphaHuman",
|
"productName": "AlphaHuman",
|
||||||
"version": "0.1.0",
|
"version": "0.31.0",
|
||||||
"identifier": "com.alphahuman.app",
|
"identifier": "com.alphahuman.app",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
Reference in New Issue
Block a user