462 lines
No EOL
18 KiB
Kotlin
462 lines
No EOL
18 KiB
Kotlin
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"
|
||
}
|
||
}
|
||
} |