Add user profile, cancel button, dark theme and UI improvements

This commit is contained in:
Алексей Будаев 2026-04-04 18:28:24 +08:00
parent cda6eb7ce0
commit a4d24df8d8
21 changed files with 635 additions and 207 deletions

View file

@ -6,13 +6,14 @@ import com.google.gson.JsonArray
import com.mistral.chat.data.Message
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.Call
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.concurrent.TimeUnit
class MistralClient(private val apiKey: String) {
class MistralClient(private val apiKey: String) {
private val client = OkHttpClient.Builder()
.connectTimeout(60, TimeUnit.SECONDS)
@ -22,6 +23,8 @@ class MistralClient(private val apiKey: String) {
private val gson = Gson()
private val jsonMediaType = "application/json".toMediaType()
private var currentCall: Call? = null
companion object {
private const val BASE_URL = "https://api.mistral.ai/v1"
@ -30,8 +33,7 @@ class MistralClient(private val apiKey: String) {
"mistral-small-latest" to "Mistral Small",
"mistral-medium-latest" to "Mistral Medium",
"mistral-large-latest" to "Mistral Large",
"codestral-latest" to "Codestral",
"pixtral-large-latest" to "Pixtral Large"
"codestral-latest" to "Codestral"
)
}
@ -73,6 +75,11 @@ class MistralClient(private val apiKey: String) {
Result.failure(e)
}
}
fun cancelRequest() {
currentCall?.cancel()
currentCall = null
}
suspend fun chat(
model: String,
@ -104,7 +111,12 @@ class MistralClient(private val apiKey: String) {
.post(body)
.build()
val response = client.newCall(request).execute()
currentCall = client.newCall(request)
val response = currentCall!!.execute()
if (response.code == 0 || response.code == -1) {
return@withContext Result.failure(Exception("Request cancelled"))
}
if (!response.isSuccessful) {
val errorBody = response.body?.string() ?: "Unknown error"
@ -133,6 +145,7 @@ class MistralClient(private val apiKey: String) {
val usedModel = responseJson.get("model")?.asString ?: model
currentCall = null
Result.success(content to usedModel)
} catch (e: Exception) {
Result.failure(e)

View file

@ -9,6 +9,7 @@ data class UserProfile(
fun toContextString(): String {
return buildString {
append("[User Profile]\n")
if (name.isNotBlank()) append("Name: $name\n")
if (bio.isNotBlank()) append("Bio: $bio\n")
if (preferences.isNotBlank()) append("Preferences: $preferences\n")

View file

@ -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"
}
}
}

View file

@ -8,9 +8,14 @@ import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import com.mistral.chat.R
import com.mistral.chat.data.Message
import io.noties.markwon.Markwon
import io.noties.markwon.ext.strikethrough.StrikethroughPlugin
import io.noties.markwon.ext.tables.TablePlugin
import io.noties.markwon.ext.tasklist.TaskListPlugin
class MessageAdapter(private val messages: List<Message>) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
@ -43,7 +48,7 @@ class MessageAdapter(private val messages: List<Message>) : RecyclerView.Adapter
val textView = itemView.findViewById<TextView>(R.id.messageText)
textView.text = message.content
textView.setBackgroundResource(R.drawable.bg_message_user)
textView.setTextColor(0xFFFFFFFF.toInt())
textView.setTextColor(ContextCompat.getColor(itemView.context, R.color.user_message_text))
itemView.setOnLongClickListener {
copyToClipboard(itemView.context, message.content)
@ -53,14 +58,20 @@ class MessageAdapter(private val messages: List<Message>) : RecyclerView.Adapter
}
class AssistantMessageHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val markwon: Markwon = Markwon.builder(itemView.context)
.usePlugin(StrikethroughPlugin.create())
.usePlugin(TablePlugin.create(itemView.context))
.usePlugin(TaskListPlugin.create(itemView.context))
.build()
fun bind(message: Message) {
val senderNameView = itemView.findViewById<TextView>(R.id.senderName)
val textView = itemView.findViewById<TextView>(R.id.messageText)
senderNameView.text = message.senderName ?: "Assistant"
textView.text = message.content
markwon.setMarkdown(textView, message.content)
textView.setBackgroundResource(R.drawable.bg_message_assistant)
textView.setTextColor(0xFF000000.toInt())
textView.setTextColor(ContextCompat.getColor(itemView.context, R.color.assistant_message_text))
itemView.setOnLongClickListener {
copyToClipboard(itemView.context, message.content)