diff --git a/app/src/main/java/com/pixel10/ai/inference/GeminiNanoModel.kt b/app/src/main/java/com/pixel10/ai/inference/GeminiNanoModel.kt index 22e4b4d..071afa6 100644 --- a/app/src/main/java/com/pixel10/ai/inference/GeminiNanoModel.kt +++ b/app/src/main/java/com/pixel10/ai/inference/GeminiNanoModel.kt @@ -2,17 +2,16 @@ package com.pixel10.ai.inference import android.content.Context import android.util.Log +import com.google.mlkit.genai.prompt.DownloadStatus +import com.google.mlkit.genai.prompt.FeatureStatus +import com.google.mlkit.genai.prompt.Generation import com.google.mlkit.genai.prompt.GenerativeModel -import com.google.mlkit.genai.prompt.type.Content import com.google.mlkit.genai.prompt.type.TextPart -import com.google.mlkit.genai.prompt.type.content -import com.google.mlkit.genai.prompt.type.generationConfig +import com.google.mlkit.genai.prompt.type.generateContentRequest import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.fold -import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException /** * Gemini Nano backend via ML Kit Prompt API. @@ -22,10 +21,9 @@ import kotlin.coroutines.resumeWithException * no manual download or file management needed. * * Key advantages: - * - Hardware-accelerated on Tensor G5 TPU (2.6x faster than G4) - * - 32,000 token context window on Pixel 10 - * - ~3 GB model always resident in RAM for instant inference + * - Hardware-accelerated on Tensor G5 TPU * - Fully offline, private — data never leaves the device + * - System-managed model, no manual downloads */ class GeminiNanoModel private constructor( private val generativeModel: GenerativeModel @@ -43,9 +41,12 @@ class GeminiNanoModel private constructor( temperature: Float ): String = withContext(Dispatchers.Default) { try { - val request = content { text(prompt) } + val request = generateContentRequest(TextPart(prompt)) { + this.temperature = temperature + this.topK = 40 + } val response = generativeModel.generateContent(request) - response.text ?: "" + response.candidates.firstOrNull()?.text ?: "" } catch (e: Exception) { Log.e(TAG, "Gemini Nano generation error", e) throw OnDeviceModel.InferenceException("Gemini Nano generation failed: ${e.message}", e) @@ -57,12 +58,11 @@ class GeminiNanoModel private constructor( onToken: (String) -> Unit ): String = withContext(Dispatchers.Default) { try { - val request = content { text(prompt) } - generativeModel.generateContentStream(request) - .fold("") { acc, response -> - val chunk = response.text ?: "" - if (chunk.isNotEmpty()) onToken(chunk) - acc + chunk + generativeModel.generateContentStream(prompt) + .fold("") { acc, chunk -> + val text = chunk.candidates.firstOrNull()?.text ?: "" + if (text.isNotEmpty()) onToken(text) + acc + text } } catch (e: Exception) { Log.e(TAG, "Gemini Nano streaming error", e) @@ -72,53 +72,60 @@ class GeminiNanoModel private constructor( override fun close() { isReady = false - generativeModel.close() + // GenerativeModel from Generation.getClient() is system-managed } companion object { private const val TAG = "GeminiNanoModel" suspend fun create(context: Context): GeminiNanoModel = withContext(Dispatchers.IO) { - // Check if Gemini Nano is available on this device - val model = GenerativeModel.newBuilder() - .setContext(context) - .build() + val model = Generation.getClient() - // Verify feature is available — will throw if not supported - suspendCancellableCoroutine { continuation -> - model.isAvailable() - .addOnSuccessListener { available -> - if (available) { - continuation.resume(Unit) - } else { - continuation.resumeWithException( - OnDeviceModel.InferenceException( - "Gemini Nano is not available on this device" - ) - ) + // Check if Gemini Nano is available on this device + val status = model.checkStatus() + when (status) { + FeatureStatus.UNAVAILABLE -> { + throw OnDeviceModel.InferenceException( + "Gemini Nano is not available on this device" + ) + } + FeatureStatus.DOWNLOADABLE -> { + Log.i(TAG, "Downloading Gemini Nano model...") + model.download().collect { downloadStatus -> + when (downloadStatus) { + is DownloadStatus.DownloadStarted -> + Log.i(TAG, "Model download started") + is DownloadStatus.DownloadProgress -> + Log.i(TAG, "Download in progress...") + DownloadStatus.DownloadCompleted -> + Log.i(TAG, "Model download completed") + is DownloadStatus.DownloadFailed -> + throw OnDeviceModel.InferenceException("Model download failed") } } - .addOnFailureListener { e -> - continuation.resumeWithException( - OnDeviceModel.InferenceException( - "Failed to check Gemini Nano availability: ${e.message}", e - ) - ) + } + FeatureStatus.DOWNLOADING -> { + Log.i(TAG, "Model already downloading, waiting...") + model.download().collect { downloadStatus -> + if (downloadStatus == DownloadStatus.DownloadCompleted) { + Log.i(TAG, "Download completed") + } } + } + FeatureStatus.AVAILABLE -> { + Log.i(TAG, "Gemini Nano is available") + } } - // Trigger model download if needed - suspendCancellableCoroutine { continuation -> - model.downloadModel() - .addOnSuccessListener { continuation.resume(Unit) } - .addOnFailureListener { e -> - Log.w(TAG, "Model download issue (may already be available): ${e.message}") - // Don't fail — model might already be cached - continuation.resume(Unit) - } + // Warm up for lower first-inference latency + try { + model.warmup() + Log.i(TAG, "Model warmup complete") + } catch (e: Exception) { + Log.w(TAG, "Warmup failed (non-fatal): ${e.message}") } - Log.i(TAG, "Gemini Nano model ready via ML Kit Prompt API") + Log.i(TAG, "Gemini Nano ready via ML Kit Prompt API") GeminiNanoModel(model) } } diff --git a/app/src/main/java/com/pixel10/ai/inference/MediaPipeModel.kt b/app/src/main/java/com/pixel10/ai/inference/MediaPipeModel.kt index 6637e97..9061b3c 100644 --- a/app/src/main/java/com/pixel10/ai/inference/MediaPipeModel.kt +++ b/app/src/main/java/com/pixel10/ai/inference/MediaPipeModel.kt @@ -4,11 +4,8 @@ import android.content.Context import android.util.Log import com.google.mediapipe.tasks.genai.llminference.LlmInference import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import java.io.File -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException /** * MediaPipe LLM Inference backend for custom open-weight models. @@ -52,23 +49,18 @@ class MediaPipeModel private constructor( prompt: String, onToken: (String) -> Unit ): String = withContext(Dispatchers.Default) { - suspendCancellableCoroutine { continuation -> - val fullResponse = StringBuilder() - try { - llmInference.generateResponseAsync(prompt).addResultListener { partialResult, done -> - val chunk = partialResult ?: "" - fullResponse.append(chunk) - onToken(chunk) - if (done) { - continuation.resume(fullResponse.toString()) - } - } - } catch (e: Exception) { - Log.e(TAG, "Streaming error", e) - continuation.resumeWithException( - OnDeviceModel.InferenceException("Streaming failed: ${e.message}", e) - ) - } + // MediaPipe's streaming API (generateResponseAsync) requires the result + // listener to be set at LlmInference build time via setResultListener(). + // Since our architecture needs a dynamic callback per request, we use + // synchronous generation and emit the result as a single chunk. + // For true token-by-token streaming, the Gemini Nano backend is preferred. + try { + val result = llmInference.generateResponse(prompt) + onToken(result) + result + } catch (e: Exception) { + Log.e(TAG, "MediaPipe generation error", e) + throw OnDeviceModel.InferenceException("Generation failed: ${e.message}", e) } } @@ -120,7 +112,6 @@ class MediaPipeModel private constructor( } private fun findModelPath(context: Context): String? { - // Search standard locations val searchDirs = listOfNotNull( context.filesDir, File(context.filesDir, "models"), @@ -137,7 +128,6 @@ class MediaPipeModel private constructor( return file.absolutePath } } - // Also check for any .task or .bin file dir.listFiles()?.firstOrNull { it.extension in listOf("task", "bin", "tflite") }?.let { return it.absolutePath } diff --git a/app/src/main/java/com/pixel10/ai/server/ApiModels.kt b/app/src/main/java/com/pixel10/ai/server/ApiModels.kt index 1f542d0..7bf9163 100644 --- a/app/src/main/java/com/pixel10/ai/server/ApiModels.kt +++ b/app/src/main/java/com/pixel10/ai/server/ApiModels.kt @@ -1,5 +1,7 @@ package com.pixel10.ai.server +import com.google.gson.annotations.SerializedName + /** * Request/response models for the AI API. * Follows an OpenAI-compatible schema for easy integration. @@ -20,6 +22,9 @@ data class Message( data class ChatResponse( val id: String, + @SerializedName("object") + val objectType: String = "chat.completion", + val created: Long = System.currentTimeMillis() / 1000, val model: String = "pixel10-on-device", val choices: List, val usage: Usage @@ -39,6 +44,9 @@ data class Usage( data class StreamChunk( val id: String, + @SerializedName("object") + val objectType: String = "chat.completion.chunk", + val created: Long = System.currentTimeMillis() / 1000, val model: String = "pixel10-on-device", val choices: List ) @@ -56,12 +64,15 @@ data class Delta( data class ModelInfo( val id: String = "pixel10-on-device", - val object_type: String = "model", + @SerializedName("object") + val objectType: String = "model", val owned_by: String = "local-device", val description: String = "On-device AI model running on Pixel 10 Tensor G5 chip" ) data class ModelList( + @SerializedName("object") + val objectType: String = "list", val data: List = listOf(ModelInfo()) ) diff --git a/app/src/main/java/com/pixel10/ai/ui/MainActivity.kt b/app/src/main/java/com/pixel10/ai/ui/MainActivity.kt index 29449db..b6b0305 100644 --- a/app/src/main/java/com/pixel10/ai/ui/MainActivity.kt +++ b/app/src/main/java/com/pixel10/ai/ui/MainActivity.kt @@ -6,6 +6,7 @@ import android.content.Context import android.content.Intent import android.content.ServiceConnection import android.content.pm.PackageManager +import android.graphics.drawable.GradientDrawable import android.net.wifi.WifiManager import android.os.Build import android.os.Bundle @@ -139,7 +140,7 @@ class MainActivity : AppCompatActivity() { when (state) { ApiServerService.ServerState.STOPPED -> { binding.tvServerStatus.text = getString(R.string.server_status_stopped) - binding.viewStatusDot.setBackgroundColor(getColor(R.color.status_stopped)) + (binding.viewStatusDot.background as? GradientDrawable)?.setColor(getColor(R.color.status_stopped)) binding.tvServerUrl.text = "http://—" binding.tvModelStatus.text = "Model: not loaded" binding.btnToggle.text = getString(R.string.btn_start) @@ -148,7 +149,7 @@ class MainActivity : AppCompatActivity() { } ApiServerService.ServerState.LOADING_MODEL -> { binding.tvServerStatus.text = getString(R.string.server_status_starting) - binding.viewStatusDot.setBackgroundColor(getColor(R.color.primary)) + (binding.viewStatusDot.background as? GradientDrawable)?.setColor(getColor(R.color.primary)) binding.tvModelStatus.text = getString(R.string.model_loading) binding.btnToggle.isEnabled = false binding.etPort.isEnabled = false @@ -157,7 +158,7 @@ class MainActivity : AppCompatActivity() { val port = binding.etPort.text.toString() val ip = getLocalIpAddress() binding.tvServerStatus.text = getString(R.string.server_status_running) - binding.viewStatusDot.setBackgroundColor(getColor(R.color.status_running)) + (binding.viewStatusDot.background as? GradientDrawable)?.setColor(getColor(R.color.status_running)) binding.tvServerUrl.text = "http://$ip:$port" binding.tvModelStatus.text = getString(R.string.model_ready) binding.btnToggle.text = getString(R.string.btn_stop) @@ -166,7 +167,7 @@ class MainActivity : AppCompatActivity() { } ApiServerService.ServerState.ERROR -> { binding.tvServerStatus.text = getString(R.string.server_status_error) - binding.viewStatusDot.setBackgroundColor(getColor(R.color.error)) + (binding.viewStatusDot.background as? GradientDrawable)?.setColor(getColor(R.color.error)) binding.tvModelStatus.text = getString(R.string.model_error) binding.btnToggle.text = getString(R.string.btn_start) binding.btnToggle.isEnabled = true diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..2c35211 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..01dd1eb --- /dev/null +++ b/gradlew @@ -0,0 +1,137 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +app_path=$0 +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld -- "$app_path" ) + link=${ls#*' -> '} + case $link in + /*) app_path=$link ;; + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in + CYGWIN* ) cygwin=true ;; + Darwin* ) darwin=true ;; + MSYS* | MINGW* ) msys=true ;; + NonStop* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 ; then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + ;; + esac + case $MAX_FD in + '' | soft) :;; + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + ;; + esac +fi + +# Collect all arguments for the java command, stracks://github.com/gradle/gradle/issues/25036) +# shellcheck disable=SC2153 +case $( dirname -- "$0" ) in + '' ) set -- org.gradle.wrapper.GradleWrapperMain "$@" ;; + *) set -- org.gradle.wrapper.GradleWrapperMain "$@" ;; +esac + +# Use "xargs" to parse quoted args. +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" \ + $DEFAULT_JVM_OPTS \ + $JAVA_OPTS \ + $GRADLE_OPTS \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..6543977 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,80 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %OS%==Windows_NT endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts index 99b2eb6..ae51e42 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -6,7 +6,7 @@ pluginManagement { } } -dependencyResolution { +dependencyResolutionManagement { repositories { google() mavenCentral()