6 Commits
Author SHA1 Message Date
alexpolo1andClaude Sonnet 4.6 0c26c836d6 Bump versionCode to 7 / versionName to 1.7.0 for proper update installs
Release / Build and Release APK (push) Failing after 10m54s
Android requires versionCode to increase for update-over-top installs.
Without this, reinstalling would say "App not installed" on some devices.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-02-28 23:23:40 +01:00
alexpolo1andClaude Sonnet 4.6 592550e71c Add in-app chat with streaming, settings UI, and request indicator
Release / Build and Release APK (push) Failing after 9m3s
Chat:
- ChatActivity with RecyclerView message list (user/AI bubbles)
- Streams tokens directly from LiteRT model via generateStreaming()
- Maintains full conversation history (multi-turn context)
- Respects auto_system_prompt setting from SharedPreferences
- "Chat" button in MainActivity, enabled only when server is running
- " Generating…" indicator while model is thinking
- Log on main screen unchanged — still shows all requests

Server:
- Settings card: temperature slider, max tokens, system prompt toggle
- " Processing request…" indicator in status card during inference
- onActiveRequest callback wired through service → activity

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-02-28 23:21:49 +01:00
alexpolo1andClaude Sonnet 4.6 baacaabf24 Fix streaming, add thread safety, settings UI, and request indicator
Release / Build and Release APK (push) Failing after 8m49s
Fixes:
- Streaming was bypassed for all requests (system prompt auto-inject made
  messages.size always > 1). Stream=true now routes directly to generateStreaming
  before multi-turn check
- LiteRTModel: add Mutex to serialize Engine calls (not thread-safe)

New features:
- Settings card: temperature slider, max tokens, agent system prompt toggle
  (all saved to SharedPreferences, applied on server start)
- Active request indicator: " Processing request…" shown in status card
  while inference is running
- onActiveRequest callback from AIApiServer → ApiServerService → MainActivity

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-02-28 23:17:41 +01:00
alexpolo1andClaude Sonnet 4.6 f9bfd76e00 Upgrade to LiteRT-LM runtime for Gemma 3n E4B support
Release / Build and Release APK (push) Failing after 9m46s
- Add com.google.ai.edge.litertlm:litertlm-android:0.9.0-alpha05 dependency
- New LiteRTModel backend: loads .litertlm files via Engine API with GPU backend
  (CPU fallback if GPU init fails)
- OnDeviceModel.create() priority: LiteRT-LM → MediaPipe → Gemini Nano
- ModelDownloader: updated to Gemma 3n E4B (4.9 GB) and E4B Web (4.3 GB)
  from google/gemma-3n-E4B-it-litert-lm (HuggingFace, license required)
- Download uses Bearer token auth; token saved to SharedPreferences

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-02-28 22:49:22 +01:00
alexpolo1andClaude Sonnet 4.6 af84d8ebc4 Fix downloads: HuggingFace auth + Gemma 3 1B IT models
Release / Build and Release APK (push) Failing after 11m18s
Google moved all models to HuggingFace (old CDN returns 404).
All downloads now require a HuggingFace API token.

- Add HF token input field (saved to SharedPreferences, restored on relaunch)
- Download uses Bearer auth header
- Switch to Gemma 3 1B IT (litert-community) — .task format, works with
  current MediaPipe API, 555 MB Q4 or 1 GB Q8
