From 1fdedbc27295c95d20c4fcc82de58b9d3efd9c55 Mon Sep 17 00:00:00 2001 From: Google Maps SDK Team Date: Thu, 9 Jul 2026 18:25:50 +0000 Subject: [PATCH] feat: Sync from internal staging. --- .github/ISSUE_TEMPLATE/bug_report.md | 16 + .github/ISSUE_TEMPLATE/feature_request.md | 16 + .github/ISSUE_TEMPLATE/support_request.md | 16 + .github/PULL_REQUEST_TEMPLATE.md | 16 + .github/dependabot.yml | 5 +- .github/workflows/web-ci.yml | 7 - .gitignore | 6 - agent/python/README.md | 116 +- agent/python/__main__.py | 78 +- agent/python/agent_executor.py | 48 +- agent/python/pyproject.toml | 2 +- client/android/.gitignore | 14 - .../android/app/src/main/AndroidManifest.xml | 4 +- .../assets/canned_responses/prompt_2.json | 1 + .../java/com/example/maui/MainActivity.kt | 1262 +++++++++-------- .../android/app/src/main/res/mipmap/.gitkeep | 0 .../android/gradle/wrapper/gradle-wrapper.jar | Bin 45457 -> 0 bytes client/android/local.properties.template | 5 + client/ios/.gitignore | 3 - .../A2UI_ExampleUITests.swift | 2 +- client/ios/ChatViewModel.swift | 2 +- client/ios/README.md | 16 +- client/ios/run_xcode_ui_tests.sh | 27 - client/web/react/README.md | 2 +- client/web/react/eslint.config.js | 2 +- client/web/react/index.html | 2 +- client/web/react/src/App.css | 4 +- client/web/react/src/App.tsx | 313 ++-- client/web/react/src/custom-elements.d.ts | 2 +- client/web/react/src/index.css | 2 +- client/web/react/tsconfig.app.json | 2 +- client/web/react/tsconfig.json | 2 +- client/web/react/tsconfig.node.json | 2 +- 33 files changed, 1118 insertions(+), 877 deletions(-) delete mode 100644 .gitignore delete mode 100644 client/android/.gitignore delete mode 100644 client/android/app/src/main/res/mipmap/.gitkeep delete mode 100644 client/android/gradle/wrapper/gradle-wrapper.jar create mode 100644 client/android/local.properties.template delete mode 100644 client/ios/.gitignore delete mode 100755 client/ios/run_xcode_ui_tests.sh diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index c1aaaf9..e7a3ae3 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,3 +1,19 @@ + + --- name: Bug report about: Create a report to help us improve diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 7a19c42..a400f07 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,3 +1,19 @@ + + --- name: Feature request about: Suggest an idea for this library diff --git a/.github/ISSUE_TEMPLATE/support_request.md b/.github/ISSUE_TEMPLATE/support_request.md index 997deef..15b08b6 100644 --- a/.github/ISSUE_TEMPLATE/support_request.md +++ b/.github/ISSUE_TEMPLATE/support_request.md @@ -1,3 +1,19 @@ + + --- name: Support request about: If you have a support contract with Google, please create an issue in the Google diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index d4914f8..1c0c3ae 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,3 +1,19 @@ + + Thank you for opening a Pull Request! Before submitting your PR, there are a few things you can do to make sure it goes smoothly: - [ ] Make sure to open an issue as a [bug/issue](https://github.com/googleapis/release-please/issues/new/choose) before writing your code! That way we can discuss the change, evaluate designs, and agree on the general idea - [ ] Ensure the tests and linter pass diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 226fe60..eb74136 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,11 +4,14 @@ updates: directory: "/client/web/react" schedule: interval: "weekly" + open-pull-requests-limit: 0 - package-ecosystem: "pip" directory: "/agent/python" schedule: interval: "weekly" + open-pull-requests-limit: 0 - package-ecosystem: "github-actions" directory: "/" schedule: - interval: "weekly" \ No newline at end of file + interval: "weekly" + open-pull-requests-limit: 0 \ No newline at end of file diff --git a/.github/workflows/web-ci.yml b/.github/workflows/web-ci.yml index 0347ef9..2393d2a 100644 --- a/.github/workflows/web-ci.yml +++ b/.github/workflows/web-ci.yml @@ -9,9 +9,6 @@ on: jobs: build: runs-on: ubuntu-latest - permissions: - contents: read - packages: read steps: - uses: actions/checkout@v4 @@ -21,14 +18,10 @@ jobs: node-version: '20' cache: 'npm' cache-dependency-path: client/web/react/package-lock.json - registry-url: 'https://npm.pkg.github.com/' - scope: '@googlemaps' - name: Install dependencies run: npm ci working-directory: client/web/react - env: - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Type check run: npx tsc --noEmit diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 52aef90..0000000 --- a/.gitignore +++ /dev/null @@ -1,6 +0,0 @@ -node_modules -.DS_STORE -.wireit -dist -.venv -__pycache__ diff --git a/agent/python/README.md b/agent/python/README.md index 33ea205..b34771f 100644 --- a/agent/python/README.md +++ b/agent/python/README.md @@ -1,107 +1,142 @@ # Agentic UI Toolkit - Python Agent -This is a sample Python agent that consumes the MAUI packages and provides a backend for the A2UI chat interface. +This is a sample Python agent that consumes the MAUI packages and provides a +backend for the A2UI chat interface. ## Prerequisites **Source code:** -* Download/clone the Agentic UI Toolkit source code from [GitHub](https://github.com/googlemaps/a2ui) -**Environment variables:** -This example requires the following environment variables to be set: +* Download/clone the Agentic UI Toolkit source code from + [GitHub](https://github.com/googlemaps/a2ui) + +**Environment variables:** This example requires the following environment +variables to be set: + * `GEMINI_API_KEY`: Your Gemini API key. -* `GOOGLE_MAPS_API_KEY`: Your Google Maps API key (used by the agent for location-based queries). +* `GOOGLE_MAPS_API_KEY`: Your Google Maps API key (used by the agent for + location-based queries). **Tools:** -* `uv`: Python package manager and runner. Install from https://docs.astral.sh/uv/ +* `uv`: Python package manager and runner. Install from + https://docs.astral.sh/uv/ ## To run this sample project 1. Open this directory in a terminal. 2. Set the path to the MAUI package in [pyproject.toml](pyproject.toml). - - You can either do this manually by replacing the `$MAUI_PATH` placeholder in [pyproject.toml](pyproject.toml) - with the path to the MAUI package, or by running the [setup.sh](setup.sh) script: + + You can either do this manually by replacing the `$MAUI_PATH` placeholder in + [pyproject.toml](pyproject.toml) with the path to the MAUI package, or by + running the [setup.sh](setup.sh) script: ```bash chmod +x setup.sh ./setup.sh ``` + 3. Run the following command to start the server: ```bash uv run . ``` - This will automatically resolve dependencies, install them in a local virtual environment, and start the A2A server on port 10002. + This will automatically resolve dependencies, install them in a local + virtual environment, and start the A2A server on port 10002. -To run the frontend, follow the instructions in [../../client/web/react/README.md](../../client/web/react/README.md) +To run the frontend, follow the instructions in +[../../client/web/react/README.md](../../client/web/react/README.md) ## Google API Keys ### Google Maps API Key -Agentic UI Toolkit requires an API Key to use Google Maps Platform products. To create a Google Maps API Key, follow the instructions in the [Google Maps Platform documentation](https://developers.google.com/maps/documentation/javascript/get-api-key). +Agentic UI Toolkit requires an API Key to use Google Maps Platform products. To +create a Google Maps API Key, follow the instructions in the +[Google Maps Platform documentation](https://developers.google.com/maps/documentation/javascript/get-api-key). -Your API Key must have the following APIs enabled in the [Google Cloud Console](https://console.cloud.google.com/apis/credentials): +Your API Key must have the following APIs enabled in the +[Google Cloud Console](https://console.cloud.google.com/apis/credentials): -* Geocoding API -* Maps JavaScript API -* Places UI Kit -* Routes API +* Geocoding API +* Maps JavaScript API +* Places UI Kit +* Routes API To use Grounding Lite MCP, you must also enable: -* Maps Grounding Lite API +* Maps Grounding Lite API -To support the use of Grounding Lite within the Python ADK backend, this API Key must be exported or contained within a `.env` file as `GOOGLE_MAPS_API_KEY`. +To support the use of Grounding Lite within the Python ADK backend, this API Key +must be exported or contained within a `.env` file as `GOOGLE_MAPS_API_KEY`. **Loading the Google Maps JavaScript API** -Your API Key must also be included when loading the Google Maps JavaScript API code. See the [Google Maps Platform Documentation](https://developers.google.com/maps/documentation/javascript/load-maps-js-api) for instructions on how to load the API, including configuring the API Key. +Your API Key must also be included when loading the Google Maps JavaScript API +code. See the +[Google Maps Platform Documentation](https://developers.google.com/maps/documentation/javascript/load-maps-js-api) +for instructions on how to load the API, including configuring the API Key. -Agentic UI Toolkit requires features available in the Alpha channel. You must use `v=alpha` when loading the Maps JavaScript API. Learn more about versions in the [Google Maps Platform Documentation](https://developers.google.com/maps/documentation/javascript/versions). +Agentic UI Toolkit requires features available in the Alpha channel. You must +use `v=alpha` when loading the Maps JavaScript API. Learn more about versions in +the +[Google Maps Platform Documentation](https://developers.google.com/maps/documentation/javascript/versions). -Use of Agentic UI Toolkit requires several [Maps JavaScript API libraries](https://developers.google.com/maps/documentation/javascript/libraries). When loading the Google Maps JavaScript API, you must include the following libraries: +Use of Agentic UI Toolkit requires several +[Maps JavaScript API libraries](https://developers.google.com/maps/documentation/javascript/libraries). +When loading the Google Maps JavaScript API, you must include the following +libraries: -* maps -* maps3d -* marker -* places -* routes +* maps +* maps3d +* marker +* places +* routes ### Gemini API Key -*Note: This API is variously referred to in Google Cloud as the* Gemini API *and the* Generative Language API. +*Note: This API is variously referred to in Google Cloud as the* Gemini API *and +the* Generative Language API. -If you are using Gemini as your LLM, you will also need a Google Cloud API Key with the *Generative Language API* enabled. In order to enable this API for your API Key, the *Gemini API* must be enabled for your Google Cloud project. You can enable this API in the [API Library](https://console.cloud.google.com/apis/library/generativelanguage.googleapis.com). +If you are using Gemini as your LLM, you will also need a Google Cloud API Key +with the *Generative Language API* enabled. In order to enable this API for your +API Key, the *Gemini API* must be enabled for your Google Cloud project. You can +enable this API in the +[API Library](https://console.cloud.google.com/apis/library/generativelanguage.googleapis.com). -To create a new Google Cloud API Key, follow the instructions here in the [Google Cloud docs](https://docs.cloud.google.com/docs/authentication/api-keys#create). +To create a new Google Cloud API Key, follow the instructions here in the +[Google Cloud docs](https://docs.cloud.google.com/docs/authentication/api-keys#create). This key must be exported or contained within a `.env` file as `GEMINI_API_KEY` ## Accessing Google Maps grounding data -Your agent can access Google Maps grounding data in two ways, depending on your project setup and needs: +Your agent can access Google Maps grounding data in two ways, depending on your +project setup and needs: -1. [Grounding Lite MCP](https://developers.google.com/maps/ai/grounding-lite) -2. [Grounding with Google Maps](https://docs.cloud.google.com/vertex-ai/generative-ai/docs/grounding/grounding-with-google-maps) +1. [Grounding Lite MCP](https://developers.google.com/maps/ai/grounding-lite) +2. [Grounding with Google Maps](https://docs.cloud.google.com/vertex-ai/generative-ai/docs/grounding/grounding-with-google-maps) ### Grounding Lite MCP -To use Grounding Lite MCP, you must first enable the Maps Grounding Lite API and create or update an API Key to support the required APIs following the [documentation](https://developers.google.com/maps/ai/grounding-lite#configure_llms_to_use_the_mcp_server). +To use Grounding Lite MCP, you must first enable the Maps Grounding Lite API and +create or update an API Key to support the required APIs following the +[documentation](https://developers.google.com/maps/ai/grounding-lite#configure_llms_to_use_the_mcp_server). ### Grounding with Google Maps -To use Grounding with Google Maps, there are additional steps you must take to configure your environment: +To use Grounding with Google Maps, there are additional steps you must take to +configure your environment: + +1. Ensure you have the latest version of the genai python package. -1. Ensure you have the latest version of the genai python package. ```bash pip install --upgrade google-genai ``` -2. Configure additional environment variables to connect to your project. +1. Configure additional environment variables to connect to your project. + ```bash ## Replace the `GOOGLE_CLOUD_PROJECT` and `GOOGLE_CLOUD_LOCATION` values ## with appropriate values for your project. @@ -110,9 +145,12 @@ export GOOGLE_CLOUD_LOCATION=global export GOOGLE_GENAI_USE_VERTEXAI=True ``` -3. Ensure you are authenticated to Google Cloud. +1. Ensure you are authenticated to Google Cloud. + ```bash gcloud auth application-default login ``` -See the [documentation](https://docs.cloud.google.com/vertex-ai/generative-ai/docs/grounding/grounding-with-google-maps#googlegenaisdk_tools_google_maps_with_txt-python_genai_sdk) for more information. +See the +[documentation](https://docs.cloud.google.com/vertex-ai/generative-ai/docs/grounding/grounding-with-google-maps#googlegenaisdk_tools_google_maps_with_txt-python_genai_sdk) +for more information. diff --git a/agent/python/__main__.py b/agent/python/__main__.py index 8441598..3416e52 100644 --- a/agent/python/__main__.py +++ b/agent/python/__main__.py @@ -15,7 +15,6 @@ import logging import os -import click from a2a.server.apps import A2AStarletteApplication from a2a.server.request_handlers import DefaultRequestHandler from a2a.server.tasks import InMemoryTaskStore @@ -23,10 +22,11 @@ from agent import MAUIAgent from agent_executor import MAUIAgentExecutor from agent_with_grounding import MAUIAgentWithGrounding +import click from dotenv import load_dotenv from starlette.middleware.cors import CORSMiddleware -from starlette.staticfiles import StaticFiles from starlette.responses import RedirectResponse +from starlette.staticfiles import StaticFiles load_dotenv() @@ -43,48 +43,52 @@ class MissingAPIKeyError(Exception): @click.option("--host", default="0.0.0.0") @click.option("--port", default=10002) def main(serverurl, host, port): - try: - # Check for API key only if Vertex AI is not configured - if not os.getenv("GOOGLE_GENAI_USE_VERTEXAI") == "TRUE": - if not os.getenv("GEMINI_API_KEY"): - raise MissingAPIKeyError( - "GEMINI_API_KEY environment variable not set and GOOGLE_GENAI_USE_VERTEXAI is not TRUE." - ) + try: + # Check for API key only if Vertex AI is not configured + if not os.getenv("GOOGLE_GENAI_USE_VERTEXAI") == "TRUE": + if not os.getenv("GEMINI_API_KEY"): + raise MissingAPIKeyError( + "GEMINI_API_KEY environment variable not set and" + " GOOGLE_GENAI_USE_VERTEXAI is not TRUE." + ) - base_url = f"http://{host}:{port}" + base_url = f"http://{host}:{port}" - if (serverurl != ""): - base_url = serverurl + if serverurl != "": + base_url = serverurl - ui_agent = MAUIAgent(base_url=base_url) - grounding_agent = MAUIAgentWithGrounding(base_url=base_url) + ui_agent = MAUIAgent(base_url=base_url) + grounding_agent = MAUIAgentWithGrounding(base_url=base_url) - agent_executor = MAUIAgentExecutor(default_agent=ui_agent, grounding_agent=grounding_agent) + agent_executor = MAUIAgentExecutor( + default_agent=ui_agent, grounding_agent=grounding_agent + ) - request_handler = DefaultRequestHandler( - agent_executor=agent_executor, - task_store=InMemoryTaskStore(), - ) - server = A2AStarletteApplication( - agent_card=ui_agent.agent_card, http_handler=request_handler - ) - import uvicorn + request_handler = DefaultRequestHandler( + agent_executor=agent_executor, + task_store=InMemoryTaskStore(), + ) + server = A2AStarletteApplication( + agent_card=ui_agent.agent_card, http_handler=request_handler + ) + import uvicorn - app = server.build() + app = server.build() - app.add_middleware( - CORSMiddleware, - allow_origin_regex=r"http://localhost:\d+", - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], - ) + app.add_middleware( + CORSMiddleware, + allow_origin_regex=r"http://localhost:\d+", + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + logger.info(f"Starting A2A server on {host}:{port}") + uvicorn.run(app, host=host, port=port) + except Exception as e: + logger.error(f"An error occurred during server startup: {e}") + exit(1) - logger.info(f"Starting A2A server on {host}:{port}") - uvicorn.run(app, host=host, port=port) - except Exception as e: - logger.error(f"An error occurred during server startup: {e}") - exit(1) if __name__ == "__main__": - main() + main() diff --git a/agent/python/agent_executor.py b/agent/python/agent_executor.py index 8b6babd..410cddb 100644 --- a/agent/python/agent_executor.py +++ b/agent/python/agent_executor.py @@ -41,7 +41,9 @@ class MAUIAgentExecutor(AgentExecutor): """MAUI AgentExecutor Example.""" - def __init__(self, default_agent: MAUIAgent, grounding_agent: MAUIAgentWithGrounding): + def __init__( + self, default_agent: MAUIAgent, grounding_agent: MAUIAgentWithGrounding + ): self._default_agent = default_agent self._grounding_agent = grounding_agent @@ -75,10 +77,10 @@ async def execute( logger.info(f"Received a2ui ClientEvent: {ui_event_part}") action = ui_event_part.get("actionName") ctx = ui_event_part.get("context", {}) - + # Use switch statement to route to the appropriate action handler query = f"User submitted an event: {action} with data: {ctx}" - + else: logger.info("No a2ui UI event part found. Falling back to text input.") query = context.get_user_input() @@ -86,23 +88,35 @@ async def execute( # Interpret prefix and choose agent agent_to_use = self._default_agent if query.startswith("[GROUNDING]"): - logger.info("--- AGENT_EXECUTOR: Prefix [GROUNDING] detected. Using Grounding Agent. ---") - agent_to_use = self._grounding_agent - query = query[len("[GROUNDING]"):].strip() + logger.info( + "--- AGENT_EXECUTOR: Prefix [GROUNDING] detected. Using Grounding" + " Agent. ---" + ) + agent_to_use = self._grounding_agent + query = query[len("[GROUNDING]") :].strip() else: - logger.info("--- AGENT_EXECUTOR: No prefix detected. Using Default Agent. ---") + logger.info( + "--- AGENT_EXECUTOR: No prefix detected. Using Default Agent. ---" + ) logger.info(f"--- AGENT_EXECUTOR: Final query for LLM: '{query}' ---") - logger.info(f"--- Client requested extensions: {context.requested_extensions} ---") - active_ui_version = try_activate_a2ui_extension(context, agent_to_use.agent_card) + logger.info( + f"--- Client requested extensions: {context.requested_extensions} ---" + ) + active_ui_version = try_activate_a2ui_extension( + context, agent_to_use.agent_card + ) # Determine which agent to use based on whether the a2ui extension is active. if active_ui_version: - logger.info("--- AGENT_EXECUTOR: A2UI extension is active. Using UI agent. ---") + logger.info( + "--- AGENT_EXECUTOR: A2UI extension is active. Using UI agent. ---" + ) else: logger.info( - "--- AGENT_EXECUTOR: A2UI extension is not active. Using text agent. ---" + "--- AGENT_EXECUTOR: A2UI extension is not active. Using text" + " agent. ---" ) task = context.current_task @@ -112,14 +126,20 @@ async def execute( await event_queue.enqueue_event(task) updater = TaskUpdater(event_queue, task.id, task.context_id) - async for item in agent_to_use.stream(query, task.context_id, active_ui_version): + async for item in agent_to_use.stream( + query, task.context_id, active_ui_version + ): is_task_complete = item["is_task_complete"] if not is_task_complete: message = None if "parts" in item: - message = new_agent_parts_message(item["parts"], task.context_id, task.id) + message = new_agent_parts_message( + item["parts"], task.context_id, task.id + ) elif "updates" in item: - message = new_agent_text_message(item["updates"], task.context_id, task.id) + message = new_agent_text_message( + item["updates"], task.context_id, task.id + ) if message: await updater.update_status(TaskState.working, message) diff --git a/agent/python/pyproject.toml b/agent/python/pyproject.toml index 868e895..e781a71 100644 --- a/agent/python/pyproject.toml +++ b/agent/python/pyproject.toml @@ -5,7 +5,7 @@ description = "Sample agent consuming MAUI packages" requires-python = ">=3.13" dependencies = [ "maui-a2ui-python", - "google-adk[a2a,extensions]>=1.28.0", + "google-adk[a2a,extensions]>=1.28.0,<2.0.0", "a2a-sdk[http-server]>=0.3.0", "google-genai>=1.64.0", "jsonschema>=4.0.0" diff --git a/client/android/.gitignore b/client/android/.gitignore deleted file mode 100644 index edf0748..0000000 --- a/client/android/.gitignore +++ /dev/null @@ -1,14 +0,0 @@ -# Built artifacts -bin/ -gen/ -out/ -build/ -app/build/ - -# Gradle -.gradle/ -.kotlin/ -local.properties - -# Android Studio / IntelliJ -.idea/ diff --git a/client/android/app/src/main/AndroidManifest.xml b/client/android/app/src/main/AndroidManifest.xml index 90bb004..f034c0e 100644 --- a/client/android/app/src/main/AndroidManifest.xml +++ b/client/android/app/src/main/AndroidManifest.xml @@ -14,7 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. --> - + + + diff --git a/client/android/app/src/main/assets/canned_responses/prompt_2.json b/client/android/app/src/main/assets/canned_responses/prompt_2.json index 7f6bd48..b1a73db 100644 --- a/client/android/app/src/main/assets/canned_responses/prompt_2.json +++ b/client/android/app/src/main/assets/canned_responses/prompt_2.json @@ -35,6 +35,7 @@ { "id": "hotel-map", "component": "GoogleMap", + "mode": "satellite", "center": { "lat": 47.612359, "lng": -122.352237 diff --git a/client/android/app/src/main/java/com/example/maui/MainActivity.kt b/client/android/app/src/main/java/com/example/maui/MainActivity.kt index 8ac4584..20aa34a 100644 --- a/client/android/app/src/main/java/com/example/maui/MainActivity.kt +++ b/client/android/app/src/main/java/com/example/maui/MainActivity.kt @@ -28,6 +28,18 @@ import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.RecyclerView +import androidx.tracing.Trace +import java.io.BufferedReader +import java.io.File +import java.io.FileReader +import java.io.FileWriter +import java.io.IOException +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.UUID +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -42,676 +54,720 @@ import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import org.json.JSONArray import org.json.JSONObject -import java.io.BufferedReader -import java.io.File -import java.io.FileReader -import java.io.FileWriter -import java.io.IOException -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale -import java.util.UUID -import java.util.concurrent.TimeUnit -import androidx.tracing.Trace -import java.util.concurrent.atomic.AtomicInteger val traceCookie = AtomicInteger(0) class MainActivity : AppCompatActivity() { - private lateinit var recyclerView: RecyclerView - private lateinit var editTextMessage: EditText - private lateinit var buttonSend: Button - private lateinit var buttonPrintLog: Button - private lateinit var promptsSpinner: android.widget.Spinner - private lateinit var chatAdapter: ChatAdapter - private val messages = mutableListOf() - private val client = OkHttpClient.Builder() - .connectTimeout(300, TimeUnit.SECONDS) - .readTimeout(300, TimeUnit.SECONDS) - .writeTimeout(300, TimeUnit.SECONDS) - .build() + private lateinit var recyclerView: RecyclerView + private lateinit var editTextMessage: EditText + private lateinit var buttonSend: Button + private lateinit var buttonPrintLog: Button + private lateinit var promptsSpinner: android.widget.Spinner + private lateinit var chatAdapter: ChatAdapter + private val messages = mutableListOf() + private val client = + OkHttpClient.Builder() + .connectTimeout(300, TimeUnit.SECONDS) + .readTimeout(300, TimeUnit.SECONDS) + .writeTimeout(300, TimeUnit.SECONDS) + .build() + + enum class ServerType { + DEMO, + VANILLA, + } + + enum class DeviceType { + PHYSICAL, + EMULATOR, + } + + // --- CONFIGURATION --- + private val activeServer = ServerType.DEMO + private val deviceType = DeviceType.EMULATOR + // --------------------- + + private val baseUrl: String + get() = + when (activeServer) { + ServerType.DEMO -> BuildConfig.GATEWAY_URL + ServerType.VANILLA -> + when (deviceType) { + DeviceType.PHYSICAL -> "http://127.0.0.1:10002" + DeviceType.EMULATOR -> "http://10.0.2.2:10002" + } + } + + private val appName: String + get() = + when (activeServer) { + ServerType.DEMO -> "hello_world_agent" + ServerType.VANILLA -> "my_agent" + } + + private var activeSessionId: String? = null + private var useSseProtocol = false + private var hasDiscoveredProtocol = false + + private var currentActiveCall: okhttp3.Call? = null + + private var currentAgentTextIndex: Int? = null + private var currentAgentA2UIIndex: Int? = null + + private val contextId = java.util.UUID.randomUUID().toString() + private val latencyLogFile = "latency_log.csv" + private val resourceLogFile = "resource_log.csv" + + private val SHOW_PRINT_LOG_BUTTON = false + + private var lastCpuTime: Long = 0 + private val cpuJiffyToMs = 10L + private val loggingIntervalMs = 1000L + + private val resourceLoggingScope = CoroutineScope(Dispatchers.IO) + private var resourceLoggingJob: Job? = null + private val numberOfCores = Runtime.getRuntime().availableProcessors() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + com.google.android.libraries.mapsplatform.a2ui.A2UIServices.provideAPIKey( + BuildConfig.MAPS_API_KEY + ) + + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) + setContentView(R.layout.activity_main) + + recyclerView = findViewById(R.id.recyclerView) + editTextMessage = findViewById(R.id.editTextMessage) + buttonSend = findViewById(R.id.buttonSend) + buttonPrintLog = findViewById(R.id.buttonPrintLog) + promptsSpinner = findViewById(R.id.promptsSpinner) + + val examplePrompts = + listOf( + "Select a frequently asked question...", + "Show me 5 coffee shops near South Lake Union in Seattle", + "Is the Edgewater Hotel in Seattle a good hotel?", + "How long will it take to commute to Google Kirkland office from downtown Redmond during my morning rush hour commute?", + "Show me 5 lunch restaurants with Salads in South Lake Union. Give me directions to the 2nd one (starting from the Google South Lake Union WLK building)", + "Give me a 3 day itinerary for a family of 3 traveling to London", + ) + + val adapter = + android.widget.ArrayAdapter( + this, + android.R.layout.simple_spinner_dropdown_item, + examplePrompts, + ) + promptsSpinner.adapter = adapter + + promptsSpinner.onItemSelectedListener = + object : android.widget.AdapterView.OnItemSelectedListener { + override fun onItemSelected( + parent: android.widget.AdapterView<*>?, + view: View?, + position: Int, + id: Long, + ) { + if (position > 0) { + editTextMessage.setText(examplePrompts[position]) + } + } - enum class ServerType { - DEMO, VANILLA - } + override fun onNothingSelected(parent: android.widget.AdapterView<*>?) {} + } - enum class DeviceType { - PHYSICAL, EMULATOR + if (SHOW_PRINT_LOG_BUTTON) { + buttonPrintLog.visibility = View.VISIBLE + } else { + buttonPrintLog.visibility = View.GONE } - // --- CONFIGURATION --- - private val activeServer = ServerType.DEMO - private val deviceType = DeviceType.EMULATOR - // --------------------- - - private val baseUrl: String - get() = when (activeServer) { - ServerType.DEMO -> BuildConfig.GATEWAY_URL - ServerType.VANILLA -> when (deviceType) { - DeviceType.PHYSICAL -> "http://127.0.0.1:10002" - DeviceType.EMULATOR -> "http://10.0.2.2:10002" - } + chatAdapter = + ChatAdapter(messages) { position, latencyMs, status -> + logLatency("A2UI", latencyMs, status) + + // Auto-scroll to show the whole conversation (Question + Answer) + // We scroll to position - 1 to ensure the user's question stays visible at the top + lifecycleScope.launch { + delay(100) + if (position == messages.size - 1 || position == messages.size - 2) { + val scrollPosition = if (position > 0) position - 1 else position + val layoutManager = + recyclerView.layoutManager as? androidx.recyclerview.widget.LinearLayoutManager + layoutManager?.scrollToPositionWithOffset(scrollPosition, 0) + } } - - private val appName: String - get() = when (activeServer) { - ServerType.DEMO -> "hello_world_agent" - ServerType.VANILLA -> "my_agent" + } + recyclerView.setItemViewCacheSize(MAX_ITEM_VIEW_CACHE_SIZE) + recyclerView.adapter = chatAdapter + + buttonSend.setOnClickListener { + val messageText = editTextMessage.text.toString().trim() + if (messageText.isNotEmpty()) { + currentActiveCall?.cancel() + currentActiveCall = null + currentAgentTextIndex = null + currentAgentA2UIIndex = null + var serverMessageText = messageText + val radioGroundingVertex = + findViewById(R.id.radioGroundingVertex) + if (radioGroundingVertex.isChecked) { + serverMessageText = "[GROUNDING] $messageText" } - private var activeSessionId: String? = null - private var useSseProtocol = false - private var hasDiscoveredProtocol = false - - private var currentActiveCall: okhttp3.Call? = null - - private var currentAgentTextIndex: Int? = null - private var currentAgentA2UIIndex: Int? = null - - private val contextId = java.util.UUID.randomUUID().toString() - private val latencyLogFile = "latency_log.csv" - private val resourceLogFile = "resource_log.csv" - - private val SHOW_PRINT_LOG_BUTTON = false - - private var lastCpuTime: Long = 0 - private val cpuJiffyToMs = 10L - private val loggingIntervalMs = 1000L - - private val resourceLoggingScope = CoroutineScope(Dispatchers.IO) - private var resourceLoggingJob: Job? = null - private val numberOfCores = Runtime.getRuntime().availableProcessors() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - com.google.android.libraries.mapsplatform.a2ui.A2UIServices.provideAPIKey(BuildConfig.MAPS_API_KEY) - - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) - setContentView(R.layout.activity_main) - - recyclerView = findViewById(R.id.recyclerView) - editTextMessage = findViewById(R.id.editTextMessage) - buttonSend = findViewById(R.id.buttonSend) - buttonPrintLog = findViewById(R.id.buttonPrintLog) - promptsSpinner = findViewById(R.id.promptsSpinner) - - val examplePrompts = listOf( - "Select a frequently asked question...", - "Show me 5 coffee shops near South Lake Union in Seattle", - "Is the Edgewater Hotel in Seattle a good hotel?", - "How long will it take to commute to Google Kirkland office from downtown Redmond during my morning rush hour commute?", - "Show me 5 lunch restaurants with Salads in South Lake Union. Give me directions to the 2nd one (starting from the Google South Lake Union WLK building)", - "Give me a 3 day itinerary for a family of 3 traveling to London" - ) + addMessage(ChatMessage.Text(messageText, true)) + editTextMessage.text.clear() + val jsonObject = JSONObject() + jsonObject.put("text", serverMessageText) + callPythonServer(jsonObject) + } + } - val adapter = android.widget.ArrayAdapter(this, android.R.layout.simple_spinner_dropdown_item, examplePrompts) - promptsSpinner.adapter = adapter + buttonPrintLog.setOnClickListener { + printResourceLogToLogcat() + printLatencyLogToLogcat() + } - promptsSpinner.onItemSelectedListener = object : android.widget.AdapterView.OnItemSelectedListener { - override fun onItemSelected(parent: android.widget.AdapterView<*>?, view: View?, position: Int, id: Long) { - if (position > 0) { - editTextMessage.setText(examplePrompts[position]) - } - } - override fun onNothingSelected(parent: android.widget.AdapterView<*>?) {} - } + startResourceLogging() + } - if (SHOW_PRINT_LOG_BUTTON) { - buttonPrintLog.visibility = View.VISIBLE - } else { - buttonPrintLog.visibility = View.GONE - } - - chatAdapter = ChatAdapter(messages) { position, latencyMs, status -> - logLatency("A2UI", latencyMs, status) - - // Auto-scroll to show the whole conversation (Question + Answer) - // We scroll to position - 1 to ensure the user's question stays visible at the top - lifecycleScope.launch { - delay(100) - if (position == messages.size - 1 || position == messages.size - 2) { - val scrollPosition = if (position > 0) position - 1 else position - val layoutManager = recyclerView.layoutManager as? androidx.recyclerview.widget.LinearLayoutManager - layoutManager?.scrollToPositionWithOffset(scrollPosition, 0) + private fun startResourceLogging() { + resourceLoggingJob?.cancel() + resourceLoggingJob = resourceLoggingScope.launch { + while (isActive) { + logResourceUsage() + delay(loggingIntervalMs) + } + } + } + + private fun logResourceUsage() { + try { + val timestamp = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date()) + val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + val pid = Process.myPid() + val memoryInfo = activityManager.getProcessMemoryInfo(intArrayOf(pid)) + val debugMemoryInfo = memoryInfo[0] + val totalPss = debugMemoryInfo.totalPss + val dalvikPss = debugMemoryInfo.dalvikPss + val nativePss = debugMemoryInfo.nativePss + val otherPss = debugMemoryInfo.otherPss + + var cpuTimeDeltaMs = 0L + var cpuPercentage = 0.0 + try { + val statFile = File("/proc/$pid/stat") + BufferedReader(FileReader(statFile)).use { reader -> + val line = reader.readLine() + if (line != null) { + val parts = line.split(" ") + if (parts.size >= 17) { + val utime = parts[13].toLong() + val stime = parts[14].toLong() + val currentCpuTime = utime + stime + + if (lastCpuTime > 0) { + val cpuJiffiesDelta = currentCpuTime - lastCpuTime + cpuTimeDeltaMs = cpuJiffiesDelta * cpuJiffyToMs + + val totalAvailableCpuTimeMs = loggingIntervalMs * numberOfCores + if (totalAvailableCpuTimeMs > 0) { + cpuPercentage = + (cpuTimeDeltaMs.toDouble() / totalAvailableCpuTimeMs.toDouble()) * 100.0 } + } + lastCpuTime = currentCpuTime } + } } - recyclerView.setItemViewCacheSize(MAX_ITEM_VIEW_CACHE_SIZE) - recyclerView.adapter = chatAdapter - - buttonSend.setOnClickListener { - val messageText = editTextMessage.text.toString().trim() - if (messageText.isNotEmpty()) { - currentActiveCall?.cancel() - currentActiveCall = null - currentAgentTextIndex = null - currentAgentA2UIIndex = null - var serverMessageText = messageText - val radioGroundingVertex = findViewById(R.id.radioGroundingVertex) - if (radioGroundingVertex.isChecked) { - serverMessageText = "[GROUNDING] $messageText" - } - - addMessage(ChatMessage.Text(messageText, true)) - editTextMessage.text.clear() - val jsonObject = JSONObject() - jsonObject.put("text", serverMessageText) - callPythonServer(jsonObject) - } + } catch (e: Exception) { + Log.e(TAG, "Error reading /proc/$pid/stat: ${e.message}") + } + + val file = File(filesDir, resourceLogFile) + FileWriter(file, true).use { writer -> + if (!file.exists() || file.length() == 0L) { + writer.append( + "Timestamp,CPU_Time_Delta_ms,CPU_Percentage,Total_PSS_KB,Dalvik_PSS_KB,Native_PSS_KB,Other_PSS_KB\n" + ) } - - buttonPrintLog.setOnClickListener { - printResourceLogToLogcat() - printLatencyLogToLogcat() - } - - startResourceLogging() + writer.append( + "$timestamp,$cpuTimeDeltaMs,${String.format("%.2f", cpuPercentage)},$totalPss,$dalvikPss,$nativePss,$otherPss\n" + ) + } + Log.d( + RESOURCE_LOG_TAG, + "Logged: CPU Delta=${cpuTimeDeltaMs}ms, CPU%=${String.format("%.2f", cpuPercentage)}, PSS=${totalPss}KB, Dalvik=${dalvikPss}KB, Native=${nativePss}KB, Other=${otherPss}KB", + ) + } catch (e: Exception) { + Log.e(TAG, "Error logging resource usage: ${e.message}") } - - private fun startResourceLogging() { - resourceLoggingJob?.cancel() - resourceLoggingJob = resourceLoggingScope.launch { - while (isActive) { - logResourceUsage() - delay(loggingIntervalMs) + } + + private fun printResourceLogToLogcat() { + resourceLoggingScope.launch { + try { + val file = File(filesDir, resourceLogFile) + if (file.exists()) { + BufferedReader(FileReader(file)).use { reader -> + var line: String? + while (reader.readLine().also { line = it } != null) { + Log.d(RESOURCE_LOG_OUTPUT_TAG, line ?: "") } + } + Log.d(RESOURCE_LOG_OUTPUT_TAG, "--- End of Resource Log ---") + } else { + Log.d(RESOURCE_LOG_OUTPUT_TAG, "Resource log file not found.") } + } catch (e: IOException) { + Log.e(RESOURCE_LOG_OUTPUT_TAG, "Error reading resource log: ${e.message}") + } } - - private fun logResourceUsage() { - try { - val timestamp = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date()) - val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager - val pid = Process.myPid() - val memoryInfo = activityManager.getProcessMemoryInfo(intArrayOf(pid)) - val debugMemoryInfo = memoryInfo[0] - val totalPss = debugMemoryInfo.totalPss - val dalvikPss = debugMemoryInfo.dalvikPss - val nativePss = debugMemoryInfo.nativePss - val otherPss = debugMemoryInfo.otherPss - - var cpuTimeDeltaMs = 0L - var cpuPercentage = 0.0 - try { - val statFile = File("/proc/$pid/stat") - BufferedReader(FileReader(statFile)).use { reader -> - val line = reader.readLine() - if (line != null) { - val parts = line.split(" ") - if (parts.size >= 17) { - val utime = parts[13].toLong() - val stime = parts[14].toLong() - val currentCpuTime = utime + stime - - if (lastCpuTime > 0) { - val cpuJiffiesDelta = currentCpuTime - lastCpuTime - cpuTimeDeltaMs = cpuJiffiesDelta * cpuJiffyToMs - - val totalAvailableCpuTimeMs = loggingIntervalMs * numberOfCores - if (totalAvailableCpuTimeMs > 0) { - cpuPercentage = (cpuTimeDeltaMs.toDouble() / totalAvailableCpuTimeMs.toDouble()) * 100.0 - } - } - lastCpuTime = currentCpuTime - } - } - } - } catch (e: Exception) { - Log.e(TAG, "Error reading /proc/$pid/stat: ${e.message}") - } - - val file = File(filesDir, resourceLogFile) - FileWriter(file, true).use { writer -> - if (!file.exists() || file.length() == 0L) { - writer.append("Timestamp,CPU_Time_Delta_ms,CPU_Percentage,Total_PSS_KB,Dalvik_PSS_KB,Native_PSS_KB,Other_PSS_KB\n") - } - writer.append("$timestamp,$cpuTimeDeltaMs,${String.format("%.2f", cpuPercentage)},$totalPss,$dalvikPss,$nativePss,$otherPss\n") - } - Log.d(RESOURCE_LOG_TAG, "Logged: CPU Delta=${cpuTimeDeltaMs}ms, CPU%=${String.format("%.2f", cpuPercentage)}, PSS=${totalPss}KB, Dalvik=${dalvikPss}KB, Native=${nativePss}KB, Other=${otherPss}KB") - - } catch (e: Exception) { - Log.e(TAG, "Error logging resource usage: ${e.message}") - } + } + + override fun onDestroy() { + resourceLoggingJob?.cancel() + super.onDestroy() + } + + private fun addMessage(message: ChatMessage) { + messages.add(message) + chatAdapter.notifyItemInserted(messages.size - 1) + recyclerView.post { recyclerView.scrollToPosition(messages.size - 1) } + } + + private fun removeLastLoadingMessage() { + val lastIndex = messages.size - 1 + if (lastIndex >= 0 && messages[lastIndex] is ChatMessage.Loading) { + messages.removeAt(lastIndex) + chatAdapter.notifyItemRemoved(lastIndex) + } + } + + private fun processJsonResponse(json: JSONObject) { + if (json.has("error")) { + val error = json.opt("error") + val errorMsg = + if (error is JSONObject) error.optString("message") + else error?.toString() ?: "Unknown error" + addMessage(ChatMessage.Text("Server Error: $errorMsg", false)) + return } - private fun printResourceLogToLogcat() { - resourceLoggingScope.launch { + val parsedParts = + try { + com.google.android.libraries.mapsplatform.a2ui.A2AResponseParser.parse(json) + } catch (e: Exception) { + Log.e(TAG, "Failed to parse A2UI payload", e) + emptyList() + } + + // Because the streaming data might be parsed into several incomplete ParsedA2AEvent.Data + // blocks, + // we extract and aggregate them into a single, valid JSON array here to prevent crashes when + // passed to the frontend. + var aggregatedText = StringBuilder() + var aggregatedJson = JSONArray() + + for (part in parsedParts) { + when (part) { + is com.google.android.libraries.mapsplatform.a2ui.ParsedA2AEvent.Text -> { + if (aggregatedText.isNotEmpty()) aggregatedText.append("\n") + aggregatedText.append(part.text) + } + is com.google.android.libraries.mapsplatform.a2ui.ParsedA2AEvent.Data -> { + if (part.data != "[]") { try { - val file = File(filesDir, resourceLogFile) - if (file.exists()) { - BufferedReader(FileReader(file)).use { reader -> - var line: String? - while (reader.readLine().also { line = it } != null) { - Log.d(RESOURCE_LOG_OUTPUT_TAG, line ?: "") - } - } - Log.d(RESOURCE_LOG_OUTPUT_TAG, "--- End of Resource Log ---") - } else { - Log.d(RESOURCE_LOG_OUTPUT_TAG, "Resource log file not found.") - } - } catch (e: IOException) { - Log.e(RESOURCE_LOG_OUTPUT_TAG, "Error reading resource log: ${e.message}") + val array = JSONArray(part.data) + for (j in 0 until array.length()) { + aggregatedJson.put(array.get(j)) + } + } catch (e: Exception) { + Log.e(TAG, "Error aggregating JSON data", e) } + } } + } } - override fun onDestroy() { - resourceLoggingJob?.cancel() - super.onDestroy() - } + val finalConversationalText = aggregatedText.toString() + val finalA2uiJson = if (aggregatedJson.length() > 0) aggregatedJson.toString() else "" - private fun addMessage(message: ChatMessage) { - messages.add(message) - chatAdapter.notifyItemInserted(messages.size - 1) - recyclerView.post { - recyclerView.scrollToPosition(messages.size - 1) + if (finalConversationalText.isNotEmpty() || finalA2uiJson.isNotEmpty()) { + if (finalConversationalText.isNotEmpty()) { + currentAgentTextIndex?.let { idx -> + messages[idx] = ChatMessage.Text(finalConversationalText, false) + chatAdapter.notifyItemChanged(idx) } - } - - private fun removeLastLoadingMessage() { - val lastIndex = messages.size - 1 - if (lastIndex >= 0 && messages[lastIndex] is ChatMessage.Loading) { - messages.removeAt(lastIndex) - chatAdapter.notifyItemRemoved(lastIndex) + ?: run { + messages.add(ChatMessage.Text(finalConversationalText, false)) + val newIdx = messages.size - 1 + currentAgentTextIndex = newIdx + chatAdapter.notifyItemInserted(newIdx) + scrollToLastMessage() + } + } + if (finalA2uiJson.isNotEmpty() && finalA2uiJson != "[]") { + currentAgentA2UIIndex?.let { idx -> + val oldMsg = messages[idx] as? ChatMessage.GmpA2UIView + messages[idx] = + ChatMessage.GmpA2UIView(finalA2uiJson, oldMsg?.startTime ?: System.currentTimeMillis()) + + val viewHolder = recyclerView.findViewHolderForAdapterPosition(idx) + if (viewHolder is ChatAdapter.GmpA2UIViewHolder) { + viewHolder.updateA2uiJson(finalA2uiJson) + } else { + chatAdapter.notifyItemChanged(idx) + } } + ?: run { + val gmpViewStartTime = System.currentTimeMillis() + messages.add(ChatMessage.GmpA2UIView(finalA2uiJson, gmpViewStartTime)) + val newIdx = messages.size - 1 + currentAgentA2UIIndex = newIdx + chatAdapter.notifyItemInserted(newIdx) + scrollToLastMessage() + } + } } + } - private fun processJsonResponse(json: JSONObject) { - if (json.has("error")) { - val error = json.opt("error") - val errorMsg = if (error is JSONObject) error.optString("message") else error?.toString() ?: "Unknown error" - addMessage(ChatMessage.Text("Server Error: $errorMsg", false)) - return - } - - val parsedParts = try { - com.google.android.libraries.mapsplatform.a2ui.A2AResponseParser.parse(json) - } catch (e: Exception) { - Log.e(TAG, "Failed to parse A2UI payload", e) - emptyList() - } - - // Because the streaming data might be parsed into several incomplete ParsedA2AEvent.Data blocks, - // we extract and aggregate them into a single, valid JSON array here to prevent crashes when passed to the frontend. - var aggregatedText = StringBuilder() - var aggregatedJson = JSONArray() + private fun logLatency(type: String, latencyMs: Long, status: String) { + lifecycleScope.launch(Dispatchers.IO) { + try { + val file = File(filesDir, latencyLogFile) + val writer = FileWriter(file, true) // Append mode - for (part in parsedParts) { - when (part) { - is com.google.android.libraries.mapsplatform.a2ui.ParsedA2AEvent.Text -> { - if (aggregatedText.isNotEmpty()) aggregatedText.append("\n") - aggregatedText.append(part.text) - } - is com.google.android.libraries.mapsplatform.a2ui.ParsedA2AEvent.Data -> { - if (part.data != "[]") { - try { - val array = JSONArray(part.data) - for (j in 0 until array.length()) { - aggregatedJson.put(array.get(j)) - } - } catch (e: Exception) { - Log.e(TAG, "Error aggregating JSON data", e) - } - } - } - } + if (!file.exists() || file.length() == 0L) { + writer.append("Timestamp,Type,Latency (ms),Status\n") } - val finalConversationalText = aggregatedText.toString() - val finalA2uiJson = if (aggregatedJson.length() > 0) aggregatedJson.toString() else "" - - if (finalConversationalText.isNotEmpty() || finalA2uiJson.isNotEmpty()) { - if (finalConversationalText.isNotEmpty()) { - currentAgentTextIndex?.let { idx -> - messages[idx] = ChatMessage.Text(finalConversationalText, false) - chatAdapter.notifyItemChanged(idx) - } ?: run { - messages.add(ChatMessage.Text(finalConversationalText, false)) - val newIdx = messages.size - 1 - currentAgentTextIndex = newIdx - chatAdapter.notifyItemInserted(newIdx) - scrollToLastMessage() - } - } - if (finalA2uiJson.isNotEmpty() && finalA2uiJson != "[]") { - currentAgentA2UIIndex?.let { idx -> - val oldMsg = messages[idx] as? ChatMessage.GmpA2UIView - messages[idx] = ChatMessage.GmpA2UIView(finalA2uiJson, oldMsg?.startTime ?: System.currentTimeMillis()) - - val viewHolder = recyclerView.findViewHolderForAdapterPosition(idx) - if (viewHolder is ChatAdapter.GmpA2UIViewHolder) { - viewHolder.updateA2uiJson(finalA2uiJson) - } else { - chatAdapter.notifyItemChanged(idx) - } - } ?: run { - val gmpViewStartTime = System.currentTimeMillis() - messages.add(ChatMessage.GmpA2UIView(finalA2uiJson, gmpViewStartTime)) - val newIdx = messages.size - 1 - currentAgentA2UIIndex = newIdx - chatAdapter.notifyItemInserted(newIdx) - scrollToLastMessage() - } + val timestamp = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date()) + writer.append("$timestamp,$type,$latencyMs,$status\n") + writer.flush() + writer.close() + Log.d(TAG, "$type Latency logged: $latencyMs ms, Status: $status") + } catch (e: IOException) { + Log.e(TAG, "Error logging latency: ${e.message}") + } + } + } + + private fun printLatencyLogToLogcat() { + lifecycleScope.launch(Dispatchers.IO) { + try { + val file = File(filesDir, latencyLogFile) + if (file.exists()) { + BufferedReader(FileReader(file)).use { reader -> + var line: String? + while (reader.readLine().also { line = it } != null) { + Log.d(LATENCY_TAG, line ?: "") } + } + Log.d(LATENCY_TAG, "--- End of Latency Log ---") + } else { + Log.d(LATENCY_TAG, "Latency log file not found.") } + } catch (e: IOException) { + Log.e(LATENCY_TAG, "Error reading latency log: ${e.message}") + } } + } + private val apiKey = BuildConfig.GATEWAY_API_KEY - private fun logLatency(type: String, latencyMs: Long, status: String) { - lifecycleScope.launch(Dispatchers.IO) { - try { - val file = File(filesDir, latencyLogFile) - val writer = FileWriter(file, true) // Append mode - - if (!file.exists() || file.length() == 0L) { - writer.append("Timestamp,Type,Latency (ms),Status\n") - } - - val timestamp = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date()) - writer.append("$timestamp,$type,$latencyMs,$status\n") - writer.flush() - writer.close() - Log.d(TAG, "$type Latency logged: $latencyMs ms, Status: $status") - } catch (e: IOException) { - Log.e(TAG, "Error logging latency: ${e.message}") - } - } + private fun discoverProtocol() { + if (hasDiscoveredProtocol) { + return } + hasDiscoveredProtocol = true + + val request = + Request.Builder() + .url("$baseUrl/apps/$appName/users/user/sessions?key=$apiKey") + .post("{}".toRequestBody("application/json".toMediaType())) + .build() - private fun printLatencyLogToLogcat() { - lifecycleScope.launch(Dispatchers.IO) { + try { + client.newCall(request).execute().use { response -> + if (response.isSuccessful) { + val body = response.body?.string() ?: "" + if (body.isNotEmpty()) { try { - val file = File(filesDir, latencyLogFile) - if (file.exists()) { - BufferedReader(FileReader(file)).use { reader -> - var line: String? - while (reader.readLine().also { line = it } != null) { - Log.d(LATENCY_TAG, line ?: "") - } - } - Log.d(LATENCY_TAG, "--- End of Latency Log ---") - } else { - Log.d(LATENCY_TAG, "Latency log file not found.") - } - } catch (e: IOException) { - Log.e(LATENCY_TAG, "Error reading latency log: ${e.message}") + val json = JSONObject(body) + val id = if (json.has("id")) json.optString("id") else null + if (id != null && id.isNotEmpty()) { + activeSessionId = id + useSseProtocol = true + Log.d(TAG, "Discovered ADK Web Server (SSE) protocol") + } else { + useSseProtocol = false + Log.d(TAG, "Discovered Standalone (JSON-RPC) protocol") + } + } catch (e: Exception) { + useSseProtocol = false + Log.d(TAG, "Discovered Standalone (JSON-RPC) protocol") } + } else { + useSseProtocol = false + Log.d(TAG, "Discovered Standalone (JSON-RPC) protocol") + } + } else { + useSseProtocol = false + Log.d(TAG, "Discovered Standalone (JSON-RPC) protocol") } + } + } catch (e: IOException) { + useSseProtocol = false + Log.d(TAG, "Discovered Standalone (JSON-RPC) protocol on failure") + } + } + + public fun callPythonServer(userMessage: JSONObject) { + runOnUiThread { addMessage(ChatMessage.Loading) } + + // --- CANNED PROMPT INTERCEPTION --- + val textStr = userMessage.optString("text") + + var fileMap: Map = emptyMap() + try { + val mappingJson = + assets.open("canned_responses/mapping.json").bufferedReader().use { it.readText() } + val mapObj = JSONObject(mappingJson) + val tempMap = mutableMapOf() + for (key in mapObj.keys()) { + val value = mapObj.getString(key) + // Ensure value points to the correct new directory name + val correctValue = + if (value.startsWith("prompt_")) "canned_responses/$value" + else value.replace("canned_prompts", "canned_responses") + tempMap[key] = correctValue + } + fileMap = tempMap + } catch (e: Exception) { + Log.e(TAG, "Error loading mapping.json", e) } - private val apiKey = BuildConfig.GATEWAY_API_KEY - - private fun discoverProtocol() { - if (hasDiscoveredProtocol) { - return - } - hasDiscoveredProtocol = true - - val request = Request.Builder() - .url("$baseUrl/apps/$appName/users/user/sessions?key=$apiKey") - .post("{}".toRequestBody("application/json".toMediaType())) - .build() - + if (textStr.isNotEmpty()) { + val fileName = fileMap[textStr] + if (fileName != null) { try { - client.newCall(request).execute().use { response -> - if (response.isSuccessful) { - val body = response.body.string() ?: "" - if (body.isNotEmpty()) { - try { - val json = JSONObject(body) - val id = if (json.has("id")) json.optString("id") else null - if (id != null && id.isNotEmpty()) { - activeSessionId = id - useSseProtocol = true - Log.d(TAG, "Discovered ADK Web Server (SSE) protocol") - } else { - useSseProtocol = false - Log.d(TAG, "Discovered Standalone (JSON-RPC) protocol") - } - } catch(e: Exception) { - useSseProtocol = false - Log.d(TAG, "Discovered Standalone (JSON-RPC) protocol") - } - } else { - useSseProtocol = false - Log.d(TAG, "Discovered Standalone (JSON-RPC) protocol") - } - } else { - useSseProtocol = false - Log.d(TAG, "Discovered Standalone (JSON-RPC) protocol") - } + val jsonString = assets.open(fileName).bufferedReader().use { it.readText() } + val cannedResponse = JSONObject(jsonString) + Log.d(TAG, "Using canned response from $fileName") + // Simulate network delay + lifecycleScope.launch(Dispatchers.IO) { + delay(2000) + runOnUiThread { + removeLastLoadingMessage() + processJsonResponse(cannedResponse) } - } catch (e: IOException) { - useSseProtocol = false - Log.d(TAG, "Discovered Standalone (JSON-RPC) protocol on failure") + } + return + } catch (e: Exception) { + Log.e(TAG, "Error loading canned response", e) } + } } - public fun callPythonServer(userMessage: JSONObject) { - runOnUiThread { addMessage(ChatMessage.Loading) } - - // --- CANNED PROMPT INTERCEPTION --- - val textStr = userMessage.optString("text") - - var fileMap: Map = emptyMap() - try { - val mappingJson = assets.open("canned_responses/mapping.json").bufferedReader().use { it.readText() } - val mapObj = JSONObject(mappingJson) - val tempMap = mutableMapOf() - for (key in mapObj.keys()) { - val value = mapObj.getString(key) - // Ensure value points to the correct new directory name - val correctValue = if (value.startsWith("prompt_")) "canned_responses/$value" else value.replace("canned_prompts", "canned_responses") - tempMap[key] = correctValue - } - fileMap = tempMap - } catch (e: Exception) { - Log.e(TAG, "Error loading mapping.json", e) + val startTime = System.currentTimeMillis() + val currentCookie = traceCookie.incrementAndGet() + // Trace.beginAsyncSection("Server Response", currentCookie) + lifecycleScope.launch(Dispatchers.IO) { + discoverProtocol() + val partsArray = JSONArray() + if (userMessage.has("text")) { + partsArray.put(JSONObject().apply { put("text", userMessage.optString("text")) }) + } else if (userMessage.has("userAction")) { + partsArray.put( + JSONObject().apply { + put("data", JSONObject().apply { put("userAction", userMessage.opt("userAction")) }) + } + ) + } + + val request: Request + if (useSseProtocol) { + val json = + JSONObject().apply { + put("appName", appName) + put("userId", "user") + put("sessionId", activeSessionId ?: "") + put( + "newMessage", + JSONObject().apply { + put("role", "user") + put("parts", partsArray) + }, + ) + } + val body = json.toString().toRequestBody("application/json".toMediaType()) + val requestBuilder = + Request.Builder() + .url("$baseUrl/run_sse") + .post(body) + .addHeader("Content-Type", "application/json") + if (activeServer == ServerType.DEMO) { + requestBuilder.addHeader("X-A2A-Extensions", "https://a2ui.org/a2a-extension/a2ui/v0.9") } - - if (textStr.isNotEmpty()) { - val fileName = fileMap[textStr] - if (fileName != null) { - try { - val jsonString = assets.open(fileName).bufferedReader().use { it.readText() } - val cannedResponse = JSONObject(jsonString) - Log.d(TAG, "Using canned response from $fileName") - // Simulate network delay - lifecycleScope.launch(Dispatchers.IO) { - delay(2000) - runOnUiThread { - removeLastLoadingMessage() - processJsonResponse(cannedResponse) - } - } - return - } catch (e: Exception) { - Log.e(TAG, "Error loading canned response", e) - } - } + request = requestBuilder.build() + } else { + val json = + JSONObject().apply { + put("jsonrpc", "2.0") + put("method", "message/send") + put("id", 1) + put( + "params", + JSONObject().apply { + put( + "message", + JSONObject().apply { + put("role", "user") + put("messageId", UUID.randomUUID().toString()) + put("contextId", contextId) + put("parts", partsArray) + }, + ) + }, + ) + } + val body = json.toString().toRequestBody("application/json".toMediaType()) + val requestBuilder = + Request.Builder() + .url("$baseUrl/?key=$apiKey") + .post(body) + .addHeader("Content-Type", "application/json") + if (activeServer == ServerType.DEMO) { + requestBuilder.addHeader("X-A2A-Extensions", "https://a2ui.org/a2a-extension/a2ui/v0.9") } - - val startTime = System.currentTimeMillis() - val currentCookie = traceCookie.incrementAndGet() - // Trace.beginAsyncSection("Server Response", currentCookie) - lifecycleScope.launch(Dispatchers.IO) { - discoverProtocol() - val partsArray = JSONArray() - if (userMessage.has("text")) { - partsArray.put(JSONObject().apply { - put("text", userMessage.optString("text")) - }) - } else if (userMessage.has("userAction")) { - partsArray.put(JSONObject().apply { - put("data", JSONObject().apply { - put("userAction", userMessage.opt("userAction")) - }) - }) - } - - val request: Request - if (useSseProtocol) { - val json = JSONObject().apply { - put("appName", appName) - put("userId", "user") - put("sessionId", activeSessionId ?: "") - put("newMessage", JSONObject().apply { - put("role", "user") - put("parts", partsArray) - }) - } - val body = json.toString().toRequestBody("application/json".toMediaType()) - val requestBuilder = Request.Builder() - .url("$baseUrl/run_sse") - .post(body) - .addHeader("Content-Type", "application/json") - if (activeServer == ServerType.DEMO) { - requestBuilder.addHeader("X-A2A-Extensions", "https://a2ui.org/a2a-extension/a2ui/v0.9") - } - request = requestBuilder.build() - } else { - val json = JSONObject().apply { - put("jsonrpc", "2.0") - put("method", "message/send") - put("id", 1) - put("params", JSONObject().apply { - put("message", JSONObject().apply { - put("role", "user") - put("messageId", UUID.randomUUID().toString()) - put("contextId", contextId) - put("parts", partsArray) - }) - }) - } - val body = json.toString().toRequestBody("application/json".toMediaType()) - val requestBuilder = Request.Builder() - .url("$baseUrl/?key=$apiKey") - .post(body) - .addHeader("Content-Type", "application/json") - if (activeServer == ServerType.DEMO) { - requestBuilder.addHeader("X-A2A-Extensions", "https://a2ui.org/a2a-extension/a2ui/v0.9") - } - request = requestBuilder.build() - } - - try { - currentActiveCall = client.newCall(request) - currentActiveCall?.execute()?.use { response -> - val endTime = System.currentTimeMillis() - val latency = endTime - startTime - // Trace.endAsyncSection("Server Response", currentCookie) - logLatency("Server", latency, if (response.isSuccessful) "Success" else "Failure") - runOnUiThread { removeLastLoadingMessage() } - if (!response.isSuccessful) { - runOnUiThread { - addMessage(ChatMessage.Text("Error: ${response.code} - ${response.message}", false)) - } - return@use - } - handleSuccessfulResponse(response) - } - } catch (e: IOException) { - val endTime = System.currentTimeMillis() - val latency = endTime - startTime - // Trace.endAsyncSection("Server Response", currentCookie) - logLatency("Server", latency, "Network Error") - runOnUiThread { - removeLastLoadingMessage() - addMessage(ChatMessage.Text("Network Error: ${e.message}", false)) - } + request = requestBuilder.build() + } + + try { + currentActiveCall = client.newCall(request) + currentActiveCall?.execute()?.use { response -> + val endTime = System.currentTimeMillis() + val latency = endTime - startTime + // Trace.endAsyncSection("Server Response", currentCookie) + logLatency("Server", latency, if (response.isSuccessful) "Success" else "Failure") + runOnUiThread { removeLastLoadingMessage() } + if (!response.isSuccessful) { + runOnUiThread { + addMessage(ChatMessage.Text("Error: ${response.code} - ${response.message}", false)) } + return@use + } + handleSuccessfulResponse(response) } + } catch (e: IOException) { + val endTime = System.currentTimeMillis() + val latency = endTime - startTime + // Trace.endAsyncSection("Server Response", currentCookie) + logLatency("Server", latency, "Network Error") + runOnUiThread { + removeLastLoadingMessage() + addMessage(ChatMessage.Text("Network Error: ${e.message}", false)) + } + } } - - // The LLM's streaming response (SSE) arrives in fragmented chunks. - // We use this StringBuilder to accumulate all the text chunks and assemble them into a complete JSON string before parsing. - private var globalSseAccumulator = StringBuilder() - - private fun handleSuccessfulResponse(response: okhttp3.Response) { - if (useSseProtocol || response.header("Content-Type")?.contains("text/event-stream") == true) { - val source = response.body.source() ?: return - globalSseAccumulator.clear() - while (!source.exhausted()) { - val line = source.readUtf8Line() - if (line != null && line.startsWith("data: ")) { - val data = line.removePrefix("data: ") - if (data.isNotEmpty() && data != "[DONE]") { - try { - val jsonObj = JSONObject(data) - // Extract text delta to accumulate - var textDelta = "" - if (jsonObj.has("parts")) { - val parts = jsonObj.optJSONArray("parts") - if (parts != null) { - for (i in 0 until parts.length()) { - val p = parts.optJSONObject(i) - if (p != null && p.has("text")) { - textDelta += p.optString("text") - } - } - } - } - if (textDelta.isNotEmpty()) { - globalSseAccumulator.append(textDelta) - } - - // 1. Update the native Android text bubble with the accumulated conversation text - if (globalSseAccumulator.isNotEmpty()) { - val textUpdate = JSONObject().put("parts", JSONArray().put(JSONObject().put("text", globalSseAccumulator.toString()))) - runOnUiThread { processJsonResponse(textUpdate) } - } - - // 2. Pass the raw JSON chunk to the WebView. - // The frontend (AppMobile.tsx) now handles hallucination fixes, path resolution, and text deduplication. - runOnUiThread { processJsonResponse(jsonObj) } - } catch (e: Exception) { - Log.e(TAG, "Error parsing SSE data: $data", e) - } - } - } - } - } else { - val responseData = response.body.string() ?: return - runOnUiThread { - try { - val jsonResponse = JSONObject(responseData) - val result = jsonResponse.opt("result") - if (result is JSONObject) { - processJsonResponse(result) - } else if (result is String) { - try { - val inner = JSONObject(result) - processJsonResponse(inner) - } catch (e: Exception) { - processJsonResponse(jsonResponse) - } - } else { - processJsonResponse(jsonResponse) + } + + // The LLM's streaming response (SSE) arrives in fragmented chunks. + // We use this StringBuilder to accumulate all the text chunks and assemble them into a complete + // JSON string before parsing. + private var globalSseAccumulator = StringBuilder() + + private fun handleSuccessfulResponse(response: okhttp3.Response) { + if (useSseProtocol || response.header("Content-Type")?.contains("text/event-stream") == true) { + val source = response.body?.source() ?: return + globalSseAccumulator.clear() + while (!source.exhausted()) { + val line = source.readUtf8Line() + if (line != null && line.startsWith("data: ")) { + val data = line.removePrefix("data: ") + if (data.isNotEmpty() && data != "[DONE]") { + try { + val jsonObj = JSONObject(data) + // Extract text delta to accumulate + var textDelta = "" + if (jsonObj.has("parts")) { + val parts = jsonObj.optJSONArray("parts") + if (parts != null) { + for (i in 0 until parts.length()) { + val p = parts.optJSONObject(i) + if (p != null && p.has("text")) { + textDelta += p.optString("text") } - } catch (e: Exception) { - addMessage(ChatMessage.Text("Error parsing JSON: ${e.message}", false)) + } } + } + if (textDelta.isNotEmpty()) { + globalSseAccumulator.append(textDelta) + } + + // 1. Update the native Android text bubble with the accumulated conversation text + if (globalSseAccumulator.isNotEmpty()) { + val textUpdate = + JSONObject() + .put( + "parts", + JSONArray().put(JSONObject().put("text", globalSseAccumulator.toString())), + ) + runOnUiThread { processJsonResponse(textUpdate) } + } + + // 2. Pass the raw JSON chunk to the WebView. + // The frontend (AppMobile.tsx) now handles hallucination fixes, path resolution, and + // text deduplication. + runOnUiThread { processJsonResponse(jsonObj) } + } catch (e: Exception) { + Log.e(TAG, "Error parsing SSE data: $data", e) } + } } - } - - public fun scrollToLastMessage() { - runOnUiThread { - if (recyclerView.scrollState == RecyclerView.SCROLL_STATE_IDLE) { - recyclerView.post { - recyclerView.scrollToPosition(messages.size - 1) - } + } + } else { + val responseData = response.body?.string() ?: return + runOnUiThread { + try { + val jsonResponse = JSONObject(responseData) + val result = jsonResponse.opt("result") + if (result is JSONObject) { + processJsonResponse(result) + } else if (result is String) { + try { + val inner = JSONObject(result) + processJsonResponse(inner) + } catch (e: Exception) { + processJsonResponse(jsonResponse) } + } else { + processJsonResponse(jsonResponse) + } + } catch (e: Exception) { + addMessage(ChatMessage.Text("Error parsing JSON: ${e.message}", false)) } + } } + } - companion object { - private const val TAG = "MainActivity" - private const val LATENCY_TAG = "LatencyLog" - private const val RESOURCE_LOG_TAG = "ResourceLog" - private const val RESOURCE_LOG_OUTPUT_TAG = "ResourceLogOutput" - private const val MAX_ITEM_VIEW_CACHE_SIZE = 10 + public fun scrollToLastMessage() { + runOnUiThread { + if (recyclerView.scrollState == RecyclerView.SCROLL_STATE_IDLE) { + recyclerView.post { recyclerView.scrollToPosition(messages.size - 1) } + } } + } + + companion object { + private const val TAG = "MainActivity" + private const val LATENCY_TAG = "LatencyLog" + private const val RESOURCE_LOG_TAG = "ResourceLog" + private const val RESOURCE_LOG_OUTPUT_TAG = "ResourceLogOutput" + private const val MAX_ITEM_VIEW_CACHE_SIZE = 10 + } } diff --git a/client/android/app/src/main/res/mipmap/.gitkeep b/client/android/app/src/main/res/mipmap/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/client/android/gradle/wrapper/gradle-wrapper.jar b/client/android/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index 8bdaf60c75ab801e22807dde59e12a8735a34077..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 45457 zcma&NW0YlEwk;ePwr$(aux;D69T}N{9ky*d!_2U4+qUuIRNZ#Jck8}7U+vcB{`IjNZqX3eq5;s6ddAkU&5{L|^Ow`ym2B0m+K02+~Q)i807X3X94qi>j)C0e$=H zm31v`=T&y}ACuKx7G~yWSYncG=NFB>O2);i9EmJ(9jSamq?Crj$g~1l3m-4M7;BWn zau2S&sSA0b0Rhg>6YlVLQa;D#)1yw+eGs~36Q$}5?avIRne3TQZXb<^e}?T69w<9~ zUmx1cG0uZ?Kd;Brd$$>r>&MrY*3$t^PWF1+J+G_xmpHW=>mly$<>~wHH+Bt3mzN7W zhR)g{_veH6>*KxLJ~~s{9HZm!UeC86d_>42NRqd$ev8zSMq4kt)q*>8kJ8p|^wuKx zq2Is_HJPoQ_apSoT?zJj7vXBp!xejBc^7F|zU0rhy%Ub*Dy#jJs!>1?CmJ-gulPVX zKit>RVmjL=G?>jytf^U@mfnC*1-7EVag@%ROu*#kA+)Rxq?MGK0v-dp^kM?nyMngb z_poL>GLThB7xAO*I7&?4^Nj`<@O@>&0M-QxIi zD@n}s%CYI4Be19C$lAb9Bbm6!R{&A;=yh=#fnFyb`s7S5W3?arZf?$khCwkGN!+GY~GT8-`!6pFr zbFBVEF`kAgtecfjJ`flN2Z!$$8}6hV>Tu;+rN%$X^t8fI>tXQnRn^$UhXO8Gu zt$~QON8`doV&{h}=2!}+xJKrNPcIQid?WuHUC-i%P^F(^z#XB`&&`xTK&L+i8a3a@ zkV-Jy;AnyQ`N=&KONV_^-0WJA{b|c#_l=v!19U@hS~M-*ix16$r01GN3#naZ|DxY2 z76nbjbOnFcx4bKbEoH~^=EikiZ)_*kOb>nW6>_vjf-UCf0uUy~QBb7~WfVO6qN@ns zz=XEG0s5Yp`mlmUad)8!(QDgIzY=OK%_hhPStbyYYd|~zDIc3J4 zy9y%wZOW>}eG4&&;Z>vj&Mjg+>4gL! z(@oCTFf-I^54t=*4AhKRoE-0Ky=qg3XK2Mu!Bmw@z>y(|a#(6PcfbVTw-dUqyx4x4 z3O#+hW1ANwSv-U+9otHE#U9T>(nWx>^7RO_aI>${jvfZQ{mUwiaxHau!H z0Nc}ucJu+bKux?l!dQ2QA(r@(5KZl(Or=U!=2K*8?D=ZT-IAcAX!5OI3w@`sF@$($ zbDk0p&3X0P%B0aKdijO|s})70K&mk1DC|P##b=k@fcJ|lo@JNWRUc>KL?6dJpvtSUK zxR|w8Bo6K&y~Bd}gvuz*3z z@sPJr{(!?mi@okhudaM{t3gp9TJ!|@j4eO1C&=@h#|QLCUKLaKVL z!lls$%N&ZG7yO#jK?U>bJ+^F@K#A4d&Jz4boGmptagnK!Qu{Ob>%+60xRYK>iffd_ z>6%0K)p!VwP$^@Apm%NrS6TpKJwj_Q=k~?4=_*NIe~eh_QtRaqX4t-rJAGYdB{pGq zSXX)-dR8mQ)X|;8@_=J6Dk7MfMp;x)^aZeCtScHs12t3vL+p-6!qhPkOM1OYQ z8YXW5tWp)Th(+$m7SnV_hNGKAP`JF4URkkNc@YV9}FK$9k zR&qgi$Cj#4bC1VK%#U)f%(+oQJ+EqvV{uAq1YG0riLvGxW@)m;*ayU-BSW61COFy0 z(-l>GJqYl;*x1PnRZ(p3Lm}* zlkpWyCoYtg9pAZ5RU^%w=vN{3Y<6WImxj(*SCcJsFj?o6CZ~>cWW^foliM#qN#We{ zwsL!u1$rzC1#4~bILZm*a!T{^kCci$XOJADm)P;y^%x5)#G#_!2uNp^S;cE`*ASCn;}H7pP^RRA z6lfXK(r4dy<_}R|(7%Lyo>QFP#s31E8zsYA${gSUykUV@?lyDNF=KhTeF^*lu7C*{ zBCIjy;bIE;9inJ$IT8_jL%)Q{7itmncYlkf2`lHl(gTwD%LmEPo^gskydVxMd~Do` zO8EzF!yn!r|BEgPjhW#>g(unY#n}=#4J;3FD2ThN5LpO0tI2~pqICaFAGT%%;3Xx$ z>~Ng(64xH-RV^Rj4=A_q1Ee8kcF}8HN{5kjYX0ADh}jq{q18x(pV!23pVsK5S}{M#p8|+LvfKx|_3;9{+6cu7%5o-+R@z>TlTft#kcJ`s2-j zUe4dgpInZU!<}aTGuwgdWJZ#8TPiV9QW<-o!ibBn&)?!ZDomECehvT7GSCRyF#VN2&5GShch9*}4p;8TX~cW*<#( zv-HmU7&+YUWO__NN3UbTFJ&^#3vxW4U9q5=&ORa+2M$4rskA4xV$rFSEYBGy55b{z z!)$_fYXiY?-GWDhGZXgTw}#ilrw=BiN(DGO*W7Vw(} zjUexksYLt_Nq?pl_nVa@c1W#edQKbT>VSN1NK?DulHkFpI-LXl7{;dl@z0#v?x%U& z8k8M1X6%TwR4BQ_eEWJASvMTy?@fQubBU__A_US567I-~;_VcX^NJ-E(ZPR^NASj1 zVP!LIf8QKtcdeH#w6ak50At)e={eF_Ns6J2Iko6dn8Qwa6!NQHZMGsD zhzWeSFK<{hJV*!cIHxjgR+e#lkUHCss-j)$g zF}DyS531TUXKPPIoePo{yH%qEr-dLMOhv^sC&@9YI~uvl?rBp^A-57{aH_wLg0&a|UxKLlYZQ24fpb24Qjil`4OCyt0<1eu>5i1Acv zaZtQRF)Q;?Aw3idg;8Yg9Cb#)03?pQ@O*bCloG zC^|TnJl`GXN*8iI;Ql&_QIY0ik}rqB;cNZ-qagp=qmci9eScHsRXG$zRNdf4SleJ} z7||<#PCW~0>3u8PP=-DjNhD(^(B0AFF+(oKOiQyO5#v4nI|v_D5@c2;zE`}DK!%;H zUn|IZ6P;rl*5`E(srr6@-hpae!jW=-G zC<*R?RLwL;#+hxN4fJ!oP4fX`vC3&)o!#l4y@MrmbmL{t;VP%7tMA-&vju_L zhtHbOL4`O;h*5^e3F{b9(mDwY6JwL8w`oi28xOyj`pVo!75hngQDNg7^D$h4t&1p2 ziWD_!ap3GM(S)?@UwWk=Szym^eDxSx3NaR}+l1~(@0car6tfP#sZRTb~w!WAS{+|SgUN3Tv`J4OMf z9ta_f>-`!`I@KA=CXj_J>CE7T`yGmej0}61sE(%nZa1WC_tV6odiysHA5gzfWN-`uXF46mhJGLpvNTBmx$!i zF67bAz~E|P{L6t1B+K|Cutp&h$fDjyq9JFy$7c_tB(Q$sR)#iMQH3{Og1AyD^lyQwX6#B|*ecl{-_;*B>~WSFInaRE_q6 zpK#uCprrCb`MU^AGddA#SS{P7-OS9h%+1`~9v-s^{s8faWNpt*Pmk_ECjt(wrpr{C_xdAqR(@!ERTSs@F%^DkE@No}wqol~pS^e7>ksF_NhL0?6R4g`P- zk8lMrVir~b(KY+hk5LQngwm`ZQT5t1^7AzHB2My6o)_ejR0{VxU<*r-Gld`l6tfA` zKoj%x9=>Ce|1R|1*aC}|F0R32^KMLAHN}MA<8NNaZ^j?HKxSwxz`N2hK8lEb{jE0& zg4G_6F@#NyDN?=i@=)eidKhlg!nQoA{`PgaH{;t|M#5z}a`u?^gy{5L~I2smLR z*4RmNxHqf9>D>sXSemHK!h4uPwMRb+W`6F>Q6j@isZ>-F=)B2*sTCD9A^jjUy)hjAw71B&$u}R(^R; zY9H3k8$|ounk>)EOi_;JAKV8U8ICSD@NrqB!&=)Ah_5hzp?L9Sw@c>>#f_kUhhm=p z1jRz8X7)~|VwO(MF3PS(|CL++1n|KT3*dhGjg!t_vR|8Yg($ z+$S$K=J`K6eG#^(J54=4&X#+7Car=_aeAuC>dHE+%v9HFu>r%ry|rwkrO-XPhR_#K zS{2Unv!_CvS7}Mb6IIT$D4Gq5v$Pvi5nbYB+1Yc&RY;3;XDihlvhhIG6AhAHsBYsm zK@MgSzs~y|+f|j-lsXKT0(%E2SkEb)p+|EkV5w8=F^!r1&0#0^tGhf9yPZ)iLJ^ zIXOg)HW_Vt{|r0W(`NmMLF$?3ZQpq+^OtjR-DaVLHpz%1+GZ7QGFA?(BIqBlVQ;)k zu)oO|KG&++gD9oL7aK4Zwjwi~5jqk6+w%{T$1`2>3Znh=OFg|kZ z>1cn>CZ>P|iQO%-Pic8wE9c*e%=3qNYKJ+z1{2=QHHFe=u3rqCWNhV_N*qzneN8A5 zj`1Ir7-5`33rjDmyIGvTx4K3qsks(I(;Kgmn%p#p3K zn8r9H8kQu+n@D$<#RZtmp$*T4B&QvT{K&qx(?>t@mX%3Lh}sr?gI#vNi=vV5d(D<=Cp5-y!a{~&y|Uz*PU{qe zI7g}mt!txT)U(q<+Xg_sSY%1wVHy;Dv3uze zJ>BIdSB2a|aK+?o63lR8QZhhP)KyQvV`J3)5q^j1-G}fq=E4&){*&hiam>ssYm!ya z#PsY0F}vT#twY1mXkGYmdd%_Uh12x0*6lN-HS-&5XWbJ^%su)-vffvKZ%rvLHVA<; zJP=h13;x?$v30`T)M)htph`=if#r#O5iC^ZHeXc6J8gewn zL!49!)>3I-q6XOZRG0=zjyQc`tl|RFCR}f-sNtc)I^~?Vv2t7tZZHvgU2Mfc9$LqG z!(iz&xb=q#4otDBO4p)KtEq}8NaIVcL3&pbvm@0Kk-~C@y3I{K61VDF_=}c`VN)3P z+{nBy^;=1N`A=xH$01dPesY_na*zrcnssA}Ix60C=sWg9EY=2>-yH&iqhhm28qq9Z z;}znS4ktr40Lf~G@6D5QxW&?q^R|=1+h!1%G4LhQs54c2Wo~4% zCA||d==lv2bP=9%hd0Dw_a$cz9kk)(Vo}NpSPx!vnV*0Bh9$CYP~ia#lEoLRJ8D#5 zSJS?}ABn1LX>8(Mfg&eefX*c0I5bf4<`gCy6VC{e>$&BbwFSJ0CgVa;0-U7=F81R+ zUmzz&c;H|%G&mSQ0K16Vosh?sjJW(Gp+1Yw+Yf4qOi|BFVbMrdO6~-U8Hr|L@LHeZ z0ALmXHsVm137&xnt#yYF$H%&AU!lf{W436Wq87nC16b%)p?r z70Wua59%7Quak50G7m3lOjtvcS>5}YL_~?Pti_pfAfQ!OxkX$arHRg|VrNx>R_Xyi z`N|Y7KV`z3(ZB2wT9{Dl8mtl zg^UOBv~k>Z(E)O>Z;~Z)W&4FhzwiPjUHE9&T#nlM)@hvAZL>cha-< zQ8_RL#P1?&2Qhk#c9fK9+xM#AneqzE-g(>chLp_Q2Xh$=MAsW z2ScEKr+YOD*R~mzy{bOJjs;X2y1}DVFZi7d_df^~((5a2%p%^4cf>vM_4Sn@@ssVJ z9ChGhs zbanJ+h74)3tWOviXI|v!=HU2mE%3Th$Mpx&lEeGFEBWRy8ogJY`BCXj@7s~bjrOY! z4nIU5S>_NrpN}|waZBC)$6ST8x91U2n?FGV8lS{&LFhHbuHU?SVU{p7yFSP_f#Eyh zJhI@o9lAeEwbZYC=~<(FZ$sJx^6j@gtl{yTOAz`Gj!Ab^y})eG&`Qt2cXdog2^~oOH^K@oHcE(L;wu2QiMv zJuGdhNd+H{t#Tjd<$PknMSfbI>L1YIdZ+uFf*Z=BEM)UPG3oDFe@8roB0h(*XAqRc zoxw`wQD@^nxGFxQXN9@GpkLqd?9@(_ZRS@EFRCO8J5{iuNAQO=!Lo5cCsPtt4=1qZN8z`EA2{ge@SjTyhiJE%ttk{~`SEl%5>s=9E~dUW0uws>&~3PwXJ!f>ShhP~U9dLvE8ElNt3g(6-d zdgtD;rgd^>1URef?*=8BkE&+HmzXD-4w61(p6o~Oxm`XexcHmnR*B~5a|u-Qz$2lf zXc$p91T~E4psJxhf^rdR!b_XmNv*?}!PK9@-asDTaen;p{Rxsa=1E}4kZ*}yQPoT0 zvM}t!CpJvk<`m~^$^1C^o1yM(BzY-Wz2q7C^+wfg-?}1bF?5Hk?S{^#U%wX4&lv0j zkNb)byI+nql(&65xV?_L<0tj!KMHX8Hmh2(udEG>@OPQ}KPtdwEuEb$?acp~yT1&r z|7YU<(v!0as6Xff5^XbKQIR&MpjSE)pmub+ECMZzn7c!|hnm_Rl&H_oXWU2!h7hhf zo&-@cLkZr#eNgUN9>b=QLE1V^b`($EX3RQIyg#45A^=G!jMY`qJ z8qjZ$*-V|?y0=zIM>!2q!Gi*t4J5Otr^OT3XzQ_GjATc(*eM zqllux#QtHhc>YtnswBNiS^t(dTDn|RYSI%i%-|sv1wh&|9jfeyx|IHowW)6uZWR<%n8I}6NidBm zJ>P7#5m`gnXLu;?7jQZ!PwA80d|AS*+mtrU6z+lzms6^vc4)6Zf+$l+Lk3AsEK7`_ zQ9LsS!2o#-pK+V`g#3hC$6*Z~PD%cwtOT8;7K3O=gHdC=WLK-i_DjPO#WN__#YLX|Akw3LnqUJUw8&7pUR;K zqJ98?rKMXE(tnmT`#080w%l1bGno7wXHQbl?QFU=GoK@d!Ov=IgsdHd-iIs4ahcgSj(L@F96=LKZ zeb5cJOVlcKBudawbz~AYk@!^p+E=dT^UhPE`96Q5J~cT-8^tp`J43nLbFD*Nf!w;6 zs>V!5#;?bwYflf0HtFvX_6_jh4GEpa0_s8UUe02@%$w^ym&%wI5_APD?9S4r9O@4m zq^Z5Br8#K)y@z*fo08@XCs;wKBydn+60ks4Z>_+PFD+PVTGNPFPg-V-|``!0l|XrTyUYA@mY?#bJYvD>jX&$o9VAbo?>?#Z^c+Y4Dl zXU9k`s74Sb$OYh7^B|SAVVz*jEW&GWG^cP<_!hW+#Qp|4791Od=HJcesFo?$#0eWD z8!Ib_>H1WQE}shsQiUNk!uWOyAzX>r(-N7;+(O333_ES7*^6z4{`p&O*q8xk{0xy@ zB&9LkW_B}_Y&?pXP-OYNJfqEWUVAPBk)pTP^;f+75Wa(W>^UO_*J05f1k{ zd-}j!4m@q#CaC6mLsQHD1&7{tJ*}LtE{g9LB>sIT7)l^ucm8&+L0=g1E_6#KHfS>A_Z?;pFP96*nX=1&ejZ+XvZ=ML`@oVu>s^WIjn^SY}n zboeP%`O9|dhzvnw%?wAsCw*lvVcv%bmO5M4cas>b%FHd;A6Z%Ej%;jgPuvL$nk=VQ=$-OTwslYg zJQtDS)|qkIs%)K$+r*_NTke8%Rv&w^v;|Ajh5QXaVh}ugccP}3E^(oGC5VO*4`&Q0 z&)z$6i_aKI*CqVBglCxo#9>eOkDD!voCJRFkNolvA2N&SAp^4<8{Y;#Kr5740 za|G`dYGE!9NGU3Ge6C)YByb6Wy#}EN`Ao#R!$LQ&SM#hifEvZp>1PAX{CSLqD4IuO z4#N4AjMj5t2|!yTMrl5r)`_{V6DlqVeTwo|tq4MHLZdZc5;=v9*ibc;IGYh+G|~PB zx2}BAv6p$}?7YpvhqHu7L;~)~Oe^Y)O(G(PJQB<&2AhwMw!(2#AHhjSsBYUd8MDeM z+UXXyV@@cQ`w}mJ2PGs>=jHE{%i44QsPPh(=yorg>jHic+K+S*q3{th6Ik^j=@%xo zXfa9L_<|xTL@UZ?4H`$vt9MOF`|*z&)!mECiuenMW`Eo2VE#|2>2ET7th6+VAmU(o zq$Fz^TUB*@a<}kr6I>r;6`l%8NWtVtkE?}Q<<$BIm*6Z(1EhDtA29O%5d1$0q#C&f zFhFrrss{hOsISjYGDOP*)j&zZUf9`xvR8G)gwxE$HtmKsezo`{Ta~V5u+J&Tg+{bh zhLlNbdzJNF6m$wZNblWNbP6>dTWhngsu=J{);9D|PPJ96aqM4Lc?&6H-J1W15uIpQ ziO{&pEc2}-cqw+)w$`p(k(_yRpmbp-Xcd`*;Y$X=o(v2K+ISW)B1(ZnkV`g4rHQ=s z+J?F9&(||&86pi}snC07Lxi1ja>6kvnut;|Ql3fD)%k+ASe^S|lN69+Ek3UwsSx=2EH)t}K>~ z`Mz-SSVH29@DWyl`ChuGAkG>J;>8ZmLhm>uEmUvLqar~vK3lS;4s<{+ehMsFXM(l- zRt=HT>h9G)JS*&(dbXrM&z;)66C=o{=+^}ciyt8|@e$Y}IREAyd_!2|CqTg=eu}yG z@sI9T;Tjix*%v)c{4G84|0j@8wX^Iig_JsPU|T%(J&KtJ>V zsAR+dcmyT5k&&G{!)VXN`oRS{n;3qd`BgAE9r?%AHy_Gf8>$&X$=>YD7M911?<{qX zkJ;IOfY$nHdy@kKk_+X%g3`T(v|jS;>`pz`?>fqMZ>Fvbx1W=8nvtuve&y`JBfvU~ zr+5pF!`$`TUVsx3^<)48&+XT92U0DS|^X6FwSa-8yviRkZ*@Wu|c*lX!m?8&$0~4T!DB0@)n}ey+ew}T1U>|fH3=W5I!=nfoNs~OkzTY7^x^G&h>M7ewZqmZ=EL0}3#ikWg+(wuoA{7hm|7eJz zNz78l-K81tP16rai+fvXtspOhN-%*RY3IzMX6~8k9oFlXWgICx9dp;`)?Toz`fxV@&m8< z{lzWJG_Y(N1nOox>yG^uDr}kDX_f`lMbtxfP`VD@l$HR*B(sDeE(+T831V-3d3$+% zDKzKnK_W(gLwAK{Saa2}zaV?1QmcuhDu$)#;*4gU(l&rgNXB^WcMuuTki*rt>|M)D zoI;l$FTWIUp}euuZjDidpVw6AS-3dal2TJJaVMGj#CROWr|;^?q>PAo2k^u-27t~v zCv10IL~E)o*|QgdM!GJTaT&|A?oW)m9qk2{=y*7qb@BIAlYgDIe)k(qVH@)#xx6%7 z@)l%aJwz5Joc84Q2jRp71d;=a@NkjSdMyN%L6OevML^(L0_msbef>ewImS=+DgrTk z4ON%Y$mYgcZ^44O*;ctP>_7=}=pslsu>~<-bw=C(jeQ-X`kUo^BS&JDHy%#L32Cj_ zXRzDCfCXKXxGSW9yOGMMOYqPKnU zTF6gDj47!7PoL%z?*{1eyc2IVF*RXX?mj1RS}++hZg_%b@6&PdO)VzvmkXxJ*O7H} z6I7XmJqwX3<>z%M@W|GD%(X|VOZ7A+=@~MxMt8zhDw`yz?V>H%C0&VY+ZZ>9AoDVZeO1c~z$r~!H zA`N_9p`X?z>jm!-leBjW1R13_i2(0&aEY2$l_+-n#powuRO;n2Fr#%jp{+3@`h$c< zcFMr;18Z`UN#spXv+3Ks_V_tSZ1!FY7H(tdAk!v}SkoL9RPYSD3O5w>A3%>7J+C-R zZfDmu=9<1w1CV8rCMEm{qyErCUaA3Q zRYYw_z!W7UDEK)8DF}la9`}8z*?N32-6c-Bwx^Jf#Muwc67sVW24 zJ4nab%>_EM8wPhL=MAN)xx1tozAl zmhXN;*-X%)s>(L=Q@vm$qmuScku>PV(W_x-6E?SFRjSk)A1xVqnml_92fbj0m};UC zcV}lRW-r*wY106|sshV`n#RN{)D9=!>XVH0vMh>od=9!1(U+sWF%#B|eeaKI9RpaW z8Ol_wAJX%j0h5fkvF)WMZ1}?#R(n-OT0CtwsL)|qk;*(!a)5a5ku2nCR9=E*iOZ`9 zy4>LHKt-BgHL@R9CBSG!v4wK zvjF8DORRva)@>nshE~VM@i2c$PKw?3nz(6-iVde;-S~~7R<5r2t$0U8k2_<5C0!$j zQg#lsRYtI#Q1YRs(-%(;F-K7oY~!m&zhuU4LL}>jbLC>B`tk8onRRcmIm{{0cpkD|o@Ixu#x9Wm5J)3oFkbfi62BX8IX1}VTe#{C(d@H|#gy5#Sa#t>sH@8v1h8XFgNGs?)tyF_S^ueJX_-1%+LR`1X@C zS3Oc)o)!8Z9!u9d!35YD^!aXtH;IMNzPp`NS|EcdaQw~<;z`lmkg zE|tQRF7!S!UCsbag%XlQZXmzAOSs= zIUjgY2jcN9`xA6mzG{m|Zw=3kZC4@XY=Bj%k8%D&iadvne$pYNfZI$^2BAB|-MnZW zU4U?*qE3`ZDx-bH})>wz~)a z_SWM!E=-BS#wdrfh;EfPNOS*9!;*+wp-zDthj<>P0a2n?$xfe;YmX~5a;(mNV5nKx zYR86%WtAPsOMIg&*o9uUfD!v&4(mpS6P`bFohPP<&^fZzfA|SvVzPQgbtwwM>IO>Z z75ejU$1_SB1tn!Y-9tajZ~F=Fa~{cnj%Y|$;%z6fJV1XC0080f)Pj|87j142q6`i>#)BCIi+x&jAH9|H#iMvS~?w;&E`y zoarJ)+5HWmZ{&OqlzbdQU=SE3GKmnQq zI{h6f$C@}Mbqf#JDsJyi&7M0O2ORXtEB`#cZ;#AcB zkao0`&|iH8XKvZ_RH|VaK@tAGKMq9x{sdd%p-o`!cJzmd&hb86N!KKxp($2G?#(#BJn5%hF0(^`= z2qRg5?82({w-HyjbffI>eqUXavp&|D8(I6zMOfM}0;h%*D_Dr@+%TaWpIEQX3*$vQ z8_)wkNMDi{rW`L+`yN^J*Gt(l7PExu3_hrntgbW0s}7m~1K=(mFymoU87#{|t*fJ?w8&>Uh zcS$Ny$HNRbT!UCFldTSp2*;%EoW+yhJD8<3FUt8@XSBeJM2dSEz+5}BWmBvdYK(OA zlm`nDDsjKED{$v*jl(&)H7-+*#jWI)W|_X)!em1qpjS_CBbAiyMt;tx*+0P%*m&v< zxV9rlslu8#cS!of#^1O$(ds8aviMFiT`6W+FzMHW{YS+SieJ^?TQb%NT&pasw^kbc znd`=%(bebvrNx3#7vq@vAX-G`4|>cY0svIXopH02{v;GZ{wJM#psz4!m8(IZu<)9D zqR~U7@cz-6H{724_*}-DWwE8Sk+dYBb*O-=c z+wdchFcm6$$^Z0_qGnv0P`)h1=D$_eg8!2-|7Y;o*c)4ax!Me0*EVcioh{wI#!qcb z1&xhOotXMrlo7P6{+C8m;E#4*=8(2y!r0d<6 zKi$d2X;O*zS(&Xiz_?|`ympxITf|&M%^WHp=694g6W@k+BL_T1JtSYX0OZ}o%?Pzu zJ{%P8A$uq?4F!NWGtq>_GLK3*c6dIcGH)??L`9Av&0k$A*14ED9!e9z_SZd3OH6ER zg%5^)3^gw;4DFw(RC;~r`bPJOR}H}?2n60=g4ESUTud$bkBLPyI#4#Ye{5x3@Yw<* z;P5Up>Yn(QdP#momCf=kOzZYzg9E330=67WOPbCMm2-T1%8{=or9L8+HGL{%83lri zODB;Y|LS`@mn#Wmez7t6-x`a2{}U9hE|xY7|BVcFCqoAZQzsEi=dYHB z(bqG3J5?teVSBqTj{aiqe<9}}CEc$HdsJSMp#I;4(EXRy_k|Y8X#5hwkqAaIGKARF zX?$|UO{>3-FU;IlFi80O^t+WMNw4So2nsg}^T1`-Ox&C%Gn_AZ-49Nir=2oYX6 z`uVke@L5PVh)YsvAgFMZfKi{DuSgWnlAaag{RN6t6oLm6{4)H~4xg#Xfcq-e@ALk& z@UP4;uCe(Yjg4jaJZ4pu*+*?4#+XCi%sTrqaT*jNY7|WQ!oR;S8nt)cI27W$Sz!94 z01zoTW`C*P3E?1@6thPe(QpIue$A54gp#C7pmfwRj}GxIw$!!qQetn`nvuwIvMBQ; zfF8K-D~O4aJKmLbNRN1?AZsWY&rp?iy`LP^3KT0UcGNy=Z@7qVM(#5u#Du#w>a&Bs z@f#zU{wk&5n!YF%D11S9*CyaI8%^oX=vq$Ei9cL1&kvv9|8vZD;Mhs1&slm`$A%ED zvz6SQ8aty~`IYp2Xd~G$z%Jf4zwVPKkCtqObrnc2gHKj^jg&-NH|xdNK_;+2d4ZXw zN9j)`jcp7y65&6P@}LsD_OLSi(#GW#hC*qF5KpmeXuQDNS%ZYpuW<;JI<>P6ln!p@ z>KPAM>8^cX|2!n@tV=P)f2Euv?!}UM`^RJ~nTT@W>KC2{{}xXS{}WH{|3najkiEUj z7l;fUWDPCtzQ$?(f)6RvzW~Tqan$bXibe%dv}**BqY!d4J?`1iX`-iy8nPo$s4^mQ z5+@=3xuZAl#KoDF*%>bJ4UrEB2EE8m7sQn!r7Z-ggig`?yy`p~3;&NFukc$`_>?}a z?LMo2LV^n>m!fv^HKKRrDn|2|zk?~S6i|xOHt%K(*TGWkq3{~|9+(G3M-L=;U-YRa zp{kIXZ8P!koE;BN2A;nBx!={yg4v=-xGOMC#~MA07zfR)yZtSF_2W^pDLcXg->*WD zY7Sz5%<_k+lbS^`y)=vX|KaN!gEMQob|(`%nP6huwr$%^?%0^vwr$(CZQD*Jc5?E( zb-q9E`OfoWSJ$rUs$ILfSFg3Mb*-!Ozgaz^%7ZkX@=3km0G;?+e?FQT_l5A9vKr<> z_CoemDo@6YIyl57l*gnJ^7+8xLW5oEGzjLv2P8vj*Q%O1^KOfrsC6eHvk{+$BMLGu z%goP8UY?J7Lj=@jcI$4{m2Sw?1E%_0C7M$lj}w{E#hM4%3QX|;tH6>RJf-TI_1A0w z@KcTEFx(@uitbo?UMMqUaSgt=n`Bu*;$4@cbg9JIS})3#2T;B7S

Z?HZkSa`=MM?n)?|XcM)@e1qmzJ$_4K^?-``~Oi&38`2}sjmP?kK z$yT)K(UU3fJID@~3R;)fU%k%9*4f>oq`y>#t90$(y*sZTzWcW$H=Xv|%^u^?2*n)Csx;35O0v7Nab-REgxDZNf5`cI69k$` zx(&pP6zVxlK5Apn5hAhui}b)(IwZD}D?&)_{_yTL7QgTxL|_X!o@A`)P#!%t9al+# zLD(Rr+?HHJEOl545~m1)cwawqY>cf~9hu-L`crI^5p~-9Mgp9{U5V&dJSwolnl_CM zwAMM1Tl$D@>v?LN2PLe0IZrQL1M zcA%i@Lc)URretFJhtw7IaZXYC6#8slg|*HfUF2Z5{3R_tw)YQ94=dprT`SFAvHB+7 z)-Hd1yE8LB1S+4H7iy$5XruPxq6pc_V)+VO{seA8^`o5{T5s<8bJ`>I3&m%R4cm1S z`hoNk%_=KU2;+#$Y!x7L%|;!Nxbu~TKw?zSP(?H0_b8Qqj4EPrb@~IE`~^#~C%D9k zvJ=ERh`xLgUwvusQbo6S=I5T+?lITYsVyeCCwT9R>DwQa&$e(PxF<}RpLD9Vm2vV# zI#M%ksVNFG1U?;QR{Kx2sf>@y$7sop6SOnBC4sv8S0-`gEt0eHJ{`QSW(_06Uwg*~ zIw}1dZ9c=K$a$N?;j`s3>)AqC$`ld?bOs^^stmYmsWA$XEVhUtGlx&OyziN1~2 z)s5fD(d@gq7htIGX!GCxKT=8aAOHW&DAP=$MpZ)SpeEZhk83}K) z0(Uv)+&pE?|4)D2PX4r6gOGHDY}$8FSg$3eDb*nEVmkFQ#lFpcH~IPeatiH3nPTkP z*xDN7l}r2GM9jwSsl=*!547nRPCS0pb;uE#myTqV+=se>bU=#e)f2}wCp%f-cIrh`FHA$2`monVy?qvJ~o2B6I7IE28bCY4=c#^){*essLG zXUH50W&SWmi{RIG9G^p;PohSPtC}djjXSoC)kyA8`o+L}SjE{i?%;Vh=h;QC{s`T7 zLmmHCr8F}#^O8_~lR)^clv$mMe`e*{MW#Sxd`rDckCnFBo9sC*vw2)dA9Q3lUi*Fy zgDsLt`xt|7G=O6+ms=`_FpD4}37uvelFLc^?snyNUNxbdSj2+Mpv<67NR{(mdtSDNJ3gSD@>gX_7S5 zCD)JP5Hnv!llc-9fwG=4@?=%qu~(4j>YXtgz%gZ#+A9i^H!_R!MxWlFsH(ClP3dU} za&`m(cM0xebj&S170&KLU%39I+XVWOJ_1XpF^ip}3|y()Fn5P@$pP5rvtiEK6w&+w z7uqIxZUj$#qN|<_LFhE@@SAdBy8)xTu>>`xC>VYU@d}E)^sb9k0}YKr=B8-5M?3}d z7&LqQWQ`a&=ihhANxe3^YT>yj&72x#X4NXRTc#+sk;K z=VUp#I(YIRO`g7#;5))p=y=MQ54JWeS(A^$qt>Y#unGRT$0BG=rI(tr>YqSxNm+-x z6n;-y8B>#FnhZX#mhVOT30baJ{47E^j-I6EOp;am;FvTlYRR2_?CjCWY+ypoUD-2S zqnFH6FS+q$H$^7>>(nd^WE+?Zn#@HU3#t|&=JnEDgIU+;CgS+krs+Y8vMo6U zHVkPoReZ-Di3z!xdBu#aW1f{8sC)etjN90`2|Y@{2=Os`(XLL9+ z1$_PE$GgTQrVx`^sx=Y(_y-SvquMF5<`9C=vM52+e+-r=g?D z+E|97MyoaK5M^n1(mnWeBpgtMs8fXOu4Q$89C5q4@YY0H{N47VANA1}M2e zspor6LdndC=kEvxs3YrPGbc;`q}|zeg`f;t3-8na)dGdZ9&d(n{|%mNaHaKJOA~@8 zgP?nkzV-=ULb)L3r`p)vj4<702a5h~Y%byo4)lh?rtu1YXYOY+qyTwzs!59I zL}XLe=q$e<+Wm7tvB$n88#a9LzBkgHhfT<&i#%e*y|}@I z!N~_)vodngB7%CI2pJT*{GX|cI5y>ZBN)}mezK~fFv@$*L`84rb0)V=PvQ2KN}3lTpT@$>a=CP?kcC0S_^PZ#Vd9#CF4 zP&`6{Y!hd^qmL!zr#F~FB0yag-V;qrmW9Jnq~-l>Sg$b%%TpO}{Q+*Pd-@n2suVh_ zSYP->P@# z&gQ^f{?}m(u5B9xqo63pUvDsJDQJi5B~ak+J{tX8$oL!_{Dh zL@=XFzWb+83H3wPbTic+osVp&~UoW3SqK0#P6+BKbOzK65tz)-@AW#g}Ew+pE3@ zVbdJkJ}EM@-Ghxp_4a)|asEk* z5)mMI&EK~BI^aaTMRl)oPJRH^Ld{;1FC&#pS`gh;l3Y;DF*`pR%OSz8U@B@zJxPNX zwyP_&8GsQ7^eYyUO3FEE|9~I~X8;{WTN=DJW0$2OH=3-!KZG=X6TH?>URr(A0l@+d zj^B9G-ACel;yYGZc}G`w9sR$Mo{tzE7&%XKuW$|u7DM<6_z}L>I{o`(=!*1 z{5?1p3F^aBONr6Ws!6@G?XRxJxXt_6b}2%Bp=0Iv5ngnpU^P+?(?O0hKwAK z*|wAisG&8&Td1XY+6qI~-5&+4DE2p|Dj8@do;!40o)F)QuoeUY;*I&QZ0*4?u)$s`VTkNl1WG`}g@J_i zjjmv4L%g&>@U9_|l>8^CN}`@4<D2aMN&?XXD-HNnsVM`irjv$ z^YVNUx3r1{-o6waQfDp=OG^P+vd;qEvd{UUYc;gF0UwaeacXkw32He^qyoYHjZeFS zo(#C9#&NEdFRcFrj7Q{CJgbmDejNS!H%aF6?;|KJQn_*Ps3pkq9yE~G{0wIS*mo0XIEYH zzIiJ>rbmD;sGXt#jlx7AXSGGcjty)5z5lTGp|M#5DCl0q0|~pNQ%1dP!-1>_7^BA~ zwu+uumJmTCcd)r|Hc)uWm7S!+Dw4;E|5+bwPb4i17Ued>NklnnsG+A{T-&}0=sLM- zY;sA9v@YH>b9#c$Vg{j@+>UULBX=jtu~N^%Y#BB5)pB|$?0Mf7msMD<7eACoP1(XY zPO^h5Brvhn$%(0JSo3KFwEPV&dz8(P41o=mo7G~A*P6wLJ@-#|_A z7>k~4&lbqyP1!la!qmhFBfIfT?nIHQ0j2WlohXk^sZ`?8-vwEwV0~uu{RDE^0yfl$ znua{^`VTZ)-h#ch_6^e2{VPaE@o&55|3dx$z_b6gbqduXJ(Lz(zq&ZbJ6qA4Ac4RT zhJO4KBLN!t;h(eW(?cZJw^swf8lP@tWMZ8GD)zg)siA3!2EJYI(j>WI$=pK!mo!Ry z?q&YkTIbTTr<>=}+N8C_EAR0XQL2&O{nNAXb?33iwo8{M``rUHJgnk z8KgZzZLFf|(O6oeugsm<;5m~4N$2Jm5#dph*@TgXC2_k&d%TG0LPY=Fw)=gf(hy9QmY*D6jCAiq44 zo-k2C+?3*+Wu7xm1w*LEAl`Vsq(sYPUMw|MiXrW)92>rVOAse5Pmx^OSi{y%EwPAE zx|csvE{U3c{vA>@;>xcjdCW15pE31F3aoIBsz@OQRvi%_MMfgar2j3Ob`9e@gLQk# zlzznEHgr|Ols%f*a+B-0klD`czi@RWGPPpR1tE@GB|nwe`td1OwG#OjGlTH zfT#^r?%3Ocp^U0F8Kekck6-Vg2gWs|sD_DTJ%2TR<5H3a$}B4ZYpP=p)oAoHxr8I! z1SYJ~v-iP&mNm{ra7!KP^KVpkER>-HFvq*>eG4J#kz1|eu;=~u2|>}TE_5nv2=d!0 z3P~?@blSo^uumuEt{lBsGcx{_IXPO8s01+7DP^yt&>k;<5(NRrF|To2h7hTWBFQ_A z+;?Q$o5L|LlIB>PH(4j)j3`JIb1xA_C@HRFnPnlg{zGO|-RO7Xn}!*2U=Z2V?{5Al z9+iL+n^_T~6Uu{law`R&fFadSVi}da8G>|>D<{(#vi{OU;}1ZnfXy8=etC7)Ae<2S zAlI`&=HkNiHhT0|tQztSLNsRR6v8bmf&$6CI|7b8V4kyJ{=pG#h{1sVeC28&Ho%Fh zwo_FIS}ST-2OF6jNQ$(pjrq)P)@sie#tigN1zSclxJLb-O9V|trp^G8<1rpsj8@+$ z2y27iiM>H8kfd%AMlK|9C>Lkvfs9iSk>k2}tCFlqF~Z_>-uWVQDd$5{3sM%2$du9; z*ukNSo}~@w@DPF)_vS^VaZ)7Mk&8ijX2hNhKom$#PM%bzSA-s$ z0O!broj`!Nuk)Qcp3(>dL|5om#XMx2RUSDMDY9#1|+~fxwP}1I4iYy4j$CGx3jD&eKhf%z`Jn z7mD!y6`nVq%&Q#5yqG`|+e~1$Zkgu!O(~~pWSDTw2^va3u!DOMVRQ8ycq)sk&H%vb z;$a`3gp74~I@swI!ILOkzVK3G&SdTcVe~RzN<+z`u(BY=yuwez{#T3a_83)8>2!X?`^02zVjqx-fN+tW`zCqH^XG>#Ies$qxa!n4*FF0m zxgJlPPYl*q4ylX;DVu3G*I6T&JyWvs`A(*u0+62=+ylt2!u)6LJ=Qe1rA$OWcNCmH zLu7PwMDY#rYQA1!!ONNcz~I^uMvi6N&Lo4dD&HF?1Su5}COTZ-jwR)-zLq=6@bN}X zSP(-MY`TOJ@1O`bLPphMMSWm+YL{Ger>cA$KT~)DuTl+H)!2Lf`c+lZ0ipxd>KfKn zIv;;eEmz(_(nwW24a+>v{K}$)A?=tp+?>zAmfL{}@0r|1>iFQfJ5C*6dKdijK=j16 zQpl4gl93ttF5@d<9e2LoZ~cqkH)aFMgt(el_)#OG4R4Hnqm(@D*Uj>2ZuUCy)o-yy z_J|&S-@o5#2IMcL(}qWF3EL<4n(`cygenA)G%Ssi7k4w)LafelpV5FvS9uJES+(Ml z?rzZ={vYrB#mB-Hd#ID{KS5dKl-|Wh_~v+Lvq3|<@w^MD-RA{q!$gkUUNIvAaex5y z)jIGW{#U=#UWyku7FIAB=TES8>L%Y9*h2N`#Gghie+a?>$CRNth?ORq)!Tde24f5K zKh>cz5oLC;ry*tHIEQEL>8L=zsjG7+(~LUN5K1pT`_Z-4Z}k^m%&H%g3*^e(FDCC{ zBh~eqx%bY?qqu_2qa+9A+oS&yFw^3nLRsN#?FcZvt?*dZhRC_a%Jd{qou(p5AG_Q6 ziOJMu8D~kJ7xEkG(69$Dl3t1J592=Olom%;13uZvYDda08YwzqFlND-;YodmA!SL) z!AOSI=(uCnG#Yo&BgrH(muUemmhQW7?}IHfxI~T`44wuLGFOMdKreQO!a=Z-LkH{T z@h;`A_l2Pp>Xg#`Vo@-?WJn-0((RR4uKM6P2*^-qprHgQhMzSd32@ho>%fFMbp9Y$ zx-#!r8gEu;VZN(fDbP7he+Nu7^o3<+pT!<<>m;m z=FC$N)wx)asxb_KLs}Z^;x*hQM}wQGr((&=%+=#jW^j|Gjn$(qqXwt-o-|>kL!?=T zh0*?m<^>S*F}kPiq@)Cp+^fnKi2)%<-Tw4K3oHwmI-}h}Kc^+%1P!D8aWp!hB@-ZT zybHrRdeYlYulEj>Bk zEIi|PU0eGg&~kWQ{q)gw%~bFT0`Q%k5S|tt!JIZXVXX=>er!7R^w>zeQ%M-(C|eOQG>5i|}i3}X#?aqAg~b1t{-fqwKd(&CyA zmyy)et*E}+q_lEqgbClewiJ=u@bFX}LKe)5o26K9fS;R`!er~a?lUCKf60`4Zq7{2q$L?k?IrAdcDu+ z4A0QJBUiGx&$TBASI2ASM_Wj{?fjv=CORO3GZz;1X*AYY`anM zI`M6C%8OUFSc$tKjiFJ|V74Yj-lK&Epi7F^Gp*rLeDTokfW#o6sl33W^~4V|edbS1 zhx%1PTdnI!C96iYqSA=qu6;p&Dd%)Skjjw0fyl>3k@O?I@x5|>2_7G#_Yc2*1>=^# z|H43bJDx$SS2!vkaMG!;VRGMbY{eJhT%FR{(a+RXDbd4OT?DRoE(`NhiVI6MsUCsT z1gc^~Nv>i;cIm2~_SYOfFpkUvV)(iINXEep;i4>&8@N#|h+_;DgzLqh3I#lzhn>cN zjm;m6U{+JXR2Mi)=~WxM&t9~WShlyA$Pnu+VIW2#;0)4J*C!{1W|y1TP{Q;!tldR< zI7aoH&cMm*apW}~BabBT;`fQ1-9q|!?6nTzmhiIo6fGQlcP{pu)kJh- zUK&Ei9lArSO6ep_SN$Lt_01|Y#@Ksznl@f<+%ku1F|k#Gcwa`(^M<2%M3FAZVb99?Ez4d9O)rqM< zCbYsdZlSo{X#nKqiRA$}XG}1Tw@)D|jGKo1ITqmvE4;ovYH{NAk{h8*Ysh@=nZFiF zmDF`@4do#UDKKM*@wDbwoO@tPx4aExhPF_dvlR&dB5>)W=wG6Pil zq{eBzw%Ov!?D+%8&(uK`m7JV7pqNp-krMd>ECQypq&?p#_3wy){eW{(2q}ij{6bfmyE+-ZO z)G4OtI;ga9;EVyKF6v3kO1RdQV+!*>tV-ditH-=;`n|2T zu(vYR*BJSBsjzFl1Oy#DpL=|pfEY4NM;y5Yly__T*Eg^3Mb_()pHwn)mAsh!7Yz-Z zY`hBLDXS4F^{>x=oOphq|LMo;G!C(b2hS9A6lJqb+e$2af}7C>zW2p{m18@Bdd>iL zoEE$nFUnaz_6p${cMO|;(c1f9nm5G5R;p)m4dcC1?1YD=2Mi&20=4{nu>AV#R^d%A zsmm_RlT#`;g~an9mo#O1dYV)2{mgUWEqb*a@^Ok;ckj;uqy{%*YB^({d{^V)P9VvP zC^qbK&lq~}TWm^RF8d4zbo~bJuw zFV!!}b^4BlJ0>5S3Q>;u*BLC&G6Fa5V|~w&bRZ*-YU>df6%qAvK?%Qf+#=M-+JqLw&w*l4{v7XTstY4j z26z69U#SVzSbY9HBXyD;%P$#vVU7G*Yb-*fy)Qpx?;ed;-P24>-L6U+OAC9Jj63kg zlY`G2+5tg1szc#*9ga3%f9H9~!(^QjECetX-PlacTR+^g8L<#VRovPGvsT)ln3lr= zm5WO@!NDuw+d4MY;K4WJg3B|Sp|WdumpFJO>I2tz$72s4^uXljWseYSAd+vGfjutO z-x~Qlct+BnlI+Iun)fOklxPH?30i&j9R$6g5^f&(x7bIom|FLKq9CUE);w2G>}vye zxWvEaXhx8|~2j)({Rq>0J9}lzdE`yhQ(l$z! z;x%d%_u?^4vlES_>JaIjJBN|N8z5}@l1#PG_@{mh`oWXQOI41_kPG}R_pV+jd^PU) zEor^SHo`VMul*80-K$0mSk|FiI+tHdWt-hzt~S>6!2-!R&rdL_^gGGUzkPe zEZkUKU=EY(5Ex)zeTA4-{Bkbn!Gm?nuaI4jLE%X;zMZ7bwn4FXz(?az;9(Uv;38U6 zi)}rA3xAcD2&6BY<~Pj9Q1~4Dyjs&!$)hyHiiTI@%qXd~+>> zW}$_puSSJ^uWv$jtWakn}}@eX6_LGz|7M#$!3yjY ztS{>HmQ%-8u0@|ig{kzD&CNK~-dIK5e{;@uWOs8$r>J7^c2P~Pwx%QVX0e8~oXK0J zM4HCNK?%t6?v~#;eP#t@tM$@SXRt;(b&kU7uDzlzUuu;+LQ5g%=FqpJPGrX8HJ8CS zITK|(fjhs3@CR}H4@)EjL@J zV_HPexOQ!@k&kvsQG)n;7lZaUh>{87l4NS_=Y-O9Ul3CaKG8iy+xD=QXZSr57a-hb z7jz3Ts-NVsMI783OPEdlE|e&a2;l^h@e>oYMh5@=Lte-9A+20|?!9>Djl~{XkAo>0p9`n&nfWGdGAfT-mSYW z1cvG>GT9dRJdcm7M_AG9JX5AqTCdJ6MRqR3p?+FvMxp(oB-6MZ`lRzSAj%N(1#8@_ zDnIIo9Rtv12(Eo}k_#FILhaZQ`yRD^Vn5tm+IK@hZO>s=t5`@p1#k?Umz2y*R64CF zGM-v&*k}zZ%Xm<_?1=g~<*&3KAy;_^QfccIp~CS7NW24Tn|mSDxb%pvvi}S}(~`2# z3I|kD@||l@lAW06K2%*gHd4x9YKeXWpwU%!ozYcJ+KJeX!s6b94j!Qyy7>S!wb?{qaMa`rpbU1phn0EpF}L zsBdZc|Im#iRiQmJjZwb5#n;`_O{$Zu$I zMXqbfu0yVmt!!Y`Fzl}QV7HUSOPib#da4i@vM$0u2FEYytsvrbR#ui9lrMkZ(AVVJ zMVl^Wi_fSRsEXLA_#rdaG%r(@UCw#o7*yBN)%22b)VSNyng6Lxk|2;XK3Qb=C_<`F zN##8MLHz-s%&O6JE~@P1=iHpj8go@4sC7*AWe99tuf$f7?2~wC&RA^UjB*2`K!%$y zSDzMd7}!vvN|#wDuP%%nuGk8&>N)7eRxtqdMXHD1W%hP7tYW{W>^DJp`3WS>3}i+$ z_li?4AlEj`r=!SPiIc+NNUZ9NCrMv&G0BdQHBO&S7d48aB)LfGi@D%5CC1%)1hVcJ zB~=yNC}LBn(K?cHkPmAX$5^M7JSnNkcc!X!0kD&^F$cJmRP(SJ`9b7}b)o$rj=BZ- zC;BX3IG94%Qz&(V$)7O~v|!=jd-yU1(6wd1u;*$z4DDe6+BFLhz>+8?59?d2Ngxck zm92yR!jk@MP@>>9FtAY2L+Z|MaSp{MnL-;fm}W3~fg!9TRr3;S@ysLf@#<)keHDRO zsJI1tP`g3PNL`2(8hK3!4;r|E-ZQbU0e-9u{(@du`4wjGj|A!QB&9w~?OI1r}M? zw)6tvsknfPfmNijZ;3VZX&HM6=|&W zy6GIe3a?_(pRxdUc==do9?C&v7+6cgIoL4)Ka^bOG9`l;S|QmVzjv%)3^PDi@=-cp z=!R0bU<@_;#*D}e1m@0!%k=VPtyRAkWYW(VFl|eu0LteWH7eDB%P|uF7BQ-|D4`n; z)UpuY1)*s32UwW756>!OoAq#5GAtfrjo*^7YUv^(eiySE?!TQzKxzqXE@jM_bq3Zq zg#1orE*Zd5ZWEpDXW9$=NzuadNSO*NW)ZJ@IDuU`w}j_FRE4-QS*rD4mPVQPH(jGg z+-Ye?3%G%=DT5U1b+TnNHHv(nz-S?3!M4hXtEB@J4WK%%p zkv=Bb`1DHmgUdYo>3kwB(T>Ba#DKv%cLp2h4r8v}p=Np}wL!&PB5J-w4V4REM{kMD z${oSuAw9?*yo3?tNp~X5WF@B^P<6L0HtIW0H7^`R8~9zAXgREH`6H{ntGu$aQ;oNq zig;pB^@KMHNoJcEb0f1fz+!M6sy?hQjof-QoxJgBM`!k^T~cykcmi^s_@1B9 z)t1)Y-ZsV9iA&FDrVoF=L7U#4&inXk{3+Xm9A|R<=ErgxPW~Fq zqu-~x0dIBlR+5_}`IK^*5l3f5$&K@l?J{)_d_*459pvsF*e*#+2guls(cid4!N%DG zl3(2`az#5!^@HNRe3O4(_5nc+){q?ENQG2|uKW0U0$aJ5SQ6hg>G4OyN6os76y%u8qNNHi;}XnRNwpsfn^!6Qt(-4tE`uxaDZ`hQp#aFX373|F?vjEiSEkV>K)cTBG+UL#wDj0_ zM9$H&-86zP=9=5_Q7d3onkqKNr4PAlF<>U^^yYAAEso|Ak~p$3NNZ$~4&kE9Nj^As zQPoo!m*uZ;z1~;#g(?zFECJ$O2@EBy<;F)fnQxOKvH`MojG5T?7thbe%F@JyN^k1K zn3H*%Ymoim)ePf)xhl2%$T)vq3P=4ty%NK)@}po&7Q^~o3l))Zm4<75Y!fFihsXJc z9?vecovF^nYfJVg#W~R3T1*PK{+^YFgb*7}Up2U#)oNyzkfJ#$)PkFxrq_{Ai?0zk zWnjq_ixF~Hs7YS9Y6H&8&k0#2cAj~!Vv4{wCM zi2f1FjQf+F@=BOB)pD|T41a4AEz+8hnH<#_PT#H|Vwm7iQ0-Tw()WMN za0eI-{B2G{sZ7+L+^k@BA)G;mOFWE$O+2nS|DzPSGZ)ede(9%+8kqu4W^wTn!yZPN z7u!Qu0u}K5(0euRZ$7=kn9DZ+llruq5A_l) zOK~wof7_^8Yeh@Qd*=P!gM)lh`Z@7^M?k8Z?t$$vMAuBG>4p56Dt!R$p{)y>QG}it zGG;Ei```7ewXrbGo6Z=!AJNQ!GP8l13m7|FIQTFZTpIg#kpZkl1wj)s1eySXjAAWy zfl;;@{QQ;Qnb$@LY8_Z&7 z6+d98F?z2Zo)sS)z$YoL(zzF>Ey8u#S_%n7)XUX1Pu(>e8gEUU1S;J=EH(#`cWi1+ zoL$5TN+?#NM8=4E7HOk)bf5MXvEo%he5QcB%_5YQ$cu_j)Pd^@5hi}d%nG}x9xXtD-JMQxr;KkC=r_dS-t`lf zF&CS?Lk~>U^!)Y0LZqNVJq+*_#F7W~!UkvZfQhzvW`q;^X&iv~ zEDDGIQ&(S;#Hb(Ej4j+#D#sDS_uHehlY0kZsQpktc?;O z22W1b%wNcdfNza<1M2{*mAkM<{}@(w`VuQ<^lG|iYSuWBD#lYK9+jsdA+&#;Y@=zXLVr840Nq_t5))#7}2s9pK* zg42zd{EY|#sIVMDhg9>t6_Y#O>JoG<{GO&OzTa;iA9&&^6=5MT21f6$7o@nS=w;R) znkgu*7Y{UNPu7B9&B&~q+N@@+%&cO0N`TZ-qQ|@f@e0g2BI+9xO$}NzMOzEbSSJ@v z1uNp(S z-dioXc$5YyA6-My@gW~1GH($Q?;GCHfk{ej-{Q^{iTFs1^Sa67RNd5y{cjX1tG+$& zbGrUte{U1{^Z_qpzW$-V!pJz$dQZrL5i(1MKU`%^= z^)i;xua4w)evDBrFVm)Id5SbXMx2u7M5Df<2L4B`wy4-Y+Wec#b^QJO|J9xF{x#M8 zuLUer`%ZL^m3gy?U&dI+`kgNZ+?bl3H%8)&k84*-=aMfADh&@$xr&IS|4{3$v&K3q zZTn&f{N(#L6<-BZYNs4 zB*Kl*@_IhGXI^_8zfXT^XNmjJ@5E~H*wFf<&er?p7suz85)$-Hqz@C zGMFg1NKs;otNViu)r-u{SOLcqwqc7$poPvm(-^ag1m71}HL#cj5t4Hw(W?*fi4GSH z9962NZ>p^ECPqVc$N}phy>N8rQsWWm%%rc5B4XLATFEtffX&TM2%|8S2Lh_q; zCytXua84HBnSybW-}(j z3Zwv4CaK)jC!{oUvdsFRXK&Sx@t)yGm(h65$!WZ!-jL52no}NX6=E<=H!aZ74h_&> zZ+~c@k!@}Cs84l{u+)%kg4fq~pOeTK3S4)gX~FKJw4t9ba!Ai{_gkKQYQvafZIyKq zX|r4xgC(l%JgmW!tvR&yNt$6uME({M`uNIi7HFiPEQo_UMRkl~12&4c& z^se;dbZWKu7>dLMg`IZq%@b@ME?|@{&xEIZEU(omKNUY? z`JszxNghuO-VA;MrZKEC0|Gi0tz3c#M?aO?WGLy64LkG4T%|PBIt_?bl{C=L@9e;A zia!35TZI7<`R8hr06xF62*rNH5T3N0v^acg+;ENvrLYo|B4!c^eILcn#+lxDZR!%l zjL6!6h9zo)<5GrSPth7+R(rLAW?HF4uu$glo?w1U-y}CR@%v+wSAlsgIXn>e%bc{FE;j@R0AoNIWf#*@BSngZ)HmNqkB z)cs3yN%_PT4f*K+Y1wFl)be=1iq+bb1G-}b|72|gJ|lMt`tf~0Jk}zMbS0+M-Mq}R z>Bv}-W6J%}j#dIz`Z0}zD(DGKn`R;E8A`)$a6qDfr(c@iHKZcCVY_nJEDpcUddGH* z*ct2$&)RelhmV}@jGXY>3Y~vp;b*l9M+hO}&x`e~q*heO8GVkvvJTwyxFetJC8VnhjR`5*+qHEDUNp16g`~$TbdliLLd}AFf}U+Oda1JXwwseRFbj?DN96;VSX~z?JxJSuA^BF}262%Z0)nv<6teKK`F zfm9^HsblS~?Xrb1_~^=5=PD!QH$Y1hD_&qe1HTQnese8N#&C(|Q)CvtAu6{{0Q%ut8ESVdn&& z4y%nsCs!$(#9d{iVjXDR##3UyoMNeY@_W^%qyuZ^K3Oa4(^!tDXOUS?b2P)yRtJ8j zSX}@qGBj+gKf;|6Kb&rq`!}S*cSu-3&S>=pM$eEB{K>PP~I}N|uGE|`3U#{Q6v^kO4nIsaq zfPld}c|4tVPI4!=!ETCNW+LjcbmEoxm0RZ%ieV0`(nVlWKClZW5^>f&h79-~CF(%+ zv|KL(^xQ7$#a}&BSGr9zf{xJ(cCfq>UR*>^-Ou_pmknCt6Y--~!duL{k2D{yLMl__ z!KeMRRg&EsD2s|cmy?xgK&XcGIKeos`&UEVhBTw;mqy|8DlP1M7PYS2z{YmTJ;n!h znPe(Qu?c7+xZz!Tm1AnE8|;&tf7fW$2dArX7ck1Jd(S1+91YB8bjISRZ`UL*?vb{b zMp*!Xq7VaLc0Ogqj5qmop8NREQ{9_iC$;tviZlubGLy1jLlIFBxAymMr@SDLAcx+) z5YRkl$bW**X)W0JzWNcLx9>fTqJj00ipY6Ua?mUlsgQrVVgpmaheE;RgA5U_+WsPh z9+X|PU4zFyNxZ2?Q+V`Mo{xH~(m}OMRZa<&$nCl7o4x`^^|V4?aPz8#KwFm=8T6_} z8=P_4$_rD2a%7}}HT6VQ>ZGKW=QF7zI-2=6oBNZR$HVn|gq`>l$HZ`48lkM7%R$>MS& zghR`WZ9Xrd_6FaDedH6_aKVJhYev*2)UQ>!CRH3PQ_d9nXlO;c z9PeqiKD@aGz^|mvD-tV<{BjfA;)B+76!*+`$CZOJ=#)}>{?!9fAg(Xngbh||n=q*C zU0mGP`NxHn$uY#@)gN<0xr)%Ue80U{-`^FX1~Q@^>WbLraiB|c#4v$5HX)0z!oA#jOXPyWg! z8EC}SBmG7j3T&zCenPLYA{kN(3l62pu}91KOWZl? zg~>T4gQ%1y3AYa^J|>ba$7F5KlVx}_&*~me*q-SYLBCXZFU=U8mHQD4K!?;B61NoX z?VS41SS&jHyhmB~+bC=w0a06V``ZXCkC~}oM9pM{$hU~-s_elYPmT1L!%B`?*<+?( zFQ@TP%y+QL`_&Y0A3679pe5~iL=z)$b)k!oSbJRyw+K};SGAvvE=|<~*aiwJc?uE@2?7a1i9|3=^N%*9smt3ZIhjY>gIsr{Q2rX(NovZ7I1n^V{ z#~(1ze-%`C>fM`^hCV**9BA-04lNuu&3=reevNOMwmX(A{yh`^c8%0mjAKMj{Th05 zXrM(zILwyL-Pcdw^(=gj(ZLVMA95zlzmLa^skb8tQq%8SV&4vp?S>L3+P4^tp`$xA zr38jBw0ItR`VbO5vB1`<3d})}aorkIU1z3*ifYN&Lpp)}|}QJS60th_v-EEkAM zyOREuj!Ou|pVeZEWg;$Hf!x;xAmFu7gB^UR$=L0BuZ~thLC@#moJ(@@wejR|`t_K@ zuQ{XmpAWz%o&~2dk!SIGR$EmpZY)@+r^gvX26%)y>1u2bt~JUPTQzQu&_tB)|{19)&n$m5Fhw0A-8S1^%XpAD%`#a z_ModVxsM|x!m3N1vRt_XEL`O-+J3cMsM1l*dbjT&S0c@}Xxl3I&AeMNT97G3c6%3C zbrZS?2EAKcEq@@Pw?r%eh0YM6z0>&Qe#n+e9hEHK?fzig3v5S#O2IxVLu;a>~c~ZfHVbgLox%_tg)bsC8Rl35P=Jhl+Y=w6zb$ z;*uO%i^U z^mp_QggBILLF$AyjPD41Z0SFdbDj&z&xjq~X|OoM7bCuBfma1CEd!4RKGqPR)K)e}+7^JfFUI_fy63cMyq#&)Z*#w18{S zhC@f9U5k#2S2`d$-)cEoH-eAz{2Qh>YF1Xa)E$rWd52N-@{#lrw3lRqr)z?BGThgO z-Mn>X=RPHQ)#9h{3ciF)<>s{uf_&XdKb&kC!a373l2OCu&y8&n#P%$7YwAVJ_lD-G zX7tgMEV8}dY^mz`R6_0tQ5Eu@CdSOyaI63Vb*mR+rCzxgsjCXLSHOmzt0tA zGoA0Cp&l>rtO@^uQayrkoe#d2@}|?SlQl9W{fmcxY(0*y zHTZ6>FL;$8FEzbb;M(o%mBe-X?o<0+1dH?ZVjcf8)Kyqb07*a zLfP1blbt)=W)TN}4M#dUnt8Gdr4p$QRA<0W)JhWLK3-g82Q~2Drmx4J z;6m4re%igus136VL}MDI-V;WmSfs4guF_(7ifNl#M~Yx5HB!UF)>*-KDQl0U?u4UXV2I*qMhEfsxb%87fi+W;mW5{h?o8!52}VUs*Fpo#aSuXk(Ug z>r>xC#&2<9Uwmao@iJQ|{Vr__?eRT2NB$OcoXQ-jZ{t|?Uy{7q$nU-i|&-R6fHPWJDgHZ69iVbK#Ab@2@y zPD*Gj=hib?PWr8NGf;g$o5I!*n>94Z!IfqRm zLvM>Gx$Y*rEL3Z-+lS42=cnEfXR)h1z`h8a+I%E_ss%qXsrgIV%qv9d|KT>fV5=3e zw>P#ju>2naGc{=6!)9TeHq$S9Pk|>$UCEl}H}lE@;0(jbNT9TXUXyss>al>S4DuGi zVCy;Qt=a2`iu2;TvrIkh2NTvNV}0)qun~9y1yEQMdOf#V#3(e(C?+--8bCsJu={Q1z5qNJIk&yW>ZnVm;A=fL~29lvXQ*4j(SLau?P zi8LC7&**O!6B6=vfY%M;!p2L2tQ+w3Y!am{b?14E`h4kN$1L0XqT5=y=DW8GI_yi% zlIWsjmf0{l#|ei>)>&IM4>jXH)?>!fK?pfWIQn9gT9N(z&w3SvjlD|u*6T@oNQRF6 zU5Uo~SA}ml5f8mvxzX>BGL}c2#AT^6Lo-TM5XluWoqBRin$tiyRQK0wJ!Ro+7S!-K z=S95p-(#IDKOZsRd{l65N(Xae`wOa4Dg9?g|Jx97N-7OfHG(rN#k=yNGW0K$Tia5J zMMX1+!ulc1%8e*FNRV8jL|OSL-_9Nv6O=CH>Ty(W@sm`j=NFa1F3tT$?wM1}GZekB z6F_VLMCSd7(b9T%IqUMo$w9sM5wOA7l8xW<(1w0T=S}MB+9X5UT|+nemtm_;!|bxX z_bnOKN+F30ehJ$459k@=69yTz^_)-hNE4XMv$~_%vlH_y^`P1pLxYF6#_IZyteO`9wpuS> z#%Vyg5mMDt?}j!0}MoBX|9PS0#B zSVo6xLVjujMN57}IVc#A{VB*_yx;#mgM4~yT6wO;Qtm8MV6DX?u(JS~JFA~PvEl%9 z2XI}c>OzPoPn_IoyXa2v}BA(M+sWq=_~L0rZ_yR17I5c^m4;?2&KdCc)3lCs!M|0OzH@(PbG8T6w%N zKzR>%SLxL_C6~r3=xm9VG8<9yLHV6rJOjFHPaNdQHHflp><44l>&;)&7s)4lX%-er znWCv8eJJe1KAi_t1p%c4`bgxD2(1v)jm(gvQLp2K-=04oaIJu{F7SIu8&)gyw7x>+ zbzYF7KXg;T71w!-=C0DjcnF^JP$^o_N>*BAjtH!^HD6t1o?(O7IrmcodeQVDD<*+j zN)JdgB6v^iiJ1q`bZ(^WvN{v@sDqG$M9L`-UV!3q&sWZUnQ{&tAkpX(nZ_L#rMs}>p7l0fU5I5IzArncQi6TWjP#1B=QZ|Uqm-3{)YPn=XFqHW-~Fb z^!0CvIdelQbgcac9;By79%T`uvNhg9tS><pLzXePP=JZzcO@?5GRAdF4)sY*)YGP* zyioMa3=HRQz(v}+cqXc0%2*Q%CQi%e2~$a9r+X*u3J8w^Shg#%4I&?!$})y@ zzg8tQ6_-`|TBa_2v$D;Q(pFutj7@yos0W$&__9$|Yn3DFe*)k{g^|JIV4bqI@2%-4kpb_p? zQ4}qQcA>R6ihbxnVa{c;f7Y)VPV&mRY-*^qm~u3HB>8lf3P&&#GhQk8uIYYgwrugY zei>mp`YdC*R^Cxuv@d0V?$~d*=m-X?1Fqd9@*IM^wQ_^-nQEuc0!OqMr#TeT=8W`JbjjXc-Dh3NhnTj8e82yP;V_B<7LIejij+B{W1ViaJ_)+q?$BaLJpxt_4@&(?rWC3NC-_Z9Sg4JJWc( zX!Y34j67vCMHKB=JcJ1|#UI^D^mn(i=A5rf-iV7y4bR5HhC=I`rFPZv4F>q+h?l34 z4(?KYwZYHwkPG%kK7$A&M#=lpIn3Qo<>s6UFy|J$Zca-s(oM7??dkuKh?f5b2`m57 zJhs4BTcVVmwsswlX?#70uQb*k1Fi3q4+9`V+ikSk{L3K=-5HgN0JekQ=J~549Nd*+H%5+fi6aJuR=K zyD3xW{X$PL7&iR)=wumlTq2gY{LdrngAaPC;Qw_xLfVE0c0Z>y918TQpL!q@?`8{L!el18Qxiki3WZONF=eK$N3)p>36EW)I@Y z7QxbWW_9_7a*`VS&5~4-9!~&g8M+*U9{I2Bz`@TJ@E(YL$l+%<=?FyR#&e&v?Y@@G zqFF`J*v;l$&(A=s`na2>4ExKnxr`|OD+Xd-b4?6xl4mQ94xuk!-$l8*%+1zQU{)!= zTooUhjC0SNBh!&Ne}Q=1%`_r=Vu1c8RuE!|(g4BQGcd5AbpLbvKv_Z~Y`l!mr!sCc zDBupoc{W@U(6KWqW@xV_`;J0~+WDx|t^WeMri#=q0U5ZN7@@FAv<1!hP6!IYX z>UjbhaEv2Fk<6C0M^@J`lH#LgKJ(`?6z5=uH+ImggSQaZtvh52WTK+EBN~-op#EQKYW`$yBmq z4wgLTJPn3;mtbs0m0RO&+EG>?rb*ZECE0#eeSOFL!2YQ$w}cae>sun`<=}m!=go!v zO2jn<0tNh4E-4)ZA(ixh5nIUuXF-qYl>0I_1)K%EAw`D7~la$=gc@6g{iWF=>i_76?Mc zh#l9h7))<|EY=sK!E|54;c!b;Zp}HLd5*-w^6^whxB98v`*P>cj!Nfu1R%@bcp{cb zUZ24(fUXn3d&oc{6H%u(@4&_O?#HO(qd^YH=V`WJ=u*u6Zie8mE^r_Oz zDw`DaXeq4G#m@EK5+p40Xe!Lr!-jTQLCV3?R1|3#`%45h8#WSA!XoLDMS7=t!SluZ4H56;G z6C9D(B6>k^ur_DGfJ@Y-=3$5HkrI zO+3P>R@$6QZ#ATUI3$)xRBEL#5IKs}yhf&fK;ANA#Qj~G zdE|k|`puh$%dyE4R0$7dZd)M*#e7s%*PKPyrS;d%&S(d{_Ktq^!Hpi&bxZx`?9pEw z%sPjo&adHm95F7Z1{RdY#*a!&LcBZVRe{qhn8d{pOUJ{fOu`_kFg7ZVeRYZ(!ezNktT5{Ab z4BZI$vS0$vm3t9q`ECjDK;pmS{8ZTKs`Js~PYv2|=VkDv{Dtt)cLU@9%K6_KqtqfM zaE*e$f$Xm=;IAURNUXw8g%=?jzG2}10ZA5qXzAaJ@eh)yv5B=ETyVwC-a*CD;GgRJ z4J1~zMUey?4iVlS0zW|F-~0nenLiN3S0)l!T2}D%;<}Z9DzeVgcB+MSj;f$KY;uP%UR#f`0u*@6U@tk@jO3N?Fjq< z{cUUhjrr$rmo>qE?52zKe+>6iP5P_tcUfxsLSy{9*)shB(w`UUveNH`a`kr$VEF@} zKh&|lTD;4;m_H6C&)9#D`kRh;S(NTa=Ve^~xe_0~x$6h8Q@B_qu#ee=(lkI9@F6$0m=z@H=4&h%Q{htM>uHs(Sr@2ry`fgLA zKj8lVXdGPyy)2J%A${}Rm_a{){wHnlM?yGPQ7#KO{8*(_l0QZHuV};nO?c%h?qwSL z3wem|w*2tdxW5&PxC(Wd0QG_w|GPbw|0UFK`u$~U%!`QKcME;=Q@?*erh4_>FP~1n zAldwG9h$$u_$RFK6Uxo20GHqJzc}Rl-EwVz3h4n z;3~%DwD84i>)-8#&#y3k)3BG5cNaP3?t4q}F%yfv?*yEiC>sSo}$f>nh0QNZXH1N)-Q7kbk=2uL9OrF)nXrE@F1y%_8Yn c82=K%QXLKFx%@O{wJjEi6Y56o#$)Bpeg diff --git a/client/android/local.properties.template b/client/android/local.properties.template new file mode 100644 index 0000000..4eb7f32 --- /dev/null +++ b/client/android/local.properties.template @@ -0,0 +1,5 @@ +# Template for local properties. Do not commit actual keys. +# Copy this file to local.properties (if not already present) and fill in your keys. +MAPS_API_KEY=YOUR_MAPS_API_KEY +GATEWAY_API_KEY=YOUR_GATEWAY_API_KEY +GATEWAY_URL=http://localhost:10002 diff --git a/client/ios/.gitignore b/client/ios/.gitignore deleted file mode 100644 index a00659e..0000000 --- a/client/ios/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# Xcode user data -A2UI-Example.xcodeproj/project.xcworkspace/xcuserdata/ -A2UI-Example.xcodeproj/xcuserdata/ diff --git a/client/ios/A2UI-ExampleUITests/A2UI_ExampleUITests.swift b/client/ios/A2UI-ExampleUITests/A2UI_ExampleUITests.swift index ba81d92..83fa4c6 100644 --- a/client/ios/A2UI-ExampleUITests/A2UI_ExampleUITests.swift +++ b/client/ios/A2UI-ExampleUITests/A2UI_ExampleUITests.swift @@ -93,7 +93,7 @@ final class A2UIExampleUITests: XCTestCase { expectation(for: exists, evaluatedWith: webView, handler: nil) // Use a longer timeout as agent responses can take time - waitForExpectations(timeout: 30, handler: nil) + waitForExpectations(timeout: 120, handler: nil) XCTAssertTrue(webView.exists, "Web view should exist for test case: \(testCaseName)") diff --git a/client/ios/ChatViewModel.swift b/client/ios/ChatViewModel.swift index 1e4d635..4c1f987 100644 --- a/client/ios/ChatViewModel.swift +++ b/client/ios/ChatViewModel.swift @@ -25,7 +25,7 @@ class ChatViewModel: ObservableObject { @Published var isLoading: Bool = false @Published var webViewToScrollID: UUID? @Published var selectedGroundingType: GroundingType = .lite - private let googleMapsApiKey = "YOUR_API_KEY" + private let googleMapsApiKey = "$GOOGLE_MAPS_API_KEY" init() { A2UIServices.provideApiKey(googleMapsApiKey) diff --git a/client/ios/README.md b/client/ios/README.md index bb83d92..fb50a44 100644 --- a/client/ios/README.md +++ b/client/ios/README.md @@ -66,16 +66,16 @@ Once the app is running: ## Running UI Tests -We provide an automated script to spin up a local backend server and execute the Xcode UI tests using the iOS Simulator. +You can execute the UI tests directly from Xcode: -To run the full test suite from the terminal: -```bash -cd /client/ios -./run_xcode_ui_tests.sh -``` -> **Note:** The test script automatically starts the backend server by looking for `run_aikit_demo.sh` at `../../../a2ui`. If you cloned the `a2ui` repository to a different location, you must open `run_xcode_ui_tests.sh` and update that path before running the tests. +1. Open the project in Xcode. +2. Open `client/ios/A2UI-ExampleUITests/A2UI_ExampleUITests.swift`. +3. Run the tests by pressing `Cmd + U` or clicking the play button next to the test class (`A2UIExampleUITests`) or individual test methods. -**Note:** This script requires the `iPhone 17 Pro` simulator to be installed on your system. It automatically launches the backend server and safely shuts it down once the UI tests finish. Additionally, ensure the `GoogleMapsA2UI` Swift Package has been successfully compiled in Xcode before running this script. +> **Note:** The UI tests rely on a backend server to process the test prompts, and agent responses can sometimes take more than 30 seconds. If your tests are failing due to a timeout, you can increase the timeout value on line 96 of `A2UI_ExampleUITests.swift`: +> ``` +> waitForExpectations(timeout: 30, handler: nil) // Increase this value if the server is slow +> ``` ## Customizing the Web Components diff --git a/client/ios/run_xcode_ui_tests.sh b/client/ios/run_xcode_ui_tests.sh deleted file mode 100755 index 1ef3ea8..0000000 --- a/client/ios/run_xcode_ui_tests.sh +++ /dev/null @@ -1,27 +0,0 @@ -#!/bin/bash -set -e - -echo "Starting A2UI backend..." -# Assuming we are in this directory when running this script, -# we need to go up three levels to find the root 'a2ui'. -(cd ../../../a2ui && ./run_aikit_demo.sh --local) & -BACKEND_PID=$! - -# Ensure backend is killed when the script exits. -trap "kill $BACKEND_PID" EXIT - -echo "Waiting for backend to start (15s)..." -sleep 15 - -# 2. Run the iOS UI tests -echo "Running iOS UI tests on iPhone 17 Pro..." - -# We use xcodebuild to run the tests. -# https://developer.apple.com/library/archive/technotes/tn2339/_index.html -xcodebuild \ - -project A2UI-Example.xcodeproj \ - -scheme A2UI-Example \ - -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ - test - -echo "UI Tests completed successfully!" diff --git a/client/web/react/README.md b/client/web/react/README.md index 0953666..8dd323a 100644 --- a/client/web/react/README.md +++ b/client/web/react/README.md @@ -13,4 +13,4 @@ npm run dev 2. Open [http://localhost:5173](http://localhost:5173) in your browser. You should see a chat interface. -To run the backend, follow the instructions in [../../../agent/python/README.md](../../../agent/python/README.md) \ No newline at end of file +To run the backend, follow the instructions in [../../../agent/python/README.md](../../../agent/python/README.md) diff --git a/client/web/react/eslint.config.js b/client/web/react/eslint.config.js index e287295..5e6b472 100644 --- a/client/web/react/eslint.config.js +++ b/client/web/react/eslint.config.js @@ -20,4 +20,4 @@ export default defineConfig([ globals: globals.browser, }, }, -]) \ No newline at end of file +]) diff --git a/client/web/react/index.html b/client/web/react/index.html index 07e9b73..98550f7 100644 --- a/client/web/react/index.html +++ b/client/web/react/index.html @@ -13,4 +13,4 @@

- \ No newline at end of file + diff --git a/client/web/react/src/App.css b/client/web/react/src/App.css index 6d5aeed..dd16a5c 100644 --- a/client/web/react/src/App.css +++ b/client/web/react/src/App.css @@ -22,7 +22,7 @@ border-left: 1px solid var(--border); display: flex; flex-direction: column; - transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), margin-right 0.3s cubic-bezier(0.4, 0, 0.2, 1); box-shadow: -10px 0 15px -10px rgba(0, 0, 0, 0.1); z-index: 10; @@ -217,4 +217,4 @@ background: var(--social-bg); color: var(--text-h); } -} \ No newline at end of file +} diff --git a/client/web/react/src/App.tsx b/client/web/react/src/App.tsx index 3db6c2d..694306e 100644 --- a/client/web/react/src/App.tsx +++ b/client/web/react/src/App.tsx @@ -1,6 +1,11 @@ -import { useState, useRef, useEffect } from 'react' -import './App.css' -import { A2UIClient, A2UIRenderer, type TimelineItem, themeStyleSheet } from '@googlemaps/a2ui/lit'; +import { + A2UIClient, + A2UIRenderer, + type TimelineItem, + themeStyleSheet, +} from '@googlemaps/a2ui/lit'; +import {useEffect, useRef, useState} from 'react'; +import './App.css'; /** * Main Application component that demonstrates A2UI integration in a React environment. @@ -8,32 +13,35 @@ import { A2UIClient, A2UIRenderer, type TimelineItem, themeStyleSheet } from '@g */ function App() { // --- UI State --- - const [isChatOpen, setIsChatOpen] = useState(true) - const [timeline, setTimeline] = useState([]) - const [input, setInput] = useState('') - const [isRequesting, setIsRequesting] = useState(false) - const [importJson, setImportJson] = useState('') - const importDialogRef = useRef(null) - const [lastResponseJson, setLastResponseJson] = useState('') + const [isChatOpen, setIsChatOpen] = useState(true); + const [timeline, setTimeline] = useState([]); + const [input, setInput] = useState(''); + const [isRequesting, setIsRequesting] = useState(false); + const [importJson, setImportJson] = useState(''); + const importDialogRef = useRef(null); + const [lastResponseJson, setLastResponseJson] = useState(''); // --- A2UI Integration Refs --- // A2UIClient handles communication with the A2A agent - const clientRef = useRef(new A2UIClient()) + const clientRef = useRef(new A2UIClient()); // A2UIRenderer manages the local state of A2UI surfaces and message processing - const rendererRef = useRef(new A2UIRenderer()) + const rendererRef = useRef(new A2UIRenderer()); // Handle scrolling properly. - const messagesEndRef = useRef(null) + const messagesEndRef = useRef(null); const scrollToBottom = () => { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }) - } + messagesEndRef.current?.scrollIntoView({behavior: 'smooth'}); + }; useEffect(() => { - scrollToBottom() - }, [timeline]) + scrollToBottom(); + }, [timeline]); useEffect(() => { if (!document.adoptedStyleSheets.includes(themeStyleSheet)) { - document.adoptedStyleSheets = [...document.adoptedStyleSheets, themeStyleSheet]; + document.adoptedStyleSheets = [ + ...document.adoptedStyleSheets, + themeStyleSheet, + ]; } }, []); @@ -41,12 +49,14 @@ function App() { try { const messages = JSON.parse(importJson); rendererRef.current = new A2UIRenderer(); - rendererRef.current.processResponse(messages.map((msg: any) => ({ type: "a2ui", message: msg }))); + rendererRef.current.processResponse( + messages.map((msg: any) => ({type: 'a2ui', message: msg})), + ); setTimeline([...rendererRef.current.timeline]); importDialogRef.current?.close(); setImportJson(''); } catch (e) { - alert("Failed to parse JSON: " + e); + alert('Failed to parse JSON: ' + e); } }; @@ -55,42 +65,46 @@ function App() { * Updates the UI timeline and processes the agent's response. */ const handleSend = async () => { - if (!input.trim() || isRequesting) return + if (!input.trim() || isRequesting) return; - const messageText = input.trim() - setInput('') - setIsRequesting(true) + const messageText = input.trim(); + setInput(''); + setIsRequesting(true); // 1. Add the user's message to the local renderer's timeline - rendererRef.current.addUserMessage(messageText) - setTimeline([...rendererRef.current.timeline]) + rendererRef.current.addUserMessage(messageText); + setTimeline([...rendererRef.current.timeline]); try { // 2. Send the message to the A2A agent via A2UIClient - const response = await clientRef.current.send(messageText) + const response = await clientRef.current.send(messageText); // 3. Process the response (which may contain text and/or A2UI data) - rendererRef.current.processResponse(response) + rendererRef.current.processResponse(response); // Update last response JSON - const uiMessages = response.filter((p: any) => p.type === "a2ui").map((p: any) => p.message); + const uiMessages = response + .filter((p: any) => p.type === 'a2ui') + .map((p: any) => p.message); if (uiMessages.length > 0) { setLastResponseJson(JSON.stringify(uiMessages, null, 2)); } // 4. Synchronize the React state with the renderer's updated timeline - setTimeline([...rendererRef.current.timeline]) + setTimeline([...rendererRef.current.timeline]); } catch (error) { - console.error("Failed to send message:", error) - rendererRef.current.processResponse([{ - type: 'text', - text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}` - }]) - setTimeline([...rendererRef.current.timeline]) + console.error('Failed to send message:', error); + rendererRef.current.processResponse([ + { + type: 'text', + text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`, + }, + ]); + setTimeline([...rendererRef.current.timeline]); } finally { - setIsRequesting(false) + setIsRequesting(false); } - } + }; return (
@@ -99,8 +113,7 @@ function App() { {!isChatOpen && ( )} @@ -116,8 +129,7 @@ function App() {
@@ -126,7 +138,7 @@ function App() {
{timeline.length === 0 && ( -

+

No messages yet.

)} @@ -136,24 +148,24 @@ function App() {
{item.text}
- ) + ); } else if (item.type === 'action') { return (
A2UI Action: {item.action}
{item.text}
- ) + ); } else if (item.type === 'text') { return (
{item.text}
- ) + ); } else if (item.type === 'surface') { // Render an A2UI Surface containing multiple UI components - const surface = rendererRef.current.getSurface(item.surfaceId) - if (!surface) return null + const surface = rendererRef.current.getSurface(item.surfaceId); + if (!surface) return null; return (
{/* @ts-ignore */} @@ -161,9 +173,9 @@ function App() { surface={surface} >
- ) + ); } - return null + return null; })} {isRequesting &&
Thinking...
}
@@ -179,63 +191,109 @@ function App() { onChange={(e) => setInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault() - handleSend() + e.preventDefault(); + handleSend(); } }} - disabled={isRequesting} - > + disabled={isRequesting}>
{lastResponseJson && } -
- { if (e.target === importDialogRef.current) importDialogRef.current.close(); }} style={{ border: 'none', borderRadius: '16px', padding: '0', width: '90%', maxWidth: '600px' }}> -
-

