Clean up unused code and fix profile selection
- Removed unused UserProfile.kt, ApiModels.kt - Fixed profile checkmark showing for all profiles (using lambda) - Increased text bubble width to 350dp - Auto-scroll to beginning of AI messages - Auto-select profile on creation - Model selection persists across restarts - Removed null-unsafe !! operators - Added Russian language support for UI strings
This commit is contained in:
parent
a5fe4bc29e
commit
21505aae75
10 changed files with 1070 additions and 332 deletions
|
|
@ -5,35 +5,60 @@ import com.google.gson.Gson
|
|||
import com.google.gson.JsonArray
|
||||
import com.mistral.chat.data.Message
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.Call
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.coroutines.Continuation
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
class MistralClient(private val apiKey: String) {
|
||||
class MistralClient(private val apiKey: String) {
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(60, TimeUnit.SECONDS)
|
||||
.readTimeout(120, TimeUnit.SECONDS)
|
||||
.writeTimeout(60, TimeUnit.SECONDS)
|
||||
private var client = createNewClient()
|
||||
|
||||
private fun createNewClient(): OkHttpClient {
|
||||
return OkHttpClient.Builder()
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(25, TimeUnit.SECONDS)
|
||||
.writeTimeout(15, TimeUnit.SECONDS)
|
||||
.retryOnConnectionFailure(false)
|
||||
.build()
|
||||
}
|
||||
|
||||
private val gson = Gson()
|
||||
private val jsonMediaType = "application/json".toMediaType()
|
||||
|
||||
private var currentCall: Call? = null
|
||||
private var currentContinuation: Continuation<Result<Pair<String, String>>>? = null
|
||||
|
||||
companion object {
|
||||
private const val BASE_URL = "https://api.mistral.ai/v1"
|
||||
|
||||
private val SUPPORTED_MODELS = setOf(
|
||||
"mistral-small-latest",
|
||||
"mistral-medium-latest",
|
||||
"mistral-large-latest",
|
||||
"mistral-small",
|
||||
"mistral-medium",
|
||||
"mistral-large",
|
||||
"codestral-latest",
|
||||
"codestral",
|
||||
"pixtral-large-latest",
|
||||
"pixtral-12b-2409"
|
||||
)
|
||||
|
||||
val AVAILABLE_MODELS = listOf(
|
||||
"mistral-small-latest" to "Mistral Small",
|
||||
"mistral-medium-latest" to "Mistral Medium",
|
||||
"mistral-large-latest" to "Mistral Large",
|
||||
"codestral-latest" to "Codestral"
|
||||
"codestral-latest" to "Codestral",
|
||||
"pixtral-large-latest" to "Pixtral Large"
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -45,8 +70,7 @@ import java.util.concurrent.TimeUnit
|
|||
.get()
|
||||
.build()
|
||||
|
||||
val response = client.newCall(request).execute()
|
||||
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext Result.failure(Exception("API error: ${response.code}"))
|
||||
}
|
||||
|
|
@ -57,28 +81,47 @@ import java.util.concurrent.TimeUnit
|
|||
val models = responseJson
|
||||
.getAsJsonArray("data")
|
||||
?.mapNotNull { obj ->
|
||||
try {
|
||||
val jsonObj = obj.asJsonObject
|
||||
val id = jsonObj.get("id")?.asString
|
||||
val created = jsonObj.get("created")?.asLong ?: 0L
|
||||
if (id != null && created > 0 && id.endsWith("-latest")) {
|
||||
val id = jsonObj.get("id")?.asString ?: return@mapNotNull null
|
||||
if (id in SUPPORTED_MODELS) {
|
||||
val displayName = id
|
||||
.replace("-latest", "")
|
||||
.replace("-12b-2409", "")
|
||||
.replace("-", " ")
|
||||
.split(" ")
|
||||
.joinToString(" ") { it.replaceFirstChar { c -> c.uppercase() } }
|
||||
id to displayName
|
||||
} else null
|
||||
} ?: emptyList()
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
?.distinctBy { it.first }
|
||||
?: emptyList()
|
||||
|
||||
Result.success(models)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is java.io.IOException && !e.message.orEmpty().contains("cancel", ignoreCase = true)) {
|
||||
Result.failure(e)
|
||||
} else {
|
||||
Result.failure(Exception("Request cancelled"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelRequest() {
|
||||
currentCall?.cancel()
|
||||
val call = currentCall
|
||||
val continuation = currentContinuation
|
||||
|
||||
call?.cancel()
|
||||
currentCall = null
|
||||
|
||||
continuation?.resume(Result.failure(Exception("Request cancelled")))
|
||||
currentContinuation = null
|
||||
|
||||
client = createNewClient()
|
||||
}
|
||||
|
||||
suspend fun chat(
|
||||
|
|
@ -90,7 +133,7 @@ import java.util.concurrent.TimeUnit
|
|||
val jsonObject = JsonObject()
|
||||
jsonObject.addProperty("model", model)
|
||||
jsonObject.addProperty("temperature", 0.7)
|
||||
jsonObject.addProperty("stream", onChunk != null)
|
||||
jsonObject.addProperty("stream", false)
|
||||
|
||||
val messagesArray = JsonArray()
|
||||
messages.forEach { msg ->
|
||||
|
|
@ -112,15 +155,40 @@ import java.util.concurrent.TimeUnit
|
|||
.build()
|
||||
|
||||
currentCall = client.newCall(request)
|
||||
val response = currentCall!!.execute()
|
||||
|
||||
if (response.code == 0 || response.code == -1) {
|
||||
return@withContext Result.failure(Exception("Request cancelled"))
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
currentContinuation = continuation
|
||||
|
||||
currentCall?.enqueue(object : okhttp3.Callback {
|
||||
override fun onFailure(call: okhttp3.Call, e: java.io.IOException) {
|
||||
val cont = currentContinuation
|
||||
currentCall = null
|
||||
currentContinuation = null
|
||||
if (cont != null) {
|
||||
if (call.isCanceled()) {
|
||||
cont.resume(Result.failure(Exception("Request cancelled")))
|
||||
} else {
|
||||
cont.resume(Result.failure(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResponse(call: okhttp3.Call, response: Response) {
|
||||
val cont = currentContinuation
|
||||
|
||||
if (cont == null) {
|
||||
response.close()
|
||||
currentCall = null
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (!response.isSuccessful) {
|
||||
val errorBody = response.body?.string() ?: "Unknown error"
|
||||
return@withContext Result.failure(Exception("API error: ${response.code} - $errorBody"))
|
||||
currentCall = null
|
||||
currentContinuation = null
|
||||
cont.resume(Result.failure(Exception("API error: ${response.code} - $errorBody")))
|
||||
return
|
||||
}
|
||||
|
||||
val responseBody = response.body?.string() ?: ""
|
||||
|
|
@ -129,24 +197,54 @@ import java.util.concurrent.TimeUnit
|
|||
onChunk(responseBody)
|
||||
}
|
||||
|
||||
val responseJson = gson.fromJson(responseBody, JsonObject::class.java)
|
||||
val responseJson = try {
|
||||
gson.fromJson(responseBody, JsonObject::class.java)
|
||||
} catch (e: Exception) {
|
||||
currentCall = null
|
||||
currentContinuation = null
|
||||
cont.resume(Result.failure(Exception("Invalid JSON response")))
|
||||
return
|
||||
}
|
||||
|
||||
val choices = responseJson.getAsJsonArray("choices")
|
||||
if (choices == null || choices.size() == 0) {
|
||||
return@withContext Result.failure(Exception("No response from API"))
|
||||
currentCall = null
|
||||
currentContinuation = null
|
||||
cont.resume(Result.failure(Exception("No response from API")))
|
||||
return
|
||||
}
|
||||
|
||||
val content = choices
|
||||
.get(0)
|
||||
?.asJsonObject
|
||||
?.getAsJsonObject("message")
|
||||
?.get("content")
|
||||
?.asString ?: ""
|
||||
val firstChoice = choices.get(0)?.asJsonObject
|
||||
if (firstChoice == null) {
|
||||
currentCall = null
|
||||
currentContinuation = null
|
||||
cont.resume(Result.failure(Exception("Empty choice")))
|
||||
return
|
||||
}
|
||||
|
||||
val message = firstChoice.getAsJsonObject("message")
|
||||
val content = message?.get("content")?.asString ?: ""
|
||||
|
||||
val usedModel = responseJson.get("model")?.asString ?: model
|
||||
|
||||
currentCall = null
|
||||
Result.success(content to usedModel)
|
||||
currentContinuation = null
|
||||
cont.resume(Result.success(content to usedModel))
|
||||
} catch (e: Exception) {
|
||||
currentCall = null
|
||||
currentContinuation = null
|
||||
cont.resume(Result.failure(e))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
continuation.invokeOnCancellation {
|
||||
currentCall?.cancel()
|
||||
currentCall = null
|
||||
currentContinuation = null
|
||||
client = createNewClient()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,27 +1,52 @@
|
|||
package com.mistral.chat.data
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(
|
||||
tableName = "messages",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = Session::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["sessionId"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
)
|
||||
],
|
||||
indices = [Index("sessionId")]
|
||||
)
|
||||
data class MessageEntity(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
val id: Long = 0,
|
||||
val sessionId: Long,
|
||||
val content: String,
|
||||
val isUser: Boolean,
|
||||
val timestamp: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
data class Message(
|
||||
val id: String = System.currentTimeMillis().toString(),
|
||||
val id: Long = 0,
|
||||
val sessionId: Long = 0,
|
||||
val content: String,
|
||||
val isUser: Boolean,
|
||||
val timestamp: Long = System.currentTimeMillis(),
|
||||
val senderName: String? = null
|
||||
)
|
||||
|
||||
data class ChatRequest(
|
||||
val model: String,
|
||||
val messages: List<Message>,
|
||||
val temperature: Double = 0.7,
|
||||
val stream: Boolean = false
|
||||
fun MessageEntity.toMessage(): Message = Message(
|
||||
id = id,
|
||||
sessionId = sessionId,
|
||||
content = content,
|
||||
isUser = isUser,
|
||||
timestamp = timestamp
|
||||
)
|
||||
|
||||
data class ChatResponse(
|
||||
val id: String,
|
||||
val choices: List<Choice>,
|
||||
val model: String
|
||||
)
|
||||
|
||||
data class Choice(
|
||||
val index: Int,
|
||||
val message: Message
|
||||
fun Message.toEntity(): MessageEntity = MessageEntity(
|
||||
id = id,
|
||||
sessionId = sessionId,
|
||||
content = content,
|
||||
isUser = isUser,
|
||||
timestamp = timestamp
|
||||
)
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
package com.mistral.chat.data
|
||||
|
||||
data class UserProfile(
|
||||
val name: String = "",
|
||||
val bio: String = "",
|
||||
val preferences: String = ""
|
||||
) {
|
||||
fun isEmpty(): Boolean = name.isBlank() && bio.isBlank() && preferences.isBlank()
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,14 +8,17 @@ import android.view.KeyEvent
|
|||
import android.view.View
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.view.MenuItem
|
||||
import android.widget.ImageButton
|
||||
import android.widget.ImageView
|
||||
import android.widget.PopupMenu
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import android.widget.EditText
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.view.GravityCompat
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
|
|
@ -23,17 +26,29 @@ import androidx.recyclerview.widget.RecyclerView
|
|||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import com.google.android.material.color.DynamicColors
|
||||
import com.google.android.material.navigation.NavigationView
|
||||
import com.google.android.material.progressindicator.LinearProgressIndicator
|
||||
import com.google.android.material.textfield.TextInputEditText
|
||||
import com.google.android.material.button.MaterialButton
|
||||
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.ChatDatabase
|
||||
import com.mistral.chat.data.Message
|
||||
import com.mistral.chat.data.UserProfile
|
||||
import com.mistral.chat.data.MessageEntity
|
||||
import com.mistral.chat.data.Profile
|
||||
import com.mistral.chat.data.Session
|
||||
import com.mistral.chat.data.toMessage
|
||||
import com.mistral.chat.data.toEntity
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener {
|
||||
|
||||
private lateinit var recyclerView: RecyclerView
|
||||
private lateinit var adapter: MessageAdapter
|
||||
|
|
@ -43,6 +58,10 @@ class MainActivity : AppCompatActivity() {
|
|||
private lateinit var logoButton: ImageView
|
||||
private lateinit var menuButton: ImageButton
|
||||
private lateinit var toolbarTitle: TextView
|
||||
private lateinit var hamburgerButton: ImageButton
|
||||
private lateinit var drawerLayout: androidx.drawerlayout.widget.DrawerLayout
|
||||
private lateinit var navigationView: NavigationView
|
||||
private lateinit var rightPanel: View
|
||||
private lateinit var gson: Gson
|
||||
private var currentJob: kotlinx.coroutines.Job? = null
|
||||
|
||||
|
|
@ -52,15 +71,26 @@ class MainActivity : AppCompatActivity() {
|
|||
private var selectedModelName: String = "mistral-small-latest"
|
||||
private lateinit var prefs: SharedPreferences
|
||||
private lateinit var encryptedPrefs: SharedPreferences
|
||||
private lateinit var database: ChatDatabase
|
||||
|
||||
private val profiles = mutableListOf<Profile>()
|
||||
private val sessions = mutableListOf<Session>()
|
||||
private var currentProfileId: Long? = null
|
||||
private var currentSessionId: Long? = null
|
||||
private var profilesAdapter: ProfilesAdapter? = null
|
||||
private var isRightPanelVisible = false
|
||||
private var isLeftDrawerOpen = false
|
||||
private var userMessageCount = 0
|
||||
private var titleGenerationJob: kotlinx.coroutines.Job? = null
|
||||
private var isFirstLoad = true
|
||||
|
||||
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 KEY_NEW_SESSION_ON_START = "new_session_on_start"
|
||||
private const val KEY_LAST_PROFILE_ID = "last_profile_id"
|
||||
private const val KEY_SELECTED_MODEL = "selected_model"
|
||||
private const val MAX_PROFILES = 10
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
|
|
@ -74,6 +104,11 @@ class MainActivity : AppCompatActivity() {
|
|||
gson = Gson()
|
||||
prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
val savedModel = prefs.getString(KEY_SELECTED_MODEL, null)
|
||||
if (savedModel != null) {
|
||||
selectedModelName = savedModel
|
||||
}
|
||||
|
||||
val masterKey = MasterKey.Builder(this)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
|
|
@ -91,20 +126,27 @@ class MainActivity : AppCompatActivity() {
|
|||
|
||||
client = MistralClient(getApiKey())
|
||||
|
||||
loadMessages()
|
||||
|
||||
logoButton = findViewById(R.id.logoButton)
|
||||
menuButton = findViewById(R.id.menuButton)
|
||||
hamburgerButton = findViewById(R.id.hamburgerButton)
|
||||
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)
|
||||
drawerLayout = findViewById(R.id.drawerLayout)
|
||||
navigationView = findViewById(R.id.navigationView)
|
||||
rightPanel = findViewById(R.id.rightPanelContainer)
|
||||
|
||||
database = ChatDatabase.getInstance(this)
|
||||
|
||||
setupToolbar()
|
||||
setupRecyclerView()
|
||||
setupDrawer()
|
||||
setupRightPanel()
|
||||
loadModels()
|
||||
setupInput()
|
||||
loadProfilesAndSessions()
|
||||
|
||||
inputField.postDelayed({
|
||||
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
|
|
@ -113,48 +155,409 @@ class MainActivity : AppCompatActivity() {
|
|||
}, 300)
|
||||
}
|
||||
|
||||
private fun setupDrawer() {
|
||||
navigationView.setNavigationItemSelectedListener(this)
|
||||
|
||||
hamburgerButton.setOnClickListener {
|
||||
drawerLayout.openDrawer(GravityCompat.START)
|
||||
}
|
||||
|
||||
drawerLayout.addDrawerListener(object : androidx.drawerlayout.widget.DrawerLayout.DrawerListener {
|
||||
override fun onDrawerSlide(drawerView: View, slideOffset: Float) {}
|
||||
override fun onDrawerOpened(drawerView: View) {
|
||||
if (drawerView == navigationView) {
|
||||
isLeftDrawerOpen = true
|
||||
} else if (drawerView == rightPanel) {
|
||||
isRightPanelVisible = true
|
||||
}
|
||||
}
|
||||
override fun onDrawerClosed(drawerView: View) {
|
||||
if (drawerView == navigationView) {
|
||||
isLeftDrawerOpen = false
|
||||
} else if (drawerView == rightPanel) {
|
||||
isRightPanelVisible = false
|
||||
}
|
||||
}
|
||||
override fun onDrawerStateChanged(newState: Int) {}
|
||||
})
|
||||
}
|
||||
|
||||
override fun onNavigationItemSelected(item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
R.id.action_profiles -> {
|
||||
showProfilesManager()
|
||||
}
|
||||
R.id.action_api_key -> {
|
||||
showApiKeyDialog()
|
||||
}
|
||||
R.id.action_clear_all -> {
|
||||
showClearAllDialog()
|
||||
}
|
||||
R.id.action_settings -> {
|
||||
showSettingsDialog()
|
||||
}
|
||||
R.id.action_about -> {
|
||||
showAboutDialog()
|
||||
}
|
||||
}
|
||||
drawerLayout.closeDrawer(GravityCompat.START)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun showProfilesManager() {
|
||||
if (profiles.isEmpty()) {
|
||||
showCreateProfileDialog()
|
||||
} else {
|
||||
val options = profiles.map { it.name }.toMutableList()
|
||||
if (profiles.size < MAX_PROFILES) {
|
||||
options.add("Создать новый профиль")
|
||||
}
|
||||
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.manage_profiles)
|
||||
.setItems(options.toTypedArray()) { _, which ->
|
||||
if (which < profiles.size) {
|
||||
showEditProfileDialog(profiles[which])
|
||||
} else if (profiles.size < MAX_PROFILES) {
|
||||
showCreateProfileDialog()
|
||||
}
|
||||
}
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showCreateProfileDialog() {
|
||||
showProfileDialog(null)
|
||||
}
|
||||
|
||||
private fun showEditProfileDialog(profile: Profile) {
|
||||
showProfileDialog(profile)
|
||||
}
|
||||
|
||||
private fun showClearAllDialog() {
|
||||
val dialogView = layoutInflater.inflate(R.layout.dialog_clear_all, null)
|
||||
val deleteProfilesCheckbox = dialogView.findViewById<android.widget.CheckBox>(R.id.deleteProfilesCheckbox)
|
||||
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.clear_all_history)
|
||||
.setView(dialogView)
|
||||
.setPositiveButton(R.string.yes) { _, _ ->
|
||||
val deleteProfiles = deleteProfilesCheckbox.isChecked
|
||||
|
||||
lifecycleScope.launch {
|
||||
database.sessionDao().deleteAll()
|
||||
|
||||
// Wait for transaction to complete
|
||||
kotlinx.coroutines.delay(100)
|
||||
|
||||
if (deleteProfiles) {
|
||||
database.profileDao().deleteAll()
|
||||
currentProfileId = null
|
||||
prefs.edit().remove(KEY_LAST_PROFILE_ID).apply()
|
||||
profiles.clear()
|
||||
}
|
||||
|
||||
messages.clear()
|
||||
adapter.notifyDataSetChanged()
|
||||
|
||||
// Create fresh session with null profileId
|
||||
val newSession = Session(
|
||||
profileId = null,
|
||||
title = "Новая сессия"
|
||||
)
|
||||
val sessionId = database.sessionDao().insert(newSession)
|
||||
currentSessionId = sessionId
|
||||
userMessageCount = 0
|
||||
messages.clear()
|
||||
adapter.notifyDataSetChanged()
|
||||
sessions.clear()
|
||||
sessions.add(newSession.copy(id = sessionId))
|
||||
|
||||
updateRightPanel()
|
||||
Toast.makeText(this@MainActivity, R.string.history_cleared, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
.setNegativeButton(R.string.no, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun showSettingsDialog() {
|
||||
val currentSetting = prefs.getBoolean(KEY_NEW_SESSION_ON_START, false)
|
||||
val options = arrayOf("Открывать последнюю сессию", "Начинать новую сессию")
|
||||
val selectedIndex = if (currentSetting) 1 else 0
|
||||
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.settings)
|
||||
.setSingleChoiceItems(options, selectedIndex) { dialog, which ->
|
||||
val newValue = which == 1
|
||||
prefs.edit().putBoolean(KEY_NEW_SESSION_ON_START, newValue).apply()
|
||||
dialog.dismiss()
|
||||
Toast.makeText(this, "Настройка сохранена", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun showAboutDialog() {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.about)
|
||||
.setMessage(R.string.about_text)
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun setupToolbar() {
|
||||
hamburgerButton.isVisible = true
|
||||
|
||||
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
|
||||
showRightPanelMenu(view)
|
||||
}
|
||||
}
|
||||
popup.show()
|
||||
|
||||
private fun showRightPanelMenu(view: View) {
|
||||
if (isRightPanelVisible) {
|
||||
drawerLayout.closeDrawer(GravityCompat.END)
|
||||
} else {
|
||||
if (isLeftDrawerOpen) {
|
||||
drawerLayout.closeDrawer(GravityCompat.START)
|
||||
}
|
||||
drawerLayout.openDrawer(GravityCompat.END)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupRightPanel() {
|
||||
val includedPanel = rightPanel.findViewById<View>(R.id.panelRightContent)
|
||||
val profilesRecyclerView = includedPanel.findViewById<RecyclerView>(R.id.profilesRecyclerView)
|
||||
val sessionsRecyclerView = includedPanel.findViewById<RecyclerView>(R.id.sessionsRecyclerView)
|
||||
val newSessionButton = includedPanel.findViewById<MaterialButton>(R.id.newSessionButton)
|
||||
|
||||
profilesAdapter = ProfilesAdapter(
|
||||
profiles = profiles,
|
||||
onProfileClick = { profile -> selectProfile(profile) },
|
||||
onProfileLongClick = { profile -> showProfileOptions(profile) },
|
||||
getSelectedProfileId = { currentProfileId }
|
||||
)
|
||||
profilesRecyclerView.layoutManager = LinearLayoutManager(this)
|
||||
profilesRecyclerView.adapter = profilesAdapter
|
||||
|
||||
val sessionsAdapter = SessionsAdapter(
|
||||
sessions = sessions,
|
||||
getCurrentSessionId = { currentSessionId },
|
||||
onSessionClick = { session -> selectSession(session) },
|
||||
onSessionLongClick = { session -> showSessionOptions(session) }
|
||||
)
|
||||
sessionsRecyclerView.layoutManager = LinearLayoutManager(this)
|
||||
sessionsRecyclerView.adapter = sessionsAdapter
|
||||
|
||||
newSessionButton.setOnClickListener { createNewSession() }
|
||||
}
|
||||
|
||||
private fun loadProfilesAndSessions() {
|
||||
lifecycleScope.launch {
|
||||
database.profileDao().getAllProfiles().collect { profileList ->
|
||||
profiles.clear()
|
||||
profiles.addAll(profileList)
|
||||
|
||||
val lastProfileId = prefs.getLong(KEY_LAST_PROFILE_ID, -1L)
|
||||
val profileExists = profiles.any { it.id == lastProfileId }
|
||||
val currentStillValid = currentProfileId != null && profiles.any { it.id == currentProfileId }
|
||||
|
||||
if (!currentStillValid) {
|
||||
currentProfileId = if (lastProfileId > 0 && profileExists) {
|
||||
lastProfileId
|
||||
} else if (profiles.isNotEmpty()) {
|
||||
profiles.first().id
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val profileId = currentProfileId
|
||||
if (profileId != null) {
|
||||
prefs.edit().putLong(KEY_LAST_PROFILE_ID, profileId).apply()
|
||||
}
|
||||
}
|
||||
|
||||
profilesAdapter?.refresh()
|
||||
updateRightPanel()
|
||||
}
|
||||
}
|
||||
lifecycleScope.launch {
|
||||
database.sessionDao().getAllSessions().collect { sessionList ->
|
||||
sessions.clear()
|
||||
sessions.addAll(sessionList)
|
||||
updateRightPanel()
|
||||
|
||||
val lastSessionId = prefs.getLong("last_session_id", -1L)
|
||||
|
||||
if (currentSessionId == null && sessions.isNotEmpty() && isFirstLoad) {
|
||||
val newSessionOnStart = prefs.getBoolean(KEY_NEW_SESSION_ON_START, false)
|
||||
|
||||
if (newSessionOnStart) {
|
||||
createNewSession()
|
||||
} else if (lastSessionId > 0 && sessions.any { it.id == lastSessionId }) {
|
||||
val session = sessions.find { it.id == lastSessionId }
|
||||
if (session != null) {
|
||||
selectSession(session)
|
||||
} else {
|
||||
selectSession(sessions.first())
|
||||
}
|
||||
} else {
|
||||
selectSession(sessions.first())
|
||||
}
|
||||
isFirstLoad = false
|
||||
} else if (currentSessionId != null && !sessions.any { it.id == currentSessionId }) {
|
||||
if (sessions.isNotEmpty()) {
|
||||
selectSession(sessions.first())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateRightPanel() {
|
||||
try {
|
||||
profilesAdapter?.refresh()
|
||||
val includedPanel = rightPanel.findViewById<View>(R.id.panelRightContent) ?: return
|
||||
val sessionsRecyclerView = includedPanel.findViewById<RecyclerView>(R.id.sessionsRecyclerView) ?: return
|
||||
(sessionsRecyclerView.adapter as? SessionsAdapter)?.notifyDataSetChanged()
|
||||
} catch (e: Exception) {
|
||||
// Panel not initialized yet
|
||||
}
|
||||
}
|
||||
|
||||
private fun selectProfile(profile: Profile) {
|
||||
currentProfileId = profile.id
|
||||
prefs.edit().putLong(KEY_LAST_PROFILE_ID, profile.id).apply()
|
||||
profilesAdapter?.refresh()
|
||||
val profileName = profiles.find { it.id == currentProfileId }?.name ?: profile.name
|
||||
Toast.makeText(this, getString(R.string.profile_info, profileName), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
private fun showProfileOptions(profile: Profile) {
|
||||
val options = arrayOf("Редактировать", "Удалить")
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(profile.name)
|
||||
.setItems(options) { _, which ->
|
||||
when (which) {
|
||||
0 -> editProfile(profile)
|
||||
1 -> deleteProfile(profile)
|
||||
}
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun editProfile(profile: Profile) {
|
||||
showProfileDialog(profile)
|
||||
}
|
||||
|
||||
private fun deleteProfile(profile: Profile) {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.delete)
|
||||
.setMessage("Удалить профиль ${profile.name}?")
|
||||
.setPositiveButton(R.string.yes) { _, _ ->
|
||||
lifecycleScope.launch {
|
||||
database.profileDao().delete(profile)
|
||||
}
|
||||
}
|
||||
.setNegativeButton(R.string.no, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun selectSession(session: Session) {
|
||||
currentSessionId = session.id
|
||||
userMessageCount = 0
|
||||
prefs.edit().putLong("last_session_id", session.id).apply()
|
||||
loadSessionMessages(session.id)
|
||||
updateRightPanel()
|
||||
drawerLayout.closeDrawer(GravityCompat.END)
|
||||
}
|
||||
|
||||
private fun loadSessionMessages(sessionId: Long) {
|
||||
lifecycleScope.launch {
|
||||
val dbMessages = database.messageDao().getMessagesBySessionSync(sessionId)
|
||||
messages.clear()
|
||||
messages.addAll(dbMessages.map { it.toMessage() })
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showSessionOptions(session: Session) {
|
||||
val options = arrayOf("Переименовать", "Удалить")
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(session.title)
|
||||
.setItems(options) { _, which ->
|
||||
when (which) {
|
||||
0 -> renameSession(session)
|
||||
1 -> deleteSession(session)
|
||||
}
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun renameSession(session: Session) {
|
||||
val input = EditText(this)
|
||||
input.setText(session.title)
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle("Переименовать")
|
||||
.setView(input)
|
||||
.setPositiveButton(R.string.save) { _, _ ->
|
||||
val newTitle = input.text.toString().trim()
|
||||
if (newTitle.isNotEmpty()) {
|
||||
lifecycleScope.launch {
|
||||
database.sessionDao().updateTitle(session.id, newTitle)
|
||||
}
|
||||
}
|
||||
}
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun deleteSession(session: Session) {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.delete)
|
||||
.setMessage("Удалить сессию ${session.title}?")
|
||||
.setPositiveButton(R.string.yes) { _, _ ->
|
||||
lifecycleScope.launch {
|
||||
database.sessionDao().delete(session)
|
||||
}
|
||||
}
|
||||
.setNegativeButton(R.string.no, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun createNewSession() {
|
||||
lifecycleScope.launch {
|
||||
val session = Session(
|
||||
profileId = if (currentProfileId != null && profiles.any { it.id == currentProfileId }) currentProfileId else null,
|
||||
title = "Новая сессия"
|
||||
)
|
||||
val sessionId = database.sessionDao().insert(session)
|
||||
currentSessionId = sessionId
|
||||
userMessageCount = 0
|
||||
messages.clear()
|
||||
adapter.notifyDataSetChanged()
|
||||
updateRightPanel()
|
||||
Toast.makeText(this@MainActivity, R.string.new_session, Toast.LENGTH_SHORT).show()
|
||||
drawerLayout.closeDrawer(GravityCompat.END)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
val uniqueModels = availableModels.distinctBy { it.second }
|
||||
val modelNames = uniqueModels.map { it.second }.toTypedArray()
|
||||
val currentModelId = uniqueModels.find { it.first == selectedModelName }?.second
|
||||
?: uniqueModels.firstOrNull()?.second
|
||||
?: ""
|
||||
val currentIndex = uniqueModels.indexOfFirst { it.second == currentModelId }.coerceAtLeast(0)
|
||||
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.select_model)
|
||||
.setSingleChoiceItems(modelNames, currentIndex) { dialog, which ->
|
||||
selectedModelName = availableModels[which].first
|
||||
selectedModelName = uniqueModels[which].first
|
||||
prefs.edit().putString(KEY_SELECTED_MODEL, selectedModelName).apply()
|
||||
dialog.dismiss()
|
||||
}
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
|
|
@ -192,17 +595,96 @@ class MainActivity : AppCompatActivity() {
|
|||
val userInput = inputField.text?.toString()?.trim()
|
||||
if (userInput.isNullOrEmpty()) return
|
||||
|
||||
addMessage(Message(content = userInput, isUser = true))
|
||||
inputField.text?.clear()
|
||||
if (currentSessionId == null) {
|
||||
createNewSessionAndSend(userInput)
|
||||
return
|
||||
}
|
||||
|
||||
addMessage(Message(content = userInput, isUser = true, senderName = getCurrentProfileName()))
|
||||
|
||||
inputField.text?.clear()
|
||||
sendMessage(userInput)
|
||||
}
|
||||
|
||||
private fun createNewSessionAndSend(userInput: String) {
|
||||
lifecycleScope.launch {
|
||||
val session = Session(
|
||||
profileId = if (currentProfileId != null && profiles.any { it.id == currentProfileId }) currentProfileId else null,
|
||||
title = "Новая сессия"
|
||||
)
|
||||
val sessionId = database.sessionDao().insert(session)
|
||||
currentSessionId = sessionId
|
||||
userMessageCount = 0
|
||||
messages.clear()
|
||||
adapter.notifyDataSetChanged()
|
||||
updateRightPanel()
|
||||
|
||||
addMessage(Message(content = userInput, isUser = true, senderName = getCurrentProfileName()))
|
||||
inputField.text?.clear()
|
||||
sendMessage(userInput)
|
||||
}
|
||||
}
|
||||
|
||||
private fun generateSessionTitle(): kotlinx.coroutines.Job {
|
||||
val sessionId = currentSessionId ?: return kotlinx.coroutines.Job()
|
||||
|
||||
return lifecycleScope.launch(Dispatchers.IO) {
|
||||
var attempts = 0
|
||||
val maxAttempts = 2
|
||||
|
||||
while (attempts < maxAttempts && isActive) {
|
||||
attempts++
|
||||
try {
|
||||
val recentMessages = messages.takeLast(4).map { msg ->
|
||||
if (msg.isUser) "User: ${msg.content}" else "AI: ${msg.content}"
|
||||
}.joinToString("\n")
|
||||
|
||||
val prompt = "Кратко озаглавь этот чат в 3-5 слов. Отвечай только названием, без кавычек."
|
||||
val fullPrompt = "$prompt\n\n$recentMessages"
|
||||
|
||||
val result = withTimeoutOrNull(10000L) {
|
||||
client?.chat(selectedModelName, listOf(
|
||||
Message(content = fullPrompt, isUser = true)
|
||||
))
|
||||
}
|
||||
|
||||
if (!isActive) return@launch
|
||||
|
||||
if (result != null) {
|
||||
result.onSuccess { (response, _) ->
|
||||
val title = response.trim().take(50)
|
||||
if (title.isNotEmpty() && currentSessionId == sessionId) {
|
||||
database.sessionDao().updateTitle(sessionId, title)
|
||||
runOnUiThread {
|
||||
sessions.find { it.id == sessionId }?.let { session ->
|
||||
val index = sessions.indexOf(session)
|
||||
if (index >= 0) {
|
||||
sessions[index] = session.copy(title = title)
|
||||
updateRightPanel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (!isActive) return@launch
|
||||
}
|
||||
if (attempts < maxAttempts && isActive) {
|
||||
kotlinx.coroutines.delay(1000L)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadModels() {
|
||||
lifecycleScope.launch {
|
||||
val result = client?.getModels()
|
||||
result?.onSuccess { models ->
|
||||
availableModels = models
|
||||
val hasUserSelectedModel = prefs.contains(KEY_SELECTED_MODEL)
|
||||
if (!hasUserSelectedModel) {
|
||||
runOnUiThread {
|
||||
val codestralIndex = models.indexOfFirst { it.first.contains("codestral", ignoreCase = true) }
|
||||
if (codestralIndex >= 0) {
|
||||
|
|
@ -211,44 +693,71 @@ class MainActivity : AppCompatActivity() {
|
|||
selectedModelName = models[0].first
|
||||
}
|
||||
}
|
||||
}
|
||||
}?.onFailure {
|
||||
availableModels = MistralClient.AVAILABLE_MODELS
|
||||
val hasUserSelectedModel = prefs.contains(KEY_SELECTED_MODEL)
|
||||
if (!hasUserSelectedModel) {
|
||||
runOnUiThread {
|
||||
selectedModelName = MistralClient.AVAILABLE_MODELS.firstOrNull()?.first ?: "mistral-small-latest"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun addMessage(message: Message) {
|
||||
val isAssistantMessage = !message.isUser
|
||||
val newPosition = messages.size - 1
|
||||
|
||||
messages.add(message)
|
||||
adapter.notifyItemInserted(messages.size - 1)
|
||||
saveMessages()
|
||||
adapter.notifyItemInserted(newPosition)
|
||||
|
||||
// Scroll to beginning of assistant messages so user sees the sender name first
|
||||
if (isAssistantMessage) {
|
||||
recyclerView.post {
|
||||
recyclerView.scrollToPosition(0)
|
||||
// Then scroll to show the new message from beginning
|
||||
val scrollAmount = (recyclerView.computeVerticalScrollExtent() - 200).coerceAtLeast(0)
|
||||
recyclerView.post {
|
||||
recyclerView.scrollBy(0, scrollAmount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val sessionId = currentSessionId
|
||||
if (sessionId != null) {
|
||||
lifecycleScope.launch {
|
||||
val entity = MessageEntity(
|
||||
sessionId = sessionId,
|
||||
content = message.content,
|
||||
isUser = message.isUser,
|
||||
timestamp = message.timestamp
|
||||
)
|
||||
database.messageDao().insert(entity)
|
||||
database.sessionDao().updateTimestamp(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
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 saveMessageToDatabase(sessionId: Long?, content: String, isUser: Boolean, senderName: String?) {
|
||||
if (sessionId != null) {
|
||||
lifecycleScope.launch {
|
||||
database.messageDao().insert(MessageEntity(
|
||||
sessionId = sessionId,
|
||||
content = content,
|
||||
isUser = isUser,
|
||||
timestamp = System.currentTimeMillis()
|
||||
))
|
||||
database.sessionDao().updateTimestamp(sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveMessages() {
|
||||
val json = gson.toJson(messages)
|
||||
prefs.edit().putString(KEY_MESSAGES, json).apply()
|
||||
}
|
||||
|
||||
private fun getApiKey(): String {
|
||||
return encryptedPrefs.getString(KEY_API_KEY, null) ?: ""
|
||||
}
|
||||
|
|
@ -320,11 +829,7 @@ class MainActivity : AppCompatActivity() {
|
|||
|
||||
currentJob = lifecycleScope.launch {
|
||||
try {
|
||||
val userProfile = loadUserProfile()
|
||||
|
||||
val profileContext = if (!userProfile.isEmpty()) {
|
||||
userProfile.toContextString()
|
||||
} else ""
|
||||
val profileContext = getSelectedProfileContext()
|
||||
|
||||
val apiMessages = messages.map { msg ->
|
||||
Message(
|
||||
|
|
@ -337,18 +842,46 @@ class MainActivity : AppCompatActivity() {
|
|||
apiMessages.add(0, Message(content = profileContext, isUser = true))
|
||||
}
|
||||
|
||||
val result = client?.chat(selectedModel, apiMessages)
|
||||
val result = withTimeout(15000L) {
|
||||
client?.chat(selectedModel, apiMessages) ?: Result.failure(Exception("Client not initialized"))
|
||||
}
|
||||
|
||||
result?.onSuccess { (response, usedModel) ->
|
||||
addMessage(Message(content = response, isUser = false, senderName = usedModel))
|
||||
}?.onFailure { error ->
|
||||
if (!isActive) return@launch
|
||||
|
||||
result.onSuccess { (response, usedModel) ->
|
||||
val displayModel = usedModel.ifEmpty { "Assistant" }
|
||||
addMessage(Message(content = response, isUser = false, senderName = displayModel))
|
||||
lifecycleScope.launch {
|
||||
saveMessageToDatabase(currentSessionId, response, false, displayModel)
|
||||
}
|
||||
|
||||
val count = userMessageCount + 1
|
||||
userMessageCount = count
|
||||
if (count == 2 && titleGenerationJob?.isActive != true) {
|
||||
titleGenerationJob = generateSessionTitle()
|
||||
}
|
||||
}.onFailure { error ->
|
||||
if (!isActive) return@launch
|
||||
val errorMessage = error.message ?: "Unknown error"
|
||||
if (!errorMessage.contains("cancelled", ignoreCase = true)) {
|
||||
val userFriendlyMessage = getUserFriendlyError(errorMessage)
|
||||
addMessage(Message(content = userFriendlyMessage, isUser = false, senderName = "Error"))
|
||||
}
|
||||
}
|
||||
|
||||
sendButton.isEnabled = true
|
||||
sendButton.setImageResource(R.drawable.ic_mistral_logo)
|
||||
progressIndicator.isVisible = false
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
addMessage(Message(content = "❌ Отменено пользователем", isUser = false, senderName = "Cancelled"))
|
||||
} finally {
|
||||
if (!isActive) return@launch
|
||||
addMessage(Message(content = "Запрос отменён", isUser = false, senderName = "System"))
|
||||
sendButton.isEnabled = true
|
||||
sendButton.setImageResource(R.drawable.ic_mistral_logo)
|
||||
progressIndicator.isVisible = false
|
||||
} catch (e: Exception) {
|
||||
if (!isActive) return@launch
|
||||
val userFriendlyMessage = getUserFriendlyError(e.message ?: "Unknown error")
|
||||
addMessage(Message(content = userFriendlyMessage, isUser = false, senderName = "Error"))
|
||||
sendButton.isEnabled = true
|
||||
sendButton.setImageResource(R.drawable.ic_mistral_logo)
|
||||
progressIndicator.isVisible = false
|
||||
|
|
@ -358,62 +891,92 @@ class MainActivity : AppCompatActivity() {
|
|||
|
||||
private fun cancelRequest() {
|
||||
currentJob?.cancel()
|
||||
titleGenerationJob?.cancel()
|
||||
client?.cancelRequest()
|
||||
|
||||
sendButton.isEnabled = true
|
||||
sendButton.setImageResource(R.drawable.ic_mistral_logo)
|
||||
progressIndicator.isVisible = false
|
||||
}
|
||||
|
||||
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 getSelectedProfileContext(): String {
|
||||
if (currentProfileId == null) return ""
|
||||
|
||||
val profile = profiles.find { it.id == currentProfileId }
|
||||
if (profile == null) return ""
|
||||
|
||||
return buildString {
|
||||
append("[Profile: ${profile.name}]\n")
|
||||
if (profile.bio.isNotBlank()) append("Bio: ${profile.bio}\n")
|
||||
if (profile.preferences.isNotBlank()) append("Preferences: ${profile.preferences}\n")
|
||||
}
|
||||
}
|
||||
|
||||
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 getCurrentProfileName(): String {
|
||||
if (currentProfileId == null) return "Вы"
|
||||
val profileName = profiles.find { it.id == currentProfileId }?.name
|
||||
return if (profileName.isNullOrBlank()) "Вы" else profileName
|
||||
}
|
||||
|
||||
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(existingProfile: Profile? = null) {
|
||||
if (existingProfile == null && profiles.size >= MAX_PROFILES) {
|
||||
Toast.makeText(this, "Максимум $MAX_PROFILES профилей", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
existingProfile?.let {
|
||||
nameInput.setText(it.name)
|
||||
bioInput.setText(it.bio)
|
||||
preferencesInput.setText(it.preferences)
|
||||
}
|
||||
|
||||
AlertDialog.Builder(this)
|
||||
val dialog = AlertDialog.Builder(this)
|
||||
.setTitle(if (existingProfile != null) R.string.profile_title else R.string.new_profile)
|
||||
.setView(dialogView)
|
||||
.setPositiveButton(R.string.save) { _, _ ->
|
||||
val newProfile = UserProfile(
|
||||
name = nameInput.text?.toString() ?: "",
|
||||
val name = nameInput.text?.toString()?.trim() ?: ""
|
||||
if (name.isNotEmpty()) {
|
||||
lifecycleScope.launch {
|
||||
if (existingProfile != null) {
|
||||
database.profileDao().update(existingProfile.copy(
|
||||
name = name,
|
||||
bio = bioInput.text?.toString() ?: "",
|
||||
preferences = preferencesInput.text?.toString() ?: "",
|
||||
updatedAt = System.currentTimeMillis()
|
||||
))
|
||||
} else {
|
||||
val newId = database.profileDao().insert(Profile(
|
||||
name = name,
|
||||
bio = bioInput.text?.toString() ?: "",
|
||||
preferences = preferencesInput.text?.toString() ?: ""
|
||||
)
|
||||
saveUserProfile(newProfile)
|
||||
))
|
||||
if (currentProfileId == null) {
|
||||
currentProfileId = newId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.setNeutralButton(R.string.delete) { _, _ ->
|
||||
deleteUserProfile()
|
||||
|
||||
if (existingProfile != null) {
|
||||
dialog.setNeutralButton(R.string.delete) { _, _ ->
|
||||
lifecycleScope.launch {
|
||||
database.profileDao().delete(existingProfile)
|
||||
if (currentProfileId == existingProfile.id) {
|
||||
currentProfileId = null
|
||||
}
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
private fun showClearChatDialog() {
|
||||
|
|
@ -422,19 +985,11 @@ class MainActivity : AppCompatActivity() {
|
|||
.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) ||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import android.content.Context
|
|||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.ContextCompat
|
||||
|
|
@ -46,6 +47,11 @@ class MessageAdapter(private val messages: List<Message>) : RecyclerView.Adapter
|
|||
class UserMessageHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
fun bind(message: Message) {
|
||||
val textView = itemView.findViewById<TextView>(R.id.messageText)
|
||||
val senderNameView = itemView.findViewById<TextView>(R.id.senderName)
|
||||
val senderIconView = itemView.findViewById<ImageView>(R.id.senderIcon)
|
||||
|
||||
senderIconView.visibility = View.VISIBLE
|
||||
senderNameView.text = message.senderName ?: "Вы"
|
||||
textView.text = message.content
|
||||
textView.setBackgroundResource(R.drawable.bg_message_user)
|
||||
textView.setTextColor(ContextCompat.getColor(itemView.context, R.color.user_message_text))
|
||||
|
|
|
|||
|
|
@ -1,13 +1,18 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RelativeLayout
|
||||
<androidx.drawerlayout.widget.DrawerLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/drawerLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fitsSystemWindows="true"
|
||||
tools:context=".ui.MainActivity">
|
||||
|
||||
<RelativeLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
android:id="@+id/appBarLayout"
|
||||
android:layout_width="match_parent"
|
||||
|
|
@ -19,9 +24,19 @@
|
|||
android:layout_height="?attr/actionBarSize"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingStart="4dp"
|
||||
android:paddingEnd="16dp">
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/hamburgerButton"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:src="@drawable/ic_menu_hamburger"
|
||||
android:contentDescription="@string/settings"
|
||||
android:clickable="true"
|
||||
android:focusable="true" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/logoButton"
|
||||
android:layout_width="36dp"
|
||||
|
|
@ -107,7 +122,7 @@
|
|||
android:background="@android:color/transparent"
|
||||
android:hint="@string/enter_message"
|
||||
android:imeOptions="actionSend"
|
||||
android:inputType="textMultiLine"
|
||||
android:inputType="textMultiLine|textCapSentences"
|
||||
android:maxLines="5"
|
||||
android:minHeight="56dp"
|
||||
android:paddingStart="8dp"
|
||||
|
|
@ -132,4 +147,26 @@
|
|||
|
||||
</LinearLayout>
|
||||
|
||||
</RelativeLayout>
|
||||
</RelativeLayout>
|
||||
|
||||
<com.google.android.material.navigation.NavigationView
|
||||
android:id="@+id/navigationView"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="start"
|
||||
android:fitsSystemWindows="true"
|
||||
app:headerLayout="@layout/nav_header"
|
||||
app:menu="@menu/drawer_menu" />
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/rightPanelContainer"
|
||||
android:layout_width="300dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="end"
|
||||
android:background="?attr/colorSurface">
|
||||
|
||||
<include layout="@layout/panel_right" />
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
</androidx.drawerlayout.widget.DrawerLayout>
|
||||
|
|
@ -24,7 +24,7 @@
|
|||
android:id="@+id/nameInput"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textPersonName"
|
||||
android:inputType="textPersonName|textCapSentences"
|
||||
android:maxLines="1" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
|
@ -41,7 +41,7 @@
|
|||
android:id="@+id/bioInput"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textMultiLine"
|
||||
android:inputType="textMultiLine|textCapSentences"
|
||||
android:minLines="3"
|
||||
android:maxLines="5" />
|
||||
|
||||
|
|
@ -59,7 +59,7 @@
|
|||
android:id="@+id/preferencesInput"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textMultiLine"
|
||||
android:inputType="textMultiLine|textCapSentences"
|
||||
android:minLines="2"
|
||||
android:maxLines="4" />
|
||||
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@
|
|||
android:id="@+id/messageText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:maxWidth="280dp"
|
||||
android:maxWidth="350dp"
|
||||
android:padding="12dp"
|
||||
android:textSize="16sp"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
|
|
|
|||
|
|
@ -7,13 +7,33 @@
|
|||
android:padding="8dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/messageText"
|
||||
android:id="@+id/senderName"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:maxWidth="280dp"
|
||||
android:padding="12dp"
|
||||
android:textSize="16sp"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:textSize="12sp"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
app:layout_constraintEnd_toStartOf="@id/senderIcon"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/senderIcon"
|
||||
android:layout_width="20dp"
|
||||
android:layout_height="20dp"
|
||||
android:src="@drawable/ic_person"
|
||||
android:contentDescription="@string/profile"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/messageText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:maxWidth="350dp"
|
||||
android:layout_marginTop="2dp"
|
||||
android:padding="12dp"
|
||||
android:textSize="16sp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/senderIcon" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
|
@ -34,4 +34,19 @@
|
|||
<string name="api_key_current">Текущий ключ: %s</string>
|
||||
<string name="enter_api_key">Введите API ключ</string>
|
||||
<string name="api_key_required">Требуется API ключ Mistral</string>
|
||||
<string name="clear_all_history">Очистить всю историю</string>
|
||||
<string name="clear_all_confirm">Удалить все сессии и сообщения?</string>
|
||||
<string name="history_cleared">История очищена</string>
|
||||
<string name="sessions">Сессии</string>
|
||||
<string name="new_session">Новая сессия</string>
|
||||
<string name="no_sessions">Нет сессий</string>
|
||||
<string name="ok">OK</string>
|
||||
<string name="profiles">Профили</string>
|
||||
<string name="profiles_uppercase">ПРОФИЛИ</string>
|
||||
<string name="new_profile">Новый профиль</string>
|
||||
<string name="manage_profiles">Управление профилями</string>
|
||||
<string name="edit">Редактировать</string>
|
||||
<string name="selected">Выбрано</string>
|
||||
<string name="clear_all_delete_profiles">Удалить все профили</string>
|
||||
<string name="profile_info">Профиль: %s</string>
|
||||
</resources>
|
||||
Loading…
Add table
Add a link
Reference in a new issue