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.google.gson.JsonArray
|
||||||
import com.mistral.chat.data.Message
|
import com.mistral.chat.data.Message
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.Call
|
import okhttp3.Call
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import okhttp3.Response
|
||||||
import java.util.concurrent.TimeUnit
|
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()
|
private var client = createNewClient()
|
||||||
.connectTimeout(60, TimeUnit.SECONDS)
|
|
||||||
.readTimeout(120, TimeUnit.SECONDS)
|
private fun createNewClient(): OkHttpClient {
|
||||||
.writeTimeout(60, TimeUnit.SECONDS)
|
return OkHttpClient.Builder()
|
||||||
|
.connectTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(25, TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.retryOnConnectionFailure(false)
|
||||||
.build()
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
private val gson = Gson()
|
private val gson = Gson()
|
||||||
private val jsonMediaType = "application/json".toMediaType()
|
private val jsonMediaType = "application/json".toMediaType()
|
||||||
|
|
||||||
private var currentCall: Call? = null
|
private var currentCall: Call? = null
|
||||||
|
private var currentContinuation: Continuation<Result<Pair<String, String>>>? = null
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val BASE_URL = "https://api.mistral.ai/v1"
|
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(
|
val AVAILABLE_MODELS = listOf(
|
||||||
"mistral-small-latest" to "Mistral Small",
|
"mistral-small-latest" to "Mistral Small",
|
||||||
"mistral-medium-latest" to "Mistral Medium",
|
"mistral-medium-latest" to "Mistral Medium",
|
||||||
"mistral-large-latest" to "Mistral Large",
|
"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()
|
.get()
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val response = client.newCall(request).execute()
|
client.newCall(request).execute().use { response ->
|
||||||
|
|
||||||
if (!response.isSuccessful) {
|
if (!response.isSuccessful) {
|
||||||
return@withContext Result.failure(Exception("API error: ${response.code}"))
|
return@withContext Result.failure(Exception("API error: ${response.code}"))
|
||||||
}
|
}
|
||||||
|
|
@ -57,28 +81,47 @@ import java.util.concurrent.TimeUnit
|
||||||
val models = responseJson
|
val models = responseJson
|
||||||
.getAsJsonArray("data")
|
.getAsJsonArray("data")
|
||||||
?.mapNotNull { obj ->
|
?.mapNotNull { obj ->
|
||||||
|
try {
|
||||||
val jsonObj = obj.asJsonObject
|
val jsonObj = obj.asJsonObject
|
||||||
val id = jsonObj.get("id")?.asString
|
val id = jsonObj.get("id")?.asString ?: return@mapNotNull null
|
||||||
val created = jsonObj.get("created")?.asLong ?: 0L
|
if (id in SUPPORTED_MODELS) {
|
||||||
if (id != null && created > 0 && id.endsWith("-latest")) {
|
|
||||||
val displayName = id
|
val displayName = id
|
||||||
.replace("-latest", "")
|
.replace("-latest", "")
|
||||||
|
.replace("-12b-2409", "")
|
||||||
.replace("-", " ")
|
.replace("-", " ")
|
||||||
.split(" ")
|
.split(" ")
|
||||||
.joinToString(" ") { it.replaceFirstChar { c -> c.uppercase() } }
|
.joinToString(" ") { it.replaceFirstChar { c -> c.uppercase() } }
|
||||||
id to displayName
|
id to displayName
|
||||||
} else null
|
} else null
|
||||||
} ?: emptyList()
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?.distinctBy { it.first }
|
||||||
|
?: emptyList()
|
||||||
|
|
||||||
Result.success(models)
|
Result.success(models)
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
if (e is java.io.IOException && !e.message.orEmpty().contains("cancel", ignoreCase = true)) {
|
||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
|
} else {
|
||||||
|
Result.failure(Exception("Request cancelled"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cancelRequest() {
|
fun cancelRequest() {
|
||||||
currentCall?.cancel()
|
val call = currentCall
|
||||||
|
val continuation = currentContinuation
|
||||||
|
|
||||||
|
call?.cancel()
|
||||||
currentCall = null
|
currentCall = null
|
||||||
|
|
||||||
|
continuation?.resume(Result.failure(Exception("Request cancelled")))
|
||||||
|
currentContinuation = null
|
||||||
|
|
||||||
|
client = createNewClient()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun chat(
|
suspend fun chat(
|
||||||
|
|
@ -90,7 +133,7 @@ import java.util.concurrent.TimeUnit
|
||||||
val jsonObject = JsonObject()
|
val jsonObject = JsonObject()
|
||||||
jsonObject.addProperty("model", model)
|
jsonObject.addProperty("model", model)
|
||||||
jsonObject.addProperty("temperature", 0.7)
|
jsonObject.addProperty("temperature", 0.7)
|
||||||
jsonObject.addProperty("stream", onChunk != null)
|
jsonObject.addProperty("stream", false)
|
||||||
|
|
||||||
val messagesArray = JsonArray()
|
val messagesArray = JsonArray()
|
||||||
messages.forEach { msg ->
|
messages.forEach { msg ->
|
||||||
|
|
@ -112,15 +155,40 @@ import java.util.concurrent.TimeUnit
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
currentCall = client.newCall(request)
|
currentCall = client.newCall(request)
|
||||||
val response = currentCall!!.execute()
|
|
||||||
|
|
||||||
if (response.code == 0 || response.code == -1) {
|
suspendCancellableCoroutine { continuation ->
|
||||||
return@withContext Result.failure(Exception("Request cancelled"))
|
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) {
|
if (!response.isSuccessful) {
|
||||||
val errorBody = response.body?.string() ?: "Unknown error"
|
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() ?: ""
|
val responseBody = response.body?.string() ?: ""
|
||||||
|
|
@ -129,24 +197,54 @@ import java.util.concurrent.TimeUnit
|
||||||
onChunk(responseBody)
|
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")
|
val choices = responseJson.getAsJsonArray("choices")
|
||||||
if (choices == null || choices.size() == 0) {
|
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
|
val firstChoice = choices.get(0)?.asJsonObject
|
||||||
.get(0)
|
if (firstChoice == null) {
|
||||||
?.asJsonObject
|
currentCall = null
|
||||||
?.getAsJsonObject("message")
|
currentContinuation = null
|
||||||
?.get("content")
|
cont.resume(Result.failure(Exception("Empty choice")))
|
||||||
?.asString ?: ""
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val message = firstChoice.getAsJsonObject("message")
|
||||||
|
val content = message?.get("content")?.asString ?: ""
|
||||||
|
|
||||||
val usedModel = responseJson.get("model")?.asString ?: model
|
val usedModel = responseJson.get("model")?.asString ?: model
|
||||||
|
|
||||||
currentCall = null
|
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) {
|
} catch (e: Exception) {
|
||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,52 @@
|
||||||
package com.mistral.chat.data
|
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(
|
data class Message(
|
||||||
val id: String = System.currentTimeMillis().toString(),
|
val id: Long = 0,
|
||||||
|
val sessionId: Long = 0,
|
||||||
val content: String,
|
val content: String,
|
||||||
val isUser: Boolean,
|
val isUser: Boolean,
|
||||||
val timestamp: Long = System.currentTimeMillis(),
|
val timestamp: Long = System.currentTimeMillis(),
|
||||||
val senderName: String? = null
|
val senderName: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
data class ChatRequest(
|
fun MessageEntity.toMessage(): Message = Message(
|
||||||
val model: String,
|
id = id,
|
||||||
val messages: List<Message>,
|
sessionId = sessionId,
|
||||||
val temperature: Double = 0.7,
|
content = content,
|
||||||
val stream: Boolean = false
|
isUser = isUser,
|
||||||
|
timestamp = timestamp
|
||||||
)
|
)
|
||||||
|
|
||||||
data class ChatResponse(
|
fun Message.toEntity(): MessageEntity = MessageEntity(
|
||||||
val id: String,
|
id = id,
|
||||||
val choices: List<Choice>,
|
sessionId = sessionId,
|
||||||
val model: String
|
content = content,
|
||||||
)
|
isUser = isUser,
|
||||||
|
timestamp = timestamp
|
||||||
data class Choice(
|
|
||||||
val index: Int,
|
|
||||||
val message: Message
|
|
||||||
)
|
)
|
||||||
|
|
@ -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.View
|
||||||
import android.view.inputmethod.EditorInfo
|
import android.view.inputmethod.EditorInfo
|
||||||
import android.view.inputmethod.InputMethodManager
|
import android.view.inputmethod.InputMethodManager
|
||||||
|
import android.view.MenuItem
|
||||||
import android.widget.ImageButton
|
import android.widget.ImageButton
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import android.widget.PopupMenu
|
import android.widget.PopupMenu
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import android.widget.EditText
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
|
import androidx.core.view.GravityCompat
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
|
@ -23,17 +26,29 @@ import androidx.recyclerview.widget.RecyclerView
|
||||||
import androidx.security.crypto.EncryptedSharedPreferences
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
import androidx.security.crypto.MasterKey
|
import androidx.security.crypto.MasterKey
|
||||||
import com.google.android.material.color.DynamicColors
|
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.progressindicator.LinearProgressIndicator
|
||||||
import com.google.android.material.textfield.TextInputEditText
|
import com.google.android.material.textfield.TextInputEditText
|
||||||
|
import com.google.android.material.button.MaterialButton
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import com.google.gson.reflect.TypeToken
|
|
||||||
import com.mistral.chat.R
|
import com.mistral.chat.R
|
||||||
import com.mistral.chat.api.MistralClient
|
import com.mistral.chat.api.MistralClient
|
||||||
|
import com.mistral.chat.data.ChatDatabase
|
||||||
import com.mistral.chat.data.Message
|
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.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 recyclerView: RecyclerView
|
||||||
private lateinit var adapter: MessageAdapter
|
private lateinit var adapter: MessageAdapter
|
||||||
|
|
@ -43,6 +58,10 @@ class MainActivity : AppCompatActivity() {
|
||||||
private lateinit var logoButton: ImageView
|
private lateinit var logoButton: ImageView
|
||||||
private lateinit var menuButton: ImageButton
|
private lateinit var menuButton: ImageButton
|
||||||
private lateinit var toolbarTitle: TextView
|
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 lateinit var gson: Gson
|
||||||
private var currentJob: kotlinx.coroutines.Job? = null
|
private var currentJob: kotlinx.coroutines.Job? = null
|
||||||
|
|
||||||
|
|
@ -52,15 +71,26 @@ class MainActivity : AppCompatActivity() {
|
||||||
private var selectedModelName: String = "mistral-small-latest"
|
private var selectedModelName: String = "mistral-small-latest"
|
||||||
private lateinit var prefs: SharedPreferences
|
private lateinit var prefs: SharedPreferences
|
||||||
private lateinit var encryptedPrefs: 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 {
|
companion object {
|
||||||
private const val PREFS_NAME = "mistral_chat_prefs"
|
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_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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
|
@ -74,6 +104,11 @@ class MainActivity : AppCompatActivity() {
|
||||||
gson = Gson()
|
gson = Gson()
|
||||||
prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
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)
|
val masterKey = MasterKey.Builder(this)
|
||||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
.build()
|
.build()
|
||||||
|
|
@ -91,20 +126,27 @@ class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
client = MistralClient(getApiKey())
|
client = MistralClient(getApiKey())
|
||||||
|
|
||||||
loadMessages()
|
|
||||||
|
|
||||||
logoButton = findViewById(R.id.logoButton)
|
logoButton = findViewById(R.id.logoButton)
|
||||||
menuButton = findViewById(R.id.menuButton)
|
menuButton = findViewById(R.id.menuButton)
|
||||||
|
hamburgerButton = findViewById(R.id.hamburgerButton)
|
||||||
toolbarTitle = findViewById(R.id.toolbarTitle)
|
toolbarTitle = findViewById(R.id.toolbarTitle)
|
||||||
recyclerView = findViewById(R.id.recyclerView)
|
recyclerView = findViewById(R.id.recyclerView)
|
||||||
inputField = findViewById(R.id.inputField)
|
inputField = findViewById(R.id.inputField)
|
||||||
sendButton = findViewById(R.id.sendButton)
|
sendButton = findViewById(R.id.sendButton)
|
||||||
progressIndicator = findViewById(R.id.progressIndicator)
|
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()
|
setupToolbar()
|
||||||
setupRecyclerView()
|
setupRecyclerView()
|
||||||
|
setupDrawer()
|
||||||
|
setupRightPanel()
|
||||||
loadModels()
|
loadModels()
|
||||||
setupInput()
|
setupInput()
|
||||||
|
loadProfilesAndSessions()
|
||||||
|
|
||||||
inputField.postDelayed({
|
inputField.postDelayed({
|
||||||
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||||
|
|
@ -113,48 +155,409 @@ class MainActivity : AppCompatActivity() {
|
||||||
}, 300)
|
}, 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() {
|
private fun setupToolbar() {
|
||||||
|
hamburgerButton.isVisible = true
|
||||||
|
|
||||||
logoButton.setOnClickListener {
|
logoButton.setOnClickListener {
|
||||||
showModelSelectorDialog()
|
showModelSelectorDialog()
|
||||||
}
|
}
|
||||||
|
|
||||||
menuButton.setOnClickListener { view ->
|
menuButton.setOnClickListener { view ->
|
||||||
val popup = PopupMenu(this, view)
|
showRightPanelMenu(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 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() {
|
private fun showModelSelectorDialog() {
|
||||||
val modelNames = availableModels.map { it.second }.toTypedArray()
|
val uniqueModels = availableModels.distinctBy { it.second }
|
||||||
val currentModelId = availableModels.find { it.first == selectedModelName }?.second ?: modelNames.firstOrNull() ?: ""
|
val modelNames = uniqueModels.map { it.second }.toTypedArray()
|
||||||
val currentIndex = modelNames.indexOf(currentModelId).coerceAtLeast(0)
|
val currentModelId = uniqueModels.find { it.first == selectedModelName }?.second
|
||||||
|
?: uniqueModels.firstOrNull()?.second
|
||||||
|
?: ""
|
||||||
|
val currentIndex = uniqueModels.indexOfFirst { it.second == currentModelId }.coerceAtLeast(0)
|
||||||
|
|
||||||
AlertDialog.Builder(this)
|
AlertDialog.Builder(this)
|
||||||
.setTitle(R.string.select_model)
|
.setTitle(R.string.select_model)
|
||||||
.setSingleChoiceItems(modelNames, currentIndex) { dialog, which ->
|
.setSingleChoiceItems(modelNames, currentIndex) { dialog, which ->
|
||||||
selectedModelName = availableModels[which].first
|
selectedModelName = uniqueModels[which].first
|
||||||
|
prefs.edit().putString(KEY_SELECTED_MODEL, selectedModelName).apply()
|
||||||
dialog.dismiss()
|
dialog.dismiss()
|
||||||
}
|
}
|
||||||
.setNegativeButton(R.string.cancel, null)
|
.setNegativeButton(R.string.cancel, null)
|
||||||
|
|
@ -192,17 +595,96 @@ class MainActivity : AppCompatActivity() {
|
||||||
val userInput = inputField.text?.toString()?.trim()
|
val userInput = inputField.text?.toString()?.trim()
|
||||||
if (userInput.isNullOrEmpty()) return
|
if (userInput.isNullOrEmpty()) return
|
||||||
|
|
||||||
addMessage(Message(content = userInput, isUser = true))
|
if (currentSessionId == null) {
|
||||||
inputField.text?.clear()
|
createNewSessionAndSend(userInput)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
addMessage(Message(content = userInput, isUser = true, senderName = getCurrentProfileName()))
|
||||||
|
|
||||||
|
inputField.text?.clear()
|
||||||
sendMessage(userInput)
|
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() {
|
private fun loadModels() {
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
val result = client?.getModels()
|
val result = client?.getModels()
|
||||||
result?.onSuccess { models ->
|
result?.onSuccess { models ->
|
||||||
availableModels = models
|
availableModels = models
|
||||||
|
val hasUserSelectedModel = prefs.contains(KEY_SELECTED_MODEL)
|
||||||
|
if (!hasUserSelectedModel) {
|
||||||
runOnUiThread {
|
runOnUiThread {
|
||||||
val codestralIndex = models.indexOfFirst { it.first.contains("codestral", ignoreCase = true) }
|
val codestralIndex = models.indexOfFirst { it.first.contains("codestral", ignoreCase = true) }
|
||||||
if (codestralIndex >= 0) {
|
if (codestralIndex >= 0) {
|
||||||
|
|
@ -211,44 +693,71 @@ class MainActivity : AppCompatActivity() {
|
||||||
selectedModelName = models[0].first
|
selectedModelName = models[0].first
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}?.onFailure {
|
}?.onFailure {
|
||||||
availableModels = MistralClient.AVAILABLE_MODELS
|
availableModels = MistralClient.AVAILABLE_MODELS
|
||||||
|
val hasUserSelectedModel = prefs.contains(KEY_SELECTED_MODEL)
|
||||||
|
if (!hasUserSelectedModel) {
|
||||||
runOnUiThread {
|
runOnUiThread {
|
||||||
selectedModelName = MistralClient.AVAILABLE_MODELS.firstOrNull()?.first ?: "mistral-small-latest"
|
selectedModelName = MistralClient.AVAILABLE_MODELS.firstOrNull()?.first ?: "mistral-small-latest"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun addMessage(message: Message) {
|
private fun addMessage(message: Message) {
|
||||||
|
val isAssistantMessage = !message.isUser
|
||||||
|
val newPosition = messages.size - 1
|
||||||
|
|
||||||
messages.add(message)
|
messages.add(message)
|
||||||
adapter.notifyItemInserted(messages.size - 1)
|
adapter.notifyItemInserted(newPosition)
|
||||||
saveMessages()
|
|
||||||
|
// 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.postDelayed({
|
||||||
recyclerView.scrollToPosition(messages.size - 1)
|
recyclerView.scrollToPosition(messages.size - 1)
|
||||||
}, 100)
|
}, 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadMessages() {
|
private fun saveMessageToDatabase(sessionId: Long?, content: String, isUser: Boolean, senderName: String?) {
|
||||||
val json = prefs.getString(KEY_MESSAGES, null)
|
if (sessionId != null) {
|
||||||
if (json != null) {
|
lifecycleScope.launch {
|
||||||
try {
|
database.messageDao().insert(MessageEntity(
|
||||||
val type = object : TypeToken<List<Message>>() {}.type
|
sessionId = sessionId,
|
||||||
val loaded: List<Message> = gson.fromJson(json, type)
|
content = content,
|
||||||
messages.clear()
|
isUser = isUser,
|
||||||
messages.addAll(loaded)
|
timestamp = System.currentTimeMillis()
|
||||||
} catch (e: Exception) {
|
))
|
||||||
// Ignore parse errors
|
database.sessionDao().updateTimestamp(sessionId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun saveMessages() {
|
|
||||||
val json = gson.toJson(messages)
|
|
||||||
prefs.edit().putString(KEY_MESSAGES, json).apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getApiKey(): String {
|
private fun getApiKey(): String {
|
||||||
return encryptedPrefs.getString(KEY_API_KEY, null) ?: ""
|
return encryptedPrefs.getString(KEY_API_KEY, null) ?: ""
|
||||||
}
|
}
|
||||||
|
|
@ -320,11 +829,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
currentJob = lifecycleScope.launch {
|
currentJob = lifecycleScope.launch {
|
||||||
try {
|
try {
|
||||||
val userProfile = loadUserProfile()
|
val profileContext = getSelectedProfileContext()
|
||||||
|
|
||||||
val profileContext = if (!userProfile.isEmpty()) {
|
|
||||||
userProfile.toContextString()
|
|
||||||
} else ""
|
|
||||||
|
|
||||||
val apiMessages = messages.map { msg ->
|
val apiMessages = messages.map { msg ->
|
||||||
Message(
|
Message(
|
||||||
|
|
@ -337,18 +842,46 @@ class MainActivity : AppCompatActivity() {
|
||||||
apiMessages.add(0, Message(content = profileContext, isUser = true))
|
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) ->
|
if (!isActive) return@launch
|
||||||
addMessage(Message(content = response, isUser = false, senderName = usedModel))
|
|
||||||
}?.onFailure { error ->
|
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"
|
val errorMessage = error.message ?: "Unknown error"
|
||||||
|
if (!errorMessage.contains("cancelled", ignoreCase = true)) {
|
||||||
val userFriendlyMessage = getUserFriendlyError(errorMessage)
|
val userFriendlyMessage = getUserFriendlyError(errorMessage)
|
||||||
addMessage(Message(content = userFriendlyMessage, isUser = false, senderName = "Error"))
|
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) {
|
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||||
addMessage(Message(content = "❌ Отменено пользователем", isUser = false, senderName = "Cancelled"))
|
if (!isActive) return@launch
|
||||||
} finally {
|
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.isEnabled = true
|
||||||
sendButton.setImageResource(R.drawable.ic_mistral_logo)
|
sendButton.setImageResource(R.drawable.ic_mistral_logo)
|
||||||
progressIndicator.isVisible = false
|
progressIndicator.isVisible = false
|
||||||
|
|
@ -358,62 +891,92 @@ class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
private fun cancelRequest() {
|
private fun cancelRequest() {
|
||||||
currentJob?.cancel()
|
currentJob?.cancel()
|
||||||
|
titleGenerationJob?.cancel()
|
||||||
client?.cancelRequest()
|
client?.cancelRequest()
|
||||||
|
|
||||||
|
sendButton.isEnabled = true
|
||||||
|
sendButton.setImageResource(R.drawable.ic_mistral_logo)
|
||||||
|
progressIndicator.isVisible = false
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadUserProfile(): UserProfile {
|
private fun getSelectedProfileContext(): String {
|
||||||
return UserProfile(
|
if (currentProfileId == null) return ""
|
||||||
name = prefs.getString(KEY_USER_NAME, "") ?: "",
|
|
||||||
bio = prefs.getString(KEY_USER_BIO, "") ?: "",
|
val profile = profiles.find { it.id == currentProfileId }
|
||||||
preferences = prefs.getString(KEY_USER_PREFS, "") ?: ""
|
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) {
|
private fun getCurrentProfileName(): String {
|
||||||
prefs.edit()
|
if (currentProfileId == null) return "Вы"
|
||||||
.putString(KEY_USER_NAME, profile.name)
|
val profileName = profiles.find { it.id == currentProfileId }?.name
|
||||||
.putString(KEY_USER_BIO, profile.bio)
|
return if (profileName.isNullOrBlank()) "Вы" else profileName
|
||||||
.putString(KEY_USER_PREFS, profile.preferences)
|
|
||||||
.remove(KEY_PROFILE_HASH)
|
|
||||||
.apply()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun deleteUserProfile() {
|
private fun showProfileDialog(existingProfile: Profile? = null) {
|
||||||
prefs.edit()
|
if (existingProfile == null && profiles.size >= MAX_PROFILES) {
|
||||||
.remove(KEY_USER_NAME)
|
Toast.makeText(this, "Максимум $MAX_PROFILES профилей", Toast.LENGTH_SHORT).show()
|
||||||
.remove(KEY_USER_BIO)
|
return
|
||||||
.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 dialogView = layoutInflater.inflate(R.layout.dialog_profile, null)
|
||||||
|
|
||||||
val nameInput = dialogView.findViewById<TextInputEditText>(R.id.nameInput)
|
val nameInput = dialogView.findViewById<TextInputEditText>(R.id.nameInput)
|
||||||
val bioInput = dialogView.findViewById<TextInputEditText>(R.id.bioInput)
|
val bioInput = dialogView.findViewById<TextInputEditText>(R.id.bioInput)
|
||||||
val preferencesInput = dialogView.findViewById<TextInputEditText>(R.id.preferencesInput)
|
val preferencesInput = dialogView.findViewById<TextInputEditText>(R.id.preferencesInput)
|
||||||
|
|
||||||
nameInput.setText(profile.name)
|
existingProfile?.let {
|
||||||
bioInput.setText(profile.bio)
|
nameInput.setText(it.name)
|
||||||
preferencesInput.setText(profile.preferences)
|
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)
|
.setView(dialogView)
|
||||||
.setPositiveButton(R.string.save) { _, _ ->
|
.setPositiveButton(R.string.save) { _, _ ->
|
||||||
val newProfile = UserProfile(
|
val name = nameInput.text?.toString()?.trim() ?: ""
|
||||||
name = nameInput.text?.toString() ?: "",
|
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() ?: "",
|
bio = bioInput.text?.toString() ?: "",
|
||||||
preferences = preferencesInput.text?.toString() ?: ""
|
preferences = preferencesInput.text?.toString() ?: ""
|
||||||
)
|
))
|
||||||
saveUserProfile(newProfile)
|
if (currentProfileId == null) {
|
||||||
|
currentProfileId = newId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.setNegativeButton(R.string.cancel, null)
|
.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() {
|
private fun showClearChatDialog() {
|
||||||
|
|
@ -422,19 +985,11 @@ class MainActivity : AppCompatActivity() {
|
||||||
.setPositiveButton(R.string.yes) { _, _ ->
|
.setPositiveButton(R.string.yes) { _, _ ->
|
||||||
messages.clear()
|
messages.clear()
|
||||||
adapter.notifyDataSetChanged()
|
adapter.notifyDataSetChanged()
|
||||||
prefs.edit().remove(KEY_MESSAGES).apply()
|
|
||||||
}
|
}
|
||||||
.setNegativeButton(R.string.no, null)
|
.setNegativeButton(R.string.no, null)
|
||||||
.show()
|
.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 {
|
private fun getUserFriendlyError(error: String): String {
|
||||||
return when {
|
return when {
|
||||||
error.contains("timeout", ignoreCase = true) ||
|
error.contains("timeout", ignoreCase = true) ||
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import android.content.Context
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import android.widget.ImageView
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.core.content.ContextCompat
|
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) {
|
class UserMessageHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||||
fun bind(message: Message) {
|
fun bind(message: Message) {
|
||||||
val textView = itemView.findViewById<TextView>(R.id.messageText)
|
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.text = message.content
|
||||||
textView.setBackgroundResource(R.drawable.bg_message_user)
|
textView.setBackgroundResource(R.drawable.bg_message_user)
|
||||||
textView.setTextColor(ContextCompat.getColor(itemView.context, R.color.user_message_text))
|
textView.setTextColor(ContextCompat.getColor(itemView.context, R.color.user_message_text))
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,18 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<RelativeLayout
|
<androidx.drawerlayout.widget.DrawerLayout
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:id="@+id/drawerLayout"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:fitsSystemWindows="true"
|
android:fitsSystemWindows="true"
|
||||||
tools:context=".ui.MainActivity">
|
tools:context=".ui.MainActivity">
|
||||||
|
|
||||||
|
<RelativeLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
<com.google.android.material.appbar.AppBarLayout
|
<com.google.android.material.appbar.AppBarLayout
|
||||||
android:id="@+id/appBarLayout"
|
android:id="@+id/appBarLayout"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
|
@ -19,9 +24,19 @@
|
||||||
android:layout_height="?attr/actionBarSize"
|
android:layout_height="?attr/actionBarSize"
|
||||||
android:orientation="horizontal"
|
android:orientation="horizontal"
|
||||||
android:gravity="center_vertical"
|
android:gravity="center_vertical"
|
||||||
android:paddingStart="16dp"
|
android:paddingStart="4dp"
|
||||||
android:paddingEnd="16dp">
|
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
|
<ImageView
|
||||||
android:id="@+id/logoButton"
|
android:id="@+id/logoButton"
|
||||||
android:layout_width="36dp"
|
android:layout_width="36dp"
|
||||||
|
|
@ -107,7 +122,7 @@
|
||||||
android:background="@android:color/transparent"
|
android:background="@android:color/transparent"
|
||||||
android:hint="@string/enter_message"
|
android:hint="@string/enter_message"
|
||||||
android:imeOptions="actionSend"
|
android:imeOptions="actionSend"
|
||||||
android:inputType="textMultiLine"
|
android:inputType="textMultiLine|textCapSentences"
|
||||||
android:maxLines="5"
|
android:maxLines="5"
|
||||||
android:minHeight="56dp"
|
android:minHeight="56dp"
|
||||||
android:paddingStart="8dp"
|
android:paddingStart="8dp"
|
||||||
|
|
@ -132,4 +147,26 @@
|
||||||
|
|
||||||
</LinearLayout>
|
</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:id="@+id/nameInput"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:inputType="textPersonName"
|
android:inputType="textPersonName|textCapSentences"
|
||||||
android:maxLines="1" />
|
android:maxLines="1" />
|
||||||
|
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
@ -41,7 +41,7 @@
|
||||||
android:id="@+id/bioInput"
|
android:id="@+id/bioInput"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:inputType="textMultiLine"
|
android:inputType="textMultiLine|textCapSentences"
|
||||||
android:minLines="3"
|
android:minLines="3"
|
||||||
android:maxLines="5" />
|
android:maxLines="5" />
|
||||||
|
|
||||||
|
|
@ -59,7 +59,7 @@
|
||||||
android:id="@+id/preferencesInput"
|
android:id="@+id/preferencesInput"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:inputType="textMultiLine"
|
android:inputType="textMultiLine|textCapSentences"
|
||||||
android:minLines="2"
|
android:minLines="2"
|
||||||
android:maxLines="4" />
|
android:maxLines="4" />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@
|
||||||
android:id="@+id/messageText"
|
android:id="@+id/messageText"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:maxWidth="280dp"
|
android:maxWidth="350dp"
|
||||||
android:padding="12dp"
|
android:padding="12dp"
|
||||||
android:textSize="16sp"
|
android:textSize="16sp"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,33 @@
|
||||||
android:padding="8dp">
|
android:padding="8dp">
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/messageText"
|
android:id="@+id/senderName"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:maxWidth="280dp"
|
android:layout_marginEnd="4dp"
|
||||||
android:padding="12dp"
|
android:textSize="12sp"
|
||||||
android:textSize="16sp"
|
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_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintTop_toTopOf="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>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
@ -34,4 +34,19 @@
|
||||||
<string name="api_key_current">Текущий ключ: %s</string>
|
<string name="api_key_current">Текущий ключ: %s</string>
|
||||||
<string name="enter_api_key">Введите API ключ</string>
|
<string name="enter_api_key">Введите API ключ</string>
|
||||||
<string name="api_key_required">Требуется API ключ Mistral</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>
|
</resources>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue