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 8bdaf60..0000000 Binary files a/client/android/gradle/wrapper/gradle-wrapper.jar and /dev/null differ 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 +}