Initial commit

This commit is contained in:
Алексей Будаев 2026-04-03 22:53:33 +08:00
commit cda6eb7ce0
680 changed files with 75081 additions and 0 deletions

View file

@ -0,0 +1,141 @@
package com.mistral.chat.api
import com.google.gson.JsonObject
import com.google.gson.Gson
import com.google.gson.JsonArray
import com.mistral.chat.data.Message
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.concurrent.TimeUnit
class MistralClient(private val apiKey: String) {
private val client = OkHttpClient.Builder()
.connectTimeout(60, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.build()
private val gson = Gson()
private val jsonMediaType = "application/json".toMediaType()
companion object {
private const val BASE_URL = "https://api.mistral.ai/v1"
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",
"pixtral-large-latest" to "Pixtral Large"
)
}
suspend fun getModels(): Result<List<Pair<String, String>>> = withContext(Dispatchers.IO) {
try {
val request = Request.Builder()
.url("$BASE_URL/models")
.addHeader("Authorization", "Bearer $apiKey")
.get()
.build()
val response = client.newCall(request).execute()
if (!response.isSuccessful) {
return@withContext Result.failure(Exception("API error: ${response.code}"))
}
val responseBody = response.body?.string() ?: ""
val responseJson = gson.fromJson(responseBody, JsonObject::class.java)
val models = responseJson
.getAsJsonArray("data")
?.mapNotNull { obj ->
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 displayName = id
.replace("-latest", "")
.replace("-", " ")
.split(" ")
.joinToString(" ") { it.replaceFirstChar { c -> c.uppercase() } }
id to displayName
} else null
} ?: emptyList()
Result.success(models)
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun chat(
model: String,
messages: List<Message>,
onChunk: ((String) -> Unit)? = null
): Result<Pair<String, String>> = withContext(Dispatchers.IO) {
try {
val jsonObject = JsonObject()
jsonObject.addProperty("model", model)
jsonObject.addProperty("temperature", 0.7)
jsonObject.addProperty("stream", onChunk != null)
val messagesArray = JsonArray()
messages.forEach { msg ->
val msgObj = JsonObject()
msgObj.addProperty("role", if (msg.isUser) "user" else "assistant")
msgObj.addProperty("content", msg.content)
messagesArray.add(msgObj)
}
jsonObject.add("messages", messagesArray)
val json = gson.toJson(jsonObject)
val body = json.toRequestBody(jsonMediaType)
val request = Request.Builder()
.url("$BASE_URL/chat/completions")
.addHeader("Authorization", "Bearer $apiKey")
.addHeader("Content-Type", "application/json")
.post(body)
.build()
val response = client.newCall(request).execute()
if (!response.isSuccessful) {
val errorBody = response.body?.string() ?: "Unknown error"
return@withContext Result.failure(Exception("API error: ${response.code} - $errorBody"))
}
val responseBody = response.body?.string() ?: ""
if (onChunk != null) {
onChunk(responseBody)
}
val responseJson = gson.fromJson(responseBody, JsonObject::class.java)
val choices = responseJson.getAsJsonArray("choices")
if (choices == null || choices.size() == 0) {
return@withContext Result.failure(Exception("No response from API"))
}
val content = choices
.get(0)
?.asJsonObject
?.getAsJsonObject("message")
?.get("content")
?.asString ?: ""
val usedModel = responseJson.get("model")?.asString ?: model
Result.success(content to usedModel)
} catch (e: Exception) {
Result.failure(e)
}
}
}

View file

@ -0,0 +1,27 @@
package com.mistral.chat.data
data class Message(
val id: String = System.currentTimeMillis().toString(),
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
)
data class ChatResponse(
val id: String,
val choices: List<Choice>,
val model: String
)
data class Choice(
val index: Int,
val message: Message
)

View file

@ -0,0 +1,17 @@
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 {
if (name.isNotBlank()) append("Name: $name\n")
if (bio.isNotBlank()) append("Bio: $bio\n")
if (preferences.isNotBlank()) append("Preferences: $preferences\n")
}
}
}

View file

