7 Commits

Author SHA1 Message Date
alexpolo1
0c26c836d6 Bump versionCode to 7 / versionName to 1.7.0 for proper update installs
Some checks failed
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 <noreply@anthropic.com>
2026-02-28 23:23:40 +01:00
alexpolo1
592550e71c Add in-app chat with streaming, settings UI, and request indicator
Some checks failed
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 <noreply@anthropic.com>
2026-02-28 23:21:49 +01:00
alexpolo1
baacaabf24 Fix streaming, add thread safety, settings UI, and request indicator
Some checks failed
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 <noreply@anthropic.com>
2026-02-28 23:17:41 +01:00
alexpolo1
f9bfd76e00 Upgrade to LiteRT-LM runtime for Gemma 3n E4B support
Some checks failed
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 <noreply@anthropic.com>
2026-02-28 22:49:22 +01:00
alexpolo1
af84d8ebc4 Fix downloads: HuggingFace auth + Gemma 3 1B IT models
Some checks failed
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 <noreply@anthropic.com>
2026-02-28 22:36:05 +01:00
alexpolo1
0861ed38fc Add multi-model download: Gemma 3n E4B (recommended), E2B, Gemma 2B
Some checks failed
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 <noreply@anthropic.com>
2026-02-28 22:25:49 +01:00
alexpolo1
371bd55e18 Fix background inference: MediaPipe-first, in-app model downloader
Some checks failed
Release / Build and Release APK (push) Failing after 9m2s
Root cause: ML Kit Gemini Nano enforces ErrorCode 30 (foreground restriction)
at the AICore binder level. No foreground service or wake lock can bypass it —
the app's Activity must be the visible top window.

Fix: MediaPipe LLM Inference runs entirely in the app process via the Tensor G5
GPU (OpenCL/Vulkan), with no AICore dependency and no foreground restriction.

Changes:
- OnDeviceModel.create() now tries MediaPipe FIRST, Nano second
- ModelDownloader.kt: downloads Gemma 2B IT GPU INT4 (~1.3GB) from Google's
  MediaPipe model CDN with resume support (Range header)
- MainActivity: "Download Model" card shows download status and progress bar;
  auto-hides once model is present; uses lifecycleScope for coroutine
- Layout: model card inserted between status and port field
- Strings: btn_download_model, model_downloaded, model_not_downloaded

Once the model is downloaded the server accepts requests in the background
indefinitely with no foreground Activity required.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 22:19:14 +01:00
19 changed files with 1135 additions and 95 deletions

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")

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"

View File

@@ -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
}
}
}

View File

@@ -0,0 +1,147 @@
package com.pixel10.ai.inference
import android.content.Context
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection
import java.net.URL
/**
* Downloads a MediaPipe-compatible model for background-safe inference.
*
* All models are hosted on HuggingFace and require a free API token.
* Get one at: https://huggingface.co/settings/tokens
*
* 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"
/** 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,
val totalBytes: Long,
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 =
ModelSpec.values().any { modelFile(context, it).let { f -> f.exists() && f.length() > 1_000_000L } }
/** 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 [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) {
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 ${spec.displayName} from $downloadUrl (already have $alreadyDownloaded bytes)")
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")
}
val serverBytes = conn.contentLengthLong.coerceAtLeast(0L)
val totalBytes = if (resuming) alreadyDownloaded + serverBytes else serverBytes
conn.inputStream.use { input ->
FileOutputStream(dest, /* append= */ resuming).use { out ->
val buf = ByteArray(128 * 1024)
var written = alreadyDownloaded
var read: Int
while (input.read(buf).also { read = it } != -1) {
out.write(buf, 0, read)
written += read
onProgress(Progress(written, totalBytes))
}
}
}
Log.i(TAG, "Download complete — ${dest.length()} bytes")
} finally {
conn.disconnect()
}
}
fun deleteModel(context: Context) {
ModelSpec.values().forEach { modelFile(context, it).delete() }
Log.i(TAG, "All models deleted")
}
}

View File

