Initial commit
This commit is contained in:
commit
cda6eb7ce0
680 changed files with 75081 additions and 0 deletions
141
app/src/main/java/com/mistral/chat/api/MistralClient.kt
Normal file
141
app/src/main/java/com/mistral/chat/api/MistralClient.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
27
app/src/main/java/com/mistral/chat/data/Message.kt
Normal file
27
app/src/main/java/com/mistral/chat/data/Message.kt
Normal 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
|
||||
)
|
||||
17
app/src/main/java/com/mistral/chat/data/UserProfile.kt
Normal file
17
app/src/main/java/com/mistral/chat/data/UserProfile.kt
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
272
app/src/main/java/com/mistral/chat/ui/MainActivity.kt
Normal file
272
app/src/main/java/com/mistral/chat/ui/MainActivity.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
80
app/src/main/java/com/mistral/chat/ui/MessageAdapter.kt
Normal file
80
app/src/main/java/com/mistral/chat/ui/MessageAdapter.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue