Skip to content

Commit 8559b03

Browse files
Merge pull request #56 from Android-PowerUser/feature-mistral-large-3
Fix System Message crash
2 parents 121c099 + c7aa60e commit 8559b03

8 files changed

Lines changed: 767 additions & 28 deletions

File tree

app/src/main/kotlin/com/google/ai/sample/ApiKeyDialog.kt

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
package com.google.ai.sample
22

3+
import android.content.Context
4+
import android.content.ClipData
5+
import android.content.ClipboardManager
36
import android.content.Intent
47
import android.net.Uri
8+
import android.widget.Toast
59
import androidx.compose.foundation.layout.*
610
import androidx.compose.foundation.lazy.LazyColumn
711
import androidx.compose.foundation.lazy.itemsIndexed
12+
import androidx.compose.foundation.rememberScrollState
13+
import androidx.compose.foundation.horizontalScroll
814
import androidx.compose.material3.*
915
import androidx.compose.runtime.*
1016
import androidx.compose.ui.Alignment
@@ -42,6 +48,7 @@ fun ApiKeyDialog(
4248
loadKeysForProvider(ApiProvider.VERCEL)
4349
loadKeysForProvider(ApiProvider.GOOGLE)
4450
loadKeysForProvider(ApiProvider.CEREBRAS)
51+
loadKeysForProvider(ApiProvider.MISTRAL)
4552
}
4653