@@ -102,38 +102,52 @@ interface OnDeviceModel {
/**
* Create the best available on-device model.
* Tries Gemini Nano (Tensor G5 TPU) first, falls back to MediaPipe.
*
* Priority order:
* 1. MediaPipe (local model file) — runs in background, uses Tensor G5 GPU.
* This is the preferred backend: no foreground restriction, no AICore dep.
* 2. Gemini Nano (ML Kit) — foreground only (ErrorCode 30 in background).
* Used as fallback when no MediaPipe model file is present.
*
* Tap "Download Model" in the app UI to get the MediaPipe model automatically.
*/
suspend fun create(context: Context): OnDeviceModel = withContext(Dispatchers.IO) {
// Try Gemini Nano via ML Kit Prompt API
// LiteRT-LM first — Gemma 3n .litertlm format, GPU-accelerated, background-safe
try {
Log.i(TAG, "Attempting Gemini Nano via ML Kit Prompt API...")
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
} catch (e: Exception) {
Log.w(TAG, "MediaPipe not available: ${e.message}")
}
// Gemini Nano last resort — foreground only
try {
Log.i(TAG, "Attempting Gemini Nano via ML Kit (foreground only)...")
val nano = GeminiNanoModel.create(context)
Log.i(TAG, "Gemini Nano ready!")
Log.i(TAG, "Gemini Nano ready (foreground only)")
return@withContext nano
} catch (e: Exception) {
Log.w(TAG, "Gemini Nano not available: ${e.message}")
}
// Fall back to MediaPipe with a local model file
try {
Log.i(TAG, "Attempting MediaPipe LLM with local model...")
val mediapipe = MediaPipeModel.create(context)
Log.i(TAG, "MediaPipe model ready!")
return@withContext mediapipe
} catch (e: Exception) {
Log.w(TAG, "MediaPipe model not available: ${e.message}")
}
throw InferenceException(
"No on-device AI model available.\n\n" +
"Option 1: Use a Pixel device with Gemini Nano support " +
"(Pixel 10/9/8 series)\n\n" +
"Option 2: Place a MediaPipe-compatible model (.bin or .task) in:\n" +
" ${context.filesDir.absolutePath}/\n" +
" Supported: gemma-3n-E2B.task, gemma-2b-it-gpu-int4.bin, etc.\n\n" +
"Download models from:\n" +
" https://ai.google.dev/edge/mediapipe/solutions/genai/llm_inference/android"
"No model loaded yet.\n\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 .litertlm file in:\n" +
" ${context.filesDir.absolutePath}/"
)
}
}

View File

@@ -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) {

View File

@@ -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

View File

@@ -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)
}
}
}

View File

@@ -0,0 +1,3 @@
package com.pixel10.ai.ui
data class ChatMessage(val role: String, var content: String)

View File

