diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ec91c2c..1000ce7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,13 +2,18 @@ + + + android:theme="@style/Theme.MistralChat" + android:requestLegacyExternalStorage="true"> () private var availableModels: List> = 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>() {}.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 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" + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/mistral/chat/ui/MessageAdapter.kt b/app/src/main/java/com/mistral/chat/ui/MessageAdapter.kt index ab9abcb..85d8557 100644 --- a/app/src/main/java/com/mistral/chat/ui/MessageAdapter.kt +++ b/app/src/main/java/com/mistral/chat/ui/MessageAdapter.kt @@ -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) : RecyclerView.Adapter() { @@ -43,7 +48,7 @@ class MessageAdapter(private val messages: List) : RecyclerView.Adapter val textView = itemView.findViewById(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) : 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(R.id.senderName) val textView = itemView.findViewById(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) diff --git a/app/src/main/res/drawable-night/bg_message_assistant.xml b/app/src/main/res/drawable-night/bg_message_assistant.xml new file mode 100644 index 0000000..65c714f --- /dev/null +++ b/app/src/main/res/drawable-night/bg_message_assistant.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-night/bg_message_user.xml b/app/src/main/res/drawable-night/bg_message_user.xml new file mode 100644 index 0000000..0c27b2e --- /dev/null +++ b/app/src/main/res/drawable-night/bg_message_user.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_message_assistant.xml b/app/src/main/res/drawable/bg_message_assistant.xml index 2727afd..c2b9c67 100644 --- a/app/src/main/res/drawable/bg_message_assistant.xml +++ b/app/src/main/res/drawable/bg_message_assistant.xml @@ -1,7 +1,7 @@ - + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_message_user.xml b/app/src/main/res/drawable/bg_message_user.xml index 7bce096..0c27b2e 100644 --- a/app/src/main/res/drawable/bg_message_user.xml +++ b/app/src/main/res/drawable/bg_message_user.xml @@ -1,7 +1,7 @@ - + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_send_button.xml b/app/src/main/res/drawable/bg_send_button.xml new file mode 100644 index 0000000..f2b1576 --- /dev/null +++ b/app/src/main/res/drawable/bg_send_button.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index 47c5f02..9b8a79c 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -1,17 +1,147 @@ - - + + android:pathData="M0 0L0 600L600 600L600 0L0 0z" + android:fillColor="#FFFFFF" /> + android:pathData="M30 30C34.7199 31.9806 40.9198 31 46 31L77 31L187 31L570 31C565.28 29.0194 559.08 30 554 30L523 30L413 30L30 30z" + android:fillColor="#FFD907" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_menu_dots.xml b/app/src/main/res/drawable/ic_menu_dots.xml new file mode 100644 index 0000000..bcd5479 --- /dev/null +++ b/app/src/main/res/drawable/ic_menu_dots.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_mistral_logo.xml b/app/src/main/res/drawable/ic_mistral_logo.xml new file mode 100644 index 0000000..fa60ae9 --- /dev/null +++ b/app/src/main/res/drawable/ic_mistral_logo.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_stop.xml b/app/src/main/res/drawable/ic_stop.xml new file mode 100644 index 0000000..bfe68f7 --- /dev/null +++ b/app/src/main/res/drawable/ic_stop.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index eba315f..c9fc317 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,5 +1,5 @@ - + android:layout_alignParentTop="true"> - + android:orientation="horizontal" + android:gravity="center_vertical" + android:paddingStart="16dp" + android:paddingEnd="16dp"> + + + + + + + + - + + - - - - - - - + android:indeterminate="true" + android:visibility="gone" + android:layout_marginBottom="8dp" /> + app:cardElevation="4dp"> + android:paddingStart="8dp" + android:paddingEnd="4dp"> - + android:layout_width="40dp" + android:layout_height="40dp" + android:padding="8dp" + android:src="@drawable/ic_mistral_logo" + android:background="@drawable/bg_send_button" + android:contentDescription="@string/send" + android:clickable="true" + android:focusable="true" /> - + - - - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/item_message_assistant.xml b/app/src/main/res/layout/item_message_assistant.xml index 242b792..c10501a 100644 --- a/app/src/main/res/layout/item_message_assistant.xml +++ b/app/src/main/res/layout/item_message_assistant.xml @@ -6,14 +6,25 @@ android:layout_height="wrap_content" android:padding="8dp"> + + + android:title="@string/profile" /> + android:title="@string/clear_chat" /> + android:title="@string/about" /> \ No newline at end of file diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml new file mode 100644 index 0000000..747beea --- /dev/null +++ b/app/src/main/res/values-night/colors.xml @@ -0,0 +1,5 @@ + + + #FFFFFF + #E6E1E5 + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 527cfee..8c3382c 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,4 +1,6 @@ #6B4EFF + #FFFFFF + #000000 \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 81aa2f6..6486dbd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,27 +1,28 @@ - Mistral Chat - Select Model - Enter message - Send - Profile - Clear Chat - About - User Profile - Name - Bio - Preferences - Save - Cancel - Delete - Clear all messages? - Yes - No - Chat cleared - Profile saved - Profile deleted - Mistral Chat\nPowered by Mistral AI - Your name - Tell AI about yourself... - No profile set + Mistral Le Chat + Выбрать модель + Введите сообщение + Отправить + Профиль + Очистить чат + О приложении + Профиль пользователя + Имя + О себе + Предпочтения + Сохранить + Отмена + Удалить + Очистить все сообщения? + Да + Нет + Чат очищен + Профиль сохранён + Профиль удалён + Mistral Le Chat\nPowered by Mistral AI + Ваше имя + Расскажите о себе... + Профиль не установлен + Настройки \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index d40423d..5e89a57 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -16,6 +16,19 @@ #B3261E #FFFFFF @android:color/transparent + @android:color/transparent true + true + false + @style/AlertDialogTheme + + + + + \ No newline at end of file