@ -0,0 +1,272 @@
package com.mistral.chat.ui
import android.content.Context
import android.content.SharedPreferences
import android.os.Bundle
import android.view.inputmethod.InputMethodManager
import android.widget.ArrayAdapter
import android.widget.AutoCompleteTextView
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.button.MaterialButton
import com.google.android.material.progressindicator.LinearProgressIndicator
import com.google.android.material.textfield.TextInputEditText
import com.mistral.chat.R
import com.mistral.chat.api.MistralClient
import com.mistral.chat.data.Message
import com.mistral.chat.data.UserProfile
import kotlinx.coroutines.launch
class MainActivity : AppCompatActivity() {
private lateinit var recyclerView: RecyclerView
private lateinit var adapter: MessageAdapter
private lateinit var inputField: TextInputEditText
private lateinit var sendButton: MaterialButton
private lateinit var modelSelector: AutoCompleteTextView
private lateinit var toolbar: MaterialToolbar
private lateinit var progressIndicator: LinearProgressIndicator
private var client: MistralClient? = null
private val messages = mutableListOf<Message>()
private var availableModels: List<Pair<String, String>> = emptyList()
private lateinit var prefs: SharedPreferences
companion object {
private const val API_KEY = "YW0IjDBRLuyEBcgNjVeVUFlMI6fcZYLA"
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"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
client = MistralClient(API_KEY)
toolbar = findViewById(R.id.toolbar)
recyclerView = findViewById(R.id.recyclerView)
inputField = findViewById(R.id.inputField)
sendButton = findViewById(R.id.sendButton)
modelSelector = findViewById(R.id.modelSelector)
progressIndicator = findViewById(R.id.progressIndicator)
setupToolbar()
setupRecyclerView()
loadModels()
setupInput()
inputField.requestFocus()
inputField.postDelayed({
val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.showSoftInput(inputField, InputMethodManager.SHOW_IMPLICIT)
}, 100)
}
private fun setupToolbar() {
toolbar.setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) {
R.id.action_profile -> {
showProfileDialog()
true
}
R.id.action_clear -> {
showClearChatDialog()
true
}
R.id.action_about -> {
showAboutDialog()
true
}
else -> false
}
}
}
private fun setupRecyclerView() {
adapter = MessageAdapter(messages)
recyclerView.layoutManager = LinearLayoutManager(this).apply {
stackFromEnd = true
}
recyclerView.adapter = adapter
}
private fun setupInput() {
sendButton.setOnClickListener {
val userInput = inputField.text?.toString()?.trim()
if (userInput.isNullOrEmpty()) return@setOnClickListener
addMessage(Message(content = userInput, isUser = true))
inputField.text?.clear()
sendMessage(userInput)
}
inputField.setOnFocusChangeListener { _, hasFocus ->
if (hasFocus) {
toolbar.title = "Mistral Chat"
}
}
}
private fun loadModels() {
lifecycleScope.launch {
val result = client?.getModels()
result?.onSuccess { models ->
availableModels = models
runOnUiThread {
val modelNames = models.map { it.second }
val adapter = ArrayAdapter(this@MainActivity, android.R.layout.simple_dropdown_item_1line, modelNames)
modelSelector.setAdapter(adapter)
val codestralIndex = models.indexOfFirst { it.first.contains("codestral", ignoreCase = true) }
if (codestralIndex >= 0) {
modelSelector.setText(modelNames[codestralIndex], false)
} else if (modelNames.isNotEmpty()) {
modelSelector.setText(modelNames[0], false)
}
}
}?.onFailure {
availableModels = MistralClient.AVAILABLE_MODELS
runOnUiThread {
val modelNames = availableModels.map { it.second }
val adapter = ArrayAdapter(this@MainActivity, android.R.layout.simple_dropdown_item_1line, modelNames)
modelSelector.setAdapter(adapter)
val codestralIndex = availableModels.indexOfFirst { it.first.contains("codestral", ignoreCase = true) }
if (codestralIndex >= 0) {
modelSelector.setText(modelNames[codestralIndex], false)
} else {
modelSelector.setText(modelNames[0], false)
}
}
}
}
}
private fun addMessage(message: Message) {
messages.add(message)
adapter.notifyItemInserted(messages.size - 1)
recyclerView.scrollToPosition(messages.size - 1)
}
private fun sendMessage(userInput: String) {
val selectedModelName = modelSelector.text.toString()
val matchedModel = availableModels.find { it.second == selectedModelName }
val selectedModel = matchedModel?.first ?: "mistral-small-latest"
val userProfile = loadUserProfile()
val profileContext = if (!userProfile.isEmpty()) {
"\n[User Profile]\n${userProfile.toContextString()}\n"
} else ""
val apiMessages = messages.map { msg ->
Message(
content = msg.content,
isUser = msg.isUser
)
}.toMutableList()
if (profileContext.isNotEmpty() && apiMessages.isEmpty()) {
apiMessages.add(0, Message(content = profileContext, isUser = true))
}
sendButton.isEnabled = false
progressIndicator.isVisible = true
lifecycleScope.launch {
val result = client?.chat(selectedModel, apiMessages)
result?.onSuccess { (response, usedModel) ->
addMessage(Message(content = response, isUser = false, senderName = usedModel))
}?.onFailure { error ->
addMessage(Message(content = "Error: ${error.message}", isUser = false, senderName = "Error"))
}
sendButton.isEnabled = true
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 saveUserProfile(profile: UserProfile) {
prefs.edit()
.putString(KEY_USER_NAME, profile.name)
.putString(KEY_USER_BIO, profile.bio)
.putString(KEY_USER_PREFS, profile.preferences)
.apply()
}
private fun deleteUserProfile() {
prefs.edit()
.remove(KEY_USER_NAME)
.remove(KEY_USER_BIO)
.remove(KEY_USER_PREFS)
.apply()
}
private fun showProfileDialog() {
val profile = loadUserProfile()
val dialogView = layoutInflater.inflate(R.layout.dialog_profile, null)
val nameInput = dialogView.findViewById<TextInputEditText>(R.id.nameInput)
val bioInput = dialogView.findViewById<TextInputEditText>(R.id.bioInput)
val preferencesInput = dialogView.findViewById<TextInputEditText>(R.id.preferencesInput)
nameInput.setText(profile.name)
bioInput.setText(profile.bio)
preferencesInput.setText(profile.preferences)
AlertDialog.Builder(this)
.setView(dialogView)
.setPositiveButton(R.string.save) { _, _ ->
val newProfile = UserProfile(
name = nameInput.text?.toString() ?: "",
bio = bioInput.text?.toString() ?: "",
preferences = preferencesInput.text?.toString() ?: ""
)
saveUserProfile(newProfile)
toolbar.subtitle = if (newProfile.name.isNotBlank()) newProfile.name else null
}
.setNegativeButton(R.string.cancel, null)
.setNeutralButton(R.string.delete) { _, _ ->
deleteUserProfile()
toolbar.subtitle = null
}
.show()
}
private fun showClearChatDialog() {
AlertDialog.Builder(this)
.setMessage(R.string.clear_chat_confirm)
.setPositiveButton(R.string.yes) { _, _ ->
messages.clear()
adapter.notifyDataSetChanged()
}
.setNegativeButton(R.string.no, null)
.show()
}
private fun showAboutDialog() {
AlertDialog.Builder(this)
.setMessage(R.string.about_text)
.setPositiveButton(android.R.string.ok, null)
.show()
}
}

View file

@ -0,0 +1,80 @@
package com.mistral.chat.ui
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import android.widget.Toast
import androidx.recyclerview.widget.RecyclerView
import com.mistral.chat.R
import com.mistral.chat.data.Message
class MessageAdapter(private val messages: List<Message>) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
override fun getItemViewType(position: Int): Int {
return if (messages[position].isUser) 0 else 1
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return if (viewType == 0) {
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_message_user, parent, false)
UserMessageHolder(view)
} else {
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_message_assistant, parent, false)
AssistantMessageHolder(view)
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val message = messages[position]
when (holder) {
is UserMessageHolder -> holder.bind(message)
is AssistantMessageHolder -> holder.bind(message)
}
}
override fun getItemCount(): Int = messages.size
class UserMessageHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
fun bind(message: Message) {
val textView = itemView.findViewById<TextView>(R.id.messageText)
textView.text = message.content
textView.setBackgroundResource(R.drawable.bg_message_user)
textView.setTextColor(0xFFFFFFFF.toInt())
itemView.setOnLongClickListener {
copyToClipboard(itemView.context, message.content)
true
}
}
}
class AssistantMessageHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
fun bind(message: Message) {
val senderNameView = itemView.findViewById<TextView>(R.id.senderName)
val textView = itemView.findViewById<TextView>(R.id.messageText)
senderNameView.text = message.senderName ?: "Assistant"
textView.text = message.content
textView.setBackgroundResource(R.drawable.bg_message_assistant)
textView.setTextColor(0xFF000000.toInt())
itemView.setOnLongClickListener {
copyToClipboard(itemView.context, message.content)
true
}
}
}
companion object {
fun copyToClipboard(context: Context, text: String) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("Message", text)
clipboard.setPrimaryClip(clip)
Toast.makeText(context, "Copied to clipboard", Toast.LENGTH_SHORT).show()
}
}
}