@@ -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,12 +13,17 @@ 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
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
import java.util.Date
import java.util.Locale
@@ -25,8 +31,10 @@ 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
private val logBuffer = StringBuilder()
@@ -46,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)
@@ -63,23 +81,56 @@ class MainActivity : AppCompatActivity() {
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
prefs = getSharedPreferences("pixel10_prefs", MODE_PRIVATE)
requestNotificationPermission()
binding.btnToggle.setOnClickListener {
if (service?.isRunning == true) {
stopServer()
} else {
startServer()
// 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 {
saveHfToken()
startModelDownload(ModelSpec.GEMMA_3N_E4B)
}
binding.btnDownloadGemma3Q8.setOnClickListener {
saveHfToken()
startModelDownload(ModelSpec.GEMMA_3N_E4B_WEB)
}
updateModelCard()
updateStatus(ApiServerService.ServerState.STOPPED)
appendLog("Pixel10 AI Server ready")
appendLog("Device: ${Build.MANUFACTURER} ${Build.MODEL}")
appendLog("SoC: ${Build.SOC_MODEL}")
appendLog("Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})")
appendLog("")
appendLog("Tap 'Start Server' to begin serving AI inference")
if (ModelDownloader.isModelPresent(this)) {
appendLog("Model ready — server works in background")
} else {
appendLog("No local model found")
appendLog("Tap 'Download Model' to enable background inference")
appendLog("(Without it, Gemini Nano only works in foreground)")
}
}
override fun onStart() {
@@ -110,16 +161,90 @@ class MainActivity : AppCompatActivity() {
}
}
private fun startServer() {
val port = binding.etPort.text.toString().toIntOrNull() ?: 8080
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
setDownloadButtonsEnabled(false)
binding.progressDownload.visibility = View.VISIBLE
binding.tvModelDownloadStatus.text = "Starting download: ${spec.displayName}"
lifecycleScope.launch {
try {
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 =
"${spec.displayName}: ${mb}MB / ${total}MB (${progress.percent}%)"
}
}
runOnUiThread {
downloading = false
updateModelCard()
appendLog("${spec.displayName} downloaded — background inference enabled")
}
} catch (e: Exception) {
runOnUiThread {
downloading = false
setDownloadButtonsEnabled(true)
binding.progressDownload.visibility = View.GONE
binding.tvModelDownloadStatus.text = "Download failed: ${e.message}"
appendLog("Download error: ${e.message}")
}
}
}
}
private fun setDownloadButtonsEnabled(enabled: Boolean) {
binding.btnDownloadModel.isEnabled = enabled
binding.btnDownloadGemma3Q8.isEnabled = enabled
}
private fun updateModelCard() {
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.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
putExtra(ApiServerService.EXTRA_PORT, port)
}
startForegroundService(intent)
// Bind if not already bound
if (!bound) {
bindService(
Intent(this, ApiServerService::class.java),
@@ -127,7 +252,6 @@ class MainActivity : AppCompatActivity() {
Context.BIND_AUTO_CREATE
)
}
updateStatus(ApiServerService.ServerState.LOADING_MODEL)
}
@@ -137,6 +261,13 @@ class MainActivity : AppCompatActivity() {
}
private fun updateStatus(state: ApiServerService.ServerState) {
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 -> {
binding.tvServerStatus.text = getString(R.string.server_status_stopped)
@@ -145,14 +276,13 @@ class MainActivity : AppCompatActivity() {
binding.tvModelStatus.text = "Model: not loaded"
binding.btnToggle.text = getString(R.string.btn_start)
binding.btnToggle.isEnabled = true
binding.etPort.isEnabled = true
binding.btnChat.isEnabled = false
}
ApiServerService.ServerState.LOADING_MODEL -> {
binding.tvServerStatus.text = getString(R.string.server_status_starting)
(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
}
ApiServerService.ServerState.RUNNING -> {
val port = binding.etPort.text.toString()
@@ -163,7 +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.etPort.isEnabled = false
binding.btnChat.isEnabled = true
}
ApiServerService.ServerState.ERROR -> {
binding.tvServerStatus.text = getString(R.string.server_status_error)
@@ -171,7 +301,6 @@ class MainActivity : AppCompatActivity() {
binding.tvModelStatus.text = getString(R.string.model_error)
binding.btnToggle.text = getString(R.string.btn_start)
binding.btnToggle.isEnabled = true
binding.etPort.isEnabled = true
}
}
}
@@ -180,16 +309,8 @@ class MainActivity : AppCompatActivity() {
val timestamp = SimpleDateFormat("HH:mm:ss", Locale.US).format(Date())
logBuffer.append("[$timestamp] $message\n")
binding.tvLog.text = logBuffer.toString()
// Auto-scroll to bottom
binding.scrollLog.post {
binding.scrollLog.fullScroll(View.FOCUS_DOWN)
}
// Update request count
service?.let {
binding.tvRequestCount.text = "Requests served: ${it.requestCount}"
}
binding.scrollLog.post { binding.scrollLog.fullScroll(View.FOCUS_DOWN) }
service?.let { binding.tvRequestCount.text = "Requests served: ${it.requestCount}" }
}
@Suppress("DEPRECATION")
@@ -202,7 +323,6 @@ class MainActivity : AppCompatActivity() {
}
} catch (_: Exception) {}
// Fallback: iterate network interfaces
try {
val interfaces = java.net.NetworkInterface.getNetworkInterfaces()
while (interfaces.hasMoreElements()) {

View File

@@ -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)
}

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>

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>

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>

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>

View File

@@ -102,54 +102,260 @@
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>
<!-- Port Config -->
<LinearLayout
android:id="@+id/layoutPort"
<!-- Model Download Card -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardModel"
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/cardStatus"
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>
<TextView
android:id="@+id/tvModelDownloadStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/log_text"
android:textSize="13sp" />
<!-- Start/Stop Button -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btnToggle"
<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"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:max="100"
android:visibility="gone" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnDownloadModel"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
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>
<!-- Settings Card -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/layoutPort"
android:layout_width="0dp"
android:layout_height="56dp"
android:layout_height="wrap_content"
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">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<!-- Port row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<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="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
@@ -161,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
@@ -172,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">

View File

@@ -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>

View File

@@ -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>

View File

@@ -18,9 +18,17 @@
<string name="model_not_loaded">Model: not loaded</string>
<!-- Controls -->
<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>