Import A2UI JSON

- -
- - +
- ) + ); } -function ResponseViewer({ json }: { json: string }) { +function ResponseViewer({json}: {json: string}) { const [copied, setCopied] = useState(false); const dialogRef = useRef(null); @@ -247,36 +305,83 @@ function ResponseViewer({ json }: { json: string }) { return ( <> - - - { if (e.target === dialogRef.current) dialogRef.current.close(); }} style={{ border: 'none', borderRadius: '16px', padding: '0', width: '90%', maxWidth: '600px' }}> -
-

Last A2UI Response

-
-            {json || "No response yet."}
+
+       {
+          if (e.target === dialogRef.current) dialogRef.current.close();
+        }}
+        style={{
+          border: 'none',
+          borderRadius: '16px',
+          padding: '0',
+          width: '90%',
+          maxWidth: '600px',
+        }}>
+        
+

+ Last A2UI Response +

+
+            {json || 'No response yet.'}
           
-
- - +
@@ -285,4 +390,4 @@ function ResponseViewer({ json }: { json: string }) { ); } -export default App \ No newline at end of file +export default App; diff --git a/client/web/react/src/custom-elements.d.ts b/client/web/react/src/custom-elements.d.ts index 5bd8411..aa5e7cb 100644 --- a/client/web/react/src/custom-elements.d.ts +++ b/client/web/react/src/custom-elements.d.ts @@ -8,4 +8,4 @@ declare module 'react' { 'maui-providers': any; } } -} \ No newline at end of file +} diff --git a/client/web/react/src/index.css b/client/web/react/src/index.css index e96dd14..1b10a75 100644 --- a/client/web/react/src/index.css +++ b/client/web/react/src/index.css @@ -107,4 +107,4 @@ code { line-height: 135%; padding: 4px 8px; background: var(--code-bg); -} \ No newline at end of file +} diff --git a/client/web/react/tsconfig.app.json b/client/web/react/tsconfig.app.json index 15c9181..af516fc 100644 --- a/client/web/react/tsconfig.app.json +++ b/client/web/react/tsconfig.app.json @@ -25,4 +25,4 @@ "noUncheckedSideEffectImports": true }, "include": ["src"] -} \ No newline at end of file +} diff --git a/client/web/react/tsconfig.json b/client/web/react/tsconfig.json index c452f43..1ffef60 100644 --- a/client/web/react/tsconfig.json +++ b/client/web/react/tsconfig.json @@ -4,4 +4,4 @@ { "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" } ] -} \ No newline at end of file +} diff --git a/client/web/react/tsconfig.node.json b/client/web/react/tsconfig.node.json index 6c49e9a..8a67f62 100644 --- a/client/web/react/tsconfig.node.json +++ b/client/web/react/tsconfig.node.json @@ -23,4 +23,4 @@ "noUncheckedSideEffectImports": true }, "include": ["vite.config.ts"] -} \ No newline at end of file +}