From 907e1d0e519c73a634cbcb4cf130e2c946997d1a Mon Sep 17 00:00:00 2001 From: David Berrios Date: Tue, 12 May 2026 16:51:18 -0700 Subject: [PATCH 1/6] feat: add JVM/Linux/Windows desktop chat-cli demos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new desktop chat REPL CLIs under `JVM/`, `Linux/`, `Windows/` — REPL chat apps that download the default `LFM2.5-350M` (Q4_0) bundle on first run, cache it under `./leap_models/`, and stream assistant responses to stdout. All three share the same SDK API surface — `LeapDownloader().loadModel(modelName, quantizationType, progress)` → `runner.createConversation(systemPrompt)` → `conversation.generateResponse(line).collect { … }` — so the demos illustrate the SDK's manifest-resolution flow rather than requiring users to provide a model bundle path. - **JVM** uses `ai.liquid.leap:leap-sdk-jvm`. The published JAR bundles JNI binaries for Linux x86_64 / aarch64 / macOS / Windows via `NativeLibLoader`, so one artifact runs on every desktop OS. - **Linux** uses Kotlin/Native (`linuxX64`) + `leap-sdk-linuxx64` and the upstream `ai.liquid.leap.nativelibs` Gradle plugin to auto-extract the per-target `.so` set (umbrella + the runtime-dispatched `libggml-cpu-{alderlake,…,zen4}.so` backends) into `build/bin/linuxX64/releaseExecutable/` so the cinterop `\$ORIGIN` rpath finds them at runtime. - **Windows** is the `mingwX64` mirror; same plugin auto-extracts the equivalent `.dll` set. For Kotlin/Native (Linux + Windows): the leap-sdk's bundled Ktor CIO engine doesn't support TLS on Native, so each demo injects `HttpClient(Curl) { install(ContentNegotiation) { json(...) } }` into `LeapDownloader`. The per-target Ktor Curl artifacts statically link libcurl + openssl, so no system libraries are needed at build/run time. Also adds: - `.github/workflows/{jvm,linux,windows}-leap-chat-cli-test.yml` — three build + smoke-test workflows. Top-level `permissions: contents: read`, `persist-credentials: false` on checkout, `gradle/actions/setup-gradle@50e97c2c # v6.1.0` (SHA-pinned), and a Sonatype-snapshot regex whitelist on the cache-purge step. Concurrency group keyed by `workflow + ref` so a fast-following push cancels the in-flight run. - `scripts/refresh-spm-if-needed.sh` — local script for iOS/macOS demos: detects when the upstream `leap-sdk` SPM tag has been force-pushed (compares local `Package.resolved` revisions against `git ls-remote`) and clears the relevant SPM caches + `Package.resolved` files. Idempotent. - Root `README.md` adds JVM, Linux, Windows sections and Quick Start entries for each platform. --- .github/workflows/jvm-leap-chat-cli-test.yml | 111 ++++++++ .../workflows/linux-leap-chat-cli-test.yml | 112 ++++++++ .../workflows/windows-leap-chat-cli-test.yml | 127 +++++++++ JVM/LeapChatCli/.gitignore | 5 + JVM/LeapChatCli/README.md | 71 +++++ JVM/LeapChatCli/build.gradle.kts | 18 ++ JVM/LeapChatCli/gradle.properties | 5 + JVM/LeapChatCli/gradle/libs.versions.toml | 12 + .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43764 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + JVM/LeapChatCli/gradlew | 251 ++++++++++++++++++ JVM/LeapChatCli/gradlew.bat | 94 +++++++ JVM/LeapChatCli/settings.gradle.kts | 17 ++ .../main/kotlin/ai/liquid/leap/cli/Main.kt | 92 +++++++ Linux/LeapChatCli/.gitignore | 5 + Linux/LeapChatCli/README.md | 66 +++++ Linux/LeapChatCli/build.gradle.kts | 36 +++ Linux/LeapChatCli/gradle.properties | 5 + Linux/LeapChatCli/gradle/libs.versions.toml | 25 ++ .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43764 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + Linux/LeapChatCli/gradlew | 251 ++++++++++++++++++ Linux/LeapChatCli/gradlew.bat | 94 +++++++ Linux/LeapChatCli/settings.gradle.kts | 20 ++ .../kotlin/ai/liquid/leap/cli/Main.kt | 122 +++++++++ README.md | 27 +- Windows/LeapChatCli/.gitignore | 5 + Windows/LeapChatCli/README.md | 65 +++++ Windows/LeapChatCli/build.gradle.kts | 70 +++++ Windows/LeapChatCli/gradle.properties | 5 + Windows/LeapChatCli/gradle/libs.versions.toml | 26 ++ .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43764 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + Windows/LeapChatCli/gradlew | 251 ++++++++++++++++++ Windows/LeapChatCli/gradlew.bat | 94 +++++++ Windows/LeapChatCli/settings.gradle.kts | 17 ++ .../kotlin/ai/liquid/leap/cli/Main.kt | 122 +++++++++ scripts/refresh-spm-if-needed.sh | 92 +++++++ 38 files changed, 2331 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/jvm-leap-chat-cli-test.yml create mode 100644 .github/workflows/linux-leap-chat-cli-test.yml create mode 100644 .github/workflows/windows-leap-chat-cli-test.yml create mode 100644 JVM/LeapChatCli/.gitignore create mode 100644 JVM/LeapChatCli/README.md create mode 100644 JVM/LeapChatCli/build.gradle.kts create mode 100644 JVM/LeapChatCli/gradle.properties create mode 100644 JVM/LeapChatCli/gradle/libs.versions.toml create mode 100644 JVM/LeapChatCli/gradle/wrapper/gradle-wrapper.jar create mode 100644 JVM/LeapChatCli/gradle/wrapper/gradle-wrapper.properties create mode 100755 JVM/LeapChatCli/gradlew create mode 100644 JVM/LeapChatCli/gradlew.bat create mode 100644 JVM/LeapChatCli/settings.gradle.kts create mode 100644 JVM/LeapChatCli/src/main/kotlin/ai/liquid/leap/cli/Main.kt create mode 100644 Linux/LeapChatCli/.gitignore create mode 100644 Linux/LeapChatCli/README.md create mode 100644 Linux/LeapChatCli/build.gradle.kts create mode 100644 Linux/LeapChatCli/gradle.properties create mode 100644 Linux/LeapChatCli/gradle/libs.versions.toml create mode 100644 Linux/LeapChatCli/gradle/wrapper/gradle-wrapper.jar create mode 100644 Linux/LeapChatCli/gradle/wrapper/gradle-wrapper.properties create mode 100755 Linux/LeapChatCli/gradlew create mode 100644 Linux/LeapChatCli/gradlew.bat create mode 100644 Linux/LeapChatCli/settings.gradle.kts create mode 100644 Linux/LeapChatCli/src/linuxX64Main/kotlin/ai/liquid/leap/cli/Main.kt create mode 100644 Windows/LeapChatCli/.gitignore create mode 100644 Windows/LeapChatCli/README.md create mode 100644 Windows/LeapChatCli/build.gradle.kts create mode 100644 Windows/LeapChatCli/gradle.properties create mode 100644 Windows/LeapChatCli/gradle/libs.versions.toml create mode 100644 Windows/LeapChatCli/gradle/wrapper/gradle-wrapper.jar create mode 100644 Windows/LeapChatCli/gradle/wrapper/gradle-wrapper.properties create mode 100755 Windows/LeapChatCli/gradlew create mode 100644 Windows/LeapChatCli/gradlew.bat create mode 100644 Windows/LeapChatCli/settings.gradle.kts create mode 100644 Windows/LeapChatCli/src/mingwX64Main/kotlin/ai/liquid/leap/cli/Main.kt create mode 100755 scripts/refresh-spm-if-needed.sh diff --git a/.github/workflows/jvm-leap-chat-cli-test.yml b/.github/workflows/jvm-leap-chat-cli-test.yml new file mode 100644 index 0000000..8113bed --- /dev/null +++ b/.github/workflows/jvm-leap-chat-cli-test.yml @@ -0,0 +1,111 @@ +name: JVM LeapChatCli Build +on: + push: + branches: [ main ] + paths: + - 'JVM/LeapChatCli/**' + - '.github/workflows/jvm-leap-chat-cli-test.yml' + pull_request: + branches: [ main ] + paths: + - 'JVM/LeapChatCli/**' + - '.github/workflows/jvm-leap-chat-cli-test.yml' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +# Least-privilege default. The workflow only consumes Maven Central / model +# downloads — no GitHub API access needed. +permissions: + contents: read + +jobs: + build-and-smoke-test: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + defaults: + run: + working-directory: JVM/LeapChatCli + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + java-version: '21' + distribution: 'temurin' + - name: Set up Gradle + uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + - name: Cache downloaded model + uses: actions/cache@v5 + with: + path: JVM/LeapChatCli/leap_models + key: leap-models-LFM2.5-350M-Q4_0-v1 + - name: Purge cached leap-sdk artifacts if Sonatype snapshot was republished + # The pin may be a SNAPSHOT in which case the upstream may force-update it under + # the same SNAPSHOT version, with new contents. When that happens, + # `--refresh-dependencies` alone isn't enough: the gradle cache restored by + # setup-java's cache: 'gradle' still contains the prior artifact, and gradle + # short-circuits the re-download because the cached SHA matches its metadata. + # + # Detect the situation by comparing Sonatype's for the snapshot + # against a marker we wrote on the previous run. Purge only when they differ — + # so the cache stays warm in the common case (no upstream republish) and + # rebuilds get the new artifact in the rare case (snapshot updated). + run: | + SDK_VERSION=$(grep -E '^\s*leapSdk\s*=' gradle/libs.versions.toml | sed -E 's/.*"([^"]+)".*/\1/') + # Whitelist the parsed version before interpolating into the curl URL + # below. The value comes from a checked-in TOML so it isn't directly + # attacker-controlled, but a typo or bad PR could let a stray char + # reach `curl` — semver + optional pre-release + optional -SNAPSHOT + # is the only shape we expect. + if ! echo "$SDK_VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)*(-[A-Za-z0-9.]+)?(-SNAPSHOT)?$'; then + echo "(unexpected leapSdk version shape '$SDK_VERSION' — skipping purge check)" + exit 0 + fi + case "$SDK_VERSION" in + *-SNAPSHOT) ;; + *) echo "leap-sdk pinned to stable $SDK_VERSION; skipping snapshot purge check"; exit 0 ;; + esac + # Check the parent `leap-sdk` artifact's — when the SDK is + # republished, the parent and every per-target sibling get re-uploaded + # together with the same timestamp, so one URL is representative for any + # workflow. Marker lives under ~/.gradle/caches so it's preserved by + # setup-java's gradle cache between runs. + REMOTE_TS=$(curl -fsSL "https://central.sonatype.com/repository/maven-snapshots/ai/liquid/leap/leap-sdk/$SDK_VERSION/maven-metadata.xml" 2>/dev/null \ + | grep -oE '[0-9]+' | grep -oE '[0-9]+' || true) + MARKER=~/.gradle/caches/leap-snapshot-marker + LOCAL_TS=$(cat "$MARKER" 2>/dev/null || true) + if [ -z "$REMOTE_TS" ]; then + echo "(could not fetch snapshot metadata for $SDK_VERSION from Sonatype — skipping purge check)" + elif [ "$REMOTE_TS" = "$LOCAL_TS" ]; then + echo "Sonatype snapshot timestamp unchanged for $SDK_VERSION ($LOCAL_TS); leap-sdk cache is fresh" + else + echo "Sonatype snapshot timestamp changed for $SDK_VERSION (${LOCAL_TS:-} -> $REMOTE_TS); purging leap-sdk cache" + rm -rf ~/.gradle/caches/modules-2/files-2.1/ai.liquid.leap* \ + ~/.gradle/caches/modules-2/metadata-*/descriptors/ai.liquid.leap* + mkdir -p "$(dirname "$MARKER")" + echo "$REMOTE_TS" > "$MARKER" + fi + - name: Build runnable distribution + run: ./gradlew --refresh-dependencies installDist + - name: Smoke test (download model, load, :quit) + run: | + # Pipe :quit so the JVM exits as soon as the model is loaded. + # Verifies the full LeapDownloader → loadModel → createConversation + # flow: JNI extraction + dlopen of all bundled .so files + the + # CPU-dispatched llamacpp backend init picks the right + # libggml-cpu-.so for the runner's CPU. + set +e + printf ':quit\n' | timeout 900 build/install/leap-chat-cli/bin/leap-chat-cli \ + > stdout.log 2> stderr.log + exit_code=$? + set -e + echo "--- exit code: $exit_code ---" + echo "--- stdout ---"; cat stdout.log + echo "--- stderr ---"; cat stderr.log + test "$exit_code" -eq 0 + grep -q 'ready (model id:' stdout.log diff --git a/.github/workflows/linux-leap-chat-cli-test.yml b/.github/workflows/linux-leap-chat-cli-test.yml new file mode 100644 index 0000000..8da7eb0 --- /dev/null +++ b/.github/workflows/linux-leap-chat-cli-test.yml @@ -0,0 +1,112 @@ +name: Linux LeapChatCli Build +on: + push: + branches: [ main ] + paths: + - 'Linux/LeapChatCli/**' + - '.github/workflows/linux-leap-chat-cli-test.yml' + pull_request: + branches: [ main ] + paths: + - 'Linux/LeapChatCli/**' + - '.github/workflows/linux-leap-chat-cli-test.yml' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +# Least-privilege default. +permissions: + contents: read + +jobs: + build-and-smoke-test: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + defaults: + run: + working-directory: Linux/LeapChatCli + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + java-version: '21' + distribution: 'temurin' + - name: Set up Gradle + uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + - name: Cache Kotlin/Native distribution + uses: actions/cache@v5 + with: + path: ~/.konan + key: konan-${{ runner.os }}-${{ hashFiles('Linux/LeapChatCli/gradle/libs.versions.toml') }} + - name: Cache downloaded model + uses: actions/cache@v5 + with: + path: Linux/LeapChatCli/leap_models + key: leap-models-LFM2.5-350M-Q4_0-v1 + - name: Purge cached leap-sdk artifacts if Sonatype snapshot was republished + # The pin may be a SNAPSHOT in which case the upstream may force-update it under + # the same SNAPSHOT version, with new contents. When that happens, + # `--refresh-dependencies` alone isn't enough: the gradle cache restored by + # setup-java's cache: 'gradle' still contains the prior artifact, and gradle + # short-circuits the re-download because the cached SHA matches its metadata. + # + # Detect the situation by comparing Sonatype's for the snapshot + # against a marker we wrote on the previous run. Purge only when they differ — + # so the cache stays warm in the common case (no upstream republish) and + # rebuilds get the new artifact in the rare case (snapshot updated). + run: | + SDK_VERSION=$(grep -E '^\s*leapSdk\s*=' gradle/libs.versions.toml | sed -E 's/.*"([^"]+)".*/\1/') + # Whitelist before interpolating into the curl URL below. + if ! echo "$SDK_VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)*(-[A-Za-z0-9.]+)?(-SNAPSHOT)?$'; then + echo "(unexpected leapSdk version shape '$SDK_VERSION' — skipping purge check)" + exit 0 + fi + case "$SDK_VERSION" in + *-SNAPSHOT) ;; + *) echo "leap-sdk pinned to stable $SDK_VERSION; skipping snapshot purge check"; exit 0 ;; + esac + # Check the parent `leap-sdk` artifact's — when the SDK is + # republished, the parent and every per-target sibling get re-uploaded + # together with the same timestamp, so one URL is representative for any + # workflow. Marker lives under ~/.gradle/caches so it's preserved by + # setup-java's gradle cache between runs. + REMOTE_TS=$(curl -fsSL "https://central.sonatype.com/repository/maven-snapshots/ai/liquid/leap/leap-sdk/$SDK_VERSION/maven-metadata.xml" 2>/dev/null \ + | grep -oE '[0-9]+' | grep -oE '[0-9]+' || true) + MARKER=~/.gradle/caches/leap-snapshot-marker + LOCAL_TS=$(cat "$MARKER" 2>/dev/null || true) + if [ -z "$REMOTE_TS" ]; then + echo "(could not fetch snapshot metadata for $SDK_VERSION from Sonatype — skipping purge check)" + elif [ "$REMOTE_TS" = "$LOCAL_TS" ]; then + echo "Sonatype snapshot timestamp unchanged for $SDK_VERSION ($LOCAL_TS); leap-sdk cache is fresh" + else + echo "Sonatype snapshot timestamp changed for $SDK_VERSION (${LOCAL_TS:-} -> $REMOTE_TS); purging leap-sdk cache" + rm -rf ~/.gradle/caches/modules-2/files-2.1/ai.liquid.leap* \ + ~/.gradle/caches/modules-2/metadata-*/descriptors/ai.liquid.leap* + mkdir -p "$(dirname "$MARKER")" + echo "$REMOTE_TS" > "$MARKER" + fi + - name: Build native executable + run: ./gradlew --refresh-dependencies linkReleaseExecutableLinuxX64 + - name: Smoke test (download model, load, :quit) + run: | + # Pipe :quit so the .kexe exits as soon as the model is loaded. + # Verifies the full LeapDownloader → loadModel → createConversation + # flow on Kotlin/Native: cinterop + $ORIGIN-rpath loading of every + # .so in releaseExecutable/, plus the CPU-dispatched llamacpp + # backend init picking the right libggml-cpu-.so for the + # runner's CPU. + set +e + printf ':quit\n' | timeout 900 build/bin/linuxX64/releaseExecutable/leap-chat-cli.kexe \ + > stdout.log 2> stderr.log + exit_code=$? + set -e + echo "--- exit code: $exit_code ---" + echo "--- stdout ---"; cat stdout.log + echo "--- stderr ---"; cat stderr.log + test "$exit_code" -eq 0 + grep -q 'ready (model id:' stdout.log diff --git a/.github/workflows/windows-leap-chat-cli-test.yml b/.github/workflows/windows-leap-chat-cli-test.yml new file mode 100644 index 0000000..7159228 --- /dev/null +++ b/.github/workflows/windows-leap-chat-cli-test.yml @@ -0,0 +1,127 @@ +name: Windows LeapChatCli Build +on: + push: + branches: [ main ] + paths: + - 'Windows/LeapChatCli/**' + - '.github/workflows/windows-leap-chat-cli-test.yml' + pull_request: + branches: [ main ] + paths: + - 'Windows/LeapChatCli/**' + - '.github/workflows/windows-leap-chat-cli-test.yml' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +# Least-privilege default. +permissions: + contents: read + +jobs: + build: + runs-on: windows-2025-vs2026 + timeout-minutes: 30 + defaults: + run: + working-directory: Windows/LeapChatCli + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + java-version: '21' + distribution: 'temurin' + - name: Set up Gradle + uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + - name: Cache Kotlin/Native distribution + uses: actions/cache@v5 + with: + path: ~\.konan + key: konan-${{ runner.os }}-${{ hashFiles('Windows/LeapChatCli/gradle/libs.versions.toml') }} + - name: Purge cached leap-sdk artifacts if Sonatype snapshot was republished + # The pin may be a SNAPSHOT in which case the upstream may force-update it under + # the same SNAPSHOT version, with new contents. When that happens, + # `--refresh-dependencies` alone isn't enough: the gradle cache restored by + # setup-java's cache: 'gradle' still contains the prior artifact, and gradle + # short-circuits the re-download because the cached SHA matches its metadata. + # + # Detect the situation by comparing Sonatype's for the snapshot + # against a marker we wrote on the previous run. Purge only when they differ — + # so the cache stays warm in the common case (no upstream republish) and + # rebuilds get the new artifact in the rare case (snapshot updated). + shell: pwsh + run: | + $sdkLine = (Get-Content "gradle\libs.versions.toml" | Select-String '^\s*leapSdk\s*=' | Select-Object -First 1).Line + if ($sdkLine -match '"([^"]+)"') { $sdkVersion = $matches[1] } else { + Write-Host "(could not parse leapSdk version from gradle/libs.versions.toml — skipping purge check)" + return + } + # Whitelist version shape before interpolating into the metadata URL. + if ($sdkVersion -notmatch '^[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)*(-[A-Za-z0-9.]+)?(-SNAPSHOT)?$') { + Write-Host "(unexpected leapSdk version shape '$sdkVersion' — skipping purge check)" + return + } + if ($sdkVersion -notmatch '-SNAPSHOT$') { + Write-Host "leap-sdk pinned to stable $sdkVersion; skipping snapshot purge check" + return + } + # Check the parent `leap-sdk` artifact's — when the SDK is + # republished, the parent and every per-target sibling get re-uploaded + # together with the same timestamp, so one URL is representative for any + # workflow. Marker lives under ~/.gradle/caches so it's preserved by + # setup-java's gradle cache between runs. + $metaUrl = "https://central.sonatype.com/repository/maven-snapshots/ai/liquid/leap/leap-sdk/$sdkVersion/maven-metadata.xml" + $marker = "$env:USERPROFILE\.gradle\caches\leap-snapshot-marker" + try { + $xml = [xml](Invoke-WebRequest -Uri $metaUrl -UseBasicParsing).Content + $remoteTs = $xml.metadata.versioning.lastUpdated + } catch { + Write-Host "(could not fetch snapshot metadata for $sdkVersion from Sonatype — skipping purge check)" + return + } + $localTs = if (Test-Path $marker) { Get-Content $marker -Raw } else { "" } + $localTs = $localTs.Trim() + if ($remoteTs -eq $localTs) { + Write-Host "Sonatype snapshot timestamp unchanged for $sdkVersion ($localTs); leap-sdk cache is fresh" + } else { + $shown = if ($localTs) { $localTs } else { "" } + Write-Host "Sonatype snapshot timestamp changed for $sdkVersion ($shown -> $remoteTs); purging leap-sdk cache" + Get-ChildItem -Path "$env:USERPROFILE\.gradle\caches\modules-2" -Filter "ai.liquid.leap*" -Recurse -Force -ErrorAction SilentlyContinue | + Remove-Item -Recurse -Force -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Force -Path (Split-Path $marker) | Out-Null + Set-Content -Path $marker -Value $remoteTs -NoNewline + } + - name: Build native executable + # Build-only — no smoke test on the github-hosted runner. The .exe links + # against libgcc_s_seh-1.dll (Konan's MinGW toolchain ships only the + # import library, no static counterpart for libgcc_s on x86_64), so the + # binary fails to load on a clean Windows host with STATUS_DLL_NOT_FOUND + # (0xC0000135) before any Kotlin code runs. End-to-end download + chat + # works on developer hosts that have a MinGW runtime on PATH (or with + # the DLL co-located). A proper redistributable bundle of the MinGW + # runtime DLLs is deferred — see JVM/LeapChatCli for the portable + # JNI-based equivalent that has no native runtime dependencies. + run: .\gradlew.bat --refresh-dependencies linkReleaseExecutableMingwX64 + - name: Verify expected artifacts produced + shell: pwsh + run: | + Set-StrictMode -Version Latest + $ErrorActionPreference = 'Stop' + $dir = "build\bin\mingwX64\releaseExecutable" + $expected = @( + "leap-chat-cli.exe", + "inference_engine.dll", + "libinference_engine_llamacpp_backend.dll", + "ie_zip.dll" + ) + Write-Host "--- files in $dir ---" + Get-ChildItem $dir | Select-Object Name, Length | Format-Table + $missing = $expected | Where-Object { -not (Test-Path "$dir\$_") } + if ($missing) { + throw "missing expected artifacts: $($missing -join ', ')" + } diff --git a/JVM/LeapChatCli/.gitignore b/JVM/LeapChatCli/.gitignore new file mode 100644 index 0000000..4134047 --- /dev/null +++ b/JVM/LeapChatCli/.gitignore @@ -0,0 +1,5 @@ +.gradle/ +build/ +.kotlin/ +local.properties +leap_models/ diff --git a/JVM/LeapChatCli/README.md b/JVM/LeapChatCli/README.md new file mode 100644 index 0000000..cd21aa5 --- /dev/null +++ b/JVM/LeapChatCli/README.md @@ -0,0 +1,71 @@ +# LeapChatCli — JVM + +A REPL-style multi-turn chat CLI on the JVM. Loads a local LeapSDK model +bundle, then streams assistant responses to stdout token-by-token. Cross-OS: +the same artifact runs on Linux, macOS, and Windows because +`leap-sdk-jvm` bundles JNI binaries for all three desktops via +`NativeLibLoader` (extracts the right `.so`/`.dll`/`.dylib` for the host +at runtime). + +## Prerequisites + +- JDK 21 (Zulu recommended on Apple Silicon) +- Internet access on first run (the demo downloads `LFM2.5-350M` Q4_0 — about + 370 MB — into `./leap_models/` and reuses the cache afterwards) + +## Build + +```bash +JAVA_HOME="$(/usr/libexec/java_home -v 21)" ./gradlew installDist +``` + +This produces a runnable distribution at +`build/install/leap-chat-cli/bin/leap-chat-cli` (`.bat` on Windows). + +## Run + +```bash +build/install/leap-chat-cli/bin/leap-chat-cli +``` + +The first run streams a `Downloading: NN% (M / N MB)` line until the model +lands on disk, then drops you into the REPL. Type a message + Enter to send. +Generation streams to stdout token-by-token; a `[ tok, tok/s]` summary +lands on stderr after each turn. EOF (Ctrl-D) or `:quit` exits. Subsequent +launches reuse the cached model and start the REPL immediately. + +To use a different model or quantization, change the `MODEL_NAME` and +`QUANTIZATION_SLUG` constants at the top of `Main.kt` and rebuild. + +## How it works + +```kotlin +val downloader = LeapDownloader() +val runner = downloader.loadModel( + modelName = "LFM2.5-350M", + quantizationSlug = "Q4_0", + progress = { pd -> /* bytes / total → percent for the progress line */ }, +) + +val conversation = runner.createConversation(systemPrompt = "…") +conversation.generateResponse("Hello").collect { response -> + when (response) { + is MessageResponse.Chunk -> print(response.text) + is MessageResponse.Complete -> println() + else -> Unit + } +} +``` + +`LeapDownloader` (from `ai.liquid.leap.manifest`, in the common SDK module — +same one the Web demo uses) resolves the manifest from `leap.liquid.ai`, +downloads the model bundle to `./leap_models/-/`, and returns a +ready `ModelRunner`. `Conversation.generateResponse` returns a +`Flow` whose `Chunk`s arrive as the model decodes; the +terminal `Complete` carries generation stats. Per-turn history is kept by +the `Conversation` object so follow-up turns include prior context. + +See [`Linux/LeapChatCli/`](../../Linux/LeapChatCli/) and +[`Windows/LeapChatCli/`](../../Windows/LeapChatCli/) for Kotlin/Native +ports of the same demo using `linuxX64` / `mingwX64` targets — same SDK +API, no JVM required. diff --git a/JVM/LeapChatCli/build.gradle.kts b/JVM/LeapChatCli/build.gradle.kts new file mode 100644 index 0000000..b07dfac --- /dev/null +++ b/JVM/LeapChatCli/build.gradle.kts @@ -0,0 +1,18 @@ +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.application) +} + +kotlin { + jvmToolchain(21) +} + +dependencies { + implementation(libs.leap.sdk) + implementation(libs.kotlinx.coroutines.core) +} + +application { + mainClass.set("ai.liquid.leap.cli.MainKt") + applicationName = "leap-chat-cli" +} diff --git a/JVM/LeapChatCli/gradle.properties b/JVM/LeapChatCli/gradle.properties new file mode 100644 index 0000000..ec49d2b --- /dev/null +++ b/JVM/LeapChatCli/gradle.properties @@ -0,0 +1,5 @@ +org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 +org.gradle.parallel=true +org.gradle.caching=true + +kotlin.code.style=official diff --git a/JVM/LeapChatCli/gradle/libs.versions.toml b/JVM/LeapChatCli/gradle/libs.versions.toml new file mode 100644 index 0000000..e2e4fee --- /dev/null +++ b/JVM/LeapChatCli/gradle/libs.versions.toml @@ -0,0 +1,12 @@ +[versions] +kotlin = "2.3.20" +coroutines = "1.10.2" +leapSdk = "0.10.6" + +[libraries] +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } +leap-sdk = { module = "ai.liquid.leap:leap-sdk", version.ref = "leapSdk" } + +[plugins] +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +application = { id = "application" } diff --git a/JVM/LeapChatCli/gradle/wrapper/gradle-wrapper.jar b/JVM/LeapChatCli/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..1b33c55baabb587c669f562ae36f953de2481846 GIT binary patch literal 43764 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 '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/JVM/LeapChatCli/gradlew.bat b/JVM/LeapChatCli/gradlew.bat new file mode 100644 index 0000000..5eed7ee --- /dev/null +++ b/JVM/LeapChatCli/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/JVM/LeapChatCli/settings.gradle.kts b/JVM/LeapChatCli/settings.gradle.kts new file mode 100644 index 0000000..f7cf7dc --- /dev/null +++ b/JVM/LeapChatCli/settings.gradle.kts @@ -0,0 +1,17 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + mavenLocal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + mavenLocal() + mavenCentral() + } +} + +rootProject.name = "LeapChatCli" diff --git a/JVM/LeapChatCli/src/main/kotlin/ai/liquid/leap/cli/Main.kt b/JVM/LeapChatCli/src/main/kotlin/ai/liquid/leap/cli/Main.kt new file mode 100644 index 0000000..1597220 --- /dev/null +++ b/JVM/LeapChatCli/src/main/kotlin/ai/liquid/leap/cli/Main.kt @@ -0,0 +1,92 @@ +package ai.liquid.leap.cli + +import ai.liquid.leap.ModelLoadingOptions +import ai.liquid.leap.manifest.LeapDownloader +import ai.liquid.leap.message.MessageResponse +import kotlin.system.exitProcess +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.runBlocking + +/** Defaults match the Android LeapChat demo. ~250 MB on first run; cached afterwards. */ +private const val MODEL_NAME = "LFM2.5-350M" +private const val QUANTIZATION_SLUG = "Q4_0" +private const val SYSTEM_PROMPT = "You are a helpful assistant. Be concise." + +// KV state cache directory — sibling of the leap_models/ bundle cache. The bounded-LRU cache +// persists per-conversation prefix snapshots so warm reloads (same system prompt + history) skip +// re-prefilling the chat template; meaningful speedup once the demo has been used a few times. +private const val CACHE_DIR = "./leap_models/cache" + +fun main(args: Array): Unit = runBlocking { + if (args.isNotEmpty() && args[0] in setOf("-h", "--help")) { + System.err.println( + """ + |usage: leap-chat-cli + | + |Downloads $MODEL_NAME ($QUANTIZATION_SLUG) on first run and caches it under + |./leap_models/. Type messages and press Enter to chat. EOF (Ctrl-D) or + |:quit exits. + """ + .trimMargin() + ) + exitProcess(0) + } + + val downloader = LeapDownloader() + print("Loading $MODEL_NAME ($QUANTIZATION_SLUG) … ") + System.out.flush() + val runner = + try { + downloader.loadModel( + modelName = MODEL_NAME, + quantizationType = QUANTIZATION_SLUG, + options = + ModelLoadingOptions(cacheOptions = ModelLoadingOptions.cacheOptions(path = CACHE_DIR)), + progress = { pd -> + if (pd.total > 0) { + val pct = (pd.bytes * 100 / pd.total).toInt() + print("\rDownloading: %3d%% (%d / %d MB)".format(pct, pd.bytes / 1_000_000, pd.total / 1_000_000)) + System.out.flush() + } + }, + ) + } catch (e: Exception) { + System.err.println("\nfailed to load model: ${e.message}") + exitProcess(1) + } + println("\nready (model id: ${runner.modelId})") + println("Type a message and press Enter. EOF (Ctrl-D) or :quit to exit.") + println() + + try { + val conversation = runner.createConversation(systemPrompt = SYSTEM_PROMPT) + while (true) { + print("> ") + System.out.flush() + val line = readlnOrNull() ?: break + val trimmed = line.trim() + if (trimmed == ":quit") break + if (trimmed.isEmpty()) continue + + conversation.generateResponse(line).collect { response -> + when (response) { + is MessageResponse.Chunk -> { + print(response.text) + System.out.flush() + } + is MessageResponse.Complete -> { + println() + response.stats?.let { stats -> + System.err.println( + "[${stats.completionTokens} tok, " + "%.1f tok/s]".format(stats.tokenPerSecond) + ) + } + } + else -> Unit // ReasoningChunk / FunctionCalls / AudioSample — not used by this CLI + } + } + } + } finally { + runner.unload() + } +} diff --git a/Linux/LeapChatCli/.gitignore b/Linux/LeapChatCli/.gitignore new file mode 100644 index 0000000..4134047 --- /dev/null +++ b/Linux/LeapChatCli/.gitignore @@ -0,0 +1,5 @@ +.gradle/ +build/ +.kotlin/ +local.properties +leap_models/ diff --git a/Linux/LeapChatCli/README.md b/Linux/LeapChatCli/README.md new file mode 100644 index 0000000..7156416 --- /dev/null +++ b/Linux/LeapChatCli/README.md @@ -0,0 +1,66 @@ +# LeapChatCli — Linux + +A REPL-style multi-turn chat CLI built as a native Linux executable using +Kotlin/Native (`linuxX64` target). Loads a local LeapSDK model bundle and +streams responses to stdout. No JVM at runtime. + +## Prerequisites + +- Linux x86_64 host (Ubuntu 22.04+ tested) +- JDK 21 to run Gradle (Zulu recommended) +- Internet access on first run (the demo downloads `LFM2.5-350M` Q4_0 — about + 370 MB — into `./leap_models/` and reuses the cache afterwards) + +> **Note on cross-compile**: Kotlin/Native can cross-compile `linuxX64` Kotlin +> code from macOS, but cross-linking against the shared `libinference_engine.so` +> from macOS isn't supported (lld rejects `-Wl,-rpath,$ORIGIN` cinterop syntax, +> and the natives directory isn't wired into the linker search path in +> cross-host mode). Build + run from a Linux host. + +## How the native libraries get there + +The `ai.liquid.leap.nativelibs` Gradle plugin (declared in `build.gradle.kts`) +auto-resolves `ai.liquid.leap:leap-sdk-linuxx64:0.10.1:natives@zip` from +Maven and extracts: + +- `libinference_engine.so` +- `libinference_engine_llamacpp_backend.so` +- `libie_zip.so` + +into `build/bin/linuxX64/releaseExecutable/` so the cinterop `$ORIGIN` rpath +finds them at runtime. No `installVendor`, no S3, no manual binary drops. + +## Build + +```bash +# Set JAVA_HOME to your JDK 21 install. On Debian/Ubuntu after installing +# `openjdk-21-jdk` it lives at /usr/lib/jvm/java-21-openjdk-amd64/. With +# SDKMAN it's $HOME/.sdkman/candidates/java/current/. Pick whichever fits. +export JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64 +./gradlew linkReleaseExecutableLinuxX64 +``` + +Output: `build/bin/linuxX64/releaseExecutable/leap-chat-cli.kexe` plus the +three `.so` files alongside it. + +## Run + +```bash +build/bin/linuxX64/releaseExecutable/leap-chat-cli.kexe +``` + +The first run streams a `Downloading: NN% (M / N MB)` line until the model +lands on disk, then drops you into the REPL. Type a message + Enter to send. +EOF (Ctrl-D) or `:quit` exits. Subsequent launches reuse the cached model. + +To use a different model or quantization, change the `MODEL_NAME` and +`QUANTIZATION_SLUG` constants at the top of `Main.kt` and rebuild. + +## How it works + +Same `LeapDownloader().loadModel(...)` + `Conversation.generateResponse(...)` +flow as the [JVM CLI](../../JVM/LeapChatCli/) and the [Web demo](../../Web/LeapVoiceAssistantDemo/) — a single Kotlin source file +(`src/linuxX64Main/kotlin/ai/liquid/leap/cli/Main.kt`) drives the REPL. The +Kotlin Multiplatform machinery picks the published `leap-sdk-linuxx64` +variant (cinterop bindings to the bare C inference engine API) instead of +the JNI-backed JVM artifact. diff --git a/Linux/LeapChatCli/build.gradle.kts b/Linux/LeapChatCli/build.gradle.kts new file mode 100644 index 0000000..c24b86b --- /dev/null +++ b/Linux/LeapChatCli/build.gradle.kts @@ -0,0 +1,36 @@ +plugins { + alias(libs.plugins.kotlin.multiplatform) + // Auto-wires the leap-sdk-linuxx64::natives@zip dependency and extracts + // libinference_engine.so + libinference_engine_llamacpp_backend.so + libie_zip.so + // alongside the produced executable so the cinterop $ORIGIN rpath finds them at runtime. + alias(libs.plugins.leap.sdk.native.libs) +} + +kotlin { + linuxX64 { + binaries { + executable { + entryPoint = "ai.liquid.leap.cli.main" + baseName = "leap-chat-cli" + } + } + } + + sourceSets { + val linuxX64Main by getting { + dependencies { + implementation(libs.leap.sdk) + implementation(libs.kotlinx.coroutines.core) + // Ktor Curl engine for TLS on Kotlin/Native (the leap-sdk's bundled CIO + // engine doesn't support TLS on Native; injecting HttpClient(Curl) into + // LeapDownloader fixes HTTPS calls to leap.liquid.ai). The + // ContentNegotiation + kotlinx-json plugins match what leap-sdk's own + // default client installs — required for `body()` to deserialize + // the JSON manifest response. + implementation(libs.ktor.client.curl) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.serialization.kotlinx.json) + } + } + } +} diff --git a/Linux/LeapChatCli/gradle.properties b/Linux/LeapChatCli/gradle.properties new file mode 100644 index 0000000..ec49d2b --- /dev/null +++ b/Linux/LeapChatCli/gradle.properties @@ -0,0 +1,5 @@ +org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 +org.gradle.parallel=true +org.gradle.caching=true + +kotlin.code.style=official diff --git a/Linux/LeapChatCli/gradle/libs.versions.toml b/Linux/LeapChatCli/gradle/libs.versions.toml new file mode 100644 index 0000000..75966e3 --- /dev/null +++ b/Linux/LeapChatCli/gradle/libs.versions.toml @@ -0,0 +1,25 @@ +[versions] +kotlin = "2.3.20" +coroutines = "1.10.2" +leapSdk = "0.10.6" +ktor = "3.3.3" + +[libraries] +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } +leap-sdk = { module = "ai.liquid.leap:leap-sdk", version.ref = "leapSdk" } +# Curl engine for Ktor on linuxX64 — the leap-sdk-linuxx64 artifact pulls in +# ktor-client-cio which has no TLS support on Kotlin/Native, so HTTPS calls +# (e.g. LeapDownloader hitting leap.liquid.ai) fail with "TLS sessions are not +# supported on Native platform." Adding the Curl engine and passing +# `HttpClient(Curl)` to LeapDownloader gives us TLS via libcurl + openssl. +ktor-client-curl = { module = "io.ktor:ktor-client-curl", version.ref = "ktor" } +# Required because LeapDownloader expects the injected HttpClient to deserialize +# JSON responses (the manifest endpoint returns application/json). Without the +# ContentNegotiation + json plugins, the SDK's `body()` throws +# NoTransformationFoundException. Mirrors what leap-sdk's own default client installs. +ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } +ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } + +[plugins] +kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +leap-sdk-native-libs = { id = "ai.liquid.leap.nativelibs", version.ref = "leapSdk" } diff --git a/Linux/LeapChatCli/gradle/wrapper/gradle-wrapper.jar b/Linux/LeapChatCli/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..1b33c55baabb587c669f562ae36f953de2481846 GIT binary patch literal 43764 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 '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/Linux/LeapChatCli/gradlew.bat b/Linux/LeapChatCli/gradlew.bat new file mode 100644 index 0000000..5eed7ee --- /dev/null +++ b/Linux/LeapChatCli/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/Linux/LeapChatCli/settings.gradle.kts b/Linux/LeapChatCli/settings.gradle.kts new file mode 100644 index 0000000..94c15c2 --- /dev/null +++ b/Linux/LeapChatCli/settings.gradle.kts @@ -0,0 +1,20 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + mavenLocal() + } +} + +dependencyResolutionManagement { + // PREFER_SETTINGS so the Kotlin/Native plugin can register its own toolchain + // ivy repos for konan / yarn distributions; ours below win for the leap SDK + // and the native-libs-plugin. + repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS) + repositories { + mavenLocal() + mavenCentral() + } +} + +rootProject.name = "LeapChatCli" diff --git a/Linux/LeapChatCli/src/linuxX64Main/kotlin/ai/liquid/leap/cli/Main.kt b/Linux/LeapChatCli/src/linuxX64Main/kotlin/ai/liquid/leap/cli/Main.kt new file mode 100644 index 0000000..d4b34fe --- /dev/null +++ b/Linux/LeapChatCli/src/linuxX64Main/kotlin/ai/liquid/leap/cli/Main.kt @@ -0,0 +1,122 @@ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) + +package ai.liquid.leap.cli + +import ai.liquid.leap.ModelLoadingOptions +import ai.liquid.leap.manifest.LeapDownloader +import ai.liquid.leap.message.MessageResponse +import io.ktor.client.HttpClient +import io.ktor.client.engine.curl.Curl +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.serialization.kotlinx.json.json +import kotlin.system.exitProcess +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json +import platform.posix.fflush + +// Kotlin/Native print()/println() go through stdio which is line-buffered on a TTY and fully +// buffered otherwise — flushing keeps the REPL truly streaming. fflush(NULL) flushes all open +// output streams per POSIX, avoiding a per-target stdout symbol lookup. +private fun flushStdout() { + fflush(null) +} + +/** Defaults match the Android LeapChat demo. ~250 MB on first run; cached afterwards. */ +private const val MODEL_NAME = "LFM2.5-350M" +private const val QUANTIZATION_SLUG = "Q4_0" +private const val SYSTEM_PROMPT = "You are a helpful assistant. Be concise." + +// KV state cache directory — sibling of the leap_models/ bundle cache. The bounded-LRU cache +// persists per-conversation prefix snapshots so warm reloads (same system prompt + history) skip +// re-prefilling the chat template; meaningful speedup once the demo has been used a few times. +private const val CACHE_DIR = "./leap_models/cache" + +fun main(args: Array): Unit = runBlocking { + if (args.isNotEmpty() && args[0] in setOf("-h", "--help")) { + println( + """ + |usage: leap-chat-cli + | + |Downloads $MODEL_NAME ($QUANTIZATION_SLUG) on first run and caches it under + |./leap_models/. Type messages and press Enter to chat. EOF (Ctrl-D) or + |:quit exits. + """ + .trimMargin() + ) + exitProcess(0) + } + + // Inject an HttpClient backed by the Curl engine so HTTPS works. The leap-sdk + // for linuxX64 bundles Ktor CIO which has no TLS support on Native; without this + // injection, LeapDownloader's call to leap.liquid.ai fails with "TLS sessions + // are not supported on Native platform." + // + // ContentNegotiation + json are required because LeapDownloader's body() + // expects the client to deserialize the JSON response — same plugins the SDK's + // own default client installs. Without them: NoTransformationFoundException. + val http = HttpClient(Curl) { + install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) } + } + http.use { chatLoop(it) } +} + +private suspend fun chatLoop(http: HttpClient) { + val downloader = LeapDownloader(httpClient = http) + print("Loading $MODEL_NAME ($QUANTIZATION_SLUG) … ") + flushStdout() + val runner = + try { + downloader.loadModel( + modelName = MODEL_NAME, + quantizationType = QUANTIZATION_SLUG, + options = + ModelLoadingOptions(cacheOptions = ModelLoadingOptions.cacheOptions(path = CACHE_DIR)), + progress = { pd -> + if (pd.total > 0) { + val pct = (pd.bytes * 100 / pd.total).toInt() + val mbDone = pd.bytes / 1_000_000 + val mbTotal = pd.total / 1_000_000 + print("\rDownloading: $pct% ($mbDone / $mbTotal MB)") + flushStdout() + } + }, + ) + } catch (e: Exception) { + println("\nfailed to load model: ${e.message}") + exitProcess(1) + } + println("\nready (model id: ${runner.modelId})") + println("Type a message and press Enter. EOF (Ctrl-D) or :quit to exit.") + println() + + try { + val conversation = runner.createConversation(systemPrompt = SYSTEM_PROMPT) + while (true) { + print("> ") + flushStdout() + val line = readlnOrNull() ?: break + val trimmed = line.trim() + if (trimmed == ":quit") break + if (trimmed.isEmpty()) continue + + conversation.generateResponse(line).collect { response -> + when (response) { + is MessageResponse.Chunk -> { + print(response.text) + flushStdout() + } + is MessageResponse.Complete -> { + println() + response.stats?.let { stats -> + println("[${stats.completionTokens} tok, ${stats.tokenPerSecond} tok/s]") + } + } + else -> Unit // ReasoningChunk / FunctionCalls / AudioSample — not used by this CLI + } + } + } + } finally { + runner.unload() + } +} diff --git a/README.md b/README.md index 88594d7..2aa05fa 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,18 @@ Gradle-based projects using the LeapSDK Android library. - **LeapVoiceAssistantDemo**: Kotlin/Wasm Compose-for-Web port of the voice assistant. Run with `./gradlew wasmJsBrowserDevelopmentRun` (dev server) or `./gradlew wasmJsBrowserDistribution` (static bundle). +### ☕ [JVM Examples](./JVM/) + +- **LeapChatCli**: REPL chat CLI on the JVM using `leap-sdk-jvm`. Single artifact runs on Linux/macOS/Windows — JNI binaries are bundled in the JAR and extracted at runtime. + +### 🐧 [Linux Examples](./Linux/) + +- **LeapChatCli**: REPL chat CLI as a native Linux x86_64 executable using Kotlin/Native (`linuxX64`) and `leap-sdk-linuxx64`. The `ai.liquid.leap.nativelibs` Gradle plugin auto-extracts `libinference_engine.so` from the published `:natives@zip` classifier alongside the binary. + +### 🪟 [Windows Examples](./Windows/) + +- **LeapChatCli**: REPL chat CLI as a native Windows x86_64 `.exe` using Kotlin/Native (`mingwX64`) and `leap-sdk-mingwx64`. Same `ai.liquid.leap.nativelibs` plugin auto-extracts `inference_engine.dll`. + ## Quick Start ### iOS @@ -63,14 +75,23 @@ cd Web/LeapVoiceAssistantDemo # open http://localhost:8080 ``` +### JVM / Linux / Windows + +```bash +cd JVM/LeapChatCli # or Linux/LeapChatCli, Windows/LeapChatCli +./gradlew installDist # JVM +./gradlew linkReleaseExecutableLinuxX64 # Linux (run on a Linux host) +./gradlew linkReleaseExecutableMingwX64 # Windows (run on a Windows host) +``` + ## What is LeapSDK? -LeapSDK enables running AI models locally on mobile devices and in the browser using the Liquid Inference Engine. It provides: +LeapSDK enables running AI models locally across mobile, desktop, and browser using the Liquid Inference Engine. It provides: - **On-device inference** - No internet required - **Real-time streaming** - Token-by-token response generation -- **Cross-platform** - iOS, macOS, Android, and Web (Kotlin/Wasm) support -- **High performance** - Optimized for mobile hardware +- **Cross-platform** - iOS, macOS, Android, Web (Kotlin/Wasm), JVM, Linux, and Windows +- **High performance** - Optimized for mobile and desktop hardware - **Easy integration** - Simple API for chat and text generation ## Documentation diff --git a/Windows/LeapChatCli/.gitignore b/Windows/LeapChatCli/.gitignore new file mode 100644 index 0000000..4134047 --- /dev/null +++ b/Windows/LeapChatCli/.gitignore @@ -0,0 +1,5 @@ +.gradle/ +build/ +.kotlin/ +local.properties +leap_models/ diff --git a/Windows/LeapChatCli/README.md b/Windows/LeapChatCli/README.md new file mode 100644 index 0000000..d0c5634 --- /dev/null +++ b/Windows/LeapChatCli/README.md @@ -0,0 +1,65 @@ +# LeapChatCli — Windows + +A REPL-style multi-turn chat CLI built as a native Windows executable using +Kotlin/Native (`mingwX64` target). Loads a local LeapSDK model bundle and +streams responses to stdout. No JVM at runtime. + +## Prerequisites + +- Windows x86_64 host (Windows 10/11) +- JDK 21 to run Gradle (Zulu recommended) +- Internet access on first run (the demo downloads `LFM2.5-350M` Q4_0 — about + 370 MB — into `.\leap_models\` and reuses the cache afterwards) + +> **Note on cross-compile**: Kotlin/Native can compile `mingwX64` Kotlin code +> from any host (verified on macOS), but cross-linking the final `.exe` +> against `inference_engine.dll` from non-Windows hosts isn't supported by +> Kotlin/Native. Build + run from Windows itself, not WSL2 (which is Linux +> under the hood and would still hit the cross-link limitation). + +## How the native libraries get there + +The `ai.liquid.leap.nativelibs` Gradle plugin (declared in `build.gradle.kts`) +auto-resolves `ai.liquid.leap:leap-sdk-mingwx64:0.10.1:natives@zip` from +Maven and extracts: + +- `inference_engine.dll` +- `libinference_engine_llamacpp_backend.dll` +- `ie_zip.dll` + +into `build/bin/mingwX64/releaseExecutable/` so standard Windows +DLL co-location loads them at runtime when you launch the `.exe`. No +`installVendor`, no S3, no manual binary drops. + +## Build + +```powershell +$env:JAVA_HOME = "C:\Path\To\jdk-21" +.\gradlew.bat linkReleaseExecutableMingwX64 +``` + +Output: `build\bin\mingwX64\releaseExecutable\leap-chat-cli.exe` plus the +three `.dll` files alongside it. + +## Run + +```powershell +.\build\bin\mingwX64\releaseExecutable\leap-chat-cli.exe +``` + +The first run streams a `Downloading: NN% (M / N MB)` line until the model +lands on disk, then drops you into the REPL. Type a message + Enter to send. +EOF (Ctrl-Z + Enter) or `:quit` exits. Subsequent launches reuse the cached +model. + +To use a different model or quantization, change the `MODEL_NAME` and +`QUANTIZATION_SLUG` constants at the top of `Main.kt` and rebuild. + +## How it works + +Same `LeapDownloader().loadModel(...)` + `Conversation.generateResponse(...)` +flow as the [JVM CLI](../../JVM/LeapChatCli/) and the [Web demo](../../Web/LeapVoiceAssistantDemo/) — a single Kotlin source file +(`src/mingwX64Main/kotlin/ai/liquid/leap/cli/Main.kt`) drives the REPL. The +Kotlin Multiplatform machinery picks the published `leap-sdk-mingwx64` +variant (cinterop bindings to the bare C inference engine API) instead of +the JNI-backed JVM artifact. diff --git a/Windows/LeapChatCli/build.gradle.kts b/Windows/LeapChatCli/build.gradle.kts new file mode 100644 index 0000000..c815e63 --- /dev/null +++ b/Windows/LeapChatCli/build.gradle.kts @@ -0,0 +1,70 @@ +plugins { + alias(libs.plugins.kotlin.multiplatform) + // Auto-wires leap-sdk-mingwx64::natives@zip and extracts inference_engine.dll + + // libinference_engine_llamacpp_backend.dll + ie_zip.dll alongside the produced .exe so + // standard Windows DLL co-location loads them at runtime. + alias(libs.plugins.leap.sdk.native.libs) +} + +kotlin { + mingwX64 { + binaries { + executable { + entryPoint = "ai.liquid.leap.cli.main" + baseName = "leap-chat-cli" + // Both ktor-client-curl-mingwx64 (statically links pthread-win32) and + // libinference_engine.dll (also statically links pthread-win32) pull in + // duplicate definitions of pthread_*, sched_yield, etc. lld errors out + // with `duplicate symbol` by default. -Wl,--allow-multiple-definition + // tells lld (via the clang driver Kotlin/Native invokes on mingwX64) to + // take the first definition and ignore subsequent — safe because both + // sides are using the same pthread-win32 ABI. + // + // Note: must be the gcc-driver passthrough form (`-Wl,...`) — unlike + // linuxX64 (which invokes ld.lld directly and wants bare `--xxx`), + // mingwX64 invokes clang++ which only accepts driver-level flags. + // + // -Wl,-Bstatic,-lstdc++,-lwinpthread,-Bdynamic: + // statically link the MinGW C++ + winpthread runtimes into the .exe so + // it does NOT depend on libstdc++-6.dll / libwinpthread-1.dll being on + // the user's PATH. The .exe still depends on libgcc_s_seh-1.dll (no + // static counterpart ships in Konan's MinGW toolchain — `-lgcc_s` + // resolves to libgcc_s.dll.a, the import library); for now, that DLL + // is expected to be alongside the .exe (or on PATH) at run time. A + // proper redistributable bundle of MinGW runtime DLLs is deferred. + // + // The explicit `-Bstatic -l` form is required (as opposed to + // the clang-driver `-static-libstdc++` flag): Kotlin/Native's + // mingwX64 link injects `-lstdc++` etc. into the link line directly + // rather than letting the clang++ driver pick the C++ runtime, so the + // driver-level flags have nothing to substitute. The explicit form + // forces those specific `-l` lookups to resolve to the static `.a` + // archive instead of the import library for the DLL. -Bdynamic at + // the end switches back so the rest of the .exe (kernel32, user32, + // ws2_32, the inference_engine DLLs) still imports dynamically. + linkerOpts( + "-Wl,--allow-multiple-definition", + "-Wl,-Bstatic,-lstdc++,-lwinpthread,-Bdynamic", + ) + } + } + } + + sourceSets { + val mingwX64Main by getting { + dependencies { + implementation(libs.leap.sdk) + implementation(libs.kotlinx.coroutines.core) + // Ktor Curl engine for TLS on Kotlin/Native (the leap-sdk's bundled CIO + // engine doesn't support TLS on Native; injecting HttpClient(Curl) into + // LeapDownloader fixes HTTPS calls to leap.liquid.ai). Windows variant + // statically links libcurl + openssl, so no system-side install needed. + // ContentNegotiation + kotlinx-json plugins match what leap-sdk's own + // default client installs — required for body() deserialization. + implementation(libs.ktor.client.curl) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.serialization.kotlinx.json) + } + } + } +} diff --git a/Windows/LeapChatCli/gradle.properties b/Windows/LeapChatCli/gradle.properties new file mode 100644 index 0000000..ec49d2b --- /dev/null +++ b/Windows/LeapChatCli/gradle.properties @@ -0,0 +1,5 @@ +org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 +org.gradle.parallel=true +org.gradle.caching=true + +kotlin.code.style=official diff --git a/Windows/LeapChatCli/gradle/libs.versions.toml b/Windows/LeapChatCli/gradle/libs.versions.toml new file mode 100644 index 0000000..4c519ef --- /dev/null +++ b/Windows/LeapChatCli/gradle/libs.versions.toml @@ -0,0 +1,26 @@ +[versions] +kotlin = "2.3.20" +coroutines = "1.10.2" +leapSdk = "0.10.6" +ktor = "3.3.3" + +[libraries] +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } +leap-sdk = { module = "ai.liquid.leap:leap-sdk", version.ref = "leapSdk" } +# Curl engine for Ktor on mingwX64 — the leap-sdk-mingwx64 artifact pulls in +# ktor-client-cio which has no TLS support on Kotlin/Native, so HTTPS calls +# (e.g. LeapDownloader hitting leap.liquid.ai) fail with "TLS sessions are not +# supported on Native platform." Adding the Curl engine and passing +# `HttpClient(Curl)` to LeapDownloader gives us TLS via libcurl. The Windows +# Curl engine artifact statically links libcurl + openssl, so no additional +# system libraries are needed at build/run time. Note: requires +# `--allow-multiple-definition` linker flag in build.gradle.kts because both +# ktor-client-curl-mingwx64 and libinference_engine.dll statically link +# pthread-win32, producing duplicate-symbol errors at link time otherwise. +ktor-client-curl = { module = "io.ktor:ktor-client-curl", version.ref = "ktor" } +ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } +ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } + +[plugins] +kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +leap-sdk-native-libs = { id = "ai.liquid.leap.nativelibs", version.ref = "leapSdk" } diff --git a/Windows/LeapChatCli/gradle/wrapper/gradle-wrapper.jar b/Windows/LeapChatCli/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..1b33c55baabb587c669f562ae36f953de2481846 GIT binary patch literal 43764 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 '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/Windows/LeapChatCli/gradlew.bat b/Windows/LeapChatCli/gradlew.bat new file mode 100644 index 0000000..5eed7ee --- /dev/null +++ b/Windows/LeapChatCli/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/Windows/LeapChatCli/settings.gradle.kts b/Windows/LeapChatCli/settings.gradle.kts new file mode 100644 index 0000000..4ee2a06 --- /dev/null +++ b/Windows/LeapChatCli/settings.gradle.kts @@ -0,0 +1,17 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + mavenLocal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS) + repositories { + mavenLocal() + mavenCentral() + } +} + +rootProject.name = "LeapChatCli" diff --git a/Windows/LeapChatCli/src/mingwX64Main/kotlin/ai/liquid/leap/cli/Main.kt b/Windows/LeapChatCli/src/mingwX64Main/kotlin/ai/liquid/leap/cli/Main.kt new file mode 100644 index 0000000..da93114 --- /dev/null +++ b/Windows/LeapChatCli/src/mingwX64Main/kotlin/ai/liquid/leap/cli/Main.kt @@ -0,0 +1,122 @@ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) + +package ai.liquid.leap.cli + +import ai.liquid.leap.ModelLoadingOptions +import ai.liquid.leap.manifest.LeapDownloader +import ai.liquid.leap.message.MessageResponse +import io.ktor.client.HttpClient +import io.ktor.client.engine.curl.Curl +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.serialization.kotlinx.json.json +import kotlin.system.exitProcess +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json +import platform.posix.fflush + +// Kotlin/Native print()/println() go through stdio which is line-buffered on a TTY and fully +// buffered otherwise — flushing keeps the REPL truly streaming. fflush(NULL) flushes all open +// output streams per POSIX, avoiding a per-target stdout symbol lookup. +private fun flushStdout() { + fflush(null) +} + +/** Defaults match the Android LeapChat demo. ~250 MB on first run; cached afterwards. */ +private const val MODEL_NAME = "LFM2.5-350M" +private const val QUANTIZATION_SLUG = "Q4_0" +private const val SYSTEM_PROMPT = "You are a helpful assistant. Be concise." + +// KV state cache directory — sibling of the leap_models/ bundle cache. The bounded-LRU cache +// persists per-conversation prefix snapshots so warm reloads (same system prompt + history) skip +// re-prefilling the chat template; meaningful speedup once the demo has been used a few times. +private const val CACHE_DIR = "./leap_models/cache" + +fun main(args: Array): Unit = runBlocking { + if (args.isNotEmpty() && args[0] in setOf("-h", "--help")) { + println( + """ + |usage: leap-chat-cli + | + |Downloads $MODEL_NAME ($QUANTIZATION_SLUG) on first run and caches it under + |./leap_models/. Type messages and press Enter to chat. EOF (Ctrl-Z) or + |:quit exits. + """ + .trimMargin() + ) + exitProcess(0) + } + + // Inject an HttpClient backed by the Curl engine so HTTPS works. The leap-sdk + // for mingwX64 bundles Ktor CIO which has no TLS support on Native; without this + // injection, LeapDownloader's call to leap.liquid.ai fails with "TLS sessions + // are not supported on Native platform." + // + // ContentNegotiation + json are required because LeapDownloader's body() + // expects the client to deserialize the JSON response — same plugins the SDK's + // own default client installs. Without them: NoTransformationFoundException. + val http = HttpClient(Curl) { + install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) } + } + http.use { chatLoop(it) } +} + +private suspend fun chatLoop(http: HttpClient) { + val downloader = LeapDownloader(httpClient = http) + print("Loading $MODEL_NAME ($QUANTIZATION_SLUG) … ") + flushStdout() + val runner = + try { + downloader.loadModel( + modelName = MODEL_NAME, + quantizationType = QUANTIZATION_SLUG, + options = + ModelLoadingOptions(cacheOptions = ModelLoadingOptions.cacheOptions(path = CACHE_DIR)), + progress = { pd -> + if (pd.total > 0) { + val pct = (pd.bytes * 100 / pd.total).toInt() + val mbDone = pd.bytes / 1_000_000 + val mbTotal = pd.total / 1_000_000 + print("\rDownloading: $pct% ($mbDone / $mbTotal MB)") + flushStdout() + } + }, + ) + } catch (e: Exception) { + println("\nfailed to load model: ${e.message}") + exitProcess(1) + } + println("\nready (model id: ${runner.modelId})") + println("Type a message and press Enter. EOF (Ctrl-Z) or :quit to exit.") + println() + + try { + val conversation = runner.createConversation(systemPrompt = SYSTEM_PROMPT) + while (true) { + print("> ") + flushStdout() + val line = readlnOrNull() ?: break + val trimmed = line.trim() + if (trimmed == ":quit") break + if (trimmed.isEmpty()) continue + + conversation.generateResponse(line).collect { response -> + when (response) { + is MessageResponse.Chunk -> { + print(response.text) + flushStdout() + } + is MessageResponse.Complete -> { + println() + response.stats?.let { stats -> + println("[${stats.completionTokens} tok, ${stats.tokenPerSecond} tok/s]") + } + } + else -> Unit // ReasoningChunk / FunctionCalls / AudioSample — not used by this CLI + } + } + } + } finally { + runner.unload() + } +} diff --git a/scripts/refresh-spm-if-needed.sh b/scripts/refresh-spm-if-needed.sh new file mode 100755 index 0000000..4b22d45 --- /dev/null +++ b/scripts/refresh-spm-if-needed.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Detect and recover from a force-pushed SPM tag for the LeapSDK pin. +# +# When upstream re-publishes a SNAPSHOT tag (e.g. v0.10.4-SNAPSHOT) under a +# new commit, SPM still has the OLD commit SHA recorded in Package.resolved +# and refuses to resolve with "does not match previously recorded value". +# The fix per ~/.claude/CLAUDE.md is a full SPM cache clear; this script +# automates the detection + clear so you can `xcodegen generate` and resolve +# without thinking about it. +# +# Compares: upstream tag SHA (`git ls-remote`) vs each Package.resolved's +# `pins[].state.revision` for `leap-sdk`. If any local revision differs from +# the current upstream, purge SPM caches + DerivedData + all Package.resolved +# files under iOS/ + macOS/, so the next xcodebuild resolve fetches fresh. +# +# Idempotent / safe to run repeatedly; exits 0 when there's nothing to do. +# +# Usage: ./scripts/refresh-spm-if-needed.sh + +set -euo pipefail + +cd "$(dirname "$0")/.." + +# All Apple demos pin the same exactVersion in their project.yml — pick the +# first one we find as the source of truth. (Avoids hardcoding the version +# in this script: bumping any demo's project.yml is enough to update what +# this script checks.) +PROJECT_YML=$(ls iOS/*/project.yml macOS/*/project.yml 2>/dev/null | head -1 || true) +if [ -z "$PROJECT_YML" ]; then + echo "no Apple demos with project.yml found — nothing to check" + exit 0 +fi + +SDK_VERSION=$(grep -E '^[[:space:]]+exactVersion:' "$PROJECT_YML" | head -1 \ + | sed -E 's/.*exactVersion:[[:space:]]*([^[:space:]]+).*/\1/') +if [ -z "$SDK_VERSION" ]; then + echo "could not parse exactVersion from $PROJECT_YML — skipping" + exit 0 +fi +TAG="v$SDK_VERSION" + +# Upstream tag SHA right now +REMOTE_SHA=$(git ls-remote https://github.com/Liquid4All/leap-sdk.git "refs/tags/$TAG" 2>/dev/null \ + | awk '{print $1}') +if [ -z "$REMOTE_SHA" ]; then + echo "tag $TAG not found at github.com/Liquid4All/leap-sdk — skipping" + exit 0 +fi + +# All Package.resolved files in the worktree +RESOLVED_FILES=$(find iOS macOS -name 'Package.resolved' 2>/dev/null || true) +if [ -z "$RESOLVED_FILES" ]; then + echo "no Package.resolved files found locally — nothing cached, nothing to do" + echo "(run xcodegen generate + open the .xcodeproj to populate, or xcodebuild -resolvePackageDependencies)" + exit 0 +fi + +# Walk every Package.resolved; collect any with a stale leap-sdk revision. +# Package.resolved v3 stores per-pin state at .pins[].state.revision; v2 used +# .object.pins[].state.revision. Try both shapes via simple grep so we don't +# need jq. +STALE=() +for f in $RESOLVED_FILES; do + LOCAL_SHA=$(awk ' + /"identity"[[:space:]]*:[[:space:]]*"leap-sdk"/ { in_block=1; next } + in_block && /"revision"[[:space:]]*:/ { + gsub(/[",]/, ""); print $NF; exit + } + ' "$f") + if [ -n "$LOCAL_SHA" ] && [ "$LOCAL_SHA" != "$REMOTE_SHA" ]; then + STALE+=("$f $LOCAL_SHA") + fi +done + +if [ ${#STALE[@]} -eq 0 ]; then + echo "SPM cache is fresh — $TAG resolves to ${REMOTE_SHA:0:12} on both upstream and every local Package.resolved" + exit 0 +fi + +echo "$TAG was force-pushed (upstream is now at ${REMOTE_SHA:0:12}); the following Package.resolved files reference a stale revision:" +for entry in "${STALE[@]}"; do + set -- $entry + echo " $1 -> ${2:0:12}" +done +echo +echo "Clearing SPM caches and stale Package.resolved files…" +rm -rf "$HOME/Library/Caches/org.swift.swiftpm" +rm -rf "$HOME/Library/org.swift.swiftpm" +rm -rf "$HOME/Library/Developer/Xcode/DerivedData" +find iOS macOS -name 'Package.resolved' -delete 2>/dev/null || true + +echo "Done. Re-run \`xcodegen generate\` in each demo dir and open the .xcodeproj (or run \`xcodebuild -resolvePackageDependencies -project .xcodeproj\`) to fetch the new tag." From 70a3afc92b9c2d9ab43b7ef2f7f61d316b7a6b67 Mon Sep 17 00:00:00 2001 From: David Berrios Date: Tue, 12 May 2026 16:51:26 -0700 Subject: [PATCH 2/6] ci: add concurrency guard + snapshot purge to android workflow Bring `android-leap-chat-test.yml` up to parity with the desktop CLI workflows: - Concurrency group keyed by workflow + ref. - Snapshot-purge step that compares Sonatype's `` against a marker in `~/.gradle/caches/leap-snapshot-marker` and only wipes the cache when a SNAPSHOT pin was force-republished. Whitelists the parsed `SDK_VERSION` shape before interpolating into the `curl` URL. No-op now that we're on stable. - `--refresh-dependencies` on the `gradle` invocations so Gradle's metadata TTL doesn't mask a republished snapshot. --- .github/workflows/android-leap-chat-test.yml | 50 +++++++++++++++++++- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/.github/workflows/android-leap-chat-test.yml b/.github/workflows/android-leap-chat-test.yml index 6d709f7..4846e6b 100644 --- a/.github/workflows/android-leap-chat-test.yml +++ b/.github/workflows/android-leap-chat-test.yml @@ -12,6 +12,10 @@ on: - '.github/workflows/android-leap-chat-test.yml' workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + # Least-privilege default. The job authenticates with gcloud against Firebase # Test Lab via a service-account JSON; nothing here needs GITHUB_TOKEN write # scope. The Actions cache (used by gradle/actions/setup-gradle below) is gated @@ -33,10 +37,52 @@ jobs: distribution: 'temurin' - name: Set up Gradle uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + - name: Purge cached leap-sdk artifacts if Sonatype snapshot was republished + # The pin may be a SNAPSHOT in which case the upstream may force-update it under + # the same SNAPSHOT version, with new contents. When that happens, + # `--refresh-dependencies` alone isn't enough: the gradle cache restored by + # setup-java's cache: 'gradle' still contains the prior artifact, and gradle + # short-circuits the re-download because the cached SHA matches its metadata. + # + # Detect the situation by comparing Sonatype's for the snapshot + # against a marker we wrote on the previous run. Purge only when they differ — + # so the cache stays warm in the common case (no upstream republish) and + # rebuilds get the new artifact in the rare case (snapshot updated). + run: | + SDK_VERSION=$(grep -E '^\s*leapSdk\s*=' Android/LeapChat/gradle/libs.versions.toml | sed -E 's/.*"([^"]+)".*/\1/') + # Whitelist before interpolating into the curl URL below. + if ! echo "$SDK_VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)*(-[A-Za-z0-9.]+)?(-SNAPSHOT)?$'; then + echo "(unexpected leapSdk version shape '$SDK_VERSION' — skipping purge check)" + exit 0 + fi + case "$SDK_VERSION" in + *-SNAPSHOT) ;; + *) echo "leap-sdk pinned to stable $SDK_VERSION; skipping snapshot purge check"; exit 0 ;; + esac + # Check the parent `leap-sdk` artifact's — when the SDK is + # republished, the parent and every per-target sibling get re-uploaded + # together with the same timestamp, so one URL is representative for any + # workflow. Marker lives under ~/.gradle/caches so it's preserved by + # setup-java's gradle cache between runs. + REMOTE_TS=$(curl -fsSL "https://central.sonatype.com/repository/maven-snapshots/ai/liquid/leap/leap-sdk/$SDK_VERSION/maven-metadata.xml" 2>/dev/null \ + | grep -oE '[0-9]+' | grep -oE '[0-9]+' || true) + MARKER=~/.gradle/caches/leap-snapshot-marker + LOCAL_TS=$(cat "$MARKER" 2>/dev/null || true) + if [ -z "$REMOTE_TS" ]; then + echo "(could not fetch snapshot metadata for $SDK_VERSION from Sonatype — skipping purge check)" + elif [ "$REMOTE_TS" = "$LOCAL_TS" ]; then + echo "Sonatype snapshot timestamp unchanged for $SDK_VERSION ($LOCAL_TS); leap-sdk cache is fresh" + else + echo "Sonatype snapshot timestamp changed for $SDK_VERSION (${LOCAL_TS:-} -> $REMOTE_TS); purging leap-sdk cache" + rm -rf ~/.gradle/caches/modules-2/files-2.1/ai.liquid.leap* \ + ~/.gradle/caches/modules-2/metadata-*/descriptors/ai.liquid.leap* + mkdir -p "$(dirname "$MARKER")" + echo "$REMOTE_TS" > "$MARKER" + fi - name: Build LeapChat - run: cd Android/LeapChat && ./gradlew :app:assemble + run: cd Android/LeapChat && ./gradlew --refresh-dependencies :app:assemble - name: Build E2E test - run: cd Android/LeapChat && ./gradlew :app:assembleAndroidTest + run: cd Android/LeapChat && ./gradlew --refresh-dependencies :app:assembleAndroidTest - name: Run E2E test on Firebase Test Lab env: SERVICE_ACCOUNT: ${{ secrets.FIREBASE_SERVICE_ACCOUNT }} From b7492ab569aee21a6d976c5a0cd9022ed0a11cb0 Mon Sep 17 00:00:00 2001 From: David Berrios Date: Tue, 12 May 2026 16:51:50 -0700 Subject: [PATCH 3/6] chore(0.10.6): bump every existing demo to leap-sdk 0.10.6, swap to LFM2.5-350M, and adopt the LMD-only iOS import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tracks `ai.liquid.leap:leap-sdk:0.10.6` on Maven Central + SPM `v0.10.6` on the leap-sdk release (both live as of 2026-05-12). Android (eight demos): - bump `leapSdk = "0.10.6"` across all `gradle/libs.versions.toml` - drop the Sonatype Central Portal Snapshots maven block from every `settings.gradle.kts` — stable releases live on Maven Central proper - switch the demo model from `LFM2-350M (Q8_0)` to `LFM2.5-350M (Q4_0)` — smaller (~250 MB vs 370 MB), faster cold start, exercises 0.10.6's CPU-dispatched llamacpp backend that picks the right `libggml-cpu-` for the runner's CPU. Updates the corresponding `MainActivity` / `ViewModel` model-id strings iOS (six demos): - pin SPM `revision: v0.10.6` in every `project.yml` - switch the five demos that use the downloader path (LeapChat, LeapAudio, LeapSlogan, LeapVLM, RecipeGenerator) from `import LeapSDK` to `import LeapModelDownloader`. In 0.10.6 the LMD K/N framework re-exports every leap-sdk Kotlin type via its ObjC binding + SKIE-bundled Swift overlay, so one import covers both the download surface and the SDK types those demos use - swap each of those demos' `OTHER_LDFLAGS` / `LD_RUNPATH_SEARCH_PATHS` / `Sign nested *.dylib` post-build scripts from `LeapSDK.framework/Frameworks` to `LeapModelDownloader.framework/Frameworks` and add `-lie_zip` for the download path - `LeapVoiceAssistantDemo` stays on the `LeapUI` SPM product — `LeapUI` doesn't `export(project(":leap-sdk"))` in its K/N framework binary, so dual-importing `LeapSDK` + `LeapUi` here is unambiguous. Top-of-file comment in its `project.yml` documents the asymmetry - adopt the renamed `LeapDownloader.loadModel(modelName:, quantizationType:, …)` kwargs (was `model:` / `quantization:`) - swap demo model to `LFM2.5-350M` (Q4_0), same rationale as Android macOS / Web / desktop CLIs: - `macOS/LeapVoiceAssistantDemo/project.yml`, `macOS/LeapVLMExample/project.yml` — SPM `revision: v0.10.6` - `Web/LeapVoiceAssistantDemo/gradle/libs.versions.toml` — `leapSdk = "0.10.6"` - (JVM/Linux/Windows CLIs are introduced at 0.10.6 directly in the previous feat commit) Local-test apparatus (new): - `scripts/use-local-sdk.sh` rewrites each iOS demo's pbxproj from the remote `https://github.com/Liquid4All/leap-sdk` SPM ref to an `XCLocalSwiftPackageReference` pointing at a leap-android-sdk worktree. Run after `xcodegen generate` (which would otherwise overwrite the local ref each time). Idempotent - `iOS/LOCAL_TESTING.md` documents the workflow end-to-end (build XCFrameworks → link symlinks → run the rewrite script) for contributors iterating on unpublished SDK changes Docs: - `iOS/README.md` + `Android/RecipeGenerator/README.md` updated for the new release URL + model name --- .../leapaudiodemo/AudioDemoViewModel.kt | 11 +- .../LeapAudioDemo/gradle/libs.versions.toml | 2 +- Android/LeapAudioDemo/settings.gradle.kts | 4 - .../java/ai/liquid/leapchat/MainActivity.kt | 6 + Android/LeapChat/gradle/libs.versions.toml | 2 +- Android/LeapChat/settings.gradle.kts | 4 - .../LeapKoogAgent/gradle/libs.versions.toml | 2 +- Android/LeapKoogAgent/settings.gradle.kts | 4 - .../leap/uidemo/VoiceAssistantViewModel.kt | 10 +- .../gradle/libs.versions.toml | 2 +- .../settings.gradle.kts | 4 - Android/RecipeGenerator/README.md | 2 +- .../recipegenerator/MainActivityViewModel.kt | 11 +- .../RecipeGenerator/gradle/libs.versions.toml | 2 +- Android/RecipeGenerator/settings.gradle.kts | 4 - .../shareai/viewmodels/AIChatViewModel.kt | 6 + Android/ShareAI/gradle/libs.versions.toml | 2 +- Android/ShareAI/settings.gradle.kts | 4 - .../java/ai/liquid/sloganapp/MainActivity.kt | 11 +- Android/SloganApp/gradle/libs.versions.toml | 2 +- Android/SloganApp/settings.gradle.kts | 4 - .../java/ai/liquid/vlmtestapp/MainActivity.kt | 11 +- Android/VLMExample/gradle/libs.versions.toml | 2 +- Android/VLMExample/settings.gradle.kts | 4 - .../gradle/libs.versions.toml | 2 +- .../settings.gradle.kts | 4 - .../kotlin/ai/liquid/leap/uidemo/Main.kt | 2 +- iOS/LOCAL_TESTING.md | 72 +++++++++++ .../LeapAudioDemo/AudioDemoStore.swift | 6 +- iOS/LeapAudioDemo/project.yml | 10 +- .../LeapChatExample/ChatStore.swift | 8 +- iOS/LeapChatExample/project.yml | 10 +- .../LeapSloganExample/SloganGeneratable.swift | 2 +- .../LeapSloganExample/SloganStore.swift | 8 +- iOS/LeapSloganExample/project.yml | 10 +- .../LeapVLMExample/VLMStore.swift | 8 +- iOS/LeapVLMExample/project.yml | 10 +- .../AppleVoiceConversation.swift | 3 +- .../DemoViewModel.swift | 6 + iOS/LeapVoiceAssistantDemo/project.yml | 12 +- iOS/README.md | 28 +++-- .../RecipeGenerator/GeneratorViewModel.swift | 8 +- .../RecipeGenerator/Models/Recipe.swift | 2 +- iOS/RecipeGenerator/project.yml | 10 +- .../LeapVLMExample/VLMStore.swift | 6 + macOS/LeapVLMExample/project.yml | 2 +- .../AppleVoiceConversation.swift | 3 +- .../DemoViewModel.swift | 6 + macOS/LeapVoiceAssistantDemo/project.yml | 2 +- scripts/use-local-sdk.sh | 114 ++++++++++++++++++ 50 files changed, 372 insertions(+), 98 deletions(-) create mode 100644 iOS/LOCAL_TESTING.md create mode 100755 scripts/use-local-sdk.sh diff --git a/Android/LeapAudioDemo/app/src/main/kotlin/ai/liquid/leapaudiodemo/AudioDemoViewModel.kt b/Android/LeapAudioDemo/app/src/main/kotlin/ai/liquid/leapaudiodemo/AudioDemoViewModel.kt index b0f2e04..03f8138 100644 --- a/Android/LeapAudioDemo/app/src/main/kotlin/ai/liquid/leapaudiodemo/AudioDemoViewModel.kt +++ b/Android/LeapAudioDemo/app/src/main/kotlin/ai/liquid/leapaudiodemo/AudioDemoViewModel.kt @@ -1,6 +1,7 @@ package ai.liquid.leapaudiodemo import ai.liquid.leap.Conversation +import ai.liquid.leap.ModelLoadingOptions import ai.liquid.leap.ModelRunner import ai.liquid.leap.downloader.LeapModelDownloader import ai.liquid.leap.downloader.LeapModelDownloaderNotificationConfig @@ -310,7 +311,15 @@ constructor( // Load the model _state.update { it.copy(status = getString(R.string.status_loading_model)) } modelRunner = - downloaderInstance.loadModel(modelName = MODEL_NAME, quantizationType = QUANTIZATION) + downloaderInstance.loadModel( + modelName = MODEL_NAME, + quantizationType = QUANTIZATION, + options = ModelLoadingOptions( + cacheOptions = ModelLoadingOptions.cacheOptions( + path = getApplication().cacheDir.resolve("leap-cache").absolutePath, + ), + ), + ) // Create initial conversation conversation = modelRunner!!.createConversation(getString(R.string.system_prompt_audio)) diff --git a/Android/LeapAudioDemo/gradle/libs.versions.toml b/Android/LeapAudioDemo/gradle/libs.versions.toml index 3260076..7db4e54 100644 --- a/Android/LeapAudioDemo/gradle/libs.versions.toml +++ b/Android/LeapAudioDemo/gradle/libs.versions.toml @@ -6,7 +6,7 @@ junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" androidxTestCore = "1.6.1" -leapSdk = "0.10.0" +leapSdk = "0.10.6" lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.12.3" composeBom = "2026.01.01" diff --git a/Android/LeapAudioDemo/settings.gradle.kts b/Android/LeapAudioDemo/settings.gradle.kts index 40634b3..9cc213a 100644 --- a/Android/LeapAudioDemo/settings.gradle.kts +++ b/Android/LeapAudioDemo/settings.gradle.kts @@ -17,10 +17,6 @@ dependencyResolutionManagement { mavenLocal() google() mavenCentral() - maven { - name = "Central Portal Snapshots" - url = uri("https://central.sonatype.com/repository/maven-snapshots/") - } } } diff --git a/Android/LeapChat/app/src/main/java/ai/liquid/leapchat/MainActivity.kt b/Android/LeapChat/app/src/main/java/ai/liquid/leapchat/MainActivity.kt index e6ed491..75d29c5 100644 --- a/Android/LeapChat/app/src/main/java/ai/liquid/leapchat/MainActivity.kt +++ b/Android/LeapChat/app/src/main/java/ai/liquid/leapchat/MainActivity.kt @@ -3,6 +3,7 @@ package ai.liquid.leapchat import ai.liquid.leap.Conversation import ai.liquid.leap.LeapClient import ai.liquid.leap.LeapModelLoadingException +import ai.liquid.leap.ModelLoadingOptions import ai.liquid.leap.ModelRunner import ai.liquid.leap.message.ChatMessage import ai.liquid.leap.downloader.LeapModelDownloader @@ -279,6 +280,11 @@ class MainActivity : ComponentActivity() { modelRunner.value = downloaderInstance.loadModel( modelName = MODEL_NAME, quantizationType = QUANTIZATION_SLUG, + options = ModelLoadingOptions( + cacheOptions = ModelLoadingOptions.cacheOptions( + path = cacheDir.resolve("leap-cache").absolutePath, + ), + ), ) } catch (e: LeapModelLoadingException) { onError(e) diff --git a/Android/LeapChat/gradle/libs.versions.toml b/Android/LeapChat/gradle/libs.versions.toml index 28b25c2..8784d98 100644 --- a/Android/LeapChat/gradle/libs.versions.toml +++ b/Android/LeapChat/gradle/libs.versions.toml @@ -5,7 +5,7 @@ coreKtx = "1.17.0" junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" -leapSdk = "0.10.0" +leapSdk = "0.10.6" lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.12.3" composeBom = "2026.01.01" diff --git a/Android/LeapChat/settings.gradle.kts b/Android/LeapChat/settings.gradle.kts index 5312961..057e38b 100644 --- a/Android/LeapChat/settings.gradle.kts +++ b/Android/LeapChat/settings.gradle.kts @@ -17,10 +17,6 @@ dependencyResolutionManagement { mavenLocal() google() mavenCentral() - maven { - name = "Central Portal Snapshots" - url = uri("https://central.sonatype.com/repository/maven-snapshots/") - } } } diff --git a/Android/LeapKoogAgent/gradle/libs.versions.toml b/Android/LeapKoogAgent/gradle/libs.versions.toml index a5c60b0..23ae28d 100644 --- a/Android/LeapKoogAgent/gradle/libs.versions.toml +++ b/Android/LeapKoogAgent/gradle/libs.versions.toml @@ -10,7 +10,7 @@ activityCompose = "1.12.3" composeBom = "2026.01.01" koog = "0.5.1" -leapSdk = "0.10.0" +leapSdk = "0.10.6" kotlinxSerialization = "1.10.0" diff --git a/Android/LeapKoogAgent/settings.gradle.kts b/Android/LeapKoogAgent/settings.gradle.kts index 85c1dc8..aab1763 100644 --- a/Android/LeapKoogAgent/settings.gradle.kts +++ b/Android/LeapKoogAgent/settings.gradle.kts @@ -17,10 +17,6 @@ dependencyResolutionManagement { mavenLocal() google() mavenCentral() - maven { - name = "Central Portal Snapshots" - url = uri("https://central.sonatype.com/repository/maven-snapshots/") - } } } diff --git a/Android/LeapVoiceAssistantDemo/app/src/main/kotlin/ai/liquid/leap/uidemo/VoiceAssistantViewModel.kt b/Android/LeapVoiceAssistantDemo/app/src/main/kotlin/ai/liquid/leap/uidemo/VoiceAssistantViewModel.kt index fcf6c2f..f2f782f 100644 --- a/Android/LeapVoiceAssistantDemo/app/src/main/kotlin/ai/liquid/leap/uidemo/VoiceAssistantViewModel.kt +++ b/Android/LeapVoiceAssistantDemo/app/src/main/kotlin/ai/liquid/leap/uidemo/VoiceAssistantViewModel.kt @@ -1,5 +1,6 @@ package ai.liquid.leap.uidemo +import ai.liquid.leap.ModelLoadingOptions import ai.liquid.leap.manifest.LeapDownloader import ai.liquid.leap.manifest.LeapDownloaderConfig import ai.liquid.leap.ui.VoiceAssistantIntent @@ -41,7 +42,14 @@ class VoiceAssistantViewModel(application: Application) : AndroidViewModel(appli val runner = downloader.loadModel( modelName = MODEL_NAME, - quantizationSlug = QUANTIZATION_SLUG, + quantizationType = QUANTIZATION_SLUG, + options = + ModelLoadingOptions( + cacheOptions = + ModelLoadingOptions.cacheOptions( + path = getApplication().cacheDir.resolve("leap-cache").absolutePath, + ) + ), progress = { pd -> val pct = if (pd.total > 0) " (${(pd.bytes * 100 / pd.total).toInt()}%)" else "" store.setModelProgress( diff --git a/Android/LeapVoiceAssistantDemo/gradle/libs.versions.toml b/Android/LeapVoiceAssistantDemo/gradle/libs.versions.toml index 6c93179..132fc48 100644 --- a/Android/LeapVoiceAssistantDemo/gradle/libs.versions.toml +++ b/Android/LeapVoiceAssistantDemo/gradle/libs.versions.toml @@ -2,7 +2,7 @@ agp = "8.13.2" kotlin = "2.3.20" coreKtx = "1.17.0" -leapSdk = "0.10.0" +leapSdk = "0.10.6" lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.12.3" composeBom = "2026.01.01" diff --git a/Android/LeapVoiceAssistantDemo/settings.gradle.kts b/Android/LeapVoiceAssistantDemo/settings.gradle.kts index 8250688..b16587c 100644 --- a/Android/LeapVoiceAssistantDemo/settings.gradle.kts +++ b/Android/LeapVoiceAssistantDemo/settings.gradle.kts @@ -17,10 +17,6 @@ dependencyResolutionManagement { mavenLocal() google() mavenCentral() - maven { - name = "Central Portal Snapshots" - url = uri("https://central.sonatype.com/repository/maven-snapshots/") - } } } diff --git a/Android/RecipeGenerator/README.md b/Android/RecipeGenerator/README.md index 8668cbe..99a21f3 100644 --- a/Android/RecipeGenerator/README.md +++ b/Android/RecipeGenerator/README.md @@ -16,7 +16,7 @@ The main business logic is in [MainActivityViewModel.kt](app/src/main/java/ai/li ## Requirements -- LeapSDK 0.10.0 +- LeapSDK 0.10.6 - Android device or emulator with internet connection (for initial model download) The model will be automatically downloaded and cached on first run via `LeapDownloader`. diff --git a/Android/RecipeGenerator/app/src/main/java/ai/liquid/recipegenerator/MainActivityViewModel.kt b/Android/RecipeGenerator/app/src/main/java/ai/liquid/recipegenerator/MainActivityViewModel.kt index c80461c..0f8cf35 100644 --- a/Android/RecipeGenerator/app/src/main/java/ai/liquid/recipegenerator/MainActivityViewModel.kt +++ b/Android/RecipeGenerator/app/src/main/java/ai/liquid/recipegenerator/MainActivityViewModel.kt @@ -2,6 +2,7 @@ package ai.liquid.recipegenerator import ai.liquid.leap.LeapClient import ai.liquid.leap.LeapJson +import ai.liquid.leap.ModelLoadingOptions import ai.liquid.leap.ModelRunner import ai.liquid.leap.GenerationOptions import ai.liquid.leap.downloader.LeapModelDownloader @@ -113,7 +114,15 @@ class MainActivityViewModel: ViewModel() { // Load the model status = "Loading model..." try { - modelRunner = downloader.loadModel(modelName, quantType) + modelRunner = downloader.loadModel( + modelName = modelName, + quantizationType = quantType, + options = ModelLoadingOptions( + cacheOptions = ModelLoadingOptions.cacheOptions( + path = context.cacheDir.resolve("leap-cache").absolutePath, + ), + ), + ) status = "Model loaded" } catch (e: Exception) { Log.e("RecipeGenerator", "Error loading model", e) diff --git a/Android/RecipeGenerator/gradle/libs.versions.toml b/Android/RecipeGenerator/gradle/libs.versions.toml index 4fe3af0..3931020 100644 --- a/Android/RecipeGenerator/gradle/libs.versions.toml +++ b/Android/RecipeGenerator/gradle/libs.versions.toml @@ -9,7 +9,7 @@ espressoCore = "3.7.0" lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.12.3" composeBom = "2026.01.01" -leapSdk = "0.10.0" +leapSdk = "0.10.6" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } diff --git a/Android/RecipeGenerator/settings.gradle.kts b/Android/RecipeGenerator/settings.gradle.kts index 6595d0a..f72d0b0 100644 --- a/Android/RecipeGenerator/settings.gradle.kts +++ b/Android/RecipeGenerator/settings.gradle.kts @@ -17,10 +17,6 @@ dependencyResolutionManagement { mavenLocal() google() mavenCentral() - maven { - name = "Central Portal Snapshots" - url = uri("https://central.sonatype.com/repository/maven-snapshots/") - } } } diff --git a/Android/ShareAI/app/src/main/java/com/leap/shareai/viewmodels/AIChatViewModel.kt b/Android/ShareAI/app/src/main/java/com/leap/shareai/viewmodels/AIChatViewModel.kt index 5cfb313..ec35669 100644 --- a/Android/ShareAI/app/src/main/java/com/leap/shareai/viewmodels/AIChatViewModel.kt +++ b/Android/ShareAI/app/src/main/java/com/leap/shareai/viewmodels/AIChatViewModel.kt @@ -3,6 +3,7 @@ package com.leap.shareai.viewmodels import ai.liquid.leap.Conversation import ai.liquid.leap.LeapClient import ai.liquid.leap.LeapModelLoadingException +import ai.liquid.leap.ModelLoadingOptions import ai.liquid.leap.ModelRunner import ai.liquid.leap.downloader.LeapModelDownloader import ai.liquid.leap.downloader.LeapModelDownloaderNotificationConfig @@ -123,6 +124,11 @@ class AIChatViewModel : ViewModel() { modelRunner = downloaderInstance.loadModel( modelName = MODEL_NAME, quantizationType = QUANTIZATION_SLUG, + options = ModelLoadingOptions( + cacheOptions = ModelLoadingOptions.cacheOptions( + path = context.cacheDir.resolve("leap-cache").absolutePath, + ), + ), ) conversation = modelRunner?.createConversation() Log.d(TAG, "Model loaded and conversation created") diff --git a/Android/ShareAI/gradle/libs.versions.toml b/Android/ShareAI/gradle/libs.versions.toml index ce44bc3..c26e529 100644 --- a/Android/ShareAI/gradle/libs.versions.toml +++ b/Android/ShareAI/gradle/libs.versions.toml @@ -5,7 +5,7 @@ coreKtx = "1.17.0" junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" -leapSdk = "0.10.0" +leapSdk = "0.10.6" lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.12.3" composeBom = "2026.01.01" diff --git a/Android/ShareAI/settings.gradle.kts b/Android/ShareAI/settings.gradle.kts index 862ccf2..1d8675a 100644 --- a/Android/ShareAI/settings.gradle.kts +++ b/Android/ShareAI/settings.gradle.kts @@ -17,10 +17,6 @@ dependencyResolutionManagement { mavenLocal() google() mavenCentral() - maven { - name = "Central Portal Snapshots" - url = uri("https://central.sonatype.com/repository/maven-snapshots/") - } } } diff --git a/Android/SloganApp/app/src/main/java/ai/liquid/sloganapp/MainActivity.kt b/Android/SloganApp/app/src/main/java/ai/liquid/sloganapp/MainActivity.kt index c0954fa..22e6870 100644 --- a/Android/SloganApp/app/src/main/java/ai/liquid/sloganapp/MainActivity.kt +++ b/Android/SloganApp/app/src/main/java/ai/liquid/sloganapp/MainActivity.kt @@ -1,6 +1,7 @@ package ai.liquid.sloganapp import ai.liquid.leap.LeapClient +import ai.liquid.leap.ModelLoadingOptions import ai.liquid.leap.ModelRunner import ai.liquid.leap.downloader.LeapModelDownloader import ai.liquid.leap.downloader.LeapModelDownloaderNotificationConfig @@ -113,7 +114,15 @@ class MainActivity : ComponentActivity() { .collect() } - modelRunner = downloaderInstance.loadModel(modelName, quantType) + modelRunner = downloaderInstance.loadModel( + modelName = modelName, + quantizationType = quantType, + options = ModelLoadingOptions( + cacheOptions = ModelLoadingOptions.cacheOptions( + path = cacheDir.resolve("leap-cache").absolutePath, + ), + ), + ) modelLoaded = true modelStatus.text = resources.getText(R.string.model_status_loaded) diff --git a/Android/SloganApp/gradle/libs.versions.toml b/Android/SloganApp/gradle/libs.versions.toml index cf378a9..8bcd8e6 100644 --- a/Android/SloganApp/gradle/libs.versions.toml +++ b/Android/SloganApp/gradle/libs.versions.toml @@ -5,7 +5,7 @@ coreKtx = "1.17.0" junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" -leapSdk = "0.10.0" +leapSdk = "0.10.6" lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.12.3" composeBom = "2026.01.01" diff --git a/Android/SloganApp/settings.gradle.kts b/Android/SloganApp/settings.gradle.kts index 43130b3..ebc6472 100644 --- a/Android/SloganApp/settings.gradle.kts +++ b/Android/SloganApp/settings.gradle.kts @@ -17,10 +17,6 @@ dependencyResolutionManagement { mavenLocal() google() mavenCentral() - maven { - name = "Central Portal Snapshots" - url = uri("https://central.sonatype.com/repository/maven-snapshots/") - } } } diff --git a/Android/VLMExample/app/src/main/java/ai/liquid/vlmtestapp/MainActivity.kt b/Android/VLMExample/app/src/main/java/ai/liquid/vlmtestapp/MainActivity.kt index 1f7a116..8912d61 100644 --- a/Android/VLMExample/app/src/main/java/ai/liquid/vlmtestapp/MainActivity.kt +++ b/Android/VLMExample/app/src/main/java/ai/liquid/vlmtestapp/MainActivity.kt @@ -1,6 +1,7 @@ package ai.liquid.vlmtestapp import ai.liquid.leap.LeapClient +import ai.liquid.leap.ModelLoadingOptions import ai.liquid.leap.ModelRunner import ai.liquid.leap.downloader.LeapModelDownloader import ai.liquid.leap.downloader.LeapModelDownloaderNotificationConfig @@ -171,7 +172,15 @@ class MainActivity : ComponentActivity() { .collect() } - modelRunner = downloaderInstance.loadModel(modelName, quantType) + modelRunner = downloaderInstance.loadModel( + modelName = modelName, + quantizationType = quantType, + options = ModelLoadingOptions( + cacheOptions = ModelLoadingOptions.cacheOptions( + path = cacheDir.resolve("leap-cache").absolutePath, + ), + ), + ) } generateText.value = "Looking at the image and generating a description..." val conversation = diff --git a/Android/VLMExample/gradle/libs.versions.toml b/Android/VLMExample/gradle/libs.versions.toml index 49f5533..01cea36 100644 --- a/Android/VLMExample/gradle/libs.versions.toml +++ b/Android/VLMExample/gradle/libs.versions.toml @@ -5,7 +5,7 @@ coreKtx = "1.17.0" junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" -leapSdk = "0.10.0" +leapSdk = "0.10.6" lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.12.3" composeBom = "2026.01.01" diff --git a/Android/VLMExample/settings.gradle.kts b/Android/VLMExample/settings.gradle.kts index cc88c2b..aeb9393 100644 --- a/Android/VLMExample/settings.gradle.kts +++ b/Android/VLMExample/settings.gradle.kts @@ -17,10 +17,6 @@ dependencyResolutionManagement { mavenLocal() google() mavenCentral() - maven { - name = "Central Portal Snapshots" - url = uri("https://central.sonatype.com/repository/maven-snapshots/") - } } } diff --git a/Web/LeapVoiceAssistantDemo/gradle/libs.versions.toml b/Web/LeapVoiceAssistantDemo/gradle/libs.versions.toml index 28e21d3..d791d59 100644 --- a/Web/LeapVoiceAssistantDemo/gradle/libs.versions.toml +++ b/Web/LeapVoiceAssistantDemo/gradle/libs.versions.toml @@ -3,7 +3,7 @@ kotlin = "2.3.20" jetbrains-compose = "1.10.1" jetbrains-compose-material3 = "1.10.0-alpha05" ktfmt = "0.25.0" -leapSdk = "0.10.0" +leapSdk = "0.10.6" [libraries] jetbrains-compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "jetbrains-compose" } diff --git a/Web/LeapVoiceAssistantDemo/settings.gradle.kts b/Web/LeapVoiceAssistantDemo/settings.gradle.kts index e29b514..a11b2b7 100644 --- a/Web/LeapVoiceAssistantDemo/settings.gradle.kts +++ b/Web/LeapVoiceAssistantDemo/settings.gradle.kts @@ -14,10 +14,6 @@ dependencyResolutionManagement { repositories { mavenCentral() google() - maven { - name = "Central Portal Snapshots" - url = uri("https://central.sonatype.com/repository/maven-snapshots/") - } // Required for Kotlin/Wasm + Kotlin/JS Node.js / Yarn / Binaryen toolchain // downloads. FAIL_ON_PROJECT_REPOS blocks the Kotlin plugin from adding its diff --git a/Web/LeapVoiceAssistantDemo/src/wasmJsMain/kotlin/ai/liquid/leap/uidemo/Main.kt b/Web/LeapVoiceAssistantDemo/src/wasmJsMain/kotlin/ai/liquid/leap/uidemo/Main.kt index 63c72af..42439b3 100644 --- a/Web/LeapVoiceAssistantDemo/src/wasmJsMain/kotlin/ai/liquid/leap/uidemo/Main.kt +++ b/Web/LeapVoiceAssistantDemo/src/wasmJsMain/kotlin/ai/liquid/leap/uidemo/Main.kt @@ -93,7 +93,7 @@ fun main() { val runner = downloader.loadModel( modelName = MODEL_NAME, - quantizationSlug = QUANTIZATION_SLUG, + quantizationType = QUANTIZATION_SLUG, progress = { pd -> val pct = if (pd.total > 0) " ${(pd.bytes * 100 / pd.total).toInt()}%" else "" idleLabel = "Downloading$pct" diff --git a/iOS/LOCAL_TESTING.md b/iOS/LOCAL_TESTING.md new file mode 100644 index 0000000..1975e86 --- /dev/null +++ b/iOS/LOCAL_TESTING.md @@ -0,0 +1,72 @@ +# Local SDK testing for the iOS demos + +The `project.yml` files in this directory pin their `LeapSDK` Swift package to the published +URL `https://github.com/Liquid4All/leap-sdk`. That's what most contributors want — open a +demo's `*.xcodeproj`, build, run. Maven Central / GitHub release publishes ship as the +canonical artifact set. + +If you're iterating on **unpublished** SDK changes in a `leap-android-sdk` worktree and want +the demos to consume them locally, follow the steps below. + +## One-time setup + +In your `leap-android-sdk` worktree, build the XCFrameworks and run the embed + dual-import +post-process tasks: + +```sh +JAVA_HOME=$ZULU_21 ./gradlew \ + :leap-sdk:assembleLeapSDKReleaseXCFramework \ + :leap-sdk-model-downloader:assembleLeapModelDownloaderReleaseXCFramework \ + :leap-sdk-openai-client:assembleLeapOpenAIClientReleaseXCFramework \ + :leap-ui:assembleLeapUiReleaseXCFramework \ + :leap-sdk:embedDylibsInXCFramework \ + :leap-sdk-model-downloader:embedDylibsInXCFramework \ + :leap-sdk-model-downloader:appendDualImportGuardHeader \ + -PliquidInferenceEngineGithubRunId= +``` + +The worktree's `Package.swift` has `binaryTarget(path: "XCFrameworks/.xcframework")` +entries pointing at gitignored symlinks under `XCFrameworks/` that resolve to the build +outputs above. `swift package resolve` from inside the worktree should report +"resolved source packages" cleanly. + +## Pointing a demo at the local SDK + +For each demo you want to test: + +```sh +# 1. (Re)generate the demo's Xcode project from its project.yml. +cd iOS/ +xcodegen generate +cd ../.. + +# 2. Swap that demo's SPM reference from the remote URL to the worktree's Package.swift. +WORKTREE=/absolute/path/to/leap-android-sdk/worktrees/ \ + scripts/use-local-sdk.sh +``` + +`scripts/use-local-sdk.sh` rewrites each demo's `*.xcodeproj/project.pbxproj` (gitignored — +edits never commit) so its `XCRemoteSwiftPackageReference` becomes an +`XCLocalSwiftPackageReference` pointing at `$WORKTREE`. The original UUIDs are preserved so +existing `XCSwiftPackageProductDependency` entries continue to resolve. + +Re-run both steps any time `xcodegen generate` regenerates the pbxproj (it always falls back +to the remote URL from `project.yml`). + +## Reverting + +When the SDK release publishes and you no longer need the local pointer, just +`xcodegen generate` once and the demo goes back to the remote URL. Or delete +`.xcodeproj/` entirely and regenerate. + +## Why this is local-only + +`project.yml`'s `packages:` block holds the remote URL, not a local path, because: + +- Most contributors don't have a `leap-android-sdk` worktree. +- The local path is developer-machine-specific (`$WORKTREE` differs per machine). +- The remote URL is what shipping versions of the demos should resolve. + +The `XCLocalSwiftPackageReference` lives only in the gitignored pbxproj, never in the tracked +`project.yml` — so committing demo changes doesn't accidentally hard-code an absolute path +into the public examples repo. diff --git a/iOS/LeapAudioDemo/LeapAudioDemo/AudioDemoStore.swift b/iOS/LeapAudioDemo/LeapAudioDemo/AudioDemoStore.swift index b89296d..901f4d6 100644 --- a/iOS/LeapAudioDemo/LeapAudioDemo/AudioDemoStore.swift +++ b/iOS/LeapAudioDemo/LeapAudioDemo/AudioDemoStore.swift @@ -1,6 +1,6 @@ import AVFoundation import Foundation -import LeapSDK +import LeapModelDownloader import Observation struct AudioDemoMessage: Identifiable, Equatable { @@ -49,10 +49,14 @@ final class AudioDemoStore { status = "Downloading \(Self.modelName) model..." // Use manifest downloading for LFM2.5-Audio-1.5B (speech + text input/output) + let cachePath = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! + .appendingPathComponent("leap-cache").path + try? FileManager.default.createDirectory(atPath: cachePath, withIntermediateDirectories: true) let runner = try await Leap.shared.load( model: Self.modelName, quantization: Self.quantization, options: LiquidInferenceEngineManifestOptions( + cacheOptions: .enabled(path: cachePath), contextSize: 1024, nGpuLayers: 0 ), diff --git a/iOS/LeapAudioDemo/project.yml b/iOS/LeapAudioDemo/project.yml index 5ee452a..c2127e5 100644 --- a/iOS/LeapAudioDemo/project.yml +++ b/iOS/LeapAudioDemo/project.yml @@ -18,7 +18,7 @@ settings: packages: LeapSDK: url: https://github.com/Liquid4All/leap-sdk - exactVersion: 0.10.0 + revision: v0.10.6 targets: LeapAudioDemo: @@ -52,20 +52,20 @@ targets: SWIFT_EMIT_LOC_STRINGS: YES SWIFT_VERSION: "5.9" IPHONEOS_DEPLOYMENT_TARGET: "17.0" - OTHER_LDFLAGS: "$(inherited) -L$(BUILT_PRODUCTS_DIR)/LeapSDK.framework/Frameworks -linference_engine -linference_engine_llamacpp_backend" - LD_RUNPATH_SEARCH_PATHS: "$(inherited) @executable_path/Frameworks @executable_path/Frameworks/LeapSDK.framework/Frameworks" + OTHER_LDFLAGS: "$(inherited) -L$(BUILT_PRODUCTS_DIR)/LeapModelDownloader.framework/Frameworks -linference_engine -linference_engine_llamacpp_backend -lie_zip" + LD_RUNPATH_SEARCH_PATHS: "$(inherited) @executable_path/Frameworks @executable_path/Frameworks/LeapModelDownloader.framework/Frameworks" configs: Debug: SWIFT_ACTIVE_COMPILATION_CONDITIONS: DEBUG Release: SWIFT_COMPILATION_MODE: wholemodule postBuildScripts: - - name: Sign nested LeapSDK dylibs + - name: Sign nested LeapModelDownloader dylibs shell: /bin/sh basedOnDependencyAnalysis: false script: | set -e - NESTED="${BUILT_PRODUCTS_DIR}/${CONTENTS_FOLDER_PATH}/Frameworks/LeapSDK.framework/Frameworks" + NESTED="${BUILT_PRODUCTS_DIR}/${CONTENTS_FOLDER_PATH}/Frameworks/LeapModelDownloader.framework/Frameworks" [ -d "$NESTED" ] || exit 0 [ "$CODE_SIGNING_ALLOWED" = "YES" ] || exit 0 find "$NESTED" -name "*.dylib" | while read dylib; do diff --git a/iOS/LeapChatExample/LeapChatExample/ChatStore.swift b/iOS/LeapChatExample/LeapChatExample/ChatStore.swift index 795ad2b..eda58bc 100644 --- a/iOS/LeapChatExample/LeapChatExample/ChatStore.swift +++ b/iOS/LeapChatExample/LeapChatExample/ChatStore.swift @@ -1,4 +1,4 @@ -import LeapSDK +import LeapModelDownloader import PhotosUI import SwiftUI @@ -30,11 +30,15 @@ class ChatStore { content: "📦 Downloading LFM2.5-VL-1.6B model...", isUser: false)) + let cachePath = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! + .appendingPathComponent("leap-cache").path + try? FileManager.default.createDirectory(atPath: cachePath, withIntermediateDirectories: true) let modelRunner = try await Leap.shared.load( model: "LFM2.5-VL-1.6B", quantization: "Q4_0", options: LiquidInferenceEngineManifestOptions( - contextSize: 4096 // Reduced from default for mobile memory constraints + cacheOptions: .enabled(path: cachePath), + contextSize: 4096 ), progress: { [weak self] progress, speed in Task { @MainActor in diff --git a/iOS/LeapChatExample/project.yml b/iOS/LeapChatExample/project.yml index ec39fa2..33b8ed1 100644 --- a/iOS/LeapChatExample/project.yml +++ b/iOS/LeapChatExample/project.yml @@ -18,7 +18,7 @@ settings: packages: LeapSDK: url: https://github.com/Liquid4All/leap-sdk - exactVersion: 0.10.0 + revision: v0.10.6 targets: LeapChatExample: @@ -47,20 +47,20 @@ targets: ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon SWIFT_VERSION: "5.9" IPHONEOS_DEPLOYMENT_TARGET: "17.0" - OTHER_LDFLAGS: "$(inherited) -L$(BUILT_PRODUCTS_DIR)/LeapSDK.framework/Frameworks -linference_engine -linference_engine_llamacpp_backend" - LD_RUNPATH_SEARCH_PATHS: "$(inherited) @executable_path/Frameworks @executable_path/Frameworks/LeapSDK.framework/Frameworks" + OTHER_LDFLAGS: "$(inherited) -L$(BUILT_PRODUCTS_DIR)/LeapModelDownloader.framework/Frameworks -linference_engine -linference_engine_llamacpp_backend -lie_zip" + LD_RUNPATH_SEARCH_PATHS: "$(inherited) @executable_path/Frameworks @executable_path/Frameworks/LeapModelDownloader.framework/Frameworks" configs: Debug: SWIFT_ACTIVE_COMPILATION_CONDITIONS: DEBUG Release: SWIFT_COMPILATION_MODE: wholemodule postBuildScripts: - - name: Sign nested LeapSDK dylibs + - name: Sign nested LeapModelDownloader dylibs shell: /bin/sh basedOnDependencyAnalysis: false script: | set -e - NESTED="${BUILT_PRODUCTS_DIR}/${CONTENTS_FOLDER_PATH}/Frameworks/LeapSDK.framework/Frameworks" + NESTED="${BUILT_PRODUCTS_DIR}/${CONTENTS_FOLDER_PATH}/Frameworks/LeapModelDownloader.framework/Frameworks" [ -d "$NESTED" ] || exit 0 [ "$CODE_SIGNING_ALLOWED" = "YES" ] || exit 0 find "$NESTED" -name "*.dylib" | while read dylib; do diff --git a/iOS/LeapSloganExample/LeapSloganExample/SloganGeneratable.swift b/iOS/LeapSloganExample/LeapSloganExample/SloganGeneratable.swift index c2d64c0..c2adada 100644 --- a/iOS/LeapSloganExample/LeapSloganExample/SloganGeneratable.swift +++ b/iOS/LeapSloganExample/LeapSloganExample/SloganGeneratable.swift @@ -1,5 +1,5 @@ import Foundation -import LeapSDK +import LeapModelDownloader import LeapSDKMacros // SloganResponse structure for constrained generation using Swift macros diff --git a/iOS/LeapSloganExample/LeapSloganExample/SloganStore.swift b/iOS/LeapSloganExample/LeapSloganExample/SloganStore.swift index b89691e..6dae93e 100644 --- a/iOS/LeapSloganExample/LeapSloganExample/SloganStore.swift +++ b/iOS/LeapSloganExample/LeapSloganExample/SloganStore.swift @@ -1,4 +1,4 @@ -import LeapSDK +import LeapModelDownloader import SwiftUI // System prompt and user prompt constants @@ -31,9 +31,15 @@ class SloganStore { do { // Use manifest downloading for LFM2.5-1.2B-Instruct (best for instruction following) + let cachePath = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! + .appendingPathComponent("leap-cache").path + try? FileManager.default.createDirectory(atPath: cachePath, withIntermediateDirectories: true) modelRunner = try await Leap.shared.load( model: "LFM2.5-1.2B-Instruct", quantization: "Q4_0", + options: LiquidInferenceEngineManifestOptions( + cacheOptions: .enabled(path: cachePath) + ), progress: { [weak self] progress, speed in Task { @MainActor in if progress < 1.0 { diff --git a/iOS/LeapSloganExample/project.yml b/iOS/LeapSloganExample/project.yml index 2bec14d..509c886 100644 --- a/iOS/LeapSloganExample/project.yml +++ b/iOS/LeapSloganExample/project.yml @@ -18,7 +18,7 @@ settings: packages: LeapSDK: url: https://github.com/Liquid4All/leap-sdk - exactVersion: 0.10.0 + revision: v0.10.6 targets: LeapSloganExample: @@ -48,20 +48,20 @@ targets: DEVELOPMENT_ASSET_PATHS: "\"LeapSloganExample/Preview Content\"" ENABLE_PREVIEWS: YES IPHONEOS_DEPLOYMENT_TARGET: "17.0" - OTHER_LDFLAGS: "$(inherited) -L$(BUILT_PRODUCTS_DIR)/LeapSDK.framework/Frameworks -linference_engine -linference_engine_llamacpp_backend" - LD_RUNPATH_SEARCH_PATHS: "$(inherited) @executable_path/Frameworks @executable_path/Frameworks/LeapSDK.framework/Frameworks" + OTHER_LDFLAGS: "$(inherited) -L$(BUILT_PRODUCTS_DIR)/LeapModelDownloader.framework/Frameworks -linference_engine -linference_engine_llamacpp_backend -lie_zip" + LD_RUNPATH_SEARCH_PATHS: "$(inherited) @executable_path/Frameworks @executable_path/Frameworks/LeapModelDownloader.framework/Frameworks" configs: Debug: SWIFT_ACTIVE_COMPILATION_CONDITIONS: DEBUG Release: SWIFT_COMPILATION_MODE: wholemodule postBuildScripts: - - name: Sign nested LeapSDK dylibs + - name: Sign nested LeapModelDownloader dylibs shell: /bin/sh basedOnDependencyAnalysis: false script: | set -e - NESTED="${BUILT_PRODUCTS_DIR}/${CONTENTS_FOLDER_PATH}/Frameworks/LeapSDK.framework/Frameworks" + NESTED="${BUILT_PRODUCTS_DIR}/${CONTENTS_FOLDER_PATH}/Frameworks/LeapModelDownloader.framework/Frameworks" [ -d "$NESTED" ] || exit 0 [ "$CODE_SIGNING_ALLOWED" = "YES" ] || exit 0 find "$NESTED" -name "*.dylib" | while read dylib; do diff --git a/iOS/LeapVLMExample/LeapVLMExample/VLMStore.swift b/iOS/LeapVLMExample/LeapVLMExample/VLMStore.swift index b79a78b..badaeb4 100644 --- a/iOS/LeapVLMExample/LeapVLMExample/VLMStore.swift +++ b/iOS/LeapVLMExample/LeapVLMExample/VLMStore.swift @@ -1,4 +1,4 @@ -import LeapSDK +import LeapModelDownloader import Observation import UIKit @@ -21,9 +21,15 @@ final class VLMStore { do { status = "Downloading \(Self.modelName) model..." + let cachePath = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! + .appendingPathComponent("leap-cache").path + try? FileManager.default.createDirectory(atPath: cachePath, withIntermediateDirectories: true) let runner = try await Leap.shared.load( model: Self.modelName, quantization: Self.quantization, + options: LiquidInferenceEngineManifestOptions( + cacheOptions: .enabled(path: cachePath) + ), progress: { [weak self] progress, speed in Task { @MainActor in if progress < 1.0 { diff --git a/iOS/LeapVLMExample/project.yml b/iOS/LeapVLMExample/project.yml index 1a3469a..47da7fd 100644 --- a/iOS/LeapVLMExample/project.yml +++ b/iOS/LeapVLMExample/project.yml @@ -18,7 +18,7 @@ settings: packages: LeapSDK: url: https://github.com/Liquid4All/leap-sdk - exactVersion: 0.10.0 + revision: v0.10.6 targets: LeapVLMExample: @@ -42,20 +42,20 @@ targets: SWIFT_EMIT_LOC_STRINGS: YES SWIFT_VERSION: "5.9" IPHONEOS_DEPLOYMENT_TARGET: "17.0" - OTHER_LDFLAGS: "$(inherited) -L$(BUILT_PRODUCTS_DIR)/LeapSDK.framework/Frameworks -linference_engine -linference_engine_llamacpp_backend" - LD_RUNPATH_SEARCH_PATHS: "$(inherited) @executable_path/Frameworks @executable_path/Frameworks/LeapSDK.framework/Frameworks" + OTHER_LDFLAGS: "$(inherited) -L$(BUILT_PRODUCTS_DIR)/LeapModelDownloader.framework/Frameworks -linference_engine -linference_engine_llamacpp_backend -lie_zip" + LD_RUNPATH_SEARCH_PATHS: "$(inherited) @executable_path/Frameworks @executable_path/Frameworks/LeapModelDownloader.framework/Frameworks" configs: Debug: SWIFT_ACTIVE_COMPILATION_CONDITIONS: DEBUG Release: SWIFT_COMPILATION_MODE: wholemodule postBuildScripts: - - name: Sign nested LeapSDK dylibs + - name: Sign nested LeapModelDownloader dylibs shell: /bin/sh basedOnDependencyAnalysis: false script: | set -e - NESTED="${BUILT_PRODUCTS_DIR}/${CONTENTS_FOLDER_PATH}/Frameworks/LeapSDK.framework/Frameworks" + NESTED="${BUILT_PRODUCTS_DIR}/${CONTENTS_FOLDER_PATH}/Frameworks/LeapModelDownloader.framework/Frameworks" [ -d "$NESTED" ] || exit 0 [ "$CODE_SIGNING_ALLOWED" = "YES" ] || exit 0 find "$NESTED" -name "*.dylib" | while read dylib; do diff --git a/iOS/LeapVoiceAssistantDemo/LeapVoiceAssistantDemo/AppleVoiceConversation.swift b/iOS/LeapVoiceAssistantDemo/LeapVoiceAssistantDemo/AppleVoiceConversation.swift index 0c87b23..995f57d 100644 --- a/iOS/LeapVoiceAssistantDemo/LeapVoiceAssistantDemo/AppleVoiceConversation.swift +++ b/iOS/LeapVoiceAssistantDemo/LeapVoiceAssistantDemo/AppleVoiceConversation.swift @@ -98,7 +98,8 @@ final class AppleVoiceConversation: VoiceConversation { promptTokens: stats.promptTokens, completionTokens: stats.completionTokens, totalTokens: stats.totalTokens, - tokenPerSecond: stats.tokenPerSecond + tokenPerSecond: stats.tokenPerSecond, + cachedPromptTokens: stats.cachedPromptTokens ) } } diff --git a/iOS/LeapVoiceAssistantDemo/LeapVoiceAssistantDemo/DemoViewModel.swift b/iOS/LeapVoiceAssistantDemo/LeapVoiceAssistantDemo/DemoViewModel.swift index 6a55fb9..bd7b9e4 100644 --- a/iOS/LeapVoiceAssistantDemo/LeapVoiceAssistantDemo/DemoViewModel.swift +++ b/iOS/LeapVoiceAssistantDemo/LeapVoiceAssistantDemo/DemoViewModel.swift @@ -75,9 +75,15 @@ final class DemoViewModel: ObservableObject { private func loadModel() async { store.setModelProgress(fraction: 0, message: "Resolving manifest\u{2026}") do { + let cachePath = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! + .appendingPathComponent("leap-cache").path + try? FileManager.default.createDirectory(atPath: cachePath, withIntermediateDirectories: true) let runner = try await Leap.shared.load( model: modelName, quantization: quantizationSlug, + options: LiquidInferenceEngineManifestOptions( + cacheOptions: .enabled(path: cachePath) + ), progress: { [weak self] fraction, _ in Task { @MainActor [weak self] in self?.store.setModelProgress( diff --git a/iOS/LeapVoiceAssistantDemo/project.yml b/iOS/LeapVoiceAssistantDemo/project.yml index dcfffb8..896e64a 100644 --- a/iOS/LeapVoiceAssistantDemo/project.yml +++ b/iOS/LeapVoiceAssistantDemo/project.yml @@ -1,3 +1,13 @@ +# This demo links the LeapUI SPM library product, which transitively bundles the LeapSDK +# target. LeapUI doesn't `export(project(":leap-sdk"))` in its K/N framework binary, so the +# LeapSDK Kotlin types aren't re-emitted into LeapUi.framework — that means dual-importing +# `LeapSDK` + `LeapUi` here is unambiguous, and the demo continues to read SDK conveniences +# from `LeapSDK.framework/Frameworks/` (where the engine dylibs ship). +# +# The other five iOS demos depend on the LeapModelDownloader SPM library product (which is +# single-target and DOES re-emit leap-sdk's Kotlin types via export), so those demos point at +# `LeapModelDownloader.framework/Frameworks/` instead. See +# `leap-sdk/src/appleMain/MIGRATION.md` for the "which product do I depend on?" decision tree. name: LeapVoiceAssistantDemo options: bundleIdPrefix: ai.liquid.leap @@ -17,7 +27,7 @@ settings: packages: LeapSDK: url: https://github.com/Liquid4All/leap-sdk - exactVersion: 0.10.0 + revision: v0.10.6 targets: LeapVoiceAssistantDemo: diff --git a/iOS/README.md b/iOS/README.md index c98e308..e5d6103 100644 --- a/iOS/README.md +++ b/iOS/README.md @@ -81,7 +81,7 @@ make open # Opens in Xcode All examples use: - **Swift Package Manager** for dependency management - **XcodeGen** for project generation -- **LeapSDK v0.10.0** directly from the official [leap-sdk](https://github.com/Liquid4All/leap-sdk/releases/tag/v0.10.0) GitHub release +- **LeapSDK v0.10.6** directly from the official [leap-sdk](https://github.com/Liquid4All/leap-sdk/releases/tag/v0.10.6) GitHub release ### Quick Start @@ -121,7 +121,7 @@ ExampleApp/ ## Using LeapSDK -All examples use LeapSDK v0.10.0 with the KMP-based SDK and SKIE Swift interop. Import `LeapModelDownloader` for manifest-based model downloading: +All examples use LeapSDK v0.10.6 with the KMP-based SDK and SKIE Swift interop. Import `LeapModelDownloader` for manifest-based model downloading: ```swift import LeapSDK @@ -156,7 +156,7 @@ for try await response in stream { ## Constrained Generation (Structured Output) -LeapSDK v0.10.0 supports constrained generation using the `@Generatable` and `@Guide` macros from the `LeapSDKMacros` product: +LeapSDK v0.10.6 supports constrained generation using the `@Generatable` and `@Guide` macros from the `LeapSDKMacros` product: ```swift import LeapSDK @@ -211,7 +211,7 @@ Reference the SDK in each example's `project.yml`: packages: LeapSDK: url: https://github.com/Liquid4All/leap-sdk - exactVersion: 0.10.0 + exactVersion: 0.10.6 targets: YourApp: @@ -255,7 +255,7 @@ make setup ### Simulator build — `x86_64` architecture errors -The LeapSDK v0.10.0 xcframeworks only include `arm64` slices for simulator (no `x86_64`). When building for a generic iOS Simulator destination, add `EXCLUDED_ARCHS=x86_64`: +The LeapSDK v0.10.6 xcframeworks only include `arm64` slices for simulator (no `x86_64`). When building for a generic iOS Simulator destination, add `EXCLUDED_ARCHS=x86_64`: ```bash xcodebuild -scheme YourScheme \ @@ -265,6 +265,18 @@ xcodebuild -scheme YourScheme \ Or in Xcode: set **Excluded Architectures** → *Any iOS Simulator SDK* → `x86_64` in Build Settings. +### SPM "does not match previously recorded value" (force-pushed snapshot) + +When the SDK pin is a SNAPSHOT tag (e.g. `0.10.6`), upstream may force-push the tag to a new commit. Xcode/SPM caches the old commit's SHA in `Package.resolved` and refuses to resolve with `does not match previously recorded value`. + +Run from the repo root to detect + recover automatically (clears SPM caches + DerivedData + every `Package.resolved`, only when the local revision is actually stale): + +```bash +./scripts/refresh-spm-if-needed.sh +``` + +Then `xcodegen generate` in the demo dir and re-open the `.xcodeproj` to re-resolve. + ### Macro trust prompt When building LeapSloganExample, Xcode may prompt to trust the `LeapSDKConstrainedGenerationPlugin` macro. Click **Trust & Enable** to proceed. For command-line builds, run: @@ -296,9 +308,9 @@ Then run `xcodegen generate` to regenerate the project. ## LeapSDK Package -All examples reference the official **LeapSDK v0.10.0** release directly from GitHub: +All examples reference the official **LeapSDK v0.10.6** release directly from GitHub: - **Repository**: https://github.com/Liquid4All/leap-sdk -- **Release**: https://github.com/Liquid4All/leap-sdk/releases/tag/v0.10.0 +- **Release**: https://github.com/Liquid4All/leap-sdk/releases/tag/v0.10.6 - **Package Configuration**: Each example's `project.yml` specifies the GitHub URL and version The SDK is automatically downloaded by Swift Package Manager when you run `xcodegen generate`. @@ -315,5 +327,5 @@ When adding new examples: packages: LeapSDK: url: https://github.com/Liquid4All/leap-sdk - exactVersion: 0.10.0 + exactVersion: 0.10.6 ``` diff --git a/iOS/RecipeGenerator/RecipeGenerator/GeneratorViewModel.swift b/iOS/RecipeGenerator/RecipeGenerator/GeneratorViewModel.swift index df96800..958fe04 100644 --- a/iOS/RecipeGenerator/RecipeGenerator/GeneratorViewModel.swift +++ b/iOS/RecipeGenerator/RecipeGenerator/GeneratorViewModel.swift @@ -1,5 +1,5 @@ import Foundation -import LeapSDK +import LeapModelDownloader @MainActor class GeneratorViewModel: ObservableObject { @@ -24,9 +24,15 @@ class GeneratorViewModel: ObservableObject { statusMessage = "Downloading and loading model..." do { + let cachePath = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! + .appendingPathComponent("leap-cache").path + try? FileManager.default.createDirectory(atPath: cachePath, withIntermediateDirectories: true) modelRunner = try await Leap.shared.load( model: modelName, quantization: quantization, + options: LiquidInferenceEngineManifestOptions( + cacheOptions: .enabled(path: cachePath) + ), progress: { [weak self] progress, speed in Task { @MainActor in self?.downloadProgress = progress diff --git a/iOS/RecipeGenerator/RecipeGenerator/Models/Recipe.swift b/iOS/RecipeGenerator/RecipeGenerator/Models/Recipe.swift index 630c1f8..ca3b075 100644 --- a/iOS/RecipeGenerator/RecipeGenerator/Models/Recipe.swift +++ b/iOS/RecipeGenerator/RecipeGenerator/Models/Recipe.swift @@ -1,5 +1,5 @@ import Foundation -import LeapSDK +import LeapModelDownloader struct Recipe: Codable { var name: String diff --git a/iOS/RecipeGenerator/project.yml b/iOS/RecipeGenerator/project.yml index f8a69d5..295449d 100644 --- a/iOS/RecipeGenerator/project.yml +++ b/iOS/RecipeGenerator/project.yml @@ -4,7 +4,7 @@ options: packages: LeapSDK: url: https://github.com/Liquid4All/leap-sdk - exactVersion: 0.10.0 + revision: v0.10.6 targets: RecipeGenerator: type: application @@ -19,15 +19,15 @@ targets: product: LeapModelDownloader settings: base: - OTHER_LDFLAGS: "$(inherited) -L$(BUILT_PRODUCTS_DIR)/LeapSDK.framework/Frameworks -linference_engine -linference_engine_llamacpp_backend" - LD_RUNPATH_SEARCH_PATHS: "$(inherited) @executable_path/Frameworks @executable_path/Frameworks/LeapSDK.framework/Frameworks" + OTHER_LDFLAGS: "$(inherited) -L$(BUILT_PRODUCTS_DIR)/LeapModelDownloader.framework/Frameworks -linference_engine -linference_engine_llamacpp_backend -lie_zip" + LD_RUNPATH_SEARCH_PATHS: "$(inherited) @executable_path/Frameworks @executable_path/Frameworks/LeapModelDownloader.framework/Frameworks" postBuildScripts: - - name: Sign nested LeapSDK dylibs + - name: Sign nested LeapModelDownloader dylibs shell: /bin/sh basedOnDependencyAnalysis: false script: | set -e - NESTED="${BUILT_PRODUCTS_DIR}/${CONTENTS_FOLDER_PATH}/Frameworks/LeapSDK.framework/Frameworks" + NESTED="${BUILT_PRODUCTS_DIR}/${CONTENTS_FOLDER_PATH}/Frameworks/LeapModelDownloader.framework/Frameworks" [ -d "$NESTED" ] || exit 0 [ "$CODE_SIGNING_ALLOWED" = "YES" ] || exit 0 find "$NESTED" -name "*.dylib" | while read dylib; do diff --git a/macOS/LeapVLMExample/LeapVLMExample/VLMStore.swift b/macOS/LeapVLMExample/LeapVLMExample/VLMStore.swift index 2d5ed58..d3de647 100644 --- a/macOS/LeapVLMExample/LeapVLMExample/VLMStore.swift +++ b/macOS/LeapVLMExample/LeapVLMExample/VLMStore.swift @@ -21,9 +21,15 @@ final class VLMStore { do { status = "Downloading \(Self.modelName) model..." + let cachePath = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! + .appendingPathComponent("leap-cache").path + try? FileManager.default.createDirectory(atPath: cachePath, withIntermediateDirectories: true) let runner = try await Leap.shared.load( model: Self.modelName, quantization: Self.quantization, + options: LiquidInferenceEngineManifestOptions( + cacheOptions: .enabled(path: cachePath) + ), progress: { [weak self] progress, speed in Task { @MainActor in if progress < 1.0 { diff --git a/macOS/LeapVLMExample/project.yml b/macOS/LeapVLMExample/project.yml index 42d7086..dd16b67 100644 --- a/macOS/LeapVLMExample/project.yml +++ b/macOS/LeapVLMExample/project.yml @@ -18,7 +18,7 @@ settings: packages: LeapSDK: url: https://github.com/Liquid4All/leap-sdk - exactVersion: 0.10.0 + revision: v0.10.6 targets: LeapVLMExample: diff --git a/macOS/LeapVoiceAssistantDemo/LeapVoiceAssistantDemo/AppleVoiceConversation.swift b/macOS/LeapVoiceAssistantDemo/LeapVoiceAssistantDemo/AppleVoiceConversation.swift index 0c87b23..995f57d 100644 --- a/macOS/LeapVoiceAssistantDemo/LeapVoiceAssistantDemo/AppleVoiceConversation.swift +++ b/macOS/LeapVoiceAssistantDemo/LeapVoiceAssistantDemo/AppleVoiceConversation.swift @@ -98,7 +98,8 @@ final class AppleVoiceConversation: VoiceConversation { promptTokens: stats.promptTokens, completionTokens: stats.completionTokens, totalTokens: stats.totalTokens, - tokenPerSecond: stats.tokenPerSecond + tokenPerSecond: stats.tokenPerSecond, + cachedPromptTokens: stats.cachedPromptTokens ) } } diff --git a/macOS/LeapVoiceAssistantDemo/LeapVoiceAssistantDemo/DemoViewModel.swift b/macOS/LeapVoiceAssistantDemo/LeapVoiceAssistantDemo/DemoViewModel.swift index fda85fc..7340455 100644 --- a/macOS/LeapVoiceAssistantDemo/LeapVoiceAssistantDemo/DemoViewModel.swift +++ b/macOS/LeapVoiceAssistantDemo/LeapVoiceAssistantDemo/DemoViewModel.swift @@ -87,9 +87,15 @@ final class DemoViewModel: ObservableObject { private func loadModel() async { store.setModelProgress(fraction: 0, message: "Resolving manifest\u{2026}") do { + let cachePath = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! + .appendingPathComponent("leap-cache").path + try? FileManager.default.createDirectory(atPath: cachePath, withIntermediateDirectories: true) let runner = try await Leap.shared.load( model: modelName, quantization: quantizationSlug, + options: LiquidInferenceEngineManifestOptions( + cacheOptions: .enabled(path: cachePath) + ), progress: { [weak self] fraction, _ in Task { @MainActor [weak self] in self?.store.setModelProgress( diff --git a/macOS/LeapVoiceAssistantDemo/project.yml b/macOS/LeapVoiceAssistantDemo/project.yml index c4545b3..e5f4643 100644 --- a/macOS/LeapVoiceAssistantDemo/project.yml +++ b/macOS/LeapVoiceAssistantDemo/project.yml @@ -17,7 +17,7 @@ settings: packages: LeapSDK: url: https://github.com/Liquid4All/leap-sdk - exactVersion: 0.10.0 + revision: v0.10.6 targets: LeapVoiceAssistantDemo: diff --git a/scripts/use-local-sdk.sh b/scripts/use-local-sdk.sh new file mode 100755 index 0000000..2b862f0 --- /dev/null +++ b/scripts/use-local-sdk.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# Switches each iOS demo's SPM ref from the remote `https://github.com/Liquid4All/leap-sdk` +# package to a local Swift package on disk — typically a leap-android-sdk worktree where +# you're iterating on unpublished SDK changes. Idempotent. +# +# Run AFTER `xcodegen generate` in each demo, since xcodegen reads `project.yml` (which holds +# the remote URL) and rewrites the pbxproj each time, undoing any local edits to the SPM ref. +# +# Usage: +# WORKTREE=/Users/.../leap-android-sdk/worktrees/ \ +# scripts/use-local-sdk.sh +# +# The local package must contain a Package.swift with the same library product names the demos +# depend on (`LeapModelDownloader`, `LeapUI`, `LeapSDKMacros`, etc.). The leap-android-sdk +# worktree's Package.swift uses `binaryTarget(path: "XCFrameworks/.xcframework")` for +# this purpose — run gradle's `::assembleLeap*ReleaseXCFramework` + +# `embedDylibsInXCFramework` + `appendDualImportGuardHeader` (LMD only) first so the symlinked +# XCFrameworks exist. +# +# When the SDK release is published to GitHub, drop this script — `xcodegen generate` alone +# produces a working remote-URL pbxproj. + +set -euo pipefail + +: "${WORKTREE:?Set WORKTREE to the leap-android-sdk worktree absolute path}" + +if [ ! -f "$WORKTREE/Package.swift" ]; then + echo "❌ WORKTREE=$WORKTREE has no Package.swift — wrong path?" >&2 + exit 1 +fi + +DEMOS=( + iOS/LeapChatExample + iOS/LeapAudioDemo + iOS/LeapSloganExample + iOS/LeapVoiceAssistantDemo + iOS/RecipeGenerator + iOS/LeapVLMExample +) + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$REPO_ROOT" + +updated=0 +for demo in "${DEMOS[@]}"; do + pbxproj="${demo}/$(basename "$demo").xcodeproj/project.pbxproj" + if [ ! -f "$pbxproj" ]; then + echo "⏭ skip $demo (no pbxproj — run 'xcodegen generate' in this demo first)" + continue + fi + + # Replace any XCRemoteSwiftPackageReference block with an XCLocalSwiftPackageReference + # pointing at $WORKTREE, preserving UUIDs. The `packageReferences` array entries' comments + # also get updated so Xcode shows the right kind in the GUI. + WORKTREE_PATH="$WORKTREE" PBXPROJ="$pbxproj" python3 - <<'PY' +import os +import re +import sys + +pbxproj = os.environ["PBXPROJ"] +worktree = os.environ["WORKTREE_PATH"] +src = open(pbxproj).read() + +# Match every XCRemoteSwiftPackageReference block. +remote_pattern = re.compile( + r'(\t\t([0-9A-F]{24}) /\* XCRemoteSwiftPackageReference "([^"]+)" \*/ = \{\n' + r'\t\t\tisa = XCRemoteSwiftPackageReference;\n' + r'.*?\n' + r'\t\t\};\n)', + re.DOTALL, +) + +matches = list(remote_pattern.finditer(src)) +if not matches: + # Already local — nothing to do. + sys.exit(0) + +for m in matches: + uuid = m.group(2) + pkg_name = m.group(3) + local_block = ( + f'\t\t{uuid} /* XCLocalSwiftPackageReference "{pkg_name}" */ = {{\n' + f'\t\t\tisa = XCLocalSwiftPackageReference;\n' + f'\t\t\trelativePath = "{worktree}";\n' + f'\t\t}};\n' + ) + src = src.replace(m.group(1), local_block, 1) + +# Swap the section header to match. +src = src.replace( + "/* Begin XCRemoteSwiftPackageReference section */", + "/* Begin XCLocalSwiftPackageReference section */", +) +src = src.replace( + "/* End XCRemoteSwiftPackageReference section */", + "/* End XCLocalSwiftPackageReference section */", +) + +# Fix up the packageReferences array entries' comments so Xcode shows the right kind. +src = re.sub( + r'XCRemoteSwiftPackageReference "([^"]+)"', + r'XCLocalSwiftPackageReference "\1"', + src, +) + +open(pbxproj, "w").write(src) +PY + echo "✅ $demo → $WORKTREE" + updated=$((updated + 1)) +done + +echo +echo "Switched $updated demo(s) to local SDK at $WORKTREE" +echo "Re-run 'xcodegen generate' followed by this script any time you regenerate a pbxproj." From d62ba60087509276d8afe9ae36dfe36467da1853 Mon Sep 17 00:00:00 2001 From: David Berrios Date: Mon, 18 May 2026 13:28:50 -0700 Subject: [PATCH 4/6] chore(0.10.7): bump every demo to leap-sdk 0.10.7 0.10.7 is a Kotlin/JVM ergonomics + KMP target-completion release with zero Swift surface change vs 0.10.6 (leap-android-sdk PR #256). All Apple XCFrameworks ship the same SKIE Swift APIs as 0.10.6. Changes upstream that motivate the pin (none require demo source edits): - leap-openai-client now publishes jvm (Ktor CIO) + wasmJs (Ktor JS) slices on top of android/apple/linux/mingw. Not consumed here. - Repo-wide KMP target config centralized into root subprojects {} block. - Bytecode hardening: leap-sdk-jvm, leap-openai-client-jvm, leap-ui-jvm, and leap-ui-android now consistently emit Java 11 class-file major (0x37). Strictly more compatible than 0.10.6's silent 17/21 bytecode. Single TOML var (leapSdk) covers leap-sdk + leap-model-downloader + leap-ui modules across all Android demos. 22 files touched: - 12 gradle/libs.versions.toml (Android x8 + JVM + Linux + Windows + Web) - 8 iOS/macOS project.yml (revision pin) - iOS/README.md (9 doc refs incl. release URL) - Android/RecipeGenerator/README.md (1 doc ref) Verified locally on macOS host: compileKotlin (JVM), compileKotlinLinuxX64 (Linux K/N cross), and :app:compileReleaseKotlin (Android LeapChat) all BUILD SUCCESSFUL against leap-sdk:0.10.7. JVM end-to-end smoke prints "ready (model id: LFM2.5-350M-Q4_0)" and exits 0 on ':quit'. --- .gitignore | 4 ++++ .../LeapAudioDemo/gradle/libs.versions.toml | 2 +- Android/LeapChat/gradle/libs.versions.toml | 2 +- .../LeapKoogAgent/gradle/libs.versions.toml | 2 +- .../gradle/libs.versions.toml | 2 +- Android/RecipeGenerator/README.md | 2 +- .../RecipeGenerator/gradle/libs.versions.toml | 2 +- Android/ShareAI/gradle/libs.versions.toml | 2 +- Android/SloganApp/gradle/libs.versions.toml | 2 +- Android/VLMExample/gradle/libs.versions.toml | 2 +- JVM/LeapChatCli/gradle/libs.versions.toml | 2 +- Linux/LeapChatCli/gradle/libs.versions.toml | 2 +- .../gradle/libs.versions.toml | 2 +- Windows/LeapChatCli/gradle/libs.versions.toml | 2 +- iOS/LeapAudioDemo/project.yml | 2 +- iOS/LeapChatExample/project.yml | 2 +- iOS/LeapSloganExample/project.yml | 2 +- iOS/LeapVLMExample/project.yml | 2 +- iOS/LeapVoiceAssistantDemo/project.yml | 2 +- iOS/README.md | 18 +++++++++--------- iOS/RecipeGenerator/project.yml | 2 +- macOS/LeapVLMExample/project.yml | 2 +- macOS/LeapVoiceAssistantDemo/project.yml | 2 +- 23 files changed, 34 insertions(+), 30 deletions(-) diff --git a/.gitignore b/.gitignore index 52dca4b..3d3fd38 100644 --- a/.gitignore +++ b/.gitignore @@ -204,6 +204,10 @@ yarn-error.log* *.mlpackage *.onnx *.tflite +*.gguf + +# Local model cache created by JVM/Linux/Windows LeapChatCli demos at run time +leap_models/ #################### # Secrets diff --git a/Android/LeapAudioDemo/gradle/libs.versions.toml b/Android/LeapAudioDemo/gradle/libs.versions.toml index 7db4e54..3f0e053 100644 --- a/Android/LeapAudioDemo/gradle/libs.versions.toml +++ b/Android/LeapAudioDemo/gradle/libs.versions.toml @@ -6,7 +6,7 @@ junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" androidxTestCore = "1.6.1" -leapSdk = "0.10.6" +leapSdk = "0.10.7" lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.12.3" composeBom = "2026.01.01" diff --git a/Android/LeapChat/gradle/libs.versions.toml b/Android/LeapChat/gradle/libs.versions.toml index 8784d98..d3a7ee5 100644 --- a/Android/LeapChat/gradle/libs.versions.toml +++ b/Android/LeapChat/gradle/libs.versions.toml @@ -5,7 +5,7 @@ coreKtx = "1.17.0" junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" -leapSdk = "0.10.6" +leapSdk = "0.10.7" lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.12.3" composeBom = "2026.01.01" diff --git a/Android/LeapKoogAgent/gradle/libs.versions.toml b/Android/LeapKoogAgent/gradle/libs.versions.toml index 23ae28d..5b8073e 100644 --- a/Android/LeapKoogAgent/gradle/libs.versions.toml +++ b/Android/LeapKoogAgent/gradle/libs.versions.toml @@ -10,7 +10,7 @@ activityCompose = "1.12.3" composeBom = "2026.01.01" koog = "0.5.1" -leapSdk = "0.10.6" +leapSdk = "0.10.7" kotlinxSerialization = "1.10.0" diff --git a/Android/LeapVoiceAssistantDemo/gradle/libs.versions.toml b/Android/LeapVoiceAssistantDemo/gradle/libs.versions.toml index 132fc48..4364281 100644 --- a/Android/LeapVoiceAssistantDemo/gradle/libs.versions.toml +++ b/Android/LeapVoiceAssistantDemo/gradle/libs.versions.toml @@ -2,7 +2,7 @@ agp = "8.13.2" kotlin = "2.3.20" coreKtx = "1.17.0" -leapSdk = "0.10.6" +leapSdk = "0.10.7" lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.12.3" composeBom = "2026.01.01" diff --git a/Android/RecipeGenerator/README.md b/Android/RecipeGenerator/README.md index 99a21f3..d49e188 100644 --- a/Android/RecipeGenerator/README.md +++ b/Android/RecipeGenerator/README.md @@ -16,7 +16,7 @@ The main business logic is in [MainActivityViewModel.kt](app/src/main/java/ai/li ## Requirements -- LeapSDK 0.10.6 +- LeapSDK 0.10.7 - Android device or emulator with internet connection (for initial model download) The model will be automatically downloaded and cached on first run via `LeapDownloader`. diff --git a/Android/RecipeGenerator/gradle/libs.versions.toml b/Android/RecipeGenerator/gradle/libs.versions.toml index 3931020..15c7e91 100644 --- a/Android/RecipeGenerator/gradle/libs.versions.toml +++ b/Android/RecipeGenerator/gradle/libs.versions.toml @@ -9,7 +9,7 @@ espressoCore = "3.7.0" lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.12.3" composeBom = "2026.01.01" -leapSdk = "0.10.6" +leapSdk = "0.10.7" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } diff --git a/Android/ShareAI/gradle/libs.versions.toml b/Android/ShareAI/gradle/libs.versions.toml index c26e529..634a7bc 100644 --- a/Android/ShareAI/gradle/libs.versions.toml +++ b/Android/ShareAI/gradle/libs.versions.toml @@ -5,7 +5,7 @@ coreKtx = "1.17.0" junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" -leapSdk = "0.10.6" +leapSdk = "0.10.7" lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.12.3" composeBom = "2026.01.01" diff --git a/Android/SloganApp/gradle/libs.versions.toml b/Android/SloganApp/gradle/libs.versions.toml index 8bcd8e6..1b1a9dc 100644 --- a/Android/SloganApp/gradle/libs.versions.toml +++ b/Android/SloganApp/gradle/libs.versions.toml @@ -5,7 +5,7 @@ coreKtx = "1.17.0" junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" -leapSdk = "0.10.6" +leapSdk = "0.10.7" lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.12.3" composeBom = "2026.01.01" diff --git a/Android/VLMExample/gradle/libs.versions.toml b/Android/VLMExample/gradle/libs.versions.toml index 01cea36..4b2bf46 100644 --- a/Android/VLMExample/gradle/libs.versions.toml +++ b/Android/VLMExample/gradle/libs.versions.toml @@ -5,7 +5,7 @@ coreKtx = "1.17.0" junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" -leapSdk = "0.10.6" +leapSdk = "0.10.7" lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.12.3" composeBom = "2026.01.01" diff --git a/JVM/LeapChatCli/gradle/libs.versions.toml b/JVM/LeapChatCli/gradle/libs.versions.toml index e2e4fee..016f23d 100644 --- a/JVM/LeapChatCli/gradle/libs.versions.toml +++ b/JVM/LeapChatCli/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] kotlin = "2.3.20" coroutines = "1.10.2" -leapSdk = "0.10.6" +leapSdk = "0.10.7" [libraries] kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } diff --git a/Linux/LeapChatCli/gradle/libs.versions.toml b/Linux/LeapChatCli/gradle/libs.versions.toml index 75966e3..b36e088 100644 --- a/Linux/LeapChatCli/gradle/libs.versions.toml +++ b/Linux/LeapChatCli/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] kotlin = "2.3.20" coroutines = "1.10.2" -leapSdk = "0.10.6" +leapSdk = "0.10.7" ktor = "3.3.3" [libraries] diff --git a/Web/LeapVoiceAssistantDemo/gradle/libs.versions.toml b/Web/LeapVoiceAssistantDemo/gradle/libs.versions.toml index d791d59..813c97b 100644 --- a/Web/LeapVoiceAssistantDemo/gradle/libs.versions.toml +++ b/Web/LeapVoiceAssistantDemo/gradle/libs.versions.toml @@ -3,7 +3,7 @@ kotlin = "2.3.20" jetbrains-compose = "1.10.1" jetbrains-compose-material3 = "1.10.0-alpha05" ktfmt = "0.25.0" -leapSdk = "0.10.6" +leapSdk = "0.10.7" [libraries] jetbrains-compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "jetbrains-compose" } diff --git a/Windows/LeapChatCli/gradle/libs.versions.toml b/Windows/LeapChatCli/gradle/libs.versions.toml index 4c519ef..0a70176 100644 --- a/Windows/LeapChatCli/gradle/libs.versions.toml +++ b/Windows/LeapChatCli/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] kotlin = "2.3.20" coroutines = "1.10.2" -leapSdk = "0.10.6" +leapSdk = "0.10.7" ktor = "3.3.3" [libraries] diff --git a/iOS/LeapAudioDemo/project.yml b/iOS/LeapAudioDemo/project.yml index c2127e5..c7b2ebe 100644 --- a/iOS/LeapAudioDemo/project.yml +++ b/iOS/LeapAudioDemo/project.yml @@ -18,7 +18,7 @@ settings: packages: LeapSDK: url: https://github.com/Liquid4All/leap-sdk - revision: v0.10.6 + revision: v0.10.7 targets: LeapAudioDemo: diff --git a/iOS/LeapChatExample/project.yml b/iOS/LeapChatExample/project.yml index 33b8ed1..0a567d4 100644 --- a/iOS/LeapChatExample/project.yml +++ b/iOS/LeapChatExample/project.yml @@ -18,7 +18,7 @@ settings: packages: LeapSDK: url: https://github.com/Liquid4All/leap-sdk - revision: v0.10.6 + revision: v0.10.7 targets: LeapChatExample: diff --git a/iOS/LeapSloganExample/project.yml b/iOS/LeapSloganExample/project.yml index 509c886..279049f 100644 --- a/iOS/LeapSloganExample/project.yml +++ b/iOS/LeapSloganExample/project.yml @@ -18,7 +18,7 @@ settings: packages: LeapSDK: url: https://github.com/Liquid4All/leap-sdk - revision: v0.10.6 + revision: v0.10.7 targets: LeapSloganExample: diff --git a/iOS/LeapVLMExample/project.yml b/iOS/LeapVLMExample/project.yml index 47da7fd..2e26484 100644 --- a/iOS/LeapVLMExample/project.yml +++ b/iOS/LeapVLMExample/project.yml @@ -18,7 +18,7 @@ settings: packages: LeapSDK: url: https://github.com/Liquid4All/leap-sdk - revision: v0.10.6 + revision: v0.10.7 targets: LeapVLMExample: diff --git a/iOS/LeapVoiceAssistantDemo/project.yml b/iOS/LeapVoiceAssistantDemo/project.yml index 896e64a..d90224d 100644 --- a/iOS/LeapVoiceAssistantDemo/project.yml +++ b/iOS/LeapVoiceAssistantDemo/project.yml @@ -27,7 +27,7 @@ settings: packages: LeapSDK: url: https://github.com/Liquid4All/leap-sdk - revision: v0.10.6 + revision: v0.10.7 targets: LeapVoiceAssistantDemo: diff --git a/iOS/README.md b/iOS/README.md index e5d6103..cb71ade 100644 --- a/iOS/README.md +++ b/iOS/README.md @@ -81,7 +81,7 @@ make open # Opens in Xcode All examples use: - **Swift Package Manager** for dependency management - **XcodeGen** for project generation -- **LeapSDK v0.10.6** directly from the official [leap-sdk](https://github.com/Liquid4All/leap-sdk/releases/tag/v0.10.6) GitHub release +- **LeapSDK v0.10.7** directly from the official [leap-sdk](https://github.com/Liquid4All/leap-sdk/releases/tag/v0.10.7) GitHub release ### Quick Start @@ -121,7 +121,7 @@ ExampleApp/ ## Using LeapSDK -All examples use LeapSDK v0.10.6 with the KMP-based SDK and SKIE Swift interop. Import `LeapModelDownloader` for manifest-based model downloading: +All examples use LeapSDK v0.10.7 with the KMP-based SDK and SKIE Swift interop. Import `LeapModelDownloader` for manifest-based model downloading: ```swift import LeapSDK @@ -156,7 +156,7 @@ for try await response in stream { ## Constrained Generation (Structured Output) -LeapSDK v0.10.6 supports constrained generation using the `@Generatable` and `@Guide` macros from the `LeapSDKMacros` product: +LeapSDK v0.10.7 supports constrained generation using the `@Generatable` and `@Guide` macros from the `LeapSDKMacros` product: ```swift import LeapSDK @@ -211,7 +211,7 @@ Reference the SDK in each example's `project.yml`: packages: LeapSDK: url: https://github.com/Liquid4All/leap-sdk - exactVersion: 0.10.6 + exactVersion: 0.10.7 targets: YourApp: @@ -255,7 +255,7 @@ make setup ### Simulator build — `x86_64` architecture errors -The LeapSDK v0.10.6 xcframeworks only include `arm64` slices for simulator (no `x86_64`). When building for a generic iOS Simulator destination, add `EXCLUDED_ARCHS=x86_64`: +The LeapSDK v0.10.7 xcframeworks only include `arm64` slices for simulator (no `x86_64`). When building for a generic iOS Simulator destination, add `EXCLUDED_ARCHS=x86_64`: ```bash xcodebuild -scheme YourScheme \ @@ -267,7 +267,7 @@ Or in Xcode: set **Excluded Architectures** → *Any iOS Simulator SDK* → `x86 ### SPM "does not match previously recorded value" (force-pushed snapshot) -When the SDK pin is a SNAPSHOT tag (e.g. `0.10.6`), upstream may force-push the tag to a new commit. Xcode/SPM caches the old commit's SHA in `Package.resolved` and refuses to resolve with `does not match previously recorded value`. +When the SDK pin is a SNAPSHOT tag (e.g. `0.10.7`), upstream may force-push the tag to a new commit. Xcode/SPM caches the old commit's SHA in `Package.resolved` and refuses to resolve with `does not match previously recorded value`. Run from the repo root to detect + recover automatically (clears SPM caches + DerivedData + every `Package.resolved`, only when the local revision is actually stale): @@ -308,9 +308,9 @@ Then run `xcodegen generate` to regenerate the project. ## LeapSDK Package -All examples reference the official **LeapSDK v0.10.6** release directly from GitHub: +All examples reference the official **LeapSDK v0.10.7** release directly from GitHub: - **Repository**: https://github.com/Liquid4All/leap-sdk -- **Release**: https://github.com/Liquid4All/leap-sdk/releases/tag/v0.10.6 +- **Release**: https://github.com/Liquid4All/leap-sdk/releases/tag/v0.10.7 - **Package Configuration**: Each example's `project.yml` specifies the GitHub URL and version The SDK is automatically downloaded by Swift Package Manager when you run `xcodegen generate`. @@ -327,5 +327,5 @@ When adding new examples: packages: LeapSDK: url: https://github.com/Liquid4All/leap-sdk - exactVersion: 0.10.6 + exactVersion: 0.10.7 ``` diff --git a/iOS/RecipeGenerator/project.yml b/iOS/RecipeGenerator/project.yml index 295449d..0837403 100644 --- a/iOS/RecipeGenerator/project.yml +++ b/iOS/RecipeGenerator/project.yml @@ -4,7 +4,7 @@ options: packages: LeapSDK: url: https://github.com/Liquid4All/leap-sdk - revision: v0.10.6 + revision: v0.10.7 targets: RecipeGenerator: type: application diff --git a/macOS/LeapVLMExample/project.yml b/macOS/LeapVLMExample/project.yml index dd16b67..feeba24 100644 --- a/macOS/LeapVLMExample/project.yml +++ b/macOS/LeapVLMExample/project.yml @@ -18,7 +18,7 @@ settings: packages: LeapSDK: url: https://github.com/Liquid4All/leap-sdk - revision: v0.10.6 + revision: v0.10.7 targets: LeapVLMExample: diff --git a/macOS/LeapVoiceAssistantDemo/project.yml b/macOS/LeapVoiceAssistantDemo/project.yml index e5f4643..a49feef 100644 --- a/macOS/LeapVoiceAssistantDemo/project.yml +++ b/macOS/LeapVoiceAssistantDemo/project.yml @@ -17,7 +17,7 @@ settings: packages: LeapSDK: url: https://github.com/Liquid4All/leap-sdk - revision: v0.10.6 + revision: v0.10.7 targets: LeapVoiceAssistantDemo: From 21f7effc9bdfe8fc54e12308eb06751860d49999 Mon Sep 17 00:00:00 2001 From: David Berrios Date: Thu, 21 May 2026 11:09:58 -0700 Subject: [PATCH 5/6] chore(0.10.8): bump every demo to leap-sdk 0.10.8 + Kotlin 2.3.21; sweep critical-review fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps every demo SDK pin from 0.10.7 → 0.10.8 (12 gradle/libs.versions.toml, 8 iOS/macOS project.yml) and Kotlin 2.3.20 → 2.3.21. 0.10.8 ships the SKIE coverage fix from leap-android-sdk PR #258: SKIE applied to leap-sdk-openai-client and a new in-band MessageResponse.Error sealed case emitted before the flow closes — required because SKIE's FlowInterop bridges Flow as SkieSwiftFlow with Failure = Never, so Swift for-await otherwise loses the close-throwable silently. Critical-review fix sweep across the 119-file change set: Correctness: - Handle MessageResponse.Error in every Kotlin collector (8 Android + 3 desktop CLIs + Web). Existing-handler demos rethrow to route through .catch{}/try/ catch/runCatching{}.onFailure{}; CLIs add .catch{} that prints and continues the REPL; VLMExample/LeapChat add user-visible error UI. - Handle .error case in every Swift collector (5 iOS + 1 macOS Store + 2 AppleVoiceConversation). RecipeGenerator/SloganExample accumulate into streamError and throw NSError post-loop; cast-style sites throw NSError inline. Dead handleGenerationError(_:) removed from AudioDemoStore. - LeapKoogAgent: replaced const val modelsPath = "/tmp/models" (unwritable on Android, runtime crash) with App.context.filesDir.resolve("models"). CI: - .github/workflows/ios-demos-test.yml + macos-demos-test.yml: runs-on macos-15 → macos-26 (the if -d /Applications/Xcode_26.app fallback was silently no-op'ing on macos-15). Dropped EXCLUDED_ARCHS=x86_64; pinned iOS destination to platform=iOS Simulator,name=iPhone 16 (matches make build). - Added 4 new workflows: android-demos-test.yml, ios-demos-test.yml, macos-demos-test.yml, web-demo-test.yml — matrix-build every demo on the right runner, gated by per-path triggers, with concurrency guards. Bugs: - Float.toFixed1() in Linux/Windows/Web Main.kt truncated instead of rounding (9.95f → "9.9"). Replaced with kotlin.math.round(this * 10).toLong(). - Android LeapVoiceAssistantDemo: onCleared used viewModelScope which is cancelled by the time onCleared runs; added @Volatile modelRunner captured at load time + fresh CoroutineScope(Dispatchers.IO) unload. Manifest had POST_NOTIFICATIONS but no runtime request; added Compose runtime ask gated by SDK 33+. - iOS LeapVLMExample VLMStore: dropped self-defeating `await generationTask?.value` that serialized the function with the task it just spawned. - macOS LeapVLMExample VLMStore: added generationTask field + stop() + deinit cancel for parity with iOS. Modernization: - iOS LeapVoiceAssistantDemo DemoViewModel: ObservableObject + @Published → @Observable + @State (last iOS demo not migrated). - JVM/Linux/Windows CLIs: added com.ncorti.ktfmt.gradle plugin with googleStyle() (every other module already had it). Polish: - Standardized QUANTIZATION_TYPE constant naming across all 12 demos (was a mix of QUANTIZATION_SLUG / QUANTIZATION / local quantType). - ShareAI: moved hardcoded notification titles into strings.xml. - SloganApp / VLMExample: promoted modelName/quantType locals to companion constants for consistency with the other Android demos. - Web demo: explicitly pinned coroutines = "1.10.2" (was relying on transitive resolution). - Root README: Xcode 15+ → Xcode 16+ (Xcode 26 recommended), matches CI. - JVM CLI --help: stderr → stdout (matches Linux/Windows + convention). - iOS/RecipeGenerator/README.md: fixed stale `rawFlow as! SkieSwiftFlow<...>` snippet to match the actual code (0.10.8 returns SkieSwiftFlow directly). - 3 new iOS Makefiles (VLMExample, VoiceAssistantDemo, RecipeGenerator) + 3 new READMEs (Android LeapVoiceAssistantDemo, iOS LeapVLMExample, macOS LeapVLMExample) for parity with the rest of the suite. Verified locally: all 7 modified Android demos pass :app:compileDebugKotlin + :app:ktfmtCheckMain. All 3 desktop CLIs pass compileKotlin* + ktfmtCheck. Web passes compileKotlinWasmJs + ktfmtCheck. LeapKoogAgent's pre-existing missing koog-edge-0.0.1.aar is unchanged and still blocks its build (separate cleanup item). --- .github/workflows/android-demos-test.yml | 106 +++ .github/workflows/ios-demos-test.yml | 88 ++ .github/workflows/macos-demos-test.yml | 82 ++ .github/workflows/web-demo-test.yml | 88 ++ .../liquid/leapaudiodemo/AudioDemoScreen.kt | 157 ++-- .../leapaudiodemo/AudioDemoViewModel.kt | 184 ++-- .../ai/liquid/leapaudiodemo/AudioPlayer.kt | 41 +- .../ai/liquid/leapaudiodemo/AudioRecorder.kt | 79 +- .../LeapAudioDemo/gradle/libs.versions.toml | 4 +- Android/LeapChat/README.md | 122 ++- Android/LeapChat/app/build.gradle.kts | 109 ++- .../ai/liquid/leapchat/MainActivityTest.kt | 85 +- .../java/ai/liquid/leapchat/MainActivity.kt | 838 +++++++++--------- .../leapchat/models/ChatMessageDisplayItem.kt | 6 +- .../liquid/leapchat/views/AssistantMessage.kt | 59 +- .../ai/liquid/leapchat/views/ChatHistory.kt | 73 +- .../ai/liquid/leapchat/views/ToolMessage.kt | 40 +- .../ai/liquid/leapchat/views/UserMessage.kt | 60 +- .../app/src/main/res/values/strings.xml | 12 + Android/LeapChat/gradle/libs.versions.toml | 6 +- Android/LeapKoogAgent/README.md | 197 +++- Android/LeapKoogAgent/app/build.gradle.kts | 94 +- .../main/kotlin/ai/liquid/koogleapsdk/App.kt | 21 +- .../ai/liquid/koogleapsdk/MainActivity.kt | 20 +- .../calculator/CalculatorAgentProvider.kt | 159 ++-- .../agents/calculator/CalculatorTools.kt | 86 +- .../agents/common/AgentProvider.kt | 28 +- .../koogleapsdk/agents/common/Common.kt | 13 +- .../koogleapsdk/agents/common/ExitTool.kt | 21 +- .../agents/weather/OpenMeteoClient.kt | 193 ++-- .../agents/weather/WeatherAgentProvider.kt | 174 ++-- .../agents/weather/WeatherTools.kt | 617 ++++++------- .../koogleapsdk/ui/common/MviViewModel.kt | 7 +- .../koogleapsdk/ui/navigation/Destination.kt | 8 +- .../ui/navigation/NavigationService.kt | 53 +- .../koogleapsdk/ui/screen/MainScreen.kt | 54 +- .../calculatorTool/CalculatorToolEvent.kt | 4 +- .../calculatorTool/CalculatorToolRoute.kt | 11 +- .../calculatorTool/CalculatorToolScreen.kt | 98 +- .../calculatorTool/CalculatorToolState.kt | 8 +- .../calculatorTool/CalculatorToolViewModel.kt | 91 +- .../ui/screen/toolsList/ToolsListEvent.kt | 4 +- .../ui/screen/toolsList/ToolsListRoute.kt | 11 +- .../ui/screen/toolsList/ToolsListScreen.kt | 100 +-- .../ui/screen/toolsList/ToolsListState.kt | 20 +- .../ui/screen/toolsList/ToolsListViewModel.kt | 36 +- .../screen/toolsList/util/AllToolsProvider.kt | 26 +- .../ui/screen/weatherTool/WeatherToolEvent.kt | 4 +- .../ui/screen/weatherTool/WeatherToolRoute.kt | 11 +- .../screen/weatherTool/WeatherToolScreen.kt | 89 +- .../ui/screen/weatherTool/WeatherToolState.kt | 5 +- .../weatherTool/WeatherToolViewModel.kt | 83 +- .../koogleapsdk/ui/util/SnackbarUtil.kt | 16 +- .../LeapKoogAgent/gradle/libs.versions.toml | 6 +- Android/LeapVoiceAssistantDemo/README.md | 151 ++++ .../app/src/main/AndroidManifest.xml | 1 + .../ai/liquid/leap/uidemo/AudioPipeline.kt | 1 + .../ai/liquid/leap/uidemo/MainActivity.kt | 16 +- .../leap/uidemo/VoiceAssistantViewModel.kt | 80 +- .../app/src/main/res/values/strings.xml | 2 + .../gradle/libs.versions.toml | 4 +- Android/README.md | 40 +- Android/RecipeGenerator/README.md | 131 ++- Android/RecipeGenerator/app/build.gradle.kts | 101 +-- .../ai/liquid/recipegenerator/MainActivity.kt | 187 ++-- .../recipegenerator/MainActivityViewModel.kt | 318 +++---- .../app/src/main/res/values/strings.xml | 26 +- .../RecipeGenerator/gradle/libs.versions.toml | 6 +- Android/ShareAI/README.md | 98 +- Android/ShareAI/app/build.gradle.kts | 107 ++- .../java/com/leap/shareai/MainActivity.kt | 65 +- .../shareai/model/ChatMessageDisplayItem.kt | 6 +- .../shareai/screens/SummaryAppScreenUI.kt | 726 +++++++-------- .../java/com/leap/shareai/ui/theme/Color.kt | 2 +- .../java/com/leap/shareai/ui/theme/Theme.kt | 61 +- .../java/com/leap/shareai/ui/theme/Type.kt | 2 +- .../shareai/viewmodels/AIChatViewModel.kt | 399 +++++---- .../viewmodels/WebScrapingViewModel.kt | 95 +- .../shareai/webscraping/WebPageContent.kt | 6 +- .../leap/shareai/webscraping/WebPageState.kt | 13 +- .../app/src/main/res/values/strings.xml | 20 +- Android/ShareAI/gradle/libs.versions.toml | 6 +- Android/SloganApp/README.md | 88 +- Android/SloganApp/app/build.gradle.kts | 105 +-- .../java/ai/liquid/sloganapp/MainActivity.kt | 410 ++++----- .../app/src/main/res/values/strings.xml | 11 +- Android/SloganApp/gradle/libs.versions.toml | 6 +- Android/VLMExample/README.md | 112 ++- Android/VLMExample/app/build.gradle.kts | 103 +-- .../java/ai/liquid/vlmtestapp/MainActivity.kt | 383 ++++---- .../app/src/main/res/values/strings.xml | 14 +- Android/VLMExample/gradle/libs.versions.toml | 6 +- JVM/LeapChatCli/build.gradle.kts | 7 +- JVM/LeapChatCli/gradle/libs.versions.toml | 6 +- .../main/kotlin/ai/liquid/leap/cli/Main.kt | 54 +- Linux/LeapChatCli/build.gradle.kts | 3 + Linux/LeapChatCli/gradle/libs.versions.toml | 10 +- .../kotlin/ai/liquid/leap/cli/Main.kt | 62 +- README.md | 121 +-- Web/LeapVoiceAssistantDemo/build.gradle.kts | 9 +- .../gradle/libs.versions.toml | 6 +- .../ai/liquid/leap/uidemo/AudioPipeline.kt | 44 +- .../kotlin/ai/liquid/leap/uidemo/Main.kt | 24 +- Windows/LeapChatCli/build.gradle.kts | 3 + Windows/LeapChatCli/gradle/libs.versions.toml | 10 +- .../kotlin/ai/liquid/leap/cli/Main.kt | 62 +- .../LeapAudioDemo/AudioDemoStore.swift | 24 +- .../LeapAudioDemo/AudioPlaybackManager.swift | 6 +- .../LeapAudioDemo/AudioRecorder.swift | 2 +- iOS/LeapAudioDemo/project.yml | 3 +- .../LeapChatExample/ChatStore.swift | 66 +- .../LeapChatExample/MessageBubble.swift | 1 - .../LeapChatExample/MessagesListView.swift | 4 +- iOS/LeapChatExample/project.yml | 3 +- .../LeapSloganExample/SloganGeneratable.swift | 1 - .../LeapSloganExample/SloganStore.swift | 102 ++- iOS/LeapSloganExample/project.yml | 3 +- .../LeapVLMExample/VLMStore.swift | 27 +- iOS/LeapVLMExample/Makefile | 25 + iOS/LeapVLMExample/README.md | 110 +++ iOS/LeapVLMExample/project.yml | 3 +- .../AppleVoiceConversation.swift | 8 + .../LeapVoiceAssistantDemo/ContentView.swift | 2 +- .../DemoViewModel.swift | 14 +- iOS/LeapVoiceAssistantDemo/Makefile | 25 + iOS/LeapVoiceAssistantDemo/project.yml | 3 +- iOS/README.md | 29 +- iOS/RecipeGenerator/Makefile | 25 + iOS/RecipeGenerator/README.md | 160 +++- .../RecipeGenerator/ContentView.swift | 2 +- .../RecipeGenerator/GeneratorViewModel.swift | 97 +- .../RecipeGenerator/Models/Recipe.swift | 22 +- iOS/RecipeGenerator/project.yml | 5 +- .../LeapVLMExample/VLMStore.swift | 29 +- macOS/LeapVLMExample/README.md | 123 +++ macOS/LeapVLMExample/project.yml | 3 +- .../AppleVoiceConversation.swift | 8 + macOS/LeapVoiceAssistantDemo/project.yml | 3 +- 138 files changed, 5823 insertions(+), 4051 deletions(-) create mode 100644 .github/workflows/android-demos-test.yml create mode 100644 .github/workflows/ios-demos-test.yml create mode 100644 .github/workflows/macos-demos-test.yml create mode 100644 .github/workflows/web-demo-test.yml create mode 100644 Android/LeapVoiceAssistantDemo/README.md create mode 100644 iOS/LeapVLMExample/Makefile create mode 100644 iOS/LeapVLMExample/README.md create mode 100644 iOS/LeapVoiceAssistantDemo/Makefile create mode 100644 iOS/RecipeGenerator/Makefile create mode 100644 macOS/LeapVLMExample/README.md diff --git a/.github/workflows/android-demos-test.yml b/.github/workflows/android-demos-test.yml new file mode 100644 index 0000000..968e340 --- /dev/null +++ b/.github/workflows/android-demos-test.yml @@ -0,0 +1,106 @@ +name: Android Demos Build +on: + push: + branches: [ main ] + paths: + - 'Android/SloganApp/**' + - 'Android/LeapAudioDemo/**' + - 'Android/ShareAI/**' + - 'Android/RecipeGenerator/**' + - 'Android/VLMExample/**' + - 'Android/LeapVoiceAssistantDemo/**' + - 'Android/LeapKoogAgent/**' + - '.github/workflows/android-demos-test.yml' + pull_request: + branches: [ main ] + paths: + - 'Android/SloganApp/**' + - 'Android/LeapAudioDemo/**' + - 'Android/ShareAI/**' + - 'Android/RecipeGenerator/**' + - 'Android/VLMExample/**' + - 'Android/LeapVoiceAssistantDemo/**' + - 'Android/LeapKoogAgent/**' + - '.github/workflows/android-demos-test.yml' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +# Least-privilege default. The job only pulls Maven Central artifacts and +# assembles a debug APK; no GitHub API write scope required. +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + # LeapKoogAgent excluded: requires vendored koog-edge-0.0.1.aar not checked in. + # See Android/LeapKoogAgent/README.md. + demo: + - SloganApp + - LeapAudioDemo + - ShareAI + - RecipeGenerator + - VLMExample + - LeapVoiceAssistantDemo + defaults: + run: + working-directory: Android/${{ matrix.demo }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + java-version: '21' + distribution: 'temurin' + - name: Set up Gradle + uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + - name: Purge cached leap-sdk artifacts if Sonatype snapshot was republished + # The pin may be a SNAPSHOT in which case the upstream may force-update it under + # the same SNAPSHOT version, with new contents. When that happens, + # `--refresh-dependencies` alone isn't enough: the gradle cache restored by + # setup-java's cache: 'gradle' still contains the prior artifact, and gradle + # short-circuits the re-download because the cached SHA matches its metadata. + # + # Detect the situation by comparing Sonatype's for the snapshot + # against a marker we wrote on the previous run. Purge only when they differ — + # so the cache stays warm in the common case (no upstream republish) and + # rebuilds get the new artifact in the rare case (snapshot updated). + run: | + SDK_VERSION=$(grep -E '^\s*leapSdk\s*=' gradle/libs.versions.toml | sed -E 's/.*"([^"]+)".*/\1/') + # Whitelist the parsed version before interpolating into the curl URL. + if ! echo "$SDK_VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)*(-[A-Za-z0-9.]+)?(-SNAPSHOT)?$'; then + echo "(unexpected leapSdk version shape '$SDK_VERSION' — skipping purge check)" + exit 0 + fi + case "$SDK_VERSION" in + *-SNAPSHOT) ;; + *) echo "leap-sdk pinned to stable $SDK_VERSION; skipping snapshot purge check"; exit 0 ;; + esac + REMOTE_TS=$(curl -fsSL "https://central.sonatype.com/repository/maven-snapshots/ai/liquid/leap/leap-sdk/$SDK_VERSION/maven-metadata.xml" 2>/dev/null \ + | grep -oE '[0-9]+' | grep -oE '[0-9]+' || true) + MARKER=~/.gradle/caches/leap-snapshot-marker + LOCAL_TS=$(cat "$MARKER" 2>/dev/null || true) + if [ -z "$REMOTE_TS" ]; then + echo "(could not fetch snapshot metadata for $SDK_VERSION from Sonatype — skipping purge check)" + elif [ "$REMOTE_TS" = "$LOCAL_TS" ]; then + echo "Sonatype snapshot timestamp unchanged for $SDK_VERSION ($LOCAL_TS); leap-sdk cache is fresh" + else + echo "Sonatype snapshot timestamp changed for $SDK_VERSION (${LOCAL_TS:-} -> $REMOTE_TS); purging leap-sdk cache" + rm -rf ~/.gradle/caches/modules-2/files-2.1/ai.liquid.leap* \ + ~/.gradle/caches/modules-2/metadata-*/descriptors/ai.liquid.leap* + mkdir -p "$(dirname "$MARKER")" + echo "$REMOTE_TS" > "$MARKER" + fi + - name: ktfmt check + run: ./gradlew :app:ktfmtCheck + - name: Assemble debug APK + run: ./gradlew --refresh-dependencies :app:assembleDebug diff --git a/.github/workflows/ios-demos-test.yml b/.github/workflows/ios-demos-test.yml new file mode 100644 index 0000000..eb3b1e0 --- /dev/null +++ b/.github/workflows/ios-demos-test.yml @@ -0,0 +1,88 @@ +name: iOS Demos Build +on: + push: + branches: [ main ] + paths: + - 'iOS/**' + - '.github/workflows/ios-demos-test.yml' + pull_request: + branches: [ main ] + paths: + - 'iOS/**' + - '.github/workflows/ios-demos-test.yml' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +# Least-privilege default. xcodegen + xcodebuild only need to read the workspace +# and pull SPM dependencies from GitHub; no write scopes required. +permissions: + contents: read + +jobs: + build: + runs-on: macos-26 + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + demo: + - LeapSloganExample + - LeapChatExample + - LeapAudioDemo + - LeapVLMExample + - LeapVoiceAssistantDemo + - RecipeGenerator + defaults: + run: + working-directory: iOS/${{ matrix.demo }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Select Xcode 26 + # GitHub-hosted macos-26 ships Xcode 26 by default; pin explicitly to guard against + # image-default drift. Falls back to whatever xcode-select already points at if + # Xcode_26.app isn't present (defensive — should always exist on macos-26). + run: | + if [ -d /Applications/Xcode_26.app ]; then + sudo xcode-select -s /Applications/Xcode_26.app/Contents/Developer + fi + xcodebuild -version + working-directory: . + - name: Install xcodegen + run: brew install xcodegen + working-directory: . + - name: Skip macro fingerprint validation + # LeapSloganExample and RecipeGenerator depend on LeapSDKMacros. Xcode's macro + # fingerprint prompt would block a non-interactive `xcodebuild` run; opt out + # globally for this CI session. + run: defaults write com.apple.dt.Xcode IDESkipMacroFingerprintValidation -bool YES + working-directory: . + - name: Clear SPM caches if LeapSDK pin is a SNAPSHOT tag + # SPM caches a force-pushed snapshot tag's old revision and refuses to resolve + # with "does not match previously recorded value". Wipe both SPM caches and + # DerivedData when the demo pins a -SNAPSHOT revision so resolution starts + # clean. No-op for stable tags. + run: | + if grep -qE '^\s*revision:\s*[A-Za-z0-9.\-]+-SNAPSHOT\s*$' project.yml; then + echo "Detected -SNAPSHOT revision; clearing SPM caches" + rm -rf ~/Library/Caches/org.swift.swiftpm \ + ~/Library/org.swift.swiftpm \ + ~/Library/Developer/Xcode/DerivedData + else + echo "Stable LeapSDK pin; SPM caches left in place" + fi + - name: Generate Xcode project + run: make setup + - name: Build for iOS Simulator + # Pin to a concrete simulator destination so xcodebuild picks the right arch on + # the runner (Apple Silicon today; Intel-hostile to EXCLUDED_ARCHS=x86_64). Matches + # what `make build` runs locally. + run: | + xcodebuild -project ${{ matrix.demo }}.xcodeproj \ + -scheme ${{ matrix.demo }} \ + -destination 'platform=iOS Simulator,name=iPhone 16' \ + build diff --git a/.github/workflows/macos-demos-test.yml b/.github/workflows/macos-demos-test.yml new file mode 100644 index 0000000..e66012a --- /dev/null +++ b/.github/workflows/macos-demos-test.yml @@ -0,0 +1,82 @@ +name: macOS Demos Build +on: + push: + branches: [ main ] + paths: + - 'macOS/**' + - '.github/workflows/macos-demos-test.yml' + pull_request: + branches: [ main ] + paths: + - 'macOS/**' + - '.github/workflows/macos-demos-test.yml' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +# Least-privilege default. xcodegen + xcodebuild only need to read the workspace +# and pull SPM dependencies from GitHub; no write scopes required. +permissions: + contents: read + +jobs: + build: + runs-on: macos-26 + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + demo: + - LeapVLMExample + - LeapVoiceAssistantDemo + defaults: + run: + working-directory: macOS/${{ matrix.demo }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Select Xcode 26 + # GitHub-hosted macos-26 ships Xcode 26 by default; pin explicitly to guard against + # image-default drift. Falls back to whatever xcode-select already points at if + # Xcode_26.app isn't present (defensive — should always exist on macos-26). + run: | + if [ -d /Applications/Xcode_26.app ]; then + sudo xcode-select -s /Applications/Xcode_26.app/Contents/Developer + fi + xcodebuild -version + working-directory: . + - name: Install xcodegen + run: brew install xcodegen + working-directory: . + - name: Skip macro fingerprint validation + # Cheap precaution — none of the current macOS demos pull LeapSDKMacros, but + # opting out keeps the workflow robust if a future demo adopts the macro and + # avoids interactive prompts under `xcodebuild`. + run: defaults write com.apple.dt.Xcode IDESkipMacroFingerprintValidation -bool YES + working-directory: . + - name: Clear SPM caches if LeapSDK pin is a SNAPSHOT tag + # SPM caches a force-pushed snapshot tag's old revision and refuses to resolve + # with "does not match previously recorded value". Wipe both SPM caches and + # DerivedData when the demo pins a -SNAPSHOT revision so resolution starts + # clean. No-op for stable tags. + run: | + if grep -qE '^\s*revision:\s*[A-Za-z0-9.\-]+-SNAPSHOT\s*$' project.yml; then + echo "Detected -SNAPSHOT revision; clearing SPM caches" + rm -rf ~/Library/Caches/org.swift.swiftpm \ + ~/Library/org.swift.swiftpm \ + ~/Library/Developer/Xcode/DerivedData + else + echo "Stable LeapSDK pin; SPM caches left in place" + fi + - name: Generate Xcode project + # No Makefile in macOS demos; invoke xcodegen directly per the per-demo README. + run: xcodegen generate + - name: Build for macOS + run: | + xcodebuild -project ${{ matrix.demo }}.xcodeproj \ + -scheme ${{ matrix.demo }} \ + -destination 'platform=macOS' \ + build diff --git a/.github/workflows/web-demo-test.yml b/.github/workflows/web-demo-test.yml new file mode 100644 index 0000000..fd3cc90 --- /dev/null +++ b/.github/workflows/web-demo-test.yml @@ -0,0 +1,88 @@ +name: Web LeapVoiceAssistantDemo Build +on: + push: + branches: [ main ] + paths: + - 'Web/**' + - '.github/workflows/web-demo-test.yml' + pull_request: + branches: [ main ] + paths: + - 'Web/**' + - '.github/workflows/web-demo-test.yml' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +# Least-privilege default. Gradle only pulls Maven Central / node toolchain +# artifacts; no GitHub API write scope required. +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + defaults: + run: + working-directory: Web/LeapVoiceAssistantDemo + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + java-version: '21' + distribution: 'temurin' + - name: Set up Gradle + uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0 + - name: Purge cached leap-sdk artifacts if Sonatype snapshot was republished + # The pin may be a SNAPSHOT in which case the upstream may force-update it under + # the same SNAPSHOT version, with new contents. When that happens, + # `--refresh-dependencies` alone isn't enough: the gradle cache restored by + # setup-java's cache: 'gradle' still contains the prior artifact, and gradle + # short-circuits the re-download because the cached SHA matches its metadata. + # + # Detect the situation by comparing Sonatype's for the snapshot + # against a marker we wrote on the previous run. Purge only when they differ — + # so the cache stays warm in the common case (no upstream republish) and + # rebuilds get the new artifact in the rare case (snapshot updated). + run: | + SDK_VERSION=$(grep -E '^\s*leapSdk\s*=' gradle/libs.versions.toml | sed -E 's/.*"([^"]+)".*/\1/') + # Whitelist the parsed version before interpolating into the curl URL. + if ! echo "$SDK_VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)*(-[A-Za-z0-9.]+)?(-SNAPSHOT)?$'; then + echo "(unexpected leapSdk version shape '$SDK_VERSION' — skipping purge check)" + exit 0 + fi + case "$SDK_VERSION" in + *-SNAPSHOT) ;; + *) echo "leap-sdk pinned to stable $SDK_VERSION; skipping snapshot purge check"; exit 0 ;; + esac + REMOTE_TS=$(curl -fsSL "https://central.sonatype.com/repository/maven-snapshots/ai/liquid/leap/leap-sdk/$SDK_VERSION/maven-metadata.xml" 2>/dev/null \ + | grep -oE '[0-9]+' | grep -oE '[0-9]+' || true) + MARKER=~/.gradle/caches/leap-snapshot-marker + LOCAL_TS=$(cat "$MARKER" 2>/dev/null || true) + if [ -z "$REMOTE_TS" ]; then + echo "(could not fetch snapshot metadata for $SDK_VERSION from Sonatype — skipping purge check)" + elif [ "$REMOTE_TS" = "$LOCAL_TS" ]; then + echo "Sonatype snapshot timestamp unchanged for $SDK_VERSION ($LOCAL_TS); leap-sdk cache is fresh" + else + echo "Sonatype snapshot timestamp changed for $SDK_VERSION (${LOCAL_TS:-} -> $REMOTE_TS); purging leap-sdk cache" + rm -rf ~/.gradle/caches/modules-2/files-2.1/ai.liquid.leap* \ + ~/.gradle/caches/modules-2/metadata-*/descriptors/ai.liquid.leap* + mkdir -p "$(dirname "$MARKER")" + echo "$REMOTE_TS" > "$MARKER" + fi + - name: Compile Kotlin/Wasm sources + # Root-level Kotlin/Wasm project (no :composeApp module) — tasks live on the + # root project. compileKotlinWasmJs catches type errors fast before we pay + # for the full webpack distribution. + run: ./gradlew --refresh-dependencies compileKotlinWasmJs + - name: Build production browser distribution + # Exercises the full webpack pipeline including extractWasmVendor and + # CopyWebpackPlugin so missing klib resources or link-time issues surface + # in CI rather than at deploy time. + run: ./gradlew wasmJsBrowserDistribution diff --git a/Android/LeapAudioDemo/app/src/main/kotlin/ai/liquid/leapaudiodemo/AudioDemoScreen.kt b/Android/LeapAudioDemo/app/src/main/kotlin/ai/liquid/leapaudiodemo/AudioDemoScreen.kt index ce394ee..48174ad 100644 --- a/Android/LeapAudioDemo/app/src/main/kotlin/ai/liquid/leapaudiodemo/AudioDemoScreen.kt +++ b/Android/LeapAudioDemo/app/src/main/kotlin/ai/liquid/leapaudiodemo/AudioDemoScreen.kt @@ -1,15 +1,18 @@ package ai.liquid.leapaudiodemo +import android.content.Context +import android.os.SystemClock +import android.view.accessibility.AccessibilityEvent +import android.view.accessibility.AccessibilityManager import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween -import androidx.compose.foundation.background import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -72,10 +75,6 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.liveRegion import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp -import android.content.Context -import android.os.SystemClock -import android.view.accessibility.AccessibilityEvent -import android.view.accessibility.AccessibilityManager import androidx.core.content.getSystemService // Extension function for accessibility announcements @@ -105,17 +104,16 @@ fun AudioDemoScreen(state: AudioDemoState, onEvent: (AudioDemoEvent) -> Unit) { LaunchedEffect(state.recordingState) { val accessibilityManager = context.getSystemService() if (accessibilityManager?.isEnabled == true) { - val announcement = when { - state.recordingState is RecordingState.Recording -> - context.getString(R.string.status_recording) - state.recordingState is RecordingState.Idle && - previousRecordingState is RecordingState.Recording -> - context.getString(R.string.a11y_recording_stopped) - else -> null - } - announcement?.let { - accessibilityManager.announceForAccessibility(context, it) - } + val announcement = + when { + state.recordingState is RecordingState.Recording -> + context.getString(R.string.status_recording) + state.recordingState is RecordingState.Idle && + previousRecordingState is RecordingState.Recording -> + context.getString(R.string.a11y_recording_stopped) + else -> null + } + announcement?.let { accessibilityManager.announceForAccessibility(context, it) } } previousRecordingState = state.recordingState } @@ -153,7 +151,7 @@ fun AudioDemoScreen(state: AudioDemoState, onEvent: (AudioDemoEvent) -> Unit) { Image( painter = painterResource(id = R.drawable.leap_logo), contentDescription = stringResource(R.string.app_name), - modifier = Modifier.height(24.dp) + modifier = Modifier.height(24.dp), ) }, colors = @@ -222,22 +220,21 @@ fun AudioDemoScreen(state: AudioDemoState, onEvent: (AudioDemoEvent) -> Unit) { tonalElevation = 2.dp, ) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { // Status text - val statusText = when (state.modelState) { - is ModelState.NotLoaded -> stringResource(R.string.load_model) - else -> state.status ?: stringResource(R.string.load_model) - } + val statusText = + when (state.modelState) { + is ModelState.NotLoaded -> stringResource(R.string.load_model) + else -> state.status ?: stringResource(R.string.load_model) + } Text( text = statusText, - modifier = Modifier - .semantics { + modifier = + Modifier.semantics { // Announce status changes to screen readers liveRegion = LiveRegionMode.Polite }, @@ -253,21 +250,15 @@ fun AudioDemoScreen(state: AudioDemoState, onEvent: (AudioDemoEvent) -> Unit) { // Determinate progress LinearProgressIndicator( progress = { state.downloadProgress }, - modifier = Modifier - .weight(1f) - .semantics { - contentDescription = statusLoadingModel - }, + modifier = + Modifier.weight(1f).semantics { contentDescription = statusLoadingModel }, color = MaterialTheme.colorScheme.onSecondaryContainer, ) } else { // Indeterminate progress LinearProgressIndicator( - modifier = Modifier - .weight(1f) - .semantics { - contentDescription = statusLoadingModel - }, + modifier = + Modifier.weight(1f).semantics { contentDescription = statusLoadingModel }, color = MaterialTheme.colorScheme.onSecondaryContainer, ) } @@ -291,16 +282,18 @@ fun AudioDemoScreen(state: AudioDemoState, onEvent: (AudioDemoEvent) -> Unit) { modifier = Modifier.size(40.dp), ) { Icon( - imageVector = if (state.isModelCached) { - Icons.Outlined.Folder - } else { - Icons.Outlined.CloudDownload - }, - contentDescription = if (state.isModelCached) { - stringResource(R.string.load_model) - } else { - stringResource(R.string.load_model) // Could be "Download and load model" - }, + imageVector = + if (state.isModelCached) { + Icons.Outlined.Folder + } else { + Icons.Outlined.CloudDownload + }, + contentDescription = + if (state.isModelCached) { + stringResource(R.string.load_model) + } else { + stringResource(R.string.load_model) // Could be "Download and load model" + }, tint = MaterialTheme.colorScheme.onSecondaryContainer, ) } @@ -321,7 +314,9 @@ fun AudioDemoScreen(state: AudioDemoState, onEvent: (AudioDemoEvent) -> Unit) { } // Show delete icon when model is loaded and not generating - if (state.modelState is ModelState.Ready && state.generationState is GenerationState.Idle) { + if ( + state.modelState is ModelState.Ready && state.generationState is GenerationState.Idle + ) { IconButton( onClick = { onEvent(AudioDemoEvent.DeleteModel) }, modifier = Modifier.size(40.dp), @@ -351,9 +346,7 @@ fun AudioDemoScreen(state: AudioDemoState, onEvent: (AudioDemoEvent) -> Unit) { modifier = Modifier.padding(horizontal = 16.dp), ) - Button( - onClick = { onEvent(AudioDemoEvent.RetryLoadModel) }, - ) { + Button(onClick = { onEvent(AudioDemoEvent.RetryLoadModel) }) { Text(stringResource(R.string.retry)) } } @@ -361,7 +354,6 @@ fun AudioDemoScreen(state: AudioDemoState, onEvent: (AudioDemoEvent) -> Unit) { else -> {} } - // Messages list LazyColumn( state = listState, @@ -445,10 +437,10 @@ fun MessageBubble( Spacer(modifier = Modifier.height(8.dp)) // Ensure minimum 48dp touch target for accessibility (WCAG AA) Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onStopAudio() } - .padding(vertical = 8.dp, horizontal = 4.dp), // Increased vertical padding for 48dp + modifier = + Modifier.fillMaxWidth() + .clickable { onStopAudio() } + .padding(vertical = 8.dp, horizontal = 4.dp), // Increased vertical padding for 48dp horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, ) { @@ -478,10 +470,12 @@ fun MessageBubble( // Show play/stop button for completed messages with valid audio // Validate: non-null, non-empty data, and valid sample rate - if (!isStreaming && + if ( + !isStreaming && message.audioData != null && message.audioData.isNotEmpty() && - message.sampleRate > 0) { + message.sampleRate > 0 + ) { Spacer(modifier = Modifier.height(8.dp)) // Ensure minimum 48dp touch target for accessibility (WCAG AA) Row( @@ -525,13 +519,15 @@ fun MessageBubble( if (isStreaming) { Spacer(modifier = Modifier.height(4.dp)) LinearProgressIndicator( - modifier = Modifier.fillMaxWidth().semantics { - contentDescription = if (isStreamingAudio) { - statusStreamingAudio - } else { - statusAwaitingResponse + modifier = + Modifier.fillMaxWidth().semantics { + contentDescription = + if (isStreamingAudio) { + statusStreamingAudio + } else { + statusAwaitingResponse + } } - } ) } } @@ -562,21 +558,20 @@ fun InputBar( if (isRecording) { // Recording indicator with pulsing animation for prominence val infiniteTransition = rememberInfiniteTransition(label = "recording_pulse") - val alpha = infiniteTransition.animateFloat( - initialValue = 1f, - targetValue = 0.3f, - animationSpec = infiniteRepeatable( - animation = tween(800), - repeatMode = RepeatMode.Reverse - ), - label = "alpha_animation" - ) + val alpha = + infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 0.3f, + animationSpec = + infiniteRepeatable(animation = tween(800), repeatMode = RepeatMode.Reverse), + label = "alpha_animation", + ) Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 20.dp) - .graphicsLayer { this.alpha = alpha.value }, + modifier = + Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 20.dp).graphicsLayer { + this.alpha = alpha.value + }, horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, ) { @@ -605,9 +600,7 @@ fun InputBar( } Spacer(modifier = Modifier.width(12.dp)) CircularProgressIndicator( - modifier = Modifier.size(24.dp).semantics { - contentDescription = cdRecordingInProgress - }, + modifier = Modifier.size(24.dp).semantics { contentDescription = cdRecordingInProgress }, color = MaterialTheme.colorScheme.error, strokeWidth = 2.dp, ) @@ -615,9 +608,7 @@ fun InputBar( } else { // Normal input UI Row( - modifier = - Modifier.fillMaxWidth() - .padding(16.dp), + modifier = Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { diff --git a/Android/LeapAudioDemo/app/src/main/kotlin/ai/liquid/leapaudiodemo/AudioDemoViewModel.kt b/Android/LeapAudioDemo/app/src/main/kotlin/ai/liquid/leapaudiodemo/AudioDemoViewModel.kt index 03f8138..ca5b6fe 100644 --- a/Android/LeapAudioDemo/app/src/main/kotlin/ai/liquid/leapaudiodemo/AudioDemoViewModel.kt +++ b/Android/LeapAudioDemo/app/src/main/kotlin/ai/liquid/leapaudiodemo/AudioDemoViewModel.kt @@ -38,7 +38,7 @@ constructor( companion object { private const val TAG = "AudioDemoViewModel" private const val MODEL_NAME = "LFM2.5-Audio-1.5B" - private const val QUANTIZATION = "Q4_0" + private const val QUANTIZATION_TYPE = "Q4_0" // Limit message history to prevent memory issues and slow scrolling private const val MAX_MESSAGES = 50 // Maximum recording duration in seconds (matches AudioRecorder.MAX_RECORDING_SECONDS) @@ -116,7 +116,7 @@ constructor( }, ) } - val status = downloader?.queryStatus(MODEL_NAME, QUANTIZATION) + val status = downloader?.queryStatus(MODEL_NAME, QUANTIZATION_TYPE) _state.update { it.copy(isModelCached = status is LeapModelDownloader.ModelDownloadStatus.Downloaded) } @@ -154,18 +154,14 @@ constructor( private fun cancelModelLoad() { // Cancel model loading without retrying - used when permissions are denied _state.update { - it.copy( - modelState = ModelState.NotLoaded, - status = null, - downloadProgress = null, - ) + it.copy(modelState = ModelState.NotLoaded, status = null, downloadProgress = null) } } private fun cancelDownload() { viewModelScope.launch { try { - downloader?.requestStopDownload(MODEL_NAME, QUANTIZATION) + downloader?.requestStopDownload(MODEL_NAME, QUANTIZATION_TYPE) _state.update { it.copy( modelState = ModelState.NotLoaded, @@ -173,7 +169,9 @@ constructor( downloadProgress = null, // Reset progress ) } - _sideEffect.emit(AudioDemoSideEffect.ShowSnackbar(getString(R.string.success_download_cancelled))) + _sideEffect.emit( + AudioDemoSideEffect.ShowSnackbar(getString(R.string.success_download_cancelled)) + ) } catch (e: Exception) { Log.e(TAG, "Failed to cancel download", e) } @@ -187,7 +185,7 @@ constructor( modelRunner?.unload() modelRunner = null - val modelFolder = downloader?.getModelResourceFolder(MODEL_NAME, QUANTIZATION) + val modelFolder = downloader?.getModelResourceFolder(MODEL_NAME, QUANTIZATION_TYPE) if (modelFolder?.exists() == true) { modelFolder.deleteRecursively() _state.update { @@ -199,12 +197,16 @@ constructor( isModelCached = false, // Model no longer cached ) } - _sideEffect.emit(AudioDemoSideEffect.ShowSnackbar(getString(R.string.success_model_deleted))) + _sideEffect.emit( + AudioDemoSideEffect.ShowSnackbar(getString(R.string.success_model_deleted)) + ) } } catch (e: Exception) { Log.e(TAG, "Failed to delete model", e) _sideEffect.emit( - AudioDemoSideEffect.ShowSnackbar(getString(R.string.error_delete_model, e.message ?: "Unknown error")) + AudioDemoSideEffect.ShowSnackbar( + getString(R.string.error_delete_model, e.message ?: "Unknown error") + ) ) } } @@ -241,7 +243,7 @@ constructor( val downloaderInstance = downloader!! // Check if model needs to be downloaded - val currentStatus = downloaderInstance.queryStatus(MODEL_NAME, QUANTIZATION) + val currentStatus = downloaderInstance.queryStatus(MODEL_NAME, QUANTIZATION_TYPE) if (currentStatus is LeapModelDownloader.ModelDownloadStatus.NotOnLocal) { _state.update { it.copy(status = getString(R.string.status_starting_download)) } @@ -249,27 +251,32 @@ constructor( // Start observing progress before requesting download val progressJob = viewModelScope.launch { - downloaderInstance.observeDownloadProgress(MODEL_NAME, QUANTIZATION).collect { + downloaderInstance.observeDownloadProgress(MODEL_NAME, QUANTIZATION_TYPE).collect { progress -> if (progress != null) { // Validate and bound progress values to prevent division by zero and overflow - val percentage = when { - progress.totalSizeInBytes <= 0 -> 0 - progress.downloadedSizeInBytes > progress.totalSizeInBytes -> 100 - else -> ((progress.downloadedSizeInBytes * 100.0) / progress.totalSizeInBytes) - .coerceIn(0.0, 100.0) - .toInt() - } - val progressFloat = when { - progress.totalSizeInBytes <= 0 -> 0f - progress.downloadedSizeInBytes > progress.totalSizeInBytes -> 1f - else -> (progress.downloadedSizeInBytes.toFloat() / progress.totalSizeInBytes.toFloat()) - .coerceIn(0f, 1f) - } + val percentage = + when { + progress.totalSizeInBytes <= 0 -> 0 + progress.downloadedSizeInBytes > progress.totalSizeInBytes -> 100 + else -> + ((progress.downloadedSizeInBytes * 100.0) / progress.totalSizeInBytes) + .coerceIn(0.0, 100.0) + .toInt() + } + val progressFloat = + when { + progress.totalSizeInBytes <= 0 -> 0f + progress.downloadedSizeInBytes > progress.totalSizeInBytes -> 1f + else -> + (progress.downloadedSizeInBytes.toFloat() / + progress.totalSizeInBytes.toFloat()) + .coerceIn(0f, 1f) + } _state.update { it.copy( status = getString(R.string.status_downloading_progress, percentage), - downloadProgress = progressFloat + downloadProgress = progressFloat, ) } } @@ -277,16 +284,17 @@ constructor( } // Request download - downloaderInstance.requestDownloadModel(MODEL_NAME, QUANTIZATION) + downloaderInstance.requestDownloadModel(MODEL_NAME, QUANTIZATION_TYPE) // Wait until download completes with timeout // 30 minutes allows for slow connections and large model (2-3 GB) // Calculation: 3 GB / 30 min = 1.7 Mbps minimum speed try { withTimeout(30 * 60 * 1000L) { - downloaderInstance.observeDownloadProgress(MODEL_NAME, QUANTIZATION).first { progress -> + downloaderInstance.observeDownloadProgress(MODEL_NAME, QUANTIZATION_TYPE).first { + progress -> progress == null && - downloaderInstance.queryStatus(MODEL_NAME, QUANTIZATION) is + downloaderInstance.queryStatus(MODEL_NAME, QUANTIZATION_TYPE) is LeapModelDownloader.ModelDownloadStatus.Downloaded } } @@ -313,12 +321,14 @@ constructor( modelRunner = downloaderInstance.loadModel( modelName = MODEL_NAME, - quantizationType = QUANTIZATION, - options = ModelLoadingOptions( - cacheOptions = ModelLoadingOptions.cacheOptions( - path = getApplication().cacheDir.resolve("leap-cache").absolutePath, + quantizationType = QUANTIZATION_TYPE, + options = + ModelLoadingOptions( + cacheOptions = + ModelLoadingOptions.cacheOptions( + path = getApplication().cacheDir.resolve("leap-cache").absolutePath + ) ), - ), ) // Create initial conversation @@ -327,7 +337,7 @@ constructor( _state.update { it.copy( modelState = ModelState.Ready, - status = getString(R.string.model_loaded, MODEL_NAME, QUANTIZATION), + status = getString(R.string.model_loaded, MODEL_NAME, QUANTIZATION_TYPE), downloadProgress = null, // Reset progress ) } @@ -368,15 +378,13 @@ constructor( _state.update { val newMessage = AudioDemoMessage(role = ChatMessage.Role.USER, text = trimmed) // Enforce bounds check before appending to prevent race conditions - val updatedMessages = if (it.messages.size < MAX_MESSAGES) { - it.messages + newMessage - } else { - (it.messages.drop(1) + newMessage) - } - it.copy( - inputText = "", - messages = updatedMessages, - ) + val updatedMessages = + if (it.messages.size < MAX_MESSAGES) { + it.messages + newMessage + } else { + (it.messages.drop(1) + newMessage) + } + it.copy(inputText = "", messages = updatedMessages) } streamResponse(message) } @@ -395,18 +403,20 @@ constructor( val display = getString(R.string.audio_prompt_format, samples.size, sampleRate) _state.update { - val newMessage = AudioDemoMessage( - role = ChatMessage.Role.USER, - text = display, - audioData = samples, - sampleRate = sampleRate, - ) + val newMessage = + AudioDemoMessage( + role = ChatMessage.Role.USER, + text = display, + audioData = samples, + sampleRate = sampleRate, + ) // Enforce bounds check before appending to prevent race conditions - val updatedMessages = if (it.messages.size < MAX_MESSAGES) { - it.messages + newMessage - } else { - (it.messages.drop(1) + newMessage) - } + val updatedMessages = + if (it.messages.size < MAX_MESSAGES) { + it.messages + newMessage + } else { + (it.messages.drop(1) + newMessage) + } it.copy(messages = updatedMessages) } streamResponse(message) @@ -431,18 +441,19 @@ constructor( private fun startRecordingTimer() { recordingTimerJob?.cancel() - recordingTimerJob = viewModelScope.launch { - var remainingSeconds = MAX_RECORDING_SECONDS - while (remainingSeconds > 0 && _state.value.recordingState is RecordingState.Recording) { - kotlinx.coroutines.delay(1000) - remainingSeconds-- - _state.update { it.copy(recordingDurationSeconds = remainingSeconds) } - } - // Auto-stop when timer reaches 0 - if (remainingSeconds == 0 && _state.value.recordingState is RecordingState.Recording) { - stopRecording() + recordingTimerJob = + viewModelScope.launch { + var remainingSeconds = MAX_RECORDING_SECONDS + while (remainingSeconds > 0 && _state.value.recordingState is RecordingState.Recording) { + kotlinx.coroutines.delay(1000) + remainingSeconds-- + _state.update { it.copy(recordingDurationSeconds = remainingSeconds) } + } + // Auto-stop when timer reaches 0 + if (remainingSeconds == 0 && _state.value.recordingState is RecordingState.Recording) { + stopRecording() + } } - } } private fun stopRecording() { @@ -512,20 +523,21 @@ constructor( generationJob = viewModelScope.launch { // Create fresh conversation for each message (audio model doesn't support multi-turn) - val currentConversation = try { - currentModelRunner.createConversation(getString(R.string.system_prompt_audio)) - } catch (e: Exception) { - _state.update { - it.copy( - // Don't update status - keep model loaded visible - generationState = GenerationState.Idle, - ) + val currentConversation = + try { + currentModelRunner.createConversation(getString(R.string.system_prompt_audio)) + } catch (e: Exception) { + _state.update { + it.copy( + // Don't update status - keep model loaded visible + generationState = GenerationState.Idle + ) + } + return@launch } - return@launch - } _state.update { it.copy( - generationState = GenerationState.GeneratingText(""), + generationState = GenerationState.GeneratingText("") // Keep model loaded status - don't show generation progress ) } @@ -536,12 +548,13 @@ constructor( // (24,000 samples/sec × 30 sec = 720,000 samples ≈ 2.88 MB) // Reduces GC pressure for typical responses // Falls back to default capacity if pre-allocation fails (low memory device) - val audioSamplesList = try { - ArrayList(720_000) - } catch (e: OutOfMemoryError) { - Log.w(TAG, "Failed to pre-allocate audio buffer, using default capacity", e) - ArrayList() - } + val audioSamplesList = + try { + ArrayList(720_000) + } catch (e: OutOfMemoryError) { + Log.w(TAG, "Failed to pre-allocate audio buffer, using default capacity", e) + ArrayList() + } var audioSampleRate = 24000 var isAudioStreamStarted = false @@ -617,6 +630,7 @@ constructor( ) } } + is MessageResponse.Error -> throw event.throwable else -> {} } } diff --git a/Android/LeapAudioDemo/app/src/main/kotlin/ai/liquid/leapaudiodemo/AudioPlayer.kt b/Android/LeapAudioDemo/app/src/main/kotlin/ai/liquid/leapaudiodemo/AudioPlayer.kt index 9318a56..b29d367 100644 --- a/Android/LeapAudioDemo/app/src/main/kotlin/ai/liquid/leapaudiodemo/AudioPlayer.kt +++ b/Android/LeapAudioDemo/app/src/main/kotlin/ai/liquid/leapaudiodemo/AudioPlayer.kt @@ -11,7 +11,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex @@ -61,8 +60,8 @@ class AudioPlayer( /** * Audio focus change listener. * - * Handles audio focus changes from the system (phone calls, notifications, other apps). - * For AI-generated audio, we don't auto-resume after interruption because: + * Handles audio focus changes from the system (phone calls, notifications, other apps). For + * AI-generated audio, we don't auto-resume after interruption because: * 1. The user may have moved on to other tasks * 2. Resuming mid-sentence would be confusing * 3. User can manually replay if desired @@ -332,9 +331,7 @@ class AudioPlayer( require(sampleRate in 8000..192000) { "Sample rate must be between 8000 and 192000 Hz, got: $sampleRate" } - require(samples.isNotEmpty()) { - "Samples array cannot be empty" - } + require(samples.isNotEmpty()) { "Samples array cannot be empty" } // Clean up any existing playback synchronously before creating new resources playbackJob?.cancel() @@ -395,24 +392,26 @@ class AudioPlayer( // Set up notification marker at the end of playback for completion callback // This is more efficient than polling with delay() track.notificationMarkerPosition = samples.size - track.setPlaybackPositionUpdateListener(object : AudioTrack.OnPlaybackPositionUpdateListener { - override fun onMarkerReached(track: AudioTrack?) { - // Playback completed naturally - clean up resources - try { - track?.stop() - track?.release() - } catch (e: Exception) { - Log.e(TAG, "Error releasing AudioTrack on completion", e) + track.setPlaybackPositionUpdateListener( + object : AudioTrack.OnPlaybackPositionUpdateListener { + override fun onMarkerReached(track: AudioTrack?) { + // Playback completed naturally - clean up resources + try { + track?.stop() + track?.release() + } catch (e: Exception) { + Log.e(TAG, "Error releasing AudioTrack on completion", e) + } + audioTrack = null + abandonAudioFocus() + onPlaybackCompleted?.invoke() } - audioTrack = null - abandonAudioFocus() - onPlaybackCompleted?.invoke() - } - override fun onPeriodicNotification(track: AudioTrack?) { - // Not used + override fun onPeriodicNotification(track: AudioTrack?) { + // Not used + } } - }) + ) track.play() } diff --git a/Android/LeapAudioDemo/app/src/main/kotlin/ai/liquid/leapaudiodemo/AudioRecorder.kt b/Android/LeapAudioDemo/app/src/main/kotlin/ai/liquid/leapaudiodemo/AudioRecorder.kt index df872df..0b220aa 100644 --- a/Android/LeapAudioDemo/app/src/main/kotlin/ai/liquid/leapaudiodemo/AudioRecorder.kt +++ b/Android/LeapAudioDemo/app/src/main/kotlin/ai/liquid/leapaudiodemo/AudioRecorder.kt @@ -22,7 +22,8 @@ import kotlinx.coroutines.withContext class AudioRecorder : AudioRecording { private var audioRecord: AudioRecord? = null private var recordingJob: Job? = null - private val recordedSamples = mutableListOf() + // Pre-allocate to the known cap to avoid repeated ArrayList growth/copy during capture. + private val recordedSamples = ArrayList(MAX_SAMPLES) private val samplesMutex = Mutex() private val coroutineScope = CoroutineScope(Dispatchers.IO) @@ -32,7 +33,7 @@ class AudioRecorder : AudioRecording { private const val CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_MONO private const val AUDIO_FORMAT = AudioFormat.ENCODING_PCM_FLOAT private const val MAX_RECORDING_SECONDS = 60 - private val MAX_SAMPLES = SAMPLE_RATE * MAX_RECORDING_SECONDS + private const val MAX_SAMPLES = SAMPLE_RATE * MAX_RECORDING_SECONDS } /** @@ -88,48 +89,50 @@ class AudioRecorder : AudioRecording { } recordingJob = - coroutineScope.launch { - val buffer = FloatArray(bufferSize / 4) // 4 bytes per float - while (isActive) { - val read = audioRecord?.read(buffer, 0, buffer.size, AudioRecord.READ_BLOCKING) ?: 0 - when { - read > 0 -> { - samplesMutex.withLock { - val samplesToAdd = minOf(read, MAX_SAMPLES - recordedSamples.size) - if (samplesToAdd > 0) { - // Copy samples directly to avoid intermediate collection allocation and boxing - for (i in 0 until samplesToAdd) { - recordedSamples.add(buffer[i]) + coroutineScope + .launch { + val buffer = FloatArray(bufferSize / 4) // 4 bytes per float + while (isActive) { + val read = audioRecord?.read(buffer, 0, buffer.size, AudioRecord.READ_BLOCKING) ?: 0 + when { + read > 0 -> { + samplesMutex.withLock { + val samplesToAdd = minOf(read, MAX_SAMPLES - recordedSamples.size) + if (samplesToAdd > 0) { + // Copy samples directly to avoid intermediate collection allocation and boxing + for (i in 0 until samplesToAdd) { + recordedSamples.add(buffer[i]) + } + } + // Check if we hit the max limit + if (recordedSamples.size >= MAX_SAMPLES) { + // Break out of while loop - will auto-stop below + return@launch } - } - // Check if we hit the max limit - if (recordedSamples.size >= MAX_SAMPLES) { - // Break out of while loop - will auto-stop below - return@launch } } - } - read < 0 -> { - // AudioRecord.read() returned error code - // Negative values indicate errors: ERROR_INVALID_OPERATION (-3), - // ERROR_BAD_VALUE (-2), ERROR_DEAD_OBJECT (-6), ERROR (-1) - Log.e(TAG, "AudioRecord read error: $read") - return@launch - } + read < 0 -> { + // AudioRecord.read() returned error code + // Negative values indicate errors: ERROR_INVALID_OPERATION (-3), + // ERROR_BAD_VALUE (-2), ERROR_DEAD_OBJECT (-6), ERROR (-1) + Log.e(TAG, "AudioRecord read error: $read") + return@launch + } // read == 0: No samples available, continue loop + } } } - }.also { job -> - // Auto-stop recording when job completes - job.invokeOnCompletion { - try { - audioRecord?.takeIf { it.recordingState == AudioRecord.RECORDSTATE_RECORDING }?.stop() - } catch (e: IllegalStateException) { - // AudioRecord already stopped or in invalid state - Log.w(TAG, "AudioRecord already stopped during auto-stop", e) + .also { job -> + // Auto-stop recording when job completes + job.invokeOnCompletion { + try { + audioRecord?.takeIf { it.recordingState == AudioRecord.RECORDSTATE_RECORDING }?.stop() + } catch (e: IllegalStateException) { + // AudioRecord already stopped or in invalid state + Log.w(TAG, "AudioRecord already stopped during auto-stop", e) + } } } - } return true } @@ -190,8 +193,6 @@ class AudioRecorder : AudioRecording { audioRecord?.release() audioRecord = null - samplesMutex.withLock { - recordedSamples.clear() - } + samplesMutex.withLock { recordedSamples.clear() } } } diff --git a/Android/LeapAudioDemo/gradle/libs.versions.toml b/Android/LeapAudioDemo/gradle/libs.versions.toml index 3f0e053..fe52d00 100644 --- a/Android/LeapAudioDemo/gradle/libs.versions.toml +++ b/Android/LeapAudioDemo/gradle/libs.versions.toml @@ -1,12 +1,12 @@ [versions] agp = "8.13.2" -kotlin = "2.3.20" +kotlin = "2.3.21" coreKtx = "1.17.0" junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" androidxTestCore = "1.6.1" -leapSdk = "0.10.7" +leapSdk = "0.10.8" lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.12.3" composeBom = "2026.01.01" diff --git a/Android/LeapChat/README.md b/Android/LeapChat/README.md index e6e647a..457427c 100644 --- a/Android/LeapChat/README.md +++ b/Android/LeapChat/README.md @@ -1,16 +1,116 @@ -Example Chat App for LeapSDK -=== -This is a simple chat app for LeapSDK in Android to illustrate how to use LeapSDK. The UI is implemented with [Jetpack Compose](https://developer.android.com/develop/ui/compose/documentation). - -Almost all logic interacting with LeapSDK is in [MainActivity.kt](app/src/main/java/ai/liquid/leapchat/MainActivity.kt). Following features are touched: -* Downloading a model from Leap Model Library. -* Loading a model. -* Generating responses from a conversation. -* Continue generation from the chat history. -* Serialize/deserialize the chat history with Kotlin Serialization. -* Function calling to get accurate answers for math compuation. +# LeapChat + +A full chat app built with Jetpack Compose and the Leap SDK. It exercises most of the SDK's conversational surface: streaming responses, multi-turn history, reasoning traces, function calling, and serializing the conversation across activity recreation. + +## Features + +- Multi-turn chat with streaming token output +- Reasoning-trace rendering for models that emit `MessageResponse.ReasoningChunk` events +- Function calling — a toggleable `compute_sum` tool the model can invoke for accurate arithmetic +- Persisted conversation: the history is JSON-serialized via `kotlinx.serialization` and restored from `onSaveInstanceState` (survives rotation and process recreation) +- Cancel-in-flight generation via a dedicated Stop button +- Clear-history button that resets the conversation state +- Automatic model download with progress reporting via `LeapModelDownloader` + +## Model + +- **LFM2.5-350M** (`Q8_0`) — downloaded automatically by `LeapModelDownloader` on first launch +- Cached under `context.cacheDir/leap-cache/` +- System prompt is provided via `R.string.chat_system_prompt` ("You are a helpful assistant. Be concise and accurate.") + +To swap models, change the `MODEL_NAME` and `QUANTIZATION_SLUG` constants in `MainActivity.kt`. + +## Prerequisites + +- Android Studio Hedgehog (2023.1.1) or newer +- JDK 21 (use `JAVA_HOME=~/.sdkman/candidates/java/21.0.9-zulu` if managing JDKs with SDKman) +- Android device or emulator on API 31 (Android 12) or higher +- Internet access on first launch for the model download + +## Running + +```bash +./gradlew installDebug +# or open the project in Android Studio and press Run +``` + +## Project Structure + +``` +app/src/main/java/ai/liquid/leapchat/ +├── MainActivity.kt # Compose UI + all SDK integration +├── models/ChatMessageDisplayItem.kt # Display-side message model +└── views/ + ├── ChatHistory.kt # LazyColumn-backed message list + ├── UserMessage.kt # User bubble composable + ├── AssistantMessage.kt # Assistant bubble with reasoning expander + └── ToolMessage.kt # Tool-result bubble +``` + +## Key SDK Patterns + +**Register a tool the model can call** (`MainActivity.getOrRestoreConversation`): + +```kotlin +conversation.registerFunction( + LeapFunction( + "compute_sum", + "Compute sum of a series of numbers", + listOf( + LeapFunctionParameter( + name = "values", + type = LeapFunctionParameterType.LeapArr( + itemType = LeapFunctionParameterType.LeapStr(), + ), + description = "Numbers to compute sum. Values should be represented in string.", + ), + ), + ), +) +``` + +**Stream a response and react to every event type** (`MainActivity.sendMessage`): + +```kotlin +val functionCallsToInvoke = mutableListOf() +conversation.generateResponse(message).onEach { event -> + when (event) { + is MessageResponse.Chunk -> generateTextBuffer.append(event.text) + is MessageResponse.ReasoningChunk -> generatedReasoningBuffer.append(event.reasoning) + is MessageResponse.FunctionCalls -> functionCallsToInvoke += event.functionCalls + else -> {} + } + updateLastAssistantMessage(generateTextBuffer.toString(), generatedReasoningBuffer.toString()) +}.onCompletion { + conversationHistoryJSONString = Json.encodeToString(conversation.history) +}.collect() + +if (functionCallsToInvoke.isNotEmpty()) processFunctionCalls(functionCallsToInvoke) +``` + +When the model emits `FunctionCalls`, the app executes them locally (e.g. `compute_sum`) and feeds the result back as a `ChatMessage` with `Role.TOOL`, letting the model continue generating with the tool output in context. + +**Persist and restore the conversation** (`MainActivity.getOrRestoreConversation` / `loadState`): + +```kotlin +// Save (after each generation completes): +conversationHistoryJSONString = Json.encodeToString(conversation.history) +outState.putString("history-json", conversationHistoryJSONString) + +// Restore: +val history: List = Json.decodeFromString(jsonStr) +val conversation = modelRunner.createConversationFromHistory(history) +``` + +`Conversation.history` is a `List` from the SDK and is directly serializable with `kotlinx.serialization`. ## Screenshot + This is a screenshot of the app running a Qwen3 reasoning model. + +## Notes + +- The downloader's `INTERNET` permission is contributed by the `leap-model-downloader` AAR's merged manifest — the app module does not redeclare it. +- Toggling the **Tool ON/OFF** button only affects whether the function is registered on the *next* conversation created; flip it before sending the first message of a turn that should be able to use the tool. diff --git a/Android/LeapChat/app/build.gradle.kts b/Android/LeapChat/app/build.gradle.kts index c0ae818..f2e2644 100644 --- a/Android/LeapChat/app/build.gradle.kts +++ b/Android/LeapChat/app/build.gradle.kts @@ -1,69 +1,62 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { - alias(libs.plugins.android.application) - alias(libs.plugins.kotlin.android) - alias(libs.plugins.kotlin.compose) - alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.com.ncorti.ktfmt.gradle) } -android { - namespace = "ai.liquid.leapchat" - compileSdk = 36 - - defaultConfig { - applicationId = "ai.liquid.leapchat" - minSdk = 31 - targetSdk = 36 - versionCode = 1 - versionName = "1.0" +ktfmt { googleStyle() } - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - } - - buildTypes { - release { - isMinifyEnabled = false - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro", - ) - } - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 - } - kotlin { - compilerOptions { - jvmTarget.set(JvmTarget.JVM_11) - } - } - buildFeatures { - compose = true +android { + namespace = "ai.liquid.leapchat" + compileSdk = 36 + + defaultConfig { + applicationId = "ai.liquid.leapchat" + minSdk = 31 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_11) } } + buildFeatures { compose = true } } dependencies { - implementation(libs.androidx.core.ktx) - implementation(libs.androidx.lifecycle.runtime.ktx) - implementation(libs.androidx.activity.compose) - implementation(platform(libs.androidx.compose.bom)) - implementation(libs.androidx.runtime.livedata) - implementation(libs.androidx.ui) - implementation(libs.androidx.ui.graphics) - implementation(libs.androidx.ui.tooling.preview) - implementation(libs.androidx.material3) - implementation(libs.kotlinx.serialization.json) - implementation(libs.leap.sdk) - implementation(libs.leap.model.downloader) - - testImplementation(libs.junit) - androidTestImplementation(libs.androidx.junit) - androidTestImplementation(libs.androidx.espresso.core) - androidTestImplementation(platform(libs.androidx.compose.bom)) - androidTestImplementation(libs.androidx.ui.test.junit4) - debugImplementation(libs.androidx.ui.tooling) - debugImplementation(libs.androidx.ui.test.manifest) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.runtime.livedata) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + implementation(libs.kotlinx.serialization.json) + implementation(libs.leap.sdk) + implementation(libs.leap.model.downloader) + + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) } - diff --git a/Android/LeapChat/app/src/androidTest/java/ai/liquid/leapchat/MainActivityTest.kt b/Android/LeapChat/app/src/androidTest/java/ai/liquid/leapchat/MainActivityTest.kt index 32a75b0..117159b 100644 --- a/Android/LeapChat/app/src/androidTest/java/ai/liquid/leapchat/MainActivityTest.kt +++ b/Android/LeapChat/app/src/androidTest/java/ai/liquid/leapchat/MainActivityTest.kt @@ -2,7 +2,6 @@ package ai.liquid.leapchat import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertTextContains import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasText import androidx.compose.ui.test.isDisplayed @@ -18,49 +17,47 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class MainActivityE2EAssetTests { - @get:Rule - val composeTestRule = createAndroidComposeRule() - - - @OptIn(ExperimentalTestApi::class) - @Test - fun testEndToEndChat() { - val modelLoadingIndicatorMatcher = hasTestTag("ModelLoadingIndicator") - val inputBoxMatcher = hasTestTag("InputBox") - val sendButtonMatcher = hasText("Send") - - // Wait for the model to be downloaded and loaded - composeTestRule.onNode(modelLoadingIndicatorMatcher).assertIsDisplayed() - composeTestRule.waitUntilDoesNotExist( - modelLoadingIndicatorMatcher, - timeoutMillis = MODEL_LOADING_TIMEOUT - ) - composeTestRule.waitUntilAtLeastOneExists(sendButtonMatcher, timeoutMillis = 5000L) - - // Send an input to the model - composeTestRule.onNode(inputBoxMatcher) - .performTextInput("How many 'r' are there in the word 'strawberry'?") - composeTestRule.onNode(sendButtonMatcher).performClick() - composeTestRule.waitUntilAtLeastOneExists( - hasTestTag("AssistantMessageView"), - timeoutMillis = 5000L - ) - composeTestRule.waitUntil(timeoutMillis = 5000L) { - composeTestRule.onNode(hasTestTag("AssistantMessageViewText").and(hasText("strawberry", substring = true))) - .isDisplayed() - } - - - // Continue the chat with a second prompt - composeTestRule.onNode(inputBoxMatcher).performTextInput("What about letter 'a'?") - composeTestRule.onNode(sendButtonMatcher).performClick() - composeTestRule.waitUntil(timeoutMillis = 5000L) { - composeTestRule.onAllNodesWithTag("AssistantMessageView") - .fetchSemanticsNodes().size == 2 - } + @get:Rule val composeTestRule = createAndroidComposeRule() + + @OptIn(ExperimentalTestApi::class) + @Test + fun testEndToEndChat() { + val modelLoadingIndicatorMatcher = hasTestTag("ModelLoadingIndicator") + val inputBoxMatcher = hasTestTag("InputBox") + val sendButtonMatcher = hasText("Send") + + // Wait for the model to be downloaded and loaded + composeTestRule.onNode(modelLoadingIndicatorMatcher).assertIsDisplayed() + composeTestRule.waitUntilDoesNotExist( + modelLoadingIndicatorMatcher, + timeoutMillis = MODEL_LOADING_TIMEOUT, + ) + composeTestRule.waitUntilAtLeastOneExists(sendButtonMatcher, timeoutMillis = 5000L) + + // Send an input to the model + composeTestRule + .onNode(inputBoxMatcher) + .performTextInput("How many 'r' are there in the word 'strawberry'?") + composeTestRule.onNode(sendButtonMatcher).performClick() + composeTestRule.waitUntilAtLeastOneExists( + hasTestTag("AssistantMessageView"), + timeoutMillis = 5000L, + ) + composeTestRule.waitUntil(timeoutMillis = 5000L) { + composeTestRule + .onNode(hasTestTag("AssistantMessageViewText").and(hasText("strawberry", substring = true))) + .isDisplayed() } - companion object { - const val MODEL_LOADING_TIMEOUT = 5L * 60L * 1000L + // Continue the chat with a second prompt + composeTestRule.onNode(inputBoxMatcher).performTextInput("What about letter 'a'?") + composeTestRule.onNode(sendButtonMatcher).performClick() + composeTestRule.waitUntil(timeoutMillis = 5000L) { + composeTestRule.onAllNodesWithTag("AssistantMessageView").fetchSemanticsNodes().size == 2 } -} \ No newline at end of file + } + + companion object { + const val MODEL_LOADING_TIMEOUT = 5L * 60L * 1000L + } +} diff --git a/Android/LeapChat/app/src/main/java/ai/liquid/leapchat/MainActivity.kt b/Android/LeapChat/app/src/main/java/ai/liquid/leapchat/MainActivity.kt index 75d29c5..7f3e8b7 100644 --- a/Android/LeapChat/app/src/main/java/ai/liquid/leapchat/MainActivity.kt +++ b/Android/LeapChat/app/src/main/java/ai/liquid/leapchat/MainActivity.kt @@ -1,17 +1,16 @@ package ai.liquid.leapchat import ai.liquid.leap.Conversation -import ai.liquid.leap.LeapClient import ai.liquid.leap.LeapModelLoadingException import ai.liquid.leap.ModelLoadingOptions import ai.liquid.leap.ModelRunner -import ai.liquid.leap.message.ChatMessage import ai.liquid.leap.downloader.LeapModelDownloader import ai.liquid.leap.downloader.LeapModelDownloaderNotificationConfig import ai.liquid.leap.function.LeapFunction import ai.liquid.leap.function.LeapFunctionCall import ai.liquid.leap.function.LeapFunctionParameter import ai.liquid.leap.function.LeapFunctionParameterType +import ai.liquid.leap.message.ChatMessage import ai.liquid.leap.message.ChatMessageContent import ai.liquid.leap.message.MessageResponse import ai.liquid.leapchat.models.ChatMessageDisplayItem @@ -53,481 +52,478 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.MutableLiveData import androidx.lifecycle.lifecycleScope +import kotlin.collections.plus +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.launch -import kotlin.collections.plus -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json class MainActivity : ComponentActivity() { - // The generation job instance. - private var job: Job? = null + // The generation job instance. + private var job: Job? = null - // The model runner instance. - private val modelRunner: MutableLiveData by lazy { - MutableLiveData() - } + // The model runner instance. + private val modelRunner: MutableLiveData by lazy { MutableLiveData() } - // The model downloader instance (reused to avoid concurrent download issues) - private var downloader: LeapModelDownloader? = null + // The model downloader instance (reused to avoid concurrent download issues) + private var downloader: LeapModelDownloader? = null - // The conversation instance. It will be cached and shared between the generations. If the - // activity is destroyed and restored, it will be re-created from the dumped states. - private var conversation: Conversation? = null + // The conversation instance. It will be cached and shared between the generations. If the + // activity is destroyed and restored, it will be re-created from the dumped states. + private var conversation: Conversation? = null - // The chat message history for displaying - private val chatMessageHistory: MutableLiveData> by lazy { - MutableLiveData>() - } + // The chat message history for displaying + private val chatMessageHistory: MutableLiveData> by lazy { + MutableLiveData>() + } - // Conversation history json string. This value will be updated once the generation is complete - // and will be used to persistent the states when the activity is destroyed - private var conversationHistoryJSONString: String? = null + // Conversation history json string. This value will be updated once the generation is complete + // and will be used to persistent the states when the activity is destroyed + private var conversationHistoryJSONString: String? = null - // Whether the generation is still ongoing - private val isInGeneration: MutableLiveData by lazy { - MutableLiveData(false) - } + // Whether the generation is still ongoing + private val isInGeneration: MutableLiveData by lazy { MutableLiveData(false) } - private val isToolEnabled: MutableLiveData by lazy { - MutableLiveData(false) - } + private val isToolEnabled: MutableLiveData by lazy { MutableLiveData(false) } - private val json = Json { ignoreUnknownKeys = true } + private val json = Json { ignoreUnknownKeys = true } - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - if (savedInstanceState != null) { - loadState(savedInstanceState) - } - enableEdgeToEdge() - setContent { MainContent() } + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (savedInstanceState != null) { + loadState(savedInstanceState) } - - override fun onDestroy() { - super.onDestroy() - lifecycleScope.launch { modelRunner.value?.unload() } + enableEdgeToEdge() + setContent { MainContent() } + } + + override fun onDestroy() { + super.onDestroy() + // Unload on a fresh scope: lifecycleScope is cancelled by the time onDestroy fires, + // so launches there would never run. + val runner = modelRunner.value + CoroutineScope(Dispatchers.IO).launch { + try { + runner?.unload() + } catch (e: Exception) { + Log.e(TAG, "Error unloading model", e) + } } + } - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - if (conversationHistoryJSONString != null) { - outState.putString("history-json", conversationHistoryJSONString) - } + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + if (conversationHistoryJSONString != null) { + outState.putString("history-json", conversationHistoryJSONString) } - - /** - * The composable of the main activity content - */ - @Composable - fun MainContent() { - val modelRunnerInstance by modelRunner.observeAsState() - val chatMessageHistory: List by chatMessageHistory.observeAsState( - listOf() - ) - var userInputFieldText by remember { mutableStateOf("") } - val chatHistoryFocusRequester = remember { FocusRequester() } - val isInGeneration = this.isInGeneration.observeAsState(false) - Scaffold( - modifier = Modifier - .fillMaxSize() - .windowInsetsPadding( - NavigationBarDefaults.windowInsets.union( - WindowInsets.ime - ) - ), - bottomBar = { - Box { - if (modelRunnerInstance == null) { - ModelLoadingIndicator(modelRunnerInstance) { onError, onStatusChange -> - loadModel(onError, onStatusChange) - } - } else { - Column { - TextField( - value = userInputFieldText, - onValueChange = { userInputFieldText = it }, - modifier = Modifier - .padding(4.dp) - .fillMaxWidth(1.0f).testTag("InputBox"), - enabled = !isInGeneration.value - ) - Row( - horizontalArrangement = Arrangement.End, - modifier = Modifier.fillMaxWidth(1.0f) - ) { - val isToolEnabledState by isToolEnabled.observeAsState(false) - Button(onClick = { - isToolEnabled.value = !isToolEnabledState - }) { - if (isToolEnabledState) { - Text(getString(R.string.tool_on_button_label)) - } else { - Text(getString(R.string.tool_off_button_label)) - } - } - Button( - onClick = { - job?.cancel() - }, - enabled = isInGeneration.value - ) { - Text(getString(R.string.stop_generation_button_label)) - } - Button( - onClick = { - conversationHistoryJSONString = null - this@MainActivity.chatMessageHistory.value = listOf() - }, - enabled = !isInGeneration.value && (conversationHistoryJSONString != null) - ) { - Text(getString(R.string.clean_history_button_label)) - } - Button( - onClick = { - this@MainActivity.isInGeneration.value = true - sendText(userInputFieldText) - userInputFieldText = "" - chatHistoryFocusRequester.requestFocus() - }, - enabled = !isInGeneration.value - ) { - Text(getString(R.string.send_message_button_label)) - } - } - } - } - } - }, - ) { innerPadding -> - Box( - Modifier - .padding(innerPadding) - .focusRequester(chatHistoryFocusRequester) - .focusable(true) - ) { - ChatHistory(chatMessageHistory) + } + + /** The composable of the main activity content */ + @Composable + fun MainContent() { + val modelRunnerInstance by modelRunner.observeAsState() + val chatMessageHistory: List by + chatMessageHistory.observeAsState(listOf()) + var userInputFieldText by remember { mutableStateOf("") } + val chatHistoryFocusRequester = remember { FocusRequester() } + val isInGeneration = this.isInGeneration.observeAsState(false) + Scaffold( + modifier = + Modifier.fillMaxSize() + .windowInsetsPadding(NavigationBarDefaults.windowInsets.union(WindowInsets.ime)), + bottomBar = { + Box { + if (modelRunnerInstance == null) { + ModelLoadingIndicator(modelRunnerInstance) { onError, onStatusChange -> + loadModel(onError, onStatusChange) } - } - } - - /** - * Load the model file. - */ - private fun loadModel(onError: (Throwable) -> Unit, onStatusChange: (String) -> Unit) { - lifecycleScope.launch { - try { - // Reuse downloader instance to avoid concurrent download issues - if (downloader == null) { - downloader = LeapModelDownloader( - this@MainActivity, - notificationConfig = LeapModelDownloaderNotificationConfig.build { - notificationTitleDownloading = "Downloading Chat Model" - notificationTitleDownloaded = "Model Ready!" - } - ) - } - val downloaderInstance = downloader!! - - // Check if model needs to be downloaded - val currentStatus = downloaderInstance.queryStatus(MODEL_NAME, QUANTIZATION_SLUG) - - if (currentStatus is LeapModelDownloader.ModelDownloadStatus.NotOnLocal) { - // Model needs to be downloaded - onStatusChange("Starting download...") - - // Observe download progress - val progressFlow = downloaderInstance.observeDownloadProgress(MODEL_NAME, QUANTIZATION_SLUG) - - // Start the download - downloaderInstance.requestDownloadModel(MODEL_NAME, QUANTIZATION_SLUG) - - // Collect progress updates until download completes - progressFlow - .onEach { progress -> - if (progress != null) { - val downloadedMB = progress.downloadedSizeInBytes / (1024 * 1024) - val totalMB = progress.totalSizeInBytes / (1024 * 1024) - val percentage = if (progress.totalSizeInBytes > 0) { - (progress.downloadedSizeInBytes * 100.0 / progress.totalSizeInBytes).toInt() - } else { - 0 - } - onStatusChange("Downloading model: $percentage% ($downloadedMB MB / $totalMB MB)") - } else { - val downloadStatus = downloaderInstance.queryStatus(MODEL_NAME, QUANTIZATION_SLUG) - if (downloadStatus is LeapModelDownloader.ModelDownloadStatus.Downloaded) { - onStatusChange("Download complete!") - } - } - } - .takeWhile { progress -> - progress != null || downloaderInstance.queryStatus(MODEL_NAME, QUANTIZATION_SLUG) is LeapModelDownloader.ModelDownloadStatus.DownloadInProgress - } - .collect() - } - - modelRunner.value = downloaderInstance.loadModel( - modelName = MODEL_NAME, - quantizationType = QUANTIZATION_SLUG, - options = ModelLoadingOptions( - cacheOptions = ModelLoadingOptions.cacheOptions( - path = cacheDir.resolve("leap-cache").absolutePath, - ), - ), - ) - } catch (e: LeapModelLoadingException) { - onError(e) - } - } - } - - /** - * Send a text as user message and start generation. - */ - private fun sendText(input: String) { - appendUserMessage(input) - val modelRunner = checkNotNull(modelRunner.value) - val conversation = getOrRestoreConversation(modelRunner) - sendMessage(conversation, ChatMessage(role = ChatMessage.Role.USER, textContent = input)) - } - - private fun sendToolText(toolText: String) { - appendToolMessage(toolText) - val modelRunner = checkNotNull(modelRunner.value) - val conversation = getOrRestoreConversation(modelRunner) - sendMessage(conversation, ChatMessage(role = ChatMessage.Role.TOOL, textContent = toolText)) - } - - private fun sendMessage(conversation: Conversation, message: ChatMessage) { - job = - lifecycleScope.launch { - val generateTextBuffer = StringBuilder() - val generatedReasoningBuffer = StringBuilder() - val functionCallsToInvoke = mutableListOf() - // Generate the response - conversation.generateResponse(message).onEach { - when (it) { - is MessageResponse.Chunk -> { - generateTextBuffer.append(it.text) - } - - is MessageResponse.ReasoningChunk -> { - generatedReasoningBuffer.append(it.reasoning) - } - - is MessageResponse.FunctionCalls -> { - it.functionCalls.forEach { call -> - generatedReasoningBuffer.append("Calling tool: ${call.name}") - functionCallsToInvoke.add(call) - } - } - - else -> {} - } - updateLastAssistantMessage( - generateTextBuffer.toString(), - generatedReasoningBuffer.toString() - ) + } else { + Column { + TextField( + value = userInputFieldText, + onValueChange = { userInputFieldText = it }, + modifier = Modifier.padding(4.dp).fillMaxWidth(1.0f).testTag("InputBox"), + enabled = !isInGeneration.value, + ) + Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth(1.0f)) { + val isToolEnabledState by isToolEnabled.observeAsState(false) + Button(onClick = { isToolEnabled.value = !isToolEnabledState }) { + if (isToolEnabledState) { + Text(getString(R.string.tool_on_button_label)) + } else { + Text(getString(R.string.tool_off_button_label)) + } } - .onCompletion { - this@MainActivity.isInGeneration.value = false - conversationHistoryJSONString = json.encodeToString(conversation.history) - } - .catch { exception -> - Log.e( - this@MainActivity::class.java.simpleName, - "Error in generation: $exception", - ) - } - .collect() - if (functionCallsToInvoke.isNotEmpty()) { - processFunctionCalls(functionCallsToInvoke) + Button(onClick = { job?.cancel() }, enabled = isInGeneration.value) { + Text(getString(R.string.stop_generation_button_label)) } - } - } - - private fun processFunctionCalls(functionCalls: List) { - for (call in functionCalls) { - when (call.name) { - "compute_sum" -> { - val numbers = call.arguments["values"] as? List ?: listOf() - var sum = 0.0 - for (v in numbers) { - sum += v.toDoubleOrNull() ?: 0.0 - } - sendToolText("Sum = $sum") + Button( + onClick = { + conversationHistoryJSONString = null + this@MainActivity.chatMessageHistory.value = listOf() + }, + enabled = !isInGeneration.value && (conversationHistoryJSONString != null), + ) { + Text(getString(R.string.clean_history_button_label)) } - - else -> { - sendToolText("Tool: ${call.name} is not available") + Button( + onClick = { + this@MainActivity.isInGeneration.value = true + sendText(userInputFieldText) + userInputFieldText = "" + chatHistoryFocusRequester.requestFocus() + }, + enabled = !isInGeneration.value, + ) { + Text(getString(R.string.send_message_button_label)) } + } } + } } + }, + ) { innerPadding -> + Box( + Modifier.padding(innerPadding).focusRequester(chatHistoryFocusRequester).focusable(true) + ) { + ChatHistory(chatMessageHistory) + } } - - /** - * In case `conversation` is not available due to the activity destroy, restore it from the - * conversation history. - */ - private fun getOrRestoreConversation(modelRunner: ModelRunner): Conversation { - val conversationInstance = conversation - val conversation = if (conversationInstance == null) { - val conversationHistory = getConversationHistory() - if (conversationHistory == null) { - modelRunner.createConversation(getString(R.string.chat_system_prompt)) - } else { - modelRunner.createConversationFromHistory(conversationHistory) - } - } else { - conversationInstance + } + + /** Load the model file. */ + private fun loadModel(onError: (Throwable) -> Unit, onStatusChange: (String) -> Unit) { + lifecycleScope.launch { + try { + // Reuse downloader instance to avoid concurrent download issues + if (downloader == null) { + downloader = + LeapModelDownloader( + this@MainActivity, + notificationConfig = + LeapModelDownloaderNotificationConfig.build { + notificationTitleDownloading = + getString(R.string.notification_downloading_chat_model) + notificationTitleDownloaded = getString(R.string.notification_chat_model_ready) + }, + ) } - - if (isToolEnabled.value == true) { - conversation.registerFunction( - LeapFunction( - "compute_sum", "Compute sum of a series of numbers", listOf( - LeapFunctionParameter( - name = "values", - type = LeapFunctionParameterType.LeapArr( - itemType = LeapFunctionParameterType.LeapStr() - ), - description = "Numbers to compute sum. Values should be represented in string." - ) - ) + val downloaderInstance = downloader!! + + // Check if model needs to be downloaded + val currentStatus = downloaderInstance.queryStatus(MODEL_NAME, QUANTIZATION_SLUG) + + if (currentStatus is LeapModelDownloader.ModelDownloadStatus.NotOnLocal) { + // Model needs to be downloaded + onStatusChange(getString(R.string.status_starting_download)) + + // Observe download progress + val progressFlow = + downloaderInstance.observeDownloadProgress(MODEL_NAME, QUANTIZATION_SLUG) + + // Start the download + downloaderInstance.requestDownloadModel(MODEL_NAME, QUANTIZATION_SLUG) + + // Collect progress updates until download completes + progressFlow + .onEach { progress -> + if (progress != null) { + val downloadedMB = (progress.downloadedSizeInBytes / (1024 * 1024)).toInt() + val totalMB = (progress.totalSizeInBytes / (1024 * 1024)).toInt() + val percentage = + if (progress.totalSizeInBytes > 0) { + (progress.downloadedSizeInBytes * 100.0 / progress.totalSizeInBytes).toInt() + } else { + 0 + } + onStatusChange( + getString(R.string.status_downloading_progress, percentage, downloadedMB, totalMB) ) - ) + } else { + val downloadStatus = downloaderInstance.queryStatus(MODEL_NAME, QUANTIZATION_SLUG) + if (downloadStatus is LeapModelDownloader.ModelDownloadStatus.Downloaded) { + onStatusChange(getString(R.string.status_download_complete)) + } + } + } + .takeWhile { progress -> + progress != null || + downloaderInstance.queryStatus(MODEL_NAME, QUANTIZATION_SLUG) is + LeapModelDownloader.ModelDownloadStatus.DownloadInProgress + } + .collect() } - return conversation + modelRunner.value = + downloaderInstance.loadModel( + modelName = MODEL_NAME, + quantizationType = QUANTIZATION_SLUG, + options = + ModelLoadingOptions( + cacheOptions = + ModelLoadingOptions.cacheOptions( + path = cacheDir.resolve("leap-cache").absolutePath + ) + ), + ) + } catch (e: LeapModelLoadingException) { + onError(e) + } } + } + + /** Send a text as user message and start generation. */ + private fun sendText(input: String) { + appendUserMessage(input) + val modelRunner = checkNotNull(modelRunner.value) + val conversation = getOrRestoreConversation(modelRunner) + sendMessage(conversation, ChatMessage(role = ChatMessage.Role.USER, textContent = input)) + } + + private fun sendToolText(toolText: String) { + appendToolMessage(toolText) + val modelRunner = checkNotNull(modelRunner.value) + val conversation = getOrRestoreConversation(modelRunner) + sendMessage(conversation, ChatMessage(role = ChatMessage.Role.TOOL, textContent = toolText)) + } + + private fun sendMessage(conversation: Conversation, message: ChatMessage) { + job = + lifecycleScope.launch { + val generateTextBuffer = StringBuilder() + val generatedReasoningBuffer = StringBuilder() + val functionCallsToInvoke = mutableListOf() + // Generate the response + conversation + .generateResponse(message) + .onEach { + when (it) { + is MessageResponse.Chunk -> { + generateTextBuffer.append(it.text) + } + + is MessageResponse.ReasoningChunk -> { + generatedReasoningBuffer.append(it.reasoning) + } + + is MessageResponse.FunctionCalls -> { + it.functionCalls.forEach { call -> + generatedReasoningBuffer.append("Calling tool: ${call.name}") + functionCallsToInvoke.add(call) + } + } - /** - * Retrieve the conversation history from the serialized JSON string. - */ - private fun getConversationHistory(): List? { - val jsonStr = conversationHistoryJSONString - if (jsonStr == null) { - return null - } - return json.decodeFromString>(jsonStr) - } + is MessageResponse.Error -> throw it.throwable - /** - * Load displayed chat history from the instance state bundle. - */ - private fun loadState(state: Bundle) { - conversationHistoryJSONString = state.getString("history-json") - - if (conversationHistoryJSONString != null) { - getConversationHistory()?.map { - val textContent = it.content.joinToString { (it as ChatMessageContent.Text).text } - ChatMessageDisplayItem( - role = it.role, - text = textContent, - reasoning = it.reasoningContent - ) - }?.let { - chatMessageHistory.value = it + else -> {} } + updateLastAssistantMessage( + generateTextBuffer.toString(), + generatedReasoningBuffer.toString(), + ) + } + .onCompletion { + this@MainActivity.isInGeneration.value = false + conversationHistoryJSONString = json.encodeToString(conversation.history) + } + .catch { exception -> + Log.e(this@MainActivity::class.java.simpleName, "Error in generation: $exception") + updateLastAssistantMessage( + content = + generateTextBuffer.toString() + + "\n\n" + + getString(R.string.error_generation_failed, exception.message ?: "unknown"), + reasoning = generatedReasoningBuffer.toString().takeIf { it.isNotBlank() }, + ) + } + .collect() + if (functionCallsToInvoke.isNotEmpty()) { + processFunctionCalls(functionCallsToInvoke) + } + } + } + + private fun processFunctionCalls(functionCalls: List) { + for (call in functionCalls) { + when (call.name) { + "compute_sum" -> { + // LeapFunctionCall.arguments is typed as Map; the runtime + // value for an array parameter is a List of the parsed elements. The cast + // to List is unchecked because of JVM type erasure, but the + // function descriptor we registered constrains the element type. Items + // failing toDoubleOrNull() are coerced to 0.0 below as a safety net. + @Suppress("UNCHECKED_CAST") + val numbers = call.arguments["values"] as? List ?: listOf() + var sum = 0.0 + for (v in numbers) { + sum += v.toDoubleOrNull() ?: 0.0 + } + sendToolText("Sum = $sum") } - } - /** - * Append a user message to the history - */ - private fun appendUserMessage(content: String) { - val chatMessageHistoryValue = chatMessageHistory.value - val newMessage = ChatMessageDisplayItem(ChatMessage.Role.USER, text = content) - if (chatMessageHistoryValue.isNullOrEmpty()) { - chatMessageHistory.value = listOf(newMessage) - } else { - chatMessageHistory.value = chatMessageHistoryValue + listOf(newMessage) + else -> { + sendToolText("Tool: ${call.name} is not available") } + } } - - /** - * Append a user message to the history - */ - private fun appendToolMessage(content: String) { - val chatMessageHistoryValue = chatMessageHistory.value - val newMessage = ChatMessageDisplayItem(ChatMessage.Role.TOOL, text = content) - if (chatMessageHistoryValue.isNullOrEmpty()) { - chatMessageHistory.value = listOf(newMessage) + } + + /** + * In case `conversation` is not available due to the activity destroy, restore it from the + * conversation history. + */ + private fun getOrRestoreConversation(modelRunner: ModelRunner): Conversation { + val conversationInstance = conversation + val conversation = + if (conversationInstance == null) { + val conversationHistory = getConversationHistory() + if (conversationHistory == null) { + modelRunner.createConversation(getString(R.string.chat_system_prompt)) } else { - chatMessageHistory.value = chatMessageHistoryValue + listOf(newMessage) + modelRunner.createConversationFromHistory(conversationHistory) } + } else { + conversationInstance + } + + if (isToolEnabled.value == true) { + conversation.registerFunction( + LeapFunction( + "compute_sum", + "Compute sum of a series of numbers", + listOf( + LeapFunctionParameter( + name = "values", + type = + LeapFunctionParameterType.LeapArr(itemType = LeapFunctionParameterType.LeapStr()), + description = "Numbers to compute sum. Values should be represented in string.", + ) + ), + ) + ) } - /** - * If the last message is not an assistant message, insert a new one. Otherwise, update that - * assistant message. - */ - private fun updateLastAssistantMessage(content: String, reasoning: String?) { - val chatMessageHistoryValue = chatMessageHistory.value - val newChatMessageHistory = (chatMessageHistoryValue ?: listOf()).toMutableList() - if (newChatMessageHistory.lastOrNull()?.role == ChatMessage.Role.ASSISTANT) { - newChatMessageHistory.removeAt(newChatMessageHistory.lastIndex) + return conversation + } + + /** Retrieve the conversation history from the serialized JSON string. */ + private fun getConversationHistory(): List? { + val jsonStr = conversationHistoryJSONString + if (jsonStr == null) { + return null + } + return json.decodeFromString>(jsonStr) + } + + /** Load displayed chat history from the instance state bundle. */ + private fun loadState(state: Bundle) { + conversationHistoryJSONString = state.getString("history-json") + + if (conversationHistoryJSONString != null) { + getConversationHistory() + ?.map { + val textContent = it.content.joinToString { (it as ChatMessageContent.Text).text } + ChatMessageDisplayItem( + role = it.role, + text = textContent, + reasoning = it.reasoningContent, + ) } - newChatMessageHistory.add( - ChatMessageDisplayItem( - role = ChatMessage.Role.ASSISTANT, - text = content, - reasoning = reasoning, - ), - ) - chatMessageHistory.value = newChatMessageHistory + ?.let { chatMessageHistory.value = it } } - - companion object { - const val MODEL_NAME = "LFM2-350M" - const val QUANTIZATION_SLUG = "Q8_0" + } + + /** Append a user message to the history */ + private fun appendUserMessage(content: String) { + val chatMessageHistoryValue = chatMessageHistory.value + val newMessage = ChatMessageDisplayItem(ChatMessage.Role.USER, text = content) + if (chatMessageHistoryValue.isNullOrEmpty()) { + chatMessageHistory.value = listOf(newMessage) + } else { + chatMessageHistory.value = chatMessageHistoryValue + listOf(newMessage) + } + } + + /** Append a user message to the history */ + private fun appendToolMessage(content: String) { + val chatMessageHistoryValue = chatMessageHistory.value + val newMessage = ChatMessageDisplayItem(ChatMessage.Role.TOOL, text = content) + if (chatMessageHistoryValue.isNullOrEmpty()) { + chatMessageHistory.value = listOf(newMessage) + } else { + chatMessageHistory.value = chatMessageHistoryValue + listOf(newMessage) + } + } + + /** + * If the last message is not an assistant message, insert a new one. Otherwise, update that + * assistant message. + */ + private fun updateLastAssistantMessage(content: String, reasoning: String?) { + val chatMessageHistoryValue = chatMessageHistory.value + val newChatMessageHistory = (chatMessageHistoryValue ?: listOf()).toMutableList() + if (newChatMessageHistory.lastOrNull()?.role == ChatMessage.Role.ASSISTANT) { + newChatMessageHistory.removeAt(newChatMessageHistory.lastIndex) } + newChatMessageHistory.add( + ChatMessageDisplayItem( + role = ChatMessage.Role.ASSISTANT, + text = content, + reasoning = reasoning, + ) + ) + chatMessageHistory.value = newChatMessageHistory + } + + companion object { + const val MODEL_NAME = "LFM2.5-350M" + const val QUANTIZATION_SLUG = "Q8_0" + private const val TAG = "MainActivity" + } } -/** - * The screen to show when the app is loading the model. - */ +/** The screen to show when the app is loading the model. */ @SuppressLint("LocalContextGetResourceValueCall") @Composable fun ModelLoadingIndicator( - modelRunnerState: ModelRunner?, - loadModelAction: (onError: (e: Throwable) -> Unit, onStatusChange: (String) -> Unit) -> Unit, + modelRunnerState: ModelRunner?, + loadModelAction: (onError: (e: Throwable) -> Unit, onStatusChange: (String) -> Unit) -> Unit, ) { - val context = LocalContext.current - var modelLoadingStatusText by remember { mutableStateOf(context.getString(R.string.loading_model_content)) } - LaunchedEffect(modelRunnerState) { - if (modelRunnerState == null) { - loadModelAction({ error -> - modelLoadingStatusText = - context.getString(R.string.loading_model_fail_content, error.message) - }, { status -> - modelLoadingStatusText = status - }) - } - } - Column( - modifier = Modifier - .padding(16.dp) - .fillMaxSize() - .testTag("ModelLoadingIndicator"), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Text( - text = modelLoadingStatusText, - style = MaterialTheme.typography.titleSmall, - textAlign = TextAlign.Center - ) + val context = LocalContext.current + var modelLoadingStatusText by remember { + mutableStateOf(context.getString(R.string.loading_model_content)) + } + LaunchedEffect(modelRunnerState) { + if (modelRunnerState == null) { + loadModelAction( + { error -> + modelLoadingStatusText = + context.getString(R.string.loading_model_fail_content, error.message) + }, + { status -> modelLoadingStatusText = status }, + ) } -} \ No newline at end of file + } + Column( + modifier = Modifier.padding(16.dp).fillMaxSize().testTag("ModelLoadingIndicator"), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = modelLoadingStatusText, + style = MaterialTheme.typography.titleSmall, + textAlign = TextAlign.Center, + ) + } +} diff --git a/Android/LeapChat/app/src/main/java/ai/liquid/leapchat/models/ChatMessageDisplayItem.kt b/Android/LeapChat/app/src/main/java/ai/liquid/leapchat/models/ChatMessageDisplayItem.kt index ea4fdaa..4286961 100644 --- a/Android/LeapChat/app/src/main/java/ai/liquid/leapchat/models/ChatMessageDisplayItem.kt +++ b/Android/LeapChat/app/src/main/java/ai/liquid/leapchat/models/ChatMessageDisplayItem.kt @@ -2,4 +2,8 @@ package ai.liquid.leapchat.models import ai.liquid.leap.message.ChatMessage -data class ChatMessageDisplayItem(val role: ChatMessage.Role, val text: String, val reasoning: String? = null) +data class ChatMessageDisplayItem( + val role: ChatMessage.Role, + val text: String, + val reasoning: String? = null, +) diff --git a/Android/LeapChat/app/src/main/java/ai/liquid/leapchat/views/AssistantMessage.kt b/Android/LeapChat/app/src/main/java/ai/liquid/leapchat/views/AssistantMessage.kt index 68d5a79..cc8598e 100644 --- a/Android/LeapChat/app/src/main/java/ai/liquid/leapchat/views/AssistantMessage.kt +++ b/Android/LeapChat/app/src/main/java/ai/liquid/leapchat/views/AssistantMessage.kt @@ -19,44 +19,55 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @Composable -fun AssistantMessage( - text: String, - reasoningText: String?, -) { - val reasoningText = reasoningText?.trim() - Row(modifier = Modifier.padding(all = 8.dp).fillMaxWidth(1.0f).testTag("AssistantMessageView"), horizontalArrangement = Arrangement.Absolute.Left) { - Image( - painter = painterResource(R.drawable.smart_toy_outline), - contentDescription = "Assistant icon", - modifier = - Modifier.size(36.dp) - .border(1.5.dp, MaterialTheme.colorScheme.secondary), +fun AssistantMessage(text: String, reasoningText: String?) { + val reasoningText = reasoningText?.trim() + Row( + modifier = Modifier.padding(all = 8.dp).fillMaxWidth(1.0f).testTag("AssistantMessageView"), + horizontalArrangement = Arrangement.Absolute.Left, + ) { + Image( + painter = painterResource(R.drawable.smart_toy_outline), + contentDescription = stringResource(R.string.assistant_icon_description), + modifier = Modifier.size(36.dp).border(1.5.dp, MaterialTheme.colorScheme.secondary), + ) + Spacer(modifier = Modifier.width(8.dp)) + Column(horizontalAlignment = Alignment.Start) { + Text( + text = stringResource(R.string.assistant_label), + color = MaterialTheme.colorScheme.secondary, + style = MaterialTheme.typography.titleSmall, + ) + if (!reasoningText.isNullOrEmpty()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = reasoningText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.secondary, ) - Spacer(modifier = Modifier.width(8.dp)) - Column(horizontalAlignment = Alignment.Start) { - Text(text = "Assistant", color = MaterialTheme.colorScheme.secondary, style = MaterialTheme.typography.titleSmall) - if (!reasoningText.isNullOrEmpty()) { - Spacer(modifier = Modifier.height(4.dp)) - Text(text = reasoningText, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary) - } - Spacer(modifier = Modifier.height(4.dp)) - Text(text = text, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.testTag("AssistantMessageViewText")) - } + } + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.testTag("AssistantMessageViewText"), + ) } + } } @Preview @Composable fun AssistantMessagePreview() { - AssistantMessage("Hello world!", null) + AssistantMessage("Hello world!", null) } @Preview @Composable fun AssistantMessageWithReasoningPreview() { - AssistantMessage("Hello world!", "I need to be friendly") + AssistantMessage("Hello world!", "I need to be friendly") } diff --git a/Android/LeapChat/app/src/main/java/ai/liquid/leapchat/views/ChatHistory.kt b/Android/LeapChat/app/src/main/java/ai/liquid/leapchat/views/ChatHistory.kt index ddbd1ae..c893a05 100644 --- a/Android/LeapChat/app/src/main/java/ai/liquid/leapchat/views/ChatHistory.kt +++ b/Android/LeapChat/app/src/main/java/ai/liquid/leapchat/views/ChatHistory.kt @@ -16,49 +16,54 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @Composable -fun ChatHistory( - history: List, - modifier: Modifier = Modifier, -) { - val scrollState = rememberLazyListState() - LaunchedEffect(history) { - scrollState.animateScrollToItem(history.count()) - } - LazyColumn(modifier = modifier, state = scrollState) { - items(history) { message -> - Box(modifier = Modifier.padding(vertical = 8.dp)) { - when (message.role) { - ChatMessage.Role.USER -> { - UserMessage(message.text) - } +fun ChatHistory(history: List, modifier: Modifier = Modifier) { + val scrollState = rememberLazyListState() + LaunchedEffect(history) { scrollState.animateScrollToItem(history.count()) } + LazyColumn(modifier = modifier, state = scrollState) { + items(history) { message -> + Box(modifier = Modifier.padding(vertical = 8.dp)) { + when (message.role) { + ChatMessage.Role.USER -> { + UserMessage(message.text) + } - ChatMessage.Role.ASSISTANT -> { - AssistantMessage(message.text, message.reasoning) - } + ChatMessage.Role.ASSISTANT -> { + AssistantMessage(message.text, message.reasoning) + } - ChatMessage.Role.TOOL -> { - ToolMessage(message.text) - } + ChatMessage.Role.TOOL -> { + ToolMessage(message.text) + } - else -> {} - } - } - } - item { - Spacer(modifier = Modifier.width(8.dp)) + else -> {} } + } } + item { Spacer(modifier = Modifier.width(8.dp)) } + } } @Preview @Composable fun ChatHistoryPreview() { - ChatHistory( - listOf( - ChatMessageDisplayItem(ChatMessage.Role.USER, text = "Hello robot!", reasoning = null), - ChatMessageDisplayItem(ChatMessage.Role.ASSISTANT, text = "Hello user!", reasoning = "I should be friendly"), - ChatMessageDisplayItem(ChatMessage.Role.USER, text = "Are you really a robot or a human?", reasoning = null), - ChatMessageDisplayItem(ChatMessage.Role.ASSISTANT, text = "I am a language model.", reasoning = "I should be accurate"), - ), + ChatHistory( + listOf( + ChatMessageDisplayItem(ChatMessage.Role.USER, text = "Hello robot!", reasoning = null), + ChatMessageDisplayItem( + ChatMessage.Role.ASSISTANT, + text = "Hello user!", + reasoning = "I should be friendly", + ), + ChatMessageDisplayItem( + ChatMessage.Role.USER, + text = "Are you really a robot or a human?", + reasoning = null, + ), + ChatMessageDisplayItem( + ChatMessage.Role.ASSISTANT, + text = "I am a language model.", + reasoning = "I should be accurate", + ), ) + ) } diff --git a/Android/LeapChat/app/src/main/java/ai/liquid/leapchat/views/ToolMessage.kt b/Android/LeapChat/app/src/main/java/ai/liquid/leapchat/views/ToolMessage.kt index 6443bd8..7147b33 100644 --- a/Android/LeapChat/app/src/main/java/ai/liquid/leapchat/views/ToolMessage.kt +++ b/Android/LeapChat/app/src/main/java/ai/liquid/leapchat/views/ToolMessage.kt @@ -18,27 +18,29 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @Composable -fun ToolMessage( - text: String, -) { - Row(modifier = Modifier.padding(all = 8.dp).fillMaxWidth(1.0f), horizontalArrangement = Arrangement.Absolute.Left) { - Image( - painter = painterResource(R.drawable.plugin_outline), - contentDescription = "Tool icon", - modifier = - Modifier.size(36.dp) - .border(1.5.dp, MaterialTheme.colorScheme.secondary), - ) - Spacer(modifier = Modifier.width(8.dp)) - Column(horizontalAlignment = Alignment.Start) { - Text(text = "Tool", color = MaterialTheme.colorScheme.secondary, style = MaterialTheme.typography.titleSmall) - Spacer(modifier = Modifier.height(4.dp)) - Text(text = text, style = MaterialTheme.typography.bodyMedium) - } +fun ToolMessage(text: String) { + Row( + modifier = Modifier.padding(all = 8.dp).fillMaxWidth(1.0f), + horizontalArrangement = Arrangement.Absolute.Left, + ) { + Image( + painter = painterResource(R.drawable.plugin_outline), + contentDescription = stringResource(R.string.tool_icon_description), + modifier = Modifier.size(36.dp).border(1.5.dp, MaterialTheme.colorScheme.secondary), + ) + Spacer(modifier = Modifier.width(8.dp)) + Column(horizontalAlignment = Alignment.Start) { + Text( + text = stringResource(R.string.tool_label), + color = MaterialTheme.colorScheme.secondary, + style = MaterialTheme.typography.titleSmall, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text(text = text, style = MaterialTheme.typography.bodyMedium) } + } } - diff --git a/Android/LeapChat/app/src/main/java/ai/liquid/leapchat/views/UserMessage.kt b/Android/LeapChat/app/src/main/java/ai/liquid/leapchat/views/UserMessage.kt index bab984e..e60d7d0 100644 --- a/Android/LeapChat/app/src/main/java/ai/liquid/leapchat/views/UserMessage.kt +++ b/Android/LeapChat/app/src/main/java/ai/liquid/leapchat/views/UserMessage.kt @@ -21,45 +21,45 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @Composable fun UserMessage(text: String) { - Box { - Column( - horizontalAlignment = Alignment.End, - modifier = Modifier.fillMaxWidth(1.0f).padding(start = 8.dp, end = 54.dp), - ) { - Text( - text = "User", - color = MaterialTheme.colorScheme.secondary, - style = MaterialTheme.typography.titleSmall, - ) - Spacer(modifier = Modifier.height(4.dp)) - Text(text = text, style = MaterialTheme.typography.bodyMedium) - } - Row( - modifier = Modifier.padding(all = 8.dp).fillMaxWidth(1.0f), - horizontalArrangement = Arrangement.Absolute.Right, - verticalAlignment = Alignment.Top, - ) { - Spacer(modifier = Modifier.width(8.dp).requiredWidthIn(8.dp, 8.dp)) - Image( - painter = painterResource(R.drawable.baseline_person_outline), - contentDescription = "User icon", - modifier = - Modifier.size(36.dp).requiredSize(36.dp) - .border(1.5.dp, MaterialTheme.colorScheme.primary), - ) - } + Box { + Column( + horizontalAlignment = Alignment.End, + modifier = Modifier.fillMaxWidth(1.0f).padding(start = 8.dp, end = 54.dp), + ) { + Text( + text = stringResource(R.string.user_label), + color = MaterialTheme.colorScheme.secondary, + style = MaterialTheme.typography.titleSmall, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text(text = text, style = MaterialTheme.typography.bodyMedium) } + Row( + modifier = Modifier.padding(all = 8.dp).fillMaxWidth(1.0f), + horizontalArrangement = Arrangement.Absolute.Right, + verticalAlignment = Alignment.Top, + ) { + Spacer(modifier = Modifier.width(8.dp).requiredWidthIn(8.dp, 8.dp)) + Image( + painter = painterResource(R.drawable.baseline_person_outline), + contentDescription = stringResource(R.string.user_icon_description), + modifier = + Modifier.size(36.dp).requiredSize(36.dp).border(1.5.dp, MaterialTheme.colorScheme.primary), + ) + } + } } @Preview @Composable fun UserMessagePreview() { - UserMessage( - "Hello world! Hello world! Hello world! Hello world! Very long text is here to explode everything!Hello world! Hello world! Hello world! Hello world! Very long text is here to explode everything! ", - ) + UserMessage( + "Hello world! Hello world! Hello world! Hello world! Very long text is here to explode everything!Hello world! Hello world! Hello world! Hello world! Very long text is here to explode everything! " + ) } diff --git a/Android/LeapChat/app/src/main/res/values/strings.xml b/Android/LeapChat/app/src/main/res/values/strings.xml index 359056d..d6f0e98 100644 --- a/Android/LeapChat/app/src/main/res/values/strings.xml +++ b/Android/LeapChat/app/src/main/res/values/strings.xml @@ -8,4 +8,16 @@ Stop Tool ON Tool OFF + Downloading Chat Model + Model Ready! + Starting download... + Downloading model: %1$d%% (%2$d MB / %3$d MB) + Download complete! + Assistant + Assistant icon + User + User icon + Tool + Tool icon + Error: %1$s \ No newline at end of file diff --git a/Android/LeapChat/gradle/libs.versions.toml b/Android/LeapChat/gradle/libs.versions.toml index d3a7ee5..aee4b9e 100644 --- a/Android/LeapChat/gradle/libs.versions.toml +++ b/Android/LeapChat/gradle/libs.versions.toml @@ -1,16 +1,17 @@ [versions] agp = "8.13.2" -kotlin = "2.3.20" +kotlin = "2.3.21" coreKtx = "1.17.0" junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" -leapSdk = "0.10.7" +leapSdk = "0.10.8" lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.12.3" composeBom = "2026.01.01" ktlint = "12.3.0" kotlinxSerialization = "1.10.0" +ktfmt = "0.25.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -38,3 +39,4 @@ kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } org-jlleitschuh-gradle-ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } +com-ncorti-ktfmt-gradle = { id = "com.ncorti.ktfmt.gradle", version.ref = "ktfmt" } diff --git a/Android/LeapKoogAgent/README.md b/Android/LeapKoogAgent/README.md index 70316df..4d60506 100644 --- a/Android/LeapKoogAgent/README.md +++ b/Android/LeapKoogAgent/README.md @@ -1,40 +1,191 @@ # LeapKoogAgent Android Example -This is an example Android application demonstrating the integration of the [Koog framework](https://docs.koog.ai) with Leap SDK. The app showcases how to use Koog's AI agent capabilities within an Android environment. +An Android demo that wires the [Koog](https://docs.koog.ai) agent framework on top of the Leap SDK, running a tool-using LLM agent entirely on-device. Two sample agents are included: a calculator and a weather forecaster (backed by the Open-Meteo public API). + +The Leap ↔ Koog bridge is provided by the `koog-edge` AAR (vendored at `app/libs/koog-edge-0.0.1.aar`), which exposes Koog `LLMClient` / `PromptExecutor` interfaces backed by a Leap `ModelRunner`. Koog handles strategy graphs, tool invocation, and event handling; Leap handles inference. ## Features -- Integration with Koog framework for AI agent functionality -- Example usage of Leap SDK -- Demonstrates event handling and agent communication -## Getting Started +- **Koog agent strategy graphs** — Calculator uses a single-tool-call loop (`nodeLLMRequest` → `nodeExecuteTool` → `nodeLLMSendToolResult`); Weather uses parallel multi-tool calls (`nodeLLMRequestMultiple` → `nodeExecuteMultipleTools` → `nodeLLMSendMultipleToolResults`) +- **Tool registration** via `ToolRegistry { tool(...) }`, with `@Serializable` `Args` / `Result` types and `@LLMDescription` annotations so Koog can generate schemas for the model +- **Custom Leap-backed `LLMClient`** — `getLeapLLMClient(modelsPath)` from `koog-edge` returns a Koog `LLMClient` that proxies to a Leap `ModelRunner` loaded from a local bundle +- **Streaming Compose UI** with per-tool screens, tool-call event log, and a navigation service +- **MVI view models** with `StateFlow` state and a sealed `Event` interface + +## Model -### Prerequisites -- Android Studio -- Android device or emulator +- **LFM2-1.2B-Tool** (referenced as `LeapModels.Chat.LFM2_1_2B_Tool` in `koog-edge`) — a 1.2 B parameter LFM2 variant trained for tool / function calling. +- **Manual bundle push required.** Unlike the other Android demos in this repo, LeapKoogAgent does NOT use `LeapModelDownloader`. The `koog-edge` bridge loads the model from a fixed on-device directory — `/tmp/models` — defined in [`agents/common/Common.kt`](app/src/main/kotlin/ai/liquid/koogleapsdk/agents/common/Common.kt). -### Model Installation -To use the AI agent, you need to push the model file to your device: +Push the bundle once before running: +```bash +adb push lfm2-1.2b-tool.bundle /tmp/models/ ``` -adb push lfm2-1.2b-tool.bundle /tmp/models + +The exact filename `koog-edge` expects is the one referenced by `LeapModels.Chat.LFM2_1_2B_Tool` — keep the file name as `lfm2-1.2b-tool.bundle` to match. + +## Prerequisites + +- Android Studio Hedgehog (2023.1.1) or newer +- JDK 17 (the app module uses `jvmToolchain(17)`; JDK 21 via SDKman also works for the outer Gradle build: `JAVA_HOME=~/.sdkman/candidates/java/21.0.9-zulu`) +- Android device or emulator running API 31 (Android 12) or higher +- `adb` on PATH for the model push +- Network access on the device (the weather agent calls the Open-Meteo public API; the app declares `INTERNET`) +- The `koog-edge-0.0.1.aar` file at `app/libs/`. The Gradle dependency declares `implementation(files("libs/koog-edge-0.0.1.aar"))`, so place the AAR there before building. + +## Running + +```bash +# 1. push the model bundle once +adb push lfm2-1.2b-tool.bundle /tmp/models/ + +# 2. install + launch +./gradlew installDebug ``` -This command copies the model bundle to the `/tmp/models` directory on your Android device. Make sure `adb` is installed and your device is connected. +From the tools list screen pick **Calculator** or **Weather Forecast**, type a query, and watch the agent reason and invoke tools. + +## Project Structure + +``` +app/src/main/kotlin/ai/liquid/koogleapsdk/ +├── App.kt # Application singleton + context provider +├── MainActivity.kt # Compose entry point +├── agents/ +│ ├── common/ +│ │ ├── AgentProvider.kt # Interface for agent factories +│ │ ├── Common.kt # modelsPath = "/tmp/models" +│ │ └── ExitTool.kt # Shared "exit conversation" tool +│ ├── calculator/ +│ │ ├── CalculatorAgentProvider.kt # Koog strategy + LeapLLM wiring +│ │ └── CalculatorTools.kt # Plus / Minus / Multiply / Divide tools +│ └── weather/ +│ ├── WeatherAgentProvider.kt # Multi-tool parallel strategy +│ ├── WeatherTools.kt # current_datetime, add_datetime, weather_forecast +│ └── OpenMeteoClient.kt # HTTP client for open-meteo.com +└── ui/ + ├── navigation/ # In-app navigation service + ├── common/MviViewModel.kt # State + Event base class + └── screen/ + ├── MainScreen.kt + ├── toolsList/ # Agent picker + ├── calculatorTool/ # Calculator agent UI + └── weatherTool/ # Weather agent UI +``` + +## Key SDK Patterns + +### Wiring Leap as Koog's LLM backend + +`koog-edge` exposes a `getLeapLLMClient(modelsPath)` factory that loads a Leap `ModelRunner` from a local bundle directory and adapts it to Koog's `LLMClient` interface. Wrap it in a `SingleLLMPromptExecutor` and Koog drives the rest: + +```kotlin +import io.github.lemcoder.koog.edge.leap.LeapLLMParams +import io.github.lemcoder.koog.edge.leap.LeapModels +import io.github.lemcoder.koog.edge.leap.getLeapLLMClient +import ai.koog.prompt.executor.llms.SingleLLMPromptExecutor + +val leapExecutor = SingleLLMPromptExecutor(getLeapLLMClient(modelsPath)) +// modelsPath = "/tmp/models" — koog-edge resolves the bundle for +// LeapModels.Chat.LFM2_1_2B_Tool inside that directory. +``` + +### Registering tools with `@LLMDescription` + +Each tool is a Koog `Tool` with `@Serializable` arg/result types. `@LLMDescription` annotations are surfaced to the model in the auto-generated schema: + +```kotlin +abstract class CalculatorTool( + override val name: String, + override val description: String, +) : Tool() { + @Serializable + data class Args( + @property:LLMDescription("First number") val a: Float, + @property:LLMDescription("Second number") val b: Float, + ) -### Building and Running -1. Clone this repository. -2. Open in Android Studio. -3. Build and run the app on your device or emulator. + @Serializable + class Result(val result: Float) + + final override val argsSerializer = Args.serializer() + final override val resultSerializer = Result.serializer() +} + +object PlusTool : CalculatorTool(name = "plus", description = "Adds a and b") { + override suspend fun execute(args: Args) = Result(args.a + args.b) +} +``` + +Tools are then bundled into a `ToolRegistry`: + +```kotlin +val toolRegistry = ToolRegistry { + tool(CalculatorTools.PlusTool) + tool(CalculatorTools.MinusTool) + tool(CalculatorTools.DivideTool) + tool(CalculatorTools.MultiplyTool) + tool(ExitTool) +} +``` + +### Building an agent with a strategy graph + +Koog strategies describe the LLM ↔ tool loop as a graph of nodes connected by `edge(... forwardTo ...)`. The calculator example: + +```kotlin +val strategy = strategy(title) { + val nodeRequestLLM by nodeLLMRequest() + val nodeToolExecute by nodeExecuteTool() + val nodeSendToolResult by nodeLLMSendToolResult() + + edge(nodeStart forwardTo nodeRequestLLM) + edge(nodeRequestLLM forwardTo nodeToolExecute onToolCall { true }) + edge(nodeToolExecute forwardTo nodeSendToolResult) + edge(nodeSendToolResult forwardTo nodeFinish onAssistantMessage { true }) +} + +val agentConfig = AIAgentConfig( + prompt = prompt("calc", params = LeapLLMParams(temperature = 0f)) { + system("You are a calculator. Use tools at your disposal to solve it.") + }, + model = LeapModels.Chat.LFM2_1_2B_Tool, + maxAgentIterations = 10, +) + +val agent = AIAgent( + promptExecutor = leapExecutor, + strategy = strategy, + agentConfig = agentConfig, + toolRegistry = toolRegistry, +) { + handleEvents { + onToolCallStarting { ctx -> /* forward to UI */ } + onAgentExecutionFailed { ctx -> /* surface error */ } + } +} +``` + +`agent.run("What is the result of: 12 * 4")` drives the loop end-to-end. + +## Notes / Troubleshooting + +- **Model not found.** Make sure `adb push` placed the bundle in `/tmp/models/` and the filename matches what `LeapModels.Chat.LFM2_1_2B_Tool` expects. On a real device, `/tmp` is the same path the Leap runtime opens directly — there's no host-side rewriting. +- **Stale model file.** Re-push the bundle if you upgraded the SDK or switched model variants. +- **Koog Netty resource conflict.** The Gradle config strips `META-INF/*` via the `packaging` block as a known workaround. Don't remove it until the upstream Koog/Netty issue is resolved. +- **No automatic download.** Adopting `LeapModelDownloader` here would require changes to the `koog-edge` bridge (it currently expects a pre-existing on-device path). The other Android demos in this repo (RecipeGenerator, VLMExample, LeapAudioDemo, etc.) show the `LeapModelDownloader` pattern if you need a reference. ## Koog Framework -[Koog](https://docs.koog.ai) is an open-source framework for building AI agents. It provides tools for: -- Natural language understanding -- Tool invocation -- Context management -- Extensible agent architecture -- Integration with MCP servers (tools retrieval and execution) + +[Koog](https://docs.koog.ai) is an open-source framework for building AI agents with: + +- Strategy DSL for orchestrating multi-step LLM workflows +- Pluggable `LLMClient` / `PromptExecutor` interfaces (this demo plugs Leap in via `koog-edge`) +- Tool registry with auto-generated JSON schemas from `@Serializable` Kotlin types +- Event handlers for observability +- MCP server integration for remote tool sourcing ## License -This project is licensed under the MIT License. +This project is licensed under the MIT License. diff --git a/Android/LeapKoogAgent/app/build.gradle.kts b/Android/LeapKoogAgent/app/build.gradle.kts index 4a4c6dc..f822554 100644 --- a/Android/LeapKoogAgent/app/build.gradle.kts +++ b/Android/LeapKoogAgent/app/build.gradle.kts @@ -1,64 +1,60 @@ plugins { - alias(libs.plugins.android.application) - alias(libs.plugins.kotlin.android) - alias(libs.plugins.kotlin.compose) - alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.com.ncorti.ktfmt.gradle) } +ktfmt { googleStyle() } + android { - namespace = "ai.liquid.koogleapsdk" - compileSdk = libs.versions.compileSdk.get().toInt() + namespace = "ai.liquid.koogleapsdk" + compileSdk = libs.versions.compileSdk.get().toInt() - kotlin { - jvmToolchain(17) - } + kotlin { jvmToolchain(17) } - defaultConfig { - applicationId = "ai.liquid.koog_leap_sdk" - minSdk = libs.versions.minSdk.get().toInt() - versionCode = 1 - versionName = "1.0" + defaultConfig { + applicationId = "ai.liquid.koog_leap_sdk" + minSdk = libs.versions.minSdk.get().toInt() + versionCode = 1 + versionName = "1.0" - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - } + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } - buildTypes { - release { - isMinifyEnabled = false - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" - ) - } + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") } + } - buildFeatures { - compose = true - } + buildFeatures { compose = true } - packaging { - resources { - // TODO remove when netty issue is resolved in koog - excludes.add("META-INF/*") - } + packaging { + resources { + // TODO remove when netty issue is resolved in koog + excludes.add("META-INF/*") } + } } dependencies { - implementation(libs.androidx.core.ktx) - implementation(libs.androidx.lifecycle.runtime.ktx) - implementation(libs.androidx.activity.compose) - implementation(platform(libs.androidx.compose.bom)) - implementation(libs.androidx.compose.ui) - implementation(libs.androidx.compose.ui.graphics) - implementation(libs.androidx.compose.ui.tooling.preview) - implementation(libs.androidx.compose.material3) - androidTestImplementation(platform(libs.androidx.compose.bom)) - debugImplementation(libs.androidx.compose.ui.tooling) - implementation(libs.koog.agents) - implementation(files("libs/koog-edge-0.0.1.aar")) - implementation(libs.androidx.lifecycle.viewmodel.compose) - - implementation(libs.kotlinx.serialization.core) - implementation(libs.kotlinx.serialization.json) -} \ No newline at end of file + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material3) + androidTestImplementation(platform(libs.androidx.compose.bom)) + debugImplementation(libs.androidx.compose.ui.tooling) + implementation(libs.koog.agents) + implementation(files("libs/koog-edge-0.0.1.aar")) + implementation(libs.androidx.lifecycle.viewmodel.compose) + + implementation(libs.kotlinx.serialization.core) + implementation(libs.kotlinx.serialization.json) +} diff --git a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/App.kt b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/App.kt index 3c9300e..479855d 100644 --- a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/App.kt +++ b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/App.kt @@ -5,14 +5,15 @@ import android.content.Context class App : Application() { - override fun onCreate() { - super.onCreate() - instance = this@App - } + override fun onCreate() { + super.onCreate() + instance = this@App + } - companion object { - internal lateinit var instance: App - private set - val context: Context by lazy { instance.applicationContext } - } -} \ No newline at end of file + companion object { + internal lateinit var instance: App + private set + + val context: Context by lazy { instance.applicationContext } + } +} diff --git a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/MainActivity.kt b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/MainActivity.kt index 02d8c58..77a00f4 100644 --- a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/MainActivity.kt +++ b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/MainActivity.kt @@ -11,15 +11,15 @@ import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme class MainActivity : ComponentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdge() - setContent { - MaterialTheme( - colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme() - ) { - MainScreen() - } - } + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + MaterialTheme( + colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme() + ) { + MainScreen() + } } + } } diff --git a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/agents/calculator/CalculatorAgentProvider.kt b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/agents/calculator/CalculatorAgentProvider.kt index 25a4129..01f6b57 100644 --- a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/agents/calculator/CalculatorAgentProvider.kt +++ b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/agents/calculator/CalculatorAgentProvider.kt @@ -20,101 +20,94 @@ import io.github.lemcoder.koog.edge.leap.LeapLLMParams import io.github.lemcoder.koog.edge.leap.LeapModels import io.github.lemcoder.koog.edge.leap.getLeapLLMClient -/** - * Factory for creating calculator agents - */ +/** Factory for creating calculator agents */ internal class CalculatorAgentProvider : AgentProvider { - override val title: String = "Calculator" - override val description: String = "Hi, I'm a calculator agent, I can do math" + override val title: String = "Calculator" + override val description: String = "Hi, I'm a calculator agent, I can do math" - override suspend fun provideAgent( - onToolCallEvent: suspend (String) -> Unit, - onErrorEvent: suspend (String) -> Unit, - onAssistantMessage: suspend (String) -> String, - ): AIAgent { - val leapExecutor = SingleLLMPromptExecutor(getLeapLLMClient(modelsPath)) + override suspend fun provideAgent( + onToolCallEvent: suspend (String) -> Unit, + onErrorEvent: suspend (String) -> Unit, + onAssistantMessage: suspend (String) -> String, + ): AIAgent { + val leapExecutor = SingleLLMPromptExecutor(getLeapLLMClient(modelsPath)) - // Create tool registry with calculator tools - val toolRegistry = ToolRegistry { - tool(CalculatorTools.PlusTool) - tool(CalculatorTools.MinusTool) - tool(CalculatorTools.DivideTool) - tool(CalculatorTools.MultiplyTool) + // Create tool registry with calculator tools + val toolRegistry = ToolRegistry { + tool(CalculatorTools.PlusTool) + tool(CalculatorTools.MinusTool) + tool(CalculatorTools.DivideTool) + tool(CalculatorTools.MultiplyTool) - tool(ExitTool) - } + tool(ExitTool) + } - @Suppress("DuplicatedCode") - val strategy = strategy(title) { - val nodeRequestLLM by nodeLLMRequest() - val nodeToolExecute by nodeExecuteTool() - val nodeSendToolResult by nodeLLMSendToolResult() + @Suppress("DuplicatedCode") + val strategy = + strategy(title) { + val nodeRequestLLM by nodeLLMRequest() + val nodeToolExecute by nodeExecuteTool() + val nodeSendToolResult by nodeLLMSendToolResult() - edge(nodeStart forwardTo nodeRequestLLM) + edge(nodeStart forwardTo nodeRequestLLM) - edge( - nodeRequestLLM forwardTo nodeToolExecute - onToolCall { ctx -> - onToolCallEvent("Tool ${ctx.tool}") - true - } - ) + edge( + nodeRequestLLM forwardTo + nodeToolExecute onToolCall + { ctx -> + onToolCallEvent("Tool ${ctx.tool}") + true + } + ) - edge( - nodeToolExecute forwardTo nodeSendToolResult - ) + edge(nodeToolExecute forwardTo nodeSendToolResult) - edge( - nodeSendToolResult forwardTo nodeFinish - onAssistantMessage { ctx -> - onAssistantMessage(ctx.content) - true - } - ) - } - - // Create agent config with proper prompt - val agentConfig = AIAgentConfig( - prompt = prompt( - "test", - params = LeapLLMParams( - temperature = 0f - ) - ) { - system( - """ - You are a calculator. - You will be provided a single math problem by the user. - Use tools at your disposal to solve it. - If you reference the result of a tool call in your answer, always explain it to the user in a clear sentence, e.g. 'The result is 4.' - Never assume the user can see the raw tool result. - """.trimIndent() - ) - }, - model = LeapModels.Chat.LFM2_1_2B_Tool, - maxAgentIterations = 10, + edge( + nodeSendToolResult forwardTo + nodeFinish onAssistantMessage + { ctx -> + onAssistantMessage(ctx.content) + true + } ) + } - // Return the agent - return AIAgent( - promptExecutor = leapExecutor, - strategy = strategy, - agentConfig = agentConfig, - toolRegistry = toolRegistry, - ) { - handleEvents { - onToolCallStarting { ctx -> - onToolCallEvent("Tool ${ctx.tool.name}, args ${ctx.toolArgs}") - } + // Create agent config with proper prompt + val agentConfig = + AIAgentConfig( + prompt = + prompt("test", params = LeapLLMParams(temperature = 0f)) { + system( + """ + You are a calculator. + You will be provided a single math problem by the user. + Use tools at your disposal to solve it. + If you reference the result of a tool call in your answer, always explain it to the user in a clear sentence, e.g. 'The result is 4.' + Never assume the user can see the raw tool result. + """ + .trimIndent() + ) + }, + model = LeapModels.Chat.LFM2_1_2B_Tool, + maxAgentIterations = 10, + ) - onAgentExecutionFailed { ctx -> - onErrorEvent("${ctx.throwable.message}") - } + // Return the agent + return AIAgent( + promptExecutor = leapExecutor, + strategy = strategy, + agentConfig = agentConfig, + toolRegistry = toolRegistry, + ) { + handleEvents { + onToolCallStarting { ctx -> onToolCallEvent("Tool ${ctx.tool.name}, args ${ctx.toolArgs}") } - onAgentCompleted { ctx -> - // Skip finish event handling - } - } + onAgentExecutionFailed { ctx -> onErrorEvent("${ctx.throwable.message}") } + + onAgentCompleted { ctx -> + // Skip finish event handling } + } } -} \ No newline at end of file + } +} diff --git a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/agents/calculator/CalculatorTools.kt b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/agents/calculator/CalculatorTools.kt index 4bd1ebb..b460519 100644 --- a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/agents/calculator/CalculatorTools.kt +++ b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/agents/calculator/CalculatorTools.kt @@ -5,63 +5,43 @@ import ai.koog.agents.core.tools.annotations.LLMDescription import kotlinx.serialization.Serializable object CalculatorTools { - abstract class CalculatorTool( - override val name: String, - override val description: String, - ) : Tool() { - @Serializable - data class Args( - @property:LLMDescription("First number")// "Second number" - val a: Float, - @property:LLMDescription("Second number") - val b: Float - ) - - @Serializable - class Result(val result: Float) - - final override val argsSerializer = Args.serializer() - final override val resultSerializer = Result.serializer() - } - - /** - * 2. Implement the tool (tools). - */ - - object PlusTool : CalculatorTool( - name = "plus", - description = "Adds a and b", - ) { - override suspend fun execute(args: Args): Result { - return Result(args.a + args.b) - } + abstract class CalculatorTool(override val name: String, override val description: String) : + Tool() { + @Serializable + data class Args( + @property:LLMDescription("First number") // "Second number" + val a: Float, + @property:LLMDescription("Second number") val b: Float, + ) + + @Serializable class Result(val result: Float) + + final override val argsSerializer = Args.serializer() + final override val resultSerializer = Result.serializer() + } + + /** 2. Implement the tool (tools). */ + object PlusTool : CalculatorTool(name = "plus", description = "Adds a and b") { + override suspend fun execute(args: Args): Result { + return Result(args.a + args.b) } + } - object MinusTool : CalculatorTool( - name = "minus", - description = "Subtracts b from a", - ) { - override suspend fun execute(args: Args): Result { - return Result(args.a - args.b) - } + object MinusTool : CalculatorTool(name = "minus", description = "Subtracts b from a") { + override suspend fun execute(args: Args): Result { + return Result(args.a - args.b) } + } - object DivideTool : CalculatorTool( - name = "divide", - description = "Divides a and b", - ) { - override suspend fun execute(args: Args): Result { - return Result(args.a / args.b) - } + object DivideTool : CalculatorTool(name = "divide", description = "Divides a and b") { + override suspend fun execute(args: Args): Result { + return Result(args.a / args.b) } + } - object MultiplyTool : CalculatorTool( - name = "multiply", - description = "Multiplies a and b", - ) { - override suspend fun execute(args: Args): Result { - return Result(args.a * args.b) - } + object MultiplyTool : CalculatorTool(name = "multiply", description = "Multiplies a and b") { + override suspend fun execute(args: Args): Result { + return Result(args.a * args.b) } - -} \ No newline at end of file + } +} diff --git a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/agents/common/AgentProvider.kt b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/agents/common/AgentProvider.kt index fa21c83..6b15596 100644 --- a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/agents/common/AgentProvider.kt +++ b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/agents/common/AgentProvider.kt @@ -2,23 +2,17 @@ package ai.liquid.koogleapsdk.agents.common import ai.koog.agents.core.agent.AIAgent -/** - * Interface for agent factory - */ +/** Interface for agent factory */ interface AgentProvider { - /** - * Title for the agent demo screen - */ - val title: String + /** Title for the agent demo screen */ + val title: String - /** - * Description of the agent - */ - val description: String + /** Description of the agent */ + val description: String - suspend fun provideAgent( - onToolCallEvent: suspend (String) -> Unit, - onErrorEvent: suspend (String) -> Unit, - onAssistantMessage: suspend (String) -> String - ): AIAgent -} \ No newline at end of file + suspend fun provideAgent( + onToolCallEvent: suspend (String) -> Unit, + onErrorEvent: suspend (String) -> Unit, + onAssistantMessage: suspend (String) -> String, + ): AIAgent +} diff --git a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/agents/common/Common.kt b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/agents/common/Common.kt index 98a54ef..6e0f51c 100644 --- a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/agents/common/Common.kt +++ b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/agents/common/Common.kt @@ -1,3 +1,14 @@ package ai.liquid.koogleapsdk.agents.common -const val modelsPath = "/tmp/models" \ No newline at end of file +import ai.liquid.koogleapsdk.App +import java.io.File + +/** + * Resolves the per-install directory where Koog/Leap downloads and caches model bundles. + * + * Uses [App.context.filesDir] — the app's internal storage — which is writable on Android. + * Previously this was `/tmp/models`, which only happens to be writable on a JVM host; on Android + * `/tmp` does not exist, so any agent that loads a model would fail at runtime. + */ +val modelsPath: String + get() = File(App.context.filesDir, "models").absolutePath diff --git a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/agents/common/ExitTool.kt b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/agents/common/ExitTool.kt index a6e83eb..c0d8e5b 100644 --- a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/agents/common/ExitTool.kt +++ b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/agents/common/ExitTool.kt @@ -5,17 +5,18 @@ import ai.koog.agents.core.tools.annotations.LLMDescription import kotlinx.serialization.Serializable import kotlinx.serialization.builtins.serializer - object ExitTool : SimpleTool() { - @Serializable - data class Args( - @property:LLMDescription("The result of the agent session. Default is empty, if there's no particular result.") - val result: String = "" + @Serializable + data class Args( + @property:LLMDescription( + "The result of the agent session. Default is empty, if there's no particular result." ) + val result: String = "" + ) - override val argsSerializer = Args.serializer() - override val description: String = - "Exit the agent session with the specified result. Call this tool to finish the conversation with the user." + override val argsSerializer = Args.serializer() + override val description: String = + "Exit the agent session with the specified result. Call this tool to finish the conversation with the user." - override suspend fun doExecute(args: Args): String = args.result -} \ No newline at end of file + override suspend fun doExecute(args: Args): String = args.result +} diff --git a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/agents/weather/OpenMeteoClient.kt b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/agents/weather/OpenMeteoClient.kt index 129d07e..6ff2d96 100644 --- a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/agents/weather/OpenMeteoClient.kt +++ b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/agents/weather/OpenMeteoClient.kt @@ -10,125 +10,120 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json -/** - * Client for interacting with the Open Meteo API - */ +/** Client for interacting with the Open Meteo API */ class OpenMeteoClient { - private val client = HttpClient { - install(ContentNegotiation) { - json(Json { - ignoreUnknownKeys = true - coerceInputValues = true - }) + private val client = HttpClient { + install(ContentNegotiation) { + json( + Json { + ignoreUnknownKeys = true + coerceInputValues = true } + ) } + } - /** - * Search for locations by name - * @param name The name of the location to search for - * @param count The maximum number of results to return - * @return A list of locations matching the search query - */ - suspend fun searchLocation(name: String, count: Int = 10): List { - val response: GeocodingResponse = - client.get("https://geocoding-api.open-meteo.com/v1/search") { - parameter("name", name) - parameter("count", count) - parameter("format", "json") - parameter("language", "en") - }.body() + /** + * Search for locations by name + * + * @param name The name of the location to search for + * @param count The maximum number of results to return + * @return A list of locations matching the search query + */ + suspend fun searchLocation(name: String, count: Int = 10): List { + val response: GeocodingResponse = + client + .get("https://geocoding-api.open-meteo.com/v1/search") { + parameter("name", name) + parameter("count", count) + parameter("format", "json") + parameter("language", "en") + } + .body() - return response.results ?: emptyList() - } + return response.results ?: emptyList() + } - /** - * Get weather forecast for a location - * @param latitude The latitude of the location - * @param longitude The longitude of the location - * @param forecastDays The number of days to forecast - * @param hourly The hourly weather variables to include - * @param daily The daily weather variables to include - * @param timezone The timezone to use for the forecast - * @return The weather forecast for the location - */ - suspend fun getWeatherForecast( - latitude: Double, - longitude: Double, - forecastDays: Int = 7, - hourly: List = listOf( - "temperature_2m", - "precipitation_probability", - "weather_code" - ), - daily: List = listOf( - "weather_code", - "temperature_2m_max", - "temperature_2m_min", - "precipitation_sum" - ), - timezone: String = "auto" - ): WeatherForecast { - return client.get("https://api.open-meteo.com/v1/forecast") { - parameter("latitude", latitude) - parameter("longitude", longitude) - parameter("forecast_days", forecastDays) - parameter("hourly", hourly.joinToString(",")) - parameter("daily", daily.joinToString(",")) - parameter("timezone", timezone) - }.body() - } + /** + * Get weather forecast for a location + * + * @param latitude The latitude of the location + * @param longitude The longitude of the location + * @param forecastDays The number of days to forecast + * @param hourly The hourly weather variables to include + * @param daily The daily weather variables to include + * @param timezone The timezone to use for the forecast + * @return The weather forecast for the location + */ + suspend fun getWeatherForecast( + latitude: Double, + longitude: Double, + forecastDays: Int = 7, + hourly: List = listOf("temperature_2m", "precipitation_probability", "weather_code"), + daily: List = + listOf("weather_code", "temperature_2m_max", "temperature_2m_min", "precipitation_sum"), + timezone: String = "auto", + ): WeatherForecast { + return client + .get("https://api.open-meteo.com/v1/forecast") { + parameter("latitude", latitude) + parameter("longitude", longitude) + parameter("forecast_days", forecastDays) + parameter("hourly", hourly.joinToString(",")) + parameter("daily", daily.joinToString(",")) + parameter("timezone", timezone) + } + .body() + } } -@Serializable -data class GeocodingResponse( - val results: List? = null -) +@Serializable data class GeocodingResponse(val results: List? = null) @Serializable data class GeocodingResult( - val id: Long, - val name: String, - val latitude: Double, - val longitude: Double, - val elevation: Double? = null, - @SerialName("feature_code") val featureCode: String? = null, - @SerialName("country_code") val countryCode: String? = null, - val timezone: String? = null, - val country: String? = null, - val admin1: String? = null, - val admin2: String? = null, - val admin3: String? = null, - val admin4: String? = null + val id: Long, + val name: String, + val latitude: Double, + val longitude: Double, + val elevation: Double? = null, + @SerialName("feature_code") val featureCode: String? = null, + @SerialName("country_code") val countryCode: String? = null, + val timezone: String? = null, + val country: String? = null, + val admin1: String? = null, + val admin2: String? = null, + val admin3: String? = null, + val admin4: String? = null, ) @Serializable data class WeatherForecast( - val latitude: Double, - val longitude: Double, - val elevation: Double? = null, - @SerialName("generationtime_ms") val generationTimeMs: Double? = null, - @SerialName("utc_offset_seconds") val utcOffsetSeconds: Int? = null, - val timezone: String? = null, - @SerialName("timezone_abbreviation") val timezoneAbbreviation: String? = null, - val hourly: HourlyForecast? = null, - @SerialName("hourly_units") val hourlyUnits: Map? = null, - val daily: DailyForecast? = null, - @SerialName("daily_units") val dailyUnits: Map? = null + val latitude: Double, + val longitude: Double, + val elevation: Double? = null, + @SerialName("generationtime_ms") val generationTimeMs: Double? = null, + @SerialName("utc_offset_seconds") val utcOffsetSeconds: Int? = null, + val timezone: String? = null, + @SerialName("timezone_abbreviation") val timezoneAbbreviation: String? = null, + val hourly: HourlyForecast? = null, + @SerialName("hourly_units") val hourlyUnits: Map? = null, + val daily: DailyForecast? = null, + @SerialName("daily_units") val dailyUnits: Map? = null, ) @Serializable data class HourlyForecast( - val time: List, - @SerialName("temperature_2m") val temperature2m: List? = null, - @SerialName("precipitation_probability") val precipitationProbability: List? = null, - @SerialName("weather_code") val weatherCode: List? = null + val time: List, + @SerialName("temperature_2m") val temperature2m: List? = null, + @SerialName("precipitation_probability") val precipitationProbability: List? = null, + @SerialName("weather_code") val weatherCode: List? = null, ) @Serializable data class DailyForecast( - val time: List, - @SerialName("weather_code") val weatherCode: List? = null, - @SerialName("temperature_2m_max") val temperature2mMax: List? = null, - @SerialName("temperature_2m_min") val temperature2mMin: List? = null, - @SerialName("precipitation_sum") val precipitationSum: List? = null -) \ No newline at end of file + val time: List, + @SerialName("weather_code") val weatherCode: List? = null, + @SerialName("temperature_2m_max") val temperature2mMax: List? = null, + @SerialName("temperature_2m_min") val temperature2mMin: List? = null, + @SerialName("precipitation_sum") val precipitationSum: List? = null, +) diff --git a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/agents/weather/WeatherAgentProvider.kt b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/agents/weather/WeatherAgentProvider.kt index e3da29b..763d6c1 100644 --- a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/agents/weather/WeatherAgentProvider.kt +++ b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/agents/weather/WeatherAgentProvider.kt @@ -20,71 +20,74 @@ import io.github.lemcoder.koog.edge.leap.LeapModels import io.github.lemcoder.koog.edge.leap.getLeapLLMClient import kotlinx.datetime.Clock -/** - * Factory for creating weather forecast agents - */ +/** Factory for creating weather forecast agents */ internal class WeatherAgentProvider : AgentProvider { - override val title: String = "Weather Forecast" - override val description: String = - "Hi, I'm a weather agent. I can provide weather forecasts for any location." - - override suspend fun provideAgent( - onToolCallEvent: suspend (String) -> Unit, - onErrorEvent: suspend (String) -> Unit, - onAssistantMessage: suspend (String) -> String, - ): AIAgent { - val leapExecutor = SingleLLMPromptExecutor(getLeapLLMClient(modelsPath)) - - // Create tool registry with weather tools - val toolRegistry = ToolRegistry { - tool(WeatherTools.CurrentDatetimeTool) - tool(WeatherTools.AddDatetimeTool) - tool(WeatherTools.WeatherForecastTool) - - tool(ExitTool) - } - - @Suppress("DuplicatedCode") - val strategy = strategy(title) { - val nodeRequestLLM by nodeLLMRequestMultiple() - val nodeAssistantMessage by node { message -> onAssistantMessage(message) } - val nodeExecuteToolMultiple by nodeExecuteMultipleTools(parallelTools = true) - val nodeSendToolResultMultiple by nodeLLMSendMultipleToolResults() - - edge(nodeStart forwardTo nodeRequestLLM) - - edge( - nodeRequestLLM forwardTo nodeExecuteToolMultiple - onMultipleToolCalls { true } - ) + override val title: String = "Weather Forecast" + override val description: String = + "Hi, I'm a weather agent. I can provide weather forecasts for any location." + + override suspend fun provideAgent( + onToolCallEvent: suspend (String) -> Unit, + onErrorEvent: suspend (String) -> Unit, + onAssistantMessage: suspend (String) -> String, + ): AIAgent { + val leapExecutor = SingleLLMPromptExecutor(getLeapLLMClient(modelsPath)) + + // Create tool registry with weather tools + val toolRegistry = ToolRegistry { + tool(WeatherTools.CurrentDatetimeTool) + tool(WeatherTools.AddDatetimeTool) + tool(WeatherTools.WeatherForecastTool) + + tool(ExitTool) + } - edge( - nodeRequestLLM forwardTo nodeAssistantMessage - transformed { it.first() } - onAssistantMessage { true } - ) + @Suppress("DuplicatedCode") + val strategy = + strategy(title) { + val nodeRequestLLM by nodeLLMRequestMultiple() + val nodeAssistantMessage by node { message -> onAssistantMessage(message) } + val nodeExecuteToolMultiple by nodeExecuteMultipleTools(parallelTools = true) + val nodeSendToolResultMultiple by nodeLLMSendMultipleToolResults() + + edge(nodeStart forwardTo nodeRequestLLM) + + edge(nodeRequestLLM forwardTo nodeExecuteToolMultiple onMultipleToolCalls { true }) + + edge( + nodeRequestLLM forwardTo + nodeAssistantMessage transformed + { + it.first() + } onAssistantMessage + { + true + } + ) - edge( - nodeExecuteToolMultiple forwardTo nodeSendToolResultMultiple - ) + edge(nodeExecuteToolMultiple forwardTo nodeSendToolResultMultiple) - edge( - nodeSendToolResultMultiple forwardTo nodeAssistantMessage - transformed { it.first() } - onAssistantMessage { true } - ) + edge( + nodeSendToolResultMultiple forwardTo + nodeAssistantMessage transformed + { + it.first() + } onAssistantMessage + { + true + } + ) - edge( - nodeAssistantMessage forwardTo nodeFinish - transformed { it } - ) - } + edge(nodeAssistantMessage forwardTo nodeFinish transformed { it }) + } - // Create agent config with proper prompt - val agentConfig = AIAgentConfig( - prompt = prompt("test") { - system( - """ + // Create agent config with proper prompt + val agentConfig = + AIAgentConfig( + prompt = + prompt("test") { + system( + """ You are a helpful weather assistant. You can provide weather forecasts for any location in the world and help the user plan their activities. ALWAYS use the available tools to get weather data. NEVER say you do not have access to weather data. @@ -96,33 +99,30 @@ internal class WeatherAgentProvider : AgentProvider { 2. Add days, hours, or minutes to a date 3. Get weather forecasts for specific locations and dates Do not say you lack access to data; always use the tools. - """.trimIndent() - ) - }, - model = LeapModels.Chat.LFM2_1_2B_Tool, - maxAgentIterations = 50 - ) - - // Return the agent - return AIAgent( - promptExecutor = leapExecutor, - strategy = strategy, - agentConfig = agentConfig, - toolRegistry = toolRegistry, - ) { - handleEvents { - onToolCallStarting { ctx -> - onToolCallEvent("Tool ${ctx.tool.name}, args ${ctx.toolArgs}") - } - - onAgentExecutionFailed { ctx -> - onErrorEvent("${ctx.throwable.message}") - } - - onAgentCompleted { ctx -> - // Skip finish event handling - } - } + """ + .trimIndent() + ) + }, + model = LeapModels.Chat.LFM2_1_2B_Tool, + maxAgentIterations = 50, + ) + + // Return the agent + return AIAgent( + promptExecutor = leapExecutor, + strategy = strategy, + agentConfig = agentConfig, + toolRegistry = toolRegistry, + ) { + handleEvents { + onToolCallStarting { ctx -> onToolCallEvent("Tool ${ctx.tool.name}, args ${ctx.toolArgs}") } + + onAgentExecutionFailed { ctx -> onErrorEvent("${ctx.throwable.message}") } + + onAgentCompleted { ctx -> + // Skip finish event handling } + } } -} \ No newline at end of file + } +} diff --git a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/agents/weather/WeatherTools.kt b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/agents/weather/WeatherTools.kt index fc2675c..d5dd527 100644 --- a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/agents/weather/WeatherTools.kt +++ b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/agents/weather/WeatherTools.kt @@ -17,354 +17,355 @@ import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -/** - * Tools for the weather agent - */ +/** Tools for the weather agent */ object WeatherTools { - private val openMeteoClient = OpenMeteoClient() + private val openMeteoClient = OpenMeteoClient() - private val UTC_ZONE = TimeZone.UTC + private val UTC_ZONE = TimeZone.UTC - /** - * Granularity options for weather forecasts - */ + /** Granularity options for weather forecasts */ + @Serializable + enum class Granularity { + @SerialName("daily") DAILY, + @SerialName("hourly") HOURLY, + } + + /** Tool for getting the current date and time */ + object CurrentDatetimeTool : Tool() { @Serializable - enum class Granularity { - @SerialName("daily") - DAILY, + data class Args( + @property:LLMDescription( + "The timezone to get the current date and time in (e.g., 'UTC', 'America/New_York', 'Europe/London'). Defaults to UTC." + ) + val timezone: String = "UTC" + ) - @SerialName("hourly") - HOURLY + @Serializable + data class Result( + val datetime: String, + val date: String, + val time: String, + val timezone: String, + ) : ToolResult.TextSerializable() { + override fun textForLLM(): String { + return "Current datetime: $datetime, Date: $date, Time: $time, Timezone: $timezone" + } } - /** - * Tool for getting the current date and time - */ - object CurrentDatetimeTool : Tool() { - @Serializable - data class Args( - @property:LLMDescription("The timezone to get the current date and time in (e.g., 'UTC', 'America/New_York', 'Europe/London'). Defaults to UTC.") - val timezone: String = "UTC" - ) - - @Serializable - data class Result( - val datetime: String, - val date: String, - val time: String, - val timezone: String - ) : ToolResult.TextSerializable() { - override fun textForLLM(): String { - return "Current datetime: $datetime, Date: $date, Time: $time, Timezone: $timezone" - } - } - - override val argsSerializer = Args.serializer() - override val resultSerializer: KSerializer = - ToolResultUtils.toTextSerializer() - - override val name = "current_datetime" - override val description = "Get the current date and time in the specified timezone" + override val argsSerializer = Args.serializer() + override val resultSerializer: KSerializer = ToolResultUtils.toTextSerializer() + override val name = "current_datetime" + override val description = "Get the current date and time in the specified timezone" - override suspend fun execute(args: Args): Result { - val zoneId = try { - TimeZone.of(args.timezone) - } catch (_: Exception) { - UTC_ZONE - } + override suspend fun execute(args: Args): Result { + val zoneId = + try { + TimeZone.of(args.timezone) + } catch (_: Exception) { + UTC_ZONE + } - val now = Clock.System.now() - val localDateTime = now.toLocalDateTime(zoneId) - val offset = zoneId.offsetAt(now) + val now = Clock.System.now() + val localDateTime = now.toLocalDateTime(zoneId) + val offset = zoneId.offsetAt(now) - val time = localDateTime.time - val timeStr = "${time.hour.toString().padStart(2, '0')}:${ + val time = localDateTime.time + val timeStr = + "${time.hour.toString().padStart(2, '0')}:${ time.minute.toString().padStart(2, '0') }:${time.second.toString().padStart(2, '0')}" - return Result( - datetime = "${localDateTime.date}T$timeStr$offset", - date = localDateTime.date.toString(), - time = timeStr, - timezone = zoneId.id - ) - } + return Result( + datetime = "${localDateTime.date}T$timeStr$offset", + date = localDateTime.date.toString(), + time = timeStr, + timezone = zoneId.id, + ) } + } - /** - * Tool for adding a duration to a date - */ - object AddDatetimeTool : Tool() { - @Serializable - data class Args( - @property:LLMDescription("The date to add to in ISO format (e.g., '2023-05-20')") - val date: String, - @property:LLMDescription("The number of days to add") - val days: Int, - @property:LLMDescription("The number of hours to add") - val hours: Int, - @property:LLMDescription("The number of minutes to add") - val minutes: Int - ) + /** Tool for adding a duration to a date */ + object AddDatetimeTool : Tool() { + @Serializable + data class Args( + @property:LLMDescription("The date to add to in ISO format (e.g., '2023-05-20')") + val date: String, + @property:LLMDescription("The number of days to add") val days: Int, + @property:LLMDescription("The number of hours to add") val hours: Int, + @property:LLMDescription("The number of minutes to add") val minutes: Int, + ) - @Serializable - data class Result( - val date: String, - val originalDate: String, - val daysAdded: Int, - val hoursAdded: Int, - val minutesAdded: Int - ) : ToolResult.TextSerializable() { - override fun textForLLM(): String { - return buildString { - append("Date: $date") - if (originalDate.isBlank()) { - append(" (starting from today)") - } else { - append(" (starting from $originalDate)") - } - - if (daysAdded != 0 || hoursAdded != 0 || minutesAdded != 0) { - append(" after adding") - - if (daysAdded != 0) { - append(" $daysAdded days") - } - - if (hoursAdded != 0) { - if (daysAdded != 0) append(",") - append(" $hoursAdded hours") - } - - if (minutesAdded != 0) { - if (daysAdded != 0 || hoursAdded != 0) append(",") - append(" $minutesAdded minutes") - } - } - } + @Serializable + data class Result( + val date: String, + val originalDate: String, + val daysAdded: Int, + val hoursAdded: Int, + val minutesAdded: Int, + ) : ToolResult.TextSerializable() { + override fun textForLLM(): String { + return buildString { + append("Date: $date") + if (originalDate.isBlank()) { + append(" (starting from today)") + } else { + append(" (starting from $originalDate)") + } + + if (daysAdded != 0 || hoursAdded != 0 || minutesAdded != 0) { + append(" after adding") + + if (daysAdded != 0) { + append(" $daysAdded days") } - } - override val argsSerializer = Args.serializer() - override val resultSerializer = ToolResultUtils.toTextSerializer() - - override val name = "add_datetime" - override val description = - "Add a duration to a date. Use this tool when you need to calculate offsets, such as tomorrow, in two days, etc." - - override suspend fun execute(args: Args): Result { - val baseDate = if (args.date.isNotBlank()) { - try { - LocalDate.parse(args.date) - } catch (_: Exception) { - // Use current date if parsing fails - Clock.System.now().toLocalDateTime(UTC_ZONE).date - } - } else { - Clock.System.now().toLocalDateTime(UTC_ZONE).date + if (hoursAdded != 0) { + if (daysAdded != 0) append(",") + append(" $hoursAdded hours") } - // Convert to LocalDateTime to handle hours and minutes - val baseDateTime = - LocalDateTime(baseDate.year, baseDate.month, baseDate.dayOfMonth, 0, 0) - val baseInstant = baseDateTime.toInstant(UTC_ZONE) - - val period = DateTimePeriod( - days = args.days, - hours = args.hours, - minutes = args.minutes - ) - - val newInstant = baseInstant.plus(period, UTC_ZONE) - val resultDate = newInstant.toLocalDateTime(UTC_ZONE).date.toString() - - return Result( - date = resultDate, - originalDate = args.date, - daysAdded = args.days, - hoursAdded = args.hours, - minutesAdded = args.minutes - ) + if (minutesAdded != 0) { + if (daysAdded != 0 || hoursAdded != 0) append(",") + append(" $minutesAdded minutes") + } + } } + } } - /** - * Tool for getting a weather forecast - */ - object WeatherForecastTool : Tool() { - @Serializable - data class Args( - @property:LLMDescription("The location to get the weather forecast for (e.g., 'New York', 'London', 'Paris')") - val location: String, - @property:LLMDescription("The date to get the weather forecast for in ISO format (e.g., '2023-05-20'). If empty, the forecast starts from today.") - val date: String = "", - @property:LLMDescription("The number of days to forecast (1-7)") - val days: Int = 1, - @property:LLMDescription("The granularity of the forecast: 'daily' for day-by-day forecast or 'hourly' for hour-by-hour forecast. Default is 'daily'.") - val granularity: Granularity = Granularity.DAILY - ) - - @Serializable - data class Result( - val locationName: String, - val locationCountry: String? = null, - val forecast: String, - val date: String, - val granularity: Granularity - ) : ToolResult.TextSerializable() { - override fun textForLLM(): String { - val granularityText = when (granularity) { - Granularity.DAILY -> "daily" - Granularity.HOURLY -> "hourly" - } - val dateInfo = if (date.isBlank()) "starting from today" else "for $date" - val formattedLocation = if (locationCountry.isNullOrBlank()) { - locationName - } else { - "$locationName, $locationCountry" - }.trim().trimEnd(',') - - return "Weather forecast for $formattedLocation ($granularityText, $dateInfo):\n$forecast" - } + override val argsSerializer = Args.serializer() + override val resultSerializer = ToolResultUtils.toTextSerializer() + + override val name = "add_datetime" + override val description = + "Add a duration to a date. Use this tool when you need to calculate offsets, such as tomorrow, in two days, etc." + + override suspend fun execute(args: Args): Result { + val baseDate = + if (args.date.isNotBlank()) { + try { + LocalDate.parse(args.date) + } catch (_: Exception) { + // Use current date if parsing fails + Clock.System.now().toLocalDateTime(UTC_ZONE).date + } + } else { + Clock.System.now().toLocalDateTime(UTC_ZONE).date } - override val argsSerializer = Args.serializer() - override val resultSerializer = ToolResultUtils.toTextSerializer() - - override val name = "weather_forecast" - override val description = - "Get a weather forecast for a location with specified granularity (daily or hourly)" - - override suspend fun execute(args: Args): Result { - // Search for the location - val locations = openMeteoClient.searchLocation(args.location) - if (locations.isEmpty()) { - return Result( - locationName = args.location, - forecast = "Location not found", - date = args.date, - granularity = args.granularity - ) - } + // Convert to LocalDateTime to handle hours and minutes + val baseDateTime = LocalDateTime(baseDate.year, baseDate.month, baseDate.dayOfMonth, 0, 0) + val baseInstant = baseDateTime.toInstant(UTC_ZONE) - val location = locations.first() - val forecastDays = args.days.coerceIn(1, 7) + val period = DateTimePeriod(days = args.days, hours = args.hours, minutes = args.minutes) - // Get the weather forecast - val forecast = openMeteoClient.getWeatherForecast( - latitude = location.latitude, - longitude = location.longitude, - forecastDays = forecastDays - ) + val newInstant = baseInstant.plus(period, UTC_ZONE) + val resultDate = newInstant.toLocalDateTime(UTC_ZONE).date.toString() + + return Result( + date = resultDate, + originalDate = args.date, + daysAdded = args.days, + hoursAdded = args.hours, + minutesAdded = args.minutes, + ) + } + } - // Format the forecast based on granularity - val formattedForecast = when (args.granularity) { - Granularity.HOURLY -> formatHourlyForecast(forecast, args.date) - Granularity.DAILY -> formatDailyForecast(forecast, args.date) + /** Tool for getting a weather forecast */ + object WeatherForecastTool : Tool() { + @Serializable + data class Args( + @property:LLMDescription( + "The location to get the weather forecast for (e.g., 'New York', 'London', 'Paris')" + ) + val location: String, + @property:LLMDescription( + "The date to get the weather forecast for in ISO format (e.g., '2023-05-20'). If empty, the forecast starts from today." + ) + val date: String = "", + @property:LLMDescription("The number of days to forecast (1-7)") val days: Int = 1, + @property:LLMDescription( + "The granularity of the forecast: 'daily' for day-by-day forecast or 'hourly' for hour-by-hour forecast. Default is 'daily'." + ) + val granularity: Granularity = Granularity.DAILY, + ) + + @Serializable + data class Result( + val locationName: String, + val locationCountry: String? = null, + val forecast: String, + val date: String, + val granularity: Granularity, + ) : ToolResult.TextSerializable() { + override fun textForLLM(): String { + val granularityText = + when (granularity) { + Granularity.DAILY -> "daily" + Granularity.HOURLY -> "hourly" + } + val dateInfo = if (date.isBlank()) "starting from today" else "for $date" + val formattedLocation = + if (locationCountry.isNullOrBlank()) { + locationName + } else { + "$locationName, $locationCountry" } + .trim() + .trimEnd(',') - return Result( - locationName = location.name, - locationCountry = location.country, - forecast = formattedForecast, - date = args.date, - granularity = args.granularity - ) - } + return "Weather forecast for $formattedLocation ($granularityText, $dateInfo):\n$forecast" + } + } - private fun formatDailyForecast(forecast: WeatherForecast, date: String): String { - val daily = forecast.daily ?: return "No daily forecast data available" + override val argsSerializer = Args.serializer() + override val resultSerializer = ToolResultUtils.toTextSerializer() + + override val name = "weather_forecast" + override val description = + "Get a weather forecast for a location with specified granularity (daily or hourly)" + + override suspend fun execute(args: Args): Result { + // Search for the location + val locations = openMeteoClient.searchLocation(args.location) + if (locations.isEmpty()) { + return Result( + locationName = args.location, + forecast = "Location not found", + date = args.date, + granularity = args.granularity, + ) + } - val startDate = date.ifBlank { - Clock.System.now().toLocalDateTime(UTC_ZONE).date.toString() - } + val location = locations.first() + val forecastDays = args.days.coerceIn(1, 7) - val startIndex = daily.time.indexOfFirst { it >= startDate }.coerceAtLeast(0) - - return buildString { - for (i in startIndex until daily.time.size) { - val dateStr = daily.time[i] - val maxTemp = daily.temperature2mMax?.getOrNull(i)?.toString() ?: "N/A" - val minTemp = daily.temperature2mMin?.getOrNull(i)?.toString() ?: "N/A" - val weatherCode = daily.weatherCode?.getOrNull(i) - val weatherDesc = getWeatherDescription(weatherCode) - val precipSum = daily.precipitationSum?.getOrNull(i)?.toString() ?: "0" - - append("$dateStr: $weatherDesc, ") - append("Temperature: $minTemp°C to $maxTemp°C, ") - append("Precipitation: $precipSum mm") - - if (i < daily.time.size - 1) { - append("\n") - } - } - } + // Get the weather forecast + val forecast = + openMeteoClient.getWeatherForecast( + latitude = location.latitude, + longitude = location.longitude, + forecastDays = forecastDays, + ) + + // Format the forecast based on granularity + val formattedForecast = + when (args.granularity) { + Granularity.HOURLY -> formatHourlyForecast(forecast, args.date) + Granularity.DAILY -> formatDailyForecast(forecast, args.date) } - private fun formatHourlyForecast(forecast: WeatherForecast, date: String): String { - val hourly = forecast.hourly ?: return "No hourly forecast data available" + return Result( + locationName = location.name, + locationCountry = location.country, + forecast = formattedForecast, + date = args.date, + granularity = args.granularity, + ) + } - val startDate = date.ifBlank { - Clock.System.now().toLocalDateTime(UTC_ZONE).date.toString() - } + private fun formatDailyForecast(forecast: WeatherForecast, date: String): String { + val daily = forecast.daily ?: return "No daily forecast data available" - // Find the starting index for the requested date - val startIndex = hourly.time.indexOfFirst { - it.startsWith(startDate) || it > startDate - }.coerceAtLeast(0) - - return buildString { - // Group hourly forecasts by date for better readability - val groupedByDate = - hourly.time.subList(startIndex, hourly.time.size).mapIndexed { index, time -> - val actualIndex = startIndex + index - val dateTime = time.split("T") - val date = dateTime[0] - val hour = if (dateTime.size > 1) dateTime[1].substringBefore(":") else "00" - - val temp = hourly.temperature2m?.getOrNull(actualIndex)?.toString() ?: "N/A" - val precipProb = - hourly.precipitationProbability?.getOrNull(actualIndex)?.toString() - ?: "N/A" - val weatherCode = hourly.weatherCode?.getOrNull(actualIndex) - val weatherDesc = getWeatherDescription(weatherCode) - - Triple( - date, - "$hour:00: $weatherDesc, Temperature: ${temp}°C, Precipitation probability: $precipProb%", - actualIndex - ) - }.groupBy { it.first } - - groupedByDate.forEach { (date, forecasts) -> - append("$date:\n") - forecasts.forEach { (_, forecast, _) -> - append(" $forecast\n") - } - } - } + val startDate = date.ifBlank { Clock.System.now().toLocalDateTime(UTC_ZONE).date.toString() } + + val startIndex = daily.time.indexOfFirst { it >= startDate }.coerceAtLeast(0) + + return buildString { + for (i in startIndex until daily.time.size) { + val dateStr = daily.time[i] + val maxTemp = daily.temperature2mMax?.getOrNull(i)?.toString() ?: "N/A" + val minTemp = daily.temperature2mMin?.getOrNull(i)?.toString() ?: "N/A" + val weatherCode = daily.weatherCode?.getOrNull(i) + val weatherDesc = getWeatherDescription(weatherCode) + val precipSum = daily.precipitationSum?.getOrNull(i)?.toString() ?: "0" + + append("$dateStr: $weatherDesc, ") + append("Temperature: $minTemp°C to $maxTemp°C, ") + append("Precipitation: $precipSum mm") + + if (i < daily.time.size - 1) { + append("\n") + } } + } + } - private fun getWeatherDescription(code: Int?): String { - return when (code) { - 0 -> "Clear sky" - 1 -> "Mainly clear" - 2 -> "Partly cloudy" - 3 -> "Overcast" - 45, 48 -> "Fog" - 51, 53, 55 -> "Drizzle" - 56, 57 -> "Freezing drizzle" - 61, 63, 65 -> "Rain" - 66, 67 -> "Freezing rain" - 71, 73, 75 -> "Snow fall" - 77 -> "Snow grains" - 80, 81, 82 -> "Rain showers" - 85, 86 -> "Snow showers" - 95 -> "Thunderstorm" - 96, 99 -> "Thunderstorm with hail" - else -> "Unknown" + private fun formatHourlyForecast(forecast: WeatherForecast, date: String): String { + val hourly = forecast.hourly ?: return "No hourly forecast data available" + + val startDate = date.ifBlank { Clock.System.now().toLocalDateTime(UTC_ZONE).date.toString() } + + // Find the starting index for the requested date + val startIndex = + hourly.time.indexOfFirst { it.startsWith(startDate) || it > startDate }.coerceAtLeast(0) + + return buildString { + // Group hourly forecasts by date for better readability + val groupedByDate = + hourly.time + .subList(startIndex, hourly.time.size) + .mapIndexed { index, time -> + val actualIndex = startIndex + index + val dateTime = time.split("T") + val date = dateTime[0] + val hour = if (dateTime.size > 1) dateTime[1].substringBefore(":") else "00" + + val temp = hourly.temperature2m?.getOrNull(actualIndex)?.toString() ?: "N/A" + val precipProb = + hourly.precipitationProbability?.getOrNull(actualIndex)?.toString() ?: "N/A" + val weatherCode = hourly.weatherCode?.getOrNull(actualIndex) + val weatherDesc = getWeatherDescription(weatherCode) + + Triple( + date, + "$hour:00: $weatherDesc, Temperature: ${temp}°C, Precipitation probability: $precipProb%", + actualIndex, + ) } + .groupBy { it.first } + + groupedByDate.forEach { (date, forecasts) -> + append("$date:\n") + forecasts.forEach { (_, forecast, _) -> append(" $forecast\n") } } + } + } + + private fun getWeatherDescription(code: Int?): String { + return when (code) { + 0 -> "Clear sky" + 1 -> "Mainly clear" + 2 -> "Partly cloudy" + 3 -> "Overcast" + 45, + 48 -> "Fog" + 51, + 53, + 55 -> "Drizzle" + 56, + 57 -> "Freezing drizzle" + 61, + 63, + 65 -> "Rain" + 66, + 67 -> "Freezing rain" + 71, + 73, + 75 -> "Snow fall" + 77 -> "Snow grains" + 80, + 81, + 82 -> "Rain showers" + 85, + 86 -> "Snow showers" + 95 -> "Thunderstorm" + 96, + 99 -> "Thunderstorm with hail" + else -> "Unknown" + } } -} \ No newline at end of file + } +} diff --git a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/common/MviViewModel.kt b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/common/MviViewModel.kt index 8a053d9..6fb41fa 100644 --- a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/common/MviViewModel.kt +++ b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/common/MviViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import kotlinx.coroutines.flow.StateFlow abstract class MviViewModel : ViewModel() { - abstract val state: StateFlow - abstract fun onEvent(event: EVENT) -} \ No newline at end of file + abstract val state: StateFlow + + abstract fun onEvent(event: EVENT) +} diff --git a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/navigation/Destination.kt b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/navigation/Destination.kt index 6ca12f5..d979d8e 100644 --- a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/navigation/Destination.kt +++ b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/navigation/Destination.kt @@ -1,9 +1,9 @@ package ai.liquid.koogleapsdk.ui.navigation sealed interface Destination { - data object CalculatorTool : Destination + data object CalculatorTool : Destination - data object WeatherTool : Destination + data object WeatherTool : Destination - data object ToolsList : Destination -} \ No newline at end of file + data object ToolsList : Destination +} diff --git a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/navigation/NavigationService.kt b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/navigation/NavigationService.kt index 8bb021e..eb12253 100644 --- a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/navigation/NavigationService.kt +++ b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/navigation/NavigationService.kt @@ -6,41 +6,32 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update interface NavigationService { - val destinationFlow: StateFlow + val destinationFlow: StateFlow - fun navigateTo(destination: Destination) + fun navigateTo(destination: Destination) - fun navigateBack() + fun navigateBack() - companion object { - val Instance: NavigationService by lazy { - NavigationServiceImpl() - } - } + companion object { + val Instance: NavigationService by lazy { NavigationServiceImpl() } + } } private class NavigationServiceImpl() : NavigationService { - private val backStack = ArrayDeque().apply { - add(Destination.ToolsList) - } - private val _destinationFlow = MutableStateFlow(backStack.last()) - override val destinationFlow: StateFlow - get() = _destinationFlow.asStateFlow() - - - override fun navigateTo(destination: Destination) { - backStack.add(destination) - _destinationFlow.update { - destination - } + private val backStack = ArrayDeque().apply { add(Destination.ToolsList) } + private val _destinationFlow = MutableStateFlow(backStack.last()) + override val destinationFlow: StateFlow + get() = _destinationFlow.asStateFlow() + + override fun navigateTo(destination: Destination) { + backStack.add(destination) + _destinationFlow.update { destination } + } + + override fun navigateBack() { + if (backStack.size > 1) { + backStack.removeLastOrNull() + _destinationFlow.update { backStack.last() } } - - override fun navigateBack() { - if (backStack.size > 1) { - backStack.removeLastOrNull() - _destinationFlow.update { - backStack.last() - } - } - } -} \ No newline at end of file + } +} diff --git a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/MainScreen.kt b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/MainScreen.kt index ae462b8..c1b1060 100644 --- a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/MainScreen.kt +++ b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/MainScreen.kt @@ -1,10 +1,17 @@ package ai.liquid.koogleapsdk.ui.screen +import ai.liquid.koogleapsdk.ui.navigation.Destination +import ai.liquid.koogleapsdk.ui.navigation.NavigationService +import ai.liquid.koogleapsdk.ui.screen.calculatorTool.CalculatorToolRoute +import ai.liquid.koogleapsdk.ui.screen.toolsList.ToolsListRoute +import ai.liquid.koogleapsdk.ui.screen.weatherTool.WeatherToolRoute +import ai.liquid.koogleapsdk.ui.util.SnackbarUtil import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -12,41 +19,26 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle -import ai.liquid.koogleapsdk.ui.navigation.Destination -import ai.liquid.koogleapsdk.ui.navigation.NavigationService -import ai.liquid.koogleapsdk.ui.screen.calculatorTool.CalculatorToolRoute -import ai.liquid.koogleapsdk.ui.screen.toolsList.ToolsListRoute -import ai.liquid.koogleapsdk.ui.screen.weatherTool.WeatherToolRoute -import ai.liquid.koogleapsdk.ui.util.SnackbarUtil -import androidx.compose.material3.SnackbarHost @Composable fun MainScreen() { - val navigationService = remember { NavigationService.Companion.Instance } - val destination by navigationService.destinationFlow.collectAsStateWithLifecycle() - val snackbarHostState = remember { SnackbarHostState() } + val navigationService = remember { NavigationService.Companion.Instance } + val destination by navigationService.destinationFlow.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } - LaunchedEffect(snackbarHostState) { - SnackbarUtil.snackbarHostState = snackbarHostState - } + LaunchedEffect(snackbarHostState) { SnackbarUtil.snackbarHostState = snackbarHostState } - Scaffold( - modifier = Modifier.fillMaxSize(), - snackbarHost = { SnackbarHost(snackbarHostState) } - ) { innerPadding -> - AnimatedContent( - targetState = destination, - modifier = Modifier.padding(innerPadding) - ) { destination -> - when (destination) { - Destination.CalculatorTool -> CalculatorToolRoute() - Destination.ToolsList -> ToolsListRoute() - Destination.WeatherTool -> WeatherToolRoute() - } - } + Scaffold(modifier = Modifier.fillMaxSize(), snackbarHost = { SnackbarHost(snackbarHostState) }) { + innerPadding -> + AnimatedContent(targetState = destination, modifier = Modifier.padding(innerPadding)) { + destination -> + when (destination) { + Destination.CalculatorTool -> CalculatorToolRoute() + Destination.ToolsList -> ToolsListRoute() + Destination.WeatherTool -> WeatherToolRoute() + } } + } - BackHandler { - navigationService.navigateBack() - } -} \ No newline at end of file + BackHandler { navigationService.navigateBack() } +} diff --git a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/calculatorTool/CalculatorToolEvent.kt b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/calculatorTool/CalculatorToolEvent.kt index 598df49..facb05b 100644 --- a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/calculatorTool/CalculatorToolEvent.kt +++ b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/calculatorTool/CalculatorToolEvent.kt @@ -1,5 +1,5 @@ package ai.liquid.koogleapsdk.ui.screen.calculatorTool sealed class CalculatorToolEvent { - data class Calculate(val expression: String) : CalculatorToolEvent() -} \ No newline at end of file + data class Calculate(val expression: String) : CalculatorToolEvent() +} diff --git a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/calculatorTool/CalculatorToolRoute.kt b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/calculatorTool/CalculatorToolRoute.kt index fd863cb..5d1d63b 100644 --- a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/calculatorTool/CalculatorToolRoute.kt +++ b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/calculatorTool/CalculatorToolRoute.kt @@ -7,11 +7,8 @@ import androidx.lifecycle.viewmodel.compose.viewModel @Composable fun CalculatorToolRoute() { - val viewModel = viewModel { CalculatorToolViewModel() } - val state by viewModel.state.collectAsStateWithLifecycle() + val viewModel = viewModel { CalculatorToolViewModel() } + val state by viewModel.state.collectAsStateWithLifecycle() - CalculatorToolScreen( - state = state, - onEvent = viewModel::onEvent - ) -} \ No newline at end of file + CalculatorToolScreen(state = state, onEvent = viewModel::onEvent) +} diff --git a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/calculatorTool/CalculatorToolScreen.kt b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/calculatorTool/CalculatorToolScreen.kt index 152ce20..67b7416 100644 --- a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/calculatorTool/CalculatorToolScreen.kt +++ b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/calculatorTool/CalculatorToolScreen.kt @@ -23,68 +23,48 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @Composable -fun CalculatorToolScreen( - state: CalculatorToolState, - onEvent: (CalculatorToolEvent) -> Unit -) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - text = stringResource(R.string.calculator_tool), - style = MaterialTheme.typography.headlineMedium, - modifier = Modifier.fillMaxWidth() - ) +fun CalculatorToolScreen(state: CalculatorToolState, onEvent: (CalculatorToolEvent) -> Unit) { + Column( + modifier = Modifier.fillMaxSize().padding(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResource(R.string.calculator_tool), + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.fillMaxWidth(), + ) - var expression by remember { mutableStateOf("1234 + 4321") } - OutlinedTextField( - value = expression, - onValueChange = { - expression = it - }, - placeholder = { - Text(stringResource(R.string.enter_expression)) - }, - modifier = Modifier.fillMaxWidth() - ) + var expression by remember { mutableStateOf("1234 + 4321") } + OutlinedTextField( + value = expression, + onValueChange = { expression = it }, + placeholder = { Text(stringResource(R.string.enter_expression)) }, + modifier = Modifier.fillMaxWidth(), + ) - if (state.toolCalls.isEmpty() || state.isCalculating) { - Spacer(modifier = Modifier.weight(1f)) - } else { - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .weight(1f) - .padding(vertical = 8.dp) - ) { - items(state.toolCalls) { - Text("Tool call: $it") - } + if (state.toolCalls.isEmpty() || state.isCalculating) { + Spacer(modifier = Modifier.weight(1f)) + } else { + LazyColumn(modifier = Modifier.fillMaxWidth().weight(1f).padding(vertical = 8.dp)) { + items(state.toolCalls) { Text("Tool call: $it") } - item { - Spacer(modifier = Modifier.padding(4.dp)) - Text( - text = "Answer: ${state.answer}", - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.fillMaxWidth() - ) - } - } + item { + Spacer(modifier = Modifier.padding(4.dp)) + Text( + text = "Answer: ${state.answer}", + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.fillMaxWidth(), + ) } + } + } - Button( - onClick = { - onEvent(CalculatorToolEvent.Calculate(expression)) - }, - modifier = Modifier - .fillMaxWidth() - .padding(8.dp), - enabled = !state.isCalculating && expression.isNotBlank(), - ) { - Text(text = "Calculate") - } + Button( + onClick = { onEvent(CalculatorToolEvent.Calculate(expression)) }, + modifier = Modifier.fillMaxWidth().padding(8.dp), + enabled = !state.isCalculating && expression.isNotBlank(), + ) { + Text(text = "Calculate") } -} \ No newline at end of file + } +} diff --git a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/calculatorTool/CalculatorToolState.kt b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/calculatorTool/CalculatorToolState.kt index 43a5f64..efe5b69 100644 --- a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/calculatorTool/CalculatorToolState.kt +++ b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/calculatorTool/CalculatorToolState.kt @@ -1,7 +1,7 @@ package ai.liquid.koogleapsdk.ui.screen.calculatorTool data class CalculatorToolState( - val toolCalls: List = emptyList(), - val answer: String? = null, - val isCalculating: Boolean = false, -) \ No newline at end of file + val toolCalls: List = emptyList(), + val answer: String? = null, + val isCalculating: Boolean = false, +) diff --git a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/calculatorTool/CalculatorToolViewModel.kt b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/calculatorTool/CalculatorToolViewModel.kt index f543d30..fc8a679 100644 --- a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/calculatorTool/CalculatorToolViewModel.kt +++ b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/calculatorTool/CalculatorToolViewModel.kt @@ -12,65 +12,42 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch class CalculatorToolViewModel : MviViewModel() { - private val _state = MutableStateFlow(CalculatorToolState()) - override val state: StateFlow = _state.asStateFlow() + private val _state = MutableStateFlow(CalculatorToolState()) + override val state: StateFlow = _state.asStateFlow() - override fun onEvent(event: CalculatorToolEvent) { - when (event) { - is CalculatorToolEvent.Calculate -> { - calculateUsingAgent(event.expression) - } - } + override fun onEvent(event: CalculatorToolEvent) { + when (event) { + is CalculatorToolEvent.Calculate -> { + calculateUsingAgent(event.expression) + } } + } - internal fun calculateUsingAgent(expression: String) { - viewModelScope.launch { - _state.update { - it.copy( - isCalculating = true - ) - } - val prompt = "What is the result of: $expression" - try { - val agent = CalculatorAgentProvider().provideAgent( - onToolCallEvent = { - _state.update { state -> - state.copy( - toolCalls = _state.value.toolCalls + it - ) - } - }, - onErrorEvent = { - SnackbarUtil.showSnackbar( - "Error occurred: $it", - ) - }, - onAssistantMessage = { - Log.d("CalculatorToolViewModel", "Assistant message: $it") - _state.update { state -> - state.copy( - answer = it - ) - } - it - } - ) - val result = agent.run(prompt) + internal fun calculateUsingAgent(expression: String) { + viewModelScope.launch { + _state.update { it.copy(isCalculating = true) } + val prompt = "What is the result of: $expression" + try { + val agent = + CalculatorAgentProvider() + .provideAgent( + onToolCallEvent = { + _state.update { state -> state.copy(toolCalls = _state.value.toolCalls + it) } + }, + onErrorEvent = { SnackbarUtil.showSnackbar("Error occurred: $it") }, + onAssistantMessage = { + Log.d("CalculatorToolViewModel", "Assistant message: $it") + _state.update { state -> state.copy(answer = it) } + it + }, + ) + val result = agent.run(prompt) - _state.update { - it.copy( - isCalculating = false, - answer = result - ) - } - } catch (ex: Exception) { - _state.update { - it.copy( - isCalculating = false, - ) - } - Log.e(this@CalculatorToolViewModel::class.simpleName, "Error occurred", ex) - } - } + _state.update { it.copy(isCalculating = false, answer = result) } + } catch (ex: Exception) { + _state.update { it.copy(isCalculating = false) } + Log.e(this@CalculatorToolViewModel::class.simpleName, "Error occurred", ex) + } } -} \ No newline at end of file + } +} diff --git a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/toolsList/ToolsListEvent.kt b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/toolsList/ToolsListEvent.kt index afa7563..e86bf52 100644 --- a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/toolsList/ToolsListEvent.kt +++ b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/toolsList/ToolsListEvent.kt @@ -1,5 +1,5 @@ package ai.liquid.koogleapsdk.ui.screen.toolsList sealed class ToolsListEvent { - data class OnToolClick(val toolId: String) : ToolsListEvent() -} \ No newline at end of file + data class OnToolClick(val toolId: String) : ToolsListEvent() +} diff --git a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/toolsList/ToolsListRoute.kt b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/toolsList/ToolsListRoute.kt index 364247e..97e592a 100644 --- a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/toolsList/ToolsListRoute.kt +++ b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/toolsList/ToolsListRoute.kt @@ -7,11 +7,8 @@ import androidx.lifecycle.viewmodel.compose.viewModel @Composable fun ToolsListRoute() { - val viewModel = viewModel { ToolsListViewModel() } - val state by viewModel.state.collectAsStateWithLifecycle() + val viewModel = viewModel { ToolsListViewModel() } + val state by viewModel.state.collectAsStateWithLifecycle() - ToolsListScreen( - state = state, - onEvent = viewModel::onEvent - ) -} \ No newline at end of file + ToolsListScreen(state = state, onEvent = viewModel::onEvent) +} diff --git a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/toolsList/ToolsListScreen.kt b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/toolsList/ToolsListScreen.kt index 655caba..67f757f 100644 --- a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/toolsList/ToolsListScreen.kt +++ b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/toolsList/ToolsListScreen.kt @@ -1,5 +1,6 @@ package ai.liquid.koogleapsdk.ui.screen.toolsList +import ai.liquid.koogleapsdk.R import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -19,74 +20,51 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import ai.liquid.koogleapsdk.R @Composable -fun ToolsListScreen( - state: ToolsListState, - onEvent: (ToolsListEvent) -> Unit -) { - Column { - Text( - text = stringResource(R.string.available_tools), - style = MaterialTheme.typography.headlineMedium, - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) +fun ToolsListScreen(state: ToolsListState, onEvent: (ToolsListEvent) -> Unit) { + Column { + Text( + text = stringResource(R.string.available_tools), + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.fillMaxWidth().padding(16.dp), + ) - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(8.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - items(state.tools, key = { it.id }) { - ToolListItem( - item = it, - onClick = { - onEvent( - ToolsListEvent.OnToolClick( - it.id - ) - ) - } - ) - } - } + LazyColumn( + modifier = Modifier.fillMaxSize().padding(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(state.tools, key = { it.id }) { + ToolListItem(item = it, onClick = { onEvent(ToolsListEvent.OnToolClick(it.id)) }) + } } + } } @Composable -private fun ToolListItem( - item: ToolsListState.ToolItem, - onClick: () -> Unit -) { - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .background(MaterialTheme.colorScheme.surfaceContainer) - .clickable(onClick = onClick) - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically, +private fun ToolListItem(item: ToolsListState.ToolItem, onClick: () -> Unit) { + Row( + modifier = + Modifier.fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer) + .clickable(onClick = onClick) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.Center, ) { - Column( - modifier = Modifier.weight(1f), - horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.Center - ) { - Text( - text = item.name, - modifier = Modifier.padding(bottom = 4.dp), - style = MaterialTheme.typography.bodyLarge - ) + Text( + text = item.name, + modifier = Modifier.padding(bottom = 4.dp), + style = MaterialTheme.typography.bodyLarge, + ) - Text( - text = item.description, - style = MaterialTheme.typography.bodyMedium - ) - } + Text(text = item.description, style = MaterialTheme.typography.bodyMedium) } -} \ No newline at end of file + } +} diff --git a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/toolsList/ToolsListState.kt b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/toolsList/ToolsListState.kt index 2613919..d2f603a 100644 --- a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/toolsList/ToolsListState.kt +++ b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/toolsList/ToolsListState.kt @@ -1,16 +1,10 @@ package ai.liquid.koogleapsdk.ui.screen.toolsList -data class ToolsListState( - val tools: List = emptyList(), -) { - data class ToolItem( - val id: String, - val name: String, - val description: String, - ) { - companion object { - const val TOOL_ID_CALCULATOR = "calculator" - const val TOOL_ID_WEATHER = "weather" - } +data class ToolsListState(val tools: List = emptyList()) { + data class ToolItem(val id: String, val name: String, val description: String) { + companion object { + const val TOOL_ID_CALCULATOR = "calculator" + const val TOOL_ID_WEATHER = "weather" } -} \ No newline at end of file + } +} diff --git a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/toolsList/ToolsListViewModel.kt b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/toolsList/ToolsListViewModel.kt index b7b9ea8..ac44798 100644 --- a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/toolsList/ToolsListViewModel.kt +++ b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/toolsList/ToolsListViewModel.kt @@ -9,28 +9,24 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow class ToolsListViewModel( - private val navigationService: NavigationService = NavigationService.Companion.Instance + private val navigationService: NavigationService = NavigationService.Companion.Instance ) : MviViewModel() { - private val _state = MutableStateFlow( - ToolsListState( - tools = allTools - ) - ) - override val state: StateFlow = _state.asStateFlow() + private val _state = MutableStateFlow(ToolsListState(tools = allTools)) + override val state: StateFlow = _state.asStateFlow() - override fun onEvent(event: ToolsListEvent) { - when (event) { - is ToolsListEvent.OnToolClick -> { - navigationService.navigateTo(resolveDestination(event.toolId)) - } - } + override fun onEvent(event: ToolsListEvent) { + when (event) { + is ToolsListEvent.OnToolClick -> { + navigationService.navigateTo(resolveDestination(event.toolId)) + } } + } - private fun resolveDestination(toolId: String): Destination { - return when (toolId) { - ToolsListState.ToolItem.Companion.TOOL_ID_CALCULATOR -> Destination.CalculatorTool - ToolsListState.ToolItem.Companion.TOOL_ID_WEATHER -> Destination.WeatherTool - else -> throw IllegalArgumentException("Unknown toolId: $toolId") - } + private fun resolveDestination(toolId: String): Destination { + return when (toolId) { + ToolsListState.ToolItem.Companion.TOOL_ID_CALCULATOR -> Destination.CalculatorTool + ToolsListState.ToolItem.Companion.TOOL_ID_WEATHER -> Destination.WeatherTool + else -> throw IllegalArgumentException("Unknown toolId: $toolId") } -} \ No newline at end of file + } +} diff --git a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/toolsList/util/AllToolsProvider.kt b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/toolsList/util/AllToolsProvider.kt index f2071dd..ae7bd7a 100644 --- a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/toolsList/util/AllToolsProvider.kt +++ b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/toolsList/util/AllToolsProvider.kt @@ -1,22 +1,22 @@ package ai.liquid.koogleapsdk.ui.screen.toolsList.util import ai.liquid.koogleapsdk.App -import ai.liquid.koogleapsdk.ui.screen.toolsList.ToolsListState import ai.liquid.koogleapsdk.R +import ai.liquid.koogleapsdk.ui.screen.toolsList.ToolsListState import ai.liquid.koogleapsdk.ui.screen.toolsList.ToolsListState.ToolItem.Companion.TOOL_ID_CALCULATOR import ai.liquid.koogleapsdk.ui.screen.toolsList.ToolsListState.ToolItem.Companion.TOOL_ID_WEATHER val allTools by lazy { - listOf( - ToolsListState.ToolItem( - id = TOOL_ID_CALCULATOR, - name = App.Companion.context.getString(R.string.calculator), - description = App.Companion.context.getString(R.string.a_simple_calculator_tool), - ), - ToolsListState.ToolItem( - id = TOOL_ID_WEATHER, - name = App.Companion.context.getString(R.string.weather), - description = App.Companion.context.getString(R.string.get_current_weather_information), - ), - ) + listOf( + ToolsListState.ToolItem( + id = TOOL_ID_CALCULATOR, + name = App.Companion.context.getString(R.string.calculator), + description = App.Companion.context.getString(R.string.a_simple_calculator_tool), + ), + ToolsListState.ToolItem( + id = TOOL_ID_WEATHER, + name = App.Companion.context.getString(R.string.weather), + description = App.Companion.context.getString(R.string.get_current_weather_information), + ), + ) } diff --git a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/weatherTool/WeatherToolEvent.kt b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/weatherTool/WeatherToolEvent.kt index 58b2b9b..3d89c91 100644 --- a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/weatherTool/WeatherToolEvent.kt +++ b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/weatherTool/WeatherToolEvent.kt @@ -1,5 +1,5 @@ package ai.liquid.koogleapsdk.ui.screen.weatherTool sealed class WeatherToolEvent { - data class OnSearchClick(val cityName: String) : WeatherToolEvent() -} \ No newline at end of file + data class OnSearchClick(val cityName: String) : WeatherToolEvent() +} diff --git a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/weatherTool/WeatherToolRoute.kt b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/weatherTool/WeatherToolRoute.kt index 3aa8222..488574a 100644 --- a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/weatherTool/WeatherToolRoute.kt +++ b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/weatherTool/WeatherToolRoute.kt @@ -7,11 +7,8 @@ import androidx.lifecycle.viewmodel.compose.viewModel @Composable fun WeatherToolRoute() { - val viewModel = viewModel { WeatherToolViewModel() } - val state by viewModel.state.collectAsStateWithLifecycle() + val viewModel = viewModel { WeatherToolViewModel() } + val state by viewModel.state.collectAsStateWithLifecycle() - WeatherToolScreen( - state = state, - onEvent = viewModel::onEvent - ) -} \ No newline at end of file + WeatherToolScreen(state = state, onEvent = viewModel::onEvent) +} diff --git a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/weatherTool/WeatherToolScreen.kt b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/weatherTool/WeatherToolScreen.kt index b6d9b01..2fd7971 100644 --- a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/weatherTool/WeatherToolScreen.kt +++ b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/weatherTool/WeatherToolScreen.kt @@ -1,5 +1,6 @@ package ai.liquid.koogleapsdk.ui.screen.weatherTool +import ai.liquid.koogleapsdk.R import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -19,63 +20,43 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import ai.liquid.koogleapsdk.R @Composable -fun WeatherToolScreen( - state: WeatherToolState, - onEvent: (WeatherToolEvent) -> Unit -) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - text = stringResource(R.string.weather_tool), - style = MaterialTheme.typography.headlineMedium, - modifier = Modifier.fillMaxWidth() - ) +fun WeatherToolScreen(state: WeatherToolState, onEvent: (WeatherToolEvent) -> Unit) { + Column( + modifier = Modifier.fillMaxSize().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResource(R.string.weather_tool), + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.fillMaxWidth(), + ) - var city by remember { mutableStateOf("New York, 2025-10-22, DAILY") } - OutlinedTextField( - value = city, - onValueChange = { - city = it - }, - placeholder = { - Text(stringResource(R.string.enter_city_name)) - }, - modifier = Modifier.fillMaxWidth() - ) + var city by remember { mutableStateOf("New York, 2025-10-22, DAILY") } + OutlinedTextField( + value = city, + onValueChange = { city = it }, + placeholder = { Text(stringResource(R.string.enter_city_name)) }, + modifier = Modifier.fillMaxWidth(), + ) - if (state.weatherInfo?.isNotEmpty() == true && !state.isLoading) { - Column( - modifier = Modifier - .verticalScroll(rememberScrollState()) - .padding(vertical = 8.dp) - .weight(1f) - ) { - Text( - text = state.weatherInfo, - ) - } - } else if (state.isLoading) { - Text( - text = stringResource(R.string.loading), - modifier = Modifier - .padding(vertical = 8.dp) - ) - } + if (state.weatherInfo?.isNotEmpty() == true && !state.isLoading) { + Column( + modifier = + Modifier.verticalScroll(rememberScrollState()).padding(vertical = 8.dp).weight(1f) + ) { + Text(text = state.weatherInfo) + } + } else if (state.isLoading) { + Text(text = stringResource(R.string.loading), modifier = Modifier.padding(vertical = 8.dp)) + } - Button( - onClick = { - onEvent(WeatherToolEvent.OnSearchClick(city)) - }, - modifier = Modifier.fillMaxWidth() - ) { - Text(text = stringResource(R.string.get_weather)) - } + Button( + onClick = { onEvent(WeatherToolEvent.OnSearchClick(city)) }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(text = stringResource(R.string.get_weather)) } -} \ No newline at end of file + } +} diff --git a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/weatherTool/WeatherToolState.kt b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/weatherTool/WeatherToolState.kt index 4863629..993b950 100644 --- a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/weatherTool/WeatherToolState.kt +++ b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/weatherTool/WeatherToolState.kt @@ -1,6 +1,3 @@ package ai.liquid.koogleapsdk.ui.screen.weatherTool -data class WeatherToolState( - val isLoading: Boolean = false, - val weatherInfo: String? = null, -) \ No newline at end of file +data class WeatherToolState(val isLoading: Boolean = false, val weatherInfo: String? = null) diff --git a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/weatherTool/WeatherToolViewModel.kt b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/weatherTool/WeatherToolViewModel.kt index 08530b8..b2084a2 100644 --- a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/weatherTool/WeatherToolViewModel.kt +++ b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/screen/weatherTool/WeatherToolViewModel.kt @@ -12,57 +12,44 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch class WeatherToolViewModel : MviViewModel() { - private val _state = MutableStateFlow(WeatherToolState()) - override val state: StateFlow = _state.asStateFlow() + private val _state = MutableStateFlow(WeatherToolState()) + override val state: StateFlow = _state.asStateFlow() - override fun onEvent(event: WeatherToolEvent) { - when (event) { - is WeatherToolEvent.OnSearchClick -> { - fetchWeather(event.cityName) - } - } + override fun onEvent(event: WeatherToolEvent) { + when (event) { + is WeatherToolEvent.OnSearchClick -> { + fetchWeather(event.cityName) + } } + } - internal fun fetchWeather(cityName: String) { - // Simulate fetching weather data - _state.update { - it.copy(isLoading = true, weatherInfo = null) - } + internal fun fetchWeather(cityName: String) { + // Simulate fetching weather data + _state.update { it.copy(isLoading = true, weatherInfo = null) } - viewModelScope.launch { - val prompt = "What is the weather in: $cityName" - try { - val agent = WeatherAgentProvider().provideAgent( - onToolCallEvent = { - Log.w(this@WeatherToolViewModel::class.simpleName, "Tool call: $it") - }, - onErrorEvent = { - SnackbarUtil.showSnackbar( - "Error occurred: $it", - ) - }, - onAssistantMessage = { - Log.w(this@WeatherToolViewModel::class.simpleName, "Assistant: $it") - val explained = it - _state.update { state -> - state.copy( - weatherInfo = explained, - ) - } - explained - } - ) - val result = agent.run(prompt) + viewModelScope.launch { + val prompt = "What is the weather in: $cityName" + try { + val agent = + WeatherAgentProvider() + .provideAgent( + onToolCallEvent = { + Log.w(this@WeatherToolViewModel::class.simpleName, "Tool call: $it") + }, + onErrorEvent = { SnackbarUtil.showSnackbar("Error occurred: $it") }, + onAssistantMessage = { + Log.w(this@WeatherToolViewModel::class.simpleName, "Assistant: $it") + val explained = it + _state.update { state -> state.copy(weatherInfo = explained) } + explained + }, + ) + val result = agent.run(prompt) - _state.update { - it.copy( - isLoading = false, - weatherInfo = result, - ) - } - } catch (ex: Exception) { - // TODO handle errors - } - } + _state.update { it.copy(isLoading = false, weatherInfo = result) } + } catch (ex: Exception) { + // TODO handle errors + } } -} \ No newline at end of file + } +} diff --git a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/util/SnackbarUtil.kt b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/util/SnackbarUtil.kt index 37c0254..0ea81be 100644 --- a/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/util/SnackbarUtil.kt +++ b/Android/LeapKoogAgent/app/src/main/kotlin/ai/liquid/koogleapsdk/ui/util/SnackbarUtil.kt @@ -3,15 +3,9 @@ package ai.liquid.koogleapsdk.ui.util import androidx.compose.material3.SnackbarHostState object SnackbarUtil { - lateinit var snackbarHostState: SnackbarHostState + lateinit var snackbarHostState: SnackbarHostState - suspend fun showSnackbar( - message: String, - actionLabel: String? = null - ) { - snackbarHostState.showSnackbar( - message = message, - actionLabel = actionLabel - ) - } -} \ No newline at end of file + suspend fun showSnackbar(message: String, actionLabel: String? = null) { + snackbarHostState.showSnackbar(message = message, actionLabel = actionLabel) + } +} diff --git a/Android/LeapKoogAgent/gradle/libs.versions.toml b/Android/LeapKoogAgent/gradle/libs.versions.toml index 5b8073e..58bc180 100644 --- a/Android/LeapKoogAgent/gradle/libs.versions.toml +++ b/Android/LeapKoogAgent/gradle/libs.versions.toml @@ -3,16 +3,17 @@ compileSdk = "36" minSdk = "31" agp = "8.13.2" -kotlin = "2.3.20" +kotlin = "2.3.21" coreKtx = "1.17.0" lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.12.3" composeBom = "2026.01.01" koog = "0.5.1" -leapSdk = "0.10.7" +leapSdk = "0.10.8" kotlinxSerialization = "1.10.0" +ktfmt = "0.25.0" [libraries] @@ -38,4 +39,5 @@ kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } android-library = { id = "com.android.library", version.ref = "agp" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +com-ncorti-ktfmt-gradle = { id = "com.ncorti.ktfmt.gradle", version.ref = "ktfmt" } diff --git a/Android/LeapVoiceAssistantDemo/README.md b/Android/LeapVoiceAssistantDemo/README.md new file mode 100644 index 0000000..f1a081e --- /dev/null +++ b/Android/LeapVoiceAssistantDemo/README.md @@ -0,0 +1,151 @@ +# LeapVoiceAssistantDemo + +A full-screen press-and-hold voice assistant powered by the Leap SDK's audio model and the prebuilt `VoiceAssistantWidget` from the `leap-ui` library. Hold the on-screen orb to record, release to stream both text and audio responses back, with the orb animation driven by live microphone / playback amplitude. + +## Features + +- Single-screen "orb" UI via `VoiceAssistantWidget` (Material 3, dark theme) +- Press-and-hold recording at 16 kHz mono float PCM (max 60 s) using `AudioRecord` +- Audio + text streaming output through `AudioTrack` with adaptive buffering driven by the model's real-time factor (RTF) +- Multi-turn voice conversation managed by the SDK's `VoiceAssistantStore` (MVI: state + intent) +- Real-time amplitude reporting from both the recorder and player feeds the orb visualization +- Automatic model download via `LeapModelDownloader` — backed by a foreground service, so the download survives process death and surfaces progress in the Android notification tray +- ktfmt (Google style) configured for the module + +## Model + +- **LFM2.5-Audio-1.5B** (`Q4_0`) — downloaded automatically by `LeapModelDownloader` on first launch (~1.5 GB) +- Cached in the downloader's managed storage (returned by `getModelResourceFolder`) plus `context.cacheDir/leap-cache/` for the runtime cache +- System prompt: `"Respond with interleaved text and audio."` so the model emits speech alongside any spoken text + +## Prerequisites + +- Android Studio Hedgehog (2023.1.1) or newer +- JDK 21 (use `JAVA_HOME=~/.sdkman/candidates/java/21.0.9-zulu` if managing JDKs with SDKman) +- Android device or emulator on API 31 (Android 12) or higher +- Permissions (declared in `AndroidManifest.xml`): + - `RECORD_AUDIO` — requested at runtime on first launch + - `INTERNET` — required for the initial model download + - `POST_NOTIFICATIONS` — used by the downloader's foreground service on API 33+ (the demo does not currently request this at runtime; downloads still work, but progress won't appear in the notification tray until the user grants it in system settings) + +## Running + +```bash +./gradlew installDebug +# or open the project in Android Studio and press Run +``` + +Grant the microphone permission when prompted, wait for the orb status to turn green ("Ready"), then press and hold the orb to speak. Release to stream back the response. Generation stats (e.g. RTF, tokens/s) appear above the status text when the model reports them. + +## Project Structure + +``` +app/src/main/kotlin/ai/liquid/leap/uidemo/ +├── MainActivity.kt # Hosts VoiceAssistantWidget, handles permission + status overlay +├── VoiceAssistantViewModel.kt # Loads model via LeapModelDownloader, owns the VoiceAssistantStore +└── AudioPipeline.kt # AndroidAudioRecorder, AndroidAudioPlayer, WAV encoder, LeapVoiceConversation +``` + +## Key SDK Patterns + +**Download + load the audio model** (`VoiceAssistantViewModel.loadModel`): + +```kotlin +val downloader = LeapModelDownloader( + app, + notificationConfig = LeapModelDownloaderNotificationConfig.build { + notificationTitleDownloading = app.getString(R.string.notification_downloading_model) + notificationTitleDownloaded = app.getString(R.string.notification_model_ready) + }, +) + +// 1. If the model isn't on disk yet, request a download and observe progress. +if (downloader.queryStatus(MODEL_NAME, QUANTIZATION_TYPE) is LeapModelDownloader.ModelDownloadStatus.NotOnLocal) { + val progressJob = viewModelScope.launch { + downloader.observeDownloadProgress(MODEL_NAME, QUANTIZATION_TYPE).collect { progress -> + if (progress != null && progress.totalSizeInBytes > 0) { + val frac = (progress.downloadedSizeInBytes.toFloat() / progress.totalSizeInBytes) + .coerceIn(0f, 1f) + store.setModelProgress(frac, "Downloading (${(frac * 100).toInt()}%)") + } + } + } + downloader.requestDownloadModel(MODEL_NAME, QUANTIZATION_TYPE) + // Wait for completion — progress emits null + status becomes Downloaded when done. + downloader.observeDownloadProgress(MODEL_NAME, QUANTIZATION_TYPE).first { progress -> + progress == null && + downloader.queryStatus(MODEL_NAME, QUANTIZATION_TYPE) is LeapModelDownloader.ModelDownloadStatus.Downloaded + } + progressJob.cancel(); progressJob.join() +} + +// 2. Now that the model is on disk, load it. +val runner = downloader.loadModel( + modelName = MODEL_NAME, + quantizationType = QUANTIZATION_TYPE, + options = ModelLoadingOptions( + cacheOptions = ModelLoadingOptions.cacheOptions( + path = app.cacheDir.resolve("leap-cache").absolutePath + ) + ), +) +store.setConversation( + LeapVoiceConversation( + conv = runner.createConversation(systemPrompt = SYSTEM_PROMPT), + systemPrompt = SYSTEM_PROMPT, + ) +) +``` + +`LeapModelDownloader` runs the download in a foreground service with its own notification channel, so the user sees download progress in the tray and the download keeps running even if the activity is destroyed. The `notificationConfig` titles come from `res/values/strings.xml` (`notification_downloading_model`, `notification_model_ready`). + +**Drive the prebuilt widget** (`MainActivity`): + +```kotlin +val vm = viewModel() +val state by vm.state.collectAsState() + +VoiceAssistantWidget( + state = state.widgetState, + onIntent = vm::processIntent, + modifier = Modifier.fillMaxSize().background(Color.Black), +) +``` + +`VoiceAssistantWidget` is provided by `ai.liquid.leap:leap-ui` (matching the SDK version). The view model forwards intents (e.g. `StartRecording`, `StopRecording`, `Reset`) to `VoiceAssistantStore`, which owns the recorder, player, and conversation. + +**Bridge raw PCM to the SDK as a WAV `ChatMessageContent.Audio`** (`LeapVoiceConversation`): + +```kotlin +override suspend fun generateResponse( + audioSamples: FloatArray, + sampleRate: Int, + onAudioChunk: (samples: FloatArray, sampleRate: Int) -> Unit, +): GenerationStats? { + val wavBytes = encodePcm16Wav(audioSamples, sampleRate) + var stats: GenerationStats? = null + conv.generateResponse( + message = ChatMessage( + role = ChatMessage.Role.USER, + content = listOf(ChatMessageContent.Audio(wavBytes)), + ), + generationOptions = GenerationOptions(), + ).collect { response -> + when (response) { + is MessageResponse.AudioSample -> onAudioChunk(response.samples, response.sampleRate) + is MessageResponse.Complete -> stats = response.stats + else -> Unit + } + } + return stats +} +``` + +The widget calls back with `AudioSample` events as soon as the model emits audio, so playback can begin before generation completes. `AndroidAudioPlayer` buffers samples adaptively based on the measured RTF (between 0.2 s and 3.0 s of pre-roll) to avoid underruns when the model is generating slower than real time. + +## Notes + +- Recording is hard-capped at 60 seconds in `AndroidAudioRecorder` to bound memory use. +- The audio model is single-turn in practice — `VoiceAssistantStore.reset()` swaps in a fresh `Conversation` (carrying the same system prompt) so history does not accumulate between turns. +- `leap-ui`, `leap-sdk`, and `leap-model-downloader` are all version-pinned together in `gradle/libs.versions.toml` via the shared `leapSdk` version reference. +- The downloader queues a single in-flight download per `(modelName, quantization)` pair — re-launching the activity while a download is in progress simply re-attaches to the existing job rather than restarting it. diff --git a/Android/LeapVoiceAssistantDemo/app/src/main/AndroidManifest.xml b/Android/LeapVoiceAssistantDemo/app/src/main/AndroidManifest.xml index 94403ea..14910b3 100644 --- a/Android/LeapVoiceAssistantDemo/app/src/main/AndroidManifest.xml +++ b/Android/LeapVoiceAssistantDemo/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ + onAudioChunk(response.samples, response.sampleRate) is MessageResponse.Complete -> stats = response.stats + is MessageResponse.Error -> throw response.throwable else -> Unit } } diff --git a/Android/LeapVoiceAssistantDemo/app/src/main/kotlin/ai/liquid/leap/uidemo/MainActivity.kt b/Android/LeapVoiceAssistantDemo/app/src/main/kotlin/ai/liquid/leap/uidemo/MainActivity.kt index e2b54fe..408a64f 100644 --- a/Android/LeapVoiceAssistantDemo/app/src/main/kotlin/ai/liquid/leap/uidemo/MainActivity.kt +++ b/Android/LeapVoiceAssistantDemo/app/src/main/kotlin/ai/liquid/leap/uidemo/MainActivity.kt @@ -3,6 +3,7 @@ package ai.liquid.leap.uidemo import ai.liquid.leap.ui.StatusType import ai.liquid.leap.ui.VoiceAssistantWidget import android.Manifest +import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.rememberLauncherForActivityResult @@ -59,9 +60,20 @@ class MainActivity : ComponentActivity() { val vm = viewModel() val state by vm.state.collectAsState() - val permissionLauncher = + val recordAudioLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {} - LaunchedEffect(Unit) { permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) } + LaunchedEffect(Unit) { recordAudioLauncher.launch(Manifest.permission.RECORD_AUDIO) } + + // POST_NOTIFICATIONS is required on Android 13+ for LeapModelDownloader to surface + // download-progress notifications. The model download still succeeds without it; the + // permission only affects user-visible notification delivery. + val postNotificationsLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {} + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + LaunchedEffect(Unit) { + postNotificationsLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } val statusColor = when (state.statusType) { diff --git a/Android/LeapVoiceAssistantDemo/app/src/main/kotlin/ai/liquid/leap/uidemo/VoiceAssistantViewModel.kt b/Android/LeapVoiceAssistantDemo/app/src/main/kotlin/ai/liquid/leap/uidemo/VoiceAssistantViewModel.kt index f2f782f..156eda5 100644 --- a/Android/LeapVoiceAssistantDemo/app/src/main/kotlin/ai/liquid/leap/uidemo/VoiceAssistantViewModel.kt +++ b/Android/LeapVoiceAssistantDemo/app/src/main/kotlin/ai/liquid/leap/uidemo/VoiceAssistantViewModel.kt @@ -1,22 +1,28 @@ package ai.liquid.leap.uidemo import ai.liquid.leap.ModelLoadingOptions -import ai.liquid.leap.manifest.LeapDownloader -import ai.liquid.leap.manifest.LeapDownloaderConfig +import ai.liquid.leap.ModelRunner +import ai.liquid.leap.downloader.LeapModelDownloader +import ai.liquid.leap.downloader.LeapModelDownloaderNotificationConfig import ai.liquid.leap.ui.VoiceAssistantIntent import ai.liquid.leap.ui.VoiceAssistantStore import ai.liquid.leap.ui.VoiceAssistantStoreState import ai.liquid.leap.ui.VoiceAudioPlayer import ai.liquid.leap.ui.VoiceAudioRecorder import android.app.Application +import android.util.Log import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope -import java.io.File +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +private const val TAG = "VoiceAssistantViewModel" private const val MODEL_NAME = "LFM2.5-Audio-1.5B" -private const val QUANTIZATION_SLUG = "Q4_0" +private const val QUANTIZATION_TYPE = "Q4_0" private const val SYSTEM_PROMPT = "Respond with interleaved text and audio." class VoiceAssistantViewModel(application: Application) : AndroidViewModel(application) { @@ -27,7 +33,7 @@ class VoiceAssistantViewModel(application: Application) : AndroidViewModel(appli val state: StateFlow = store.state - private val modelDir = File(application.filesDir, "leap_models").apply { mkdirs() } + @Volatile private var modelRunner: ModelRunner? = null init { viewModelScope.launch { loadModel() } @@ -37,27 +43,56 @@ class VoiceAssistantViewModel(application: Application) : AndroidViewModel(appli private suspend fun loadModel() { runCatching { - val downloader = LeapDownloader(LeapDownloaderConfig(saveDir = modelDir.absolutePath)) - store.setModelProgress(0f, "Resolving manifest\u2026") + val app = getApplication() + val downloader = + LeapModelDownloader( + app, + notificationConfig = + LeapModelDownloaderNotificationConfig.build { + notificationTitleDownloading = + app.getString(R.string.notification_downloading_model) + notificationTitleDownloaded = app.getString(R.string.notification_model_ready) + }, + ) + + store.setModelProgress(0f, "Checking model…") + + if ( + downloader.queryStatus(MODEL_NAME, QUANTIZATION_TYPE) + is LeapModelDownloader.ModelDownloadStatus.NotOnLocal + ) { + downloader.requestDownloadModel(MODEL_NAME, QUANTIZATION_TYPE) + // Single observer that reports progress and exits when the download completes. + // 30-minute timeout matches LeapAudioDemo and accommodates slow connections. + withTimeout(30 * 60 * 1000L) { + downloader.observeDownloadProgress(MODEL_NAME, QUANTIZATION_TYPE).first { progress -> + if (progress != null && progress.totalSizeInBytes > 0) { + val frac = + (progress.downloadedSizeInBytes.toFloat() / progress.totalSizeInBytes.toFloat()) + .coerceIn(0f, 1f) + store.setModelProgress(frac, "Downloading (${(frac * 100).toInt()}%)") + } + progress == null && + downloader.queryStatus(MODEL_NAME, QUANTIZATION_TYPE) is + LeapModelDownloader.ModelDownloadStatus.Downloaded + } + } + } + + store.setModelProgress(1f, "Loading…") val runner = downloader.loadModel( modelName = MODEL_NAME, - quantizationType = QUANTIZATION_SLUG, + quantizationType = QUANTIZATION_TYPE, options = ModelLoadingOptions( cacheOptions = ModelLoadingOptions.cacheOptions( - path = getApplication().cacheDir.resolve("leap-cache").absolutePath, + path = app.cacheDir.resolve("leap-cache").absolutePath ) ), - progress = { pd -> - val pct = if (pd.total > 0) " (${(pd.bytes * 100 / pd.total).toInt()}%)" else "" - store.setModelProgress( - fraction = if (pd.total > 0) pd.bytes.toFloat() / pd.total else 0f, - message = "Downloading$pct", - ) - }, ) + modelRunner = runner store.setConversation( LeapVoiceConversation( conv = runner.createConversation(systemPrompt = SYSTEM_PROMPT), @@ -65,11 +100,22 @@ class VoiceAssistantViewModel(application: Application) : AndroidViewModel(appli ) ) } - .onFailure { e -> store.setModelError("\u2717 ${e.message}") } + .onFailure { e -> store.setModelError("✗ ${e.message}") } } override fun onCleared() { super.onCleared() store.close() + // Unload on a fresh scope: viewModelScope is already cancelled by the time onCleared runs, + // so a launch on it would never execute. Fire-and-forget on IO matches the other demos. + val runner = modelRunner ?: return + modelRunner = null + CoroutineScope(Dispatchers.IO).launch { + try { + runner.unload() + } catch (e: Exception) { + Log.e(TAG, "Error unloading model", e) + } + } } } diff --git a/Android/LeapVoiceAssistantDemo/app/src/main/res/values/strings.xml b/Android/LeapVoiceAssistantDemo/app/src/main/res/values/strings.xml index 1738e33..e53be10 100644 --- a/Android/LeapVoiceAssistantDemo/app/src/main/res/values/strings.xml +++ b/Android/LeapVoiceAssistantDemo/app/src/main/res/values/strings.xml @@ -1,4 +1,6 @@ Leap UI Demo + Downloading Voice Model + Voice Model Ready diff --git a/Android/LeapVoiceAssistantDemo/gradle/libs.versions.toml b/Android/LeapVoiceAssistantDemo/gradle/libs.versions.toml index 4364281..a14c565 100644 --- a/Android/LeapVoiceAssistantDemo/gradle/libs.versions.toml +++ b/Android/LeapVoiceAssistantDemo/gradle/libs.versions.toml @@ -1,8 +1,8 @@ [versions] agp = "8.13.2" -kotlin = "2.3.20" +kotlin = "2.3.21" coreKtx = "1.17.0" -leapSdk = "0.10.7" +leapSdk = "0.10.8" lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.12.3" composeBom = "2026.01.01" diff --git a/Android/README.md b/Android/README.md index df76724..aa782cb 100644 --- a/Android/README.md +++ b/Android/README.md @@ -1,8 +1,32 @@ -LeapSDK Android Example Apps -=== -Here is a list of the example apps: -* [LeapChat](LeapChat/): A simple chat-style app allowing the users to chat with the model. -* [SloganApp](SloganApp/): A slogan generation. -* [ShareAI](ShareAI/): A web content summary generator by sharing the web page to the app. -* [RecipeGenerator](RecipeGenerator/): Example of [constrained generation](https://leap.liquid.ai/docs/edge-sdk/android/constrained-generation). -* [VLMExample](VLMExample): Example of vision language model. +# LeapSDK Android Example Apps + +Gradle-based Android demos using LeapSDK v0.10.8. Each app has its own README with model, build, and integration details. + +## Demos + +| App | What it shows | +| --- | --- | +| [LeapChat](LeapChat/) | Multi-turn chat, streaming, reasoning chunks, function calling (`compute_sum`), conversation persistence with `kotlinx.serialization` | +| [SloganApp](SloganApp/) | Single-turn slogan generation with an Android Views UI | +| [LeapAudioDemo](LeapAudioDemo/) | Audio input/output with streaming playback and `AudioTrack` buffering | +| [ShareAI](ShareAI/) | Receives shared URLs, scrapes the page with Jsoup, and streams a Markdown summary | +| [RecipeGenerator](RecipeGenerator/) | [Constrained JSON generation](https://leap.liquid.ai/docs/edge-sdk/android/constrained-generation) with the `@Generatable` annotation | +| [VLMExample](VLMExample/) | Vision language model — image attachment via `ChatMessageContent.Image` | +| [LeapVoiceAssistantDemo](LeapVoiceAssistantDemo/) | Press-and-hold voice assistant via `VoiceAssistantWidget` from `leap-ui` | +| [LeapKoogAgent](LeapKoogAgent/) | Agent integration with the [Koog framework](https://docs.koog.ai). Requires a vendored `koog-edge` AAR and a manually-pushed model bundle. | + +## Model downloading + +Every demo (except `LeapKoogAgent`, which uses a manually-pushed bundle) uses **`LeapModelDownloader`** from `ai.liquid.leap.downloader`. The downloader runs in a foreground service so downloads survive process death and surface progress in the Android notification tray. The standard pattern is: + +1. `queryStatus(modelName, quantization)` — check the local cache +2. If `NotOnLocal`: `observeDownloadProgress(...)` to drive UI, then `requestDownloadModel(...)` +3. `loadModel(...)` — returns a `ModelRunner` ready for inference + +See `LeapAudioDemo/README.md` or `LeapVoiceAssistantDemo/README.md` for a worked example of the full lifecycle including progress reporting. + +## Prerequisites + +- Android Studio Hedgehog (2023.1.1) or newer +- JDK 21 (e.g. `JAVA_HOME=~/.sdkman/candidates/java/21.0.9-zulu`) +- Android device or emulator with internet access (for the initial model download — except `LeapKoogAgent`, which uses a manually pushed bundle) diff --git a/Android/RecipeGenerator/README.md b/Android/RecipeGenerator/README.md index d49e188..29cb75a 100644 --- a/Android/RecipeGenerator/README.md +++ b/Android/RecipeGenerator/README.md @@ -1,25 +1,130 @@ -Recipe Generator -=== +# Recipe Generator -This example demonstrates how to use LeapSDK to generate structured outputs with constraints. See [Leap documentation](https://leap.liquid.ai/docs/edge-sdk/android/constrained-generation) for more details. +An Android demo that uses the Leap SDK's constrained generation to produce a fully-typed `Recipe` object from a single prompt. Demonstrates `@Generatable` data classes, automatic JSON schema injection, and decoding the streamed JSON back into Kotlin. + +See the [Leap constrained generation docs](https://leap.liquid.ai/docs/edge-sdk/android/constrained-generation) for the underlying concepts. ## Features -- Generates recipe data as structured JSON output using JSON schema constraints -- Uses `LeapDownloader` to automatically download and cache the LFM2-700M model -- Implements kotlinx serialization for type-safe data parsing -- Demonstrates the `@Generatable` annotation for structured output generation +- Structured-output generation via a Kotlin data class annotated with `@Generatable` and `@Guide` +- Automatic JSON schema injection — the SDK builds the schema from the annotated class and constrains the model's decoding +- `GeneratableFactory.createFromJsonObject(...)` parses the model output back into a strongly-typed `Recipe` +- Automatic model download with progress UI via `LeapModelDownloader` +- Jetpack Compose UI that renders the parsed recipe (name, ingredients, cooking time, steps, vegetarian flag) + +## Model + +- **LFM2-700M** (Q8_0) — ~700 M parameter Liquid Foundation Model +- Downloaded automatically on first run via `LeapModelDownloader` +- Cached on-device; KV cache stored under `cacheDir/leap-cache` + +## Prerequisites + +- Android Studio Hedgehog (2023.1.1) or newer +- JDK 21 (with SDKman: `JAVA_HOME=~/.sdkman/candidates/java/21.0.9-zulu`) +- Android device or emulator running API 31 (Android 12) or higher +- Internet connection on first run for model download +- LeapSDK 0.10.8 (pinned in `gradle/libs.versions.toml`) + +## Running + +```bash +./gradlew installDebug +# or open in Android Studio and press Run +``` + +The app starts generating on launch — once the model finishes downloading and loading, the recipe view replaces the status text. + +## Project Structure + +``` +app/src/main/java/ai/liquid/recipegenerator/ +├── MainActivity.kt # Compose UI; renders status or RecipeView +└── MainActivityViewModel.kt # Recipe schema, model load + generate logic +``` + +## Key SDK Patterns + +### Declaring the output schema with `@Generatable` + +The `Recipe` data class doubles as the JSON schema. `@Guide` adds per-field hints that the SDK includes in the schema description, which improves the model's grounding: -## Implementation +```kotlin +@Serializable +@Generatable("A recipe for cooking great dishes") +data class Recipe( + val name: String, -The main business logic is in [MainActivityViewModel.kt](app/src/main/java/ai/liquid/recipegenerator/MainActivityViewModel.kt). + @Guide("Ingredients for making the dishes") + val ingredients: List, -## Requirements + @Guide("Cooking time in minutes") + val cookingTime: Int, -- LeapSDK 0.10.7 -- Android device or emulator with internet connection (for initial model download) + @Guide("Whether the meal is vegetarian") + val isVegetarian: Boolean, -The model will be automatically downloaded and cached on first run via `LeapDownloader`. + @Guide("Steps of cooking") + val steps: List, +) +``` + +### Constraining generation and parsing the result + +`GenerationOptions.setResponseFormatType()` derives the JSON schema from the class and attaches it as `jsonSchemaConstraint`. The SDK injects the schema into the system prompt and constrains decoding so the streamed text is guaranteed to be valid JSON for `Recipe`: + +```kotlin +val conversation = modelRunner.createConversation( + "You are a recipe generator bot that reads the user's message and generates JSON output." +) + +val options = GenerationOptions() +options.setResponseFormatType() + +val buffer = StringBuilder() +conversation.generateResponse("A recipe for a dinner dish", options) + .onEach { response -> + if (response is MessageResponse.Chunk) buffer.append(response.text) + } + .collect() + +val recipe = GeneratableFactory.createFromJsonObject( + LeapJson.parseToJsonElement(buffer.toString()).jsonObject +) +``` + +### Loading the model with `LeapModelDownloader` + +```kotlin +val downloader = LeapModelDownloader( + context, + notificationConfig = LeapModelDownloaderNotificationConfig.build { + notificationTitleDownloading = "Downloading Recipe Generator Model" + notificationTitleDownloaded = "Model Ready!" + }, +) + +if (downloader.queryStatus("LFM2-700M", "Q8_0") is LeapModelDownloader.ModelDownloadStatus.NotOnLocal) { + downloader.requestDownloadModel("LFM2-700M", "Q8_0") + // observe downloader.observeDownloadProgress(...) for UI updates +} + +modelRunner = downloader.loadModel( + modelName = "LFM2-700M", + quantizationType = "Q8_0", + options = ModelLoadingOptions( + cacheOptions = ModelLoadingOptions.cacheOptions( + path = context.cacheDir.resolve("leap-cache").absolutePath, + ), + ), +) +``` ## Screenshot + + +## Notes + +- The model is unloaded asynchronously in `ViewModel.onCleared()` on a background dispatcher to avoid blocking the main thread. +- `POST_NOTIFICATIONS` is requested on Android 13+ so the downloader's progress notification can be shown. diff --git a/Android/RecipeGenerator/app/build.gradle.kts b/Android/RecipeGenerator/app/build.gradle.kts index c957075..fea1755 100644 --- a/Android/RecipeGenerator/app/build.gradle.kts +++ b/Android/RecipeGenerator/app/build.gradle.kts @@ -1,67 +1,60 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { - alias(libs.plugins.android.application) - alias(libs.plugins.kotlin.android) - alias(libs.plugins.kotlin.compose) - alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.com.ncorti.ktfmt.gradle) } +ktfmt { googleStyle() } + android { - namespace = "ai.liquid.recipegenerator" - compileSdk = 36 + namespace = "ai.liquid.recipegenerator" + compileSdk = 36 - defaultConfig { - applicationId = "ai.liquid.recipegenerator" - minSdk = 31 - targetSdk = 36 - versionCode = 1 - versionName = "1.0" + defaultConfig { + applicationId = "ai.liquid.recipegenerator" + minSdk = 31 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - } + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } - buildTypes { - release { - isMinifyEnabled = false - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" - ) - } - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 - } - kotlin { - compilerOptions { - jvmTarget.set(JvmTarget.JVM_11) - } - } - buildFeatures { - compose = true + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_11) } } + buildFeatures { compose = true } } dependencies { - - implementation(libs.androidx.core.ktx) - implementation(libs.androidx.lifecycle.runtime.ktx) - implementation(libs.androidx.activity.compose) - implementation(platform(libs.androidx.compose.bom)) - implementation(libs.androidx.ui) - implementation(libs.androidx.ui.graphics) - implementation(libs.androidx.ui.tooling.preview) - implementation(libs.androidx.material3) - implementation(libs.leap.sdk) - implementation(libs.leap.model.downloader) - implementation(libs.kotlinx.serialization.json) - testImplementation(libs.junit) - androidTestImplementation(libs.androidx.junit) - androidTestImplementation(libs.androidx.espresso.core) - androidTestImplementation(platform(libs.androidx.compose.bom)) - androidTestImplementation(libs.androidx.ui.test.junit4) - debugImplementation(libs.androidx.ui.tooling) - debugImplementation(libs.androidx.ui.test.manifest) -} \ No newline at end of file + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + implementation(libs.leap.sdk) + implementation(libs.leap.model.downloader) + implementation(libs.kotlinx.serialization.json) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) +} diff --git a/Android/RecipeGenerator/app/src/main/java/ai/liquid/recipegenerator/MainActivity.kt b/Android/RecipeGenerator/app/src/main/java/ai/liquid/recipegenerator/MainActivity.kt index 0ead0a4..58fc330 100644 --- a/Android/RecipeGenerator/app/src/main/java/ai/liquid/recipegenerator/MainActivity.kt +++ b/Android/RecipeGenerator/app/src/main/java/ai/liquid/recipegenerator/MainActivity.kt @@ -17,111 +17,120 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat -import androidx.lifecycle.lifecycleScope -import kotlinx.coroutines.launch class MainActivity : ComponentActivity() { - val viewModel: MainActivityViewModel by viewModels() + val viewModel: MainActivityViewModel by viewModels() - private val requestPermissionLauncher = registerForActivityResult( - ActivityResultContracts.RequestPermission() - ) { isGranted -> - if (isGranted) { - startGeneration() - } else { - // Permission denied, proceed without notifications - startGeneration() - } + private val requestPermissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { _ -> + // Permission outcome doesn't affect generation; the downloader can run without it, + // it just won't surface progress in the notification tray. + startGeneration() } - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdge() + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() - // Check and request notification permission for Android 13+ - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - when { - ContextCompat.checkSelfPermission( - this, - android.Manifest.permission.POST_NOTIFICATIONS - ) == PackageManager.PERMISSION_GRANTED -> { - startGeneration() - } - else -> { - requestPermissionLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS) - } - } - } else { - startGeneration() - } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + when { + ContextCompat.checkSelfPermission(this, android.Manifest.permission.POST_NOTIFICATIONS) == + PackageManager.PERMISSION_GRANTED -> startGeneration() + else -> requestPermissionLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS) + } + } else { + startGeneration() + } - setContent { - Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - val recipe = viewModel.recipeState - if (recipe == null) { - Column( - modifier = Modifier - .padding(innerPadding) - .fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Text( - text = viewModel.status, - fontStyle = FontStyle.Italic, - fontSize = 24.sp, - textAlign = TextAlign.Center - ) - } - } else { - Column(Modifier.padding(innerPadding)) { - RecipeView(recipe, modifier = Modifier.padding(8.dp)) - } - } - } + setContent { + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + val recipe = viewModel.recipeState + if (recipe == null) { + Column( + modifier = Modifier.padding(innerPadding).fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = statusText(viewModel.status), + fontStyle = FontStyle.Italic, + fontSize = 24.sp, + textAlign = TextAlign.Center, + ) + } + } else { + Column(Modifier.padding(innerPadding)) { + RecipeView(recipe, modifier = Modifier.padding(8.dp)) + } } + } } + } - private fun startGeneration() { - viewModel.generateRecipe(this) - } + private fun startGeneration() { + viewModel.generateRecipe(this) + } } +/** Resolves a [Status] to a user-facing localized string. */ +@Composable +private fun statusText(status: Status): String = + when (status) { + is Status.NotReady -> stringResource(R.string.status_not_ready) + is Status.CheckingModel -> stringResource(R.string.status_checking_model) + is Status.StartingDownload -> stringResource(R.string.status_starting_download) + is Status.Downloading -> + stringResource( + R.string.status_downloading, + status.percentage, + status.downloadedMb, + status.totalMb, + ) + is Status.DownloadComplete -> stringResource(R.string.status_download_complete) + is Status.LoadingModel -> stringResource(R.string.status_loading_model) + is Status.ModelLoaded -> stringResource(R.string.status_model_loaded) + is Status.Generating -> stringResource(R.string.status_generating) + is Status.Success -> stringResource(R.string.status_success) + is Status.Error -> + if (status.generatedText.isNullOrEmpty()) { + stringResource(R.string.status_error, status.message) + } else { + stringResource(R.string.status_error_with_text, status.message, status.generatedText) + } + } + @Composable fun RecipeView(recipe: Recipe, modifier: Modifier = Modifier) { - Column(modifier) { - Text(recipe.name, fontSize = 24.sp) - Text( - "Cooking time: ${recipe.cookingTime} min", - fontSize = 18.sp, - modifier = Modifier.padding(top = 10.dp) - ) - Text( - "Vegetarian: ${ - if (recipe.isVegetarian) { - "Yes" - } else { - "No" - } - }", fontSize = 18.sp, - modifier = Modifier.padding(top = 10.dp) - ) - Text("Ingredients", fontSize = 18.sp, modifier = Modifier.padding(top = 10.dp)) - Column { - recipe.ingredients.map { item -> - Text(" $item") - } - } - Text("Steps", fontSize = 18.sp, modifier = Modifier.padding(top = 10.dp)) - Column { - recipe.steps.map { item -> - Text(" $item") - } - } - } -} \ No newline at end of file + Column(modifier) { + Text(recipe.name, fontSize = 24.sp) + Text( + stringResource(R.string.recipe_cooking_time, recipe.cookingTime), + fontSize = 18.sp, + modifier = Modifier.padding(top = 10.dp), + ) + Text( + if (recipe.isVegetarian) stringResource(R.string.recipe_vegetarian_yes) + else stringResource(R.string.recipe_vegetarian_no), + fontSize = 18.sp, + modifier = Modifier.padding(top = 10.dp), + ) + Text( + stringResource(R.string.recipe_ingredients_header), + fontSize = 18.sp, + modifier = Modifier.padding(top = 10.dp), + ) + Column { recipe.ingredients.map { item -> Text(" $item") } } + Text( + stringResource(R.string.recipe_steps_header), + fontSize = 18.sp, + modifier = Modifier.padding(top = 10.dp), + ) + Column { recipe.steps.map { item -> Text(" $item") } } + } +} diff --git a/Android/RecipeGenerator/app/src/main/java/ai/liquid/recipegenerator/MainActivityViewModel.kt b/Android/RecipeGenerator/app/src/main/java/ai/liquid/recipegenerator/MainActivityViewModel.kt index 0f8cf35..983e7bc 100644 --- a/Android/RecipeGenerator/app/src/main/java/ai/liquid/recipegenerator/MainActivityViewModel.kt +++ b/Android/RecipeGenerator/app/src/main/java/ai/liquid/recipegenerator/MainActivityViewModel.kt @@ -1,191 +1,209 @@ package ai.liquid.recipegenerator -import ai.liquid.leap.LeapClient +import ai.liquid.leap.GenerationOptions import ai.liquid.leap.LeapJson import ai.liquid.leap.ModelLoadingOptions import ai.liquid.leap.ModelRunner -import ai.liquid.leap.GenerationOptions import ai.liquid.leap.downloader.LeapModelDownloader import ai.liquid.leap.downloader.LeapModelDownloaderNotificationConfig import ai.liquid.leap.message.MessageResponse -import android.content.Context -import android.util.Log import ai.liquid.leap.structuredoutput.Generatable import ai.liquid.leap.structuredoutput.GeneratableFactory import ai.liquid.leap.structuredoutput.Guide +import android.content.Context +import android.util.Log import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.launch -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.serialization.Serializable import kotlinx.serialization.json.jsonObject - @Serializable @Generatable("A recipe for cooking great dishes") data class Recipe( - val name: String, + val name: String, + @Guide("Ingredients for making the dishes") val ingredients: List, + @Guide("Cooking time in minutes") val cookingTime: Int, + @Guide("Whether the meal is vegetarian") val isVegetarian: Boolean, + @Guide("Steps of cooking") val steps: List, +) - @Guide("Ingredients for making the dishes") - val ingredients: List, +/** UI status for the recipe-generation flow. Resolved to a localized string in the composable. */ +sealed interface Status { + data object NotReady : Status - @Guide("Cooking time in minutes") - val cookingTime: Int, + data object CheckingModel : Status - @Guide("Whether the meal is vegetarian") - val isVegetarian: Boolean, + data object StartingDownload : Status - @Guide("Steps of cooking") - val steps: List, + data class Downloading(val percentage: Int, val downloadedMb: Long, val totalMb: Long) : Status -) + data object DownloadComplete : Status -class MainActivityViewModel: ViewModel() { - private var modelRunner: ModelRunner? = null - var recipeState: Recipe? by mutableStateOf(null) - var status: String by mutableStateOf("Not ready to generate") - - private val modelName = "LFM2-700M" - private val quantType = "Q8_0" - private var downloader: LeapModelDownloader? = null - - private fun getDownloader(context: Context): LeapModelDownloader { - if (downloader == null) { - downloader = LeapModelDownloader( - context, - notificationConfig = LeapModelDownloaderNotificationConfig.build { - notificationTitleDownloading = "Downloading Recipe Generator Model" - notificationTitleDownloaded = "Model Ready!" - } - ) - } - return downloader!! - } + data object LoadingModel : Status - suspend fun loadModel(context: Context) { - val downloader = getDownloader(context) - - status = "Checking model status..." - val currentStatus = downloader.queryStatus(modelName, quantType) - - if (currentStatus is LeapModelDownloader.ModelDownloadStatus.NotOnLocal) { - // Model needs to be downloaded - status = "Starting download..." - - // Observe download progress - val progressFlow = downloader.observeDownloadProgress(modelName, quantType) - - // Start the download - downloader.requestDownloadModel(modelName, quantType) - - // Collect progress updates until download completes - progressFlow - .onEach { progress -> - if (progress != null) { - val downloadedMB = progress.downloadedSizeInBytes / (1024 * 1024) - val totalMB = progress.totalSizeInBytes / (1024 * 1024) - val percentage = if (progress.totalSizeInBytes > 0) { - (progress.downloadedSizeInBytes * 100.0 / progress.totalSizeInBytes).toInt() - } else { - 0 - } - status = "Downloading: $percentage% ($downloadedMB MB / $totalMB MB)" - } else { - val downloadStatus = downloader.queryStatus(modelName, quantType) - if (downloadStatus is LeapModelDownloader.ModelDownloadStatus.Downloaded) { - status = "Download complete!" - } - } - } - .takeWhile { progress -> - progress != null || downloader.queryStatus(modelName, quantType) is LeapModelDownloader.ModelDownloadStatus.DownloadInProgress - } - .collect() - } + data object ModelLoaded : Status - // Load the model - status = "Loading model..." - try { - modelRunner = downloader.loadModel( - modelName = modelName, - quantizationType = quantType, - options = ModelLoadingOptions( - cacheOptions = ModelLoadingOptions.cacheOptions( - path = context.cacheDir.resolve("leap-cache").absolutePath, - ), - ), - ) - status = "Model loaded" - } catch (e: Exception) { - Log.e("RecipeGenerator", "Error loading model", e) - status = "Error: ${e.message}" - } - } + data object Generating : Status - fun generateRecipe(context: Context) { - viewModelScope.launch { - if (modelRunner == null) { - loadModel(context) - } - val modelRunner = checkNotNull(modelRunner) - status = "Generating the recipe..." - // Simple system prompt - the schema will be automatically injected by the SDK - val conversation = modelRunner.createConversation( - "You are a recipe generator bot that reads the user's message and generates JSON output." - ) - val options = GenerationOptions() - options.setResponseFormatType() - Log.d(MainActivityViewModel::class.simpleName, "Generating recipe with automatic schema injection. Constraint: ${options.jsonSchemaConstraint}") - val buffer = StringBuilder() - try { - conversation.generateResponse("A recipe for a dinner dish", options) - .onEach { response -> - when (response) { - is MessageResponse.Chunk -> { - buffer.append(response.text) - } - - else -> { - // ignore - } - } - }.collect() - - val generatedText = buffer.toString() - Log.d("RecipeGenerator", "Generated text: $generatedText") - - val recipe = GeneratableFactory.createFromJsonObject( - LeapJson.parseToJsonElement(generatedText).jsonObject - ) - Log.d("RecipeGenerator", "Successfully parsed recipe: ${recipe.name}") - recipeState = recipe - status = "Recipe generated successfully!" - } catch (e: Exception) { - Log.e("RecipeGenerator", "Error generating recipe", e) - status = "Error: ${e.message}\n\nGenerated text:\n${buffer.toString()}" + data object Success : Status + + data class Error(val message: String, val generatedText: String? = null) : Status +} + +class MainActivityViewModel : ViewModel() { + private var modelRunner: ModelRunner? = null + var recipeState: Recipe? by mutableStateOf(null) + var status: Status by mutableStateOf(Status.NotReady) + + private val modelName = "LFM2-700M" + private val quantType = "Q8_0" + private var downloader: LeapModelDownloader? = null + + private fun getDownloader(context: Context): LeapModelDownloader { + if (downloader == null) { + downloader = + LeapModelDownloader( + context, + notificationConfig = + LeapModelDownloaderNotificationConfig.build { + notificationTitleDownloading = + context.getString(R.string.notification_downloading_model) + notificationTitleDownloaded = context.getString(R.string.notification_model_ready) + }, + ) + } + return downloader!! + } + + suspend fun loadModel(context: Context) { + val downloader = getDownloader(context) + + status = Status.CheckingModel + val currentStatus = downloader.queryStatus(modelName, quantType) + + if (currentStatus is LeapModelDownloader.ModelDownloadStatus.NotOnLocal) { + status = Status.StartingDownload + + val progressFlow = downloader.observeDownloadProgress(modelName, quantType) + + downloader.requestDownloadModel(modelName, quantType) + + progressFlow + .onEach { progress -> + if (progress != null) { + val downloadedMb = progress.downloadedSizeInBytes / (1024 * 1024) + val totalMb = progress.totalSizeInBytes / (1024 * 1024) + val percentage = + if (progress.totalSizeInBytes > 0) { + (progress.downloadedSizeInBytes * 100.0 / progress.totalSizeInBytes).toInt() + } else { + 0 + } + status = Status.Downloading(percentage, downloadedMb, totalMb) + } else { + val downloadStatus = downloader.queryStatus(modelName, quantType) + if (downloadStatus is LeapModelDownloader.ModelDownloadStatus.Downloaded) { + status = Status.DownloadComplete } + } + } + .takeWhile { progress -> + progress != null || + downloader.queryStatus(modelName, quantType) is + LeapModelDownloader.ModelDownloadStatus.DownloadInProgress } + .collect() } - override fun onCleared() { - super.onCleared() - - // Unload model asynchronously to avoid ANR - // Do NOT use runBlocking here - it blocks the main thread - CoroutineScope(Dispatchers.IO).launch { - try { - modelRunner?.unload() - } catch (e: Exception) { - Log.e("RecipeGenerator", "Error unloading model", e) + status = Status.LoadingModel + try { + modelRunner = + downloader.loadModel( + modelName = modelName, + quantizationType = quantType, + options = + ModelLoadingOptions( + cacheOptions = + ModelLoadingOptions.cacheOptions( + path = context.cacheDir.resolve("leap-cache").absolutePath + ) + ), + ) + status = Status.ModelLoaded + } catch (e: Exception) { + Log.e("RecipeGenerator", "Error loading model", e) + status = Status.Error(message = e.message ?: "unknown error") + } + } + + fun generateRecipe(context: Context) { + viewModelScope.launch { + if (modelRunner == null) { + loadModel(context) + } + val runner = checkNotNull(modelRunner) + status = Status.Generating + val conversation = + runner.createConversation( + "You are a recipe generator bot that reads the user's message and generates JSON output." + ) + val options = GenerationOptions() + options.setResponseFormatType() + Log.d( + MainActivityViewModel::class.simpleName, + "Generating recipe with automatic schema injection. Constraint: ${options.jsonSchemaConstraint}", + ) + val buffer = StringBuilder() + try { + conversation + .generateResponse("A recipe for a dinner dish", options) + .onEach { response -> + when (response) { + is MessageResponse.Chunk -> buffer.append(response.text) + is MessageResponse.Error -> throw response.throwable + else -> Unit } - } + } + .collect() + + val generatedText = buffer.toString() + Log.d("RecipeGenerator", "Generated text: $generatedText") + + val recipe = + GeneratableFactory.createFromJsonObject( + LeapJson.parseToJsonElement(generatedText).jsonObject + ) + Log.d("RecipeGenerator", "Successfully parsed recipe: ${recipe.name}") + recipeState = recipe + status = Status.Success + } catch (e: Exception) { + Log.e("RecipeGenerator", "Error generating recipe", e) + status = + Status.Error(message = e.message ?: "unknown error", generatedText = buffer.toString()) + } + } + } + + override fun onCleared() { + super.onCleared() + + // Unload model asynchronously to avoid ANR. Do NOT use runBlocking — it blocks the main + // thread. viewModelScope is already cancelled at this point, so use a dedicated scope. + CoroutineScope(Dispatchers.IO).launch { + try { + modelRunner?.unload() + } catch (e: Exception) { + Log.e("RecipeGenerator", "Error unloading model", e) + } } -} \ No newline at end of file + } +} diff --git a/Android/RecipeGenerator/app/src/main/res/values/strings.xml b/Android/RecipeGenerator/app/src/main/res/values/strings.xml index 962a991..7b7fd90 100644 --- a/Android/RecipeGenerator/app/src/main/res/values/strings.xml +++ b/Android/RecipeGenerator/app/src/main/res/values/strings.xml @@ -1,3 +1,27 @@ RecipeGenerator - \ No newline at end of file + + + Not ready to generate + Checking model status… + Starting download… + Downloading: %1$d%% (%2$d MB / %3$d MB) + Download complete! + Loading model… + Model loaded + Generating the recipe… + Recipe generated successfully! + Error: %1$s + Error: %1$s\n\nGenerated text:\n%2$s + + + Downloading Recipe Generator Model + Model Ready! + + + Cooking time: %1$d min + Vegetarian: Yes + Vegetarian: No + Ingredients + Steps + diff --git a/Android/RecipeGenerator/gradle/libs.versions.toml b/Android/RecipeGenerator/gradle/libs.versions.toml index 15c7e91..2885e6e 100644 --- a/Android/RecipeGenerator/gradle/libs.versions.toml +++ b/Android/RecipeGenerator/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] agp = "8.13.2" -kotlin = "2.3.20" +kotlin = "2.3.21" kotlinxSerialization = "1.10.0" coreKtx = "1.17.0" junit = "4.13.2" @@ -9,7 +9,8 @@ espressoCore = "3.7.0" lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.12.3" composeBom = "2026.01.01" -leapSdk = "0.10.7" +leapSdk = "0.10.8" +ktfmt = "0.25.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -35,4 +36,5 @@ android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +com-ncorti-ktfmt-gradle = { id = "com.ncorti.ktfmt.gradle", version.ref = "ktfmt" } diff --git a/Android/ShareAI/README.md b/Android/ShareAI/README.md index 4fc4a2c..16f8718 100644 --- a/Android/ShareAI/README.md +++ b/Android/ShareAI/README.md @@ -1,7 +1,99 @@ # ShareAI -Sharing the web content to this ShareAI app and get quick summary of the web page. -This example combines the local LLM ability of LeapSDK with web scraping to build a summarizer. +Share any web URL to ShareAI from the Android share sheet and get a streaming on-device summary. Combines Jsoup-based web scraping with the Leap SDK to keep the entire summarization pipeline local after the page has been fetched. + +## Features + +- Registered as a `text/plain` share target via `ACTION_SEND` — share a link from any app to summarize it +- Scrapes the shared page with Jsoup, stripping `script`, `style`, `nav`, `header`, `footer`, and `aside` elements before sending the cleaned text to the model +- Streams the summary in real time with Markdown rendering (Compose Richtext + Commonmark) +- Two-ViewModel architecture cleanly separating web fetching from LLM inference +- Falls back to a built-in Liquid AI blog URL when launched without a shared link (handy for quick testing) +- Automatic model download with progress reporting via `LeapModelDownloader` + +## Model + +- **LFM2.5-350M** (`Q8_0`) — downloaded automatically by `LeapModelDownloader` on first launch +- Cached under `context.cacheDir/leap-cache/` +- Summarization prompt: `"Make a summary less than 500 words of the following text. Use Markdown If needed:\n\n"` followed by the scraped page text (see `AIChatViewModel.PROMPT`) + +## Prerequisites + +- Android Studio Hedgehog (2023.1.1) or newer +- JDK 21 (use `JAVA_HOME=~/.sdkman/candidates/java/21.0.9-zulu` if managing JDKs with SDKman) +- Android device or emulator on API 33 (Android 13) or higher +- `INTERNET` permission (declared) and an active network connection for both the model download and page scraping + +## Running + +```bash +./gradlew installDebug +# or open the project in Android Studio and press Run +``` + +To exercise the share flow: open Chrome (or any browser), tap the system share button on the current page, and pick **ShareAI** from the share sheet. The app launches with the URL pre-populated, scrapes the page, then summarizes. + +## Project Structure + +``` +app/src/main/java/com/leap/shareai/ +├── MainActivity.kt # Reads ACTION_SEND intent, hosts Compose tree +├── screens/SummaryAppScreenUI.kt # Main screen, wires both ViewModels together +├── viewmodels/ +│ ├── WebScrapingViewModel.kt # Jsoup-based fetch + cleanup on Dispatchers.IO +│ └── AIChatViewModel.kt # Leap SDK lifecycle, streaming summary state +├── webscraping/{WebPageContent,WebPageState}.kt +└── model/ChatMessageDisplayItem.kt +``` + +## Key SDK Patterns + +**Receive a shared URL** (`MainActivity.onCreate` and `onNewIntent`): + +```kotlin +val sharedUrl = intent.takeIf { + it.action == Intent.ACTION_SEND && it.type == "text/plain" +}?.getStringExtra(Intent.EXTRA_TEXT) +// When launched without a share intent, the activity inlines a fallback Liquid AI blog URL. +setContent { ThemeShareAi { SummaryAppScreen(linkUrl = sharedUrl ?: fallbackUrl) } } +``` + +`launchMode="singleTop"` in the manifest plus the duplicated `onNewIntent` handler ensure subsequent shares replace the current page instead of stacking activities. + +**Scrape the page off the main thread** (`WebScrapingViewModel.scrapeWebPage`): + +```kotlin +val document = Jsoup.connect(url) + .userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") + .timeout(10_000) + .get() +document.select("script, style, nav, header, footer, aside").remove() +WebPageContent(title = document.title(), text = document.body().text().trim(), url = url) +``` + +**Stream the summary** (`AIChatViewModel.generateResponse`): + +```kotlin +conv.generateResponse(PROMPT + scrapedText) + .onCompletion { /* finalize state */ } + .catch { e -> _state.value = LeapState.Error("Error: ${e.message}") } + .collect { response -> + when (response) { + is MessageResponse.Chunk -> { currentResponseText.append(response.text); _responseChunks.emit(response.text) } + is MessageResponse.ReasoningChunk -> { currentReasoningText.append(response.reasoning); _reasoningChunks.emit(response.reasoning) } + is MessageResponse.Complete -> _messages.update { it + ChatMessageDisplayItem(ASSISTANT, currentResponseText.toString()) } + else -> Unit + } + } +``` + +The ViewModel exposes both a `StateFlow>` for completed messages and a `SharedFlow` of raw chunks so the UI can render a typewriter effect while the message is still streaming. `modelRunner?.unload()` is invoked from `onCleared()` on `Dispatchers.IO` to avoid ANRs. ## Screen recording - + + + +## Notes + +- `usesCleartextTraffic="true"` is set in the manifest so HTTP (non-HTTPS) URLs can be scraped. Remove this if you want to restrict ShareAI to HTTPS-only. +- The default fallback URL points at a Liquid AI blog post, so launching the app from the launcher (not the share sheet) immediately produces a summary you can use to validate the model is working. diff --git a/Android/ShareAI/app/build.gradle.kts b/Android/ShareAI/app/build.gradle.kts index ae5b557..3ed9e31 100644 --- a/Android/ShareAI/app/build.gradle.kts +++ b/Android/ShareAI/app/build.gradle.kts @@ -1,72 +1,65 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { - alias(libs.plugins.android.application) - alias(libs.plugins.kotlin.android) - alias(libs.plugins.kotlin.compose) + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.com.ncorti.ktfmt.gradle) } +ktfmt { googleStyle() } + android { - namespace = "com.leap.shareai" - compileSdk = 36 + namespace = "com.leap.shareai" + compileSdk = 36 - defaultConfig { - applicationId = "com.leap.shareai" - minSdk = 33 - targetSdk = 36 - versionCode = 1 - versionName = "1.0" + defaultConfig { + applicationId = "com.leap.shareai" + minSdk = 33 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - } + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } - buildTypes { - release { - isMinifyEnabled = false - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" - ) - } - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 - } - kotlin { - compilerOptions { - jvmTarget.set(JvmTarget.JVM_11) - } - } - buildFeatures { - compose = true + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_11) } } + buildFeatures { compose = true } } dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + implementation(libs.androidx.material.icons.extended) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) - implementation(libs.androidx.core.ktx) - implementation(libs.androidx.lifecycle.runtime.ktx) - implementation(libs.androidx.activity.compose) - implementation(platform(libs.androidx.compose.bom)) - implementation(libs.androidx.ui) - implementation(libs.androidx.ui.graphics) - implementation(libs.androidx.ui.tooling.preview) - implementation(libs.androidx.material3) - implementation(libs.androidx.material.icons.extended) - testImplementation(libs.junit) - androidTestImplementation(libs.androidx.junit) - androidTestImplementation(libs.androidx.espresso.core) - androidTestImplementation(platform(libs.androidx.compose.bom)) - androidTestImplementation(libs.androidx.ui.test.junit4) - debugImplementation(libs.androidx.ui.tooling) - debugImplementation(libs.androidx.ui.test.manifest) - - implementation(libs.leap.sdk) - implementation(libs.leap.model.downloader) - implementation(libs.lifecycle.viewmodel.compose) - implementation(libs.jsoup) + implementation(libs.leap.sdk) + implementation(libs.leap.model.downloader) + implementation(libs.lifecycle.viewmodel.compose) + implementation(libs.jsoup) - implementation(libs.commonmark) - implementation(libs.richtext) -} \ No newline at end of file + implementation(libs.commonmark) + implementation(libs.richtext) +} diff --git a/Android/ShareAI/app/src/main/java/com/leap/shareai/MainActivity.kt b/Android/ShareAI/app/src/main/java/com/leap/shareai/MainActivity.kt index c5df057..9e2b89a 100644 --- a/Android/ShareAI/app/src/main/java/com/leap/shareai/MainActivity.kt +++ b/Android/ShareAI/app/src/main/java/com/leap/shareai/MainActivity.kt @@ -10,47 +10,28 @@ import androidx.activity.enableEdgeToEdge import com.leap.shareai.screens.SummaryAppScreen import com.leap.shareai.ui.theme.ThemeShareAi +private const val DEFAULT_URL = + "https://www.liquid.ai/blog/liquid-foundation-models-v2-our-second-series-of-generative-ai-models" + class MainActivity : ComponentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdge() - val sharedUrl = intent.takeIf { - it.action == Intent.ACTION_SEND && it.type == "text/plain" - }?.getStringExtra(Intent.EXTRA_TEXT) - Log.d("MainActivity", "onCreate: ${sharedUrl.toString()}") - setContent { - ThemeShareAi { - if (sharedUrl != null) { - SummaryAppScreen( - linkUrl = sharedUrl - ) - } else { - SummaryAppScreen( - linkUrl = "https://www.liquid.ai/blog/liquid-foundation-models-v2-our-second-series-of-generative-ai-models" - ) - } - } - } - } + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + val sharedUrl = + intent + .takeIf { it.action == Intent.ACTION_SEND && it.type == "text/plain" } + ?.getStringExtra(Intent.EXTRA_TEXT) + Log.d("MainActivity", "onCreate: ${sharedUrl.toString()}") + setContent { ThemeShareAi { SummaryAppScreen(linkUrl = sharedUrl ?: DEFAULT_URL) } } + } - override fun onNewIntent(intent: Intent, caller: ComponentCaller) { - super.onNewIntent(intent, caller) - val sharedUrl = intent.takeIf { - it.action == Intent.ACTION_SEND && it.type == "text/plain" - }?.getStringExtra(Intent.EXTRA_TEXT) - Log.d("MainActivity", "onNewIntent: ${sharedUrl.toString()}") - setContent { - ThemeShareAi { - if (sharedUrl != null) { - SummaryAppScreen( - linkUrl = sharedUrl - ) - } else { - SummaryAppScreen( - linkUrl = "https://www.liquid.ai/blog/liquid-foundation-models-v2-our-second-series-of-generative-ai-models" - ) - } - } - } - } -} \ No newline at end of file + override fun onNewIntent(intent: Intent, caller: ComponentCaller) { + super.onNewIntent(intent, caller) + val sharedUrl = + intent + .takeIf { it.action == Intent.ACTION_SEND && it.type == "text/plain" } + ?.getStringExtra(Intent.EXTRA_TEXT) + Log.d("MainActivity", "onNewIntent: ${sharedUrl.toString()}") + setContent { ThemeShareAi { SummaryAppScreen(linkUrl = sharedUrl ?: DEFAULT_URL) } } + } +} diff --git a/Android/ShareAI/app/src/main/java/com/leap/shareai/model/ChatMessageDisplayItem.kt b/Android/ShareAI/app/src/main/java/com/leap/shareai/model/ChatMessageDisplayItem.kt index 0d60278..bd44f4f 100644 --- a/Android/ShareAI/app/src/main/java/com/leap/shareai/model/ChatMessageDisplayItem.kt +++ b/Android/ShareAI/app/src/main/java/com/leap/shareai/model/ChatMessageDisplayItem.kt @@ -2,4 +2,8 @@ package com.leap.shareai.model import ai.liquid.leap.message.ChatMessage -data class ChatMessageDisplayItem(val role: ChatMessage.Role, val text: String, val reasoning: String? = null) \ No newline at end of file +data class ChatMessageDisplayItem( + val role: ChatMessage.Role, + val text: String, + val reasoning: String? = null, +) diff --git a/Android/ShareAI/app/src/main/java/com/leap/shareai/screens/SummaryAppScreenUI.kt b/Android/ShareAI/app/src/main/java/com/leap/shareai/screens/SummaryAppScreenUI.kt index 50d6912..2b3e599 100644 --- a/Android/ShareAI/app/src/main/java/com/leap/shareai/screens/SummaryAppScreenUI.kt +++ b/Android/ShareAI/app/src/main/java/com/leap/shareai/screens/SummaryAppScreenUI.kt @@ -45,10 +45,11 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -58,465 +59,416 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.halilibo.richtext.commonmark.CommonmarkAstNodeParser import com.halilibo.richtext.markdown.BasicMarkdown import com.halilibo.richtext.ui.material3.RichText +import com.leap.shareai.R import com.leap.shareai.viewmodels.AIChatViewModel import com.leap.shareai.viewmodels.LeapState import com.leap.shareai.viewmodels.WebScrapingViewModel import com.leap.shareai.webscraping.WebPageState enum class ProcessingStatus { - LOADING_MODEL, - SCRAPING_WEBPAGE, - THINKING, - GENERATING_SUMMARY, - COMPLETED, - ERROR + LOADING_MODEL, + SCRAPING_WEBPAGE, + THINKING, + GENERATING_SUMMARY, + COMPLETED, + ERROR, } -data class StatusStep( - val status: ProcessingStatus, - val title: String, - val description: String -) +data class StatusStep(val status: ProcessingStatus, val titleRes: Int, val descriptionRes: Int) @Composable fun SummaryAppScreen( - modifier: Modifier = Modifier, - linkUrl: String, - webScrapingViewModel: WebScrapingViewModel = viewModel(), - aiChatViewModel: AIChatViewModel = viewModel() + modifier: Modifier = Modifier, + linkUrl: String, + webScrapingViewModel: WebScrapingViewModel = viewModel(), + aiChatViewModel: AIChatViewModel = viewModel(), ) { - var currentStatus by remember { mutableStateOf(ProcessingStatus.LOADING_MODEL) } - var isProcessing by remember { mutableStateOf(true) } - var summaryText by remember { mutableStateOf("") } - var errorText by remember { mutableStateOf("") } - - val aiChatState by aiChatViewModel.state - val webScrapingState by webScrapingViewModel.state - - val isLoading by aiChatViewModel.isLoading.collectAsState(false) - val context = LocalContext.current - - LaunchedEffect(Unit) { aiChatViewModel.loadModel(context) } - LaunchedEffect(isLoading) { - when { - isLoading -> { - currentStatus = ProcessingStatus.LOADING_MODEL - isProcessing = true - } - - else -> { - Log.d("SummaryAppScreenUI", "Request to load url") - webScrapingViewModel.extractTextFromUrl( - url = linkUrl - ) - } - } + var currentStatus by remember { mutableStateOf(ProcessingStatus.LOADING_MODEL) } + var isProcessing by remember { mutableStateOf(true) } + var summaryText by remember { mutableStateOf("") } + var errorText by remember { mutableStateOf("") } + + val aiChatState by aiChatViewModel.state + val webScrapingState by webScrapingViewModel.state + + val isLoading by aiChatViewModel.isLoading.collectAsState(false) + val context = LocalContext.current + + LaunchedEffect(Unit) { aiChatViewModel.loadModel(context) } + LaunchedEffect(isLoading) { + when { + isLoading -> { + currentStatus = ProcessingStatus.LOADING_MODEL + isProcessing = true + } + + else -> { + Log.d("SummaryAppScreenUI", "Request to load url") + webScrapingViewModel.extractTextFromUrl(url = linkUrl) + } } + } - val message by aiChatViewModel.responseChunks.collectAsState("") - val messages = remember { mutableStateOf("") } + val message by aiChatViewModel.responseChunks.collectAsState("") + val messages = remember { mutableStateOf("") } - val reasoningChunks by aiChatViewModel.reasoningChunks.collectAsState("") - val reasoningMessages = remember { mutableStateOf("") } + val reasoningChunks by aiChatViewModel.reasoningChunks.collectAsState("") + val reasoningMessages = remember { mutableStateOf("") } - LaunchedEffect(reasoningChunks) { - if (reasoningChunks.isNotEmpty()) { - reasoningMessages.value += reasoningChunks - summaryText = reasoningMessages.value - } + LaunchedEffect(reasoningChunks) { + if (reasoningChunks.isNotEmpty()) { + reasoningMessages.value += reasoningChunks + summaryText = reasoningMessages.value } + } - LaunchedEffect(message) { - if (message.isNotEmpty()) { - messages.value += message - summaryText = messages.value - } + LaunchedEffect(message) { + if (message.isNotEmpty()) { + messages.value += message + summaryText = messages.value } + } - LaunchedEffect(webScrapingState) { - when (webScrapingState) { + LaunchedEffect(webScrapingState) { + when (webScrapingState) { + is WebPageState.Error -> { + currentStatus = ProcessingStatus.ERROR + errorText = (webScrapingState as WebPageState.Error).message + isProcessing = false + } - is WebPageState.Error -> { - currentStatus = ProcessingStatus.ERROR - errorText = (webScrapingState as WebPageState.Error).message - isProcessing = false - } - - WebPageState.Idle -> {} + WebPageState.Idle -> {} - WebPageState.Loading -> { - currentStatus = ProcessingStatus.SCRAPING_WEBPAGE - isProcessing = true - } + WebPageState.Loading -> { + currentStatus = ProcessingStatus.SCRAPING_WEBPAGE + isProcessing = true + } - is WebPageState.Success -> { - currentStatus = ProcessingStatus.SCRAPING_WEBPAGE - isProcessing = true + is WebPageState.Success -> { + currentStatus = ProcessingStatus.SCRAPING_WEBPAGE + isProcessing = true - aiChatViewModel.sendMessage( - userInput = (webScrapingState as WebPageState.Success).content.text - ) - } - } + aiChatViewModel.sendMessage( + userInput = (webScrapingState as WebPageState.Success).content.text + ) + } } - - LaunchedEffect(aiChatState) { - when (aiChatState) { - LeapState.Idle -> {} - LeapState.Loading -> { - currentStatus = ProcessingStatus.LOADING_MODEL - isProcessing = true - } - - is LeapState.Error -> { - currentStatus = ProcessingStatus.ERROR - errorText = (aiChatState as LeapState.Error).message - isProcessing = false - } - - LeapState.Thinking -> { - currentStatus = ProcessingStatus.THINKING - isProcessing = true - } - - LeapState.Generating -> { - currentStatus = ProcessingStatus.GENERATING_SUMMARY - isProcessing = true - } - - is LeapState.Success -> { - currentStatus = ProcessingStatus.COMPLETED - isProcessing = false - summaryText = (aiChatState as LeapState.Success).response - messages.value = summaryText // Update the messages state with the final summary - } - } + } + + LaunchedEffect(aiChatState) { + when (aiChatState) { + LeapState.Idle -> {} + LeapState.Loading -> { + currentStatus = ProcessingStatus.LOADING_MODEL + isProcessing = true + } + + is LeapState.Error -> { + currentStatus = ProcessingStatus.ERROR + errorText = (aiChatState as LeapState.Error).message + isProcessing = false + } + + LeapState.Thinking -> { + currentStatus = ProcessingStatus.THINKING + isProcessing = true + } + + LeapState.Generating -> { + currentStatus = ProcessingStatus.GENERATING_SUMMARY + isProcessing = true + } + + is LeapState.Success -> { + currentStatus = ProcessingStatus.COMPLETED + isProcessing = false + summaryText = (aiChatState as LeapState.Success).response + messages.value = summaryText // Update the messages state with the final summary + } } - SummaryAppScreenUI( - modifier = modifier, - currentStatus = currentStatus, - isProcessing = isProcessing, - isThinking = aiChatState == LeapState.Thinking, - summaryText = summaryText, - errorText = errorText - ) + } + SummaryAppScreenUI( + modifier = modifier, + currentStatus = currentStatus, + isProcessing = isProcessing, + isThinking = aiChatState == LeapState.Thinking, + summaryText = summaryText, + errorText = errorText, + ) } @Composable fun SummaryAppScreenUI( - modifier: Modifier = Modifier, - currentStatus: ProcessingStatus, - isProcessing: Boolean, - summaryText: String, - isThinking: Boolean, - errorText: String = "" + modifier: Modifier = Modifier, + currentStatus: ProcessingStatus, + isProcessing: Boolean, + summaryText: String, + isThinking: Boolean, + errorText: String = "", ) { - val gradientBrush = Brush.verticalGradient( - colors = listOf( - Color(0xFF6366F1), // Indigo - Color(0xFF8B5CF6), // Violet - Color(0xFFA855F7) // Purple + val gradientBrush = + Brush.verticalGradient( + colors = + listOf( + Color(0xFF6366F1), // Indigo + Color(0xFF8B5CF6), // Violet + Color(0xFFA855F7), // Purple ) ) - Box( - modifier = Modifier - .fillMaxSize() - .background(gradientBrush) - .windowInsetsPadding(WindowInsets.systemBars) + Box( + modifier = + Modifier.fillMaxSize().background(gradientBrush).windowInsetsPadding(WindowInsets.systemBars) + ) { + Column( + modifier = modifier.fillMaxSize().padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { - Column( - modifier = modifier - .fillMaxSize() - .padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer(modifier = Modifier.height(60.dp)) - - // Header - Text( - text = "Summary Generator", - fontSize = 28.sp, - fontWeight = FontWeight.Bold, - color = Color.White, - textAlign = TextAlign.Center - ) - - Spacer(modifier = Modifier.height(12.dp)) - - Text( - text = when { - isProcessing -> "Processing your content..." - errorText.isNotEmpty() -> "Error occurred" - else -> "Summary Complete" - }, - fontSize = 16.sp, - color = Color.White.copy(alpha = 0.8f), - textAlign = TextAlign.Center - ) - - Spacer(modifier = Modifier.height(40.dp)) - - // Main content area - when { - errorText.isNotEmpty() -> { - ErrorCard(errorText, Modifier.weight(1f)) - } - - summaryText.isNotEmpty() -> { - SummaryCard(summaryText, isThinking, modifier.weight(1f)) - } + Spacer(modifier = Modifier.height(60.dp)) + + // Header + Text( + text = stringResource(R.string.summary_screen_title), + fontSize = 28.sp, + fontWeight = FontWeight.Bold, + color = Color.White, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = + when { + isProcessing -> stringResource(R.string.summary_status_processing) + errorText.isNotEmpty() -> stringResource(R.string.summary_status_error) + else -> stringResource(R.string.summary_status_complete) + }, + fontSize = 16.sp, + color = Color.White.copy(alpha = 0.8f), + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(40.dp)) + + // Main content area + when { + errorText.isNotEmpty() -> { + ErrorCard(errorText, Modifier.weight(1f)) + } - else -> { - Spacer(modifier = Modifier.weight(1f)) - } - } + summaryText.isNotEmpty() -> { + SummaryCard(summaryText, isThinking, modifier.weight(1f)) + } - Spacer(modifier = Modifier.height(40.dp)) + else -> { + Spacer(modifier = Modifier.weight(1f)) } + } - // Status indicators at the bottom - StatusIndicators( - currentStatus = currentStatus, - isProcessing = isProcessing, - modifier = Modifier.align(Alignment.BottomCenter) - ) + Spacer(modifier = Modifier.height(40.dp)) } + + // Status indicators at the bottom + StatusIndicators( + currentStatus = currentStatus, + isProcessing = isProcessing, + modifier = Modifier.align(Alignment.BottomCenter), + ) + } } @Composable fun StatusIndicators( - currentStatus: ProcessingStatus, - isProcessing: Boolean, - modifier: Modifier = Modifier + currentStatus: ProcessingStatus, + isProcessing: Boolean, + modifier: Modifier = Modifier, ) { - val statusSteps = listOf( - StatusStep( - ProcessingStatus.LOADING_MODEL, - "Loading Model", - "Initializing AI model" - ), - StatusStep( - ProcessingStatus.SCRAPING_WEBPAGE, - "Scraping Webpage", - "Extracting content" - ), - StatusStep( - ProcessingStatus.THINKING, - "Thinking", - "Analyzing content" - ), - StatusStep( - ProcessingStatus.GENERATING_SUMMARY, - "Generating Summary", - "Creating final summary" - ) + val statusSteps = + listOf( + StatusStep( + ProcessingStatus.LOADING_MODEL, + R.string.step_loading_model_title, + R.string.step_loading_model_description, + ), + StatusStep( + ProcessingStatus.SCRAPING_WEBPAGE, + R.string.step_scraping_webpage_title, + R.string.step_scraping_webpage_description, + ), + StatusStep( + ProcessingStatus.THINKING, + R.string.step_thinking_title, + R.string.step_thinking_description, + ), + StatusStep( + ProcessingStatus.GENERATING_SUMMARY, + R.string.step_generating_summary_title, + R.string.step_generating_summary_description, + ), ) - val currentStep = statusSteps.find { it.status == currentStatus } + val currentStep = statusSteps.find { it.status == currentStatus } - val animatedOffset by animateFloatAsState( - targetValue = if (isProcessing) 0f else -100f, - animationSpec = tween(durationMillis = 500, easing = FastOutSlowInEasing), - label = "statusOffset" + val animatedOffset by + animateFloatAsState( + targetValue = if (isProcessing) 0f else -100f, + animationSpec = tween(durationMillis = 500, easing = FastOutSlowInEasing), + label = "statusOffset", ) - if (currentStep != null && isProcessing) { - Column( - modifier = modifier - .fillMaxWidth() - .padding(24.dp) - .clip(CircleShape) - .background(Color.White) - .padding(16.dp) - .offset(y = animatedOffset.dp), - ) { - StatusItem( - step = currentStep, - isActive = true, - isCompleted = false, - ) - } + if (currentStep != null && isProcessing) { + Column( + modifier = + modifier + .fillMaxWidth() + .padding(24.dp) + .clip(CircleShape) + .background(Color.White) + .padding(16.dp) + .offset(y = animatedOffset.dp) + ) { + StatusItem(step = currentStep, isActive = true, isCompleted = false) } + } } @Composable -fun StatusItem( - step: StatusStep, - isActive: Boolean, - isCompleted: Boolean, -) { - val infiniteTransition = rememberInfiniteTransition(label = "pulse") - val pulseAlpha by infiniteTransition.animateFloat( - initialValue = 0.4f, - targetValue = 1f, - animationSpec = infiniteRepeatable( - animation = tween(1000), - repeatMode = RepeatMode.Reverse - ), - label = "pulse" +fun StatusItem(step: StatusStep, isActive: Boolean, isCompleted: Boolean) { + val infiniteTransition = rememberInfiniteTransition(label = "pulse") + val pulseAlpha by + infiniteTransition.animateFloat( + initialValue = 0.4f, + targetValue = 1f, + animationSpec = infiniteRepeatable(animation = tween(1000), repeatMode = RepeatMode.Reverse), + label = "pulse", ) - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - // Status circle - Box( - modifier = Modifier - .size(40.dp) - .clip(CircleShape) - .background( - when { - isCompleted && !isActive -> Color(0xFF10B981) // Green - isActive -> Color(0xFF6366F1).copy(alpha = pulseAlpha) // Blue with pulse - else -> Color(0xFFE5E7EB) // Gray - } - ), - contentAlignment = Alignment.Center - ) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + // Status circle + Box( + modifier = + Modifier.size(40.dp) + .clip(CircleShape) + .background( when { - isCompleted && !isActive -> { - Icon( - imageVector = Icons.Default.Check, - contentDescription = "Completed", - tint = Color.White, - modifier = Modifier.size(20.dp) - ) - } - - isActive -> { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - strokeWidth = 2.dp, - color = Color.White - ) - } - - else -> { - Box( - modifier = Modifier - .size(12.dp) - .clip(CircleShape) - .background(Color(0xFF9CA3AF)) - ) - } + isCompleted && !isActive -> Color(0xFF10B981) // Green + isActive -> Color(0xFF6366F1).copy(alpha = pulseAlpha) // Blue with pulse + else -> Color(0xFFE5E7EB) // Gray } + ), + contentAlignment = Alignment.Center, + ) { + when { + isCompleted && !isActive -> { + Icon( + imageVector = Icons.Default.Check, + contentDescription = stringResource(R.string.status_completed_description), + tint = Color.White, + modifier = Modifier.size(20.dp), + ) + } + + isActive -> { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = Color.White, + ) } - Spacer(modifier = Modifier.width(16.dp)) - - // Status text - Column( - modifier = Modifier.weight(1f) - ) { - Text( - text = step.title, - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, - color = when { - isActive -> Color(0xFF6366F1) - isCompleted -> Color(0xFF10B981) - else -> Color(0xFF6B7280) - } - ) - - Text( - text = step.description, - fontSize = 14.sp, - color = Color(0xFF9CA3AF), - modifier = Modifier.alpha(if (isActive || isCompleted) 1f else 0.6f) - ) + else -> { + Box(modifier = Modifier.size(12.dp).clip(CircleShape).background(Color(0xFF9CA3AF))) } + } } + + Spacer(modifier = Modifier.width(16.dp)) + + // Status text + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(step.titleRes), + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = + when { + isActive -> Color(0xFF6366F1) + isCompleted -> Color(0xFF10B981) + else -> Color(0xFF6B7280) + }, + ) + + Text( + text = stringResource(step.descriptionRes), + fontSize = 14.sp, + color = Color(0xFF9CA3AF), + modifier = Modifier.alpha(if (isActive || isCompleted) 1f else 0.6f), + ) + } + } } @Composable -fun ErrorCard(errorText: String, modifier: Modifier = Modifier){ - Card( - modifier = modifier - .fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors( - containerColor = Color.White.copy(alpha = 0.9f) - ), - elevation = CardDefaults.cardElevation(defaultElevation = 12.dp) - ) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(20.dp) - ) { - Text( - text = "Error", - fontSize = 20.sp, - fontWeight = FontWeight.Bold, - color = Color(0xFFB91C1C) - ) - Spacer(modifier = Modifier.height(12.dp)) - Text( - text = errorText, - fontSize = 16.sp, - color = Color(0xFFB91C1C), - lineHeight = 24.sp - ) - } +fun ErrorCard(errorText: String, modifier: Modifier = Modifier) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.9f)), + elevation = CardDefaults.cardElevation(defaultElevation = 12.dp), + ) { + Column(modifier = Modifier.fillMaxSize().padding(20.dp)) { + Text( + text = stringResource(R.string.error_card_title), + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFFB91C1C), + ) + Spacer(modifier = Modifier.height(12.dp)) + Text(text = errorText, fontSize = 16.sp, color = Color(0xFFB91C1C), lineHeight = 24.sp) } + } } - @Composable fun SummaryCard(summaryText: String, isThinking: Boolean, modifier: Modifier) { - val textScrollState = rememberScrollState() - LaunchedEffect(summaryText) { - textScrollState.scrollTo(textScrollState.maxValue) - } - Card( - modifier = modifier - .fillMaxWidth() - .padding(bottom = 90.dp), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors( - containerColor = Color.White.copy(alpha = 0.9f) - ), - elevation = CardDefaults.cardElevation(defaultElevation = 12.dp) - ) { - Column( - modifier = Modifier - .verticalScroll(textScrollState) - .fillMaxSize() - .padding(20.dp) - ) { - Text( - text = if (isThinking) "\uD83E\uDD14 Thinking" else "\uD83D\uDCDD Summary", - fontSize = 20.sp, - fontWeight = FontWeight.Bold, - color = Color(0xFF1F2937) - ) - Spacer(modifier = Modifier.height(12.dp)) - RichText( - modifier = Modifier - ) { - val parser = remember { CommonmarkAstNodeParser() } - val astNode = remember(summaryText, parser) { - parser.parse(summaryText) - } - BasicMarkdown(astNode) - } - } + val textScrollState = rememberScrollState() + LaunchedEffect(summaryText) { textScrollState.scrollTo(textScrollState.maxValue) } + Card( + modifier = modifier.fillMaxWidth().padding(bottom = 90.dp), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.9f)), + elevation = CardDefaults.cardElevation(defaultElevation = 12.dp), + ) { + Column(modifier = Modifier.verticalScroll(textScrollState).fillMaxSize().padding(20.dp)) { + Text( + text = + if (isThinking) stringResource(R.string.summary_card_thinking_title) + else stringResource(R.string.summary_card_summary_title), + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + color = Color(0xFF1F2937), + ) + Spacer(modifier = Modifier.height(12.dp)) + RichText(modifier = Modifier) { + val parser = remember { CommonmarkAstNodeParser() } + val astNode = remember(summaryText, parser) { parser.parse(summaryText) } + BasicMarkdown(astNode) + } } + } } @Preview(showBackground = true) @Composable fun SummaryAppScreenPreview() { - MaterialTheme { - SummaryAppScreenUI( - currentStatus = ProcessingStatus.LOADING_MODEL, - isProcessing = true, - summaryText = "dna ld a", - isThinking = false, - errorText = "" - ) - } -} \ No newline at end of file + MaterialTheme { + SummaryAppScreenUI( + currentStatus = ProcessingStatus.LOADING_MODEL, + isProcessing = true, + summaryText = "dna ld a", + isThinking = false, + errorText = "", + ) + } +} diff --git a/Android/ShareAI/app/src/main/java/com/leap/shareai/ui/theme/Color.kt b/Android/ShareAI/app/src/main/java/com/leap/shareai/ui/theme/Color.kt index 41bfe45..9aaed0a 100644 --- a/Android/ShareAI/app/src/main/java/com/leap/shareai/ui/theme/Color.kt +++ b/Android/ShareAI/app/src/main/java/com/leap/shareai/ui/theme/Color.kt @@ -216,4 +216,4 @@ val surfaceContainerLowestDarkHighContrast = Color(0xFF000000) val surfaceContainerLowDarkHighContrast = Color(0xFF1E201A) val surfaceContainerDarkHighContrast = Color(0xFF2F312A) val surfaceContainerHighDarkHighContrast = Color(0xFF3A3C35) -val surfaceContainerHighestDarkHighContrast = Color(0xFF454840) \ No newline at end of file +val surfaceContainerHighestDarkHighContrast = Color(0xFF454840) diff --git a/Android/ShareAI/app/src/main/java/com/leap/shareai/ui/theme/Theme.kt b/Android/ShareAI/app/src/main/java/com/leap/shareai/ui/theme/Theme.kt index e496b16..896132b 100644 --- a/Android/ShareAI/app/src/main/java/com/leap/shareai/ui/theme/Theme.kt +++ b/Android/ShareAI/app/src/main/java/com/leap/shareai/ui/theme/Theme.kt @@ -9,7 +9,8 @@ import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext -private val lightScheme = lightColorScheme( +private val lightScheme = + lightColorScheme( primary = primaryLight, onPrimary = onPrimaryLight, primaryContainer = primaryContainerLight, @@ -45,9 +46,10 @@ private val lightScheme = lightColorScheme( surfaceContainer = surfaceContainerLight, surfaceContainerHigh = surfaceContainerHighLight, surfaceContainerHighest = surfaceContainerHighestLight, -) + ) -private val darkScheme = darkColorScheme( +private val darkScheme = + darkColorScheme( primary = primaryDark, onPrimary = onPrimaryDark, primaryContainer = primaryContainerDark, @@ -83,9 +85,10 @@ private val darkScheme = darkColorScheme( surfaceContainer = surfaceContainerDark, surfaceContainerHigh = surfaceContainerHighDark, surfaceContainerHighest = surfaceContainerHighestDark, -) + ) -private val mediumContrastLightColorScheme = lightColorScheme( +private val mediumContrastLightColorScheme = + lightColorScheme( primary = primaryLightMediumContrast, onPrimary = onPrimaryLightMediumContrast, primaryContainer = primaryContainerLightMediumContrast, @@ -121,9 +124,10 @@ private val mediumContrastLightColorScheme = lightColorScheme( surfaceContainer = surfaceContainerLightMediumContrast, surfaceContainerHigh = surfaceContainerHighLightMediumContrast, surfaceContainerHighest = surfaceContainerHighestLightMediumContrast, -) + ) -private val highContrastLightColorScheme = lightColorScheme( +private val highContrastLightColorScheme = + lightColorScheme( primary = primaryLightHighContrast, onPrimary = onPrimaryLightHighContrast, primaryContainer = primaryContainerLightHighContrast, @@ -159,9 +163,10 @@ private val highContrastLightColorScheme = lightColorScheme( surfaceContainer = surfaceContainerLightHighContrast, surfaceContainerHigh = surfaceContainerHighLightHighContrast, surfaceContainerHighest = surfaceContainerHighestLightHighContrast, -) + ) -private val mediumContrastDarkColorScheme = darkColorScheme( +private val mediumContrastDarkColorScheme = + darkColorScheme( primary = primaryDarkMediumContrast, onPrimary = onPrimaryDarkMediumContrast, primaryContainer = primaryContainerDarkMediumContrast, @@ -197,9 +202,10 @@ private val mediumContrastDarkColorScheme = darkColorScheme( surfaceContainer = surfaceContainerDarkMediumContrast, surfaceContainerHigh = surfaceContainerHighDarkMediumContrast, surfaceContainerHighest = surfaceContainerHighestDarkMediumContrast, -) + ) -private val highContrastDarkColorScheme = darkColorScheme( +private val highContrastDarkColorScheme = + darkColorScheme( primary = primaryDarkHighContrast, onPrimary = onPrimaryDarkHighContrast, primaryContainer = primaryContainerDarkHighContrast, @@ -235,30 +241,25 @@ private val highContrastDarkColorScheme = darkColorScheme( surfaceContainer = surfaceContainerDarkHighContrast, surfaceContainerHigh = surfaceContainerHighDarkHighContrast, surfaceContainerHighest = surfaceContainerHighestDarkHighContrast, -) - + ) @Composable fun ThemeShareAi( - darkTheme: Boolean = isSystemInDarkTheme(), // Will be determined by collected theme setting - dynamicColor: Boolean = true, // Will be determined by collected theme setting - content: @Composable () -> Unit + darkTheme: Boolean = isSystemInDarkTheme(), // Will be determined by collected theme setting + dynamicColor: Boolean = true, // Will be determined by collected theme setting + content: @Composable () -> Unit, ) { - //currentThemeSetting == ThemeSetting.DYNAMIC - val context = LocalContext.current + // currentThemeSetting == ThemeSetting.DYNAMIC + val context = LocalContext.current - val colorScheme = when { - dynamicColor -> { - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) - } - darkTheme -> darkScheme - else -> lightScheme + val colorScheme = + when { + dynamicColor -> { + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + darkTheme -> darkScheme + else -> lightScheme } - MaterialTheme( - colorScheme = colorScheme, - typography = AppTypography, - content = content - ) + MaterialTheme(colorScheme = colorScheme, typography = AppTypography, content = content) } - diff --git a/Android/ShareAI/app/src/main/java/com/leap/shareai/ui/theme/Type.kt b/Android/ShareAI/app/src/main/java/com/leap/shareai/ui/theme/Type.kt index aa71d75..0bf6a10 100644 --- a/Android/ShareAI/app/src/main/java/com/leap/shareai/ui/theme/Type.kt +++ b/Android/ShareAI/app/src/main/java/com/leap/shareai/ui/theme/Type.kt @@ -2,4 +2,4 @@ package com.leap.shareai.ui.theme import androidx.compose.material3.Typography -val AppTypography = Typography() \ No newline at end of file +val AppTypography = Typography() diff --git a/Android/ShareAI/app/src/main/java/com/leap/shareai/viewmodels/AIChatViewModel.kt b/Android/ShareAI/app/src/main/java/com/leap/shareai/viewmodels/AIChatViewModel.kt index ec35669..28289d6 100644 --- a/Android/ShareAI/app/src/main/java/com/leap/shareai/viewmodels/AIChatViewModel.kt +++ b/Android/ShareAI/app/src/main/java/com/leap/shareai/viewmodels/AIChatViewModel.kt @@ -1,7 +1,6 @@ package com.leap.shareai.viewmodels import ai.liquid.leap.Conversation -import ai.liquid.leap.LeapClient import ai.liquid.leap.LeapModelLoadingException import ai.liquid.leap.ModelLoadingOptions import ai.liquid.leap.ModelRunner @@ -15,9 +14,12 @@ import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.leap.shareai.R import com.leap.shareai.model.ChatMessageDisplayItem -import com.leap.shareai.webscraping.WebPageState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -29,205 +31,234 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers - class AIChatViewModel : ViewModel() { - companion object { - private const val TAG = "LeapViewModel" - private const val MODEL_NAME = "LFM2-350M" - private const val QUANTIZATION_SLUG = "Q8_0" - private const val PROMPT = "Make a summary less than 500 words of the following text. Use Markdown If needed:\n\n" - } - - private val _state = mutableStateOf(LeapState.Idle) - val state: State = _state - - private var modelRunner: ModelRunner? = null - private var conversation: Conversation? = null - private var generationJob: Job? = null - private var downloader: LeapModelDownloader? = null - - private val _responseChunks = MutableSharedFlow() - val responseChunks: SharedFlow = _responseChunks - - private val _reasoningChunks = MutableSharedFlow() - val reasoningChunks: SharedFlow = _reasoningChunks - - private val _isLoading = MutableSharedFlow() - val isLoading: SharedFlow = _isLoading - - private val _messages = MutableStateFlow>(emptyList()) - val messages: StateFlow> = _messages - - // Temporary holders for current model response - private var currentResponseText = StringBuilder() - private var currentReasoningText = StringBuilder() - - fun loadModel(context: Context) { - _state.value = LeapState.Loading - viewModelScope.launch { - _isLoading.emit(true) - try { - // Reuse downloader instance to avoid concurrent download issues - if (downloader == null) { - downloader = LeapModelDownloader( - context, - notificationConfig = LeapModelDownloaderNotificationConfig.build { - notificationTitleDownloading = "Downloading ShareAI Model" - notificationTitleDownloaded = "Model Ready!" - } - ) - } - val downloaderInstance = downloader!! - - // Check if model needs to be downloaded - val currentStatus = downloaderInstance.queryStatus(MODEL_NAME, QUANTIZATION_SLUG) - - if (currentStatus is LeapModelDownloader.ModelDownloadStatus.NotOnLocal) { - // Model needs to be downloaded - Log.d(TAG, "Model not found locally, starting download...") - - // Observe download progress - val progressFlow = downloaderInstance.observeDownloadProgress(MODEL_NAME, QUANTIZATION_SLUG) - - // Start the download - downloaderInstance.requestDownloadModel(MODEL_NAME, QUANTIZATION_SLUG) - - // Collect progress updates until download completes - progressFlow - .onEach { progress -> - if (progress != null) { - val downloadedMB = progress.downloadedSizeInBytes / (1024 * 1024) - val totalMB = progress.totalSizeInBytes / (1024 * 1024) - val percentage = if (progress.totalSizeInBytes > 0) { - (progress.downloadedSizeInBytes * 100.0 / progress.totalSizeInBytes).toInt() - } else { - 0 - } - Log.d(TAG, "Downloading: $percentage% ($downloadedMB MB / $totalMB MB)") - } else { - val downloadStatus = downloaderInstance.queryStatus(MODEL_NAME, QUANTIZATION_SLUG) - if (downloadStatus is LeapModelDownloader.ModelDownloadStatus.Downloaded) { - Log.d(TAG, "Download complete!") - } - } - } - .takeWhile { progress -> - progress != null || downloaderInstance.queryStatus(MODEL_NAME, QUANTIZATION_SLUG) is LeapModelDownloader.ModelDownloadStatus.DownloadInProgress - } - .collect() + companion object { + private const val TAG = "LeapViewModel" + private const val MODEL_NAME = "LFM2.5-350M" + private const val QUANTIZATION_TYPE = "Q8_0" + private const val PROMPT = + "Make a summary less than 500 words of the following text. Use Markdown If needed:\n\n" + } + + private val _state = mutableStateOf(LeapState.Idle) + val state: State = _state + + private var modelRunner: ModelRunner? = null + private var conversation: Conversation? = null + private var generationJob: Job? = null + private var downloader: LeapModelDownloader? = null + + private val _responseChunks = + MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val responseChunks: SharedFlow = _responseChunks + + private val _reasoningChunks = + MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val reasoningChunks: SharedFlow = _reasoningChunks + + private val _isLoading = + MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val isLoading: SharedFlow = _isLoading + + private val _messages = MutableStateFlow>(emptyList()) + val messages: StateFlow> = _messages + + // Temporary holders for current model response + private var currentResponseText = StringBuilder() + private var currentReasoningText = StringBuilder() + + fun loadModel(context: Context) { + _state.value = LeapState.Loading + viewModelScope.launch { + _isLoading.emit(true) + try { + // Reuse downloader instance to avoid concurrent download issues + if (downloader == null) { + downloader = + LeapModelDownloader( + context, + notificationConfig = + LeapModelDownloaderNotificationConfig.build { + notificationTitleDownloading = + context.getString(R.string.notification_downloading_shareai_model) + notificationTitleDownloaded = + context.getString(R.string.notification_shareai_model_ready) + }, + ) + } + val downloaderInstance = downloader!! + + // Check if model needs to be downloaded + val currentStatus = downloaderInstance.queryStatus(MODEL_NAME, QUANTIZATION_TYPE) + + if (currentStatus is LeapModelDownloader.ModelDownloadStatus.NotOnLocal) { + // Model needs to be downloaded + Log.d(TAG, "Model not found locally, starting download...") + + // Observe download progress + val progressFlow = + downloaderInstance.observeDownloadProgress(MODEL_NAME, QUANTIZATION_TYPE) + + // Start the download + downloaderInstance.requestDownloadModel(MODEL_NAME, QUANTIZATION_TYPE) + + // Collect progress updates until download completes + progressFlow + .onEach { progress -> + if (progress != null) { + val downloadedMB = progress.downloadedSizeInBytes / (1024 * 1024) + val totalMB = progress.totalSizeInBytes / (1024 * 1024) + val percentage = + if (progress.totalSizeInBytes > 0) { + (progress.downloadedSizeInBytes * 100.0 / progress.totalSizeInBytes).toInt() + } else { + 0 + } + Log.d(TAG, "Downloading: $percentage% ($downloadedMB MB / $totalMB MB)") + } else { + val downloadStatus = downloaderInstance.queryStatus(MODEL_NAME, QUANTIZATION_TYPE) + if (downloadStatus is LeapModelDownloader.ModelDownloadStatus.Downloaded) { + Log.d(TAG, "Download complete!") } - - modelRunner = downloaderInstance.loadModel( - modelName = MODEL_NAME, - quantizationType = QUANTIZATION_SLUG, - options = ModelLoadingOptions( - cacheOptions = ModelLoadingOptions.cacheOptions( - path = context.cacheDir.resolve("leap-cache").absolutePath, - ), - ), - ) - conversation = modelRunner?.createConversation() - Log.d(TAG, "Model loaded and conversation created") - } catch (e: LeapModelLoadingException) { - _state.value = LeapState.Error("Failed to load model: ${e.message}") - Log.e(TAG, "Failed to load model: ${e.message}") - } finally { - _isLoading.emit(false) + } + } + .takeWhile { progress -> + progress != null || + downloaderInstance.queryStatus(MODEL_NAME, QUANTIZATION_TYPE) is + LeapModelDownloader.ModelDownloadStatus.DownloadInProgress } + .collect() } - } - - fun sendMessage(userInput: String) { - // Add user's message to the list - Log.d(TAG, "Sending user input: $userInput") - val massage = PROMPT + userInput.trim() - val userMessage = ChatMessageDisplayItem(ChatMessage.Role.USER, massage) - _messages.update { it + userMessage } - generateResponse(massage) + modelRunner = + downloaderInstance.loadModel( + modelName = MODEL_NAME, + quantizationType = QUANTIZATION_TYPE, + options = + ModelLoadingOptions( + cacheOptions = + ModelLoadingOptions.cacheOptions( + path = context.cacheDir.resolve("leap-cache").absolutePath + ) + ), + ) + conversation = modelRunner?.createConversation() + Log.d(TAG, "Model loaded and conversation created") + } catch (e: LeapModelLoadingException) { + _state.value = LeapState.Error("Failed to load model: ${e.message}") + Log.e(TAG, "Failed to load model: ${e.message}") + } finally { + _isLoading.emit(false) + } } + } + fun sendMessage(userInput: String) { + // Add user's message to the list + Log.d(TAG, "Sending user input: $userInput") + val message = PROMPT + userInput.trim() + val userMessage = ChatMessageDisplayItem(ChatMessage.Role.USER, message) + _messages.update { it + userMessage } - private fun generateResponse(input: String) { - - val conv = conversation ?: run { - Log.e(TAG, "Conversation not initialized") - return - } + generateResponse(message) + } - generationJob?.cancel() // cancel previous if any + private fun generateResponse(input: String) { - generationJob = viewModelScope.launch { - _state.value = LeapState.Thinking - conv.generateResponse(input) - .onCompletion { - Log.d(TAG, "Generation complete") - } - .catch { e -> - Log.e(TAG, "Error generating response: ${e.message}") - _state.value = LeapState.Error("Error: ${e.message}") - } - .collect { response -> - when (response) { - is MessageResponse.Chunk -> { - _state.value = LeapState.Generating - currentResponseText.append(response.text) - _responseChunks.emit(response.text) - } - - is MessageResponse.ReasoningChunk -> { - _state.value = LeapState.Thinking - currentReasoningText.append(response.reasoning) - _reasoningChunks.emit(response.reasoning) - } - - is MessageResponse.Complete -> { - _state.value = LeapState.Success(currentResponseText.toString()) - val aiMessage = ChatMessageDisplayItem( - role = ChatMessage.Role.ASSISTANT, - text = currentResponseText.toString(), - reasoning = currentReasoningText.takeIf { it.isNotBlank() } - ?.toString() - ) - _messages.update { it + aiMessage } - Log.d(TAG, "AI message completed and added to list") - } - - else -> Unit - } - } + val conv = + conversation + ?: run { + Log.e(TAG, "Conversation not initialized") + return } - } - - fun cancelGeneration() { - generationJob?.cancel() - Log.d(TAG, "Generation canceled") - } - override fun onCleared() { - super.onCleared() - - // Unload model asynchronously to avoid ANR - // Do NOT use runBlocking here - it blocks the main thread - CoroutineScope(Dispatchers.IO).launch { - try { - modelRunner?.unload() - } catch (e: Exception) { - Log.e(TAG, "Error unloading model", e) + generationJob?.cancel() // cancel previous if any + + generationJob = + viewModelScope.launch { + _state.value = LeapState.Thinking + conv + .generateResponse(input) + .onCompletion { Log.d(TAG, "Generation complete") } + .catch { e -> + Log.e(TAG, "Error generating response: ${e.message}") + _state.value = LeapState.Error("Error: ${e.message}") + } + .collect { response -> + when (response) { + is MessageResponse.Chunk -> { + _state.value = LeapState.Generating + currentResponseText.append(response.text) + _responseChunks.emit(response.text) + } + + is MessageResponse.ReasoningChunk -> { + _state.value = LeapState.Thinking + currentReasoningText.append(response.reasoning) + _reasoningChunks.emit(response.reasoning) + } + + is MessageResponse.Complete -> { + _state.value = LeapState.Success(currentResponseText.toString()) + val aiMessage = + ChatMessageDisplayItem( + role = ChatMessage.Role.ASSISTANT, + text = currentResponseText.toString(), + reasoning = currentReasoningText.takeIf { it.isNotBlank() }?.toString(), + ) + _messages.update { it + aiMessage } + Log.d(TAG, "AI message completed and added to list") + } + + is MessageResponse.Error -> throw response.throwable + + else -> Unit } - } + } + } + } + + fun cancelGeneration() { + generationJob?.cancel() + Log.d(TAG, "Generation canceled") + } + + override fun onCleared() { + super.onCleared() + + // Unload model asynchronously to avoid ANR + // Do NOT use runBlocking here - it blocks the main thread + CoroutineScope(Dispatchers.IO).launch { + try { + modelRunner?.unload() + } catch (e: Exception) { + Log.e(TAG, "Error unloading model", e) + } } + } } sealed class LeapState { - object Idle: LeapState() - object Thinking : LeapState() - object Generating : LeapState() - object Loading : LeapState() - data class Error(val message: String) : LeapState() - data class Success(val response: String) : LeapState() + data object Idle : LeapState() + + data object Thinking : LeapState() + + data object Generating : LeapState() + + data object Loading : LeapState() + + data class Error(val message: String) : LeapState() + + data class Success(val response: String) : LeapState() } diff --git a/Android/ShareAI/app/src/main/java/com/leap/shareai/viewmodels/WebScrapingViewModel.kt b/Android/ShareAI/app/src/main/java/com/leap/shareai/viewmodels/WebScrapingViewModel.kt index 7ed5463..fa01da7 100644 --- a/Android/ShareAI/app/src/main/java/com/leap/shareai/viewmodels/WebScrapingViewModel.kt +++ b/Android/ShareAI/app/src/main/java/com/leap/shareai/viewmodels/WebScrapingViewModel.kt @@ -7,62 +7,57 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.leap.shareai.webscraping.WebPageContent import com.leap.shareai.webscraping.WebPageState +import java.io.IOException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jsoup.Jsoup import org.jsoup.nodes.Document -import java.io.IOException class WebScrapingViewModel : ViewModel() { - private val _state = mutableStateOf(WebPageState.Idle) - val state: State = _state - - fun extractTextFromUrl(url: String) { - viewModelScope.launch { - _state.value = WebPageState.Loading - Log.d("WebScrapingViewModel", "loading data") - try { - val content = withContext(Dispatchers.IO) { - scrapeWebPage(url) - } - Log.d("WebScrapingViewModel", "data loaded") - _state.value = WebPageState.Success(content) - } catch (e: Exception) { - Log.d("WebScrapingViewModel", "data loading error: $e") - _state.value = WebPageState.Error(e.message ?: "Unknown error occurred") - } - } + private val _state = mutableStateOf(WebPageState.Idle) + val state: State = _state + + fun extractTextFromUrl(url: String) { + viewModelScope.launch { + _state.value = WebPageState.Loading + Log.d("WebScrapingViewModel", "loading data") + try { + val content = withContext(Dispatchers.IO) { scrapeWebPage(url) } + Log.d("WebScrapingViewModel", "data loaded") + _state.value = WebPageState.Success(content) + } catch (e: Exception) { + Log.d("WebScrapingViewModel", "data loading error: $e") + _state.value = WebPageState.Error(e.message ?: "Unknown error occurred") + } } - - private suspend fun scrapeWebPage(url: String): WebPageContent { - return withContext(Dispatchers.IO) { - try { - // Connect and parse the document - val document: Document = Jsoup.connect(url) - .userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") - .timeout(10000) - .get() - - // Extract title - val title = document.title() - - // Remove script and style elements - document.select("script, style, nav, header, footer, aside").remove() - - // Extract text content - val text = document.body().text().trim() - - WebPageContent( - title = title, - text = text, - url = url - ) - } catch (e: IOException) { - throw Exception("Failed to connect to URL: ${e.message}") - } catch (e: Exception) { - throw Exception("Error parsing webpage: ${e.message}") - } - } + } + + private suspend fun scrapeWebPage(url: String): WebPageContent { + return withContext(Dispatchers.IO) { + try { + // Connect and parse the document + val document: Document = + Jsoup.connect(url) + .userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") + .timeout(10000) + .get() + + // Extract title + val title = document.title() + + // Remove script and style elements + document.select("script, style, nav, header, footer, aside").remove() + + // Extract text content + val text = document.body().text().trim() + + WebPageContent(title = title, text = text, url = url) + } catch (e: IOException) { + throw Exception("Failed to connect to URL: ${e.message}") + } catch (e: Exception) { + throw Exception("Error parsing webpage: ${e.message}") + } } -} \ No newline at end of file + } +} diff --git a/Android/ShareAI/app/src/main/java/com/leap/shareai/webscraping/WebPageContent.kt b/Android/ShareAI/app/src/main/java/com/leap/shareai/webscraping/WebPageContent.kt index 2d4cdb0..e11b1ca 100644 --- a/Android/ShareAI/app/src/main/java/com/leap/shareai/webscraping/WebPageContent.kt +++ b/Android/ShareAI/app/src/main/java/com/leap/shareai/webscraping/WebPageContent.kt @@ -1,7 +1,3 @@ package com.leap.shareai.webscraping -data class WebPageContent( - val title: String = "", - val text: String = "", - val url: String = "" -) +data class WebPageContent(val title: String = "", val text: String = "", val url: String = "") diff --git a/Android/ShareAI/app/src/main/java/com/leap/shareai/webscraping/WebPageState.kt b/Android/ShareAI/app/src/main/java/com/leap/shareai/webscraping/WebPageState.kt index 90d5849..5b6c5e4 100644 --- a/Android/ShareAI/app/src/main/java/com/leap/shareai/webscraping/WebPageState.kt +++ b/Android/ShareAI/app/src/main/java/com/leap/shareai/webscraping/WebPageState.kt @@ -1,8 +1,11 @@ package com.leap.shareai.webscraping sealed class WebPageState { - object Idle : WebPageState() - object Loading : WebPageState() - data class Success(val content: WebPageContent) : WebPageState() - data class Error(val message: String) : WebPageState() -} \ No newline at end of file + data object Idle : WebPageState() + + data object Loading : WebPageState() + + data class Success(val content: WebPageContent) : WebPageState() + + data class Error(val message: String) : WebPageState() +} diff --git a/Android/ShareAI/app/src/main/res/values/strings.xml b/Android/ShareAI/app/src/main/res/values/strings.xml index 94f2f26..aa06dc9 100644 --- a/Android/ShareAI/app/src/main/res/values/strings.xml +++ b/Android/ShareAI/app/src/main/res/values/strings.xml @@ -1,3 +1,21 @@ ShareAI - \ No newline at end of file + Summary Generator + Processing your content… + Error occurred + Summary Complete + Loading Model + Initializing AI model + Scraping Webpage + Extracting content + Thinking + Analyzing content + Generating Summary + Creating final summary + Completed + Error + 🤔 Thinking + 📝 Summary + Downloading ShareAI Model + Model Ready! + diff --git a/Android/ShareAI/gradle/libs.versions.toml b/Android/ShareAI/gradle/libs.versions.toml index 634a7bc..eacbe0d 100644 --- a/Android/ShareAI/gradle/libs.versions.toml +++ b/Android/ShareAI/gradle/libs.versions.toml @@ -1,17 +1,18 @@ [versions] agp = "8.13.2" -kotlin = "2.3.20" +kotlin = "2.3.21" coreKtx = "1.17.0" junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" -leapSdk = "0.10.7" +leapSdk = "0.10.8" lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.12.3" composeBom = "2026.01.01" lifecycleViewmodelCompose = "2.10.0" commonmark = "1.0.0-alpha02" richtext = "1.0.0-alpha02" +ktfmt = "0.25.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -40,4 +41,5 @@ richtext = { group = "com.halilibo.compose-richtext", name = "richtext-ui-materi android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +com-ncorti-ktfmt-gradle = { id = "com.ncorti.ktfmt.gradle", version.ref = "ktfmt" } diff --git a/Android/SloganApp/README.md b/Android/SloganApp/README.md index 622f8d2..7ba1b01 100644 --- a/Android/SloganApp/README.md +++ b/Android/SloganApp/README.md @@ -1,7 +1,89 @@ -Slogan Generator -=== -This example illusrate how to use LeapSDK for a single turn generation for certain product needs. The UI is implemented with [Android Views](https://developer.android.com/develop/ui/views/layout/declaring-layout). +# Slogan Generator +A single-turn marketing-slogan generator built on the Leap SDK. The user describes a business and the model streams back a list of creative slogans. UI is built with classic [Android Views](https://developer.android.com/develop/ui/views/layout/declaring-layout) (XML layouts), not Compose, so it doubles as a reference for non-Compose integrations. + +## Features + +- Single-turn text generation with a fixed system prompt (no chat history) +- Streaming output token-by-token into a scrolling `TextView` +- Mid-generation cancellation — the Generate button toggles to Stop while a job is running +- Automatic model download with progress reporting via `LeapModelDownloader` +- Persistent on-disk cache for fast warm starts after the first launch + +## Model + +- **Qwen3-0.6B** (`Q8_0`) — downloaded automatically by `LeapModelDownloader` on first use +- Cached under `context.cacheDir/leap-cache/` +- The user prompt is prefixed with `/no_think` to skip Qwen3's reasoning trace and keep output concise + +## Prerequisites + +- Android Studio Hedgehog (2023.1.1) or newer +- JDK 21 (use `JAVA_HOME=~/.sdkman/candidates/java/21.0.9-zulu` if managing JDKs with SDKman) +- Android device or emulator on API 31 (Android 12) or higher +- Internet access on first launch for the model download +- `INTERNET` permission is declared in `AndroidManifest.xml` + +## Running + +```bash +./gradlew installDebug +# or open the project in Android Studio and press Run +``` + +On first launch tap **Generate a slogan**. The app will download the model (progress reported in the main `TextView`), load it, then stream slogans for whatever business description you typed in. + +## Project Structure + +``` +app/src/main/ +├── java/ai/liquid/sloganapp/MainActivity.kt # All SDK + UI wiring (Views-based) +├── res/layout/main_activity_layout.xml # XML layout (LinearLayout, EditText, Button, ScrollView, TextView) +└── res/values/strings.xml # User-facing strings (labels, status messages) +``` + +## Key SDK Patterns + +**Download-on-demand and load the model** (`MainActivity.loadModel`): + +```kotlin +val downloader = LeapModelDownloader(this, notificationConfig = ...) +if (downloader.queryStatus("Qwen3-0.6B", "Q8_0") is LeapModelDownloader.ModelDownloadStatus.NotOnLocal) { + downloader.requestDownloadModel("Qwen3-0.6B", "Q8_0") + downloader.observeDownloadProgress("Qwen3-0.6B", "Q8_0") + .onEach { progress -> /* update UI */ } + .takeWhile { it != null || downloader.queryStatus(...) is ModelDownloadStatus.DownloadInProgress } + .collect() +} +modelRunner = downloader.loadModel( + modelName = "Qwen3-0.6B", + quantizationType = "Q8_0", + options = ModelLoadingOptions( + cacheOptions = ModelLoadingOptions.cacheOptions(path = cacheDir.resolve("leap-cache").absolutePath), + ), +) +``` + +**Stream a single-turn generation** (`MainActivity.generateContent`): + +```kotlin +val conversation = modelRunner.createConversation(SYSTEM_PROMPT) +conversation.generateResponse(USER_PROMPT_TEMPLATE.format(userInput)) + .onEach { chunk -> + if (chunk is MessageResponse.Chunk) textView.append(chunk.text) + } + .onCompletion { /* reset button label */ } + .catch { e -> /* surface error */ } + .collect() +``` + +A fresh `Conversation` is created per generation, so each request is independent. `Chunk` is the only `MessageResponse` branch this demo cares about — `ReasoningChunk`, `Complete`, and `FunctionCalls` are intentionally ignored. ## Screenshot + + +## Notes + +- The Compose dependencies in `build.gradle.kts` are pulled in transitively for theme/material support but the UI itself is Views-based; the activity calls `setContentView(R.layout.main_activity_layout)`. +- `LeapClient` is imported but unused — generation goes through `modelRunner.createConversation(...)` directly from the loaded runner. diff --git a/Android/SloganApp/app/build.gradle.kts b/Android/SloganApp/app/build.gradle.kts index 4974d6a..270904a 100644 --- a/Android/SloganApp/app/build.gradle.kts +++ b/Android/SloganApp/app/build.gradle.kts @@ -1,66 +1,59 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { - alias(libs.plugins.android.application) - alias(libs.plugins.kotlin.android) - alias(libs.plugins.kotlin.compose) + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.com.ncorti.ktfmt.gradle) } -android { - namespace = "ai.liquid.sloganapp" - compileSdk = 36 - - defaultConfig { - applicationId = "ai.liquid.sloganapp" - minSdk = 31 - targetSdk = 36 - versionCode = 1 - versionName = "1.0" - - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - } +ktfmt { googleStyle() } - buildTypes { - release { - isMinifyEnabled = false - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" - ) - } - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 - } - kotlin { - compilerOptions { - jvmTarget.set(JvmTarget.JVM_11) - } - } - buildFeatures { - compose = true +android { + namespace = "ai.liquid.sloganapp" + compileSdk = 36 + + defaultConfig { + applicationId = "ai.liquid.sloganapp" + minSdk = 31 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_11) } } + buildFeatures { compose = true } } dependencies { - - implementation(libs.androidx.core.ktx) - implementation(libs.androidx.lifecycle.runtime.ktx) - implementation(libs.androidx.activity.compose) - implementation(platform(libs.androidx.compose.bom)) - implementation(libs.androidx.ui) - implementation(libs.androidx.ui.graphics) - implementation(libs.androidx.ui.tooling.preview) - implementation(libs.androidx.material3) - implementation(libs.leap.sdk) - implementation(libs.leap.model.downloader) - - testImplementation(libs.junit) - androidTestImplementation(libs.androidx.junit) - androidTestImplementation(libs.androidx.espresso.core) - androidTestImplementation(platform(libs.androidx.compose.bom)) - androidTestImplementation(libs.androidx.ui.test.junit4) - debugImplementation(libs.androidx.ui.tooling) - debugImplementation(libs.androidx.ui.test.manifest) -} \ No newline at end of file + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + implementation(libs.leap.sdk) + implementation(libs.leap.model.downloader) + + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) +} diff --git a/Android/SloganApp/app/src/main/java/ai/liquid/sloganapp/MainActivity.kt b/Android/SloganApp/app/src/main/java/ai/liquid/sloganapp/MainActivity.kt index 22e6870..813cb60 100644 --- a/Android/SloganApp/app/src/main/java/ai/liquid/sloganapp/MainActivity.kt +++ b/Android/SloganApp/app/src/main/java/ai/liquid/sloganapp/MainActivity.kt @@ -1,6 +1,5 @@ package ai.liquid.sloganapp -import ai.liquid.leap.LeapClient import ai.liquid.leap.ModelLoadingOptions import ai.liquid.leap.ModelRunner import ai.liquid.leap.downloader.LeapModelDownloader @@ -28,221 +27,226 @@ import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.launch // System prompt constants -private const val SYSTEM_PROMPT = "You are a marketing expert. Suggest engaging and creative slogans based on a business description provided by the user." +private const val MODEL_NAME = "Qwen3-0.6B" +private const val QUANTIZATION_TYPE = "Q8_0" + +private const val SYSTEM_PROMPT = + "You are a marketing expert. Suggest engaging and creative slogans based on a business description provided by the user." // User prompt template. private const val USER_PROMPT_TEMPLATE = "/no_think Suggest slogans for this business: %s" - class MainActivity : ComponentActivity() { - private lateinit var modelRunner: ModelRunner - private var modelLoaded = false - private var generationJob: Job? = null - private var downloader: LeapModelDownloader? = null - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setContentView(R.layout.main_activity_layout) - configMainLayout() - } - - /** - * The method to initialize the model. - * - * @param textView the main TextView to show generated contents - * @param modelStatus model status indicator TextView - * @return return whether the model is successfully loaded - */ - private suspend fun loadModel(textView: TextView, modelStatus: TextView): Boolean { - try { - textView.text = getText(R.string.model_loading_in_progress_label) - modelStatus.visibility = android.view.View.VISIBLE - modelStatus.text = resources.getText(R.string.model_status_loading) - - modelStatus.setTextColor(resources.getColor(R.color.model_loading_status_color, null)) - - val modelName = "Qwen3-0.6B" - val quantType = "Q8_0" - - // Reuse downloader instance to avoid concurrent download issues - if (downloader == null) { - downloader = LeapModelDownloader( - this@MainActivity, - notificationConfig = LeapModelDownloaderNotificationConfig.build { - notificationTitleDownloading = "Downloading Slogan Generator Model" - notificationTitleDownloaded = "Model Ready!" - } - ) - } - val downloaderInstance = downloader!! - - // Check if model needs to be downloaded - val currentStatus = downloaderInstance.queryStatus(modelName, quantType) - - if (currentStatus is LeapModelDownloader.ModelDownloadStatus.NotOnLocal) { - // Model needs to be downloaded - textView.text = "Starting download..." - - // Observe download progress - val progressFlow = downloaderInstance.observeDownloadProgress(modelName, quantType) - - // Start the download - downloaderInstance.requestDownloadModel(modelName, quantType) - - // Collect progress updates until download completes - progressFlow - .onEach { progress -> - if (progress != null) { - val downloadedMB = progress.downloadedSizeInBytes / (1024 * 1024) - val totalMB = progress.totalSizeInBytes / (1024 * 1024) - val percentage = if (progress.totalSizeInBytes > 0) { - (progress.downloadedSizeInBytes * 100.0 / progress.totalSizeInBytes).toInt() - } else { - 0 - } - textView.text = "Downloading: $percentage% ($downloadedMB MB / $totalMB MB)" - } else { - val downloadStatus = downloaderInstance.queryStatus(modelName, quantType) - if (downloadStatus is LeapModelDownloader.ModelDownloadStatus.Downloaded) { - textView.text = "Download complete!" - } - } - } - .takeWhile { progress -> - progress != null || downloaderInstance.queryStatus(modelName, quantType) is LeapModelDownloader.ModelDownloadStatus.DownloadInProgress - } - .collect() - } - - modelRunner = downloaderInstance.loadModel( - modelName = modelName, - quantizationType = quantType, - options = ModelLoadingOptions( - cacheOptions = ModelLoadingOptions.cacheOptions( - path = cacheDir.resolve("leap-cache").absolutePath, - ), - ), - ) - modelLoaded = true - - modelStatus.text = resources.getText(R.string.model_status_loaded) - modelStatus.setTextColor(Color.GREEN) - textView.text = getText(R.string.model_loaded_label) - return true - } catch (e: Exception) { - textView.text = - getString(R.string.error_loading_ai_model_oops_an_error_happened, e.message) - modelStatus.text = getText(R.string.model_status_error) - modelStatus.setTextColor(Color.RED) - } - return false - } - - /** - * Invoke the model to generate a translation from the user provided input. - * - * @param textView the main TextView to show generated contents - * @param userInput the user input - * @param generateButton the button for invoking generation. This button will be changed into "Stop" button when the generation starts. - * @param onComplete the callback to invoke when the generation is completed - */ - private fun generateContent( - textView: TextView, - userInput: String, - generateButton: Button, - onComplete: () -> Unit - ) { - generationJob = lifecycleScope.launch { - textView.text = getText(R.string.generation_in_progress_label) - val conversation = modelRunner.createConversation(SYSTEM_PROMPT) - - conversation.generateResponse(USER_PROMPT_TEMPLATE.format(userInput)).onEach { chunk -> - when (chunk) { - is MessageResponse.Chunk -> { - if (textView.text.startsWith(getText(R.string.generation_in_progress_label))) { - textView.text = chunk.text - } else { - textView.append(chunk.text) - } - } - else -> {} - } - (textView.parent as ScrollView).post { - (textView.parent as ScrollView).fullScroll(ScrollView.FOCUS_DOWN) - } - }.onCompletion { - generateButton.text = getText(R.string.generate_button_label) - generationJob = null - onComplete() - }.catch { e -> - textView.text = - "Error in translation: ${e.message} Try again? 🎨" - generateButton.text = getText(R.string.generate_button_label) - generationJob = null - } - .collect() - } - } - - - /** - * Create the main UI layout. - */ - private fun configMainLayout() { - val modelStatus = findViewById(R.id.model_status) - val userInput = findViewById(R.id.user_input) - val button = findViewById