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/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 }} diff --git a/.github/workflows/ios-demos-test.yml b/.github/workflows/ios-demos-test.yml new file mode 100644 index 0000000..3f7acf3 --- /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 17' \ + build 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/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/.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/.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/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 b0f2e04..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 @@ -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 @@ -37,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) @@ -115,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) } @@ -153,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, @@ -172,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) } @@ -186,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 { @@ -198,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") + ) ) } } @@ -240,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)) } @@ -248,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, ) } } @@ -276,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 } } @@ -310,7 +319,17 @@ 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_TYPE, + options = + ModelLoadingOptions( + cacheOptions = + ModelLoadingOptions.cacheOptions( + path = getApplication().cacheDir.resolve("leap-cache").absolutePath + ) + ), + ) // Create initial conversation conversation = modelRunner!!.createConversation(getString(R.string.system_prompt_audio)) @@ -318,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 ) } @@ -359,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) } @@ -386,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) @@ -422,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() { @@ -503,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 ) } @@ -527,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 @@ -608,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 3260076..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.0" +leapSdk = "0.10.8" 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/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 e6ed491..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,16 +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 @@ -52,476 +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, - ) - } 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 28b25c2..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.0" +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/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/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 a5c60b0..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.0" +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/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/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 fcf6c2f..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,21 +1,28 @@ package ai.liquid.leap.uidemo -import ai.liquid.leap.manifest.LeapDownloader -import ai.liquid.leap.manifest.LeapDownloaderConfig +import ai.liquid.leap.ModelLoadingOptions +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) { @@ -26,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() } @@ -36,20 +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, - quantizationSlug = QUANTIZATION_SLUG, - 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", - ) - }, + quantizationType = QUANTIZATION_TYPE, + options = + ModelLoadingOptions( + cacheOptions = + ModelLoadingOptions.cacheOptions( + path = app.cacheDir.resolve("leap-cache").absolutePath + ) + ), ) + modelRunner = runner store.setConversation( LeapVoiceConversation( conv = runner.createConversation(systemPrompt = SYSTEM_PROMPT), @@ -57,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 6c93179..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.0" +leapSdk = "0.10.8" 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/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 8668cbe..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.0 -- 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 c80461c..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,182 +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, quantType) - 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 4fe3af0..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.0" +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/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/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 5cfb313..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,8 +1,8 @@ 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 @@ -14,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 @@ -28,200 +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, - ) - 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 ce44bc3..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.0" +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/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/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 c0954fa..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,6 @@ 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 @@ -27,213 +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, quantType) - 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