4754
Dialog(onDismissRequest = {
@@ -66,8 +73,13 @@ fun ApiKeyDialog(
6673
)
6774

6875
// Provider selection
69-
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
70-
listOf(ApiProvider.VERCEL, ApiProvider.CEREBRAS, ApiProvider.GOOGLE).forEach { provider ->
76+
Row(
77+
Modifier
78+
.fillMaxWidth()
79+
.horizontalScroll(rememberScrollState()),
80+
horizontalArrangement = Arrangement.spacedBy(8.dp)
81+
) {
82+
listOf(ApiProvider.VERCEL, ApiProvider.CEREBRAS, ApiProvider.GOOGLE, ApiProvider.MISTRAL, ApiProvider.PUTER).forEach { provider ->
7183
FilterChip(
7284
selected = selectedProvider == provider,
7385
onClick = {
@@ -88,16 +100,32 @@ fun ApiKeyDialog(
88100
ApiProvider.GOOGLE -> "https://makersuite.google.com/app/apikey"
89101
ApiProvider.CEREBRAS -> "https://cloud.cerebras.ai/"
90102
ApiProvider.VERCEL -> "https://vercel.com/ai-gateway"
103+
ApiProvider.MISTRAL -> "https://console.mistral.ai/home?profile_dialog=api-keys"
104+
ApiProvider.PUTER -> "https://puter.com/dashboard#account"
91105
ApiProvider.HUMAN_EXPERT -> return@Button
92106
}
107+
108+
if (selectedProvider == ApiProvider.PUTER) {
109+
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
110+
val clip = ClipData.newPlainText("Puter Link", url)
111+
clipboard.setPrimaryClip(clip)
112+
Toast.makeText(context, "Link is in the clipboard.", Toast.LENGTH_SHORT).show()
113+
Toast.makeText(context, "After the sign up paste the link in the Browser", Toast.LENGTH_LONG).show()
114+
}
115+
93116
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
94117
context.startActivity(intent)
95118
},
96119
modifier = Modifier
97120
.fillMaxWidth()
98121
.padding(bottom = 16.dp)
99122
) {
100-
Text("Get API Key for ${selectedProvider.name.replaceFirstChar { it.uppercase() }}")
123+
val buttonText = if (selectedProvider == ApiProvider.PUTER) {
124+
"Get Auth Token for Puter"
125+
} else {
126+
"Get API Key for ${selectedProvider.name.replaceFirstChar { it.uppercase() }}"
127+
}
128+
Text(buttonText)
101129
}
102130

103131
// Input and Add section
@@ -108,7 +136,14 @@ fun ApiKeyDialog(
108136
apiKeyInput = it
109137
errorMessage = ""
110138
},
111-
label = { Text("Enter ${selectedProvider.name.replaceFirstChar { it.uppercase() }} API Key") },
139+
label = {
140+
val labelText = if (selectedProvider == ApiProvider.PUTER) {
141+
"Enter PUTER Auth Token"
142+
} else {
143+
"Enter ${selectedProvider.name.replaceFirstChar { it.uppercase() }} API Key"
144+
}
145+
Text(labelText)
146+
},
112147
modifier = Modifier.weight(1f),
113148
singleLine = true
114149
)

app/src/main/kotlin/com/google/ai/sample/GenerativeAiViewModelFactory.kt

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ enum class ApiProvider {
1616
VERCEL,
1717
GOOGLE,
1818
CEREBRAS,
19+
MISTRAL,
20+
PUTER,
1921
HUMAN_EXPERT
2022
}
2123

@@ -24,20 +26,23 @@ enum class ModelOption(
2426
val modelName: String,
2527
val apiProvider: ApiProvider = ApiProvider.GOOGLE,
2628
val downloadUrl: String? = null,
27-
val size: String? = null
29+
val size: String? = null,
30+
val supportsScreenshot: Boolean = true
2831
) {
32+
PUTER_GLM5("GLM-5 (Puter)", "z-ai/glm-5", ApiProvider.PUTER, supportsScreenshot = false),
33+
MISTRAL_LARGE_3("Mistral Large 3", "mistral-large-latest", ApiProvider.MISTRAL),
2934
GPT_5_1_CODEX_MAX("GPT-5.1 Codex Max (Vercel)", "openai/gpt-5.1-codex-max", ApiProvider.VERCEL),
3035
GPT_5_1_CODEX_MINI("GPT-5.1 Codex Mini (Vercel)", "openai/gpt-5.1-codex-mini", ApiProvider.VERCEL),
3136
GPT_5_NANO("GPT-5 Nano (Vercel)", "openai/gpt-5-nano", ApiProvider.VERCEL),
32-
GPT_OSS_120B("GPT-OSS 120B (Cerebras)", "gpt-oss-120b", ApiProvider.CEREBRAS),
37+
GPT_OSS_120B("GPT-OSS 120B (Cerebras)", "gpt-oss-120b", ApiProvider.CEREBRAS, supportsScreenshot = false),
3338
GEMINI_3_FLASH("Gemini 3 Flash", "gemini-3-flash-preview"),
3439
GEMINI_PRO("Gemini 2.5 Pro", "gemini-2.5-pro"),
3540
GEMINI_FLASH_PREVIEW("Gemini 2.5 Flash", "gemini-2.5-flash"),
3641
GEMINI_FLASH_LIVE_PREVIEW("Gemini 2.5 Flash Live Preview", "gemini-live-2.5-flash-native-audio"),
3742
GEMINI_FLASH_LITE_PREVIEW("Gemini 2.5 Flash Lite Preview", "gemini-2.5-flash-lite-preview-06-17"),
3843
GEMINI_FLASH("Gemini 2.0 Flash", "gemini-2.0-flash"),
3944
GEMINI_FLASH_LITE("Gemini 2.0 Flash Lite", "gemini-2.0-flash-lite"),
40-
GEMMA_3_27B_IT("Gemma 3 27B IT", "gemma-3-27b-it"),
45+
GEMMA_3_27B_IT("Gemma 3 27B IT", "gemma-3-27b-it", supportsScreenshot = false),
4146
GEMMA_3N_E4B_IT(
4247
"Gemma 3n E4B it (offline)",
4348
"gemma-3n-e4b-it",
@@ -134,7 +139,7 @@ enum class InferenceBackend {
134139
}
135140

136141
object GenerativeAiViewModelFactory {
137-
private var currentModel: ModelOption = ModelOption.GPT_5_1_CODEX_MAX
142+
private var currentModel: ModelOption = ModelOption.MISTRAL_LARGE_3
138143
private var currentBackend: InferenceBackend = InferenceBackend.GPU
139144

140145
fun setModel(modelOption: ModelOption, context: Context? = null) {
@@ -171,11 +176,11 @@ object GenerativeAiViewModelFactory {
171176

172177
fun loadModelPreference(context: Context) {
173178
val prefs = context.getSharedPreferences("inference_prefs", Context.MODE_PRIVATE)
174-
val modelNameStr = prefs.getString("selected_model", ModelOption.GPT_5_1_CODEX_MAX.name)
179+
val modelNameStr = prefs.getString("selected_model", ModelOption.MISTRAL_LARGE_3.name)
175180
currentModel = try {
176-
ModelOption.valueOf(modelNameStr ?: ModelOption.GPT_5_1_CODEX_MAX.name)
181+
ModelOption.valueOf(modelNameStr ?: ModelOption.MISTRAL_LARGE_3.name)
177182
} catch (e: IllegalArgumentException) {
178-
ModelOption.GPT_5_1_CODEX_MAX
183+
ModelOption.MISTRAL_LARGE_3
179184
}
180185
}
181186
}

app/src/main/kotlin/com/google/ai/sample/MenuScreen.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -581,10 +581,15 @@ fun MenuScreen(
581581
append("")
582582
withStyle(boldStyle) { append("Preview Models") }
583583
append(" could be deactivated by Google without being handed over to the final release.\n")
584+
append("")
585+
withStyle(boldStyle) { append("API Keys") }
586+
append(" are automatically switched if multiple are inserted and one is exhausted.\n")
587+
584588
append("")
585589
withStyle(boldStyle) { append("GPT-oss 120b") }
586590
append(" is a pure text model.\n")
587591
append("")
592+
588593
withStyle(boldStyle) { append("Gemma 27B IT") }
589594
append(" cannot handle screenshots in the API.\n")
590595
append("• GPT models (")

app/src/main/kotlin/com/google/ai/sample/ScreenCaptureService.kt

Lines changed: 196 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,14 @@ class ScreenCaptureService : Service() {
281281
val result = callVercelApi(modelName, apiKey, chatHistory, inputContent)
282282
responseText = result.first
283283
errorMessage = result.second
284+
} else if (apiProvider == ApiProvider.MISTRAL) {
285+
val result = callMistralApi(modelName, apiKey, chatHistory, inputContent)
286+
responseText = result.first
287+
errorMessage = result.second
288+
} else if (apiProvider == ApiProvider.PUTER) {
289+
val result = callPuterApi(modelName, apiKey, chatHistory, inputContent)
290+
responseText = result.first
291+
errorMessage = result.second
284292
} else {
285293
val generativeModel = GenerativeModel(
286294
modelName = modelName,
@@ -781,13 +789,20 @@ private suspend fun callVercelApi(modelName: String, apiKey: String, chatHistory
781789
ignoreUnknownKeys = true
782790
}
783791

792+
val currentModelOption = com.google.ai.sample.ModelOption.values().find { it.modelName == modelName }
793+
val supportsScreenshot = currentModelOption?.supportsScreenshot ?: true
794+
784795
try {
785796
val messages = (chatHistory + inputContent).map { content ->
786-
val parts = content.parts.map { part ->
797+
val parts = content.parts.mapNotNull { part ->
787798
when (part) {
788799
is TextPart -> VercelTextContent(content = part.text)
789-
is ImagePart -> VercelImageContent(content = VercelImageUrl(url = part.image.toBase64()))
790-
else -> VercelTextContent(content = "") // Or handle other part types appropriately
800+
is ImagePart -> {
801+
if (supportsScreenshot) {
802+
VercelImageContent(content = VercelImageUrl(url = part.image.toBase64()))
803+
} else null
804+
}
805+
else -> null
791806
}
792807
}
793808
VercelMessage(role = if (content.role == "user") "user" else "assistant", content = parts)
@@ -829,3 +844,181 @@ private suspend fun callVercelApi(modelName: String, apiKey: String, chatHistory
829844

830845
return Pair(responseText, errorMessage)
831846
}
847+
848+
// Data classes for Mistral API in Service
849+
@Serializable
850+
data class ServiceMistralRequest(
851+
val model: String,
852+
val messages: List<ServiceMistralMessage>,
853+
val max_tokens: Int = 4096,
854+
val temperature: Double = 0.7,
855+
val top_p: Double = 1.0,
856+
val stream: Boolean = false
857+
)
858+
859+
@Serializable
860+
data class ServiceMistralMessage(
861+
val role: String,
862+
val content: List<ServiceMistralContent>
863+
)
864+
865+
@Serializable
866+
@JsonClassDiscriminator("type")
867+
sealed class ServiceMistralContent
868+
869+
@Serializable
870+
@SerialName("text")
871+
data class ServiceMistralTextContent(@SerialName("text") val text: String) : ServiceMistralContent()
872+
873+
@Serializable
874+
@SerialName("image_url")
875+
data class ServiceMistralImageContent(@SerialName("image_url") val imageUrl: ServiceMistralImageUrl) : ServiceMistralContent()
876+
877+
@Serializable
878+
data class ServiceMistralImageUrl(val url: String)
879+
880+
@Serializable
881+
data class ServiceMistralResponse(
882+
val choices: List<ServiceMistralChoice>
883+
)
884+
885+
@Serializable
886+
data class ServiceMistralChoice(
887+
val message: ServiceMistralResponseMessage
888+
)
889+
890+
@Serializable
891+
data class ServiceMistralResponseMessage(
892+
val role: String,
893+
val content: String
894+
)
895+
896+
private suspend fun callMistralApi(modelName: String, apiKey: String, chatHistory: List<Content>, inputContent: Content): Pair<String?, String?> {
897+
var responseText: String? = null
898+
var errorMessage: String? = null
899+
900+
val json = Json {
901+
serializersModule = SerializersModule {
902+
polymorphic(ServiceMistralContent::class) {
903+
subclass(ServiceMistralTextContent::class, ServiceMistralTextContent.serializer())
904+
subclass(ServiceMistralImageContent::class, ServiceMistralImageContent.serializer())
905+
}
906+
}
907+
ignoreUnknownKeys = true
908+
}
909+
910+
val currentModelOption = com.google.ai.sample.ModelOption.values().find { it.modelName == modelName }
911+
val supportsScreenshot = currentModelOption?.supportsScreenshot ?: true
912+
913+
try {
914+
val apiMessages = mutableListOf<ServiceMistralMessage>()
915+
916+
// Combine history and input, but handle system role if needed
917+
(chatHistory + inputContent).forEach { content ->
918+
val parts = content.parts.mapNotNull { part ->
919+
when (part) {
920+
is TextPart -> if (part.text.isNotBlank()) ServiceMistralTextContent(text = part.text) else null
921+
is ImagePart -> {
922+
if (supportsScreenshot) {
923+
ServiceMistralImageContent(imageUrl = ServiceMistralImageUrl(url = part.image.toBase64()))
924+
} else null
925+
}
926+
else -> null
927+
}
928+
}
929+
if (parts.isNotEmpty()) {
930+
val role = when (content.role) {
931+
"user" -> "user"
932+
"system" -> "system"
933+
else -> "assistant"
934+
}
935+
apiMessages.add(ServiceMistralMessage(role = role, content = parts))
936+
}
937+
}
938+
939+
val requestBody = ServiceMistralRequest(
940+
model = modelName,
941+
messages = apiMessages
942+
)
943+
944+
val client = OkHttpClient()
945+
val mediaType = "application/json".toMediaType()
946+
val jsonBody = json.encodeToString(ServiceMistralRequest.serializer(), requestBody)
947+
948+
val request = Request.Builder()
949+
.url("https://api.mistral.ai/v1/chat/completions")
950+
.post(jsonBody.toRequestBody(mediaType))
951+
.addHeader("Content-Type", "application/json")
952+
.addHeader("Authorization", "Bearer $apiKey")
953+
.build()
954+
955+
client.newCall(request).execute().use { response ->
956+
val responseBody = response.body?.string()
957+
if (!response.isSuccessful) {
958+
Log.e("ScreenCaptureService", "Mistral API Error ($response.code): $responseBody")
959+
errorMessage = "Mistral Error ${response.code}: $responseBody"
960+
} else {
961+
if (responseBody != null) {
962+
val mistralResponse = json.decodeFromString(ServiceMistralResponse.serializer(), responseBody)
963+
responseText = mistralResponse.choices.firstOrNull()?.message?.content ?: "No response from model"
964+
} else {
965+
errorMessage = "Empty response body from Mistral"
966+
}
967+
}
968+
}
969+
} catch (e: Exception) {
970+
errorMessage = e.localizedMessage ?: "Mistral API call failed"
971+
Log.e("ScreenCaptureService", "Mistral API failure", e)
972+
}
973+
974+
return Pair(responseText, errorMessage)
975+
}
976+
977+
private suspend fun callPuterApi(modelName: String, apiKey: String, chatHistory: List<Content>, inputContent: Content): Pair<String?, String?> {
978+
var responseText: String? = null
979+
var errorMessage: String? = null
980+
981+
val currentModelOption = com.google.ai.sample.ModelOption.values().find { it.modelName == modelName }
982+
val supportsScreenshot = currentModelOption?.supportsScreenshot ?: true
983+
984+
try {
985+
val apiMessages = mutableListOf<com.google.ai.sample.network.PuterMessage>()
986+
987+
// Combine history and input, but handle system role if needed
988+
(chatHistory + inputContent).forEach { content ->
989+
val parts = content.parts.mapNotNull { part ->
990+
when (part) {
991+
is TextPart -> if (part.text.isNotBlank()) com.google.ai.sample.network.PuterTextContent(text = part.text) else null
992+
is ImagePart -> {
993+
if (supportsScreenshot) {
994+
val base64Uri = com.google.ai.sample.network.PuterApiClient.bitmapToBase64DataUri(part.image)
995+
com.google.ai.sample.network.PuterImageContent(image_url = com.google.ai.sample.network.PuterImageUrl(url = base64Uri))
996+
} else null
997+
}
998+
else -> null
999+
}
1000+
}
1001+
if (parts.isNotEmpty()) {
1002+
val role = when (content.role) {
1003+
"user" -> "user"
1004+
"system" -> "system"
1005+
else -> "assistant"
1006+
}
1007+
apiMessages.add(com.google.ai.sample.network.PuterMessage(role = role, content = parts))
1008+
}
1009+
}
1010+
1011+
val requestBody = com.google.ai.sample.network.PuterRequest(
1012+
model = modelName,
1013+
messages = apiMessages
1014+
)
1015+
1016+
responseText = com.google.ai.sample.network.PuterApiClient.call(apiKey, requestBody)
1017+
1018+
} catch (e: Exception) {
1019+
errorMessage = e.localizedMessage ?: "Puter API call failed"
1020+
Log.e("ScreenCaptureService", "Puter API failure", e)
1021+
}
1022+
1023+
return Pair(responseText, errorMessage)
1024+
}

0 commit comments

Comments
 (0)