Add in-app chat with streaming, settings UI, and request indicator
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>
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -38,6 +38,7 @@ class ApiServerService : Service() {
|
||||
|
||||
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
|
||||
|
||||
158
app/src/main/java/com/pixel10/ai/ui/ChatActivity.kt
Normal file
158
app/src/main/java/com/pixel10/ai/ui/ChatActivity.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
3
app/src/main/java/com/pixel10/ai/ui/ChatMessage.kt
Normal file
3
app/src/main/java/com/pixel10/ai/ui/ChatMessage.kt
Normal file
@@ -0,0 +1,3 @@
|
||||
package com.pixel10.ai.ui
|
||||
|
||||
data class ChatMessage(val role: String, var content: String)
|
||||
@@ -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)
|
||||
|
||||
43
app/src/main/java/com/pixel10/ai/ui/MessageAdapter.kt
Normal file
43
app/src/main/java/com/pixel10/ai/ui/MessageAdapter.kt
Normal 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)
|
||||
}
|
||||
10
app/src/main/res/drawable/bubble_ai.xml
Normal file
10
app/src/main/res/drawable/bubble_ai.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="@color/surface_variant" />
|
||||
<corners
|
||||
android:topLeftRadius="4dp"
|
||||
android:topRightRadius="16dp"
|
||||
android:bottomLeftRadius="16dp"
|
||||
android:bottomRightRadius="16dp" />
|
||||
</shape>
|
||||
10
app/src/main/res/drawable/bubble_user.xml
Normal file
10
app/src/main/res/drawable/bubble_user.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="@color/primary" />
|
||||
<corners
|
||||
android:topLeftRadius="16dp"
|
||||
android:topRightRadius="16dp"
|
||||
android:bottomLeftRadius="16dp"
|
||||
android:bottomRightRadius="4dp" />
|
||||
</shape>
|
||||
11
app/src/main/res/drawable/ic_back.xml
Normal file
11
app/src/main/res/drawable/ic_back.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="@color/on_surface">
|
||||
<path
|
||||
android:fillColor="@color/on_surface"
|
||||
android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z" />
|
||||
</vector>
|
||||
76
app/src/main/res/layout/activity_chat.xml
Normal file
76
app/src/main/res/layout/activity_chat.xml
Normal 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>
|
||||
@@ -324,18 +324,38 @@
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<!-- Start/Stop Button -->
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnToggle"
|
||||
<!-- 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
|
||||
@@ -347,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
|
||||
@@ -358,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">
|
||||
|
||||
23
app/src/main/res/layout/item_message_ai.xml
Normal file
23
app/src/main/res/layout/item_message_ai.xml
Normal 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>
|
||||
22
app/src/main/res/layout/item_message_user.xml
Normal file
22
app/src/main/res/layout/item_message_user.xml
Normal 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>
|
||||
Reference in New Issue
Block a user