Add user profile, cancel button, dark theme and UI improvements
This commit is contained in:
parent
cda6eb7ce0
commit
a4d24df8d8
21 changed files with 635 additions and 207 deletions
|
|
@ -3,19 +3,24 @@ package com.mistral.chat.ui
|
|||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Bundle
|
||||
import android.view.KeyEvent
|
||||
import android.view.View
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.AutoCompleteTextView
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import android.widget.PopupMenu
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.appbar.MaterialToolbar
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.progressindicator.LinearProgressIndicator
|
||||
import com.google.android.material.textfield.TextInputEditText
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.mistral.chat.R
|
||||
import com.mistral.chat.api.MistralClient
|
||||
import com.mistral.chat.data.Message
|
||||
|
|
@ -27,14 +32,18 @@ class MainActivity : AppCompatActivity() {
|
|||
private lateinit var recyclerView: RecyclerView
|
||||
private lateinit var adapter: MessageAdapter
|
||||
private lateinit var inputField: TextInputEditText
|
||||
private lateinit var sendButton: MaterialButton
|
||||
private lateinit var modelSelector: AutoCompleteTextView
|
||||
private lateinit var toolbar: MaterialToolbar
|
||||
private lateinit var sendButton: ImageView
|
||||
private lateinit var progressIndicator: LinearProgressIndicator
|
||||
private lateinit var logoButton: ImageView
|
||||
private lateinit var menuButton: ImageButton
|
||||
private lateinit var toolbarTitle: TextView
|
||||
private lateinit var gson: Gson
|
||||
private var currentJob: kotlinx.coroutines.Job? = null
|
||||
|
||||
private var client: MistralClient? = null
|
||||
private val messages = mutableListOf<Message>()
|
||||
private var availableModels: List<Pair<String, String>> = emptyList()
|
||||
private var selectedModelName: String = "mistral-small-latest"
|
||||
private lateinit var prefs: SharedPreferences
|
||||
|
||||
companion object {
|
||||
|
|
@ -43,20 +52,26 @@ class MainActivity : AppCompatActivity() {
|
|||
private const val KEY_USER_NAME = "user_name"
|
||||
private const val KEY_USER_BIO = "user_bio"
|
||||
private const val KEY_USER_PREFS = "user_preferences"
|
||||
private const val KEY_MESSAGES = "chat_messages"
|
||||
private const val KEY_PROFILE_HASH = "profile_hash"
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_main)
|
||||
|
||||
gson = Gson()
|
||||
prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
client = MistralClient(API_KEY)
|
||||
|
||||
toolbar = findViewById(R.id.toolbar)
|
||||
loadMessages()
|
||||
|
||||
logoButton = findViewById(R.id.logoButton)
|
||||
menuButton = findViewById(R.id.menuButton)
|
||||
toolbarTitle = findViewById(R.id.toolbarTitle)
|
||||
recyclerView = findViewById(R.id.recyclerView)
|
||||
inputField = findViewById(R.id.inputField)
|
||||
sendButton = findViewById(R.id.sendButton)
|
||||
modelSelector = findViewById(R.id.modelSelector)
|
||||
progressIndicator = findViewById(R.id.progressIndicator)
|
||||
|
||||
setupToolbar()
|
||||
|
|
@ -64,31 +79,55 @@ class MainActivity : AppCompatActivity() {
|
|||
loadModels()
|
||||
setupInput()
|
||||
|
||||
inputField.requestFocus()
|
||||
inputField.postDelayed({
|
||||
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
inputField.requestFocus()
|
||||
imm.showSoftInput(inputField, InputMethodManager.SHOW_IMPLICIT)
|
||||
}, 100)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
private fun setupToolbar() {
|
||||
toolbar.setOnMenuItemClickListener { menuItem ->
|
||||
when (menuItem.itemId) {
|
||||
R.id.action_profile -> {
|
||||
showProfileDialog()
|
||||
true
|
||||
}
|
||||
R.id.action_clear -> {
|
||||
showClearChatDialog()
|
||||
true
|
||||
}
|
||||
R.id.action_about -> {
|
||||
showAboutDialog()
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
logoButton.setOnClickListener {
|
||||
showModelSelectorDialog()
|
||||
}
|
||||
|
||||
menuButton.setOnClickListener { view ->
|
||||
val popup = PopupMenu(this, view)
|
||||
popup.menuInflater.inflate(R.menu.main_menu, popup.menu)
|
||||
popup.setOnMenuItemClickListener { menuItem ->
|
||||
when (menuItem.itemId) {
|
||||
R.id.action_profile -> {
|
||||
showProfileDialog()
|
||||
true
|
||||
}
|
||||
R.id.action_clear -> {
|
||||
showClearChatDialog()
|
||||
true
|
||||
}
|
||||
R.id.action_about -> {
|
||||
showAboutDialog()
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
popup.show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showModelSelectorDialog() {
|
||||
val modelNames = availableModels.map { it.second }.toTypedArray()
|
||||
val currentModelId = availableModels.find { it.first == selectedModelName }?.second ?: modelNames.firstOrNull() ?: ""
|
||||
val currentIndex = modelNames.indexOf(currentModelId).coerceAtLeast(0)
|
||||
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.select_model)
|
||||
.setSingleChoiceItems(modelNames, currentIndex) { dialog, which ->
|
||||
selectedModelName = availableModels[which].first
|
||||
dialog.dismiss()
|
||||
}
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun setupRecyclerView() {
|
||||
|
|
@ -101,20 +140,31 @@ class MainActivity : AppCompatActivity() {
|
|||
|
||||
private fun setupInput() {
|
||||
sendButton.setOnClickListener {
|
||||
val userInput = inputField.text?.toString()?.trim()
|
||||
if (userInput.isNullOrEmpty()) return@setOnClickListener
|
||||
|
||||
addMessage(Message(content = userInput, isUser = true))
|
||||
inputField.text?.clear()
|
||||
|
||||
sendMessage(userInput)
|
||||
}
|
||||
|
||||
inputField.setOnFocusChangeListener { _, hasFocus ->
|
||||
if (hasFocus) {
|
||||
toolbar.title = "Mistral Chat"
|
||||
if (currentJob?.isActive == true) {
|
||||
cancelRequest()
|
||||
} else {
|
||||
sendInput()
|
||||
}
|
||||
}
|
||||
|
||||
inputField.setOnEditorActionListener { _, actionId, event ->
|
||||
if (actionId == EditorInfo.IME_ACTION_SEND || actionId == EditorInfo.IME_ACTION_GO || actionId == EditorInfo.IME_ACTION_DONE || (event?.keyCode == KeyEvent.KEYCODE_ENTER && event.action == KeyEvent.ACTION_DOWN)) {
|
||||
sendInput()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendInput() {
|
||||
val userInput = inputField.text?.toString()?.trim()
|
||||
if (userInput.isNullOrEmpty()) return
|
||||
|
||||
addMessage(Message(content = userInput, isUser = true))
|
||||
inputField.text?.clear()
|
||||
|
||||
sendMessage(userInput)
|
||||
}
|
||||
|
||||
private fun loadModels() {
|
||||
|
|
@ -123,30 +173,17 @@ class MainActivity : AppCompatActivity() {
|
|||
result?.onSuccess { models ->
|
||||
availableModels = models
|
||||
runOnUiThread {
|
||||
val modelNames = models.map { it.second }
|
||||
val adapter = ArrayAdapter(this@MainActivity, android.R.layout.simple_dropdown_item_1line, modelNames)
|
||||
modelSelector.setAdapter(adapter)
|
||||
|
||||
val codestralIndex = models.indexOfFirst { it.first.contains("codestral", ignoreCase = true) }
|
||||
if (codestralIndex >= 0) {
|
||||
modelSelector.setText(modelNames[codestralIndex], false)
|
||||
} else if (modelNames.isNotEmpty()) {
|
||||
modelSelector.setText(modelNames[0], false)
|
||||
selectedModelName = models[codestralIndex].first
|
||||
} else if (models.isNotEmpty()) {
|
||||
selectedModelName = models[0].first
|
||||
}
|
||||
}
|
||||
}?.onFailure {
|
||||
availableModels = MistralClient.AVAILABLE_MODELS
|
||||
runOnUiThread {
|
||||
val modelNames = availableModels.map { it.second }
|
||||
val adapter = ArrayAdapter(this@MainActivity, android.R.layout.simple_dropdown_item_1line, modelNames)
|
||||
modelSelector.setAdapter(adapter)
|
||||
|
||||
val codestralIndex = availableModels.indexOfFirst { it.first.contains("codestral", ignoreCase = true) }
|
||||
if (codestralIndex >= 0) {
|
||||
modelSelector.setText(modelNames[codestralIndex], false)
|
||||
} else {
|
||||
modelSelector.setText(modelNames[0], false)
|
||||
}
|
||||
selectedModelName = MistralClient.AVAILABLE_MODELS.firstOrNull()?.first ?: "mistral-small-latest"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -155,48 +192,82 @@ class MainActivity : AppCompatActivity() {
|
|||
private fun addMessage(message: Message) {
|
||||
messages.add(message)
|
||||
adapter.notifyItemInserted(messages.size - 1)
|
||||
recyclerView.scrollToPosition(messages.size - 1)
|
||||
saveMessages()
|
||||
|
||||
recyclerView.postDelayed({
|
||||
recyclerView.scrollToPosition(messages.size - 1)
|
||||
}, 100)
|
||||
}
|
||||
|
||||
private fun loadMessages() {
|
||||
val json = prefs.getString(KEY_MESSAGES, null)
|
||||
if (json != null) {
|
||||
try {
|
||||
val type = object : TypeToken<List<Message>>() {}.type
|
||||
val loaded: List<Message> = gson.fromJson(json, type)
|
||||
messages.clear()
|
||||
messages.addAll(loaded)
|
||||
} catch (e: Exception) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveMessages() {
|
||||
val json = gson.toJson(messages)
|
||||
prefs.edit().putString(KEY_MESSAGES, json).apply()
|
||||
}
|
||||
|
||||
private fun sendMessage(userInput: String) {
|
||||
val selectedModelName = modelSelector.text.toString()
|
||||
val selectedModel = selectedModelName
|
||||
|
||||
val matchedModel = availableModels.find { it.second == selectedModelName }
|
||||
val selectedModel = matchedModel?.first ?: "mistral-small-latest"
|
||||
|
||||
val userProfile = loadUserProfile()
|
||||
val profileContext = if (!userProfile.isEmpty()) {
|
||||
"\n[User Profile]\n${userProfile.toContextString()}\n"
|
||||
} else ""
|
||||
|
||||
val apiMessages = messages.map { msg ->
|
||||
Message(
|
||||
content = msg.content,
|
||||
isUser = msg.isUser
|
||||
)
|
||||
}.toMutableList()
|
||||
|
||||
if (profileContext.isNotEmpty() && apiMessages.isEmpty()) {
|
||||
apiMessages.add(0, Message(content = profileContext, isUser = true))
|
||||
}
|
||||
|
||||
sendButton.isEnabled = false
|
||||
sendButton.setImageResource(R.drawable.ic_stop)
|
||||
progressIndicator.isVisible = true
|
||||
|
||||
lifecycleScope.launch {
|
||||
val result = client?.chat(selectedModel, apiMessages)
|
||||
currentJob = lifecycleScope.launch {
|
||||
try {
|
||||
val userProfile = loadUserProfile()
|
||||
|
||||
val profileContext = if (!userProfile.isEmpty()) {
|
||||
userProfile.toContextString()
|
||||
} else ""
|
||||
|
||||
result?.onSuccess { (response, usedModel) ->
|
||||
addMessage(Message(content = response, isUser = false, senderName = usedModel))
|
||||
}?.onFailure { error ->
|
||||
addMessage(Message(content = "Error: ${error.message}", isUser = false, senderName = "Error"))
|
||||
val apiMessages = messages.map { msg ->
|
||||
Message(
|
||||
content = msg.content,
|
||||
isUser = msg.isUser
|
||||
)
|
||||
}.toMutableList()
|
||||
|
||||
if (profileContext.isNotEmpty()) {
|
||||
apiMessages.add(0, Message(content = profileContext, isUser = true))
|
||||
}
|
||||
|
||||
val result = client?.chat(selectedModel, apiMessages)
|
||||
|
||||
result?.onSuccess { (response, usedModel) ->
|
||||
addMessage(Message(content = response, isUser = false, senderName = usedModel))
|
||||
}?.onFailure { error ->
|
||||
val errorMessage = error.message ?: "Unknown error"
|
||||
val userFriendlyMessage = getUserFriendlyError(errorMessage)
|
||||
addMessage(Message(content = userFriendlyMessage, isUser = false, senderName = "Error"))
|
||||
}
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
addMessage(Message(content = "❌ Отменено пользователем", isUser = false, senderName = "Cancelled"))
|
||||
} finally {
|
||||
sendButton.isEnabled = true
|
||||
sendButton.setImageResource(R.drawable.ic_mistral_logo)
|
||||
progressIndicator.isVisible = false
|
||||
}
|
||||
|
||||
sendButton.isEnabled = true
|
||||
progressIndicator.isVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelRequest() {
|
||||
currentJob?.cancel()
|
||||
client?.cancelRequest()
|
||||
}
|
||||
|
||||
private fun loadUserProfile(): UserProfile {
|
||||
return UserProfile(
|
||||
name = prefs.getString(KEY_USER_NAME, "") ?: "",
|
||||
|
|
@ -210,6 +281,7 @@ class MainActivity : AppCompatActivity() {
|
|||
.putString(KEY_USER_NAME, profile.name)
|
||||
.putString(KEY_USER_BIO, profile.bio)
|
||||
.putString(KEY_USER_PREFS, profile.preferences)
|
||||
.remove(KEY_PROFILE_HASH)
|
||||
.apply()
|
||||
}
|
||||
|
||||
|
|
@ -218,6 +290,7 @@ class MainActivity : AppCompatActivity() {
|
|||
.remove(KEY_USER_NAME)
|
||||
.remove(KEY_USER_BIO)
|
||||
.remove(KEY_USER_PREFS)
|
||||
.remove(KEY_PROFILE_HASH)
|
||||
.apply()
|
||||
}
|
||||
|
||||
|
|
@ -242,12 +315,10 @@ class MainActivity : AppCompatActivity() {
|
|||
preferences = preferencesInput.text?.toString() ?: ""
|
||||
)
|
||||
saveUserProfile(newProfile)
|
||||
toolbar.subtitle = if (newProfile.name.isNotBlank()) newProfile.name else null
|
||||
}
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.setNeutralButton(R.string.delete) { _, _ ->
|
||||
deleteUserProfile()
|
||||
toolbar.subtitle = null
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
|
@ -258,6 +329,7 @@ class MainActivity : AppCompatActivity() {
|
|||
.setPositiveButton(R.string.yes) { _, _ ->
|
||||
messages.clear()
|
||||
adapter.notifyDataSetChanged()
|
||||
prefs.edit().remove(KEY_MESSAGES).apply()
|
||||
}
|
||||
.setNegativeButton(R.string.no, null)
|
||||
.show()
|
||||
|
|
@ -269,4 +341,51 @@ class MainActivity : AppCompatActivity() {
|
|||
.setPositiveButton(android.R.string.ok, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun getUserFriendlyError(error: String): String {
|
||||
return when {
|
||||
error.contains("timeout", ignoreCase = true) ||
|
||||
error.contains("TimedOut", ignoreCase = true) ||
|
||||
error.contains("SocketTimeoutException", ignoreCase = true) ||
|
||||
error.contains("connection abort", ignoreCase = true) ||
|
||||
error.contains("Software caused connection abort", ignoreCase = true) ->
|
||||
"⚠️ Время ожидания истекло. Проверьте интернет-соединение и попробуйте снова."
|
||||
|
||||
error.contains("network", ignoreCase = true) ||
|
||||
error.contains("Unable to resolve host", ignoreCase = true) ||
|
||||
error.contains("No route to host", ignoreCase = true) ->
|
||||
"🌐 Нет подключения к интернету. Проверьте соединение и попробуйте снова."
|
||||
|
||||
error.contains("401", ignoreCase = true) ||
|
||||
error.contains("unauthorized", ignoreCase = true) ->
|
||||
"🔑 Ошибка авторизации. Проверьте API ключ в настройках приложения."
|
||||
|
||||
error.contains("403", ignoreCase = true) ||
|
||||
error.contains("forbidden", ignoreCase = true) ->
|
||||
"🚫 Доступ запрещён. Возможно, API ключ истёк или недействителен."
|
||||
|
||||
error.contains("429", ignoreCase = true) ||
|
||||
error.contains("rate limit", ignoreCase = true) ||
|
||||
error.contains("too many requests", ignoreCase = true) ->
|
||||
"⏳ Слишком много запросов. Подождите немного и попробуйте снова."
|
||||
|
||||
error.contains("500", ignoreCase = true) ||
|
||||
error.contains("internal server error", ignoreCase = true) ->
|
||||
"🔧 Ошибка сервера Mistral. Попробуйте выбрать другую модель или повторите позже."
|
||||
|
||||
error.contains("503", ignoreCase = true) ||
|
||||
error.contains("service unavailable", ignoreCase = true) ->
|
||||
"🛠️ Сервис временно недоступен. Попробуйте позже."
|
||||
|
||||
error.contains("404", ignoreCase = true) ||
|
||||
error.contains("not found", ignoreCase = true) ->
|
||||
"❓ Ресурс не найден. Попробуйте выбрать другую модель."
|
||||
|
||||
error.contains("null", ignoreCase = true) ||
|
||||
error.contains("empty response", ignoreCase = true) ->
|
||||
"📭 Пустой ответ от сервера. Попробуйте ещё раз."
|
||||
|
||||
else -> "❌ Произошла ошибка: $error"
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue