Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c26c836d6 | ||
|
|
592550e71c | ||
|
|
baacaabf24 | ||
|
|
f9bfd76e00 |
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,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)
|
||||
@@ -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,20 +84,37 @@ 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()
|
||||
}
|
||||
|
||||
binding.btnChat.setOnClickListener {
|
||||
startActivity(Intent(this, ChatActivity::class.java))
|
||||
}
|
||||
|
||||
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 +229,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 +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 -> {
|
||||
@@ -236,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)
|
||||
@@ -252,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)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,51 +186,176 @@
|
||||
</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
|
||||
@@ -232,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
|
||||
@@ -243,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>
|
||||
@@ -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