Skip to content

Commit 862c9d4

Browse files
Implement SSE streaming for Mistral, Cerebras, Puter, and Vercel
1 parent d5c57ff commit 862c9d4

2 files changed

Lines changed: 386 additions & 293 deletions

File tree

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

Lines changed: 86 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -278,9 +278,7 @@ class ScreenCaptureService : Service() {
278278
}
279279
try {
280280
if (apiProvider == ApiProvider.VERCEL) {
281-
val result = callVercelApi(modelName, apiKey, chatHistory, inputContent)
282-
responseText = result.first
283-
errorMessage = result.second
281+
responseText = callVercelApi(applicationContext, modelName, apiKey, chatHistoryDtos, inputContentDto)
284282
} else if (apiProvider == ApiProvider.MISTRAL) {
285283
val result = callMistralApi(modelName, apiKey, chatHistory, inputContent)
286284
responseText = result.first
@@ -414,6 +412,19 @@ class ScreenCaptureService : Service() {
414412
Log.d(TAG, "Broadcast error sent for AI_CALL_RESULT: $message")
415413
}
416414

415+
private fun broadcastAiResult(responseText: String? = null, errorMessage: String? = null, isError: Boolean = false) {
416+
val resultIntent = Intent(ACTION_AI_CALL_RESULT).apply {
417+
if (responseText != null && !isError) {
418+
putExtra(EXTRA_AI_RESPONSE_TEXT, responseText)
419+
}
420+
if (errorMessage != null || isError) {
421+
putExtra(EXTRA_AI_ERROR_MESSAGE, errorMessage ?: "An unknown error occurred.")
422+
}
423+
}
424+
LocalBroadcastManager.getInstance(applicationContext).sendBroadcast(resultIntent)
425+
Log.d(TAG, "Local broadcast sent for AI_CALL_RESULT. Error: $errorMessage, Response: ${responseText != null}")
426+
}
427+
417428
private fun createAiOperationNotification(): Notification {
418429
return NotificationCompat.Builder(this, CHANNEL_ID) // Reuse existing channel
419430
.setContentTitle("Screen Operator")
@@ -630,7 +641,7 @@ class ScreenCaptureService : Service() {
630641
// List existing screenshot files
631642
val screenshotFiles = picturesDir.listFiles { _, name ->
632643
name.startsWith("screenshot_") && name.endsWith(".png")
633-
}?.toMutableList() ?: mutableListOf()
644+
)?.toMutableList() ?: mutableListOf()
634645

635646
// Sort files by name (timestamp) to find the oldest
636647
screenshotFiles.sortBy { it.name }
@@ -729,120 +740,108 @@ class ScreenCaptureService : Service() {
729740
@Serializable
730741
data class VercelRequest(
731742
val model: String,
732-
val messages: List<VercelMessage>
743+
val messages: List<VercelMessage>,
744+
val stream: Boolean = true
733745
)
734746

735747
@Serializable
736-
data class VercelMessage(
737-
val role: String,
738-
val content: List<VercelContent>
748+
data class VercelStreamChunk(
749+
val choices: List<VercelStreamChoice>
739750
)
740751

741752
@Serializable
742-
data class VercelResponse(
743-
val choices: List<VercelChoice>
753+
data class VercelStreamChoice(
754+
val delta: VercelStreamDelta
744755
)
745756

746757
@Serializable
747-
data class VercelChoice(
748-
val message: VercelResponseMessage
758+
data class VercelStreamDelta(
759+
val content: String? = null
749760
)
750761

751762
@Serializable
752-
data class VercelResponseMessage(
763+
data class VercelMessage(
753764
val role: String,
754765
val content: String
755766
)
756767

757-
@Serializable
758-
@JsonClassDiscriminator("type")
759-
sealed class VercelContent
760-
761-
@Serializable
762-
@SerialName("text")
763-
data class VercelTextContent(@SerialName("text") val content: String) : VercelContent()
768+
private suspend fun callVercelApi(
769+
context: android.content.Context,
770+
modelName: String,
771+
apiKey: String,
772+
chatHistory: List<com.google.ai.sample.feature.multimodal.ContentDto>,
773+
inputContent: com.google.ai.sample.feature.multimodal.ContentDto
774+
): String {
775+
val messages = mutableListOf<VercelMessage>()
776+
777+
// Add Chat History
778+
chatHistory.forEach { contentDto ->
779+
val role = if (contentDto.role == "user") "user" else "assistant"
780+
val text = contentDto.parts.filterIsInstance<com.google.ai.sample.feature.multimodal.TextPartDto>()
781+
.joinToString("\n") { it.text }
782+
if (text.isNotBlank()) messages.add(VercelMessage(role = role, content = text))
783+
}
764784

765-
@Serializable
766-
@SerialName("image_url")
767-
data class VercelImageContent(@SerialName("image_url") val content: VercelImageUrl) : VercelContent()
785+
// Add current input
786+
val inputText = inputContent.parts.filterIsInstance<com.google.ai.sample.feature.multimodal.TextPartDto>()
787+
.joinToString("\n") { it.text }
788+
if (inputText.isNotBlank()) messages.add(VercelMessage(role = "user", content = inputText))
768789

769-
@Serializable
770-
data class VercelImageUrl(val url: String)
790+
val requestBodyJson = Json.encodeToString(VercelRequest(model = modelName, messages = messages, stream = true))
791+
val mediaType = "application/json".toMediaType()
771792

772-
private fun Bitmap.toBase64(): String {
773-
val outputStream = java.io.ByteArrayOutputStream()
774-
this.compress(Bitmap.CompressFormat.JPEG, 80, outputStream)
775-
return "data:image/jpeg;base64," + android.util.Base64.encodeToString(outputStream.toByteArray(), android.util.Base64.DEFAULT)
776-
}
793+
val httpRequest = Request.Builder()
794+
.url("https://v0-screen-operator-clon-pi.vercel.app/api/chat")
795+
.post(requestBodyJson.toRequestBody(mediaType))
796+
.addHeader("Content-Type", "application/json")
797+
.addHeader("x-api-key", apiKey)
798+
.build()
777799

778-
private suspend fun callVercelApi(modelName: String, apiKey: String, chatHistory: List<Content>, inputContent: Content): Pair<String?, String?> {
779-
var responseText: String? = null
780-
var errorMessage: String? = null
800+
val client = OkHttpClient()
801+
val response = client.newCall(httpRequest).execute()
781802

782-
val json = Json {
783-
serializersModule = SerializersModule {
784-
polymorphic(VercelContent::class) {
785-
subclass(VercelTextContent::class, VercelTextContent.serializer())
786-
subclass(VercelImageContent::class, VercelImageContent.serializer())
787-
}
788-
}
789-
ignoreUnknownKeys = true
803+
if (!response.isSuccessful) {
804+
val err = response.body?.string()
805+
response.close()
806+
throw IOException("Vercel API error ${response.code}: $err")
790807
}
791808

792-
val currentModelOption = com.google.ai.sample.ModelOption.values().find { it.modelName == modelName }
793-
val supportsScreenshot = currentModelOption?.supportsScreenshot ?: true
809+
val body = response.body ?: throw IOException("Empty response from Vercel")
810+
val reader = body.charStream().buffered()
811+
val accumulated = StringBuilder()
812+
val sseJson = Json { ignoreUnknownKeys = true }
794813

795814
try {
796-
val messages = (chatHistory + inputContent).map { content ->
797-
val parts = content.parts.mapNotNull { part ->
798-
when (part) {
799-
is TextPart -> VercelTextContent(content = part.text)
800-
is ImagePart -> {
801-
if (supportsScreenshot) {
802-
VercelImageContent(content = VercelImageUrl(url = part.image.toBase64()))
803-
} else null
815+
var line: String?
816+
while (reader.readLine().also { line = it } != null) {
817+
val l = line ?: break
818+
if (l.startsWith("data: ")) {
819+
val data = l.removePrefix("data: ").trim()
820+
if (data == "[DONE]") break
821+
if (data.isEmpty()) continue
822+
823+
try {
824+
val chunk = sseJson.decodeFromString<VercelStreamChunk>(data)
825+
val delta = chunk.choices.firstOrNull()?.delta?.content
826+
if (!delta.isNullOrEmpty()) {
827+
accumulated.append(delta)
828+
// Broadcast update to ViewModel
829+
val intent = Intent(ScreenCaptureService.ACTION_AI_STREAM_UPDATE).apply {
830+
putExtra(ScreenCaptureService.EXTRA_AI_STREAM_CHUNK, accumulated.toString())
831+
}
832+
androidx.localbroadcastmanager.content.LocalBroadcastManager.getInstance(context).sendBroadcast(intent)
804833
}
805-
else -> null
806-
}
807-
}
808-
VercelMessage(role = if (content.role == "user") "user" else "assistant", content = parts)
809-
}
810-
811-
val requestBody = VercelRequest(
812-
model = modelName,
813-
messages = messages
814-
)
815-
816-
val client = OkHttpClient()
817-
val mediaType = "application/json".toMediaType()
818-
val jsonBody = json.encodeToString(VercelRequest.serializer(), requestBody)
819-
820-
val request = Request.Builder()
821-
.url("https://ai-gateway.vercel.sh/v1/chat/completions")
822-
.post(jsonBody.toRequestBody(mediaType))
823-
.addHeader("Content-Type", "application/json")
824-
.addHeader("Authorization", "Bearer $apiKey")
825-
.build()
826-
827-
client.newCall(request).execute().use { response ->
828-
if (!response.isSuccessful) {
829-
errorMessage = "Unexpected code ${response.code} - ${response.body?.string()}"
830-
} else {
831-
val responseBody = response.body?.string()
832-
if (responseBody != null) {
833-
val json = Json { ignoreUnknownKeys = true }
834-
val vercelResponse = json.decodeFromString(VercelResponse.serializer(), responseBody)
835-
responseText = vercelResponse.choices.firstOrNull()?.message?.content ?: "No response from model"
836-
} else {
837-
errorMessage = "Empty response body"
834+
} catch (e: Exception) {
835+
// Skip malformed chunks
838836
}
839837
}
840838
}
841-
} catch (e: Exception) {
842-
errorMessage = e.localizedMessage ?: "Vercel API call failed"
839+
} finally {
840+
reader.close()
841+
response.close()
843842
}
844843

845-
return Pair(responseText, errorMessage)
844+
return accumulated.toString()
846845
}
847846

848847
// Data classes for Mistral API in Service

0 commit comments

Comments
 (0)