diff --git a/examples/backend/src/backend/agents/airline/_agent.py b/examples/backend/src/backend/agents/airline/_agent.py index f2f9e16..12defff 100644 --- a/examples/backend/src/backend/agents/airline/_agent.py +++ b/examples/backend/src/backend/agents/airline/_agent.py @@ -8,30 +8,54 @@ add_checked_bag, cancel_trip, change_seat, + flight_option_list, get_customer_profile, + meal_preference_list, request_assistance, set_meal_preference, ) _INSTRUCTION = """ -You are a friendly and efficient airline customer support agent for OpenSkies. -You help elite flyers with seat changes, cancellations, checked bags, and -special requests. Follow these guidelines: +You are a friendly and efficient OpenSkies concierge representing the +traveller. Act on the customer's behalf as you help elite flyers with seat +changes, cancellations, checked bags, and special requests. Follow these +guidelines: -- Always acknowledge the customer's loyalty status and recent travel plans. +- At the start of a conversation, call get_customer_profile to understand the + customer's account, loyalty status, and upcoming travel. +- Acknowledge the customer's loyalty status and recent travel plans if you + haven't already done so. +- Always speak as the traveller's concierge acting on their behalf. - When a task requires action, call the appropriate tool instead of describing the change hypothetically. - After using a tool, confirm the outcome and offer next steps. -- If you cannot fulfill a request, apologise and suggest an alternative. +- If you cannot fulfill a request, apologize and suggest an alternative. - Keep responses concise (2-3 sentences) unless extra detail is required. +- For tool calls `cancel_trip` and `add_checked_bag`, ask the user for + confirmation before proceeding. +- For trip booking requests, gather origin (use the traveller's home airport if + not provided), destination, depart/return dates, and cabin type (economy, + premium economy, business, first). Once you have those details, call + `flight_option_list` to share options instead of describing them. Use airport + codes, not city names, when showing options. Available tools: -- get_customer_profile() - retrieve the customer's profile and recent activity. -- change_seat(flight_number: str, seat: str) - move the passenger to a new seat. -- cancel_trip() - cancel the upcoming reservation and note the refund. -- add_checked_bag() - add one checked bag to the itinerary. -- set_meal_preference(meal: str) - update meal preference (e.g. vegetarian). -- request_assistance(note: str) - record a special assistance request. +- get_customer_profile() – retrieve the customer's profile including loyalty + status, upcoming flights, and preferences. Call this at the start of a + conversation or when you need to check the current state. +- change_seat(flight_number: str, seat: str) – move the passenger to a new + seat. +- cancel_trip() – cancel the upcoming reservation and note the refund. +- add_checked_bag() – add one checked bag to the itinerary. +- meal_preference_list() – show meal options so the traveller can pick their + preference. Invoke this tool when the user requests to set or change their + meal preference or option. +- set_meal_preference(meal: str) – set the traveller's meal preference. Use + this when you receive a [HIDDEN] message indicating the user selected a meal. +- flight_option_list(origin?: str, destination: str, depart_date: str, + return_date: str, cabin: str) – present bookable flight options after the + key details are confirmed. +- request_assistance(note: str) – record a special assistance request. Only use information provided in the customer context or tool results. Do not invent confirmation numbers or policy details. @@ -69,7 +93,9 @@ def __init__( change_seat, cancel_trip, add_checked_bag, + meal_preference_list, set_meal_preference, + flight_option_list, request_assistance, ], generate_content_config=generate_content_config, diff --git a/examples/backend/src/backend/agents/airline/_server.py b/examples/backend/src/backend/agents/airline/_server.py index 6f16d42..f4e17a2 100644 --- a/examples/backend/src/backend/agents/airline/_server.py +++ b/examples/backend/src/backend/agents/airline/_server.py @@ -1,21 +1,69 @@ +from __future__ import annotations + +import logging +import random from collections.abc import AsyncIterator +from datetime import datetime from typing import Any +from uuid import uuid4 -from adk_chatkit import ADKAgentContext, ADKChatKitServer, ADKContext, ADKStore, ChatkitRunConfig, stream_agent_response +from adk_chatkit import ( + ADKAgentContext, + ADKChatKitServer, + ADKContext, + ADKStore, + ChatkitRunConfig, + serialize_widget_item, + stream_agent_response, +) +from adk_chatkit._constants import CHATKIT_WIDGET_STATE_KEY +from chatkit.actions import Action from chatkit.types import ( + AssistantMessageContent, + AssistantMessageItem, + ClientEffectEvent, ClientToolCallItem, + ThreadItemDoneEvent, + ThreadItemUpdated, ThreadMetadata, ThreadStreamEvent, UserMessageItem, + WidgetItem, + WidgetRootUpdated, ) from google.adk.agents.run_config import StreamingMode from google.adk.models.lite_llm import LiteLlm +from google.adk.sessions.base_session_service import BaseSessionService from google.genai import types as genai_types +from pydantic import ValidationError from backend._config import Settings from backend._runner_manager import RunnerManager from ._agent import AirlineSupportAgent +from ._state import AirlineAgentContext, FlightSegment +from ._title_agent import TitleAgent +from .widgets import ( + FLIGHT_SELECT_ACTION_TYPE, + SET_MEAL_PREFERENCE_ACTION_TYPE, + FlightOption, + FlightSearchRequest, + FlightSelectPayload, + SetMealPreferencePayload, + build_flight_options_widget, + build_meal_preference_widget, + describe_flight_option, + generate_flight_options, + meal_preference_label, +) + +BOOKING_CONFIRM_ACTION_TYPE = "booking.confirm_selection" +BOOKING_MODIFY_ACTION_TYPE = "booking.modify_request" +UPSELL_ACCEPT_ACTION_TYPE = "upsell.accept" +UPSELL_DECLINE_ACTION_TYPE = "upsell.decline" +REBOOK_SELECT_ACTION_TYPE = "rebook.select_option" + +logger = logging.getLogger(__name__) def _make_airline_support_agent(settings: Settings) -> AirlineSupportAgent: @@ -28,6 +76,16 @@ def _make_airline_support_agent(settings: Settings) -> AirlineSupportAgent: ) +def _make_title_agent(settings: Settings) -> TitleAgent: + return TitleAgent( + llm=LiteLlm( + model=settings.gpt41_mini_agent.llm.model_name, + **settings.gpt41_mini_agent.llm.provider_args, + ), + generate_content_config=settings.gpt41_mini_agent.generate_content, + ) + + def _user_message_text(item: UserMessageItem) -> str: parts: list[str] = [] for part in item.content: @@ -45,12 +103,412 @@ class AirlineSupportChatKitServer(ADKChatKitServer): def __init__( self, store: ADKStore, + session_service: BaseSessionService, runner_manager: RunnerManager, settings: Settings, ) -> None: super().__init__(store) + self._store = store + self._session_service = session_service + self._settings = settings + + # Create agents and runners agent = _make_airline_support_agent(settings) + title_agent = _make_title_agent(settings) + self._runner = runner_manager.add_runner(settings.AIRLINE_APP_NAME, agent) + self._title_runner = runner_manager.add_runner(f"{settings.AIRLINE_APP_NAME}_title", title_agent) + + async def _get_context_from_session( + self, + context: ADKContext, + thread_id: str, + ) -> AirlineAgentContext: + """Get airline context from ADK session state.""" + session = await self._session_service.get_session( + app_name=context.app_name, + user_id=context.user_id, + session_id=thread_id, + ) + if not session: + return AirlineAgentContext.create_initial_context() + + ctx_dict = session.state.get("context", None) + if ctx_dict is None: + return AirlineAgentContext.create_initial_context() + return AirlineAgentContext.model_validate(ctx_dict) + + async def _save_widget_to_session( + self, + widget_item: WidgetItem, + context: ADKContext, + ) -> None: + """Save a widget item to the session state so it can be loaded later.""" + session = await self._session_service.get_session( + app_name=context.app_name, + user_id=context.user_id, + session_id=widget_item.thread_id, + ) + if not session: + return + + timestamp = datetime.now().timestamp() + state_delta = { + CHATKIT_WIDGET_STATE_KEY: {widget_item.id: serialize_widget_item(widget_item)}, + } + from google.adk.events import Event, EventActions + + system_event = Event( + invocation_id=uuid4().hex, + author="system", + actions=EventActions(state_delta=state_delta), + timestamp=timestamp, + ) + await self._session_service.append_event(session, system_event) + + async def _save_context_to_session( + self, + ctx: AirlineAgentContext, + thread_id: str, + context: ADKContext, + ) -> None: + """Save the airline context back to session state.""" + session = await self._session_service.get_session( + app_name=context.app_name, + user_id=context.user_id, + session_id=thread_id, + ) + if not session: + return + + timestamp = datetime.now().timestamp() + state_delta = {"context": ctx.model_dump()} + from google.adk.events import Event, EventActions + + system_event = Event( + invocation_id=uuid4().hex, + author="system", + actions=EventActions(state_delta=state_delta), + timestamp=timestamp, + ) + await self._session_service.append_event(session, system_event) + + def _profile_effect(self, ctx: AirlineAgentContext) -> ClientEffectEvent: + return ClientEffectEvent( + name="customer_profile/update", + data={"profile": ctx.customer_profile.to_dict()}, + ) + + async def action( + self, + thread: ThreadMetadata, + action: Action[str, Any], + sender: WidgetItem | None, + context: ADKContext, + ) -> AsyncIterator[ThreadStreamEvent]: + """Handle widget actions.""" + if action.type == SET_MEAL_PREFERENCE_ACTION_TYPE: + async for event in self._handle_meal_preference_action(thread, action, sender, context): + yield event + elif action.type == FLIGHT_SELECT_ACTION_TYPE: + async for event in self._handle_flight_select_action(thread, action, sender, context): + yield event + elif action.type == BOOKING_CONFIRM_ACTION_TYPE: + async for event in self._handle_booking_confirm_action(thread, action, sender, context): + yield event + elif action.type == BOOKING_MODIFY_ACTION_TYPE: + async for event in self._handle_booking_modify_action(thread, action, sender, context): + yield event + elif action.type == UPSELL_ACCEPT_ACTION_TYPE: + async for event in self._handle_upgrade_accept_action(thread, action, sender, context): + yield event + elif action.type == UPSELL_DECLINE_ACTION_TYPE: + async for event in self._handle_upgrade_decline_action(thread, action, sender, context): + yield event + elif action.type == REBOOK_SELECT_ACTION_TYPE: + async for event in self._handle_rebook_action(thread, action, sender, context): + yield event + + async def _handle_meal_preference_action( + self, + thread: ThreadMetadata, + action: Action[str, Any], + sender: WidgetItem | None, + context: ADKContext, + ) -> AsyncIterator[ThreadStreamEvent]: + payload = self._parse_meal_preference_payload(action) + if payload is None: + return + + meal_label = meal_preference_label(payload.meal) + + if sender is not None: + widget = build_meal_preference_widget(selected=payload.meal) + yield ThreadItemUpdated( + item_id=sender.id, + update=WidgetRootUpdated(widget=widget), + ) + + # Re-run agent with hidden message to update state + hidden_message = f"[HIDDEN]\nUser selected meal preference: {meal_label}" + async for event in self._run_agent_with_message(thread, hidden_message, context): + yield event + + async def _handle_flight_select_action( + self, + thread: ThreadMetadata, + action: Action[str, Any], + sender: WidgetItem | None, + context: ADKContext, + ) -> AsyncIterator[ThreadStreamEvent]: + payload = self._parse_flight_select_payload(action) + if payload is None: + return + + # Check if widget was already used + ctx = await self._get_context_from_session(context, thread.id) + if sender is not None and ctx.is_widget_consumed(sender.id): + return + + try: + options = [FlightOption.model_validate(opt) for opt in payload.options] + except ValidationError as exc: + logger.warning("Invalid flight options in payload: %s", exc) + options = [] + + if not options: + options = generate_flight_options(payload.request) + + selected = next((opt for opt in options if opt.id == payload.id), None) + if selected is None: + return + + # Lock the current widget to the chosen option + if sender is not None: + selected_widget = build_flight_options_widget( + [selected], + payload.request, + selected_id=selected.id, + leg=payload.leg, + ) + yield ThreadItemUpdated( + item_id=sender.id, + update=WidgetRootUpdated(widget=selected_widget), + ) + # Mark widget as consumed + ctx.mark_widget_consumed(sender.id) + + # Record the flight booking in context + seat_assignment = _pick_default_seat(payload.request.cabin) + flight_number = _generate_flight_number(payload.leg) + booking = ctx.record_flight_booking( + flight_number=flight_number, + date=payload.request.depart_date, + origin=payload.request.normalized_origin(), + destination=payload.request.normalized_destination(), + depart_time=selected.dep_time, + arrival_time=selected.arr_time, + seat=seat_assignment, + ) + + # Save context back to session + await self._save_context_to_session(ctx, thread.id, context) + + summary = describe_flight_option(selected, payload.request) + action_text = ( + "You're scheduled on that option. I'll surface a few returns now." + if payload.leg == "outbound" + else "Return scheduled. Want me to watch for upgrades?" + ) + yield ThreadItemDoneEvent( + item=self._assistant_message( + thread, + f"Scheduled: {summary}. Seat {booking.seat} for now; {action_text}", + context, + ), + ) + + # Send profile update effect + yield self._profile_effect(ctx) + + # Show return options for outbound leg + if payload.leg == "outbound": + return_request = FlightSearchRequest( + origin=payload.request.normalized_destination(), + destination=payload.request.normalized_origin(), + depart_date=payload.request.return_date, + return_date=payload.request.depart_date, + cabin=payload.request.cabin, + ) + return_options = generate_flight_options(return_request) + yield ThreadItemDoneEvent( + item=self._assistant_message( + thread, + "Here are return options that line up with your trip:", + context, + ), + ) + new_widget = build_flight_options_widget( + return_options, + return_request, + leg="return", + ) + return_widget_id = uuid4().hex + return_widget_item = WidgetItem( + thread_id=thread.id, + id=return_widget_id, + created_at=datetime.now(), + widget=new_widget, + ) + # Save widget to session so it can be loaded when user clicks on it + await self._save_widget_to_session(return_widget_item, context) + yield ThreadItemDoneEvent(item=return_widget_item) + + async def _handle_booking_confirm_action( + self, + thread: ThreadMetadata, + action: Action[str, Any], + _sender: WidgetItem | None, + context: ADKContext, + ) -> AsyncIterator[ThreadStreamEvent]: + payload = action.payload or {} + destination = payload.get("destination", "the trip") + depart_label = payload.get("depart_label", "outbound flight") + return_label = payload.get("return_label", "return flight") + + hidden_message = ( + f"[HIDDEN]\n" + f"User confirmed booking:\n" + f"Destination: {destination}\n" + f"Outbound: {depart_label}\n" + f"Return: {return_label}" + ) + async for event in self._run_agent_with_message(thread, hidden_message, context): + yield event + + async def _handle_booking_modify_action( + self, + thread: ThreadMetadata, + _action: Action[str, Any], + _sender: WidgetItem | None, + context: ADKContext, + ) -> AsyncIterator[ThreadStreamEvent]: + yield ThreadItemDoneEvent( + item=self._assistant_message( + thread, + ( + "Happy to tweak the plan. Let me know what you'd like to " + "change and feel free to attach a new inspiration photo " + "if it helps." + ), + context, + ), + ) + + async def _handle_upgrade_accept_action( + self, + thread: ThreadMetadata, + action: Action[str, Any], + _sender: WidgetItem | None, + context: ADKContext, + ) -> AsyncIterator[ThreadStreamEvent]: + async for event in self._handle_upgrade_action(thread, action, context, accepted=True): + yield event + + async def _handle_upgrade_decline_action( + self, + thread: ThreadMetadata, + action: Action[str, Any], + _sender: WidgetItem | None, + context: ADKContext, + ) -> AsyncIterator[ThreadStreamEvent]: + async for event in self._handle_upgrade_action(thread, action, context, accepted=False): + yield event + + async def _handle_upgrade_action( + self, + thread: ThreadMetadata, + action: Action[str, Any], + context: ADKContext, + *, + accepted: bool, + ) -> AsyncIterator[ThreadStreamEvent]: + cabin = (action.payload or {}).get("cabin_name", "the upgrade") + price = (action.payload or {}).get("price", "the quoted amount") + + if accepted: + hidden_message = f"[HIDDEN]\nUser accepted {cabin} upgrade for {price}." + else: + hidden_message = f"[HIDDEN]\nUser declined {cabin} upgrade." + + async for event in self._run_agent_with_message(thread, hidden_message, context): + yield event + + async def _handle_rebook_action( + self, + thread: ThreadMetadata, + action: Action[str, Any], + _sender: WidgetItem | None, + context: ADKContext, + ) -> AsyncIterator[ThreadStreamEvent]: + payload = action.payload or {} + flight_number = payload.get("flight_number") + option_id = payload.get("option_id") + depart_time = payload.get("depart_time") + arrival_time = payload.get("arrival_time") + note = payload.get("option_note", "Selected alternate time") + + if not flight_number or option_id is None: + return + + if option_id == "keep": + yield ThreadItemDoneEvent( + item=self._assistant_message( + thread, + "Sounds good — we'll keep the original departure on file.", + context, + ), + ) + return + + hidden_message = ( + f"[HIDDEN]\n" + f"User requested rebooking:\n" + f"Flight: {flight_number}\n" + f"New departure: {depart_time}\n" + f"New arrival: {arrival_time}\n" + f"Note: {note}" + ) + async for event in self._run_agent_with_message(thread, hidden_message, context): + yield event + + async def _run_agent_with_message( + self, + thread: ThreadMetadata, + message_text: str, + context: ADKContext, + ) -> AsyncIterator[ThreadStreamEvent]: + """Run agent with a hidden message to update state.""" + agent_context = ADKAgentContext( + app_name=context.app_name, + user_id=context.user_id, + thread=thread, + ) + + content = genai_types.Content( + role="user", + parts=[genai_types.Part.from_text(text=message_text)], + ) + + event_stream = self._runner.run_async( + user_id=context.user_id, + session_id=thread.id, + new_message=content, + run_config=ChatkitRunConfig(streaming_mode=StreamingMode.SSE, context=agent_context), + ) + + async for event in stream_agent_response(agent_context, event_stream): + yield event async def _adk_respond( self, @@ -68,6 +526,10 @@ async def _adk_respond( if not message_text: return + # Update thread title if needed + if thread.title is None: + await self._maybe_update_thread_title(thread, message_text, context) + content = genai_types.Content( role="user", parts=[genai_types.Part.from_text(text=message_text)], @@ -88,3 +550,74 @@ async def _adk_respond( async for event in stream_agent_response(agent_context, event_stream): yield event + + async def _maybe_update_thread_title( + self, + thread: ThreadMetadata, + message_text: str, + context: ADKContext, + ) -> None: + """Generate and update thread title.""" + try: + if len(message_text) > 50: + thread.title = message_text[:47] + "..." + else: + thread.title = message_text + await self._store.save_thread(thread, context) + except Exception: + pass + + @staticmethod + def _parse_meal_preference_payload(action: Action[str, Any]) -> SetMealPreferencePayload | None: + try: + return SetMealPreferencePayload.model_validate(action.payload or {}) + except ValidationError as exc: + logger.warning("Invalid meal preference payload: %s", exc) + return None + + @staticmethod + def _parse_flight_select_payload(action: Action[str, Any]) -> FlightSelectPayload | None: + try: + return FlightSelectPayload.model_validate(action.payload or {}) + except ValidationError as exc: + logger.warning("Invalid flight selection payload: %s", exc) + return None + + def _assistant_message( + self, + thread: ThreadMetadata, + text: str, + context: ADKContext, + ) -> AssistantMessageItem: + return AssistantMessageItem( + thread_id=thread.id, + id=uuid4().hex, + created_at=datetime.now(), + content=[AssistantMessageContent(text=text)], + ) + + +def _generate_flight_number(leg: str) -> str: + suffix = "1" if leg == "outbound" else "2" + return f"OA9{suffix}7" + + +def _pick_default_seat(cabin: str) -> str: + """Return a randomized seat assignment biased by fare class.""" + normalized = cabin.lower().strip() + seat_letters = { + "first": ["A", "D"], + "business": ["A", "C", "D", "F"], + "premium economy": list("ABCDEF"), + "economy": list("ABCDEF"), + } + row_ranges = { + "first": (1, 3), + "business": (4, 9), + "premium economy": (10, 19), + "economy": (20, 45), + } + letters = seat_letters.get(normalized, list("ABCDEF")) + start, end = row_ranges.get(normalized, (12, 38)) + row = random.randint(start, end) + return f"{row}{random.choice(letters)}" diff --git a/examples/backend/src/backend/agents/airline/_state.py b/examples/backend/src/backend/agents/airline/_state.py index 33697ab..9861d19 100644 --- a/examples/backend/src/backend/agents/airline/_state.py +++ b/examples/backend/src/backend/agents/airline/_state.py @@ -44,18 +44,24 @@ class CustomerProfile(BaseModel): def log(self, entry: str, kind: str = "info") -> None: self.timeline.insert(0, {"timestamp": _now_iso(), "kind": kind, "entry": entry}) + def to_dict(self) -> dict[str, Any]: + data = self.model_dump() + data["segments"] = [seg.model_dump() for seg in self.segments] + return data + def format(self) -> str: - segments = [] + """Return a formatted string for agent context.""" + segment_lines = [] for segment in self.segments: - segments.append( + segment_lines.append( f"- {segment.flight_number} {segment.origin}->{segment.destination}" f" on {segment.date} seat {segment.seat} ({segment.status})" ) - summary = "\n".join(segments) - timeline = self.timeline[:3] - recent = "\n".join(f" * {entry['entry']} ({entry['timestamp']})" for entry in timeline) + summary = "\n".join(segment_lines) + recent_timeline = self.timeline[:3] + recent = "\n".join(f" * {entry['entry']} ({entry['timestamp']})" for entry in recent_timeline) return ( - "Customer Profile\n" + "\n" f"Name: {self.name} ({self.loyalty_status})\n" f"Loyalty ID: {self.loyalty_id}\n" f"Contact: {self.email}, {self.phone}\n" @@ -63,20 +69,25 @@ def format(self) -> str: f"Meal Preference: {self.meal_preference or 'Not set'}\n" f"Special Assistance: {self.special_assistance or 'None'}\n" "Upcoming Segments:\n" - f"{summary}\n" + f"{summary or ' * No segments scheduled.'}\n" "Recent Service Timeline:\n" - f"{recent or ' * No service actions recorded yet.'}" + f"{recent or ' * No service actions recorded yet.'}\n" + "" ) class AirlineAgentContext(BaseModel): + """Context stored in ADK session state for airline support agent.""" + customer_profile: CustomerProfile + booked_widget_ids: list[str] = [] @staticmethod def create_initial_context() -> AirlineAgentContext: + """Create a new context with default customer profile.""" segments = [ FlightSegment( - flight_number="0A476", + flight_number="OA476", date="2025-10-02", origin="SFO", destination="JFK", @@ -85,7 +96,7 @@ def create_initial_context() -> AirlineAgentContext: seat="14A", ), FlightSegment( - flight_number="0A477", + flight_number="OA477", date="2025-10-10", origin="JFK", destination="SFO", @@ -145,6 +156,45 @@ def set_meal(self, meal: str) -> str: self.customer_profile.log(f"Meal preference updated to {meal}.", kind="info") return f"We'll note {meal} as the meal preference." + def record_flight_booking( + self, + flight_number: str, + date: str, + origin: str, + destination: str, + depart_time: str, + arrival_time: str, + *, + seat: str = "TBD", + status: str = "Scheduled", + ) -> FlightSegment: + """Record a new flight booking on the customer's itinerary.""" + segment = FlightSegment( + flight_number=flight_number, + date=date, + origin=origin, + destination=destination, + departure_time=depart_time, + arrival_time=arrival_time, + seat=seat, + status=status, + ) + self.customer_profile.segments.append(segment) + self.customer_profile.log( + f"{status}: {flight_number} {origin}->{destination} on {date} {depart_time}-{arrival_time} seat {seat}.", + kind="success", + ) + return segment + + def mark_widget_consumed(self, widget_id: str) -> None: + """Mark a widget as consumed so it can't be reused.""" + if widget_id not in self.booked_widget_ids: + self.booked_widget_ids.append(widget_id) + + def is_widget_consumed(self, widget_id: str) -> bool: + """Check if a widget has already been consumed.""" + return widget_id in self.booked_widget_ids + def request_assistance(self, note: str) -> str: self.customer_profile.special_assistance = note self.customer_profile.log(f"Special assistance noted: {note}.", kind="info") diff --git a/examples/backend/src/backend/agents/airline/_title_agent.py b/examples/backend/src/backend/agents/airline/_title_agent.py new file mode 100644 index 0000000..36a8bd6 --- /dev/null +++ b/examples/backend/src/backend/agents/airline/_title_agent.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from typing import Final + +from google.adk.agents.llm_agent import LlmAgent +from google.adk.models.lite_llm import LiteLlm +from google.genai import types as genai_types + +_INSTRUCTIONS: Final[str] = """ +Generate a title for a conversation between an airline concierge acting on +behalf of the traveller and the user. +The first user message in the conversation is included below. +Do not just repeat the user message, use your own words. +YOU MUST respond with 2-5 words without punctuation. +""" + + +class TitleAgent(LlmAgent): + def __init__( + self, + llm: LiteLlm, + generate_content_config: genai_types.GenerateContentConfig | None = None, + ) -> None: + super().__init__( + name="airline_title_generator", + description="Generates short conversation titles for airline support chats.", + model=llm, + instruction=_INSTRUCTIONS, + tools=[], + generate_content_config=generate_content_config, + ) diff --git a/examples/backend/src/backend/agents/airline/_tools.py b/examples/backend/src/backend/agents/airline/_tools.py index b1c6458..a7aa327 100644 --- a/examples/backend/src/backend/agents/airline/_tools.py +++ b/examples/backend/src/backend/agents/airline/_tools.py @@ -1,6 +1,40 @@ -from google.adk.tools import ToolContext +from __future__ import annotations + +from adk_chatkit import stream_event, stream_widget +from chatkit.types import ClientEffectEvent +from google.adk.tools.tool_context import ToolContext from ._state import AirlineAgentContext +from .widgets import ( + FlightSearchRequest, + build_flight_options_widget, + build_meal_preference_widget, + generate_flight_options, +) + + +def _get_context(tool_context: ToolContext) -> AirlineAgentContext: + """Get airline context from session state, creating if needed.""" + context = tool_context.state.get("context", None) + if context is None: + ctx = AirlineAgentContext.create_initial_context() + tool_context.state["context"] = ctx.model_dump() + return ctx + return AirlineAgentContext.model_validate(context) + + +def _save_context(tool_context: ToolContext, ctx: AirlineAgentContext) -> None: + """Save updated context back to session state.""" + tool_context.state["context"] = ctx.model_dump() + + +async def _sync_profile_effect(tool_context: ToolContext, ctx: AirlineAgentContext) -> None: + """Send profile update effect to the client.""" + event = ClientEffectEvent( + name="customer_profile/update", + data={"profile": ctx.customer_profile.to_dict()}, + ) + await stream_event(event, tool_context) def get_customer_profile(tool_context: ToolContext) -> str: @@ -9,13 +43,11 @@ def get_customer_profile(tool_context: ToolContext) -> str: Returns: A string with the formatted customer profile. """ + ctx = _get_context(tool_context) + return ctx.customer_profile.format() - context: AirlineAgentContext = AirlineAgentContext.model_validate(tool_context.state["context"]) - - return context.customer_profile.format() - -def change_seat(flight_number: str, seat: str, tool_context: ToolContext) -> dict[str, str]: +async def change_seat(flight_number: str, seat: str, tool_context: ToolContext) -> dict[str, str]: """Move the passenger to a different seat on a flight. Args: @@ -25,74 +57,103 @@ def change_seat(flight_number: str, seat: str, tool_context: ToolContext) -> dic Returns: A dictionary with a message confirming the seat change. """ - - context: AirlineAgentContext = AirlineAgentContext.model_validate(tool_context.state["context"]) - - try: - message = context.change_seat(flight_number, seat) - except ValueError as exc: # translate user errors - raise ValueError(str(exc)) from exc - - # Persist updated context - tool_context.state["context"] = context.model_dump() - + ctx = _get_context(tool_context) + message = ctx.change_seat(flight_number, seat) + _save_context(tool_context, ctx) + await _sync_profile_effect(tool_context, ctx) return {"result": message} -def cancel_trip(tool_context: ToolContext) -> dict[str, str]: +async def cancel_trip(tool_context: ToolContext) -> dict[str, str]: """Cancel the traveller's upcoming trip and note the refund. Returns: A dictionary with a message confirming the cancellation. """ - - context: AirlineAgentContext = AirlineAgentContext.model_validate(tool_context.state["context"]) - - message = context.cancel_trip() - - # Persist updated context - tool_context.state["context"] = context.model_dump() - + ctx = _get_context(tool_context) + message = ctx.cancel_trip() + _save_context(tool_context, ctx) + await _sync_profile_effect(tool_context, ctx) return {"result": message} -def add_checked_bag(tool_context: ToolContext) -> dict[str, str | int]: +async def add_checked_bag(tool_context: ToolContext) -> dict[str, str | int]: """Add a checked bag to the reservation. Returns: A dictionary with a message confirming the addition and the total bags checked. """ + ctx = _get_context(tool_context) + message = ctx.add_bag() + _save_context(tool_context, ctx) + await _sync_profile_effect(tool_context, ctx) + return {"result": message, "bags_checked": ctx.customer_profile.bags_checked} - context: AirlineAgentContext = AirlineAgentContext.model_validate(tool_context.state["context"]) - - message = context.add_bag() - # Persist updated context - tool_context.state["context"] = context.model_dump() +async def meal_preference_list(tool_context: ToolContext) -> dict[str, str]: + """Display the meal preference picker widget. - return {"result": message, "bags_checked": context.customer_profile.bags_checked} + Returns: + A dictionary with a message indicating the widget was shown. + """ + widget = build_meal_preference_widget() + await stream_widget(widget, tool_context) + return {"result": "Shared meal preference options with the traveller."} -def set_meal_preference(meal: str, tool_context: ToolContext) -> dict[str, str]: - """Record or update the passenger's meal preference. +async def set_meal_preference(meal: str, tool_context: ToolContext) -> dict[str, str]: + """Set the traveller's meal preference. Args: meal: The meal preference to set (e.g. vegetarian). Returns: A dictionary with a message confirming the meal preference update. """ + ctx = _get_context(tool_context) + message = ctx.set_meal(meal) + _save_context(tool_context, ctx) + await _sync_profile_effect(tool_context, ctx) + return {"result": message} - context: AirlineAgentContext = AirlineAgentContext.model_validate(tool_context.state["context"]) - message = context.set_meal(meal) +async def flight_option_list( + destination: str, + depart_date: str, + return_date: str, + cabin: str, + tool_context: ToolContext, + origin: str | None = None, +) -> dict[str, str]: + """Share specific flight options after collecting destination, dates, and cabin. + Returns: + A dictionary with a message indicating the widget was shown. + """ + ctx = _get_context(tool_context) - # Persist updated context - tool_context.state["context"] = context.model_dump() + origin_airport = (origin or "SFO").strip().upper() - return {"result": message} + request = FlightSearchRequest( + origin=origin_airport, + destination=destination.strip().upper(), + depart_date=depart_date.strip(), + return_date=return_date.strip(), + cabin=cabin.strip(), + ) + ctx.customer_profile.log( + f"Booking request for {origin_airport} → {destination.upper()} ({depart_date} to {return_date}).", + kind="info", + ) + _save_context(tool_context, ctx) -def request_assistance(note: str, tool_context: ToolContext) -> dict[str, str]: + options = generate_flight_options(request) + widget = build_flight_options_widget(options, request) + await stream_widget(widget, tool_context) + + return {"result": "Shared flight options with the traveller."} + + +async def request_assistance(note: str, tool_context: ToolContext) -> dict[str, str]: """Note a special assistance request for airport staff. Args: @@ -100,12 +161,8 @@ def request_assistance(note: str, tool_context: ToolContext) -> dict[str, str]: Returns: A dictionary with a message confirming the assistance request. """ - - context: AirlineAgentContext = AirlineAgentContext.model_validate(tool_context.state["context"]) - - message = context.request_assistance(note) - - # Persist updated context - tool_context.state["context"] = context.model_dump() - + ctx = _get_context(tool_context) + message = ctx.request_assistance(note) + _save_context(tool_context, ctx) + await _sync_profile_effect(tool_context, ctx) return {"result": message} diff --git a/examples/backend/src/backend/agents/airline/widgets/__init__.py b/examples/backend/src/backend/agents/airline/widgets/__init__.py new file mode 100644 index 0000000..3322b57 --- /dev/null +++ b/examples/backend/src/backend/agents/airline/widgets/__init__.py @@ -0,0 +1,29 @@ +from ._flight_options_widget import ( + FLIGHT_SELECT_ACTION_TYPE, + FlightOption, + FlightSearchRequest, + FlightSelectPayload, + build_flight_options_widget, + describe_flight_option, + generate_flight_options, +) +from ._meal_preferences_widget import ( + SET_MEAL_PREFERENCE_ACTION_TYPE, + SetMealPreferencePayload, + build_meal_preference_widget, + meal_preference_label, +) + +__all__ = [ + "FLIGHT_SELECT_ACTION_TYPE", + "FlightOption", + "FlightSearchRequest", + "FlightSelectPayload", + "build_flight_options_widget", + "describe_flight_option", + "generate_flight_options", + "SET_MEAL_PREFERENCE_ACTION_TYPE", + "SetMealPreferencePayload", + "build_meal_preference_widget", + "meal_preference_label", +] diff --git a/examples/backend/src/backend/agents/airline/widgets/_flight_options_widget.py b/examples/backend/src/backend/agents/airline/widgets/_flight_options_widget.py new file mode 100644 index 0000000..cee5a92 --- /dev/null +++ b/examples/backend/src/backend/agents/airline/widgets/_flight_options_widget.py @@ -0,0 +1,276 @@ +"""Flight options widget for the airline support agent.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal + +from chatkit.widgets import ListView, WidgetRoot +from pydantic import BaseModel + +FlightCabin = Literal["economy", "premium economy", "business", "first"] + +FLIGHT_SELECT_ACTION_TYPE = "flight.select" +FlightLeg = Literal["outbound", "return"] + + +class FlightSearchRequest(BaseModel): + """Minimal search context used to render flight options.""" + + origin: str + destination: str + depart_date: str + return_date: str + cabin: str + + def normalized_origin(self) -> str: + return _sanitize_airport_code(self.origin) + + def normalized_destination(self) -> str: + return _sanitize_airport_code(self.destination) + + +class FlightOption(BaseModel): + """Single flight option shown in the picker.""" + + id: str + from_airport: str + to_airport: str + dep_time: str + arr_time: str + date_label: str + cabin: str + + model_config = {"populate_by_name": True} + + +class FlightSelectPayload(BaseModel): + """Payload sent when the user taps a flight option.""" + + id: str + options: list[FlightOption] + request: FlightSearchRequest + leg: FlightLeg = "outbound" + + +def _sanitize_airport_code(raw: str) -> str: + code = raw.strip().upper() + if len(code) == 3 and code.isalpha(): + return code + return code[:3] + + +def _format_date_label(raw_date: str) -> str: + """Convert YYYY-MM-DD into a friendly label, otherwise return the input.""" + + try: + parsed = datetime.fromisoformat(raw_date) + return f"{parsed.strftime('%a, %b ')}{parsed.day}" + except Exception: + return raw_date + + +def generate_flight_options( + request: FlightSearchRequest, +) -> list[FlightOption]: + """Return a small set of plausible flight options for the widget.""" + + date_label = _format_date_label(request.depart_date) + cabin_label = request.cabin.title() + return [ + FlightOption( + id="flight-morning", + from_airport=request.normalized_origin(), + to_airport=request.normalized_destination(), + dep_time="08:10", + arr_time="16:40", + date_label=date_label, + cabin=cabin_label, + ), + FlightOption( + id="flight-midday", + from_airport=request.normalized_origin(), + to_airport=request.normalized_destination(), + dep_time="12:35", + arr_time="21:05", + date_label=date_label, + cabin=cabin_label, + ), + FlightOption( + id="flight-late", + from_airport=request.normalized_origin(), + to_airport=request.normalized_destination(), + dep_time="21:55", + arr_time="06:20 (+1)", + date_label=date_label, + cabin=cabin_label, + ), + ] + + +def _serialize_option(option: FlightOption) -> dict[str, Any]: + """Return a template-friendly dict for a flight option.""" + + return { + "id": option.id, + "from_airport": option.from_airport, + "to_airport": option.to_airport, + "dep_time": option.dep_time, + "arr_time": option.arr_time, + "date_label": option.date_label, + "cabin": option.cabin, + } + + +def describe_flight_option(option: FlightOption, request: FlightSearchRequest) -> str: + """Human-readable summary used in assistant replies and logs.""" + + return ( + f"{option.cabin} {option.from_airport} → {option.to_airport} on " + f"{_format_date_label(request.depart_date)} departing " + f"{option.dep_time} (arrives {option.arr_time}); " + f"return on {request.return_date}" + ) + + +def build_flight_options_widget( + options: list[FlightOption], + request: FlightSearchRequest, + *, + selected_id: str | None = None, + leg: FlightLeg = "outbound", +) -> WidgetRoot: + """Render the flight picker widget programmatically.""" + + items: list[dict[str, Any]] = [] + for option in options: + is_selected = selected_id == option.id + + item_data: dict[str, Any] = { + "type": "ListViewItem", + "key": option.id, + "gap": 0, + "align": "stretch", + "children": [ + { + "type": "Box", + "width": "100%", + "padding": 3, + "border": { + "size": 1, + "color": "blue-500" if is_selected else "default", + "style": "solid" if is_selected else "dashed", + }, + "radius": "xl", + "background": "surface", + "children": [ + { + "type": "Row", + "gap": 3, + "align": "stretch", + "children": [ + { + "type": "Box", + "width": 3, + "background": "blue-600", + "radius": "full", + }, + { + "type": "Col", + "flex": "auto", + "gap": 2, + "children": [ + { + "type": "Row", + "children": [ + { + "type": "Title", + "value": f"{option.from_airport} → {option.to_airport}", + "size": "md", + }, + {"type": "Spacer"}, + { + "type": "Badge", + "label": option.cabin, + "color": "discovery", + "variant": "soft", + }, + ], + }, + { + "type": "Row", + "children": [ + { + "type": "Col", + "children": [ + { + "type": "Caption", + "value": "Depart", + "color": "secondary", + }, + { + "type": "Title", + "value": option.dep_time, + "size": "lg", + }, + ], + }, + {"type": "Spacer"}, + { + "type": "Col", + "align": "end", + "children": [ + { + "type": "Caption", + "value": "Arrive", + "color": "secondary", + }, + { + "type": "Title", + "value": option.arr_time, + "size": "lg", + }, + ], + }, + ], + }, + {"type": "Divider"}, + { + "type": "Row", + "children": [ + { + "type": "Caption", + "value": option.date_label, + }, + {"type": "Spacer"}, + { + "type": "Caption", + "value": f"{option.from_airport} • {option.to_airport}", + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + } + + if not is_selected: + item_data["onClickAction"] = { + "type": FLIGHT_SELECT_ACTION_TYPE, + "handler": "server", + "payload": { + "id": option.id, + "options": [_serialize_option(opt) for opt in options], + "request": request.model_dump(mode="json"), + "leg": leg, + }, + } + + items.append(item_data) + + widget_data = {"type": "ListView", "children": items} + return ListView.model_validate(widget_data) diff --git a/examples/backend/src/backend/agents/airline/widgets/_meal_preferences_widget.py b/examples/backend/src/backend/agents/airline/widgets/_meal_preferences_widget.py new file mode 100644 index 0000000..e4f1ca1 --- /dev/null +++ b/examples/backend/src/backend/agents/airline/widgets/_meal_preferences_widget.py @@ -0,0 +1,88 @@ +"""Meal preference widget for the airline support agent.""" + +from __future__ import annotations + +from typing import Any, Literal + +from chatkit.widgets import ListView, WidgetRoot +from pydantic import BaseModel + +MealPreferenceOption = Literal[ + "vegetarian", + "kosher", + "gluten intolerant", + "child", +] + +SET_MEAL_PREFERENCE_ACTION_TYPE = "support.set_meal_preference" + + +class SetMealPreferencePayload(BaseModel): + meal: MealPreferenceOption + + +_MEAL_PREFERENCE_LABELS: dict[MealPreferenceOption, str] = { + "vegetarian": "Vegetarian", + "kosher": "Kosher", + "gluten intolerant": "Gluten intolerant", + "child": "Child", +} + +MEAL_PREFERENCE_ORDER: tuple[MealPreferenceOption, ...] = ( + "vegetarian", + "kosher", + "gluten intolerant", + "child", +) + + +def meal_preference_label(value: MealPreferenceOption) -> str: + return _MEAL_PREFERENCE_LABELS.get(value, value.title()) + + +def build_meal_preference_widget( + *, + selected: MealPreferenceOption | None = None, +) -> WidgetRoot: + """Render the meal preference list widget with optional selection state.""" + + items: list[dict[str, Any]] = [] + for option_value in MEAL_PREFERENCE_ORDER: + is_selected = selected == option_value + label = meal_preference_label(option_value) + + item_data: dict[str, Any] = { + "type": "ListViewItem", + "key": option_value, + "children": [ + { + "type": "Row", + "gap": 2, + "children": [ + { + "type": "Icon", + "name": "check" if is_selected else "empty-circle", + "color": "secondary", + }, + { + "type": "Text", + "value": label, + "weight": "semibold" if is_selected else "medium", + **({"color": "emphasis"} if is_selected else {}), + }, + ], + }, + ], + } + + if not selected: + item_data["onClickAction"] = { + "type": SET_MEAL_PREFERENCE_ACTION_TYPE, + "handler": "server", + "payload": {"meal": option_value}, + } + + items.append(item_data) + + widget_data = {"type": "ListView", "children": items} + return ListView.model_validate(widget_data) diff --git a/examples/backend/src/backend/api/support.py b/examples/backend/src/backend/api/support.py index 1c1ec4f..0f638ef 100644 --- a/examples/backend/src/backend/api/support.py +++ b/examples/backend/src/backend/api/support.py @@ -24,8 +24,6 @@ async def chatkit_endpoint( request_server: FromDishka[AirlineSupportChatKitServer], ) -> Response: payload = await request.body() - print("Received payload:", payload) - user_id = "ksachdeva-1" result = await request_server.process( @@ -33,8 +31,6 @@ async def chatkit_endpoint( ADKContext(user_id=user_id, app_name=settings.AIRLINE_APP_NAME), ) - print(result) - if isinstance(result, StreamingResult): return StreamingResponse(result, media_type="text/event-stream") if hasattr(result, "json"): @@ -53,13 +49,12 @@ async def customer_snapshot( settings: FromDishka[Settings], thread_id: str | None = Query(None, description="ChatKit thread identifier"), ) -> dict[str, Any]: - # hard coded user id for now - # as not doing an authentication + """Return customer profile for a given thread, or default if no thread.""" user_id = "ksachdeva-1" if not thread_id: - context = AirlineAgentContext.create_initial_context().model_dump() - return {"customer": context["customer_profile"]} + initial_context = AirlineAgentContext.create_initial_context() + return {"customer": initial_context.customer_profile.to_dict()} session = await session_service.get_session( app_name=settings.AIRLINE_APP_NAME, @@ -68,11 +63,14 @@ async def customer_snapshot( ) if not session: - raise ValueError(f"Session with id {thread_id} not found") + initial_context = AirlineAgentContext.create_initial_context() + return {"customer": initial_context.customer_profile.to_dict()} - context: dict[str, Any] | None = session.state.get("context", None) + context_dict: dict[str, Any] | None = session.state.get("context", None) - if context is None: - raise ValueError(f"No context found in session {thread_id}") + if context_dict is None: + initial_context = AirlineAgentContext.create_initial_context() + return {"customer": initial_context.customer_profile.to_dict()} - return {"customer": context["customer_profile"]} + ctx = AirlineAgentContext.model_validate(context_dict) + return {"customer": ctx.customer_profile.to_dict()} diff --git a/examples/frontend/package-lock.json b/examples/frontend/package-lock.json index 192e12f..b7e44fd 100644 --- a/examples/frontend/package-lock.json +++ b/examples/frontend/package-lock.json @@ -8,7 +8,7 @@ "name": "customer-support-frontend", "version": "0.1.0", "dependencies": { - "@openai/chatkit-react": "^1.4.0", + "@openai/chatkit-react": "^1.4.3", "@xyflow/react": "^12.7.0", "canvas-confetti": "^1.9.4", "clsx": "^2.1.1", @@ -794,18 +794,18 @@ } }, "node_modules/@openai/chatkit": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@openai/chatkit/-/chatkit-1.4.0.tgz", - "integrity": "sha512-MqNaK8EHKO5bf0flekY/JNjX55eh7Ae0Yt8Y3T9HwSN0E1ULkk5PYn2bM7Qllltb8FnO5nXNc6bD9yqrIfNlQA==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@openai/chatkit/-/chatkit-1.5.0.tgz", + "integrity": "sha512-F9G91ZNq7bmDsoP4ToDWmv1FMZx2bLZ6WHf+YsKvQ8uw+PBRdkaRGG4WlalnnRl0ekQLhVB1kNjsWcQJpzalUg==", "license": "MIT" }, "node_modules/@openai/chatkit-react": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/@openai/chatkit-react/-/chatkit-react-1.4.2.tgz", - "integrity": "sha512-qEA5Jw9+Fnc6H7X5RmhDUR7+bEW31D5HWN2yGSgiwBVtfML74uIlNuJxCfD39fXWDbHneDR6QJJtzMhalWD7Yg==", + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@openai/chatkit-react/-/chatkit-react-1.4.3.tgz", + "integrity": "sha512-Y/isbMh2CSFTtg2oL7FbmsfAnGGS6PDESQkjH9AJ42/TNGxAF53FIZM19PmrhdUq2QLkEzAf6adQ+pu8GO0Yhw==", "license": "MIT", "dependencies": { - "@openai/chatkit": "1.4.0" + "@openai/chatkit": "1.5.0" }, "peerDependencies": { "react": ">=18", diff --git a/examples/frontend/package.json b/examples/frontend/package.json index 60a2541..85a0d2f 100644 --- a/examples/frontend/package.json +++ b/examples/frontend/package.json @@ -14,7 +14,7 @@ "npm": ">=9" }, "dependencies": { - "@openai/chatkit-react": "^1.4.0", + "@openai/chatkit-react": "^1.4.3", "@xyflow/react": "^12.7.0", "canvas-confetti": "^1.9.4", "clsx": "^2.1.1", diff --git a/examples/frontend/src/assets/customer-support/paris.png b/examples/frontend/src/assets/customer-support/paris.png new file mode 100644 index 0000000..6c45b58 Binary files /dev/null and b/examples/frontend/src/assets/customer-support/paris.png differ diff --git a/examples/frontend/src/assets/customer-support/santorini.png b/examples/frontend/src/assets/customer-support/santorini.png new file mode 100644 index 0000000..8ddc12b Binary files /dev/null and b/examples/frontend/src/assets/customer-support/santorini.png differ diff --git a/examples/frontend/src/assets/customer-support/tokyo.png b/examples/frontend/src/assets/customer-support/tokyo.png new file mode 100644 index 0000000..c8bfde2 Binary files /dev/null and b/examples/frontend/src/assets/customer-support/tokyo.png differ diff --git a/examples/frontend/src/components/ThemeToggle.tsx b/examples/frontend/src/components/ThemeToggle.tsx index c000646..75403f5 100644 --- a/examples/frontend/src/components/ThemeToggle.tsx +++ b/examples/frontend/src/components/ThemeToggle.tsx @@ -1,13 +1,24 @@ import clsx from "clsx"; import { Moon, Sun } from "lucide-react"; import { useAppStore } from "../store/useAppStore"; +import type { ColorScheme } from "../hooks/useColorScheme"; + +type ThemeToggleProps = { + value?: ColorScheme; + onChange?: (scheme: ColorScheme) => void; +}; const buttonBase = "inline-flex h-9 w-9 items-center justify-center rounded-full text-[0.7rem] transition-colors duration-200"; -export function ThemeToggle() { - const scheme = useAppStore((state) => state.scheme); - const setScheme = useAppStore((state) => state.setScheme); +export function ThemeToggle({ value, onChange }: ThemeToggleProps) { + const storeScheme = useAppStore((state) => state.scheme); + const storeSetScheme = useAppStore((state) => state.setScheme); + + // Use props if provided, otherwise fall back to store + const scheme = value ?? storeScheme; + const setScheme = onChange ?? storeSetScheme; + return (
+ ))} + + ); -} -function Timeline({ entries }: { entries: TimelineEntry[] }) { - if (!entries.length) { - return ( -
- No concierge actions recorded yet. -
- ); + let bodyContent: ReactNode = null; + if (view === "trips") { + bodyContent = ; + } else if (view === "loyalty") { + bodyContent = ; } return ( -
    - {entries.map((entry) => ( -
  • -

    {entry.entry}

    -

    - {formatTimestamp(entry.timestamp)} -

    -
  • - ))} -
+
+ {headerContent} +
+ {bodyContent} + {view === "overview" ? ( + + ) : null} +
+ { + const file = event.target.files?.[0]; + if (!file) { + return; + } + await handleCustomUpload(file); + event.target.value = ""; + }} + /> +
); } -function timelineTone(kind: string | undefined) { - switch (kind) { - case "success": - return "border-emerald-200/70 bg-emerald-50/80 text-emerald-700 dark:border-emerald-900/40 dark:bg-emerald-900/30 dark:text-emerald-200"; - case "warning": - return "border-amber-200/70 bg-amber-50/80 text-amber-700 dark:border-amber-900/40 dark:bg-amber-900/30 dark:text-amber-200"; - case "error": - return "border-rose-200/70 bg-rose-50/80 text-rose-700 dark:border-rose-900/40 dark:bg-rose-900/30 dark:text-rose-200"; - default: - return "border-slate-200/70 bg-slate-50/90 text-slate-600 dark:border-slate-800/70 dark:bg-slate-900/60 dark:text-slate-200"; - } -} - -function formatTimestamp(value: string): string { - try { - const date = new Date(value); - return `${date.toLocaleDateString()} ${date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`; - } catch (err) { - return value; - } -} - -function formatDate(value: string): string { - try { - return new Date(value).toLocaleDateString(undefined, { - month: "short", - day: "numeric", - year: "numeric", - }); - } catch (err) { - return value; - } +async function dataUrlToFile(dataUrl: string, filename: string): Promise { + const response = await fetch(dataUrl); + const blob = await response.blob(); + const mimeType = blob.type || "image/jpeg"; + return new File([blob], filename, { type: mimeType }); } - diff --git a/examples/frontend/src/components/customer-support/customer-context/DestinationIdeasShelf.tsx b/examples/frontend/src/components/customer-support/customer-context/DestinationIdeasShelf.tsx new file mode 100644 index 0000000..49012ef --- /dev/null +++ b/examples/frontend/src/components/customer-support/customer-context/DestinationIdeasShelf.tsx @@ -0,0 +1,76 @@ +import { Upload } from "lucide-react"; +import type { RefObject } from "react"; + +import type { BookingDestinationCard } from "../../../types/support"; +import { MAX_UPLOAD_SIZE_MB } from "../../../lib/customer-support/uploads"; + +type DestinationIdeasShelfProps = { + destinations: BookingDestinationCard[]; + uploadError: string | null; + onAttachDestination: (destination: BookingDestinationCard) => Promise; + fileInputRef: RefObject; +}; + +export function DestinationIdeasShelf({ + destinations, + uploadError, + onAttachDestination, + fileInputRef, +}: DestinationIdeasShelfProps) { + return ( +
+
+

+ Destination ideas +

+ +

+ PNG or JPG up to {MAX_UPLOAD_SIZE_MB}MB +

+
+ {uploadError ? ( +

+ {uploadError} +

+ ) : null} +
+ {destinations.map((destination) => ( + + ))} +
+
+ ); +} diff --git a/examples/frontend/src/components/customer-support/customer-context/FlightsList.tsx b/examples/frontend/src/components/customer-support/customer-context/FlightsList.tsx new file mode 100644 index 0000000..7dd7e8a --- /dev/null +++ b/examples/frontend/src/components/customer-support/customer-context/FlightsList.tsx @@ -0,0 +1,56 @@ +import clsx from "clsx"; + +import type { FlightSegment } from "../../../hooks/useCustomerContext"; +import { formatDate } from "./utils"; + +type FlightsListProps = { + segments: FlightSegment[]; +}; + +export function FlightsList({ segments }: FlightsListProps) { + return ( +
+

+ Upcoming flights +

+
+ {segments.map((segment) => ( +
+
+
+

+ {segment.flight_number} +

+

+ {segment.origin} → {segment.destination} +

+

+ {formatDate(segment.date)} · {segment.departure_time} –{" "} + {segment.arrival_time} +

+
+
+

+ Seat {segment.seat} +

+

+ {segment.status} +

+
+
+
+ ))} +
+
+ ); +} diff --git a/examples/frontend/src/components/customer-support/customer-context/InfoPill.tsx b/examples/frontend/src/components/customer-support/customer-context/InfoPill.tsx new file mode 100644 index 0000000..c362798 --- /dev/null +++ b/examples/frontend/src/components/customer-support/customer-context/InfoPill.tsx @@ -0,0 +1,24 @@ +import type { LucideIcon } from "lucide-react"; +import type { ReactNode } from "react"; + +type InfoPillProps = { + icon: LucideIcon; + label: string; + children: ReactNode; +}; + +export function InfoPill({ icon: Icon, label, children }: InfoPillProps) { + return ( +
+ +
+

+ {label} +

+

+ {children} +

+
+
+ ); +} diff --git a/examples/frontend/src/components/customer-support/customer-context/LoyaltyView.tsx b/examples/frontend/src/components/customer-support/customer-context/LoyaltyView.tsx new file mode 100644 index 0000000..38dff76 --- /dev/null +++ b/examples/frontend/src/components/customer-support/customer-context/LoyaltyView.tsx @@ -0,0 +1,82 @@ +import { Star } from "lucide-react"; + +import type { CustomerProfile } from "../../../hooks/useCustomerContext"; +import { formatDate } from "./utils"; + +type LoyaltyViewProps = { + profile: CustomerProfile; +}; + +export function LoyaltyView({ profile }: LoyaltyViewProps) { + const progress = profile.loyalty_progress; + const percent = Math.min( + 100, + Math.round((progress.points_earned / progress.points_required) * 100) + ); + + return ( +
+
+
+
+

+ Status track +

+

+ {progress.current_tier} → {progress.next_tier} +

+
+ + + {percent}% to Platinum + +
+
+
+
+
+
+

+ Points +

+

+ {progress.points_earned.toLocaleString()} /{" "} + {progress.points_required.toLocaleString()} +

+
+
+

+ Segments +

+

+ {progress.segments_flown} / {progress.segments_required} +

+
+
+

+ Renewal on {formatDate(progress.renewal_date)} +

+
+ +
+

+ Gold perks +

+
    + {profile.tier_benefits.map((benefit) => ( +
  • + + {benefit} +
  • + ))} +
+
+
+ ); +} diff --git a/examples/frontend/src/components/customer-support/customer-context/OverviewView.tsx b/examples/frontend/src/components/customer-support/customer-context/OverviewView.tsx new file mode 100644 index 0000000..77345c7 --- /dev/null +++ b/examples/frontend/src/components/customer-support/customer-context/OverviewView.tsx @@ -0,0 +1,53 @@ +import { ArrowRight, Sparkles } from "lucide-react"; + +import type { CustomerProfile } from "../../../hooks/useCustomerContext"; +import type { SupportView } from "../../../types/support"; +import { TimelineList } from "./TimelineList"; + +type OverviewViewProps = { + profile: CustomerProfile; + onViewChange: (view: SupportView) => void; +}; + +export function OverviewView({ + profile, + onViewChange, +}: OverviewViewProps) { + return ( +
+
+
+
+

+ Today's snapshot +

+

+ {profile.travel_summary} +

+
+ +
+
+ {profile.spotlight.map((item) => ( + + + {item} + + ))} +
+
+ + +
+ ); +} diff --git a/examples/frontend/src/components/customer-support/customer-context/TimelineList.tsx b/examples/frontend/src/components/customer-support/customer-context/TimelineList.tsx new file mode 100644 index 0000000..349b2fe --- /dev/null +++ b/examples/frontend/src/components/customer-support/customer-context/TimelineList.tsx @@ -0,0 +1,48 @@ +import clsx from "clsx"; + +import type { TimelineEntry } from "../../../hooks/useCustomerContext"; + +type TimelineListProps = { + timeline: TimelineEntry[]; + limit?: number; +}; + +export function TimelineList({ timeline, limit }: TimelineListProps) { + const entries = limit ? timeline.slice(0, limit) : timeline; + if (!entries.length) { + return null; + } + + return ( +
+

+ Service timeline +

+
    + {entries.map((entry) => ( +
  • + +
    +

    + {entry.entry} +

    +

    + {new Date(entry.timestamp).toLocaleString()} +

    +
    +
  • + ))} +
+
+ ); +} diff --git a/examples/frontend/src/components/customer-support/customer-context/TripsView.tsx b/examples/frontend/src/components/customer-support/customer-context/TripsView.tsx new file mode 100644 index 0000000..7dc8b75 --- /dev/null +++ b/examples/frontend/src/components/customer-support/customer-context/TripsView.tsx @@ -0,0 +1,32 @@ +import { CalendarDays, Luggage, Utensils } from "lucide-react"; + +import type { CustomerProfile } from "../../../hooks/useCustomerContext"; +import { FlightsList } from "./FlightsList"; +import { InfoPill } from "./InfoPill"; +import { TimelineList } from "./TimelineList"; + +type TripsViewProps = { + profile: CustomerProfile; +}; + +export function TripsView({ profile }: TripsViewProps) { + return ( +
+ + +
+ + {profile.bags_checked} + + + {profile.meal_preference || "Not set"} + + + {profile.special_assistance || "None"} + +
+ + +
+ ); +} diff --git a/examples/frontend/src/components/customer-support/customer-context/utils.ts b/examples/frontend/src/components/customer-support/customer-context/utils.ts new file mode 100644 index 0000000..f30dc67 --- /dev/null +++ b/examples/frontend/src/components/customer-support/customer-context/utils.ts @@ -0,0 +1,11 @@ +export function formatDate(value: string): string { + try { + return new Date(value).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }); + } catch { + return value; + } +} diff --git a/examples/frontend/src/hooks/useCustomerContext.ts b/examples/frontend/src/hooks/useCustomerContext.ts index 94049cd..9134138 100644 --- a/examples/frontend/src/hooks/useCustomerContext.ts +++ b/examples/frontend/src/hooks/useCustomerContext.ts @@ -19,6 +19,16 @@ export type TimelineEntry = { entry: string; }; +export type LoyaltyProgress = { + current_tier: string; + next_tier: string; + points_earned: number; + points_required: number; + segments_flown: number; + segments_required: number; + renewal_date: string; +}; + export type CustomerProfile = { customer_id: string; name: string; @@ -26,12 +36,17 @@ export type CustomerProfile = { loyalty_id: string; email: string; phone: string; + home_airport: string; + preferred_routes: string[]; + travel_summary: string; tier_benefits: string[]; + loyalty_progress: LoyaltyProgress; segments: FlightSegment[]; bags_checked: number; meal_preference: string | null; special_assistance: string | null; timeline: TimelineEntry[]; + spotlight: string[]; }; type CustomerResponse = { @@ -71,5 +86,17 @@ export function useCustomerContext(threadId: string | null) { void fetchProfile(); }, [fetchProfile]); - return { profile, loading, error, refresh: fetchProfile }; + const applyProfileUpdate = useCallback((nextProfile: CustomerProfile) => { + setProfile(nextProfile); + setLoading(false); + setError(null); + }, []); + + return { + profile, + loading, + error, + refresh: fetchProfile, + setProfile: applyProfileUpdate, + }; } diff --git a/examples/frontend/src/lib/customer-support/config.ts b/examples/frontend/src/lib/customer-support/config.ts index cb52d07..0eaa164 100644 --- a/examples/frontend/src/lib/customer-support/config.ts +++ b/examples/frontend/src/lib/customer-support/config.ts @@ -1,39 +1,102 @@ import { StartScreenPrompt } from "@openai/chatkit"; -export const THEME_STORAGE_KEY = "adk-chatkit-theme"; +import type { SupportView } from "../../types/support"; -const SUPPORT_API_BASE = - import.meta.env.VITE_SUPPORT_API_BASE ?? "/support"; +export const THEME_STORAGE_KEY = "customer-support-theme"; +const SUPPORT_API_BASE = import.meta.env.VITE_SUPPORT_API_BASE ?? "/support"; + +/** + * ChatKit still expects a domain key at runtime. Use any placeholder locally, + * but register your production domain at + * https://platform.openai.com/settings/organization/security/domain-allowlist + * and deploy the real key. + */ export const SUPPORT_CHATKIT_API_DOMAIN_KEY = - import.meta.env.VITE_SUPPORT_CHATKIT_API_DOMAIN_KEY ?? "domain_pk_localhost_dev"; + import.meta.env.VITE_SUPPORT_CHATKIT_API_DOMAIN_KEY ?? + "domain_pk_localhost_dev"; export const SUPPORT_CHATKIT_API_URL = - import.meta.env.VITE_SUPPORT_CHATKIT_API_URL ?? - `${SUPPORT_API_BASE}/chatkit`; + import.meta.env.VITE_SUPPORT_CHATKIT_API_URL ?? `${SUPPORT_API_BASE}/chatkit`; export const SUPPORT_CUSTOMER_URL = - import.meta.env.VITE_SUPPORT_CUSTOMER_URL ?? - `${SUPPORT_API_BASE}/customer`; + import.meta.env.VITE_SUPPORT_CUSTOMER_URL ?? `${SUPPORT_API_BASE}/customer`; -export const SUPPORT_GREETING = - import.meta.env.VITE_SUPPORT_GREETING ?? - "Thanks for reaching OpenSkies concierge. How can I make your trip smoother today?"; +const OVERVIEW_PROMPTS: StartScreenPrompt[] = [ + { + label: "Book a flight", + prompt: + "Book a new trip for the traveler. Remind me to attach an inspiration photo and handle the planning as their concierge.", + icon: "map-pin", + }, + { + label: "Account snapshot", + prompt: "Give me a quick overview of my account.", + icon: "sparkle", + }, + { + label: "Trip readiness", + prompt: "Check if there are open items I should handle before my flights.", + icon: "notebook", + }, + { + label: "VIP notes", + prompt: "Summarize the VIP notes that matter most today.", + icon: "profile", + }, +]; -export const SUPPORT_STARTER_PROMPTS: StartScreenPrompt[] = [ +const TRIP_PROMPTS: StartScreenPrompt[] = [ { label: "Change my seat", - prompt: "Can you move me to seat 14C on flight 0A476?", + prompt: "Move me to seat 14C on flight OA476.", icon: "lightbulb", }, { label: "Cancel trip", - prompt: "I need to cancel my trip and request a refund.", + prompt: "Cancel my upcoming trip and request a refund.", icon: "sparkle", }, { label: "Add checked bag", - prompt: "Add one more checked bag to my reservation.", - icon: "compass", + prompt: "Add one more checked bag to the reservation.", + icon: "suitcase", + }, + { + label: "Set a meal preference", + prompt: "Set my meal preference.", + icon: "notebook-pencil", + }, +]; + +const LOYALTY_PROMPTS: StartScreenPrompt[] = [ + { + label: "Explain Gold perks", + prompt: "Explain what Aviator Gold currently unlocks for me.", + icon: "star", + }, + { + label: "Path to Platinum", + prompt: + "Show how close I am to Aviator Platinum and what would help me get there.", + icon: "chart", + }, + { + label: "Upgrade offers", + prompt: "Surface any upgrade or lounge offers we should mention today.", + icon: "sparkle-double", }, ]; + +export const SUPPORT_STARTER_PROMPTS: Record = + { + overview: OVERVIEW_PROMPTS, + trips: TRIP_PROMPTS, + loyalty: LOYALTY_PROMPTS, + }; + +export const SUPPORT_GREETINGS: Record = { + overview: "How can I keep your day running smoothly?", + trips: "Need help with flights, seats, or booking?", + loyalty: "Let's talk loyalty perks, points, and status.", +}; diff --git a/examples/frontend/src/lib/customer-support/destinations.ts b/examples/frontend/src/lib/customer-support/destinations.ts new file mode 100644 index 0000000..db90df8 --- /dev/null +++ b/examples/frontend/src/lib/customer-support/destinations.ts @@ -0,0 +1,43 @@ +import tokyoImageDataUrl from "../../assets/customer-support/tokyo.png?inline"; +import santoriniImageDataUrl from "../../assets/customer-support/santorini.png?inline"; +import parisImageDataUrl from "../../assets/customer-support/paris.png?inline"; +import type { BookingDestinationCard } from "../../types/support"; + +export const DEFAULT_DESTINATIONS: BookingDestinationCard[] = [ + { + id: "tokyo", + name: "Tokyo", + headline: "Shibuya light trails & Daikanyama studios", + description: + "Jordan keeps pinning midnight art residencies around Omotesandō and ramen crawls for inspo.", + airport: "HND", + season: "Spring bloom · April", + image_url: tokyoImageDataUrl, + preview_data_url: tokyoImageDataUrl, + accent: "#fb7185", + }, + { + id: "santorini", + name: "Santorini", + headline: "Cycladic galleries & cliffside studios", + description: + "Matches the loyalty offer Jordan saved for Mediterranean design tours with sea-view suites.", + airport: "JTR", + season: "Shoulder summer · September", + image_url: santoriniImageDataUrl, + preview_data_url: santoriniImageDataUrl, + accent: "#0ea5e9", + }, + { + id: "paris", + name: "Paris", + headline: "Left Bank ateliers & Seine-side salons", + description: + "Their concierge dossier includes couture previews and jazz picnics leading into Bastille week.", + airport: "CDG", + season: "Early summer · June", + image_url: parisImageDataUrl, + preview_data_url: parisImageDataUrl, + accent: "#facc15", + }, +]; diff --git a/examples/frontend/src/lib/customer-support/uploads.ts b/examples/frontend/src/lib/customer-support/uploads.ts new file mode 100644 index 0000000..344217b --- /dev/null +++ b/examples/frontend/src/lib/customer-support/uploads.ts @@ -0,0 +1,6 @@ +export const MAX_UPLOAD_SIZE_MB = 5; +export const MAX_UPLOAD_BYTES = MAX_UPLOAD_SIZE_MB * 1024 * 1024; + +export const IMAGE_ATTACHMENT_ACCEPT: Record = { + "image/*": [".png", ".jpg", ".jpeg", ".webp"], +}; diff --git a/examples/frontend/src/pages/CustomerSupport.tsx b/examples/frontend/src/pages/CustomerSupport.tsx index f7969fd..f2fac79 100644 --- a/examples/frontend/src/pages/CustomerSupport.tsx +++ b/examples/frontend/src/pages/CustomerSupport.tsx @@ -1,21 +1,25 @@ -import { useCallback, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import clsx from "clsx"; -import { ChatKitPanel } from "../components/customer-support/ChatKitPanel"; +import { ChatKitPanel, type ChatKitInstance } from "../components/customer-support/ChatKitPanel"; import { CustomerContextPanel } from "../components/customer-support/CustomerContextPanel"; import { ThemeToggle } from "../components/ThemeToggle"; +import type { CustomerProfile } from "../hooks/useCustomerContext"; import { useCustomerContext } from "../hooks/useCustomerContext"; import type { ColorScheme } from "../hooks/useColorScheme"; import { useColorScheme } from "../hooks/useColorScheme"; - - +import { SUPPORT_GREETINGS, SUPPORT_STARTER_PROMPTS } from "../lib/customer-support/config"; +import type { SupportView } from "../types/support"; export default function CustomerSupport() { const [threadId, setThreadId] = useState(null); - const { profile, loading, error, refresh } = useCustomerContext(threadId); + const [view, setView] = useState("overview"); + const [chatkit, setChatkit] = useState(null); + const { profile, loading, error, refresh, setProfile } = + useCustomerContext(threadId); const { scheme, setScheme } = useColorScheme(); - + const handleThemeChange = useCallback( (value: ColorScheme) => { setScheme(value); @@ -35,9 +39,28 @@ export default function CustomerSupport() { }, []); const handleResponseCompleted = useCallback(() => { - refresh(); + void refresh(); + }, [refresh]); + + const handleWidgetActionComplete = useCallback(() => { + void refresh(); }, [refresh]); + const handleProfileEffect = useCallback( + (nextProfile: CustomerProfile) => { + setProfile(nextProfile); + }, + [setProfile] + ); + + const startScreen = useMemo( + () => ({ + greeting: SUPPORT_GREETINGS[view], + prompts: SUPPORT_STARTER_PROMPTS[view], + }), + [view] + ); + return (
@@ -47,12 +70,12 @@ export default function CustomerSupport() { OpenSkies concierge desk

- Airline customer support overview + Airline customer support workspace

- Chat with the concierge on the left. The right panel refreshes with customer - profile details, itinerary changes, and a live service timeline after each - action. + Chat with the concierge on the left. Use the tabs below to switch + between overview details, trips, and loyalty, while the agent + keeps everything up to date.

@@ -63,13 +86,25 @@ export default function CustomerSupport() {
- +
diff --git a/examples/frontend/src/types/support.ts b/examples/frontend/src/types/support.ts new file mode 100644 index 0000000..b2049b6 --- /dev/null +++ b/examples/frontend/src/types/support.ts @@ -0,0 +1,14 @@ +export type SupportView = "overview" | "trips" | "loyalty"; + +export type BookingDestinationCard = { + id: string; + name: string; + headline: string; + description: string; + airport: string; + season: string; + image_url: string; + preview_data_url?: string; + accent?: string; + attachment_id?: string; +};