- Clear auth error messages (401/403 shown to user)
- Gemma 3n E4B/E2B (.litertlm format) requires runtime upgrade — planned next

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-02-28 22:36:05 +01:00
alexpolo1andClaude Sonnet 4.6 0861ed38fc Add multi-model download: Gemma 3n E4B (recommended), E2B, Gemma 2B
Release / Build and Release APK (push) Failing after 14m8s
- ModelSpec enum with 3 downloadable options from MediaPipe CDN
- Gemma 3n E4B set as recommended default (best coding/reasoning via MoE)
- Gemma 3n E2B as faster/smaller alternative
- Gemma 2B kept as lightest option
- UI shows all 3 download buttons, hides all once any model is installed
- Installed model name shown in status (e.g. "✓ Gemma 3n E4B ready")
- Custom models (DeepSeek Coder, Qwen2.5-Coder) can be manually placed in files dir

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-02-28 22:25:49 +01:00
19 changed files with 929 additions and 90 deletions
+6 -3
View File
@@ -11,8 +11,8 @@ android {
applicationId = "com.pixel10.ai"
minSdk = 31
targetSdk = 35
versionCode = 1
versionName = "1.0.0"
versionCode = 7
versionName = "1.7.0"
}
buildTypes {
@@ -51,9 +51,12 @@ dependencies {
// ML Kit GenAI — Gemini Nano via AICore (recommended for Pixel 10)
implementation("com.google.mlkit:genai-prompt:1.0.0-beta1")
// MediaPipe LLM Inference — for custom models (Gemma, etc.)
// MediaPipe LLM Inference — legacy fallback for .task/.bin models
implementation("com.google.mediapipe:tasks-genai:0.10.24")
// LiteRT-LM — primary backend for Gemma 3n .litertlm models
implementation("com.google.ai.edge.litertlm:litertlm-android:0.9.0-alpha05")
// Embedded HTTP server
implementation("org.nanohttpd:nanohttpd:2.3.1")
+5
View File
@@ -27,6 +27,11 @@
android:networkSecurityConfig="@xml/network_security_config"
android:theme="@style/Theme.Pixel10AI">
<activity
android:name=".ui.ChatActivity"
android:exported="false"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".ui.MainActivity"
android:exported="true"
@@ -0,0 +1,146 @@
package com.pixel10.ai.inference
import android.content.Context
import android.util.Log
import com.google.ai.edge.litertlm.Backend
import com.google.ai.edge.litertlm.Engine
import com.google.ai.edge.litertlm.EngineConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.io.File
/**
* LiteRT-LM backend for Gemma 3n models (.litertlm format).
*
* This replaces MediaPipe for the newer Gemma 3n E4B/E2B models which use
* the LiteRT-LM runtime. Runs fully on-device using the Tensor G5 GPU.
*
* Model files must be placed in the app's files directory (see [ModelDownloader]).
*/
class LiteRTModel private constructor(
private val engine: Engine,
private val modelName: String
) : OnDeviceModel {
override val backendName = "LiteRT-LM ($modelName)"
@Volatile
override var isReady: Boolean = true
private set
// LiteRT Engine is not thread-safe — serialize all inference calls
private val mutex = Mutex()
override suspend fun generate(
prompt: String,
maxTokens: Int,
temperature: Float
): String = mutex.withLock {
withContext(Dispatchers.Default) {
val conversation = engine.createConversation()
try {
conversation.sendMessage(prompt).toString()
} catch (e: Exception) {
Log.e(TAG, "LiteRT inference error", e)
throw OnDeviceModel.InferenceException("Generation failed: ${e.message}", e)
} finally {
conversation.close()
}
}
}
override suspend fun generateStreaming(
prompt: String,
onToken: (String) -> Unit
): String = mutex.withLock {
withContext(Dispatchers.Default) {
val conversation = engine.createConversation()
val sb = StringBuilder()
try {
conversation.sendMessageAsync(prompt)
.catch { e ->
throw OnDeviceModel.InferenceException("Streaming failed: ${e.message}", e)
}
.collect { message ->
val token = message.toString()
sb.append(token)
onToken(token)
}
} finally {
conversation.close()
}
sb.toString()
}
}
override fun close() {
isReady = false
engine.close()
}
companion object {
private const val TAG = "LiteRTModel"
private val MODEL_EXTENSIONS = listOf("litertlm")
suspend fun create(context: Context): LiteRTModel = withContext(Dispatchers.IO) {
val modelPath = findModelPath(context)
?: throw OnDeviceModel.InferenceException(
"No LiteRT-LM model file found.\n" +
"Download a .litertlm model via the app or place one in:\n" +
" ${context.filesDir.absolutePath}/"
)
val modelName = File(modelPath).name
Log.i(TAG, "Loading LiteRT-LM model: $modelPath")
try {
val config = EngineConfig(
modelPath = modelPath,
backend = Backend.GPU
)
val engine = Engine(config)
withContext(Dispatchers.Default) {
engine.initialize()
}
Log.i(TAG, "LiteRT-LM model loaded: $modelName")
LiteRTModel(engine, modelName)
} catch (gpuError: Exception) {
Log.w(TAG, "GPU backend failed, trying CPU: ${gpuError.message}")
try {
val config = EngineConfig(
modelPath = modelPath,
backend = Backend.CPU
)
val engine = Engine(config)
withContext(Dispatchers.Default) {
engine.initialize()
}
Log.i(TAG, "LiteRT-LM model loaded on CPU: $modelName")
LiteRTModel(engine, modelName)
} catch (e: Exception) {
throw OnDeviceModel.InferenceException(
"Failed to load LiteRT-LM model from $modelPath: ${e.message}", e
)
}
}
}
private fun findModelPath(context: Context): String? {
val searchDirs = listOfNotNull(
context.filesDir,
File(context.filesDir, "models"),
context.getExternalFilesDir(null)
)
for (dir in searchDirs) {
if (!dir.exists()) continue
dir.listFiles()?.firstOrNull { it.extension in MODEL_EXTENSIONS }
?.let { return it.absolutePath }
}
return null
}
}
}
@@ -10,23 +10,50 @@ import java.net.HttpURLConnection
import java.net.URL
/**
* Downloads a MediaPipe-compatible Gemma model for background-safe inference.
* Downloads a MediaPipe-compatible model for background-safe inference.
*
* Gemini Nano (ML Kit) blocks inference when the app is backgrounded (ErrorCode 30).
* MediaPipe with a local model file has no such restriction — it runs entirely in
* the app process using the Tensor G5 GPU via OpenCL/Vulkan.
* All models are hosted on HuggingFace and require a free API token.
* Get one at: https://huggingface.co/settings/tokens
*
* The downloaded model is stored in the app's private files directory and
* survives app restarts. Only needs to be downloaded once (~1.3 GB).
* Models use the MediaPipe `.task` format, compatible with [MediaPipeModel].
* Gemma 3n E4B/E2B (`.litertlm` format) requires a runtime upgrade — coming later.
*/
object ModelDownloader {
private const val TAG = "ModelDownloader"
private const val HF_BASE = "https://huggingface.co"
const val MODEL_FILENAME = "gemma-2b-it-gpu-int4.bin"
private const val MODEL_URL =
"https://storage.googleapis.com/mediapipe-models/llm_inference/" +
"gemma-2b-it-gpu-int4/float16/1/gemma-2b-it-gpu-int4.bin"
/** Available model specs downloadable from HuggingFace (requires token + license acceptance). */
enum class ModelSpec(
val displayName: String,
val filename: String,
val repo: String,
val sizeMb: Int,
val description: String
) {
/**
* Gemma 3n E4B INT4 — best quality, Tensor G5 optimised, background-safe.
* Accept license at: https://huggingface.co/google/gemma-3n-E4B-it-litert-lm
*/
GEMMA_3N_E4B(
displayName = "Gemma 3n E4B",
filename = "gemma-3n-E4B-it-int4.litertlm",
repo = "google/gemma-3n-E4B-it-litert-lm",
sizeMb = 4920,
description = "Best quality — Tensor G5 optimised (~4.9 GB)"
),
/**
* Gemma 3n E4B Web INT4 — smaller variant, slightly lower quality.
* Same license as above.
*/
GEMMA_3N_E4B_WEB(
displayName = "Gemma 3n E4B (Web)",
filename = "gemma-3n-E4B-it-int4-Web.litertlm",
repo = "google/gemma-3n-E4B-it-litert-lm",
sizeMb = 4280,
description = "Slightly smaller variant (~4.3 GB)"
)
}
data class Progress(
val downloadedBytes: Long,
@@ -34,34 +61,58 @@ object ModelDownloader {
val percent: Int = if (totalBytes > 0) (downloadedBytes * 100 / totalBytes).toInt() else 0
)
/** Returns true if any supported model is present in the app's files directory. */
fun isModelPresent(context: Context): Boolean =
modelFile(context).let { it.exists() && it.length() > 1_000_000L }
ModelSpec.values().any { modelFile(context, it).let { f -> f.exists() && f.length() > 1_000_000L } }
fun modelFile(context: Context): File = File(context.filesDir, MODEL_FILENAME)
/** Returns the installed [ModelSpec], or null if no model is present. */
fun installedSpec(context: Context): ModelSpec? =
ModelSpec.values().firstOrNull { modelFile(context, it).let { f -> f.exists() && f.length() > 1_000_000L } }
fun modelFile(context: Context, spec: ModelSpec): File =
File(context.filesDir, spec.filename)
/** Returns the file of the installed model, or E4B path as default. */
fun modelFile(context: Context): File =
installedSpec(context)?.let { modelFile(context, it) }
?: modelFile(context, ModelSpec.GEMMA_3N_E4B)
/**
* Download the model, reporting progress via [onProgress].
* Download [spec] from HuggingFace, using [hfToken] for authentication.
* Supports resume — if a partial file exists, continues from where it left off.
*
* Get a free token at https://huggingface.co/settings/tokens
*/
suspend fun download(
context: Context,
spec: ModelSpec = ModelSpec.GEMMA_3N_E4B,
hfToken: String,
onProgress: (Progress) -> Unit
) = withContext(Dispatchers.IO) {
val dest = modelFile(context)
if (hfToken.isBlank()) throw OnDeviceModel.InferenceException(
"HuggingFace token required.\nGet a free token at huggingface.co/settings/tokens"
)
val dest = modelFile(context, spec)
val alreadyDownloaded = if (dest.exists()) dest.length() else 0L
val downloadUrl = "$HF_BASE/${spec.repo}/resolve/main/${spec.filename}"
Log.i(TAG, "Download starting (already have $alreadyDownloaded bytes)")
Log.i(TAG, "Download starting ${spec.displayName} from $downloadUrl (already have $alreadyDownloaded bytes)")
val conn = URL(MODEL_URL).openConnection() as HttpURLConnection
val conn = URL(downloadUrl).openConnection() as HttpURLConnection
try {
conn.connectTimeout = 30_000
conn.readTimeout = 60_000
conn.setRequestProperty("Authorization", "Bearer $hfToken")
if (alreadyDownloaded > 0) {
conn.setRequestProperty("Range", "bytes=$alreadyDownloaded-")
}
conn.connect()
val code = conn.responseCode
if (code == 401 || code == 403) throw OnDeviceModel.InferenceException(
"Authentication failed (HTTP $code).\nCheck your HuggingFace token."
)
val resuming = code == HttpURLConnection.HTTP_PARTIAL // 206
if (code != HttpURLConnection.HTTP_OK && !resuming) {
throw OnDeviceModel.InferenceException("Download failed: HTTP $code")
@@ -90,7 +141,7 @@ object ModelDownloader {
}
fun deleteModel(context: Context) {
modelFile(context).delete()
Log.i(TAG, "Model deleted")
ModelSpec.values().forEach { modelFile(context, it).delete() }
Log.i(TAG, "All models deleted")
}
}
@@ -112,9 +112,19 @@ interface OnDeviceModel {
* Tap "Download Model" in the app UI to get the MediaPipe model automatically.
*/
suspend fun create(context: Context): OnDeviceModel = withContext(Dispatchers.IO) {
// MediaPipe first — background-safe, GPU-accelerated via Tensor G5
// LiteRT-LM first — Gemma 3n .litertlm format, GPU-accelerated, background-safe
try {
Log.i(TAG, "Attempting MediaPipe LLM with local model...")
Log.i(TAG, "Attempting LiteRT-LM with local .litertlm model...")
val litert = LiteRTModel.create(context)
Log.i(TAG, "LiteRT-LM model ready: ${litert.backendName}")
return@withContext litert
} catch (e: Exception) {
Log.w(TAG, "LiteRT-LM not available: ${e.message}")
}
// MediaPipe fallback — .task/.bin format, background-safe
try {
Log.i(TAG, "Attempting MediaPipe LLM with local .task model...")
val mediapipe = MediaPipeModel.create(context)
Log.i(TAG, "MediaPipe model ready: ${mediapipe.backendName}")
return@withContext mediapipe
@@ -122,7 +132,7 @@ interface OnDeviceModel {
Log.w(TAG, "MediaPipe not available: ${e.message}")
}
// Gemini Nano fallback — only works when app is in foreground
// Gemini Nano last resort — foreground only
try {
Log.i(TAG, "Attempting Gemini Nano via ML Kit (foreground only)...")
val nano = GeminiNanoModel.create(context)
@@ -134,11 +144,10 @@ interface OnDeviceModel {
throw InferenceException(
"No model loaded yet.\n\n" +
"Tap 'Download Model' in the app to download Gemma 2B (~1.3 GB).\n" +
"Tap 'Download Model' in the app to download Gemma 3n E4B.\n" +
"Once downloaded the server works fully in the background.\n\n" +
"Or place a compatible model file in:\n" +
" ${context.filesDir.absolutePath}/\n" +
" Supported: gemma-2b-it-gpu-int4.bin, gemma-3n-E2B.task, etc."
"Or place a .litertlm file in:\n" +
" ${context.filesDir.absolutePath}/"
)
}
}
@@ -27,9 +27,16 @@ import java.util.concurrent.atomic.AtomicLong
* -H "Content-Type: application/json" \
* -d '{"messages":[{"role":"user","content":"Hello!"}]}'
*/
data class ServerConfig(
val defaultTemperature: Float = 0.7f,
val defaultMaxTokens: Int = 1024,
val autoSystemPrompt: Boolean = true
)
class AIApiServer(
port: Int,
private val model: OnDeviceModel
private val model: OnDeviceModel,
private val config: ServerConfig = ServerConfig()
) : NanoHTTPD(port) {
private val gson = Gson()
@@ -37,6 +44,7 @@ class AIApiServer(
val requestCount = AtomicLong(0)
var onRequestLogged: ((String) -> Unit)? = null
var onActiveRequest: ((Boolean) -> Unit)? = null
override fun serve(session: IHTTPSession): Response {
val method = session.method
@@ -113,31 +121,48 @@ class AIApiServer(
return errorResponse(400, "messages array is required and must not be empty")
}
// Auto-inject agent system prompt if the conversation has no system message.
// Auto-inject default tools if the request provides none.
// This makes the server zero-config as a coding agent for any OpenAI-compatible client.
val messages = if (raw.messages.none { it.role == "system" }) {
// Auto-inject agent system prompt if enabled and no system message present
val messages = if (config.autoSystemPrompt && raw.messages.none { it.role == "system" }) {
listOf(Message(role = "system", content = AgentConfig.SYSTEM_PROMPT)) + raw.messages
} else {
raw.messages
}
val request = raw.copy(
messages = messages,
tools = raw.tools.takeUnless { it.isNullOrEmpty() } ?: AgentConfig.DEFAULT_TOOLS
tools = raw.tools.takeUnless { it.isNullOrEmpty() }
?: if (config.autoSystemPrompt) AgentConfig.DEFAULT_TOOLS else null,
temperature = if (raw.temperature == 0.7f) config.defaultTemperature else raw.temperature,
max_tokens = if (raw.max_tokens == 8192) config.defaultMaxTokens else raw.max_tokens
)
val id = "chatcmpl-${UUID.randomUUID().toString().take(8)}"
val hasTools = !request.tools.isNullOrEmpty()
log("Chat: ${request.messages.size} messages, tools=${request.tools?.size ?: 0}, stream=${request.stream}")
log("Chat: ${request.messages.size} messages, tools=${request.tools?.size ?: 0}, stream=${request.stream}, temp=${request.temperature}")
// ── Streaming — always uses flat prompt + generateStreaming ────────────
if (request.stream) {
val prompt = buildFlatPrompt(request.messages)
onActiveRequest?.invoke(true)
return try {
handleStreamingResponse(id, prompt, request)
} finally {
onActiveRequest?.invoke(false)
}
}
// ── Tool calling / multi-turn chat ─────────────────────────────────────
if (hasTools || request.messages.size > 1 || request.messages.any { it.role == "system" }) {
val convMessages = request.messages.map { it.toConvMessage() }
val toolDefs = request.tools?.map { it.toToolDef() } ?: emptyList()
val result = runBlocking {
model.chat(convMessages, toolDefs, request.max_tokens, request.temperature)
onActiveRequest?.invoke(true)
val result = try {
runBlocking {
model.chat(convMessages, toolDefs, request.max_tokens, request.temperature)
}
} finally {
onActiveRequest?.invoke(false)
}
if (result.toolCalls != null) {
@@ -34,9 +34,11 @@ class ApiServerService : Service() {
var onStatusChanged: ((ServerState) -> Unit)? = null
var onLog: ((String) -> Unit)? = null
var onActiveRequest: ((Boolean) -> Unit)? = null
val isRunning: Boolean get() = server != null
val requestCount: Long get() = server?.requestCount?.get() ?: 0
val currentModel: OnDeviceModel? get() = model
inner class LocalBinder : Binder() {
val service: ApiServerService get() = this@ApiServerService
@@ -70,8 +72,15 @@ class ApiServerService : Service() {
// Start the HTTP server
notifyLog("Starting API server on port $port...")
val apiServer = AIApiServer(port, model!!)
val prefs = getSharedPreferences("pixel10_prefs", MODE_PRIVATE)
val serverConfig = ServerConfig(
defaultTemperature = prefs.getFloat("temperature", 0.7f),
defaultMaxTokens = prefs.getInt("max_tokens", 1024),
autoSystemPrompt = prefs.getBoolean("auto_system_prompt", true)
)
val apiServer = AIApiServer(port, model!!, serverConfig)
apiServer.onRequestLogged = { msg -> notifyLog(msg) }
apiServer.onActiveRequest = { active -> onActiveRequest?.invoke(active) }
apiServer.start()
server = apiServer
@@ -0,0 +1,158 @@
package com.pixel10.ai.ui
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.Bundle
import android.os.IBinder
import android.view.View
import android.view.inputmethod.EditorInfo
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.pixel10.ai.databinding.ActivityChatBinding
import com.pixel10.ai.inference.OnDeviceModel
import com.pixel10.ai.server.AgentConfig
import com.pixel10.ai.server.ApiServerService
import kotlinx.coroutines.launch
class ChatActivity : AppCompatActivity() {
private lateinit var binding: ActivityChatBinding
private val messages = mutableListOf<ChatMessage>()
private lateinit var adapter: MessageAdapter
private var service: ApiServerService? = null
private var bound = false
private var generating = false
private val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
service = (binder as ApiServerService.LocalBinder).service
bound = true
}
override fun onServiceDisconnected(name: ComponentName?) {
service = null
bound = false
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityChatBinding.inflate(layoutInflater)
setContentView(binding.root)
setSupportActionBar(binding.toolbar)
binding.toolbar.setNavigationOnClickListener { finish() }
adapter = MessageAdapter(messages)
binding.rvMessages.layoutManager = LinearLayoutManager(this).also {
it.stackFromEnd = true
}
binding.rvMessages.adapter = adapter
binding.btnSend.setOnClickListener { sendMessage() }
binding.etMessage.setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_SEND) { sendMessage(); true } else false
}
}
override fun onStart() {
super.onStart()
bindService(
Intent(this, ApiServerService::class.java),
serviceConnection,
Context.BIND_AUTO_CREATE
)
}
override fun onStop() {
super.onStop()
if (bound) { unbindService(serviceConnection); bound = false }
}
private fun sendMessage() {
if (generating) return
val text = binding.etMessage.text.toString().trim()
if (text.isEmpty()) return
binding.etMessage.text?.clear()
// Add user message
messages.add(ChatMessage("user", text))
adapter.notifyItemInserted(messages.size - 1)
scrollToBottom()
// Add empty AI placeholder
messages.add(ChatMessage("assistant", ""))
val aiIndex = messages.size - 1
adapter.notifyItemInserted(aiIndex)
scrollToBottom()
binding.tvTyping.visibility = View.VISIBLE
binding.btnSend.isEnabled = false
generating = true
val model = service?.currentModel
if (model == null || !model.isReady) {
messages[aiIndex].content = "⚠️ Server not running — start the server first."
adapter.notifyItemChanged(aiIndex)
finishGeneration()
return
}
val prompt = buildPrompt()
lifecycleScope.launch {
try {
model.generateStreaming(prompt) { token ->
runOnUiThread {
messages[aiIndex].content += token
adapter.notifyItemChanged(aiIndex)
scrollToBottom()
}
}
} catch (e: Exception) {
runOnUiThread {
messages[aiIndex].content = "⚠️ Error: ${e.message}"
adapter.notifyItemChanged(aiIndex)
}
} finally {
runOnUiThread { finishGeneration() }
}
}
}
private fun finishGeneration() {
generating = false
binding.tvTyping.visibility = View.GONE
binding.btnSend.isEnabled = true
scrollToBottom()
}
private fun buildPrompt(): String {
val prefs = getSharedPreferences("pixel10_prefs", MODE_PRIVATE)
val useSystemPrompt = prefs.getBoolean("auto_system_prompt", true)
val sb = StringBuilder()
if (useSystemPrompt) {
sb.append("System: ${AgentConfig.SYSTEM_PROMPT}\n\n")
}
// Include all messages except the last empty AI placeholder
for (i in 0 until messages.size - 1) {
val msg = messages[i]
when (msg.role) {
"user" -> sb.append("User: ${msg.content}\n\n")
"assistant" -> sb.append("Assistant: ${msg.content}\n\n")
}
}
sb.append("Assistant:")
return sb.toString()
}
private fun scrollToBottom() {
if (messages.isNotEmpty()) {
binding.rvMessages.smoothScrollToPosition(messages.size - 1)
}
}
}
@@ -0,0 +1,3 @@
package com.pixel10.ai.ui
data class ChatMessage(val role: String, var content: String)
@@ -5,6 +5,7 @@ import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.graphics.drawable.GradientDrawable
import android.net.wifi.WifiManager
@@ -12,6 +13,7 @@ import android.os.Build
import android.os.Bundle
import android.os.IBinder
import android.view.View
import android.widget.SeekBar
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
@@ -19,6 +21,7 @@ import androidx.lifecycle.lifecycleScope
import com.pixel10.ai.R
import com.pixel10.ai.databinding.ActivityMainBinding
import com.pixel10.ai.inference.ModelDownloader
import com.pixel10.ai.inference.ModelDownloader.ModelSpec
import com.pixel10.ai.server.ApiServerService
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
@@ -28,6 +31,7 @@ import java.util.Locale
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private lateinit var prefs: SharedPreferences
private var service: ApiServerService? = null
private var bound = false
private var downloading = false
@@ -50,6 +54,16 @@ class MainActivity : AppCompatActivity() {
service?.onLog = { message ->
runOnUiThread { appendLog(message) }
}
service?.onActiveRequest = { active ->
runOnUiThread {
if (active) {
binding.tvActiveRequest.text = "⚡ Processing request…"
binding.tvActiveRequest.visibility = View.VISIBLE
} else {
binding.tvActiveRequest.visibility = View.GONE
}
}
}
if (service?.isRunning == true) {
updateStatus(ApiServerService.ServerState.RUNNING)
@@ -67,14 +81,40 @@ class MainActivity : AppCompatActivity() {
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
prefs = getSharedPreferences("pixel10_prefs", MODE_PRIVATE)
requestNotificationPermission()
// Restore saved settings
binding.etHfToken.setText(prefs.getString("hf_token", ""))
val savedTemp = (prefs.getFloat("temperature", 0.7f) * 100).toInt()
binding.seekTemperature.progress = savedTemp
binding.tvTemperatureValue.text = "%.1f".format(savedTemp / 100f)
binding.etMaxTokens.setText(prefs.getInt("max_tokens", 1024).toString())
binding.switchSystemPrompt.isChecked = prefs.getBoolean("auto_system_prompt", true)
binding.seekTemperature.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
binding.tvTemperatureValue.text = "%.1f".format(progress / 100f)
}
override fun onStartTrackingTouch(seekBar: SeekBar) {}
override fun onStopTrackingTouch(seekBar: SeekBar) {}
})
binding.btnToggle.setOnClickListener {
if (service?.isRunning == true) stopServer() else startServer()
}
binding.btnChat.setOnClickListener {
startActivity(Intent(this, ChatActivity::class.java))
}
binding.btnDownloadModel.setOnClickListener {
startModelDownload()
saveHfToken()
startModelDownload(ModelSpec.GEMMA_3N_E4B)
}
binding.btnDownloadGemma3Q8.setOnClickListener {
saveHfToken()
startModelDownload(ModelSpec.GEMMA_3N_E4B_WEB)
}
updateModelCard()
@@ -121,34 +161,43 @@ class MainActivity : AppCompatActivity() {
}
}
private fun startModelDownload() {
private fun saveHfToken() {
val token = binding.etHfToken.text.toString().trim()
prefs.edit().putString("hf_token", token).apply()
}
private fun startModelDownload(spec: ModelSpec) {
if (downloading) return
val token = binding.etHfToken.text.toString().trim()
if (token.isBlank()) {
binding.tvModelDownloadStatus.text = "Enter your HuggingFace token first"
return
}
downloading = true
binding.btnDownloadModel.isEnabled = false
binding.btnDownloadModel.text = "Downloading…"
setDownloadButtonsEnabled(false)
binding.progressDownload.visibility = View.VISIBLE
binding.tvModelDownloadStatus.text = "Starting download…"
binding.tvModelDownloadStatus.text = "Starting download: ${spec.displayName}"
lifecycleScope.launch {
try {
ModelDownloader.download(this@MainActivity) { progress ->
ModelDownloader.download(this@MainActivity, spec, token) { progress ->
runOnUiThread {
binding.progressDownload.progress = progress.percent
val mb = progress.downloadedBytes / 1_048_576
val total = progress.totalBytes / 1_048_576
binding.tvModelDownloadStatus.text = "Downloading… ${mb}MB / ${total}MB (${progress.percent}%)"
binding.tvModelDownloadStatus.text =
"${spec.displayName}: ${mb}MB / ${total}MB (${progress.percent}%)"
}
}
runOnUiThread {
downloading = false
updateModelCard()
appendLog("Model downloaded — background inference enabled")
appendLog("${spec.displayName} downloaded — background inference enabled")
}
} catch (e: Exception) {
runOnUiThread {
downloading = false
binding.btnDownloadModel.isEnabled = true
binding.btnDownloadModel.text = getString(R.string.btn_download_model)
setDownloadButtonsEnabled(true)
binding.progressDownload.visibility = View.GONE
binding.tvModelDownloadStatus.text = "Download failed: ${e.message}"
appendLog("Download error: ${e.message}")
@@ -157,22 +206,39 @@ class MainActivity : AppCompatActivity() {
}
}
private fun setDownloadButtonsEnabled(enabled: Boolean) {
binding.btnDownloadModel.isEnabled = enabled
binding.btnDownloadGemma3Q8.isEnabled = enabled
}
private fun updateModelCard() {
val present = ModelDownloader.isModelPresent(this)
if (present) {
binding.tvModelDownloadStatus.text = getString(R.string.model_downloaded)
val spec = ModelDownloader.installedSpec(this)
if (spec != null) {
binding.tvModelDownloadStatus.text = getString(R.string.model_downloaded, spec.displayName)
binding.etHfToken.visibility = View.GONE
binding.btnDownloadModel.visibility = View.GONE
binding.btnDownloadGemma3Q8.visibility = View.GONE
binding.progressDownload.visibility = View.GONE
} else {
binding.tvModelDownloadStatus.text = getString(R.string.model_not_downloaded)
binding.etHfToken.visibility = View.VISIBLE
binding.btnDownloadModel.visibility = View.VISIBLE
binding.btnDownloadModel.isEnabled = true
binding.btnDownloadModel.text = getString(R.string.btn_download_model)
binding.btnDownloadGemma3Q8.visibility = View.VISIBLE
setDownloadButtonsEnabled(true)
binding.progressDownload.visibility = View.GONE
}
}
private fun saveSettings() {
prefs.edit()
.putFloat("temperature", binding.seekTemperature.progress / 100f)
.putInt("max_tokens", binding.etMaxTokens.text.toString().toIntOrNull() ?: 1024)
.putBoolean("auto_system_prompt", binding.switchSystemPrompt.isChecked)
.apply()
}
private fun startServer() {
saveSettings()
val port = binding.etPort.text.toString().toIntOrNull() ?: 8080
val intent = Intent(this, ApiServerService::class.java).apply {
action = ApiServerService.ACTION_START
@@ -198,6 +264,9 @@ class MainActivity : AppCompatActivity() {
val canEdit = state == ApiServerService.ServerState.STOPPED ||
state == ApiServerService.ServerState.ERROR
binding.etPort.isEnabled = canEdit
binding.seekTemperature.isEnabled = canEdit
binding.etMaxTokens.isEnabled = canEdit
binding.switchSystemPrompt.isEnabled = canEdit
when (state) {
ApiServerService.ServerState.STOPPED -> {
@@ -207,6 +276,7 @@ class MainActivity : AppCompatActivity() {
binding.tvModelStatus.text = "Model: not loaded"
binding.btnToggle.text = getString(R.string.btn_start)
binding.btnToggle.isEnabled = true
binding.btnChat.isEnabled = false
}
ApiServerService.ServerState.LOADING_MODEL -> {
binding.tvServerStatus.text = getString(R.string.server_status_starting)
@@ -223,6 +293,7 @@ class MainActivity : AppCompatActivity() {
binding.tvModelStatus.text = getString(R.string.model_ready)
binding.btnToggle.text = getString(R.string.btn_stop)
binding.btnToggle.isEnabled = true
binding.btnChat.isEnabled = true
}
ApiServerService.ServerState.ERROR -> {
binding.tvServerStatus.text = getString(R.string.server_status_error)
@@ -0,0 +1,43 @@
package com.pixel10.ai.ui
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.pixel10.ai.R
class MessageAdapter(private val messages: List<ChatMessage>) :
RecyclerView.Adapter<RecyclerView.ViewHolder>() {
companion object {
private const val TYPE_USER = 0
private const val TYPE_AI = 1
}
override fun getItemViewType(position: Int) =
if (messages[position].role == "user") TYPE_USER else TYPE_AI
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context)
return if (viewType == TYPE_USER) {
val view = inflater.inflate(R.layout.item_message_user, parent, false)
UserViewHolder(view.findViewById(R.id.tvContent))
} else {
val view = inflater.inflate(R.layout.item_message_ai, parent, false)
AiViewHolder(view.findViewById(R.id.tvContent))
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val msg = messages[position]
when (holder) {
is UserViewHolder -> holder.tv.text = msg.content
is AiViewHolder -> holder.tv.text = msg.content.ifEmpty { "" }
}
}
override fun getItemCount() = messages.size
class UserViewHolder(val tv: TextView) : RecyclerView.ViewHolder(tv.parent as android.view.View)
class AiViewHolder(val tv: TextView) : RecyclerView.ViewHolder(tv.parent as android.view.View)
}
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/surface_variant" />
<corners
android:topLeftRadius="4dp"
android:topRightRadius="16dp"
android:bottomLeftRadius="16dp"
android:bottomRightRadius="16dp" />
</shape>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/primary" />
<corners
android:topLeftRadius="16dp"
android:topRightRadius="16dp"
android:bottomLeftRadius="16dp"
android:bottomRightRadius="4dp" />
</shape>
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="@color/on_surface">
<path
android:fillColor="@color/on_surface"
android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z" />
</vector>
+76
View File
@@ -0,0 +1,76 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="@color/surface">
<!-- Toolbar -->
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="@color/surface_variant"
android:paddingStart="4dp"
app:title="Chat"
app:titleTextColor="@color/on_surface"
app:navigationIcon="@drawable/ic_back" />
<!-- Message list -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvMessages"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:padding="12dp"
android:clipToPadding="false" />
<!-- Typing indicator -->
<TextView
android:id="@+id/tvTyping"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingBottom="4dp"
android:text="⚡ Generating…"
android:textColor="@color/primary"
android:textSize="12sp"
android:visibility="gone" />
<!-- Input row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:padding="8dp"
android:background="@color/surface_variant">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etMessage"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="Message…"
android:textColor="@color/on_surface"
android:textColorHint="@color/log_text"
android:textSize="15sp"
android:maxLines="4"
android:inputType="textMultiLine|textCapSentences"
android:backgroundTint="@color/primary" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSend"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="Send"
android:textSize="14sp"
app:cornerRadius="8dp" />
</LinearLayout>
</LinearLayout>
+192 -33
View File
@@ -102,6 +102,16 @@
android:textColor="@color/log_text"
android:textSize="13sp"
android:layout_marginTop="2dp" />
<TextView
android:id="@+id/tvActiveRequest"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/primary"
android:textSize="13sp"
android:textStyle="bold"
android:layout_marginTop="2dp"
android:visibility="gone" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
@@ -132,6 +142,19 @@
android:textColor="@color/log_text"
android:textSize="13sp" />
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etHfToken"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_marginTop="8dp"
android:hint="@string/hf_token_hint"
android:inputType="textPassword"
android:textColor="@color/on_surface"
android:textColorHint="@color/log_text"
android:textSize="13sp"
android:fontFamily="monospace"
android:backgroundTint="@color/primary" />
<ProgressBar
android:id="@+id/progressDownload"
style="@android:style/Widget.ProgressBar.Horizontal"
@@ -147,57 +170,192 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/btn_download_model"
android:text="@string/btn_download_gemma3_q4"
android:textSize="13sp"
app:cornerRadius="8dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnDownloadGemma3Q8"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/btn_download_gemma3_q8"
android:textSize="13sp"
app:cornerRadius="8dp" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Port Config -->
<LinearLayout
<!-- Settings Card -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/layoutPort"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginTop="16dp"
android:layout_marginTop="12dp"
app:cardBackgroundColor="@color/surface_variant"
app:cardCornerRadius="16dp"
app:cardElevation="0dp"
app:strokeWidth="0dp"
app:layout_constraintTop_toBottomOf="@id/cardModel"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<TextView
android:layout_width="wrap_content"
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/port_label"
android:textColor="@color/on_surface"
android:textSize="16sp" />
android:orientation="vertical"
android:padding="16dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etPort"
android:layout_width="100dp"
android:layout_height="48dp"
android:layout_marginStart="12dp"
android:text="@string/port_default"
android:inputType="number"
android:textColor="@color/on_surface"
android:backgroundTint="@color/primary"
android:fontFamily="monospace"
android:textSize="16sp"
android:gravity="center" />
</LinearLayout>
<!-- Port row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<!-- Start/Stop Button -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btnToggle"
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/port_label"
android:textColor="@color/on_surface"
android:textSize="14sp" />
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etPort"
android:layout_width="80dp"
android:layout_height="40dp"
android:text="@string/port_default"
android:inputType="number"
android:textColor="@color/on_surface"
android:backgroundTint="@color/primary"
android:fontFamily="monospace"
android:textSize="14sp"
android:gravity="center" />
</LinearLayout>
<!-- Temperature row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginTop="12dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/setting_temperature"
android:textColor="@color/on_surface"
android:textSize="14sp" />
<TextView
android:id="@+id/tvTemperatureValue"
android:layout_width="36dp"
android:layout_height="wrap_content"
android:text="0.7"
android:textColor="@color/log_text"
android:textSize="13sp"
android:fontFamily="monospace"
android:gravity="end" />
<SeekBar
android:id="@+id/seekTemperature"
android:layout_width="120dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:max="100"
android:progress="70" />
</LinearLayout>
<!-- Max tokens row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginTop="8dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/setting_max_tokens"
android:textColor="@color/on_surface"
android:textSize="14sp" />
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etMaxTokens"
android:layout_width="80dp"
android:layout_height="40dp"
android:text="1024"
android:inputType="number"
android:textColor="@color/on_surface"
android:backgroundTint="@color/primary"
android:fontFamily="monospace"
android:textSize="14sp"
android:gravity="center" />
</LinearLayout>
<!-- System prompt toggle -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginTop="8dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/setting_auto_system_prompt"
android:textColor="@color/on_surface"
android:textSize="14sp" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/switchSystemPrompt"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:checked="true" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Start/Stop + Chat Buttons -->
<LinearLayout
android:id="@+id/layoutButtons"
android:layout_width="0dp"
android:layout_height="56dp"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="16dp"
android:text="@string/btn_start"
android:textSize="16sp"
app:cornerRadius="12dp"
app:layout_constraintTop_toBottomOf="@id/layoutPort"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
app:layout_constraintEnd_toEndOf="parent">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnToggle"
android:layout_width="0dp"
android:layout_height="56dp"
android:layout_weight="1"
android:text="@string/btn_start"
android:textSize="16sp"
app:cornerRadius="12dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnChat"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="56dp"
android:layout_marginStart="8dp"
android:text="Chat"
android:textSize="16sp"
android:enabled="false"
app:cornerRadius="12dp" />
</LinearLayout>
<!-- Log Output -->
<TextView
@@ -209,7 +367,7 @@
android:textSize="14sp"
android:textStyle="bold"
android:layout_marginTop="20dp"
app:layout_constraintTop_toBottomOf="@id/btnToggle"
app:layout_constraintTop_toBottomOf="@id/layoutButtons"
app:layout_constraintStart_toStartOf="parent" />
<ScrollView
@@ -220,6 +378,7 @@
android:background="@color/log_bg"
android:padding="12dp"
app:layout_constraintTop_toBottomOf="@id/tvLogLabel"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="start"
android:paddingTop="4dp"
android:paddingBottom="4dp">
<TextView
android:id="@+id/tvContent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxWidth="280dp"
android:background="@drawable/bubble_ai"
android:padding="10dp"
android:textColor="@color/on_surface"
android:textSize="14sp"
android:lineSpacingMultiplier="1.2"
android:fontFamily="monospace" />
</LinearLayout>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="end"
android:paddingTop="4dp"
android:paddingBottom="4dp">
<TextView
android:id="@+id/tvContent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxWidth="280dp"
android:background="@drawable/bubble_user"
android:padding="10dp"
android:textColor="#FFFFFF"
android:textSize="14sp"
android:lineSpacingMultiplier="1.2" />
</LinearLayout>
+9 -4
View File
@@ -18,12 +18,17 @@
<string name="model_not_loaded">Model: not loaded</string>
<!-- Controls -->
<string name="btn_download_model">Download Model (~1.3 GB)</string>
<string name="model_downloaded"> Gemma 2B ready — background inference enabled</string>
<string name="model_not_downloaded">No local model. Download to enable background inference.</string>
<string name="hf_token_hint">HuggingFace token (huggingface.co/settings/tokens)</string>
<string name="btn_download_gemma3_q4"> Gemma 3n E4B — Best quality (~4.9 GB)</string>
<string name="btn_download_gemma3_q8">Gemma 3n E4B Web — Smaller (~4.3 GB)</string>
<string name="model_downloaded">✓ %s ready — background inference enabled</string>
<string name="model_not_downloaded">No local model. Enter HuggingFace token and download.</string>
<string name="btn_start">Start Server</string>
<string name="btn_stop">Stop Server</string>
<string name="port_label">Port:</string>
<string name="port_label">Port</string>
<string name="setting_temperature">Temperature</string>
<string name="setting_max_tokens">Max tokens</string>
<string name="setting_auto_system_prompt">Agent system prompt</string>
<string name="port_default">8080</string>
<string name="requests_served">Requests served: %d</string>
<string name="request_log_label">Request Log</string>