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() private var availableModels: List> = 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>() {}.type val loaded: List = 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(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(R.id.nameInput) val bioInput = dialogView.findViewById(R.id.bioInput) val preferencesInput = dialogView.findViewById(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" } } }