mistral-chat-app/app/src/main/java/com/mistral/chat/ui/MainActivity.kt
2026-04-05 14:56:29 +08:00

462 lines
No EOL
18 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package com.mistral.chat.ui
import android.content.Context
import android.content.SharedPreferences
import android.os.Build
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.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.appcompat.app.AppCompatDelegate
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.color.DynamicColors
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
import com.mistral.chat.data.UserProfile
import kotlinx.coroutines.launch
class MainActivity : AppCompatActivity() {
private lateinit var recyclerView: RecyclerView
private lateinit var adapter: MessageAdapter
private lateinit var inputField: TextInputEditText
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 {
private const val PREFS_NAME = "mistral_chat_prefs"
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"
private const val KEY_API_KEY = "api_key"
private const val DEFAULT_API_KEY = "YW0IjDBRLuyEBcgNjVeVUFlMI6fcZYLA"
}
override fun onCreate(savedInstanceState: Bundle?) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
DynamicColors.applyToActivityIfAvailable(this)
}
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
gson = Gson()
prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
client = MistralClient(getApiKey())
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)
progressIndicator = findViewById(R.id.progressIndicator)
setupToolbar()
setupRecyclerView()
loadModels()
setupInput()
inputField.postDelayed({
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
inputField.requestFocus()
imm.showSoftInput(inputField, InputMethodManager.SHOW_IMPLICIT)
}, 300)
}
private fun setupToolbar() {
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_api_key -> {
showApiKeyDialog()
true
}
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() {
adapter = MessageAdapter(messages)
recyclerView.layoutManager = LinearLayoutManager(this).apply {
stackFromEnd = true
}
recyclerView.adapter = adapter
}
private fun setupInput() {
sendButton.setOnClickListener {
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() {
lifecycleScope.launch {
val result = client?.getModels()
result?.onSuccess { models ->
availableModels = models
runOnUiThread {
val codestralIndex = models.indexOfFirst { it.first.contains("codestral", ignoreCase = true) }
if (codestralIndex >= 0) {
selectedModelName = models[codestralIndex].first
} else if (models.isNotEmpty()) {
selectedModelName = models[0].first
}
}
}?.onFailure {
availableModels = MistralClient.AVAILABLE_MODELS
runOnUiThread {
selectedModelName = MistralClient.AVAILABLE_MODELS.firstOrNull()?.first ?: "mistral-small-latest"
}
}
}
}
private fun addMessage(message: Message) {
messages.add(message)
adapter.notifyItemInserted(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 getApiKey(): String {
return prefs.getString(KEY_API_KEY, DEFAULT_API_KEY) ?: DEFAULT_API_KEY
}
private fun saveApiKey(apiKey: String) {
prefs.edit().putString(KEY_API_KEY, apiKey).apply()
client = MistralClient(apiKey)
}
private fun deleteApiKey() {
prefs.edit().remove(KEY_API_KEY).apply()
client = MistralClient(DEFAULT_API_KEY)
}
private fun showApiKeyDialog() {
val currentKey = getApiKey()
val hasCustomKey = currentKey != DEFAULT_API_KEY && prefs.contains(KEY_API_KEY)
val displayKey = if (hasCustomKey && currentKey.length > 8) {
currentKey.take(4) + "*".repeat(currentKey.length - 8) + currentKey.takeLast(4)
} else if (hasCustomKey) {
"******"
} else {
getString(R.string.no_api_key)
}
val dialogView = layoutInflater.inflate(R.layout.dialog_api_key, null)
val inputField = dialogView.findViewById<TextInputEditText>(R.id.apiKeyInput)
if (hasCustomKey) {
inputField.setText(currentKey)
}
AlertDialog.Builder(this)
.setTitle(R.string.api_key_title)
.setMessage(getString(R.string.api_key_current, displayKey))
.setView(dialogView)
.setPositiveButton(R.string.save) { _, _ ->
val newKey = inputField.text?.toString()?.trim()
if (!newKey.isNullOrEmpty()) {
saveApiKey(newKey)
showToast(getString(R.string.api_key_saved))
}
}
.setNegativeButton(R.string.cancel, null)
.apply {
if (hasCustomKey) {
setNeutralButton(R.string.delete) { _, _ ->
deleteApiKey()
showToast(getString(R.string.api_key_deleted))
}
}
}
.show()
}
private fun showToast(message: String) {
android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_SHORT).show()
}
private fun sendMessage(userInput: String) {
val selectedModel = selectedModelName
sendButton.isEnabled = false
sendButton.setImageResource(R.drawable.ic_stop)
progressIndicator.isVisible = true
currentJob = lifecycleScope.launch {
try {
val userProfile = loadUserProfile()
val profileContext = if (!userProfile.isEmpty()) {
userProfile.toContextString()
} else ""
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
}
}
}
private fun cancelRequest() {
currentJob?.cancel()
client?.cancelRequest()
}
private fun loadUserProfile(): UserProfile {
return UserProfile(
name = prefs.getString(KEY_USER_NAME, "") ?: "",
bio = prefs.getString(KEY_USER_BIO, "") ?: "",
preferences = prefs.getString(KEY_USER_PREFS, "") ?: ""
)
}
private fun saveUserProfile(profile: UserProfile) {
prefs.edit()
.putString(KEY_USER_NAME, profile.name)
.putString(KEY_USER_BIO, profile.bio)
.putString(KEY_USER_PREFS, profile.preferences)
.remove(KEY_PROFILE_HASH)
.apply()
}
private fun deleteUserProfile() {
prefs.edit()
.remove(KEY_USER_NAME)
.remove(KEY_USER_BIO)
.remove(KEY_USER_PREFS)
.remove(KEY_PROFILE_HASH)
.apply()
}
private fun showProfileDialog() {
val profile = loadUserProfile()
val dialogView = layoutInflater.inflate(R.layout.dialog_profile, null)
val nameInput = dialogView.findViewById<TextInputEditText>(R.id.nameInput)
val bioInput = dialogView.findViewById<TextInputEditText>(R.id.bioInput)
val preferencesInput = dialogView.findViewById<TextInputEditText>(R.id.preferencesInput)
nameInput.setText(profile.name)
bioInput.setText(profile.bio)
preferencesInput.setText(profile.preferences)
AlertDialog.Builder(this)
.setView(dialogView)
.setPositiveButton(R.string.save) { _, _ ->
val newProfile = UserProfile(
name = nameInput.text?.toString() ?: "",
bio = bioInput.text?.toString() ?: "",
preferences = preferencesInput.text?.toString() ?: ""
)
saveUserProfile(newProfile)
}
.setNegativeButton(R.string.cancel, null)
.setNeutralButton(R.string.delete) { _, _ ->
deleteUserProfile()
}
.show()
}
private fun showClearChatDialog() {
AlertDialog.Builder(this)
.setMessage(R.string.clear_chat_confirm)
.setPositiveButton(R.string.yes) { _, _ ->
messages.clear()
adapter.notifyDataSetChanged()
prefs.edit().remove(KEY_MESSAGES).apply()
}
.setNegativeButton(R.string.no, null)
.show()
}
private fun showAboutDialog() {
AlertDialog.Builder(this)
.setMessage(R.string.about_text)
.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"
}
}
}