diff --git a/jetpacker/.gitignore b/jetpacker/.gitignore index e6bbe5e3..8ec8c528 100644 --- a/jetpacker/.gitignore +++ b/jetpacker/.gitignore @@ -26,3 +26,8 @@ export/ # Generated third-party notices THIRD_PARTY_NOTICES + +# Python files +**/__pycache__/ +*.pyc + diff --git a/jetpacker/CONTRIBUTING.md b/jetpacker/CONTRIBUTING.md index f6abfb3c..f90da93e 100644 --- a/jetpacker/CONTRIBUTING.md +++ b/jetpacker/CONTRIBUTING.md @@ -42,6 +42,7 @@ If you wish to run, explore, or modify the code locally: 1. Clone the repository: ```bash git clone https://github.com/android/ai-samples.git + cd ai-samples/jetpacker ``` 2. Open the project in Android Studio. 3. Gradle will automatically sync and resolve dependencies. diff --git a/jetpacker/README.md b/jetpacker/README.md index 21fc2773..165abf84 100644 --- a/jetpacker/README.md +++ b/jetpacker/README.md @@ -14,11 +14,12 @@ ## Overview -JetPacker provides users with powerful tools to manage their upcoming trips, build out rich itineraries, record voice notes, manage travel expenses, generate on-device "Trip Summaries and Tips", generate AI reviews, chat with hotel staff via automatic translation, get real-time museum assistant guidance, and contribute to Android's intelligence system with trip management functions through [AppFunctions](https://d.android.com/ai/appfunctions). +JetPacker provides users with powerful tools to manage their upcoming trips, build out rich itineraries, record voice notes, manage travel expenses, generate on-device "Trip Summaries and Tips", generate AI reviews, chat with hotel staff via automatic translation, get real-time museum assistant guidance, coordinate reservations with an AI Booking Assistant using Jetpack A2UI, and contribute to Android's intelligence system with trip management functions through [AppFunctions](https://d.android.com/ai/appfunctions). ## Architecture This project is built using modern Android architecture components: - **UI**: Jetpack Compose +- **Agent-Driven UI**: Jetpack A2UI (`androidx.a2ui`) Compose renderer - **Dependency Injection**: Dagger/Hilt - **Local Persistence**: Room Database - **State Management**: ViewModels with StateFlow @@ -50,6 +51,7 @@ JetPacker follows a clean, multi-module Android structure organized by responsib - **`:feature:trip:itinerary:enrichment`**: On-device AI summaries and tips (`TripSummaryAndTipsCard`) and dynamic daily theme generators. - **`:feature:trip:expenses`**: Expense tracking screen and automated receipt parser. - **`:feature:trip:voice_notes`**: Audio voice note recorder and real-time speech-to-text transcription screen. + - **`:feature:trip:booking_assistant`**: Booking Assistant interactive agent interface rendered with Jetpack A2UI. ## Getting Started @@ -92,6 +94,26 @@ cd android ./gradlew test ``` +### Running the Booking Assistant Server +JetPacker uses a Python-based Server-Sent Events (SSE) server powered by Google ADK to coordinate the booking agents. To run the booking assistant backend locally: + +```bash +# Navigate to the server directory +cd server + +# Install dependencies +pip install -r requirements.txt + +# Run the server +uvicorn main:app --host 0.0.0.0 --port 8000 +``` + +Alternatively, run with Docker: +```bash +docker build -t jetpacker-server . +docker run -p 8080:8080 jetpacker-server +``` + ## On-Device AI Features JetPacker integrates local on-device AI capabilities using ML Kit. These features run entirely on-device and can be toggled or customized in `android/core/flags/src/main/kotlin/com/example/jetpacker/core/flags/FeatureFlags.kt`: - **ENABLE_TRIP_SUMMARY_AND_TIPS**: Generates an on-device card summary of the current trip using ML Kit GenAI Prompt. @@ -104,6 +126,7 @@ JetPacker also integrates online hybrid features using Firebase AI Logic (Gemini - **ENABLE_MUSEUM_ASSISTANT**: Museum Assistant chatbot with URL, Maps, and Search grounding (uses Gemini 3.5 flash-lite). - **ENABLE_REVIEW_GENERATION**: Topic-selected review generator (uses Gemini 3.5 flash-lite on-device with cloud fallback). - **Hotel Support Chat**: Receives hotel receptionist assistance with real-time ML Kit + Gemini translation. +- **Booking Assistant**: Parallel multi-agent travel coordinator providing dynamic interactive booking cards rendered via Jetpack A2UI with Cloud Run SSE backend streaming. ## AppFunctions Integration JetPacker uses Android's [**AppFunctions**](https://d.android.com/ai/appfunctions) library (`androidx.appfunctions`) in `:feature:appfunctions` to contribute to Android's intelligence system with structured travel actions and data queries via `JetPackerAppFunctionService`: diff --git a/jetpacker/android/app/build.gradle.kts b/jetpacker/android/app/build.gradle.kts index a677752f..b24aa686 100644 --- a/jetpacker/android/app/build.gradle.kts +++ b/jetpacker/android/app/build.gradle.kts @@ -14,7 +14,6 @@ * limitations under the License. */ - plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.compose) @@ -28,9 +27,10 @@ plugins { android { namespace = "com.example.jetpacker" compileSdk { - version = release(libs.versions.compileSdk.get().toInt()) { - minorApiLevel = libs.versions.compileSdkMinor.get().toInt() - } + version = + release(libs.versions.compileSdk.get().toInt()) { + minorApiLevel = libs.versions.compileSdkMinor.get().toInt() + } } defaultConfig { @@ -61,9 +61,7 @@ android { } packaging { - jniLibs { - useLegacyPackaging = false - } + jniLibs { useLegacyPackaging = false } resources { excludes += "META-INF/DEPENDENCIES" excludes += "META-INF/LICENSE" @@ -92,6 +90,7 @@ dependencies { implementation(project(":data:itinerary")) implementation(project(":data:trips")) implementation(project(":feature:appfunctions")) + implementation(libs.androidx.appfunctions) implementation(project(":feature:create_trip")) implementation(project(":feature:detail")) implementation(project(":feature:detail:museum_assistant")) @@ -104,6 +103,7 @@ dependencies { implementation(project(":feature:trip:voice_notes")) implementation(project(":feature:trip:expenses")) implementation(project(":feature:trip:itinerary:enrichment")) + implementation(project(":feature:trip:booking_assistant")) implementation(libs.androidx.activity.compose) implementation(libs.androidx.camera.camera2) implementation(libs.androidx.camera.core) diff --git a/jetpacker/android/app/src/main/AndroidManifest.xml b/jetpacker/android/app/src/main/AndroidManifest.xml index 8da10625..bf3fa4ad 100644 --- a/jetpacker/android/app/src/main/AndroidManifest.xml +++ b/jetpacker/android/app/src/main/AndroidManifest.xml @@ -32,7 +32,7 @@ android:label="@string/app_name" android:supportsRtl="true" android:theme="@style/Theme.JetPacker" - android:usesCleartextTraffic="false"> + android:usesCleartextTraffic="true"> + + + diff --git a/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/BookingAssistantScreen.kt b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/BookingAssistantScreen.kt new file mode 100644 index 00000000..4f4d6f99 --- /dev/null +++ b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/BookingAssistantScreen.kt @@ -0,0 +1,412 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.booking_assistant + +import androidx.a2ui.model.processor.A2uiSurfaceModel +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.ConfirmationNumber +import androidx.compose.material.icons.rounded.Flight +import androidx.compose.material.icons.rounded.Hotel +import androidx.compose.material.icons.rounded.KeyboardArrowDown +import androidx.compose.material.icons.rounded.KeyboardArrowUp +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material.icons.rounded.Restaurant +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.a2ui.A2uiSurface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.example.jetpacker.core.ui.SekuyaFontFamily + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BookingAssistantScreen( + tripId: String, + contentPadding: PaddingValues, + onBack: () -> Unit = {}, + viewModel: BookingAssistantViewModel = hiltViewModel(), + modifier: Modifier = Modifier, +) { + val activeSurfaces by viewModel.activeSurfaces.collectAsStateWithLifecycle() + val isStreaming by viewModel.isStreaming.collectAsStateWithLifecycle() + val surfaceStatuses by viewModel.surfaceStatuses.collectAsStateWithLifecycle() + + val groupedSurfaces = remember(activeSurfaces) { + activeSurfaces + .groupBy { BookingCategory.fromCategoryName(viewModel.getCategory(it.id)) } + .toSortedMap(compareBy { it.order }) + } + + var expandedCategories by remember { + mutableStateOf(emptySet()) + } + + LaunchedEffect(tripId) { + viewModel.startBooking(tripId) + } + + Scaffold( + modifier = modifier.fillMaxSize(), + containerColor = MaterialTheme.colorScheme.primary, + topBar = { + Column(modifier = Modifier.background(MaterialTheme.colorScheme.primary)) { + TopAppBar( + title = { + Text( + text = "MANAGE BOOKINGS", + style = + MaterialTheme.typography.titleLarge.copy( + fontFamily = SekuyaFontFamily, + fontSize = 24.sp, + letterSpacing = 0.5.sp, + ), + color = Color(0xFF1E293B), + ) + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = "Back", + tint = Color(0xFF1E293B), + ) + } + }, + actions = { + IconButton(onClick = { viewModel.resetBookings() }) { + Icon( + imageVector = Icons.Rounded.Refresh, + contentDescription = "Reset Bookings", + tint = Color(0xFF1E293B).copy(alpha = 0.4f), + ) + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) + if (isStreaming) { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.secondary, + trackColor = Color.Transparent, + ) + } + } + }, + ) { innerPadding -> + LazyColumn( + modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp), + contentPadding = + PaddingValues( + top = innerPadding.calculateTopPadding() + 8.dp, + bottom = contentPadding.calculateBottomPadding() + 24.dp, + ), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 4.dp, bottom = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = "Current Itinerary •", + style = + MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 15.sp, + ), + color = Color(0xFF1E293B), + ) + Text( + text = "YOUR NEXT JOURNEY", + style = + MaterialTheme.typography.headlineMedium.copy( + fontFamily = SekuyaFontFamily, + fontSize = 24.sp, + letterSpacing = 0.5.sp, + ), + color = Color(0xFF1E293B), + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = + "Review and finalize your travel segments. We've organized your selections for a seamless booking.", + style = + MaterialTheme.typography.bodyMedium.copy( + fontSize = 14.sp, + lineHeight = 20.sp, + ), + color = Color(0xFF334155), + ) + } + } + + if (activeSurfaces.isEmpty()) { + item { + Box( + modifier = Modifier.fillMaxWidth().padding(top = 48.dp), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = "No active booking surfaces.\nPress 'Start Booking' to initialize.", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Button( + onClick = { viewModel.resetBookings() }, + shape = RoundedCornerShape(12.dp), + ) { + Text("Start Booking", fontWeight = FontWeight.Bold) + } + } + } + } + } else { + groupedSurfaces.forEach { (category, surfaces) -> + item(key = category.name) { + CategoryCard( + category = category, + surfaces = surfaces, + subtitle = viewModel.getCategorySubtitle(surfaces), + isExpanded = expandedCategories.contains(category), + onToggleExpand = { + expandedCategories = + if (expandedCategories.contains(category)) { + expandedCategories - category + } else { + expandedCategories + category + } + }, + ) + } + } + } + } + } +} + +@Composable +private fun CategoryCard( + category: BookingCategory, + surfaces: List, + subtitle: String, + isExpanded: Boolean, + onToggleExpand: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(28.dp), + colors = + CardDefaults.cardColors( + containerColor = Color(0xFFFFFDF5), + ), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(20.dp), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onToggleExpand, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + Box( + modifier = + Modifier + .size(48.dp) + .background(category.circleColor, CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = category.icon, + contentDescription = category.displayName, + tint = Color.White, + modifier = Modifier.size(24.dp), + ) + } + + Column(modifier = Modifier.weight(1f)) { + Text( + text = "${category.displayName} (${surfaces.size})", + style = + MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + ), + color = Color(0xFF1E293B), + ) + Text( + text = subtitle, + style = + MaterialTheme.typography.bodySmall.copy( + fontSize = 13.sp, + ), + color = Color(0xFF64748B), + ) + } + + Box( + modifier = + Modifier + .size(36.dp) + .background(Color.White, CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = + if (isExpanded) Icons.Rounded.KeyboardArrowUp + else Icons.Rounded.KeyboardArrowDown, + contentDescription = if (isExpanded) "Collapse" else "Expand", + tint = Color(0xFF1E293B), + modifier = Modifier.size(20.dp), + ) + } + } + + AnimatedVisibility(visible = isExpanded) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 16.dp), + ) { + surfaces.forEachIndexed { index, surfaceModel -> + if (index > 0) { + HorizontalDivider( + modifier = Modifier.padding(vertical = 16.dp), + color = Color(0xFFE5E7EB), + thickness = 1.dp, + ) + } + A2uiSurface( + surfaceModel = surfaceModel, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } + } +} + +enum class BookingCategory( + val displayName: String, + val order: Int, +) { + FLIGHT("Flights", 1), + HOTEL("Hotels", 2), + ACTIVITY("Activities", 3), + DINING("Dining", 4), + OTHER("Other", 5); + + val icon: ImageVector + get() = + when (this) { + FLIGHT -> Icons.Rounded.Flight + HOTEL -> Icons.Rounded.Hotel + ACTIVITY -> Icons.Rounded.ConfirmationNumber + DINING -> Icons.Rounded.Restaurant + OTHER -> Icons.Rounded.ConfirmationNumber + } + + val circleColor: Color + get() = + when (this) { + FLIGHT -> Color(0xFFF05123) + HOTEL -> Color(0xFF2563EB) + ACTIVITY -> Color(0xFF059669) + DINING -> Color(0xFFD97706) + OTHER -> Color(0xFF64748B) + } + + companion object { + fun fromCategoryName(name: String): BookingCategory { + return when (name.lowercase()) { + "flight", "flights" -> FLIGHT + "hotel", "hotels" -> HOTEL + "activity", "activities" -> ACTIVITY + "dining", "dining & restaurants" -> DINING + else -> OTHER + } + } + } +} diff --git a/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/BookingAssistantViewModel.kt b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/BookingAssistantViewModel.kt new file mode 100644 index 00000000..175df686 --- /dev/null +++ b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/BookingAssistantViewModel.kt @@ -0,0 +1,754 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.booking_assistant + +import android.util.Log +import androidx.a2ui.compose.ui.A2uiMessageProcessor +import androidx.a2ui.model.processor.A2uiMessageProcessor +import androidx.a2ui.model.processor.A2uiSurfaceModel +import androidx.a2ui.model.protocol.A2uiClientEventMessage +import androidx.a2ui.model.protocol.A2uiComponentPayload +import androidx.a2ui.model.protocol.A2uiCreateSurfaceMessage +import androidx.a2ui.model.protocol.A2uiUpdateComponentsMessage +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.jetpacker.data.itinerary.EventDao +import com.example.jetpacker.data.itinerary.EventType +import com.example.jetpacker.data.itinerary.TimelineEvent +import com.example.jetpacker.data.trips.DummyData +import com.google.firebase.auth.FirebaseAuth +import dagger.hilt.android.lifecycle.HiltViewModel +import java.io.BufferedReader +import java.net.URLEncoder +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.tasks.await +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONArray +import org.json.JSONObject + +data class SurfaceStatus( + val surfaceId: String, + val type: String, + val status: String, +) + +@HiltViewModel +class BookingAssistantViewModel @Inject constructor( + private val savedStateHandle: SavedStateHandle, + private val eventDao: EventDao, +) : ViewModel() { + + private val messageProcessor: A2uiMessageProcessor = A2uiMessageProcessor( + catalogs = listOf(bookingAssistantCatalog()) + ) + + val activeSurfaces: StateFlow> = messageProcessor.activeSurfaces + + private var tripEvents: List = emptyList() + private var currentTripId: String? = null + private var activeStreamJob: Job? = null + private var streamIdleJob: Job? = null + + // Set to true when testing against local server (http://localhost:8000) + val useLocalServer: Boolean = false + + private val client = OkHttpClient.Builder().readTimeout(0, TimeUnit.MILLISECONDS).build() + + private var sessionId: String? + get() = savedStateHandle["session_id"] + set(value) { + savedStateHandle["session_id"] = value + } + + private var streamToken: String? + get() = savedStateHandle["stream_token"] + set(value) { + savedStateHandle["stream_token"] = value + } + + private var streamUrl: String? + get() = savedStateHandle["stream_url"] + set(value) { + savedStateHandle["stream_url"] = value + } + + private val _isStreaming = MutableStateFlow(false) + val isStreaming: StateFlow = _isStreaming.asStateFlow() + + private val _surfaceStatuses = MutableStateFlow>(emptyMap()) + val surfaceStatuses: StateFlow> = _surfaceStatuses.asStateFlow() + + private val _logs = MutableStateFlow>(emptyList()) + val logs: StateFlow> = _logs.asStateFlow() + + init { + viewModelScope.launch { + messageProcessor.collectMessages() + } + viewModelScope.launch { + messageProcessor.outboundEvents.collect { message -> + if (message is A2uiClientEventMessage) { + handleClientEvent(message) + } + } + } + } + + private fun resetStreamIdleTimer(delayMillis: Long = 2500L) { + streamIdleJob?.cancel() + streamIdleJob = viewModelScope.launch { + kotlinx.coroutines.delay(delayMillis) + _isStreaming.value = false + } + } + + fun startBooking(tripId: String, forceRestart: Boolean = false) { + if (currentTripId == tripId && activeStreamJob?.isActive == true && !forceRestart) return + currentTripId = tripId + savedStateHandle["tripId"] = tripId + + activeStreamJob?.cancel() + streamIdleJob?.cancel() + activeStreamJob = viewModelScope.launch(Dispatchers.IO) { + try { + _isStreaming.value = true + log("Loading trip events for trip: $tripId") + + val dbEvents = runCatching { eventDao.getEventsForTrip(tripId).first() }.getOrNull() ?: emptyList() + tripEvents = if (dbEvents.isNotEmpty()) { + dbEvents + } else { + DummyData.events.filter { it.tripId == tripId }.ifEmpty { + DummyData.events.take(4) + } + } + + // Initialize A2UI surfaces for each itinerary event + tripEvents.forEach { event -> + val cat = when (event.type) { + EventType.TRANSPORTATION -> "Flight" + EventType.ACCOMMODATION -> "Hotel" + EventType.ACTIVITY, EventType.CULTURE -> "Activity" + EventType.FOOD_AND_DRINK -> "Dining" + else -> "Booking" + } + upsertSurface( + surfaceId = event.title, + payload = A2uiComponentPayload( + id = "root", + type = "BookingStatus", + properties = mapOf( + "category" to cat, + "title" to event.title, + "description" to (event.description?.ifEmpty { "Waiting for coordinator..." } ?: "Waiting for coordinator..."), + "status" to "Queued", + ), + ), + ) + } + + val sId = if (forceRestart) { + System.currentTimeMillis().toString().also { sessionId = it } + } else { + sessionId ?: System.currentTimeMillis().toString().also { sessionId = it } + } + + log("Surfaces ready for trip $tripId, sId=$sId. Connecting to stream...") + connectToStream(sId) + log("connectToStream completed.") + } catch (e: Exception) { + log("Error starting booking: ${e.message}") + Log.e("BookingAssistant", "Exception in startBooking", e) + _isStreaming.value = false + } + } + } + + fun resetBookings() { + val tripId = currentTripId ?: savedStateHandle["tripId"] ?: "2026-1" + sessionId = null + streamToken = null + streamUrl = null + streamIdleJob?.cancel() + _isStreaming.value = false + _surfaceStatuses.value = emptyMap() + startBooking(tripId, forceRestart = true) + } + + private fun upsertSurface(surfaceId: String, payload: A2uiComponentPayload) { + val exists = messageProcessor.activeSurfaces.value.any { it.id == surfaceId } + if (!exists) { + messageProcessor.processMessage( + A2uiCreateSurfaceMessage( + surfaceId = surfaceId, + catalogId = BOOKING_ASSISTANT_CATALOG_ID, + ) + ) + } + messageProcessor.processMessage( + A2uiUpdateComponentsMessage( + surfaceId = surfaceId, + components = listOf(payload), + ) + ) + val status = (payload.properties["status"] as? String).orEmpty() + _surfaceStatuses.value = _surfaceStatuses.value + ( + surfaceId to SurfaceStatus( + surfaceId = surfaceId, + type = payload.type, + status = status, + ) + ) + } + + fun getCategory(title: String): String { + val event = tripEvents.find { it.title.equals(title, ignoreCase = true) || it.id == title } + return when (event?.type) { + EventType.TRANSPORTATION -> "Flight" + EventType.ACCOMMODATION -> "Hotel" + EventType.ACTIVITY, EventType.CULTURE -> "Activity" + EventType.FOOD_AND_DRINK -> "Dining" + else -> when { + title.contains("Flight", ignoreCase = true) || + title.contains("AMS", ignoreCase = true) || + title.contains("SFO", ignoreCase = true) -> "Flight" + title.contains("Hotel", ignoreCase = true) || + title.contains("Check-in", ignoreCase = true) || + title.contains("Checkout", ignoreCase = true) || + title.contains("Check out", ignoreCase = true) -> "Hotel" + title.contains("Restaurant", ignoreCase = true) || + title.contains("Dining", ignoreCase = true) || + title.contains("Bistro", ignoreCase = true) || + title.contains("Cafe", ignoreCase = true) -> "Dining" + else -> "Activity" + } + } + } + + private suspend fun connectToStream(sId: String) { + val itineraryJson = JSONArray() + val accommodationEvents = tripEvents.filter { it.type == EventType.ACCOMMODATION } + val checkins = accommodationEvents.filter { + it.title.contains("Check-in", ignoreCase = true) || it.title.contains("Check in", ignoreCase = true) + } + val checkouts = accommodationEvents.filter { + it.title.contains("Checkout", ignoreCase = true) || + it.title.contains("Check-out", ignoreCase = true) || + it.title.contains("Check out", ignoreCase = true) + } + val matchedCheckouts = mutableSetOf() + val processedCheckins = mutableSetOf() + + for (checkin in checkins) { + val matchingCheckout = checkouts.find { + it.location.equals(checkin.location, ignoreCase = true) && it.timestamp > checkin.timestamp + } + if (matchingCheckout != null) { + matchedCheckouts.add(matchingCheckout) + processedCheckins.add(checkin) + val eventJson = JSONObject() + eventJson.put("type", "ACCOMMODATION") + val cleanTitle = checkin.location.trim() + eventJson.put("title", if (cleanTitle.isNotEmpty()) cleanTitle else checkin.title) + itineraryJson.put(eventJson) + } + } + + for (event in tripEvents) { + if (event.type == EventType.ACCOMMODATION) { + if (checkins.contains(event)) { + if (!processedCheckins.contains(event)) { + val eventJson = JSONObject() + eventJson.put("type", event.type.name) + eventJson.put("title", event.title) + itineraryJson.put(eventJson) + } + } else if (checkouts.contains(event)) { + if (!matchedCheckouts.contains(event)) { + val eventJson = JSONObject() + eventJson.put("type", event.type.name) + eventJson.put("title", event.title) + itineraryJson.put(eventJson) + } + } else { + val eventJson = JSONObject() + eventJson.put("type", event.type.name) + eventJson.put("title", event.title) + itineraryJson.put(eventJson) + } + } else { + val eventJson = JSONObject() + eventJson.put("type", event.type.name) + eventJson.put("title", event.title) + itineraryJson.put(eventJson) + } + } + + log("Starting connectToStream with sId: $sId, useLocalServer: $useLocalServer") + + if (useLocalServer) { + streamToken = "local_dev_token" + streamUrl = "http://localhost:8000/run_sse" + } else { + val fbToken = getFirebaseAuthToken() + if (fbToken.isEmpty()) { + log("Failed to obtain Firebase Auth token") + _isStreaming.value = false + return + } + + val gatewayUrl = "https://jetset-gateway-38wzy18y.uc.gateway.dev/stream?auth_only=true" + val tokenRequest = Request.Builder() + .url(gatewayUrl) + .addHeader("Authorization", "Bearer $fbToken") + .get() + .build() + + client.newCall(tokenRequest).execute().use { tokenResponse -> + if (!tokenResponse.isSuccessful) { + log("Failed to get stream token from gateway: ${tokenResponse.code}") + _isStreaming.value = false + return + } + val responseBody = tokenResponse.body?.string().orEmpty() + val jsonResponse = JSONObject(responseBody) + streamToken = jsonResponse.optString("token") + streamUrl = jsonResponse.optString("url") + } + } + + val payload = JSONObject() + payload.put("app_name", "booking") + payload.put("user_id", "user_id") + payload.put("session_id", sId) + payload.put("streaming", true) + + val messageJson = JSONObject() + messageJson.put("command", "start") + messageJson.put("itinerary", itineraryJson) + + val partObj = JSONObject() + partObj.put("text", messageJson.toString()) + val partsArr = JSONArray() + partsArr.put(partObj) + + val newMessageObj = JSONObject() + newMessageObj.put("parts", partsArr) + payload.put("new_message", newMessageObj) + + val st = streamToken + val su = streamUrl + if (st.isNullOrEmpty() || su.isNullOrEmpty()) { + log("Missing stream credentials") + _isStreaming.value = false + return + } + + log("Connecting to SSE stream at $su...") + val streamRequest = Request.Builder() + .url(su) + .addHeader("Accept", "text/event-stream") + .addHeader("Authorization", "Bearer $st") + .post(payload.toString().toRequestBody("application/json".toMediaType())) + .build() + + client.newCall(streamRequest).execute().use { streamResponse -> + if (!streamResponse.isSuccessful) { + log("Stream connection failed with code: ${streamResponse.code}") + _isStreaming.value = false + return + } + + log("Stream connected successfully! (code: ${streamResponse.code})") + resetStreamIdleTimer(3000L) + + val source = streamResponse.body?.source() ?: return + val reader = BufferedReader(source.inputStream().reader()) + var line: String? + + try { + while (reader.readLine().also { line = it } != null) { + val currentLine = line ?: continue + if (currentLine.startsWith("data: ")) { + val jsonStr = currentLine.substring(6) + _isStreaming.value = true + resetStreamIdleTimer(2000L) + parseAndApplyEvent(jsonStr) + } + } + } catch (e: Exception) { + log("Stream reading interrupted: ${e.message}") + } finally { + streamIdleJob?.cancel() + _isStreaming.value = false + } + } + } + + private fun parseAndApplyEvent(jsonStr: String) { + try { + val json = JSONObject(jsonStr) + val author = json.optString("author") + if (author.isEmpty()) return + + val category = getCategory(author) + val actionsJson = json.optJSONObject("actions") + val endOfAgent = actionsJson?.optBoolean("endOfAgent", false) ?: false + val contentJson = json.optJSONObject("content") + val partsArr = contentJson?.optJSONArray("parts") + + var textContent = "" + var uiType: String? = null + var uiOptions = emptyList() + var uiMessage: String? = null + + if (partsArr != null) { + for (i in 0 until partsArr.length()) { + val part = partsArr.getJSONObject(i) + if (part.has("text")) { + val txt = part.getString("text") + if (txt.contains("\"ui\":")) { + try { + val uiJson = JSONObject(txt) + uiType = uiJson.optString("ui") + uiMessage = uiJson.optString("message") + val opts = uiJson.optJSONArray("options") + if (opts != null) { + uiOptions = List(opts.length()) { opts.getString(it) } + } + } catch (e: Exception) { + textContent += txt + } + } else { + textContent += txt + } + } + } + } + + when { + uiType == "time_selection" -> { + upsertSurface( + surfaceId = author, + payload = A2uiComponentPayload( + id = "root", + type = "InteractiveOptionPicker", + properties = mapOf( + "title" to author, + "category" to category, + "status" to "CONFIRMATION REQUIRED", + "prompt" to (uiMessage ?: "Flight Departure"), + "options" to uiOptions, + "selectedOption" to uiOptions.firstOrNull(), + "confirmBtnText" to "Confirm Selection", + "action" to mapOf( + "event" to mapOf( + "name" to "confirm_option", + "context" to mapOf( + "agentId" to author, + "selectionType" to "time", + ), + ) + ), + ), + ), + ) + } + + uiType == "seat_map" || uiType == "seat_selection" -> { + upsertSurface( + surfaceId = author, + payload = A2uiComponentPayload( + id = "root", + type = "SeatSelectionPicker", + properties = mapOf( + "title" to author, + "category" to category, + "status" to "ACTION REQUIRED", + "prompt" to (uiMessage ?: "Seat Selection"), + "options" to uiOptions.ifEmpty { listOf("1A", "1B", "2A", "2B") }, + "selectedSeat" to uiOptions.firstOrNull(), + "confirmBtnText" to "Confirm Selection", + "action" to mapOf( + "event" to mapOf( + "name" to "confirm_seat", + "context" to mapOf( + "agentId" to author, + "selectionType" to "seat", + ), + ) + ), + ), + ), + ) + } + + uiType == "ticket_selection" -> { + upsertSurface( + surfaceId = author, + payload = A2uiComponentPayload( + id = "root", + type = "InteractiveOptionPicker", + properties = mapOf( + "title" to author, + "category" to category, + "status" to "CONFIRMATION REQUIRED", + "prompt" to (uiMessage ?: "Select party size:"), + "options" to uiOptions.ifEmpty { listOf("1", "2", "3", "4+") }, + "selectedOption" to uiOptions.firstOrNull(), + "confirmBtnText" to "Confirm Selection", + "action" to mapOf( + "event" to mapOf( + "name" to "confirm_option", + "context" to mapOf( + "agentId" to author, + "selectionType" to "tickets", + ), + ) + ), + ), + ), + ) + } + + textContent.contains("Please confirm", ignoreCase = true) || + textContent.contains("confirm reservation", ignoreCase = true) || + textContent.contains("confirm tickets", ignoreCase = true) -> { + upsertSurface( + surfaceId = author, + payload = A2uiComponentPayload( + id = "root", + type = "BookingConfirmation", + properties = mapOf( + "category" to category, + "title" to author, + "status" to "CONFIRMATION REQUIRED", + "prompt" to textContent.trim(), + "confirmBtnText" to "Confirm Reservation", + "action" to mapOf( + "event" to mapOf( + "name" to "confirm_booking", + "context" to mapOf( + "agentId" to author, + "selectionType" to "confirm", + ), + ) + ), + ), + ), + ) + } + + endOfAgent || + textContent.contains("confirmed", ignoreCase = true) || + textContent.contains("secured", ignoreCase = true) -> { + upsertSurface( + surfaceId = author, + payload = A2uiComponentPayload( + id = "root", + type = "BookingStatus", + properties = mapOf( + "category" to category, + "title" to author, + "description" to textContent.ifEmpty { "Booking confirmed." }, + "status" to "Confirmed", + ), + ), + ) + } + + textContent.isNotEmpty() -> { + upsertSurface( + surfaceId = author, + payload = A2uiComponentPayload( + id = "root", + type = "BookingStatus", + properties = mapOf( + "category" to category, + "title" to author, + "description" to textContent, + "status" to "Processing", + ), + ), + ) + } + } + } catch (e: Exception) { + log("Error parsing SSE event: ${e.message}") + } + } + + private fun handleClientEvent(event: A2uiClientEventMessage) { + val context = event.context + val agentId = (context["agentId"] as? String) ?: event.surfaceId + val selectionType = (context["selectionType"] as? String) ?: (context["type"] as? String).orEmpty() + val selectedOption = context["selectedOption"] as? String + val selectedSeat = context["selectedSeat"] as? String + val sId = sessionId ?: return + + log("User interaction for $agentId ($selectionType): option=$selectedOption, seat=$selectedSeat") + + _isStreaming.value = true + resetStreamIdleTimer(5000L) + + val category = getCategory(agentId) + upsertSurface( + surfaceId = agentId, + payload = A2uiComponentPayload( + id = "root", + type = "BookingStatus", + properties = mapOf( + "category" to category, + "title" to agentId, + "description" to when (selectionType) { + "time" -> "Selected $selectedOption. Confirming with provider..." + "seat" -> "Selected seat $selectedSeat. Confirming assignment..." + "tickets" -> "Selected party size $selectedOption. Securing reservation..." + else -> "Processing confirmation..." + }, + "status" to "Processing", + ), + ), + ) + + viewModelScope.launch(Dispatchers.IO) { + try { + val json = JSONObject() + when (selectionType) { + "time" -> json.put("time", selectedOption) + "seat" -> json.put("seat", selectedSeat ?: selectedOption) + "tickets" -> { + json.put("tickets", selectedOption) + json.put("people", selectedOption) + } + "confirm", "confirm_booking" -> json.put("confirmed", true) + else -> { + if (selectedOption != null) json.put("time", selectedOption) + else if (selectedSeat != null) json.put("seat", selectedSeat) + else json.put("confirmed", true) + } + } + + val token = getFirebaseAuthToken() + val baseUrl = if (useLocalServer) "http://localhost:8000" else "https://jetset-gateway-38wzy18y.uc.gateway.dev" + val encodedSessionId = URLEncoder.encode(sId, "UTF-8") + val encodedAgentId = URLEncoder.encode(agentId, "UTF-8") + val request = Request.Builder() + .url("$baseUrl/respond?session_id=$encodedSessionId&agent_id=$encodedAgentId") + .addHeader("Authorization", "Bearer $token") + .post(json.toString().toRequestBody("application/json".toMediaType())) + .build() + + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + log("Error sending response: ${response.code}") + } else { + log("Response successfully delivered to server for $agentId") + } + } + } catch (e: Exception) { + log("Error sending response to server: ${e.message}") + } + } + } + + private suspend fun getFirebaseAuthToken(): String { + log("getFirebaseAuthToken: getting FirebaseAuth instance") + val auth = FirebaseAuth.getInstance() + var user = auth.currentUser + log("getFirebaseAuthToken: current user is ${user?.uid ?: "null"}") + if (user == null) { + log("getFirebaseAuthToken: signing in anonymously...") + user = try { + auth.signInAnonymously().await().user + } catch (e: Exception) { + log("Firebase signInAnonymously failed: ${e.message}") + Log.e("BookingAssistant", "Firebase signInAnonymously failed", e) + null + } + log("getFirebaseAuthToken: signed in as ${user?.uid ?: "null"}") + } + return try { + val token = user?.getIdToken(false)?.await()?.token.orEmpty() + log("getFirebaseAuthToken: got token length=${token.length}") + token + } catch (e: Exception) { + log("Firebase getIdToken failed: ${e.message}") + Log.e("BookingAssistant", "Firebase getIdToken failed", e) + "" + } + } + + fun getCategorySubtitle(surfaces: List): String { + val statuses = surfaces.map { _surfaceStatuses.value[it.id] } + val hasActionRequired = statuses.any { statusInfo -> + statusInfo != null && ( + statusInfo.type in listOf("InteractiveOptionPicker", "SeatSelectionPicker", "BookingConfirmation") || + statusInfo.status.contains("REQUIRED", ignoreCase = true) || + statusInfo.status.contains("ACTION", ignoreCase = true) + ) + } + if (hasActionRequired) { + return "Action required" + } + + val allConfirmed = statuses.isNotEmpty() && statuses.all { statusInfo -> + statusInfo != null && ( + statusInfo.status.equals("Confirmed", ignoreCase = true) || + statusInfo.status.equals("Complete", ignoreCase = true) || + statusInfo.status.equals("Completed", ignoreCase = true) + ) + } + if (allConfirmed) { + return if (surfaces.size > 1) "All confirmed" else "Confirmed" + } + + val anyProcessing = statuses.any { statusInfo -> + statusInfo != null && statusInfo.status.equals("Processing", ignoreCase = true) + } + if (anyProcessing) { + return "Processing..." + } + + val anyConfirmed = statuses.any { statusInfo -> + statusInfo != null && statusInfo.status.equals("Confirmed", ignoreCase = true) + } + if (anyConfirmed) { + val confirmedCount = statuses.count { it?.status.equals("Confirmed", ignoreCase = true) } + return "$confirmedCount of ${surfaces.size} confirmed" + } + + return "Queued" + } + + private fun log(message: String) { + Log.e("BookingAssistant", message) + _logs.value = (_logs.value + message).takeLast(100) + } +} diff --git a/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/CustomBookingAssistantCatalog.kt b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/CustomBookingAssistantCatalog.kt new file mode 100644 index 00000000..8476b62c --- /dev/null +++ b/jetpacker/android/feature/trip/booking_assistant/src/main/kotlin/com/example/jetpacker/feature/booking_assistant/CustomBookingAssistantCatalog.kt @@ -0,0 +1,790 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetpacker.feature.booking_assistant + +import androidx.a2ui.compose.runtime.A2uiComponentProperties +import androidx.a2ui.compose.runtime.A2uiComponentScope +import androidx.a2ui.compose.runtime.A2uiProperty +import androidx.a2ui.compose.ui.A2uiCatalog +import androidx.a2ui.compose.ui.A2uiComponent +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.ConfirmationNumber +import androidx.compose.material.icons.rounded.Flight +import androidx.compose.material.icons.rounded.Hotel +import androidx.compose.material.icons.rounded.Restaurant +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +const val BOOKING_ASSISTANT_CATALOG_ID: String = + "https://example.com/catalogs/booking_assistant/v1/catalog.json" + +/** + * Returns the custom A2UI catalog for Jetpacker's Booking Assistant. + */ +fun bookingAssistantCatalog(): A2uiCatalog { + return A2uiCatalog( + catalogId = BOOKING_ASSISTANT_CATALOG_ID, + components = listOf( + InteractiveOptionPickerComponent(), + SeatSelectionPickerComponent(), + BookingStatusComponent(), + BookingConfirmationComponent(), + ), + ) +} + +/** + * An interactive option picker that allows selecting a single option from a list and confirming. + */ +class InteractiveOptionPickerComponent : A2uiComponent { + private val titleProp = + A2uiProperty.dynamicString( + key = "title", + required = false, + description = "The title of the booking item.", + ) + + private val categoryProp = + A2uiProperty.dynamicString( + key = "category", + required = false, + description = "The booking category.", + ) + + private val statusProp = + A2uiProperty.dynamicString( + key = "status", + required = false, + description = "Status chip label (e.g. CONFIRMATION REQUIRED).", + ) + + private val promptProp = + A2uiProperty.dynamicString( + key = "prompt", + required = true, + description = "The prompt or instruction to display to the user.", + ) + + private val optionsProp = + A2uiProperty.dynamicStringList( + key = "options", + required = true, + description = "The list of selectable options.", + ) + + private val selectedOptionProp = + A2uiProperty.dynamicString( + key = "selectedOption", + required = false, + description = "The currently selected option string.", + ) + + private val confirmBtnTextProp = + A2uiProperty.dynamicString( + key = "confirmBtnText", + required = false, + description = "The label for the confirmation button.", + ) + + private val actionProp = + A2uiProperty.action( + key = "action", + required = false, + description = "Custom action payload dispatched when the confirm button is clicked.", + ) + + override val name: String = "InteractiveOptionPicker" + override val description: String = + "An interactive option picker allowing users to select an option and confirm." + override val properties: List> = + listOf(titleProp, categoryProp, statusProp, promptProp, optionsProp, selectedOptionProp, confirmBtnTextProp, actionProp) + + @Composable + override fun A2uiComponentScope.isReady(properties: A2uiComponentProperties): Boolean { + return properties.bind(promptProp) != null && properties.bind(optionsProp) != null + } + + @Composable + override fun A2uiComponentScope.Content( + properties: A2uiComponentProperties, + modifier: Modifier, + ) { + val title = properties.bind(titleProp) + val category = properties.bind(categoryProp) ?: "Flight" + val status = properties.bind(statusProp) ?: "CONFIRMATION REQUIRED" + val prompt = properties.bind(promptProp) ?: "Flight Departure" + val options = properties.bind(optionsProp) ?: emptyList() + val initialSelected = properties.bind(selectedOptionProp) + val confirmBtnText = properties.bind(confirmBtnTextProp) ?: "Confirm Selection" + val action = properties[actionProp] + + var selected by rememberSaveable(initialSelected) { + mutableStateOf(initialSelected ?: options.firstOrNull()) + } + + val categoryIcon = when { + category.contains("Hotel", ignoreCase = true) -> Icons.Rounded.Hotel + category.contains("Activity", ignoreCase = true) -> Icons.Rounded.ConfirmationNumber + category.contains("Dining", ignoreCase = true) -> Icons.Rounded.Restaurant + else -> Icons.Rounded.Flight + } + + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (!title.isNullOrEmpty()) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 17.sp, + ), + color = Color(0xFF1E293B), + ) + } + + Box( + modifier = Modifier + .background(Color(0xFFFDE8E8), RoundedCornerShape(8.dp)) + .padding(horizontal = 10.dp, vertical = 4.dp), + ) { + Text( + text = status.uppercase(), + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + letterSpacing = 0.5.sp, + fontSize = 11.sp, + ), + color = Color(0xFF8A1F11), + ) + } + + Text( + text = prompt, + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 15.sp, + ), + color = Color(0xFF1E293B), + ) + + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + options.forEach { option -> + val isSelected = option == selected + Row( + modifier = + Modifier.fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(Color.White) + .border( + BorderStroke( + if (isSelected) 1.5.dp else 1.dp, + if (isSelected) Color(0xFF94A3B8) else Color(0xFFD1D5DB), + ), + RoundedCornerShape(16.dp), + ) + .clickable { selected = option } + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = categoryIcon, + contentDescription = null, + tint = Color(0xFF374151), + modifier = Modifier.size(20.dp), + ) + Text( + text = option, + style = MaterialTheme.typography.bodyMedium.copy( + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, + fontSize = 15.sp, + ), + color = Color(0xFF1F2937), + modifier = Modifier.weight(1f), + ) + } + } + } + + Spacer(modifier = Modifier.height(4.dp)) + + Button( + onClick = { + val context = mutableMapOf( + "type" to "confirm_option", + ) + if (selected != null) { + context["selectedOption"] = selected!! + } + val eventPayload = if (action != null && action.containsKey("event")) { + val originalEvent = (action["event"] as? Map<*, *>) ?: emptyMap() + val originalContext = (originalEvent["context"] as? Map<*, *>) ?: emptyMap() + val mergedContext = originalContext.toMutableMap() + mergedContext.putAll(context) + mapOf( + "event" to mapOf( + "name" to (originalEvent["name"] as? String ?: "confirm_option"), + "context" to mergedContext, + ) + ) + } else { + mapOf( + "event" to mapOf( + "name" to "confirm_option", + "context" to context, + ) + ) + } + dispatchAction(eventPayload) + }, + enabled = selected != null, + modifier = Modifier.fillMaxWidth().height(48.dp), + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFFE5E2DA), + contentColor = Color(0xFF78716C), + disabledContainerColor = Color(0xFFE5E2DA).copy(alpha = 0.5f), + disabledContentColor = Color(0xFF78716C).copy(alpha = 0.5f), + ), + ) { + Text( + text = confirmBtnText, + style = MaterialTheme.typography.bodyMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 15.sp, + ), + ) + } + } + } +} + +/** + * A seat selection picker for flights or travel segments. + */ +class SeatSelectionPickerComponent : A2uiComponent { + private val titleProp = + A2uiProperty.dynamicString( + key = "title", + required = false, + description = "The title of the booking item.", + ) + + private val categoryProp = + A2uiProperty.dynamicString( + key = "category", + required = false, + description = "Booking category.", + ) + + private val statusProp = + A2uiProperty.dynamicString( + key = "status", + required = false, + description = "Status chip label (e.g. ACTION REQUIRED).", + ) + + private val promptProp = + A2uiProperty.dynamicString( + key = "prompt", + required = false, + description = "The prompt for seat selection.", + ) + + private val optionsProp = + A2uiProperty.dynamicStringList( + key = "options", + required = true, + description = "Available seats list.", + ) + + private val selectedSeatProp = + A2uiProperty.dynamicString( + key = "selectedSeat", + required = false, + description = "Currently selected seat.", + ) + + private val confirmBtnTextProp = + A2uiProperty.dynamicString( + key = "confirmBtnText", + required = false, + description = "Button label for confirmation.", + ) + + private val actionProp = + A2uiProperty.action( + key = "action", + required = false, + description = "Action payload dispatched when confirming seat selection.", + ) + + override val name: String = "SeatSelectionPicker" + override val description: String = + "A seat selection picker displaying seat options and a confirmation button." + override val properties: List> = + listOf(titleProp, categoryProp, statusProp, promptProp, optionsProp, selectedSeatProp, confirmBtnTextProp, actionProp) + + @Composable + override fun A2uiComponentScope.isReady(properties: A2uiComponentProperties): Boolean { + return properties.bind(optionsProp) != null + } + + @Composable + override fun A2uiComponentScope.Content( + properties: A2uiComponentProperties, + modifier: Modifier, + ) { + val title = properties.bind(titleProp) + val status = properties.bind(statusProp) ?: "ACTION REQUIRED" + val prompt = properties.bind(promptProp) ?: "Seat Selection" + val options = properties.bind(optionsProp) ?: listOf("1A", "1B", "1C", "2A", "2B", "2C") + val initialSeat = properties.bind(selectedSeatProp) + val confirmBtnText = properties.bind(confirmBtnTextProp) ?: "Confirm Selection" + val action = properties[actionProp] + + var selectedSeat by rememberSaveable(initialSeat) { mutableStateOf(initialSeat) } + + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (!title.isNullOrEmpty()) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 17.sp, + ), + color = Color(0xFF1E293B), + ) + } + + Box( + modifier = Modifier + .background(Color(0xFFFDE8E8), RoundedCornerShape(8.dp)) + .padding(horizontal = 10.dp, vertical = 4.dp), + ) { + Text( + text = status.uppercase(), + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + letterSpacing = 0.5.sp, + fontSize = 11.sp, + ), + color = Color(0xFF8A1F11), + ) + } + + Text( + text = prompt, + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.Bold, + fontSize = 15.sp, + ), + color = Color(0xFF1E293B), + ) + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + options.chunked(4).forEach { rowSeats -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + rowSeats.forEach { seat -> + val isSelected = seat == selectedSeat + OutlinedButton( + onClick = { selectedSeat = seat }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(12.dp), + colors = + if (isSelected) { + ButtonDefaults.outlinedButtonColors( + containerColor = Color(0xFF1E293B), + contentColor = Color.White, + ) + } else { + ButtonDefaults.outlinedButtonColors( + containerColor = Color.White, + contentColor = Color(0xFF1E293B), + ) + }, + border = + if (isSelected) null + else BorderStroke(1.dp, Color(0xFFD1D5DB)), + ) { + Text( + text = seat, + style = MaterialTheme.typography.bodyMedium.copy( + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + ), + ) + } + } + } + } + } + + Spacer(modifier = Modifier.height(4.dp)) + + Button( + onClick = { + val context = mutableMapOf( + "type" to "confirm_seat", + ) + if (selectedSeat != null) { + context["selectedSeat"] = selectedSeat!! + } + val eventPayload = if (action != null && action.containsKey("event")) { + val originalEvent = (action["event"] as? Map<*, *>) ?: emptyMap() + val originalContext = (originalEvent["context"] as? Map<*, *>) ?: emptyMap() + val mergedContext = originalContext.toMutableMap() + mergedContext.putAll(context) + mapOf( + "event" to mapOf( + "name" to (originalEvent["name"] as? String ?: "confirm_seat"), + "context" to mergedContext, + ) + ) + } else { + mapOf( + "event" to mapOf( + "name" to "confirm_seat", + "context" to context, + ) + ) + } + dispatchAction(eventPayload) + }, + enabled = selectedSeat != null, + modifier = Modifier.fillMaxWidth().height(48.dp), + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFFE5E2DA), + contentColor = Color(0xFF78716C), + disabledContainerColor = Color(0xFFE5E2DA).copy(alpha = 0.5f), + disabledContentColor = Color(0xFF78716C).copy(alpha = 0.5f), + ), + ) { + Text( + text = confirmBtnText, + style = MaterialTheme.typography.bodyMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 15.sp, + ), + ) + } + } + } +} + +/** + * Displays booking confirmation status, title, details, and status badge. + */ +class BookingStatusComponent : A2uiComponent { + private val categoryProp = + A2uiProperty.dynamicString( + key = "category", + required = false, + description = "Booking category: Flight, Hotel, Activity, Dining.", + ) + + private val titleProp = + A2uiProperty.dynamicString( + key = "title", + required = true, + description = "Title of the booking item.", + ) + + private val descriptionProp = + A2uiProperty.dynamicString( + key = "description", + required = false, + description = "Booking description or update details.", + ) + + private val statusProp = + A2uiProperty.dynamicString( + key = "status", + required = true, + description = "Status string: Confirmed, Action Required, Processing, Complete, Queued.", + ) + + override val name: String = "BookingStatus" + override val description: String = + "Displays the booking status with icon, badge, title, and descriptive message." + override val properties: List> = + listOf(categoryProp, titleProp, descriptionProp, statusProp) + + @Composable + override fun A2uiComponentScope.isReady(properties: A2uiComponentProperties): Boolean { + return properties.bind(titleProp) != null && properties.bind(statusProp) != null + } + + @Composable + override fun A2uiComponentScope.Content( + properties: A2uiComponentProperties, + modifier: Modifier, + ) { + val category = properties.bind(categoryProp) ?: "Booking" + val title = properties.bind(titleProp) ?: "Travel Booking" + val description = properties.bind(descriptionProp) + val status = properties.bind(statusProp) ?: "Processing" + + val (bg, txt) = + when (status.uppercase()) { + "COMPLETE", + "CONFIRMED" -> Color(0xFFD1FAE5) to Color(0xFF065F46) + "CONFIRMATION REQUIRED", + "ACTION REQUIRED" -> Color(0xFFFDE8E8) to Color(0xFF8A1F11) + "RUNNING", + "PROCESSING...", + "PROCESSING" -> Color(0xFFE0F2FE) to Color(0xFF0369A1) + else -> Color(0xFFF1F5F9) to Color(0xFF475569) + } + + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 17.sp, + ), + color = Color(0xFF1E293B), + ) + + Box( + modifier = + Modifier.background(bg, RoundedCornerShape(8.dp)) + .padding(horizontal = 10.dp, vertical = 4.dp) + ) { + Text( + text = status.uppercase(), + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + letterSpacing = 0.5.sp, + fontSize = 11.sp, + ), + color = txt, + ) + } + + if (!description.isNullOrEmpty()) { + Text( + text = description, + style = MaterialTheme.typography.bodyMedium.copy( + fontSize = 14.sp, + lineHeight = 20.sp, + ), + color = Color(0xFF475569), + ) + } + } + } +} + +/** + * A booking confirmation card that allows confirming a reservation or ticket. + */ +class BookingConfirmationComponent : A2uiComponent { + private val categoryProp = + A2uiProperty.dynamicString( + key = "category", + required = false, + description = "Booking category: Flight, Hotel, Activity, Dining.", + ) + + private val titleProp = + A2uiProperty.dynamicString( + key = "title", + required = true, + description = "Title of the booking item.", + ) + + private val statusProp = + A2uiProperty.dynamicString( + key = "status", + required = false, + description = "Status badge text.", + ) + + private val promptProp = + A2uiProperty.dynamicString( + key = "prompt", + required = true, + description = "Justification or details explaining the booking selection.", + ) + + private val confirmBtnTextProp = + A2uiProperty.dynamicString( + key = "confirmBtnText", + required = false, + description = "Label for the confirmation button.", + ) + + private val actionProp = + A2uiProperty.action( + key = "action", + required = false, + description = "Action payload dispatched when confirmed.", + ) + + override val name: String = "BookingConfirmation" + override val description: String = + "A confirmation card with justification details and a confirm button." + override val properties: List> = + listOf(categoryProp, titleProp, statusProp, promptProp, confirmBtnTextProp, actionProp) + + @Composable + override fun A2uiComponentScope.isReady(properties: A2uiComponentProperties): Boolean { + return properties.bind(titleProp) != null && properties.bind(promptProp) != null + } + + @Composable + override fun A2uiComponentScope.Content( + properties: A2uiComponentProperties, + modifier: Modifier, + ) { + val category = properties.bind(categoryProp) ?: "Booking" + val title = properties.bind(titleProp) ?: "Reservation" + val status = properties.bind(statusProp) ?: "CONFIRMATION REQUIRED" + val prompt = properties.bind(promptProp) ?: "Please confirm reservation." + val confirmBtnText = properties.bind(confirmBtnTextProp) ?: "Confirm Reservation" + val action = properties[actionProp] + + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 17.sp, + ), + color = Color(0xFF1E293B), + ) + + Box( + modifier = + Modifier.background(Color(0xFFFDE8E8), RoundedCornerShape(8.dp)) + .padding(horizontal = 10.dp, vertical = 4.dp) + ) { + Text( + text = status.uppercase(), + style = MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + letterSpacing = 0.5.sp, + fontSize = 11.sp, + ), + color = Color(0xFF8A1F11), + ) + } + + Text( + text = prompt, + style = MaterialTheme.typography.bodyMedium.copy( + fontSize = 14.sp, + lineHeight = 20.sp, + ), + color = Color(0xFF334155), + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Button( + onClick = { + val context = mutableMapOf( + "type" to "confirm_booking", + ) + val eventPayload = if (action != null && action.containsKey("event")) { + val originalEvent = (action["event"] as? Map<*, *>) ?: emptyMap() + val originalContext = (originalEvent["context"] as? Map<*, *>) ?: emptyMap() + val mergedContext = originalContext.toMutableMap() + mergedContext.putAll(context) + mapOf( + "event" to mapOf( + "name" to (originalEvent["name"] as? String ?: "confirm_booking"), + "context" to mergedContext, + ) + ) + } else { + mapOf( + "event" to mapOf( + "name" to "confirm_booking", + "context" to context, + ) + ) + } + dispatchAction(eventPayload) + }, + modifier = Modifier.fillMaxWidth().height(48.dp), + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFFE5E2DA), + contentColor = Color(0xFF78716C), + ), + ) { + Text( + text = confirmBtnText, + style = MaterialTheme.typography.bodyMedium.copy( + fontWeight = FontWeight.Bold, + fontSize = 15.sp, + ), + ) + } + } + } +} + diff --git a/jetpacker/android/feature/trip/build.gradle.kts b/jetpacker/android/feature/trip/build.gradle.kts index 6c8b2dfb..02b7b0d6 100644 --- a/jetpacker/android/feature/trip/build.gradle.kts +++ b/jetpacker/android/feature/trip/build.gradle.kts @@ -48,6 +48,7 @@ dependencies { implementation(project(":feature:trip:itinerary")) implementation(project(":feature:trip:expenses")) implementation(project(":feature:trip:voice_notes")) + implementation(project(":feature:trip:booking_assistant")) implementation(libs.androidx.compose.material.icons.core) implementation(libs.androidx.compose.material.icons.extended) diff --git a/jetpacker/android/feature/trip/src/main/kotlin/com/example/jetpacker/feature/trip/TripScreen.kt b/jetpacker/android/feature/trip/src/main/kotlin/com/example/jetpacker/feature/trip/TripScreen.kt index d6aadd18..163675a5 100644 --- a/jetpacker/android/feature/trip/src/main/kotlin/com/example/jetpacker/feature/trip/TripScreen.kt +++ b/jetpacker/android/feature/trip/src/main/kotlin/com/example/jetpacker/feature/trip/TripScreen.kt @@ -26,7 +26,6 @@ import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.ui.layout.LookaheadScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets @@ -40,6 +39,7 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.ConfirmationNumber import androidx.compose.material.icons.rounded.Event import androidx.compose.material.icons.rounded.Wallet import androidx.compose.material3.MaterialTheme @@ -52,6 +52,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.LookaheadScope import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.dp import com.example.jetpacker.core.flags.FeatureFlags @@ -61,6 +62,7 @@ import com.example.jetpacker.core.ui.components.JetPackerFabConfig import com.example.jetpacker.core.ui.components.JetPackerToolbar import com.example.jetpacker.core.ui.components.JetPackerToolbarAction import com.example.jetpacker.data.itinerary.EventType +import com.example.jetpacker.feature.booking_assistant.BookingAssistantScreen import com.example.jetpacker.feature.expenses.ManageExpensesScreen import com.example.jetpacker.feature.itinerary.ItineraryScreen import com.example.jetpacker.feature.voice_notes.VoiceNotesScreen @@ -69,11 +71,12 @@ enum class TripTab { ITINERARY, EXPENSES, VOICE_NOTES, + BOOKING, } /** - * Composable screen serving as the main container for viewing details of a specific trip. - * Displays the itinerary and links to event details, maps, and editing options. + * Composable screen serving as the main container for viewing details of a specific trip. Displays + * the itinerary and links to event details, maps, and editing options. */ @Composable fun TripScreen( @@ -92,10 +95,12 @@ fun TripScreen( bottomBar = { val showExpenses = FeatureFlags.ENABLE_EXPENSE_MANAGEMENT val showVoiceNotes = FeatureFlags.ENABLE_VOICE_NOTES + val showBooking = FeatureFlags.ENABLE_BOOKING_ASSISTANT var visibleCount = 1 if (showExpenses) visibleCount++ if (showVoiceNotes) visibleCount++ + if (showBooking) visibleCount++ if (visibleCount > 1) { AnimatedVisibility( @@ -143,6 +148,14 @@ fun TripScreen( onFabConfigChange = { fabConfig = it }, ) } + + TripTab.BOOKING -> { + BookingAssistantScreen( + tripId = tripId, + contentPadding = innerPadding, + onBack = { selectedTab = TripTab.ITINERARY }, + ) + } } } } @@ -157,6 +170,7 @@ fun JetPackerBottomBar( ) { val showExpenses = FeatureFlags.ENABLE_EXPENSE_MANAGEMENT val showVoiceNotes = FeatureFlags.ENABLE_VOICE_NOTES + val showBooking = FeatureFlags.ENABLE_BOOKING_ASSISTANT LookaheadScope { Row( @@ -170,7 +184,7 @@ fun JetPackerBottomBar( horizontalArrangement = Arrangement.Center, ) { JetPackerToolbar( - modifier = Modifier.widthIn(max = 272.dp).animateBounds(this@LookaheadScope) + modifier = Modifier.widthIn(max = 336.dp).animateBounds(this@LookaheadScope) ) { JetPackerToolbarAction( icon = Icons.Rounded.Event, @@ -195,6 +209,15 @@ fun JetPackerBottomBar( contentDescription = "Voice Notes", ) } + + if (showBooking) { + JetPackerToolbarAction( + selected = selectedTab == TripTab.BOOKING, + onClick = { onTabSelected(TripTab.BOOKING) }, + icon = Icons.Rounded.ConfirmationNumber, + contentDescription = "Booking Assistant", + ) + } } Spacer(Modifier.width(4.dp)) diff --git a/jetpacker/android/gradle/libs.versions.toml b/jetpacker/android/gradle/libs.versions.toml index f117b439..2a482182 100644 --- a/jetpacker/android/gradle/libs.versions.toml +++ b/jetpacker/android/gradle/libs.versions.toml @@ -1,4 +1,5 @@ [versions] +a2ui = "1.0.0-SNAPSHOT" accompanistPermissions = "0.37.3" activityCompose = "1.13.0" agp = "9.2.1" @@ -47,13 +48,17 @@ roomCompiler = "2.7.0" roomKtx = "2.7.0" roomRuntime = "2.7.0" runner = "1.6.2" -targetSdk = "36" +targetSdk = "37" truth = "1.4.2" versionCode = "1" versionName = "1.0" appfunctions = "1.0.0-alpha10" [libraries] +androidx-a2ui-model = { group = "androidx.a2ui", name = "a2ui-model", version.ref = "a2ui" } +androidx-a2ui-compose-runtime = { group = "androidx.a2ui.compose", name = "compose-runtime", version.ref = "a2ui" } +androidx-a2ui-compose-ui = { group = "androidx.a2ui.compose", name = "compose-ui", version.ref = "a2ui" } +androidx-compose-material3-a2ui = { group = "androidx.compose.material3", name = "material3-a2ui", version.ref = "a2ui" } accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanistPermissions" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "cameraCamera2" } diff --git a/jetpacker/android/settings.gradle.kts b/jetpacker/android/settings.gradle.kts index b19b5638..d29cf8d1 100644 --- a/jetpacker/android/settings.gradle.kts +++ b/jetpacker/android/settings.gradle.kts @@ -35,6 +35,7 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + maven { url = uri("https://androidx.dev/snapshots/builds/16368166/artifacts/repository") } } } @@ -73,3 +74,4 @@ include(":feature:trip:voice_notes") include(":feature:trip:itinerary:enrichment") include(":feature:appfunctions") +include(":feature:trip:booking_assistant") diff --git a/jetpacker/server/Dockerfile b/jetpacker/server/Dockerfile new file mode 100644 index 00000000..609251ff --- /dev/null +++ b/jetpacker/server/Dockerfile @@ -0,0 +1,23 @@ +# Use an official Python runtime as a parent image (Updated to 3.10 to support Type Union syntax `|` used in main.py) +FROM python:3.10-slim + +# Set the working directory in the container +WORKDIR /app + +# Copy the requirements file into the container at /app +COPY requirements.txt . + +# Install any needed packages specified in requirements.txt +RUN pip install --no-cache-dir -r requirements.txt + +# Copy the rest of your application code into the container at /app +COPY . . + +# Make port 8080 available +EXPOSE 8080 + +# Define environment variable for the port +ENV PORT 8080 + +# Run uvicorn server when the container launches +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/jetpacker/server/main.py b/jetpacker/server/main.py new file mode 100644 index 00000000..eda91dc7 --- /dev/null +++ b/jetpacker/server/main.py @@ -0,0 +1,552 @@ +import asyncio +import json +from typing import AsyncGenerator +import urllib.request + +import fastapi +from google.adk.agents.base_agent import BaseAgent +from google.adk.agents.invocation_context import InvocationContext +from google.adk.agents.parallel_agent import ParallelAgent +from google.adk.cli.fast_api import get_fast_api_app +from google.adk.cli.utils.base_agent_loader import BaseAgentLoader +from google.adk.events.event import Event +from google.adk.events.event_actions import EventActions +from google import genai +from google.genai import types +import pydantic +import uvicorn + +app = fastapi.FastAPI() + + +class SessionState: + + def __init__(self): + self.pause_events: dict[str, asyncio.Event] = {} + self.results: dict[str, any] = {} + self.completed_steps: dict[str, int] = {} + + +sessions: dict[str, SessionState] = {} + + +class BaseSimulationAgent(BaseAgent): + + async def _generate_text(self, prompt: str) -> str: + client = genai.Client(vertexai=True) + response = await asyncio.to_thread( + client.models.generate_content, + model="gemini-2.5-flash", + contents=prompt, + ) + text = response.text or "" + return text.strip() + + def _create_text_event( + self, ctx: InvocationContext, text: str, author: str | None = None + ) -> Event: + return Event( + invocation_id=ctx.invocation_id, + author=author or self.name, + content=types.Content(parts=[types.Part(text=text)]), + branch=ctx.branch, + ) + + def _create_end_event( + self, ctx: InvocationContext, author: str | None = None + ) -> Event: + return Event( + invocation_id=ctx.invocation_id, + author=author or self.name, + actions=EventActions(end_of_agent=True), + content=types.Content(parts=[]), + branch=ctx.branch, + ) + + def _create_options_event( + self, + ctx: InvocationContext, + title: str, + options: list[str], + message: str, + ui_type: str = "seat_map", + ) -> Event: + return self._create_text_event( + ctx, + json.dumps({ + "ui": ui_type, + "options": options, + "message": message, + }), + author=title, + ) + + +class SingleFlightSimulationAgent(BaseSimulationAgent): + name: str = "single_flight" + item_title: str = "Flight" + + async def _run_async_impl( + self, ctx: InvocationContext + ) -> AsyncGenerator[Event, None]: + session = sessions[ctx.session.id] + title = self.item_title + + # Step 1: Time Confirmation + if session.completed_steps.get(title, 0) < 1: + confirmed = False + while not confirmed: + time_proposal = await self._generate_text( + f"Propose a single logical departure time for the flight '{title}'" + " (assume origin is France). Keep it under 10 words." + ) + + yield self._create_options_event( + ctx, + title, + options=[f"Confirm {time_proposal}", "Try Another Time"], + message=( + f"I found a flight at {time_proposal}. Please confirm or" + " request another time." + ), + ui_type="time_selection", + ) + + if title not in session.pause_events: + session.pause_events[title] = asyncio.Event() + else: + session.pause_events[title].clear() + + await session.pause_events[title].wait() + + response = session.results.get(title) + if response and "Confirm" in response: + confirmed = True + else: + session.results[title] = None + yield self._create_text_event( + ctx, "Searching for alternative flight times...", author=title + ) + await asyncio.sleep(2) + session.completed_steps[title] = 1 + + # Step 2: Seat Selection + if session.completed_steps.get(title, 0) < 2: + yield self._create_options_event( + ctx, + title, + options=["1A", "1B", "2A", "2B"], + message=f"Flight time confirmed! Please select a seat for {title}.", + ) + + session.pause_events[title].clear() + await session.pause_events[title].wait() + + selected_seat = session.results.get(title) or "Unknown" + + confirmed_text = await self._generate_text( + "Generate a SINGLE realistic status line for confirming the flight" + f" '{title}' with seat {selected_seat}. Do NOT include flight details" + " in the output, just the status." + ) + yield self._create_text_event(ctx, confirmed_text, author=title) + yield self._create_end_event(ctx, author=title) + await asyncio.sleep(1) + session.completed_steps[title] = 2 + + +class FlightSimulationAgent(BaseSimulationAgent): + name: str = "flight" + items: list[dict] = [] + + async def _run_async_impl( + self, ctx: InvocationContext + ) -> AsyncGenerator[Event, None]: + if not self.items: + return + + sub_agents = [ + SingleFlightSimulationAgent( + name=f"single_flight_{i}", + item_title=item.get("title", "Flight"), + ) + for i, item in enumerate(self.items) + ] + parallel_agent = ParallelAgent(name="flights_parallel", sub_agents=sub_agents) + + async for event in parallel_agent.run_async(ctx): + yield event + + +class SingleHotelSimulationAgent(BaseSimulationAgent): + name: str = "single_hotel" + item_title: str = "Hotel" + full_itinerary: list[dict] = [] + + async def _run_async_impl( + self, ctx: InvocationContext + ) -> AsyncGenerator[Event, None]: + session = sessions[ctx.session.id] + title = self.item_title + + prompt = f""" + You are a travel agent booking a hotel. + Generate a brief justification (1-2 sentences) for choosing the hotel '{title}' for the user. + Mention that you are booking it for the correct number of nights based on the dates (assume 3 nights if dates are missing). + Explain why you chose it (e.g., proximity to other locations in the itinerary, reviews, or user preferences). + Context: Full itinerary is {self.full_itinerary} + """ + justification = await self._generate_text(prompt) + + status_text = f"{justification} Please confirm reservation." + yield self._create_text_event(ctx, status_text, author=title) + + # HITL Pause + pause_key = title + if pause_key not in session.pause_events: + session.pause_events[pause_key] = asyncio.Event() + + await session.pause_events[pause_key].wait() + + response = session.results.get(pause_key) + if response == "Confirmed": + confirmed_text = await self._generate_text( + "Generate a SINGLE realistic status line for confirming the hotel" + f" reservation for '{title}'. Do NOT include the hotel name in the" + " output, just the status (e.g., 'Reservation confirmed', 'Room" + " secured')." + ) + yield self._create_text_event(ctx, confirmed_text, author=title) + else: + yield self._create_text_event( + ctx, f"Reservation for '{title}' was not confirmed.", author=title + ) + + yield self._create_end_event(ctx, author=title) + await asyncio.sleep(1) + + +class HotelSimulationAgent(BaseSimulationAgent): + name: str = "hotel" + items: list[dict] = [] + full_itinerary: list[dict] = [] + + async def _run_async_impl( + self, ctx: InvocationContext + ) -> AsyncGenerator[Event, None]: + if not self.items: + return + + sub_agents = [ + SingleHotelSimulationAgent( + name=f"single_hotel_{i}", + item_title=item.get("title", "Hotel"), + full_itinerary=self.full_itinerary, + ) + for i, item in enumerate(self.items) + ] + parallel_agent = ParallelAgent(name="hotels_parallel", sub_agents=sub_agents) + + async for event in parallel_agent.run_async(ctx): + yield event + + +class SingleMuseumSimulationAgent(BaseSimulationAgent): + name: str = "single_museum" + item_title: str = "Museum" + full_itinerary: list[dict] = [] + + async def _run_async_impl( + self, ctx: InvocationContext + ) -> AsyncGenerator[Event, None]: + session = sessions[ctx.session.id] + title = self.item_title + + prompt = f""" + You are a travel agent booking museum tickets. + Generate a brief justification (1-2 sentences) for reserving tickets for the museum '{title}'. + Propose a logical time slot for the visit (e.g., morning or afternoon) and ask user to confirm. + Explain why you chose it (e.g., proximity to other events in the itinerary, popularity, or specific exhibitions). + Context: Full itinerary is {self.full_itinerary} + """ + justification = await self._generate_text(prompt) + + status_text = f"{justification} Please confirm tickets." + yield self._create_text_event(ctx, status_text, author=title) + + # HITL Pause + pause_key = title + if pause_key not in session.pause_events: + session.pause_events[pause_key] = asyncio.Event() + + await session.pause_events[pause_key].wait() + + response = session.results.get(pause_key) + if response == "Confirmed": + confirmed_text = await self._generate_text( + "Generate a SINGLE realistic status line for confirming tickets for" + f" '{title}'. Do NOT include the museum name in the output, just the" + " status (e.g., 'Tickets confirmed', 'Reservation secured')." + ) + yield self._create_text_event(ctx, confirmed_text, author=title) + else: + yield self._create_text_event( + ctx, f"Tickets for '{title}' were not confirmed.", author=title + ) + + await asyncio.sleep(1) + yield self._create_end_event(ctx, author=title) + + +class MuseumSimulationAgent(BaseSimulationAgent): + name: str = "museum" + items: list[dict] = [] + full_itinerary: list[dict] = [] + + async def _run_async_impl( + self, ctx: InvocationContext + ) -> AsyncGenerator[Event, None]: + if not self.items: + return + + sub_agents = [ + SingleMuseumSimulationAgent( + name=f"single_museum_{i}", + item_title=item.get("title", "Museum"), + full_itinerary=self.full_itinerary, + ) + for i, item in enumerate(self.items) + ] + parallel_agent = ParallelAgent(name="museums_parallel", sub_agents=sub_agents) + + async for event in parallel_agent.run_async(ctx): + yield event + + yield self._create_end_event(ctx, author=self.name) + + +class SingleRestaurantSimulationAgent(BaseSimulationAgent): + name: str = "single_restaurant" + item_title: str = "Restaurant" + full_itinerary: list[dict] = [] + + async def _run_async_impl( + self, ctx: InvocationContext + ) -> AsyncGenerator[Event, None]: + session = sessions[ctx.session.id] + title = self.item_title + + prompt = f""" + You are a travel agent booking a restaurant. + Generate a brief justification (1-2 sentences) for choosing the restaurant '{title}' for the user. + Explain why you chose it (e.g., proximity to other locations in the itinerary, cuisine, or reviews). + Context: Full itinerary is {self.full_itinerary} + """ + justification = await self._generate_text(prompt) + + # Ask for people count using options UI + yield self._create_options_event( + ctx, + title, + options=["1", "2", "3", "4+"], + message=( + f"{justification} Please select the number of people for the" + " reservation." + ), + ui_type="ticket_selection", + ) + + if title not in session.pause_events: + session.pause_events[title] = asyncio.Event() + else: + session.pause_events[title].clear() + + await session.pause_events[title].wait() + + people_count = session.results.get(title) or "Unknown" + + confirmed_text = await self._generate_text( + "Generate a SINGLE realistic status line for confirming a table for" + f" {people_count} people at '{title}'. Do NOT include the restaurant" + " name in the output, just the status (e.g., 'Table reserved for" + f" {people_count}', 'Reservation secured')." + ) + yield self._create_text_event(ctx, confirmed_text, author=title) + yield self._create_end_event(ctx, author=title) + await asyncio.sleep(1) + + +class RestaurantSimulationAgent(BaseSimulationAgent): + name: str = "restaurant" + items: list[dict] = [] + full_itinerary: list[dict] = [] + + async def _run_async_impl( + self, ctx: InvocationContext + ) -> AsyncGenerator[Event, None]: + if not self.items: + return + + sub_agents = [ + SingleRestaurantSimulationAgent( + name=f"single_restaurant_{i}", + item_title=item.get("title", "Restaurant"), + full_itinerary=self.full_itinerary, + ) + for i, item in enumerate(self.items) + ] + parallel_agent = ParallelAgent( + name="restaurants_parallel", sub_agents=sub_agents + ) + + async for event in parallel_agent.run_async(ctx): + yield event + + +class ItineraryOrchestratorAgent(BaseSimulationAgent): + name: str = "booking" + + async def _run_async_impl( + self, ctx: InvocationContext + ) -> AsyncGenerator[Event, None]: + if ctx.session.id not in sessions: + sessions[ctx.session.id] = SessionState() + + user_text = "" + if ctx.user_content and ctx.user_content.parts: + user_text = ctx.user_content.parts[0].text + + try: + data = json.loads(user_text) + itinerary = data.get("itinerary", []) + print(f"DEBUG: Parsed itinerary: {itinerary}") + except json.JSONDecodeError: + yield self._create_text_event( + ctx, "Failed to parse itinerary. Running all agents as default." + ) + itinerary = [ + {"type": "TRANSPORTATION"}, + {"type": "CULTURE"}, + {"type": "FOOD_AND_DRINK"}, + ] + print("DEBUG: Failed to parse itinerary, using default.") + + sub_agents = [] + + # Handle Hotels group + hotel_items = [e for e in itinerary if e.get("type") == "ACCOMMODATION"] + if hotel_items: + sub_agents.append(HotelSimulationAgent(items=hotel_items, full_itinerary=itinerary)) + + # Handle Museums group + museum_items = [ + e for e in itinerary if e.get("type") in ["CULTURE", "ACTIVITY"] + ] + if museum_items: + sub_agents.append(MuseumSimulationAgent(items=museum_items, full_itinerary=itinerary)) + + # Handle Restaurants group + restaurant_items = [ + e for e in itinerary if e.get("type") == "FOOD_AND_DRINK" + ] + if restaurant_items: + sub_agents.append(RestaurantSimulationAgent(items=restaurant_items, full_itinerary=itinerary)) + + # Handle Flights group + flight_items = [e for e in itinerary if e.get("type") == "TRANSPORTATION"] + if flight_items: + sub_agents.append(FlightSimulationAgent(items=flight_items)) + + if not sub_agents: + yield self._create_text_event( + ctx, "No relevant agents found for itinerary." + ) + return + + parallel_agent = ParallelAgent( + name="parallel_orchestrator", sub_agents=sub_agents + ) + + async for event in parallel_agent.run_async(ctx): + yield event + + +class BookingAgentLoader(BaseAgentLoader): + + def load_agent(self, agent_name: str) -> BaseAgent: + if agent_name == "booking": + return ItineraryOrchestratorAgent() + raise ValueError(f"Unknown agent: {agent_name}") + + def list_agents(self) -> list[str]: + return ["booking"] + + +app = get_fast_api_app( + agents_dir=".", + agent_loader=BookingAgentLoader(), + web=False, + auto_create_session=True, +) + + +class ResponseData(pydantic.BaseModel): + seat: str | None = None + time: str | None = None + tickets: str | None = None + people: str | None = None + confirmed: bool | None = None + + +@app.post("/respond") +async def respond(session_id: str, data: ResponseData, agent_id: str | None = None): + if session_id not in sessions: + raise fastapi.HTTPException(status_code=404, detail="Session not found") + + session = sessions[session_id] + + # For backward compatibility or default flight use flight_Flight + pause_key = agent_id or "flight_Flight" + + if data.seat: + session.results[pause_key] = data.seat + elif data.time: + session.results[pause_key] = data.time + elif data.tickets: + session.results[pause_key] = data.tickets + elif data.people: + session.results[pause_key] = data.people + elif data.confirmed: + session.results[pause_key] = "Confirmed" + + if pause_key in session.pause_events: + session.pause_events[pause_key].set() + else: + raise fastapi.HTTPException(status_code=404, detail=f"Pause for {pause_key} not active") + + return {"status": "success", "message": "User response received"} + + +for route in app.routes: + path = getattr(route, "path", "N/A") + methods = getattr(route, "methods", "N/A") + print(f"DEBUG: Bound route: {path} methods: {methods}") + + +@app.get("/stream") +def get_stream_token(auth_only: bool = False): + """Returns an OIDC token for direct streaming access.""" + audience = "https://jetpacker-server-254502043090.us-central1.run.app" + url = f"http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience={audience}" + + try: + req = urllib.request.Request(url) + req.add_header("Metadata-Flavor", "Google") + token = urllib.request.urlopen(req).read().decode("utf-8") + return {"token": token, "url": f"{audience}/run_sse"} + except Exception as e: + raise fastapi.HTTPException(status_code=500, detail=str(e)) + + +if __name__ == "__main__": + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/jetpacker/server/openapi.yaml b/jetpacker/server/openapi.yaml new file mode 100644 index 00000000..15bd37d6 --- /dev/null +++ b/jetpacker/server/openapi.yaml @@ -0,0 +1,61 @@ +swagger: '2.0' +info: + title: JetPacker API Gateway + description: API Gateway for JetPacker + version: 1.0.0 +schemes: + - https + +securityDefinitions: + firebase: + authorizationUrl: "" + flow: "implicit" + type: "oauth2" + x-google-issuer: "https://securetoken.google.com/android-devrel-ci" + x-google-jwks_uri: "https://www.googleapis.com/service_accounts/v1/metadata/x509/securetoken@system.gserviceaccount.com" + x-google-audiences: "android-devrel-ci" + +security: + - firebase: [] + +paths: + /orchestrate: + post: + summary: Orchestrate + operationId: orchestrate + x-google-backend: + address: https://jetpacker-server-254502043090.us-central1.run.app/orchestrate + deadline: 300.0 + responses: + '200': + description: OK + /stream: + get: + summary: Stream + operationId: stream + x-google-backend: + address: https://jetpacker-server-254502043090.us-central1.run.app/stream + deadline: 300.0 + responses: + '200': + description: OK + /respond: + post: + summary: Respond + operationId: respond + x-google-backend: + address: https://jetpacker-server-254502043090.us-central1.run.app/respond + deadline: 300.0 + responses: + '200': + description: OK + /status_stream: + post: + summary: Status Stream + operationId: status_stream + x-google-backend: + address: https://jetpacker-server-254502043090.us-central1.run.app/status_stream + deadline: 300.0 + responses: + '200': + description: OK diff --git a/jetpacker/server/requirements.txt b/jetpacker/server/requirements.txt new file mode 100644 index 00000000..9982e570 --- /dev/null +++ b/jetpacker/server/requirements.txt @@ -0,0 +1,5 @@ +fastapi +uvicorn +pydantic +google-adk +google-genai