Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
baacaabf24 | ||
|
|
f9bfd76e00 |
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ object ModelDownloader {
|
||||
private const val TAG = "ModelDownloader"
|
||||
private const val HF_BASE = "https://huggingface.co"
|
||||
|
||||
/** Available model specs downloadable from HuggingFace. */
|
||||
/** Available model specs downloadable from HuggingFace (requires token + license acceptance). */
|
||||
enum class ModelSpec(
|
||||
val displayName: String,
|
||||
val filename: String,
|
||||
@@ -31,21 +31,27 @@ object ModelDownloader {
|
||||
val sizeMb: Int,
|
||||
val description: String
|
||||
) {
|
||||
/** Recommended: best size/quality trade-off, runs fast on Tensor G5. */
|
||||
GEMMA_3_1B_Q4(
|
||||
displayName = "Gemma 3 1B IT (Q4)",
|
||||
filename = "gemma3-1b-it-int4.task",
|
||||
repo = "litert-community/Gemma3-1B-IT",
|
||||
sizeMb = 555,
|
||||
description = "Best balance — fast & capable (~555 MB)"
|
||||
/**
|
||||
* 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)"
|
||||
),
|
||||
/** Higher quality, slower. Good for complex reasoning. */
|
||||
GEMMA_3_1B_Q8(
|
||||
displayName = "Gemma 3 1B IT (Q8)",
|
||||
filename = "gemma3-1b-it-int8-web.task",
|
||||
repo = "litert-community/Gemma3-1B-IT",
|
||||
sizeMb = 1010,
|
||||
description = "Higher quality, slower (~1 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)"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -66,10 +72,10 @@ object ModelDownloader {
|
||||
fun modelFile(context: Context, spec: ModelSpec): File =
|
||||
File(context.filesDir, spec.filename)
|
||||
|
||||
/** Legacy compat — returns the file of the installed model, or Q4 path as default. */
|
||||
/** 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_3_1B_Q4)
|
||||
?: modelFile(context, ModelSpec.GEMMA_3N_E4B)
|
||||
|
||||
/**
|
||||
* Download [spec] from HuggingFace, using [hfToken] for authentication.
|
||||
@@ -79,7 +85,7 @@ object ModelDownloader {
|
||||
*/
|
||||
suspend fun download(
|
||||
context: Context,
|
||||
spec: ModelSpec = ModelSpec.GEMMA_3_1B_Q4,
|
||||
spec: ModelSpec = ModelSpec.GEMMA_3N_E4B,
|
||||
hfToken: String,
|
||||
onProgress: (Progress) -> Unit
|
||||
) = withContext(Dispatchers.IO) {
|
||||
|
||||
@@ -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,6 +34,7 @@ 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
|
||||
@@ -70,8 +71,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
|
||||
|
||||
|
||||
@@ -13,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
|
||||
@@ -53,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)
|
||||
@@ -73,8 +84,21 @@ class MainActivity : AppCompatActivity() {
|
||||
prefs = getSharedPreferences("pixel10_prefs", MODE_PRIVATE)
|
||||
requestNotificationPermission()
|
||||
|
||||
// Restore saved HF token
|
||||
// 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()
|
||||
@@ -82,11 +106,11 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
binding.btnDownloadModel.setOnClickListener {
|
||||
saveHfToken()
|
||||
startModelDownload(ModelSpec.GEMMA_3_1B_Q4)
|
||||
startModelDownload(ModelSpec.GEMMA_3N_E4B)
|
||||
}
|
||||
binding.btnDownloadGemma3Q8.setOnClickListener {
|
||||
saveHfToken()
|
||||
startModelDownload(ModelSpec.GEMMA_3_1B_Q8)
|
||||
startModelDownload(ModelSpec.GEMMA_3N_E4B_WEB)
|
||||
}
|
||||
|
||||
updateModelCard()
|
||||
@@ -201,7 +225,16 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -227,6 +260,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 -> {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -176,38 +186,143 @@
|
||||
</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">
|
||||
|
||||
<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 Button -->
|
||||
<com.google.android.material.button.MaterialButton
|
||||
|
||||
@@ -19,13 +19,16 @@
|
||||
|
||||
<!-- Controls -->
|
||||
<string name="hf_token_hint">HuggingFace token (huggingface.co/settings/tokens)</string>
|
||||
<string name="btn_download_gemma3_q4">⭐ Gemma 3 1B IT Q4 — Fast (~555 MB)</string>
|
||||
<string name="btn_download_gemma3_q8">Gemma 3 1B IT Q8 — Higher quality (~1 GB)</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>
|
||||
|
||||
Reference in New Issue
Block a user