From 9ba935940032ccd0cf78a97f929c6416453d6d2d Mon Sep 17 00:00:00 2001 From: alexpolo1 Date: Sat, 28 Feb 2026 23:21:49 +0100 Subject: [PATCH] Add in-app chat with streaming, settings UI, and request indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/src/main/AndroidManifest.xml | 5 + .../com/pixel10/ai/server/ApiServerService.kt | 1 + .../java/com/pixel10/ai/ui/ChatActivity.kt | 158 ++++++++++++++++++ .../java/com/pixel10/ai/ui/ChatMessage.kt | 3 + .../java/com/pixel10/ai/ui/MainActivity.kt | 6 + .../java/com/pixel10/ai/ui/MessageAdapter.kt | 43 +++++ app/src/main/res/drawable/bubble_ai.xml | 10 ++ app/src/main/res/drawable/bubble_user.xml | 10 ++ app/src/main/res/drawable/ic_back.xml | 11 ++ app/src/main/res/layout/activity_chat.xml | 76 +++++++++ app/src/main/res/layout/activity_main.xml | 39 ++++- app/src/main/res/layout/item_message_ai.xml | 23 +++ app/src/main/res/layout/item_message_user.xml | 22 +++ 13 files changed, 398 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/com/pixel10/ai/ui/ChatActivity.kt create mode 100644 app/src/main/java/com/pixel10/ai/ui/ChatMessage.kt create mode 100644 app/src/main/java/com/pixel10/ai/ui/MessageAdapter.kt create mode 100644 app/src/main/res/drawable/bubble_ai.xml create mode 100644 app/src/main/res/drawable/bubble_user.xml create mode 100644 app/src/main/res/drawable/ic_back.xml create mode 100644 app/src/main/res/layout/activity_chat.xml create mode 100644 app/src/main/res/layout/item_message_ai.xml create mode 100644 app/src/main/res/layout/item_message_user.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 17fadf4..b661762 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -27,6 +27,11 @@ android:networkSecurityConfig="@xml/network_security_config" android:theme="@style/Theme.Pixel10AI"> + + () + 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) + } + } +} diff --git a/app/src/main/java/com/pixel10/ai/ui/ChatMessage.kt b/app/src/main/java/com/pixel10/ai/ui/ChatMessage.kt new file mode 100644 index 0000000..af79b8e --- /dev/null +++ b/app/src/main/java/com/pixel10/ai/ui/ChatMessage.kt @@ -0,0 +1,3 @@ +package com.pixel10.ai.ui + +data class ChatMessage(val role: String, var content: String) diff --git a/app/src/main/java/com/pixel10/ai/ui/MainActivity.kt b/app/src/main/java/com/pixel10/ai/ui/MainActivity.kt index db50e2d..d42284a 100644 --- a/app/src/main/java/com/pixel10/ai/ui/MainActivity.kt +++ b/app/src/main/java/com/pixel10/ai/ui/MainActivity.kt @@ -104,6 +104,10 @@ class MainActivity : AppCompatActivity() { 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) @@ -272,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) @@ -288,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) diff --git a/app/src/main/java/com/pixel10/ai/ui/MessageAdapter.kt b/app/src/main/java/com/pixel10/ai/ui/MessageAdapter.kt new file mode 100644 index 0000000..0fc8073 --- /dev/null +++ b/app/src/main/java/com/pixel10/ai/ui/MessageAdapter.kt @@ -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) : + RecyclerView.Adapter() { + + 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) +} diff --git a/app/src/main/res/drawable/bubble_ai.xml b/app/src/main/res/drawable/bubble_ai.xml new file mode 100644 index 0000000..e8fa768 --- /dev/null +++ b/app/src/main/res/drawable/bubble_ai.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/app/src/main/res/drawable/bubble_user.xml b/app/src/main/res/drawable/bubble_user.xml new file mode 100644 index 0000000..35b96b5 --- /dev/null +++ b/app/src/main/res/drawable/bubble_user.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_back.xml b/app/src/main/res/drawable/ic_back.xml new file mode 100644 index 0000000..0237090 --- /dev/null +++ b/app/src/main/res/drawable/ic_back.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/layout/activity_chat.xml b/app/src/main/res/layout/activity_chat.xml new file mode 100644 index 0000000..ce1c496 --- /dev/null +++ b/app/src/main/res/layout/activity_chat.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index a1d3ed2..2f15536 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -324,18 +324,38 @@ - - + + app:layout_constraintEnd_toEndOf="parent"> + + + + + + diff --git a/app/src/main/res/layout/item_message_ai.xml b/app/src/main/res/layout/item_message_ai.xml new file mode 100644 index 0000000..78e87fb --- /dev/null +++ b/app/src/main/res/layout/item_message_ai.xml @@ -0,0 +1,23 @@ + + + + + + diff --git a/app/src/main/res/layout/item_message_user.xml b/app/src/main/res/layout/item_message_user.xml new file mode 100644 index 0000000..ef72251 --- /dev/null +++ b/app/src/main/res/layout/item_message_user.xml @@ -0,0 +1,22 @@ + + + + + +