@@ -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
730741data 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