chore: import upstream snapshot with attribution
@@ -0,0 +1,7 @@
|
||||
OPENAI_API_KEY=your-api-key-here
|
||||
|
||||
# --- copilotkit:intelligence (optional local threads stack) ---
|
||||
# COPILOTKIT_LICENSE_TOKEN=
|
||||
# INTELLIGENCE_API_KEY=
|
||||
# INTELLIGENCE_API_URL=http://localhost:4201
|
||||
# INTELLIGENCE_GATEWAY_WS_URL=ws://localhost:4401
|
||||
@@ -0,0 +1,143 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
.output
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Sveltekit cache directory
|
||||
.svelte-kit/
|
||||
|
||||
# vitepress build output
|
||||
**/.vitepress/dist
|
||||
|
||||
# vitepress cache directory
|
||||
**/.vitepress/cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# Firebase cache directory
|
||||
.firebase/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v3
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
|
||||
# Vite files
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
.vite/
|
||||
|
||||
next-env.d.ts
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d46cf4769d80326d37a138944110aca23179451101e4874f8dac1392feaabf02
|
||||
size 29036612
|
||||
@@ -0,0 +1,121 @@
|
||||
# CopilotKit <> A2A + A2UI Starter
|
||||
|
||||
This is a starter template for building AI agents that use [A2UI](https://a2ui.org) and [CopilotKit](https://copilotkit.ai). It provides a modern Next.js application with an integrated restaurant finder agent that can find restaurants and book reservations
|
||||
|
||||

|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Gemeni API Key (for the ADK/A2A agent)
|
||||
- Python 3.12+
|
||||
- uv
|
||||
- Node.js 20+
|
||||
- Any of the following package managers:
|
||||
- npm (default)
|
||||
- [pnpm](https://pnpm.io/installation)
|
||||
- [yarn](https://classic.yarnpkg.com/lang/en/docs/install/)
|
||||
- [bun](https://bun.sh/)
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Install dependencies using your preferred package manager:
|
||||
|
||||
```bash
|
||||
# Using npm (default)
|
||||
npm install
|
||||
|
||||
# Using pnpm
|
||||
pnpm install
|
||||
|
||||
# Using yarn
|
||||
yarn install
|
||||
|
||||
# Using bun
|
||||
bun install
|
||||
```
|
||||
|
||||
> **Note:** This will automatically setup the Python environment as well.
|
||||
>
|
||||
> If you have manual issues, you can run:
|
||||
>
|
||||
> ```sh
|
||||
> npm run install:agent
|
||||
> ```
|
||||
|
||||
3. Set up your Gemeni API key:
|
||||
|
||||
Create a `.env` file inside the `agent` folder with the following content:
|
||||
|
||||
```
|
||||
GEMENI_API_KEY=sk-...your-openai-key-here...
|
||||
```
|
||||
|
||||
4. Start the development server:
|
||||
|
||||
```bash
|
||||
# Using npm (default)
|
||||
npm run dev
|
||||
|
||||
# Using pnpm
|
||||
pnpm dev
|
||||
|
||||
# Using yarn
|
||||
yarn dev
|
||||
|
||||
# Using bun
|
||||
bun run dev
|
||||
```
|
||||
|
||||
This will start both the UI and agent servers concurrently.
|
||||
|
||||
## Available Scripts
|
||||
|
||||
The following scripts can also be run using your preferred package manager:
|
||||
|
||||
- `dev` - Starts both UI and agent servers in development mode
|
||||
- `dev:debug` - Starts development servers with debug logging enabled
|
||||
- `dev:ui` - Starts only the Next.js UI server
|
||||
- `dev:agent` - Starts only the A2A agent server
|
||||
- `build` - Builds the Next.js application for production
|
||||
- `start` - Starts the production server
|
||||
- `install:agent` - Installs Python dependencies for the agent
|
||||
|
||||
## Documentation
|
||||
|
||||
The main UI component is in `app/page.tsx`, but most of the UI comes from from the agent in the form of A2UI declarative components. To see and edit the components it can generate, look in `agent/prompt_builder.py`.
|
||||
To generate new components, try the [A2UI Composer](https://a2ui-editor.ag-ui.com)
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
- [A2UI + CopilotKit Documentation](https://docs.copilotkit.ai/a2a) - Learn more about how to use A2UI with CopilotKit
|
||||
- [A2UI Documentation](https://a2ui.org) - Learn more about A2UI and its capabilities
|
||||
- [CopilotKit Documentation](https://docs.copilotkit.ai) - Explore CopilotKit's capabilities
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - Learn about Next.js features and API
|
||||
|
||||
## Contributing
|
||||
|
||||
Feel free to submit issues and enhancement requests! This starter is designed to be easily extensible.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License - see the LICENSE file for details.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Agent Connection Issues
|
||||
|
||||
If you see "I'm having trouble connecting to my tools", make sure:
|
||||
|
||||
1. The ADK agent is running on port 10002
|
||||
2. Your Gemini API key is set correctly
|
||||
3. Both servers started successfully
|
||||
|
||||
### Python Dependencies
|
||||
|
||||
If you encounter Python import errors:
|
||||
|
||||
```bash
|
||||
cd agent
|
||||
uv sync
|
||||
uv run .
|
||||
```
|
||||
@@ -0,0 +1,216 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[codz]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py.cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
# Pipfile.lock
|
||||
|
||||
# UV
|
||||
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# uv.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
# poetry.lock
|
||||
# poetry.toml
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
||||
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
||||
# pdm.lock
|
||||
# pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# pixi
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
||||
# pixi.lock
|
||||
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
||||
# in the .venv directory. It is recommended not to include this directory in version control.
|
||||
.pixi
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# Redis
|
||||
*.rdb
|
||||
*.aof
|
||||
*.pid
|
||||
|
||||
# RabbitMQ
|
||||
mnesia/
|
||||
rabbitmq/
|
||||
rabbitmq-data/
|
||||
|
||||
# ActiveMQ
|
||||
activemq-data/
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.envrc
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
# .idea/
|
||||
|
||||
# Abstra
|
||||
# Abstra is an AI-powered process automation framework.
|
||||
# Ignore directories containing user credentials, local state, and settings.
|
||||
# Learn more at https://abstra.io/docs
|
||||
.abstra/
|
||||
|
||||
# Visual Studio Code
|
||||
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
||||
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
||||
# you could uncomment the following to ignore the entire vscode folder
|
||||
# .vscode/
|
||||
|
||||
# Ruff stuff:
|
||||
.ruff_cache/
|
||||
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
# Marimo
|
||||
marimo/_static/
|
||||
marimo/_lsp/
|
||||
__marimo__/
|
||||
|
||||
# Streamlit
|
||||
.streamlit/secrets.toml
|
||||
@@ -0,0 +1,11 @@
|
||||
# A2UI Extension Implementation
|
||||
|
||||
This is the Python implementation of the a2ui extension.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
Important: The sample code provided is for demonstration purposes and illustrates the mechanics of the Agent-to-Agent (A2A) protocol. When building production applications, it is critical to treat any agent operating outside of your direct control as a potentially untrusted entity.
|
||||
|
||||
All data received from an external agent—including but not limited to its AgentCard, messages, artifacts, and task statuses—should be handled as untrusted input. For example, a malicious agent could provide an AgentCard containing crafted data in its fields (e.g., description, name, skills.description). If this data is used without sanitization to construct prompts for a Large Language Model (LLM), it could expose your application to prompt injection attacks. Failure to properly validate and sanitize this data before use can introduce security vulnerabilities into your application.
|
||||
|
||||
Developers are responsible for implementing appropriate security measures, such as input validation and secure handling of credentials to protect their systems and users.
|
||||
@@ -0,0 +1,11 @@
|
||||
[project]
|
||||
name = "a2ui"
|
||||
version = "0.1.0"
|
||||
description = "A2UI Extension"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = ["a2a-sdk>=0.3.0"]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
@@ -0,0 +1,13 @@
|
||||
# Copyright 2025 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
@@ -0,0 +1,118 @@
|
||||
# Copyright 2025 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from a2a.server.agent_execution import RequestContext
|
||||
from a2a.types import AgentExtension, Part, DataPart
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
A2UI_EXTENSION_URI = "https://a2ui.org/a2a-extension/a2ui/v0.8"
|
||||
|
||||
MIME_TYPE_KEY = "mimeType"
|
||||
A2UI_MIME_TYPE = "application/json+a2ui"
|
||||
|
||||
A2UI_CLIENT_CAPABILITIES_KEY = "a2uiClientCapabilities"
|
||||
SUPPORTED_CATALOG_IDS_KEY = "supportedCatalogIds"
|
||||
INLINE_CATALOGS_KEY = "inlineCatalogs"
|
||||
|
||||
STANDARD_CATALOG_ID = "https://raw.githubusercontent.com/google/A2UI/refs/heads/main/specification/0.8/json/standard_catalog_definition.json"
|
||||
|
||||
|
||||
def create_a2ui_part(a2ui_data: dict[str, Any]) -> Part:
|
||||
"""Creates an A2A Part containing A2UI data.
|
||||
|
||||
Args:
|
||||
a2ui_data: The A2UI data dictionary.
|
||||
|
||||
Returns:
|
||||
An A2A Part with a DataPart containing the A2UI data.
|
||||
"""
|
||||
return Part(
|
||||
root=DataPart(
|
||||
data=a2ui_data,
|
||||
metadata={
|
||||
MIME_TYPE_KEY: A2UI_MIME_TYPE,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def is_a2ui_part(part: Part) -> bool:
|
||||
"""Checks if an A2A Part contains A2UI data.
|
||||
|
||||
Args:
|
||||
part: The A2A Part to check.
|
||||
|
||||
Returns:
|
||||
True if the part contains A2UI data, False otherwise.
|
||||
"""
|
||||
return (
|
||||
isinstance(part.root, DataPart)
|
||||
and part.root.metadata
|
||||
and part.root.metadata.get(MIME_TYPE_KEY) == A2UI_MIME_TYPE
|
||||
)
|
||||
|
||||
|
||||
def get_a2ui_datapart(part: Part) -> Optional[DataPart]:
|
||||
"""Extracts the DataPart containing A2UI data from an A2A Part, if present.
|
||||
|
||||
Args:
|
||||
part: The A2A Part to extract A2UI data from.
|
||||
|
||||
Returns:
|
||||
The DataPart containing A2UI data if present, None otherwise.
|
||||
"""
|
||||
if is_a2ui_part(part):
|
||||
return part.root
|
||||
return None
|
||||
|
||||
|
||||
def get_a2ui_agent_extension(
|
||||
accepts_inline_custom_catalog: bool = False,
|
||||
) -> AgentExtension:
|
||||
"""Creates the A2UI AgentExtension configuration.
|
||||
|
||||
Args:
|
||||
accepts_inline_custom_catalog: Whether the agent accepts inline custom catalogs.
|
||||
|
||||
Returns:
|
||||
The configured A2UI AgentExtension.
|
||||
"""
|
||||
params = {}
|
||||
if accepts_inline_custom_catalog:
|
||||
params["acceptsInlineCustomCatalog"] = True # Only set if not default of False
|
||||
|
||||
return AgentExtension(
|
||||
uri=A2UI_EXTENSION_URI,
|
||||
description="Provides agent driven UI using the A2UI JSON format.",
|
||||
params=params if params else None,
|
||||
)
|
||||
|
||||
|
||||
def try_activate_a2ui_extension(context: RequestContext) -> bool:
|
||||
"""Activates the A2UI extension if requested.
|
||||
|
||||
Args:
|
||||
context: The request context to check.
|
||||
|
||||
Returns:
|
||||
True if activated, False otherwise.
|
||||
"""
|
||||
if A2UI_EXTENSION_URI in context.requested_extensions:
|
||||
context.add_activated_extension(A2UI_EXTENSION_URI)
|
||||
return True
|
||||
return False
|
||||
@@ -0,0 +1,13 @@
|
||||
# Copyright 2025 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
@@ -0,0 +1,91 @@
|
||||
# Copyright 2025 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
|
||||
from a2a.server.agent_execution import RequestContext
|
||||
from a2a.types import DataPart, TextPart, Part
|
||||
from a2ui import a2ui_extension
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
def test_a2ui_part_serialization():
|
||||
a2ui_data = {"beginRendering": {"surfaceId": "test-surface", "root": "root-column"}}
|
||||
|
||||
part = a2ui_extension.create_a2ui_part(a2ui_data)
|
||||
|
||||
assert a2ui_extension.is_a2ui_part(part), "Should be identified as A2UI part"
|
||||
|
||||
data_part = a2ui_extension.get_a2ui_datapart(part)
|
||||
assert data_part is not None, "Should contain DataPart"
|
||||
assert a2ui_data == data_part.data, "Deserialized data should match original"
|
||||
|
||||
|
||||
def test_non_a2ui_data_part():
|
||||
part = Part(
|
||||
root=DataPart(
|
||||
data={"foo": "bar"},
|
||||
metadata={"mimeType": "application/json"}, # Not A2UI
|
||||
)
|
||||
)
|
||||
assert not a2ui_extension.is_a2ui_part(part), (
|
||||
"Should not be identified as A2UI part"
|
||||
)
|
||||
assert a2ui_extension.get_a2ui_datapart(part) is None, (
|
||||
"Should not return A2UI DataPart"
|
||||
)
|
||||
|
||||
|
||||
def test_non_a2ui_part():
|
||||
text_part = TextPart(text="this is some text")
|
||||
part = Part(root=text_part)
|
||||
|
||||
assert not a2ui_extension.is_a2ui_part(part), (
|
||||
"Should not be identified as A2UI part"
|
||||
)
|
||||
assert a2ui_extension.get_a2ui_datapart(part) is None, (
|
||||
"Should not return A2UI DataPart"
|
||||
)
|
||||
|
||||
|
||||
def test_get_a2ui_agent_extension():
|
||||
agent_extension = a2ui_extension.get_a2ui_agent_extension()
|
||||
assert agent_extension.uri == a2ui_extension.A2UI_EXTENSION_URI
|
||||
assert agent_extension.params is None
|
||||
|
||||
|
||||
def test_get_a2ui_agent_extension_with_inline_custom_catalog():
|
||||
agent_extension = a2ui_extension.get_a2ui_agent_extension(
|
||||
accepts_inline_custom_catalog=True
|
||||
)
|
||||
assert agent_extension.uri == a2ui_extension.A2UI_EXTENSION_URI
|
||||
assert agent_extension.params is not None
|
||||
|
||||
|
||||
def test_try_activate_a2ui_extension():
|
||||
context = MagicMock(spec=RequestContext)
|
||||
context.requested_extensions = [a2ui_extension.A2UI_EXTENSION_URI]
|
||||
|
||||
assert a2ui_extension.try_activate_a2ui_extension(context)
|
||||
context.add_activated_extension.assert_called_once_with(
|
||||
a2ui_extension.A2UI_EXTENSION_URI
|
||||
)
|
||||
|
||||
|
||||
def test_try_activate_a2ui_extension_not_requested():
|
||||
context = MagicMock(spec=RequestContext)
|
||||
context.requested_extensions = []
|
||||
|
||||
assert not a2ui_extension.try_activate_a2ui_extension(context)
|
||||
context.add_activated_extension.assert_not_called()
|
||||
@@ -0,0 +1,216 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[codz]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py.cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
# Pipfile.lock
|
||||
|
||||
# UV
|
||||
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# uv.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
# poetry.lock
|
||||
# poetry.toml
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
||||
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
||||
# pdm.lock
|
||||
# pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# pixi
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
||||
# pixi.lock
|
||||
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
||||
# in the .venv directory. It is recommended not to include this directory in version control.
|
||||
.pixi
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# Redis
|
||||
*.rdb
|
||||
*.aof
|
||||
*.pid
|
||||
|
||||
# RabbitMQ
|
||||
mnesia/
|
||||
rabbitmq/
|
||||
rabbitmq-data/
|
||||
|
||||
# ActiveMQ
|
||||
activemq-data/
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.envrc
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
# .idea/
|
||||
|
||||
# Abstra
|
||||
# Abstra is an AI-powered process automation framework.
|
||||
# Ignore directories containing user credentials, local state, and settings.
|
||||
# Learn more at https://abstra.io/docs
|
||||
.abstra/
|
||||
|
||||
# Visual Studio Code
|
||||
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
||||
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
||||
# you could uncomment the following to ignore the entire vscode folder
|
||||
# .vscode/
|
||||
|
||||
# Ruff stuff:
|
||||
.ruff_cache/
|
||||
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
# Marimo
|
||||
marimo/_static/
|
||||
marimo/_lsp/
|
||||
__marimo__/
|
||||
|
||||
# Streamlit
|
||||
.streamlit/secrets.toml
|
||||
@@ -0,0 +1,37 @@
|
||||
# A2UI Restaurant finder and table reservation agent sample.
|
||||
|
||||
This sample uses the Agent Development Kit (ADK) along with the A2A protocol to create a simple "Restaurant finder and table reservation" agent that is hosted as an A2A server.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.9 or higher
|
||||
- [UV](https://docs.astral.sh/uv/)
|
||||
- Access to an LLM and API Key
|
||||
|
||||
## Running the Sample
|
||||
|
||||
1. Navigate to the samples directory:
|
||||
|
||||
```bash
|
||||
cd a2a_samples/a2ui_restaurant_finder
|
||||
```
|
||||
|
||||
2. Create an environment file with your API key:
|
||||
|
||||
```bash
|
||||
echo "GEMINI_API_KEY=your_api_key_here" > .env
|
||||
```
|
||||
|
||||
3. Run the agent server:
|
||||
|
||||
```bash
|
||||
uv run .
|
||||
```
|
||||
|
||||
## Disclaimer
|
||||
|
||||
Important: The sample code provided is for demonstration purposes and illustrates the mechanics of the Agent-to-Agent (A2A) protocol. When building production applications, it is critical to treat any agent operating outside of your direct control as a potentially untrusted entity.
|
||||
|
||||
All data received from an external agent—including but not limited to its AgentCard, messages, artifacts, and task statuses—should be handled as untrusted input. For example, a malicious agent could provide an AgentCard containing crafted data in its fields (e.g., description, name, skills.description). If this data is used without sanitization to construct prompts for a Large Language Model (LLM), it could expose your application to prompt injection attacks. Failure to properly validate and sanitize this data before use can introduce security vulnerabilities into your application.
|
||||
|
||||
Developers are responsible for implementing appropriate security measures, such as input validation and secure handling of credentials to protect their systems and users.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Copyright 2025 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from . import agent
|
||||
@@ -0,0 +1,113 @@
|
||||
# Copyright 2025 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
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
|
||||
from a2a.types import AgentCapabilities, AgentCard, AgentSkill
|
||||
from a2ui.a2ui_extension import get_a2ui_agent_extension
|
||||
from agent import RestaurantAgent
|
||||
from agent_executor import RestaurantAgentExecutor
|
||||
from dotenv import load_dotenv
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
from starlette.staticfiles import StaticFiles
|
||||
|
||||
load_dotenv()
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MissingAPIKeyError(Exception):
|
||||
"""Exception for missing API key."""
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--host", default="localhost")
|
||||
@click.option("--port", default=10002)
|
||||
def main(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."
|
||||
)
|
||||
|
||||
capabilities = AgentCapabilities(
|
||||
streaming=True,
|
||||
extensions=[get_a2ui_agent_extension()],
|
||||
)
|
||||
skill = AgentSkill(
|
||||
id="find_restaurants",
|
||||
name="Find Restaurants Tool",
|
||||
description="Helps find restaurants based on user criteria (e.g., cuisine, location).",
|
||||
tags=["restaurant", "finder"],
|
||||
examples=["Find me the top 10 chinese restaurants in the US"],
|
||||
)
|
||||
|
||||
base_url = f"http://{host}:{port}"
|
||||
|
||||
agent_card = AgentCard(
|
||||
name="Restaurant Agent",
|
||||
description="This agent helps find restaurants based on user criteria.",
|
||||
url=base_url, # <-- Use base_url here
|
||||
version="1.0.0",
|
||||
default_input_modes=RestaurantAgent.SUPPORTED_CONTENT_TYPES,
|
||||
default_output_modes=RestaurantAgent.SUPPORTED_CONTENT_TYPES,
|
||||
capabilities=capabilities,
|
||||
skills=[skill],
|
||||
)
|
||||
|
||||
agent_executor = RestaurantAgentExecutor(base_url=base_url)
|
||||
|
||||
request_handler = DefaultRequestHandler(
|
||||
agent_executor=agent_executor,
|
||||
task_store=InMemoryTaskStore(),
|
||||
)
|
||||
server = A2AStarletteApplication(
|
||||
agent_card=agent_card, http_handler=request_handler
|
||||
)
|
||||
import uvicorn
|
||||
|
||||
app = server.build()
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:5173"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Use absolute path based on this script's location
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
images_dir = os.path.join(script_dir, "images")
|
||||
app.mount("/static", StaticFiles(directory=images_dir), name="static")
|
||||
|
||||
uvicorn.run(app, host=host, port=port)
|
||||
except MissingAPIKeyError as e:
|
||||
logger.error(f"Error: {e}")
|
||||
exit(1)
|
||||
except Exception as e:
|
||||
logger.error(f"An error occurred during server startup: {e}")
|
||||
exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,303 @@
|
||||
# Copyright 2025 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import AsyncIterable
|
||||
from typing import Any
|
||||
|
||||
import jsonschema
|
||||
from google.adk.agents.llm_agent import LlmAgent
|
||||
from google.adk.artifacts import InMemoryArtifactService
|
||||
from google.adk.memory.in_memory_memory_service import InMemoryMemoryService
|
||||
from google.adk.models.lite_llm import LiteLlm
|
||||
from google.adk.runners import Runner
|
||||
from google.adk.sessions import InMemorySessionService
|
||||
from google.genai import types
|
||||
from prompt_builder import (
|
||||
A2UI_SCHEMA,
|
||||
RESTAURANT_UI_EXAMPLES,
|
||||
get_text_prompt,
|
||||
get_ui_prompt,
|
||||
)
|
||||
from tools import get_restaurants
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
AGENT_INSTRUCTION = """
|
||||
You are a helpful restaurant finding assistant. Your goal is to help users find and book restaurants using a rich UI.
|
||||
|
||||
To achieve this, you MUST follow this logic:
|
||||
|
||||
1. **For finding restaurants:**
|
||||
a. You MUST call the `get_restaurants` tool. Extract the cuisine, location, and a specific number (`count`) of restaurants from the user's query (e.g., for "top 5 chinese places", count is 5).
|
||||
b. After receiving the data, you MUST follow the instructions precisely to generate the final a2ui UI JSON, using the appropriate UI example from the `prompt_builder.py` based on the number of restaurants.
|
||||
|
||||
2. **For booking a table (when you receive a query like 'USER_WANTS_TO_BOOK...'):**
|
||||
a. You MUST use the appropriate UI example from `prompt_builder.py` to generate the UI, populating the `dataModelUpdate.contents` with the details from the user's query.
|
||||
|
||||
3. **For confirming a booking (when you receive a query like 'User submitted a booking...'):**
|
||||
a. You MUST use the appropriate UI example from `prompt_builder.py` to generate the confirmation UI, populating the `dataModelUpdate.contents` with the final booking details.
|
||||
"""
|
||||
|
||||
|
||||
class RestaurantAgent:
|
||||
"""An agent that finds restaurants based on user criteria."""
|
||||
|
||||
SUPPORTED_CONTENT_TYPES = ["text", "text/plain"]
|
||||
|
||||
def __init__(self, base_url: str, use_ui: bool = False):
|
||||
self.base_url = base_url
|
||||
self.use_ui = use_ui
|
||||
self._agent = self._build_agent(use_ui)
|
||||
self._user_id = "remote_agent"
|
||||
self._runner = Runner(
|
||||
app_name=self._agent.name,
|
||||
agent=self._agent,
|
||||
artifact_service=InMemoryArtifactService(),
|
||||
session_service=InMemorySessionService(),
|
||||
memory_service=InMemoryMemoryService(),
|
||||
)
|
||||
|
||||
# --- MODIFICATION: Wrap the schema ---
|
||||
# Load the A2UI_SCHEMA string into a Python object for validation
|
||||
try:
|
||||
# First, load the schema for a *single message*
|
||||
single_message_schema = json.loads(A2UI_SCHEMA)
|
||||
|
||||
# The prompt instructs the LLM to return a *list* of messages.
|
||||
# Therefore, our validation schema must be an *array* of the single message schema.
|
||||
self.a2ui_schema_object = {"type": "array", "items": single_message_schema}
|
||||
logger.info(
|
||||
"A2UI_SCHEMA successfully loaded and wrapped in an array validator."
|
||||
)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"CRITICAL: Failed to parse A2UI_SCHEMA: {e}")
|
||||
self.a2ui_schema_object = None
|
||||
# --- END MODIFICATION ---
|
||||
|
||||
def get_processing_message(self) -> str:
|
||||
return "Finding restaurants that match your criteria..."
|
||||
|
||||
def _build_agent(self, use_ui: bool) -> LlmAgent:
|
||||
"""Builds the LLM agent for the restaurant agent."""
|
||||
LITELLM_MODEL = os.getenv("LITELLM_MODEL", "gemini/gemini-2.5-flash")
|
||||
|
||||
if use_ui:
|
||||
# Construct the full prompt with UI instructions, examples, and schema
|
||||
instruction = AGENT_INSTRUCTION + get_ui_prompt(
|
||||
self.base_url, RESTAURANT_UI_EXAMPLES
|
||||
)
|
||||
else:
|
||||
instruction = get_text_prompt()
|
||||
|
||||
return LlmAgent(
|
||||
model=LiteLlm(model=LITELLM_MODEL),
|
||||
name="restaurant_agent",
|
||||
description="An agent that finds restaurants and helps book tables.",
|
||||
instruction=instruction,
|
||||
tools=[get_restaurants],
|
||||
)
|
||||
|
||||
async def stream(self, query, session_id) -> AsyncIterable[dict[str, Any]]:
|
||||
session_state = {"base_url": self.base_url}
|
||||
|
||||
session = await self._runner.session_service.get_session(
|
||||
app_name=self._agent.name,
|
||||
user_id=self._user_id,
|
||||
session_id=session_id,
|
||||
)
|
||||
if session is None:
|
||||
session = await self._runner.session_service.create_session(
|
||||
app_name=self._agent.name,
|
||||
user_id=self._user_id,
|
||||
state=session_state,
|
||||
session_id=session_id,
|
||||
)
|
||||
elif "base_url" not in session.state:
|
||||
session.state["base_url"] = self.base_url
|
||||
|
||||
# --- Begin: UI Validation and Retry Logic ---
|
||||
max_retries = 1 # Total 2 attempts
|
||||
attempt = 0
|
||||
current_query_text = query
|
||||
|
||||
# Ensure schema was loaded
|
||||
if self.use_ui and self.a2ui_schema_object is None:
|
||||
logger.error(
|
||||
"--- RestaurantAgent.stream: A2UI_SCHEMA is not loaded. "
|
||||
"Cannot perform UI validation. ---"
|
||||
)
|
||||
yield {
|
||||
"is_task_complete": True,
|
||||
"content": (
|
||||
"I'm sorry, I'm facing an internal configuration error with my UI components. "
|
||||
"Please contact support."
|
||||
),
|
||||
}
|
||||
return
|
||||
|
||||
while attempt <= max_retries:
|
||||
attempt += 1
|
||||
logger.info(
|
||||
f"--- RestaurantAgent.stream: Attempt {attempt}/{max_retries + 1} "
|
||||
f"for session {session_id} ---"
|
||||
)
|
||||
|
||||
current_message = types.Content(
|
||||
role="user", parts=[types.Part.from_text(text=current_query_text)]
|
||||
)
|
||||
final_response_content = None
|
||||
|
||||
async for event in self._runner.run_async(
|
||||
user_id=self._user_id,
|
||||
session_id=session.id,
|
||||
new_message=current_message,
|
||||
):
|
||||
logger.info(f"Event from runner: {event}")
|
||||
if event.is_final_response():
|
||||
if (
|
||||
event.content
|
||||
and event.content.parts
|
||||
and event.content.parts[0].text
|
||||
):
|
||||
final_response_content = "\n".join(
|
||||
[p.text for p in event.content.parts if p.text]
|
||||
)
|
||||
break # Got the final response, stop consuming events
|
||||
else:
|
||||
logger.info(f"Intermediate event: {event}")
|
||||
# Yield intermediate updates on every attempt
|
||||
yield {
|
||||
"is_task_complete": False,
|
||||
"updates": self.get_processing_message(),
|
||||
}
|
||||
|
||||
if final_response_content is None:
|
||||
logger.warning(
|
||||
f"--- RestaurantAgent.stream: Received no final response content from runner "
|
||||
f"(Attempt {attempt}). ---"
|
||||
)
|
||||
if attempt <= max_retries:
|
||||
current_query_text = (
|
||||
"I received no response. Please try again."
|
||||
f"Please retry the original request: '{query}'"
|
||||
)
|
||||
continue # Go to next retry
|
||||
else:
|
||||
# Retries exhausted on no-response
|
||||
final_response_content = "I'm sorry, I encountered an error and couldn't process your request."
|
||||
# Fall through to send this as a text-only error
|
||||
|
||||
is_valid = False
|
||||
error_message = ""
|
||||
|
||||
if self.use_ui:
|
||||
logger.info(
|
||||
f"--- RestaurantAgent.stream: Validating UI response (Attempt {attempt})... ---"
|
||||
)
|
||||
try:
|
||||
if "---a2ui_JSON---" not in final_response_content:
|
||||
raise ValueError("Delimiter '---a2ui_JSON---' not found.")
|
||||
|
||||
text_part, json_string = final_response_content.split(
|
||||
"---a2ui_JSON---", 1
|
||||
)
|
||||
|
||||
if not json_string.strip():
|
||||
raise ValueError("JSON part is empty.")
|
||||
|
||||
json_string_cleaned = (
|
||||
json_string.strip().lstrip("```json").rstrip("```").strip()
|
||||
)
|
||||
|
||||
if not json_string_cleaned:
|
||||
raise ValueError("Cleaned JSON string is empty.")
|
||||
|
||||
# --- New Validation Steps ---
|
||||
# 1. Check if it's parsable JSON
|
||||
parsed_json_data = json.loads(json_string_cleaned)
|
||||
|
||||
# 2. Check if it validates against the A2UI_SCHEMA
|
||||
# This will raise jsonschema.exceptions.ValidationError if it fails
|
||||
logger.info(
|
||||
"--- RestaurantAgent.stream: Validating against A2UI_SCHEMA... ---"
|
||||
)
|
||||
jsonschema.validate(
|
||||
instance=parsed_json_data, schema=self.a2ui_schema_object
|
||||
)
|
||||
# --- End New Validation Steps ---
|
||||
|
||||
logger.info(
|
||||
f"--- RestaurantAgent.stream: UI JSON successfully parsed AND validated against schema. "
|
||||
f"Validation OK (Attempt {attempt}). ---"
|
||||
)
|
||||
is_valid = True
|
||||
|
||||
except (
|
||||
ValueError,
|
||||
json.JSONDecodeError,
|
||||
jsonschema.exceptions.ValidationError,
|
||||
) as e:
|
||||
logger.warning(
|
||||
f"--- RestaurantAgent.stream: A2UI validation failed: {e} (Attempt {attempt}) ---"
|
||||
)
|
||||
logger.warning(
|
||||
f"--- Failed response content: {final_response_content[:500]}... ---"
|
||||
)
|
||||
error_message = f"Validation failed: {e}."
|
||||
|
||||
else: # Not using UI, so text is always "valid"
|
||||
is_valid = True
|
||||
|
||||
if is_valid:
|
||||
logger.info(
|
||||
f"--- RestaurantAgent.stream: Response is valid. Sending final response (Attempt {attempt}). ---"
|
||||
)
|
||||
logger.info(f"Final response: {final_response_content}")
|
||||
yield {
|
||||
"is_task_complete": True,
|
||||
"content": final_response_content,
|
||||
}
|
||||
return # We're done, exit the generator
|
||||
|
||||
# --- If we're here, it means validation failed ---
|
||||
|
||||
if attempt <= max_retries:
|
||||
logger.warning(
|
||||
f"--- RestaurantAgent.stream: Retrying... ({attempt}/{max_retries + 1}) ---"
|
||||
)
|
||||
# Prepare the query for the retry
|
||||
current_query_text = (
|
||||
f"Your previous response was invalid. {error_message} "
|
||||
"You MUST generate a valid response that strictly follows the A2UI JSON SCHEMA. "
|
||||
"The response MUST be a JSON list of A2UI messages. "
|
||||
"Ensure the response is split by '---a2ui_JSON---' and the JSON part is well-formed. "
|
||||
f"Please retry the original request: '{query}'"
|
||||
)
|
||||
# Loop continues...
|
||||
|
||||
# --- If we're here, it means we've exhausted retries ---
|
||||
logger.error(
|
||||
"--- RestaurantAgent.stream: Max retries exhausted. Sending text-only error. ---"
|
||||
)
|
||||
yield {
|
||||
"is_task_complete": True,
|
||||
"content": (
|
||||
"I'm sorry, I'm having trouble generating the interface for that request right now. "
|
||||
"Please try again in a moment."
|
||||
),
|
||||
}
|
||||
# --- End: UI Validation and Retry Logic ---
|
||||
@@ -0,0 +1,197 @@
|
||||
# Copyright 2025 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from a2a.server.agent_execution import AgentExecutor, RequestContext
|
||||
from a2a.server.events import EventQueue
|
||||
from a2a.server.tasks import TaskUpdater
|
||||
from a2a.types import (
|
||||
DataPart,
|
||||
Part,
|
||||
Task,
|
||||
TaskState,
|
||||
TextPart,
|
||||
UnsupportedOperationError,
|
||||
)
|
||||
from a2a.utils import (
|
||||
new_agent_parts_message,
|
||||
new_agent_text_message,
|
||||
new_task,
|
||||
)
|
||||
from a2a.utils.errors import ServerError
|
||||
from a2ui.a2ui_extension import create_a2ui_part, try_activate_a2ui_extension
|
||||
from agent import RestaurantAgent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RestaurantAgentExecutor(AgentExecutor):
|
||||
"""Restaurant AgentExecutor Example."""
|
||||
|
||||
def __init__(self, base_url: str):
|
||||
# Instantiate two agents: one for UI and one for text-only.
|
||||
# The appropriate one will be chosen at execution time.
|
||||
self.ui_agent = RestaurantAgent(base_url=base_url, use_ui=True)
|
||||
self.text_agent = RestaurantAgent(base_url=base_url, use_ui=False)
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
context: RequestContext,
|
||||
event_queue: EventQueue,
|
||||
) -> None:
|
||||
query = ""
|
||||
ui_event_part = None
|
||||
action = None
|
||||
|
||||
logger.info(
|
||||
f"--- Client requested extensions: {context.requested_extensions} ---"
|
||||
)
|
||||
use_ui = try_activate_a2ui_extension(context)
|
||||
|
||||
# Determine which agent to use based on whether the a2ui extension is active.
|
||||
if use_ui:
|
||||
agent = self.ui_agent
|
||||
logger.info(
|
||||
"--- AGENT_EXECUTOR: A2UI extension is active. Using UI agent. ---"
|
||||
)
|
||||
else:
|
||||
agent = self.text_agent
|
||||
logger.info(
|
||||
"--- AGENT_EXECUTOR: A2UI extension is not active. Using text agent. ---"
|
||||
)
|
||||
|
||||
if context.message and context.message.parts:
|
||||
logger.info(
|
||||
f"--- AGENT_EXECUTOR: Processing {len(context.message.parts)} message parts ---"
|
||||
)
|
||||
for i, part in enumerate(context.message.parts):
|
||||
if isinstance(part.root, DataPart):
|
||||
if "userAction" in part.root.data:
|
||||
logger.info(f" Part {i}: Found a2ui UI ClientEvent payload.")
|
||||
ui_event_part = part.root.data["userAction"]
|
||||
else:
|
||||
logger.info(f" Part {i}: DataPart (data: {part.root.data})")
|
||||
elif isinstance(part.root, TextPart):
|
||||
logger.info(f" Part {i}: TextPart (text: {part.root.text})")
|
||||
else:
|
||||
logger.info(f" Part {i}: Unknown part type ({type(part.root)})")
|
||||
|
||||
if ui_event_part:
|
||||
logger.info(f"Received a2ui ClientEvent: {ui_event_part}")
|
||||
action = ui_event_part.get("actionName")
|
||||
ctx = ui_event_part.get("context", {})
|
||||
|
||||
if action == "book_restaurant":
|
||||
restaurant_name = ctx.get("restaurantName", "Unknown Restaurant")
|
||||
address = ctx.get("address", "Address not provided")
|
||||
image_url = ctx.get("imageUrl", "")
|
||||
query = f"USER_WANTS_TO_BOOK: {restaurant_name}, Address: {address}, ImageURL: {image_url}"
|
||||
|
||||
elif action == "submit_booking":
|
||||
restaurant_name = ctx.get("restaurantName", "Unknown Restaurant")
|
||||
party_size = ctx.get("partySize", "Unknown Size")
|
||||
reservation_time = ctx.get("reservationTime", "Unknown Time")
|
||||
dietary_reqs = ctx.get("dietary", "None")
|
||||
image_url = ctx.get("imageUrl", "")
|
||||
query = f"User submitted a booking for {restaurant_name} for {party_size} people at {reservation_time} with dietary requirements: {dietary_reqs}. The image URL is {image_url}"
|
||||
|
||||
else:
|
||||
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()
|
||||
|
||||
logger.info(f"--- AGENT_EXECUTOR: Final query for LLM: '{query}' ---")
|
||||
|
||||
task = context.current_task
|
||||
|
||||
if not task:
|
||||
task = new_task(context.message)
|
||||
await event_queue.enqueue_event(task)
|
||||
updater = TaskUpdater(event_queue, task.id, task.context_id)
|
||||
|
||||
async for item in agent.stream(query, task.context_id):
|
||||
is_task_complete = item["is_task_complete"]
|
||||
if not is_task_complete:
|
||||
await updater.update_status(
|
||||
TaskState.working,
|
||||
new_agent_text_message(item["updates"], task.context_id, task.id),
|
||||
)
|
||||
continue
|
||||
|
||||
final_state = (
|
||||
TaskState.completed
|
||||
if action == "submit_booking"
|
||||
else TaskState.input_required
|
||||
)
|
||||
|
||||
content = item["content"]
|
||||
final_parts = []
|
||||
if "---a2ui_JSON---" in content:
|
||||
logger.info("Splitting final response into text and UI parts.")
|
||||
text_content, json_string = content.split("---a2ui_JSON---", 1)
|
||||
|
||||
if text_content.strip():
|
||||
final_parts.append(Part(root=TextPart(text=text_content.strip())))
|
||||
|
||||
if json_string.strip():
|
||||
try:
|
||||
json_string_cleaned = (
|
||||
json_string.strip().lstrip("```json").rstrip("```").strip()
|
||||
)
|
||||
# The new protocol sends a stream of JSON objects.
|
||||
# For this example, we'll assume they are sent as a list in the final response.
|
||||
json_data = json.loads(json_string_cleaned)
|
||||
|
||||
if isinstance(json_data, list):
|
||||
logger.info(
|
||||
f"Found {len(json_data)} messages. Creating individual DataParts."
|
||||
)
|
||||
for message in json_data:
|
||||
final_parts.append(create_a2ui_part(message))
|
||||
else:
|
||||
# Handle the case where a single JSON object is returned
|
||||
logger.info(
|
||||
"Received a single JSON object. Creating a DataPart."
|
||||
)
|
||||
final_parts.append(create_a2ui_part(json_data))
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Failed to parse UI JSON: {e}")
|
||||
final_parts.append(Part(root=TextPart(text=json_string)))
|
||||
else:
|
||||
final_parts.append(Part(root=TextPart(text=content.strip())))
|
||||
|
||||
logger.info("--- FINAL PARTS TO BE SENT ---")
|
||||
for i, part in enumerate(final_parts):
|
||||
logger.info(f" - Part {i}: Type = {type(part.root)}")
|
||||
if isinstance(part.root, TextPart):
|
||||
logger.info(f" - Text: {part.root.text[:200]}...")
|
||||
elif isinstance(part.root, DataPart):
|
||||
logger.info(f" - Data: {str(part.root.data)[:200]}...")
|
||||
logger.info("-----------------------------")
|
||||
|
||||
await updater.update_status(
|
||||
final_state,
|
||||
new_agent_parts_message(final_parts, task.context_id, task.id),
|
||||
final=(final_state == TaskState.completed),
|
||||
)
|
||||
break
|
||||
|
||||
async def cancel(
|
||||
self, request: RequestContext, event_queue: EventQueue
|
||||
) -> Task | None:
|
||||
raise ServerError(error=UnsupportedOperationError())
|
||||
@@ -0,0 +1 @@
|
||||
../../../../images/restaurant-food/Spaghetti Carbonara.png
|
||||
@@ -0,0 +1 @@
|
||||
../../../../images/restaurant-food/beefbroccoli.jpeg
|
||||
@@ -0,0 +1 @@
|
||||
../../../../images/restaurant-food/classic tiramasu.png
|
||||
@@ -0,0 +1 @@
|
||||
../../../../images/restaurant-food/italian restaurant 2.png
|
||||
@@ -0,0 +1 @@
|
||||
../../../../images/restaurant-food/italian restaurant1.png
|
||||
@@ -0,0 +1 @@
|
||||
../../../../images/restaurant-food/italian1.png
|
||||
@@ -0,0 +1 @@
|
||||
../../../../images/restaurant-food/italian2.png
|
||||
@@ -0,0 +1 @@
|
||||
../../../../images/restaurant-food/italian3.png
|
||||
@@ -0,0 +1 @@
|
||||
../../../../images/restaurant-food/kungpao.jpeg
|
||||
@@ -0,0 +1 @@
|
||||
../../../../images/restaurant-food/lasagna.png
|
||||
@@ -0,0 +1 @@
|
||||
../../../../images/restaurant-food/logo.png
|
||||
@@ -0,0 +1 @@
|
||||
../../../../images/restaurant-food/mapotofu.jpeg
|
||||
@@ -0,0 +1 @@
|
||||
../../../../images/restaurant-food/margharita.png
|
||||
@@ -0,0 +1 @@
|
||||
../../../../images/restaurant-food/saffron risotto.png
|
||||
@@ -0,0 +1 @@
|
||||
../../../../images/restaurant-food/shrimpchowmein.jpeg
|
||||
@@ -0,0 +1 @@
|
||||
../../../../images/restaurant-food/springrolls.jpeg
|
||||
@@ -0,0 +1 @@
|
||||
../../../../images/restaurant-food/sweetsourpork.jpeg
|
||||
@@ -0,0 +1 @@
|
||||
../../../../images/restaurant-food/vegfriedrice.jpeg
|
||||
@@ -0,0 +1,26 @@
|
||||
[project]
|
||||
name = "a2ui-restaurant-finder"
|
||||
version = "0.1.0"
|
||||
description = "Sample Google ADK-based Restaurant finder agent that uses a2ui UI and is hosted as an A2A server agent."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"a2a-sdk>=0.3.0",
|
||||
"click>=8.1.8",
|
||||
"google-adk>=1.8.0",
|
||||
"google-genai>=1.27.0",
|
||||
"python-dotenv>=1.1.0",
|
||||
"litellm",
|
||||
"a2ui",
|
||||
"jsonschema>=4.0.0",
|
||||
]
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["."]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.uv.sources]
|
||||
a2ui = { workspace = true }
|
||||
@@ -0,0 +1,66 @@
|
||||
[
|
||||
{
|
||||
"name": "Xi'an Famous Foods",
|
||||
"detail": "Spicy and savory hand-pulled noodles.",
|
||||
"imageUrl": "http://localhost:10002/static/shrimpchowmein.jpeg",
|
||||
"rating": "★★★★☆",
|
||||
"infoLink": "[More Info](https://www.xianfoods.com/)",
|
||||
"address": "81 St Marks Pl, New York, NY 10003"
|
||||
},
|
||||
{
|
||||
"name": "Han Dynasty",
|
||||
"detail": "Authentic Szechuan cuisine.",
|
||||
"imageUrl": "http://localhost:10002/static/mapotofu.jpeg",
|
||||
"rating": "★★★★☆",
|
||||
"infoLink": "[More Info](https://www.handynasty.net/)",
|
||||
"address": "90 3rd Ave, New York, NY 10003"
|
||||
},
|
||||
{
|
||||
"name": "RedFarm",
|
||||
"detail": "Modern Chinese with a farm-to-table approach.",
|
||||
"imageUrl": "http://localhost:10002/static/beefbroccoli.jpeg",
|
||||
"rating": "★★★★☆",
|
||||
"infoLink": "[More Info](https://www.redfarmnyc.com/)",
|
||||
"address": "529 Hudson St, New York, NY 10014"
|
||||
},
|
||||
{
|
||||
"name": "Mott 32",
|
||||
"detail": "Upscale Cantonese dining.",
|
||||
"imageUrl": "http://localhost:10002/static/springrolls.jpeg",
|
||||
"rating": "★★★★★",
|
||||
"infoLink": "[More Info](https://mott32.com/newyork/)",
|
||||
"address": "111 W 57th St, New York, NY 10019"
|
||||
},
|
||||
{
|
||||
"name": "Hwa Yuan Szechuan",
|
||||
"detail": "Famous for its cold noodles with sesame sauce.",
|
||||
"imageUrl": "http://localhost:10002/static/kungpao.jpeg",
|
||||
"rating": "★★★★☆",
|
||||
"infoLink": "[More Info](https://hwayuannyc.com/)",
|
||||
"address": "40 E Broadway, New York, NY 10002"
|
||||
},
|
||||
{
|
||||
"name": "Cafe China",
|
||||
"detail": "Szechuan food in a 1930s Shanghai setting.",
|
||||
"imageUrl": "http://localhost:10002/static/mapotofu.jpeg",
|
||||
"rating": "★★★★☆",
|
||||
"infoLink": "[More Info](https://www.cafechinanyc.com/)",
|
||||
"address": "59 W 37th St, New York, NY 10018"
|
||||
},
|
||||
{
|
||||
"name": "Philippe Chow",
|
||||
"detail": "High-end Beijing-style cuisine.",
|
||||
"imageUrl": "http://localhost:10002/static/beefbroccoli.jpeg",
|
||||
"rating": "★★★★☆",
|
||||
"infoLink": "[More Info](https://www.philippechow.com/)",
|
||||
"address": "33 E 60th St, New York, NY 10022"
|
||||
},
|
||||
{
|
||||
"name": "Chinese Tuxedo",
|
||||
"detail": "Contemporary Chinese in a former opera house.",
|
||||
"imageUrl": "http://localhost:10002/static/mapotofu.jpeg",
|
||||
"rating": "★★★★☆",
|
||||
"infoLink": "[More Info](https://chinesetuxedo.com/)",
|
||||
"address": "5 Doyers St, New York, NY 10013"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,59 @@
|
||||
# Copyright 2025 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
from google.adk.tools.tool_context import ToolContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_restaurants(
|
||||
cuisine: str, location: str, tool_context: ToolContext, count: int = 5
|
||||
) -> str:
|
||||
"""Call this tool to get a list of restaurants based on a cuisine and location.
|
||||
'count' is the number of restaurants to return.
|
||||
"""
|
||||
logger.info(f"--- TOOL CALLED: get_restaurants (count: {count}) ---")
|
||||
logger.info(f" - Cuisine: {cuisine}")
|
||||
logger.info(f" - Location: {location}")
|
||||
|
||||
items = []
|
||||
if "new york" in location.lower() or "ny" in location.lower():
|
||||
try:
|
||||
script_dir = os.path.dirname(__file__)
|
||||
file_path = os.path.join(script_dir, "restaurant_data.json")
|
||||
with open(file_path) as f:
|
||||
restaurant_data_str = f.read()
|
||||
if base_url := tool_context.state.get("base_url"):
|
||||
restaurant_data_str = restaurant_data_str.replace(
|
||||
"http://localhost:10002", base_url
|
||||
)
|
||||
logger.info(f"Updated base URL from tool context: {base_url}")
|
||||
all_items = json.loads(restaurant_data_str)
|
||||
|
||||
# Slice the list to return only the requested number of items
|
||||
items = all_items[:count]
|
||||
logger.info(
|
||||
f" - Success: Found {len(all_items)} restaurants, returning {len(items)}."
|
||||
)
|
||||
|
||||
except FileNotFoundError:
|
||||
logger.error(f" - Error: restaurant_data.json not found at {file_path}")
|
||||
except json.JSONDecodeError:
|
||||
logger.error(f" - Error: Failed to decode JSON from {file_path}")
|
||||
|
||||
return json.dumps(items)
|
||||
@@ -0,0 +1,162 @@
|
||||
:root {
|
||||
--n-100: #ffffff;
|
||||
--n-99: #fcfcfc;
|
||||
--n-98: #f9f9f9;
|
||||
--n-95: #f1f1f1;
|
||||
--n-90: #e2e2e2;
|
||||
--n-80: #c6c6c6;
|
||||
--n-70: #ababab;
|
||||
--n-60: #919191;
|
||||
--n-50: #777777;
|
||||
--n-40: #5e5e5e;
|
||||
--n-35: #525252;
|
||||
--n-30: #474747;
|
||||
--n-25: #3b3b3b;
|
||||
--n-20: #303030;
|
||||
--n-15: #262626;
|
||||
--n-10: #1b1b1b;
|
||||
--n-5: #111111;
|
||||
--n-0: #000000;
|
||||
|
||||
--p-100: #ffffff;
|
||||
--p-99: #fffbff;
|
||||
--p-98: #fcf8ff;
|
||||
--p-95: #f2efff;
|
||||
--p-90: #e1e0ff;
|
||||
--p-80: #c0c1ff;
|
||||
--p-70: #a0a3ff;
|
||||
--p-60: #8487ea;
|
||||
--p-50: #6a6dcd;
|
||||
--p-40: #5154b3;
|
||||
--p-35: #4447a6;
|
||||
--p-30: #383b99;
|
||||
--p-25: #2c2e8d;
|
||||
--p-20: #202182;
|
||||
--p-15: #131178;
|
||||
--p-10: #06006c;
|
||||
--p-5: #03004d;
|
||||
--p-0: #000000;
|
||||
|
||||
--s-100: #ffffff;
|
||||
--s-99: #fffbff;
|
||||
--s-98: #fcf8ff;
|
||||
--s-95: #f2efff;
|
||||
--s-90: #e2e0f9;
|
||||
--s-80: #c6c4dd;
|
||||
--s-70: #aaa9c1;
|
||||
--s-60: #8f8fa5;
|
||||
--s-50: #75758b;
|
||||
--s-40: #5d5c72;
|
||||
--s-35: #515165;
|
||||
--s-30: #454559;
|
||||
--s-25: #393a4d;
|
||||
--s-20: #2e2f42;
|
||||
--s-15: #242437;
|
||||
--s-10: #191a2c;
|
||||
--s-5: #0f0f21;
|
||||
--s-0: #000000;
|
||||
|
||||
--t-100: #ffffff;
|
||||
--t-99: #fffbff;
|
||||
--t-98: #fff8f9;
|
||||
--t-95: #ffecf4;
|
||||
--t-90: #ffd8ec;
|
||||
--t-80: #e9b9d3;
|
||||
--t-70: #cc9eb8;
|
||||
--t-60: #af849d;
|
||||
--t-50: #946b83;
|
||||
--t-40: #79536a;
|
||||
--t-35: #6c475d;
|
||||
--t-30: #5f3c51;
|
||||
--t-25: #523146;
|
||||
--t-20: #46263a;
|
||||
--t-15: #3a1b2f;
|
||||
--t-10: #2e1125;
|
||||
--t-5: #22071a;
|
||||
--t-0: #000000;
|
||||
|
||||
--nv-100: #ffffff;
|
||||
--nv-99: #fffbff;
|
||||
--nv-98: #fcf8ff;
|
||||
--nv-95: #f2effa;
|
||||
--nv-90: #e4e1ec;
|
||||
--nv-80: #c8c5d0;
|
||||
--nv-70: #acaab4;
|
||||
--nv-60: #918f9a;
|
||||
--nv-50: #777680;
|
||||
--nv-40: #5e5d67;
|
||||
--nv-35: #52515b;
|
||||
--nv-30: #46464f;
|
||||
--nv-25: #3b3b43;
|
||||
--nv-20: #303038;
|
||||
--nv-15: #25252d;
|
||||
--nv-10: #1b1b23;
|
||||
--nv-5: #101018;
|
||||
--nv-0: #000000;
|
||||
|
||||
--e-100: #ffffff;
|
||||
--e-99: #fffbff;
|
||||
--e-98: #fff8f7;
|
||||
--e-95: #ffedea;
|
||||
--e-90: #ffdad6;
|
||||
--e-80: #ffb4ab;
|
||||
--e-70: #ff897d;
|
||||
--e-60: #ff5449;
|
||||
--e-50: #de3730;
|
||||
--e-40: #ba1a1a;
|
||||
--e-35: #a80710;
|
||||
--e-30: #93000a;
|
||||
--e-25: #7e0007;
|
||||
--e-20: #690005;
|
||||
--e-15: #540003;
|
||||
--e-10: #410002;
|
||||
--e-5: #2d0001;
|
||||
--e-0: #000000;
|
||||
|
||||
--primary: #137fec;
|
||||
--text-color: #fff;
|
||||
--background-light: #f6f7f8;
|
||||
--background-dark: #101922;
|
||||
--border-color: oklch(
|
||||
from var(--background-light) l c h / calc(alpha * 0.15)
|
||||
);
|
||||
--elevated-background-light: oklch(
|
||||
from var(--background-light) l c h / calc(alpha * 0.05)
|
||||
);
|
||||
--bb-grid-size: 4px;
|
||||
--bb-grid-size-2: calc(var(--bb-grid-size) * 2);
|
||||
--bb-grid-size-3: calc(var(--bb-grid-size) * 3);
|
||||
--bb-grid-size-4: calc(var(--bb-grid-size) * 4);
|
||||
--bb-grid-size-5: calc(var(--bb-grid-size) * 5);
|
||||
--bb-grid-size-6: calc(var(--bb-grid-size) * 6);
|
||||
--bb-grid-size-7: calc(var(--bb-grid-size) * 7);
|
||||
--bb-grid-size-8: calc(var(--bb-grid-size) * 8);
|
||||
--bb-grid-size-9: calc(var(--bb-grid-size) * 9);
|
||||
--bb-grid-size-10: calc(var(--bb-grid-size) * 10);
|
||||
--bb-grid-size-11: calc(var(--bb-grid-size) * 11);
|
||||
--bb-grid-size-12: calc(var(--bb-grid-size) * 12);
|
||||
--bb-grid-size-13: calc(var(--bb-grid-size) * 13);
|
||||
--bb-grid-size-14: calc(var(--bb-grid-size) * 14);
|
||||
--bb-grid-size-15: calc(var(--bb-grid-size) * 15);
|
||||
--bb-grid-size-16: calc(var(--bb-grid-size) * 16);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
--font-family: "Google Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
--font-family-flex:
|
||||
"Google Sans Flex", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
--font-family-mono:
|
||||
"Google Sans Code", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
|
||||
background: var(--background-light);
|
||||
font-family: var(--font-family);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100svw;
|
||||
height: 100svh;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
CopilotRuntime,
|
||||
CopilotKitIntelligence,
|
||||
createCopilotEndpoint,
|
||||
InMemoryAgentRunner,
|
||||
} from "@copilotkit/runtime/v2";
|
||||
import type {
|
||||
AgentSubscriber,
|
||||
RunAgentInput,
|
||||
RunAgentParameters,
|
||||
RunAgentResult,
|
||||
} from "@ag-ui/client";
|
||||
import { handle } from "hono/vercel";
|
||||
import { A2AAgent } from "@ag-ui/a2a";
|
||||
import type { A2AAgentConfig } from "@ag-ui/a2a";
|
||||
import { A2AClient } from "@a2a-js/sdk/client";
|
||||
|
||||
const a2aClient = new A2AClient("http://localhost:10002");
|
||||
|
||||
type RuntimeRunAgentInput = RunAgentParameters &
|
||||
Partial<Pick<RunAgentInput, "messages" | "state" | "threadId">>;
|
||||
|
||||
class RuntimeA2AAgent extends A2AAgent {
|
||||
private readonly client: A2AClient;
|
||||
|
||||
constructor(config: A2AAgentConfig) {
|
||||
super(config);
|
||||
this.client = config.a2aClient;
|
||||
}
|
||||
|
||||
async runAgent(
|
||||
parameters: RuntimeRunAgentInput = {},
|
||||
subscriber?: AgentSubscriber,
|
||||
): Promise<RunAgentResult> {
|
||||
const isolatedAgent = new A2AAgent({
|
||||
a2aClient: this.client,
|
||||
agentId: this.agentId,
|
||||
debug: this.debug,
|
||||
description: this.description,
|
||||
initialMessages: this.messages,
|
||||
initialState: this.state,
|
||||
threadId: this.threadId,
|
||||
});
|
||||
|
||||
if (parameters.threadId) {
|
||||
isolatedAgent.threadId = parameters.threadId;
|
||||
}
|
||||
|
||||
if (parameters.state) {
|
||||
isolatedAgent.setState(parameters.state);
|
||||
}
|
||||
|
||||
if (parameters.messages) {
|
||||
isolatedAgent.setMessages(parameters.messages);
|
||||
}
|
||||
|
||||
return isolatedAgent.runAgent(
|
||||
{
|
||||
context: parameters.context,
|
||||
forwardedProps: parameters.forwardedProps,
|
||||
runId: parameters.runId,
|
||||
tools: parameters.tools,
|
||||
},
|
||||
subscriber,
|
||||
);
|
||||
}
|
||||
|
||||
clone(): RuntimeA2AAgent {
|
||||
return new RuntimeA2AAgent({
|
||||
a2aClient: this.client,
|
||||
agentId: this.agentId,
|
||||
debug: this.debug,
|
||||
description: this.description,
|
||||
initialMessages: this.messages,
|
||||
initialState: this.state,
|
||||
threadId: this.threadId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const agent = new RuntimeA2AAgent({ a2aClient });
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
agents: {
|
||||
default: agent,
|
||||
},
|
||||
a2ui: {},
|
||||
// --- copilotkit:intelligence (remove this block to opt out) ---
|
||||
...(process.env.COPILOTKIT_LICENSE_TOKEN
|
||||
? {
|
||||
intelligence: new CopilotKitIntelligence({
|
||||
apiKey: process.env.INTELLIGENCE_API_KEY ?? "",
|
||||
apiUrl: process.env.INTELLIGENCE_API_URL ?? "http://localhost:4201",
|
||||
wsUrl:
|
||||
process.env.INTELLIGENCE_GATEWAY_WS_URL ?? "ws://localhost:4401",
|
||||
}),
|
||||
// Demo stub — replace with your own auth-derived user identity (e.g. OIDC)
|
||||
// before any multi-user deployment, or all users share one thread history.
|
||||
identifyUser: () => ({ id: "demo-user", name: "Demo User" }),
|
||||
licenseToken: process.env.COPILOTKIT_LICENSE_TOKEN,
|
||||
}
|
||||
: { runner: new InMemoryAgentRunner() }),
|
||||
// --- /copilotkit:intelligence ---
|
||||
});
|
||||
|
||||
const app = createCopilotEndpoint({
|
||||
runtime,
|
||||
basePath: "/api/copilotkit",
|
||||
});
|
||||
|
||||
export const GET = handle(app);
|
||||
export const POST = handle(app);
|
||||
export const PATCH = handle(app);
|
||||
export const DELETE = handle(app);
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import * as UI from "@a2ui/lit/ui";
|
||||
|
||||
type Status = "pending" | "success" | "failure";
|
||||
|
||||
const TARGET_TAG = "a2ui-root";
|
||||
|
||||
export function A2UIStatus() {
|
||||
const [status, setStatus] = useState<Status>("pending");
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const hasCustomElement =
|
||||
typeof customElements !== "undefined" &&
|
||||
!!customElements.get(TARGET_TAG);
|
||||
|
||||
const hasExport = typeof UI?.Root === "function";
|
||||
|
||||
if (hasCustomElement && hasExport) {
|
||||
setStatus("success");
|
||||
} else {
|
||||
setStatus("failure");
|
||||
}
|
||||
} catch {
|
||||
setStatus("failure");
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (status === "pending") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return status === "success" ? (
|
||||
<span aria-live="polite">✅ All set</span>
|
||||
) : (
|
||||
<span aria-live="assertive">❌ Failed</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactActivityMessageRenderer } from "@copilotkit/react-core/v2";
|
||||
import { z } from "zod";
|
||||
|
||||
type A2UIOperation = {
|
||||
beginRendering?: { surfaceId?: string };
|
||||
surfaceUpdate?: {
|
||||
components?: Array<{
|
||||
id?: string;
|
||||
component?: {
|
||||
Text?: {
|
||||
text?: { literalString?: string };
|
||||
};
|
||||
};
|
||||
}>;
|
||||
};
|
||||
dataModelUpdate?: {
|
||||
contents?: A2UIDataEntry[];
|
||||
};
|
||||
};
|
||||
|
||||
type A2UIDataEntry = {
|
||||
key: string;
|
||||
valueString?: string;
|
||||
valueMap?: A2UIDataEntry[];
|
||||
};
|
||||
|
||||
type Restaurant = {
|
||||
name?: string;
|
||||
rating?: string;
|
||||
detail?: string;
|
||||
infoLink?: string;
|
||||
imageUrl?: string;
|
||||
address?: string;
|
||||
};
|
||||
|
||||
function getOperations(content: unknown): A2UIOperation[] {
|
||||
if (!content || typeof content !== "object") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const payload = content as {
|
||||
a2ui_operations?: A2UIOperation[];
|
||||
operations?: A2UIOperation[];
|
||||
};
|
||||
|
||||
const operations = payload.a2ui_operations ?? payload.operations;
|
||||
if (Array.isArray(operations)) {
|
||||
return operations;
|
||||
}
|
||||
|
||||
if (!operations || typeof operations !== "object") {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (
|
||||
"beginRendering" in operations ||
|
||||
"surfaceUpdate" in operations ||
|
||||
"dataModelUpdate" in operations
|
||||
) {
|
||||
return [operations as A2UIOperation];
|
||||
}
|
||||
|
||||
return Object.values(operations).filter(
|
||||
(operation): operation is A2UIOperation =>
|
||||
!!operation && typeof operation === "object",
|
||||
);
|
||||
}
|
||||
|
||||
function getTitle(operations: A2UIOperation[]): string {
|
||||
for (const operation of operations) {
|
||||
const title = operation.surfaceUpdate?.components?.find(
|
||||
(component) => component.id === "title-heading",
|
||||
)?.component?.Text?.text?.literalString;
|
||||
|
||||
if (title) {
|
||||
return title;
|
||||
}
|
||||
}
|
||||
|
||||
return "Top Restaurants";
|
||||
}
|
||||
|
||||
function dataEntriesToObject(entries: A2UIDataEntry[] = []): Restaurant {
|
||||
return Object.fromEntries(
|
||||
entries.map((entry) => [entry.key, entry.valueString ?? ""]),
|
||||
);
|
||||
}
|
||||
|
||||
function getRestaurants(operations: A2UIOperation[]): Restaurant[] {
|
||||
const dataModel = operations.find(
|
||||
(operation) => operation.dataModelUpdate,
|
||||
)?.dataModelUpdate;
|
||||
|
||||
const itemsEntry = dataModel?.contents?.find(
|
||||
(entry) => entry.key === "items",
|
||||
);
|
||||
return (itemsEntry?.valueMap ?? []).map((entry) =>
|
||||
dataEntriesToObject(entry.valueMap),
|
||||
);
|
||||
}
|
||||
|
||||
function readableInfoLink(infoLink: string | undefined): string | null {
|
||||
if (!infoLink) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = infoLink.match(/\[([^\]]+)\]\(([^)]+)\)/);
|
||||
return match?.[2] ?? infoLink;
|
||||
}
|
||||
|
||||
function A2UIV08Surface({ content }: { content: unknown }) {
|
||||
const operations = getOperations(content);
|
||||
const restaurants = getRestaurants(operations);
|
||||
const title = getTitle(operations);
|
||||
|
||||
if (!operations.length) {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-500">
|
||||
Generating UI...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
|
||||
<div className="flex flex-col gap-3">
|
||||
{restaurants.map((restaurant, index) => (
|
||||
<article
|
||||
key={`${restaurant.name ?? "restaurant"}-${index}`}
|
||||
className="grid gap-4 rounded-lg border border-gray-200 bg-white p-4 shadow-sm sm:grid-cols-[144px_1fr]"
|
||||
>
|
||||
{restaurant.imageUrl ? (
|
||||
<img
|
||||
src={restaurant.imageUrl}
|
||||
alt={restaurant.name ?? "Restaurant"}
|
||||
className="h-32 w-full rounded-md object-cover sm:h-full"
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-gray-950">
|
||||
{restaurant.name}
|
||||
</h3>
|
||||
{restaurant.rating ? (
|
||||
<p className="text-sm text-amber-500">{restaurant.rating}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{restaurant.detail ? (
|
||||
<p className="text-sm text-gray-600">{restaurant.detail}</p>
|
||||
) : null}
|
||||
{restaurant.address ? (
|
||||
<p className="text-xs text-gray-500">{restaurant.address}</p>
|
||||
) : null}
|
||||
<div className="mt-1 flex flex-wrap gap-2">
|
||||
{readableInfoLink(restaurant.infoLink) ? (
|
||||
<a
|
||||
href={readableInfoLink(restaurant.infoLink) ?? undefined}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex h-9 items-center rounded-md border border-gray-200 px-3 text-sm font-medium text-gray-700"
|
||||
>
|
||||
More Info
|
||||
</a>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-9 items-center rounded-md bg-[#FF0000] px-3 text-sm font-medium text-white"
|
||||
>
|
||||
Book Now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const a2uiV08Renderer: ReactActivityMessageRenderer<unknown> = {
|
||||
activityType: "a2ui-surface",
|
||||
content: z.unknown(),
|
||||
render: ({ content }) => <A2UIV08Surface content={content} />,
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
# Threads Panel — Design Notes (mastra)
|
||||
|
||||
These are mastra's **bespoke** copies of the threads panel. They are no longer a
|
||||
shared/tokenized base component — they are styled to read as one product with
|
||||
mastra's `CopilotSidebar` (the right-side chat from `@copilotkit/react-core/v2`).
|
||||
|
||||
## Design source of truth
|
||||
|
||||
All surfaces, borders, radii, and type ramps are lifted from CopilotKit's V2
|
||||
design system: `@copilotkit/react-core/src/v2/styles/globals.css` plus the chat
|
||||
components (`CopilotModalHeader`, `CopilotChatSuggestionPill`,
|
||||
`CopilotChatInput`, `CopilotSidebarView`). The tokens are mirrored verbatim into
|
||||
`src/app/globals.css`:
|
||||
|
||||
| Token | Value (V2 light) | Role in the panel |
|
||||
| -------------------------------------- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `--card` / `--background` | `oklch(1 0 0)` (white) | Panel + card surfaces |
|
||||
| `--foreground` | `oklch(0.145 0 0)` | Titles, thread titles, dialog text |
|
||||
| `--muted` / `--secondary` / `--accent` | `oklch(0.97 0 0)` | Hover/active surfaces, segment track, archived chip, code well |
|
||||
| `--muted-foreground` | `oklch(0.556 0 0)` | Meta text, idle icons, descriptions, placeholders |
|
||||
| `--border` / `--input` | `oklch(0.922 0 0)` | All hairline borders |
|
||||
| `--primary` | `oklch(0.205 0 0)` (near-black) | New-thread pill, selected accent, primary CTA — the V2 sidebar's primary buttons are charcoal/black, **not** a brand accent |
|
||||
| `--primary-foreground` | `oklch(0.985 0 0)` | Primary button text |
|
||||
| `--destructive` | `oklch(0.577 0.245 27.325)` | Delete hover |
|
||||
| `--ring` | `oklch(0.708 0 0)` | Focus rings (2px box-shadow) |
|
||||
| `--radius` | `0.625rem` (+ sm/md/lg/xl) | Rectangular controls; icon buttons / pills / segments use `999px` to echo the sidebar's close button, suggestion pills, and send button |
|
||||
|
||||
## Forced light
|
||||
|
||||
mastra's `CopilotSidebar` is always light regardless of OS color scheme. The
|
||||
panel must match it, so `src/app/globals.css` re-pins `--foreground` and
|
||||
`--background` to the V2 light values on `.threadsLayout` (the layout wrapper)
|
||||
and on `body > [role="presentation"]` (the confirm dialog renders in a portal on
|
||||
`<body>`). The dark-mode `@media (prefers-color-scheme: dark)` block only flips
|
||||
the bare page `--background`/`--foreground`; the panel overrides win because
|
||||
they are scoped to the layout/portal roots.
|
||||
|
||||
## Typography
|
||||
|
||||
Geist (the app font, via `--font-body` / `--font-code`). Sizes/weights track the
|
||||
sidebar: header title `1rem / 500 / tracking-tight`, thread titles
|
||||
`0.8125rem / 500`, meta `0.6875rem`, all medium-weight — no heavy `700`s.
|
||||
|
||||
Edit these files freely; they are mastra-owned and not shared with other
|
||||
examples.
|
||||
@@ -0,0 +1,4 @@
|
||||
"use client";
|
||||
|
||||
export { default as ThreadsDrawer } from "./threads-drawer";
|
||||
export type { ThreadsDrawerProps } from "./threads-drawer";
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Lock } from "lucide-react";
|
||||
import styles from "./threads-drawer.module.css";
|
||||
|
||||
export function ThreadsPanelGate({ children }: { children: React.ReactNode }) {
|
||||
// The Threads drawer reads a client-only external store (useThreads /
|
||||
// useSyncExternalStore) with no server snapshot, so it must not render during
|
||||
// SSR/prerender — Next would fail to prerender "/". Defer to client mount.
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
React.useEffect(() => setMounted(true), []);
|
||||
|
||||
if (process.env.NEXT_PUBLIC_COPILOTKIT_THREADS_ENABLED === "true") {
|
||||
if (!mounted) {
|
||||
// SSR / first-paint placeholder: matches the open drawer's footprint +
|
||||
// surface (and collapses to nothing on mobile) so the panel doesn't flash
|
||||
// a bare-background column or shift the content when the drawer mounts.
|
||||
return <div className={styles.drawerPlaceholder} aria-hidden />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<aside aria-label="Threads (locked)" className={styles.lockedPanel}>
|
||||
<div className={styles.drawerHeader}>
|
||||
<div className={styles.drawerHeaderMain}>
|
||||
<h2 className={styles.drawerTitle}>Threads</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.lockedBody}>
|
||||
<div className={styles.lockedCard}>
|
||||
<span aria-hidden className={styles.lockedIcon}>
|
||||
<Lock size={18} />
|
||||
</span>
|
||||
<div className={styles.lockedHeading}>
|
||||
<h3 className={styles.lockedTitle}>
|
||||
Threads is a licensed feature
|
||||
</h3>
|
||||
<p className={styles.lockedDescription}>
|
||||
Unlock persistent conversation history, multi-session context, and
|
||||
thread management with CopilotKit Intelligence.
|
||||
</p>
|
||||
</div>
|
||||
<p className={styles.lockedDescription}>
|
||||
Add it to your project with:
|
||||
</p>
|
||||
<div className={styles.lockedCommand}>
|
||||
<code className={styles.lockedCommandCode}>
|
||||
npx copilotkit@latest license
|
||||
</code>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.lockedCta}
|
||||
onClick={() =>
|
||||
window.open(
|
||||
"https://docs.copilotkit.ai/intelligence",
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
)
|
||||
}
|
||||
>
|
||||
Learn more
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,891 @@
|
||||
/* Threads panel — a left-side companion to mastra's CopilotSidebar.
|
||||
Every surface, border, radius, and type ramp here is lifted from CopilotKit's
|
||||
V2 design system (@copilotkit/react-core/src/v2) so the panel reads as the
|
||||
same product as the chat on the right:
|
||||
- surfaces: white card on a white app; borders are the --border hairline
|
||||
- radius: --radius (0.625rem) for rectangular controls; rounded-full
|
||||
(999px) for icon buttons, the segmented control, and the New-thread
|
||||
affordance — echoing the sidebar's close button, suggestion pills, and
|
||||
send button
|
||||
- type: 0.875rem base / font-medium / tracking-tight, matching the
|
||||
sidebar header + pills (no heavy 700 weights)
|
||||
- primary action color is the sidebar's near-black --primary, NOT a brand
|
||||
accent — the V2 sidebar's primary buttons render bg-black/text-white */
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
min-height: 100vh;
|
||||
min-height: 100svh;
|
||||
height: 100dvh;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.drawer {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
min-height: 100svh;
|
||||
height: 100dvh;
|
||||
background: var(--card);
|
||||
border-right: 1px solid var(--border);
|
||||
font-family: var(--font-body);
|
||||
transition:
|
||||
width 180ms ease,
|
||||
box-shadow 180ms ease;
|
||||
}
|
||||
|
||||
.drawerOpen {
|
||||
width: 18rem;
|
||||
}
|
||||
|
||||
.drawerClosed {
|
||||
width: 3.5rem;
|
||||
}
|
||||
|
||||
/* First-paint placeholder (rendered by ThreadsPanelGate before the client-only
|
||||
drawer mounts). Matches the open drawer's footprint + surface so there's no
|
||||
bare-background column flash and no content shift on mount. On mobile the
|
||||
real drawer floats (no grid footprint), so the placeholder reserves nothing. */
|
||||
.drawerPlaceholder {
|
||||
width: 18rem;
|
||||
flex-shrink: 0;
|
||||
min-height: 100vh;
|
||||
min-height: 100svh;
|
||||
height: 100dvh;
|
||||
background: var(--card);
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.drawerSurface {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Header — mirrors CopilotModalHeader: hairline bottom border, generous
|
||||
px-4 py-4 rhythm, medium-weight tracking-tight title (not bold). */
|
||||
.drawerHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.drawerHeaderMain {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.drawerTitle {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
/* Icon button — the sidebar's close button: size-8, rounded-full, muted
|
||||
foreground, hover lifts to bg-muted + foreground. */
|
||||
.iconButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
color: var(--muted-foreground);
|
||||
background: transparent;
|
||||
transition:
|
||||
background-color 140ms ease,
|
||||
color 140ms ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.iconButton:hover,
|
||||
.iconButton:focus-visible {
|
||||
background: var(--muted);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.iconButton:focus-visible,
|
||||
.threadItem:focus-visible,
|
||||
.newThreadButton:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--ring);
|
||||
}
|
||||
|
||||
/* New-thread affordance — a compact pill in the sidebar's near-black
|
||||
--primary, echoing the send button (bg-black, rounded-full, medium text). */
|
||||
.newThreadButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.375rem;
|
||||
height: 2rem;
|
||||
padding: 0 0.75rem;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 140ms ease,
|
||||
opacity 140ms ease;
|
||||
}
|
||||
|
||||
.newThreadButton:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.filterBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
/* Segmented control — a single muted track with a white "lifted" active tab,
|
||||
the same surface relationship the suggestion pills use (bg over muted). */
|
||||
.segmented {
|
||||
display: inline-flex;
|
||||
width: 100%;
|
||||
padding: 0.1875rem;
|
||||
border-radius: 999px;
|
||||
background: var(--muted);
|
||||
gap: 0.1875rem;
|
||||
}
|
||||
|
||||
.segmentedOption {
|
||||
flex: 1;
|
||||
min-height: 1.75rem;
|
||||
padding: 0.3rem 0.75rem;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
color: var(--muted-foreground);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 140ms ease,
|
||||
color 140ms ease,
|
||||
box-shadow 140ms ease;
|
||||
}
|
||||
|
||||
.segmentedOption:hover {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.segmentedOption:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--ring);
|
||||
}
|
||||
|
||||
.segmentedOptionActive {
|
||||
background: var(--card);
|
||||
color: var(--foreground);
|
||||
box-shadow: 0 1px 2px rgb(0 0 0 / 0.08);
|
||||
}
|
||||
|
||||
.drawerContent {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.threadList {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
overflow-y: auto;
|
||||
/* Reserve scrollbar space so the list doesn't shift horizontally
|
||||
when the scrollbar appears during the thread-enter animation. */
|
||||
scrollbar-gutter: stable;
|
||||
padding: 0.25rem 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.threadRow {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Thread row — a quiet hover/selected surface in the sidebar's accent gray,
|
||||
--radius corners, no hairline rule between rows (the chat list is borderless
|
||||
too). */
|
||||
.threadItem {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
padding: 0.5rem 0.625rem;
|
||||
border: 0;
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 140ms ease,
|
||||
padding-right 140ms ease;
|
||||
}
|
||||
|
||||
.threadItem:hover,
|
||||
.threadItem:focus-visible {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.threadRow:hover .threadItem,
|
||||
.threadRow:focus-within .threadItem {
|
||||
padding-right: 3.5rem;
|
||||
}
|
||||
|
||||
.threadItemSelected,
|
||||
.threadItemSelected:hover {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.threadItemAnimatingIn {
|
||||
animation: threadItemEnter 420ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
/* A slim left accent rail; muted by default, fills to --primary when selected
|
||||
— the same charcoal that drives the primary action buttons. */
|
||||
.threadAccent {
|
||||
flex: none;
|
||||
width: 0.1875rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
transition: background 140ms ease;
|
||||
}
|
||||
|
||||
.threadItemSelected .threadAccent {
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.threadBody {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.threadTitle {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.threadTitlePlaceholder {
|
||||
color: var(--muted-foreground);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.threadTitleAnimated {
|
||||
display: inline-block;
|
||||
animation: generatedTitleReveal 360ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
transform-origin: left center;
|
||||
}
|
||||
|
||||
.threadMeta {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1.3;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.threadItemArchived .threadTitle {
|
||||
color: var(--muted-foreground);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.threadItemArchived .threadAccent {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Archived chip — a muted pill, the same surface/type as the suggestion
|
||||
pills (bg muted, font-medium, tiny). */
|
||||
.archivedBadge {
|
||||
display: inline-block;
|
||||
margin-left: 0.375rem;
|
||||
padding: 0.0625rem 0.375rem;
|
||||
border-radius: 999px;
|
||||
background: var(--muted);
|
||||
font-size: 0.625rem;
|
||||
font-weight: 500;
|
||||
color: var(--muted-foreground);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Load-more — a full-width outline pill, the sidebar's suggestion-pill
|
||||
treatment (border, bg-background, hover to accent). */
|
||||
.loadMoreButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-height: 2rem;
|
||||
margin-top: 0.375rem;
|
||||
padding: 0.4rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--card);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--muted-foreground);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 140ms ease,
|
||||
color 140ms ease;
|
||||
}
|
||||
|
||||
.loadMoreButton:hover:not(:disabled) {
|
||||
background: var(--accent);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.loadMoreButton:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.threadActions {
|
||||
position: absolute;
|
||||
right: 0.375rem;
|
||||
top: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
transform: translateY(-50%) scale(0.96);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 140ms ease,
|
||||
transform 140ms ease;
|
||||
}
|
||||
|
||||
.threadRow:hover .threadActions,
|
||||
.threadRow:focus-within .threadActions {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateY(-50%) scale(1);
|
||||
}
|
||||
|
||||
.threadActionButton {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Tooltip — matches the V2 dropdown/popover surface (white card, hairline
|
||||
border, soft shadow, rounded-md), not an inverted chip. */
|
||||
.tooltip::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
/* Render below the trigger: a CSS pseudo-tooltip can't escape the drawer's
|
||||
overflow containers, and "above" clips on the top row / collapsed rail. */
|
||||
top: calc(100% + 0.35rem);
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(-2px);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 0.12);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 110ms ease 200ms,
|
||||
transform 110ms ease 200ms;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.tooltip:hover::after,
|
||||
.tooltip:focus-visible::after {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
|
||||
.deleteButton {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.deleteButton:hover,
|
||||
.deleteButton:focus-visible {
|
||||
background: color-mix(in oklch, var(--destructive) 10%, transparent);
|
||||
color: var(--destructive);
|
||||
}
|
||||
|
||||
.loadingList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.loadingRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
padding: 0.5rem 0.625rem;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.loadingAccent {
|
||||
flex: none;
|
||||
width: 0.1875rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 999px;
|
||||
background: var(--muted);
|
||||
animation: threadsDrawerPulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.loadingBody {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.loadingTitleBar {
|
||||
height: 0.6rem;
|
||||
width: 60%;
|
||||
border-radius: 999px;
|
||||
background: var(--muted);
|
||||
animation: threadsDrawerPulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.loadingMetaBar {
|
||||
height: 0.45rem;
|
||||
width: 35%;
|
||||
border-radius: 999px;
|
||||
background: var(--muted);
|
||||
animation: threadsDrawerPulse 1.4s ease-in-out infinite;
|
||||
animation-delay: 140ms;
|
||||
}
|
||||
|
||||
@keyframes threadsDrawerPulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.45;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
/* Empty/error card — clean white card with hairline border and the V2
|
||||
radius-lg, same card vocabulary as the locked state. */
|
||||
.emptyCard {
|
||||
display: flex;
|
||||
max-width: 13rem;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--card);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.emptyTitle {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.emptyMessage {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* Locked state — same panel chrome (white surface, hairline right border,
|
||||
header rhythm) as the unlocked drawer, with a single clean card that speaks
|
||||
the V2 card vocabulary: rounded-lg, hairline border, muted icon chip, the
|
||||
license command in a muted code well, and a near-black primary CTA
|
||||
pill matching the sidebar's send button. */
|
||||
.lockedPanel {
|
||||
display: flex;
|
||||
width: 20rem;
|
||||
flex-shrink: 0;
|
||||
flex-direction: column;
|
||||
height: 100dvh;
|
||||
background: var(--card);
|
||||
border-right: 1px solid var(--border);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.lockedBody {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.lockedCard {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
gap: 0.875rem;
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
.lockedIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 999px;
|
||||
background: var(--muted);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.lockedHeading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.lockedTitle {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.lockedDescription {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.lockedCommand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.lockedCommandCode {
|
||||
font-family: var(--font-code);
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
color: var(--secondary-foreground);
|
||||
}
|
||||
|
||||
.lockedCta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 2.25rem;
|
||||
padding: 0 0.875rem;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: opacity 140ms ease;
|
||||
}
|
||||
|
||||
.lockedCta:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.lockedCta:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--ring);
|
||||
}
|
||||
|
||||
.collapsedRail {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem 0.5rem;
|
||||
}
|
||||
|
||||
.mainPanel {
|
||||
min-width: 0;
|
||||
min-height: 100vh;
|
||||
min-height: 100svh;
|
||||
height: 100dvh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.dialogOverlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
background: rgb(0 0 0 / 0.4);
|
||||
backdrop-filter: blur(2px);
|
||||
animation: dialogOverlayEnter 140ms ease-out;
|
||||
}
|
||||
|
||||
/* Confirm dialog — the V2 popover/card surface: white, hairline border,
|
||||
radius-xl, soft elevated shadow. */
|
||||
.dialog {
|
||||
width: 100%;
|
||||
max-width: 22rem;
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--card);
|
||||
color: var(--foreground);
|
||||
box-shadow: 0 20px 50px rgb(0 0 0 / 0.18);
|
||||
font-family: var(--font-body);
|
||||
animation: dialogEnter 160ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.dialogTitle {
|
||||
margin: 0 0 0.4rem;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.dialogDescription {
|
||||
margin: 0 0 1.1rem;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.dialogActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.dialogButton {
|
||||
min-height: 2.25rem;
|
||||
padding: 0.5rem 0.95rem;
|
||||
border: 0;
|
||||
border-radius: var(--radius);
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 140ms ease,
|
||||
color 140ms ease,
|
||||
opacity 140ms ease;
|
||||
}
|
||||
|
||||
.dialogButton:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--ring);
|
||||
}
|
||||
|
||||
.dialogButtonSecondary {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.dialogButtonSecondary:hover {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.dialogButtonPrimary {
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.dialogButtonPrimary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.dialogButtonDestructive {
|
||||
background: var(--destructive);
|
||||
color: var(--destructive-foreground);
|
||||
}
|
||||
|
||||
.dialogButtonDestructive:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
@keyframes dialogOverlayEnter {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dialogEnter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes threadItemEnter {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
background: var(--accent);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes generatedTitleReveal {
|
||||
0% {
|
||||
opacity: 0;
|
||||
filter: blur(6px);
|
||||
transform: translateY(4px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Tablet + phone: the threads panel goes off-canvas so the content and the
|
||||
(full-screen) chat get the whole width instead of squeezing into a column. */
|
||||
@media (max-width: 1024px) {
|
||||
.layout {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
/* The mounted drawer floats on mobile, so the first-paint placeholder must
|
||||
reserve no column (otherwise content shifts left when the drawer mounts). */
|
||||
.drawerPlaceholder {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* No-license locked panel: hide on mobile (no interactive drawer to launch,
|
||||
and a fixed-width panel leaves a dead column). */
|
||||
.lockedPanel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Collapsed: a small floating launcher pinned top-left, above the full-screen
|
||||
mobile chat (z-index 1200) so threads stay reachable over it. */
|
||||
.drawer.drawerClosed {
|
||||
position: fixed;
|
||||
top: 0.5rem;
|
||||
left: 0.5rem;
|
||||
width: auto;
|
||||
height: auto;
|
||||
/* Override the base drawer's full-viewport height + chrome so the closed
|
||||
state shrinks to a small floating launcher. */
|
||||
min-height: 0;
|
||||
border-right: 0;
|
||||
background: transparent;
|
||||
z-index: 1300;
|
||||
}
|
||||
|
||||
.drawerClosed .collapsedRail {
|
||||
flex-direction: row;
|
||||
width: auto;
|
||||
height: auto;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem;
|
||||
overflow: visible;
|
||||
border-radius: 999px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 0.16);
|
||||
}
|
||||
|
||||
/* Open: full-height off-canvas panel from the left, above the chat. */
|
||||
.drawer.drawerOpen {
|
||||
position: fixed;
|
||||
inset: 0 auto 0 0;
|
||||
z-index: 1300;
|
||||
width: min(20rem, 92vw);
|
||||
box-shadow: 0 20px 50px rgb(0 0 0 / 0.25);
|
||||
}
|
||||
|
||||
.mainPanel {
|
||||
grid-column: 1;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,586 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useThreads } from "@copilotkit/react-core/v2";
|
||||
import styles from "./threads-drawer.module.css";
|
||||
|
||||
export interface ThreadsDrawerProps {
|
||||
agentId: string;
|
||||
threadId: string | undefined;
|
||||
onThreadChange: (threadId: string | undefined) => void;
|
||||
}
|
||||
|
||||
interface DrawerThread {
|
||||
id: string;
|
||||
name: string | null;
|
||||
updatedAt: string;
|
||||
archived: boolean;
|
||||
lastRunAt?: string;
|
||||
}
|
||||
|
||||
const THREAD_ENTRY_ANIMATION_MS = 420;
|
||||
const TITLE_ANIMATION_MS = 360;
|
||||
const UNTITLED_THREAD_LABEL = "New thread";
|
||||
const RUNTIME_BASE_PATH = "/api/copilotkit";
|
||||
|
||||
function formatThreadTimestamp(updatedAt: string): string {
|
||||
const timestamp = new Date(updatedAt);
|
||||
if (Number.isNaN(timestamp.getTime())) return "Updated recently";
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(timestamp);
|
||||
}
|
||||
|
||||
function cx(...classNames: Array<string | false | undefined>): string {
|
||||
return classNames.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
export default function ThreadsDrawer({
|
||||
agentId,
|
||||
threadId,
|
||||
onThreadChange,
|
||||
}: ThreadsDrawerProps) {
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
// Start collapsed on narrow screens (tablet + phone) so the panel — which
|
||||
// becomes an off-canvas overlay below 1024px — doesn't cover the content +
|
||||
// chat on load. The drawer is client-mounted, so reading window here is safe
|
||||
// and won't cause a hydration mismatch.
|
||||
const [isOpen, setIsOpen] = useState(
|
||||
() => typeof window === "undefined" || window.innerWidth > 1024,
|
||||
);
|
||||
const [pendingDelete, setPendingDelete] = useState<{
|
||||
id: string;
|
||||
title: string;
|
||||
} | null>(null);
|
||||
const deleteTriggerRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const {
|
||||
threads,
|
||||
archiveThread,
|
||||
deleteThread,
|
||||
error,
|
||||
isLoading,
|
||||
hasMoreThreads,
|
||||
isFetchingMoreThreads,
|
||||
fetchMoreThreads,
|
||||
} = useThreads({
|
||||
agentId,
|
||||
includeArchived: showArchived,
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
const restoreThread = useCallback(
|
||||
async (id: string) => {
|
||||
const response = await fetch(
|
||||
`${RUNTIME_BASE_PATH}/threads/${encodeURIComponent(id)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ agentId, archived: false }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Restore failed: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
[agentId],
|
||||
);
|
||||
|
||||
const hasMountedRef = useRef(false);
|
||||
const hasLoadedOnceRef = useRef(false);
|
||||
const stableThreadsRef = useRef<DrawerThread[]>(threads);
|
||||
const previousThreadIdsRef = useRef<Set<string>>(new Set());
|
||||
const previousNamesRef = useRef<Map<string, string | null>>(new Map());
|
||||
const entryTimeoutsRef = useRef<Map<string, number>>(new Map());
|
||||
const titleTimeoutsRef = useRef<Map<string, number>>(new Map());
|
||||
|
||||
if (!isLoading) {
|
||||
hasLoadedOnceRef.current = true;
|
||||
stableThreadsRef.current = threads;
|
||||
}
|
||||
const displayThreads: DrawerThread[] =
|
||||
isLoading && hasLoadedOnceRef.current ? stableThreadsRef.current : threads;
|
||||
const [enteringThreadIds, setEnteringThreadIds] = useState<
|
||||
Record<string, true>
|
||||
>({});
|
||||
const [revealedTitleIds, setRevealedTitleIds] = useState<
|
||||
Record<string, true>
|
||||
>({});
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
for (const timeoutId of entryTimeoutsRef.current.values()) {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
for (const timeoutId of titleTimeoutsRef.current.values()) {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Skip diffing while the store is refetching (e.g. after a filter change
|
||||
// clears the list). Otherwise every thread would be treated as newly
|
||||
// added once the new page lands.
|
||||
if (isLoading) return;
|
||||
|
||||
const nextThreadIds = new Set(threads.map((t) => t.id));
|
||||
|
||||
if (!hasMountedRef.current) {
|
||||
hasMountedRef.current = true;
|
||||
previousThreadIdsRef.current = nextThreadIds;
|
||||
previousNamesRef.current = new Map(threads.map((t) => [t.id, t.name]));
|
||||
return;
|
||||
}
|
||||
|
||||
const addedThreadIds = threads
|
||||
.filter((t) => !previousThreadIdsRef.current.has(t.id))
|
||||
.map((t) => t.id);
|
||||
|
||||
if (addedThreadIds.length > 0) {
|
||||
setEnteringThreadIds((current) => {
|
||||
const next = { ...current };
|
||||
for (const id of addedThreadIds) {
|
||||
next[id] = true;
|
||||
const existing = entryTimeoutsRef.current.get(id);
|
||||
if (existing !== undefined) window.clearTimeout(existing);
|
||||
const tid = window.setTimeout(() => {
|
||||
setEnteringThreadIds((s) => {
|
||||
const updated = { ...s };
|
||||
delete updated[id];
|
||||
return updated;
|
||||
});
|
||||
entryTimeoutsRef.current.delete(id);
|
||||
}, THREAD_ENTRY_ANIMATION_MS);
|
||||
entryTimeoutsRef.current.set(id, tid);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
const renamedThreadIds = threads
|
||||
.filter((t) => {
|
||||
// Only reveal when an already-tracked thread's name transitions from
|
||||
// null → named. Threads appearing for the first time (e.g. on a
|
||||
// filter switch) already have their final name and should not trigger
|
||||
// the title reveal animation — that would layer a blur/translateY
|
||||
// onto the row's enter animation and produce a visible jitter.
|
||||
if (!previousNamesRef.current.has(t.id)) return false;
|
||||
const prev = previousNamesRef.current.get(t.id) ?? null;
|
||||
return prev === null && t.name !== null;
|
||||
})
|
||||
.map((t) => t.id);
|
||||
|
||||
if (renamedThreadIds.length > 0) {
|
||||
setRevealedTitleIds((current) => {
|
||||
const next = { ...current };
|
||||
for (const id of renamedThreadIds) {
|
||||
next[id] = true;
|
||||
const existing = titleTimeoutsRef.current.get(id);
|
||||
if (existing !== undefined) window.clearTimeout(existing);
|
||||
const tid = window.setTimeout(() => {
|
||||
setRevealedTitleIds((s) => {
|
||||
const updated = { ...s };
|
||||
delete updated[id];
|
||||
return updated;
|
||||
});
|
||||
titleTimeoutsRef.current.delete(id);
|
||||
}, TITLE_ANIMATION_MS);
|
||||
titleTimeoutsRef.current.set(id, tid);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
previousThreadIdsRef.current = nextThreadIds;
|
||||
previousNamesRef.current = new Map(threads.map((t) => [t.id, t.name]));
|
||||
}, [threads, isLoading]);
|
||||
|
||||
const isInitialLoading = isLoading && !hasLoadedOnceRef.current;
|
||||
if (error) {
|
||||
console.error("Unable to load threads", error);
|
||||
}
|
||||
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<aside
|
||||
aria-label="Threads drawer"
|
||||
className={cx(styles.drawer, styles.drawerClosed)}
|
||||
>
|
||||
<div className={styles.collapsedRail}>
|
||||
{/* Native title here (not the styled ::after): the collapsed rail
|
||||
sits at the viewport's left edge where a centered tooltip clips. */}
|
||||
<button
|
||||
aria-label="Open threads drawer"
|
||||
title="Expand"
|
||||
className={styles.iconButton}
|
||||
type="button"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
<button
|
||||
aria-label="Create thread"
|
||||
title="New thread"
|
||||
className={styles.iconButton}
|
||||
type="button"
|
||||
onClick={() => onThreadChange(crypto.randomUUID())}
|
||||
>
|
||||
<Plus size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
const closeDeleteDialog = () => {
|
||||
setPendingDelete(null);
|
||||
const trigger = deleteTriggerRef.current;
|
||||
deleteTriggerRef.current = null;
|
||||
trigger?.focus?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside
|
||||
aria-label="Threads drawer"
|
||||
className={cx(styles.drawer, styles.drawerOpen)}
|
||||
>
|
||||
<div className={styles.drawerSurface}>
|
||||
<div className={styles.drawerHeader}>
|
||||
<div className={styles.drawerHeaderMain}>
|
||||
<h2 className={styles.drawerTitle}>Threads</h2>
|
||||
</div>
|
||||
<div className={styles.headerActions}>
|
||||
<button
|
||||
aria-label="Create thread"
|
||||
className={styles.newThreadButton}
|
||||
type="button"
|
||||
onClick={() => onThreadChange(crypto.randomUUID())}
|
||||
>
|
||||
<Plus size={14} />
|
||||
<span>New thread</span>
|
||||
</button>
|
||||
<button
|
||||
aria-label="Collapse threads drawer"
|
||||
className={styles.iconButton}
|
||||
type="button"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.filterBar}>
|
||||
<div
|
||||
aria-label="Thread filter"
|
||||
className={styles.segmented}
|
||||
role="tablist"
|
||||
>
|
||||
<button
|
||||
aria-selected={!showArchived}
|
||||
className={cx(
|
||||
styles.segmentedOption,
|
||||
!showArchived && styles.segmentedOptionActive,
|
||||
)}
|
||||
role="tab"
|
||||
type="button"
|
||||
onClick={() => setShowArchived(false)}
|
||||
>
|
||||
Active
|
||||
</button>
|
||||
<button
|
||||
aria-selected={showArchived}
|
||||
className={cx(
|
||||
styles.segmentedOption,
|
||||
showArchived && styles.segmentedOptionActive,
|
||||
)}
|
||||
role="tab"
|
||||
type="button"
|
||||
onClick={() => setShowArchived(true)}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.drawerContent}>
|
||||
{error ? (
|
||||
<div className={styles.emptyState}>
|
||||
<div className={styles.emptyCard}>
|
||||
<p className={styles.emptyTitle}>
|
||||
Couldn’t load threads
|
||||
</p>
|
||||
<p className={styles.emptyMessage}>
|
||||
The thread list failed to load. Try reloading the page.
|
||||
</p>
|
||||
<button
|
||||
className={styles.loadMoreButton}
|
||||
type="button"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : isInitialLoading ? (
|
||||
<div
|
||||
aria-busy="true"
|
||||
aria-label="Loading threads"
|
||||
className={styles.loadingList}
|
||||
role="status"
|
||||
>
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className={styles.loadingRow}>
|
||||
<span className={styles.loadingAccent} />
|
||||
<span className={styles.loadingBody}>
|
||||
<span className={styles.loadingTitleBar} />
|
||||
<span className={styles.loadingMetaBar} />
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : displayThreads.length === 0 ? (
|
||||
<div className={styles.emptyState}>
|
||||
<div className={styles.emptyCard}>
|
||||
<p className={styles.emptyTitle}>No threads yet</p>
|
||||
<p className={styles.emptyMessage}>
|
||||
Create a thread to start a fresh conversation.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.threadList}>
|
||||
{displayThreads.map((thread) => {
|
||||
const hasTitle = thread.name !== null;
|
||||
const title = thread.name ?? UNTITLED_THREAD_LABEL;
|
||||
|
||||
return (
|
||||
<div key={thread.id} className={styles.threadRow}>
|
||||
<button
|
||||
aria-current={
|
||||
threadId === thread.id ? "page" : undefined
|
||||
}
|
||||
className={cx(
|
||||
styles.threadItem,
|
||||
threadId === thread.id && styles.threadItemSelected,
|
||||
enteringThreadIds[thread.id] &&
|
||||
styles.threadItemAnimatingIn,
|
||||
thread.archived && styles.threadItemArchived,
|
||||
)}
|
||||
type="button"
|
||||
onClick={() => onThreadChange(thread.id)}
|
||||
>
|
||||
<span aria-hidden className={styles.threadAccent} />
|
||||
<span className={styles.threadBody}>
|
||||
<span
|
||||
className={cx(
|
||||
styles.threadTitle,
|
||||
!hasTitle && styles.threadTitlePlaceholder,
|
||||
revealedTitleIds[thread.id] &&
|
||||
styles.threadTitleAnimated,
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
{thread.archived && (
|
||||
<span className={styles.archivedBadge}>
|
||||
Archived
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className={styles.threadMeta}>
|
||||
{formatThreadTimestamp(
|
||||
thread.lastRunAt ?? thread.updatedAt,
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<div className={styles.threadActions}>
|
||||
{thread.archived ? (
|
||||
<button
|
||||
aria-label={`Restore ${title}`}
|
||||
className={cx(
|
||||
styles.iconButton,
|
||||
styles.threadActionButton,
|
||||
styles.tooltip,
|
||||
)}
|
||||
data-tooltip="Restore thread"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
restoreThread(thread.id).catch((err: unknown) => {
|
||||
console.error("Unable to restore thread", err);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ArchiveRestore size={14} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
aria-label={`Archive ${title}`}
|
||||
className={cx(
|
||||
styles.iconButton,
|
||||
styles.threadActionButton,
|
||||
styles.tooltip,
|
||||
)}
|
||||
data-tooltip="Archive thread"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (threadId === thread.id)
|
||||
onThreadChange(undefined);
|
||||
archiveThread(thread.id).catch((err: unknown) => {
|
||||
console.error("Unable to archive thread", err);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Archive size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
aria-label={`Delete ${title}`}
|
||||
className={cx(
|
||||
styles.iconButton,
|
||||
styles.threadActionButton,
|
||||
styles.deleteButton,
|
||||
styles.tooltip,
|
||||
)}
|
||||
data-tooltip="Delete thread"
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
deleteTriggerRef.current = e.currentTarget;
|
||||
setPendingDelete({ id: thread.id, title });
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{hasMoreThreads && (
|
||||
<button
|
||||
className={styles.loadMoreButton}
|
||||
disabled={isFetchingMoreThreads}
|
||||
type="button"
|
||||
onClick={fetchMoreThreads}
|
||||
>
|
||||
{isFetchingMoreThreads ? "Loading\u2026" : "Load more"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
{pendingDelete && (
|
||||
<ConfirmDialog
|
||||
confirmLabel="Delete"
|
||||
description={`Delete "${pendingDelete.title}"? This cannot be undone.`}
|
||||
destructive
|
||||
title="Delete thread"
|
||||
onCancel={closeDeleteDialog}
|
||||
onConfirm={() => {
|
||||
const { id } = pendingDelete;
|
||||
closeDeleteDialog();
|
||||
if (threadId === id) onThreadChange(undefined);
|
||||
deleteThread(id).catch((err: unknown) => {
|
||||
console.error("Unable to delete thread", err);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
title: string;
|
||||
description: string;
|
||||
confirmLabel: string;
|
||||
cancelLabel?: string;
|
||||
destructive?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function ConfirmDialog({
|
||||
title,
|
||||
description,
|
||||
confirmLabel,
|
||||
cancelLabel = "Cancel",
|
||||
destructive = false,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmDialogProps) {
|
||||
const titleId = useId();
|
||||
const descId = useId();
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onCancel();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onCancel]);
|
||||
|
||||
if (typeof document === "undefined") return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={styles.dialogOverlay}
|
||||
role="presentation"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<div
|
||||
aria-describedby={descId}
|
||||
aria-labelledby={titleId}
|
||||
aria-modal="true"
|
||||
className={styles.dialog}
|
||||
role="dialog"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className={styles.dialogTitle} id={titleId}>
|
||||
{title}
|
||||
</h3>
|
||||
<p className={styles.dialogDescription} id={descId}>
|
||||
{description}
|
||||
</p>
|
||||
<div className={styles.dialogActions}>
|
||||
<button
|
||||
autoFocus
|
||||
className={cx(styles.dialogButton, styles.dialogButtonSecondary)}
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
className={cx(
|
||||
styles.dialogButton,
|
||||
destructive
|
||||
? styles.dialogButtonDestructive
|
||||
: styles.dialogButtonPrimary,
|
||||
)}
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,58 @@
|
||||
@import "tailwindcss";
|
||||
@config "../tailwind.config.ts";
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
|
||||
--radius: 0.625rem;
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
|
||||
--font-body: Arial, Helvetica, sans-serif;
|
||||
--font-code: "SFMono-Regular", Menlo, monospace;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply min-h-screen bg-background text-foreground font-sans antialiased;
|
||||
}
|
||||
}
|
||||
|
||||
.threadsLayout,
|
||||
body > [role="presentation"] {
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--background: oklch(1 0 0);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Metadata } from "next";
|
||||
import localFont from "next/font/local";
|
||||
import "./globals.css";
|
||||
import "./a2ui-theme.css";
|
||||
import "@copilotkit/react-core/v2/styles.css";
|
||||
|
||||
const geistSans = localFont({
|
||||
src: "./fonts/GeistVF.woff",
|
||||
variable: "--font-geist-sans",
|
||||
});
|
||||
const geistMono = localFont({
|
||||
src: "./fonts/GeistMonoVF.woff",
|
||||
variable: "--font-geist-mono",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Google+Sans+Code&family=Google+Sans+Flex:opsz,wght,ROND@6..144,1..1000,100&family=Google+Sans:opsz,wght@17..18,400..700&display=block&family=IBM+Plex+Serif:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&display=swap"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Google+Symbols:opsz,wght,FILL,GRAD,ROND@20..48,100..700,0..1,-50..200,0..100&display=swap&icon_names=arrow_drop_down,check_circle,close,communication,content_copy,delete,draw,error,info,mobile_layout,pen_size_1,progress_activity,rectangle,send,upload,warning"
|
||||
/>
|
||||
</head>
|
||||
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CopilotChat,
|
||||
CopilotChatConfigurationProvider,
|
||||
CopilotKitProvider,
|
||||
} from "@copilotkit/react-core/v2";
|
||||
import { useState } from "react";
|
||||
import { a2uiV08Renderer } from "./components/a2ui-v0-8-renderer";
|
||||
import { ThreadsDrawer } from "./components/threads-drawer";
|
||||
import { ThreadsPanelGate } from "./components/threads-drawer/locked-state";
|
||||
import styles from "./components/threads-drawer/threads-drawer.module.css";
|
||||
import { theme } from "./theme";
|
||||
|
||||
// Disable static optimization for this page
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const activityRenderers = [a2uiV08Renderer];
|
||||
|
||||
export default function Home() {
|
||||
const [threadId, setThreadId] = useState<string | undefined>(undefined);
|
||||
|
||||
return (
|
||||
<CopilotKitProvider
|
||||
runtimeUrl="/api/copilotkit"
|
||||
showDevConsole="auto"
|
||||
useSingleEndpoint={false}
|
||||
a2ui={{ theme }}
|
||||
renderActivityMessages={activityRenderers}
|
||||
>
|
||||
<div className={`${styles.layout} threadsLayout`}>
|
||||
<ThreadsPanelGate>
|
||||
<ThreadsDrawer
|
||||
agentId="default"
|
||||
threadId={threadId}
|
||||
onThreadChange={setThreadId}
|
||||
/>
|
||||
</ThreadsPanelGate>
|
||||
<div className={styles.mainPanel}>
|
||||
<CopilotChatConfigurationProvider
|
||||
agentId="default"
|
||||
threadId={threadId}
|
||||
>
|
||||
<main
|
||||
className="h-full overflow-auto w-screen"
|
||||
style={{ minHeight: "100dvh" }}
|
||||
>
|
||||
<CopilotChat agentId="default" className="h-full" />
|
||||
</main>
|
||||
</CopilotChatConfigurationProvider>
|
||||
</div>
|
||||
</div>
|
||||
</CopilotKitProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
/*
|
||||
Copyright 2025 Google LLC
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { Styles, type Types } from "@a2ui/lit/0.8";
|
||||
|
||||
/** Elements */
|
||||
|
||||
const a = {
|
||||
"typography-f-sf": true,
|
||||
"typography-fs-n": true,
|
||||
"typography-w-500": true,
|
||||
"layout-as-n": true,
|
||||
"layout-dis-iflx": true,
|
||||
"layout-al-c": true,
|
||||
};
|
||||
|
||||
const audio = {
|
||||
"layout-w-100": true,
|
||||
};
|
||||
|
||||
const body = {
|
||||
"typography-f-s": true,
|
||||
"typography-fs-n": true,
|
||||
"typography-w-400": true,
|
||||
"layout-mt-0": true,
|
||||
"layout-mb-2": true,
|
||||
"typography-sz-bm": true,
|
||||
"color-c-n10": true,
|
||||
};
|
||||
|
||||
const button = {
|
||||
"typography-f-sf": true,
|
||||
"typography-fs-n": true,
|
||||
"typography-w-500": true,
|
||||
"layout-pt-3": true,
|
||||
"layout-pb-3": true,
|
||||
"layout-pl-5": true,
|
||||
"layout-pr-5": true,
|
||||
"layout-mb-1": true,
|
||||
"border-br-16": true,
|
||||
"border-bw-0": true,
|
||||
"border-c-n70": true,
|
||||
"border-bs-s": true,
|
||||
"color-bgc-s30": true,
|
||||
"color-c-n100": true,
|
||||
"behavior-ho-80": true,
|
||||
};
|
||||
|
||||
const heading = {
|
||||
"typography-f-sf": true,
|
||||
"typography-fs-n": true,
|
||||
"typography-w-500": true,
|
||||
"layout-mt-0": true,
|
||||
"layout-mb-2": true,
|
||||
"color-c-n10": true,
|
||||
};
|
||||
|
||||
const h1 = {
|
||||
...heading,
|
||||
"typography-sz-tl": true,
|
||||
};
|
||||
|
||||
const h2 = {
|
||||
...heading,
|
||||
"typography-sz-tm": true,
|
||||
};
|
||||
|
||||
const h3 = {
|
||||
...heading,
|
||||
"typography-sz-ts": true,
|
||||
};
|
||||
|
||||
const h4 = {
|
||||
...heading,
|
||||
"typography-sz-bl": true,
|
||||
};
|
||||
|
||||
const h5 = {
|
||||
...heading,
|
||||
"typography-sz-bm": true,
|
||||
};
|
||||
|
||||
const iframe = {
|
||||
"behavior-sw-n": true,
|
||||
};
|
||||
|
||||
const input = {
|
||||
"typography-f-sf": true,
|
||||
"typography-fs-n": true,
|
||||
"typography-w-400": true,
|
||||
"layout-pl-4": true,
|
||||
"layout-pr-4": true,
|
||||
"layout-pt-2": true,
|
||||
"layout-pb-2": true,
|
||||
"border-br-6": true,
|
||||
"border-bw-1": true,
|
||||
"color-bc-s70": true,
|
||||
"border-bs-s": true,
|
||||
"layout-as-n": true,
|
||||
"color-c-n10": true,
|
||||
};
|
||||
|
||||
const p = {
|
||||
"typography-f-s": true,
|
||||
"typography-fs-n": true,
|
||||
"typography-w-400": true,
|
||||
"layout-m-0": true,
|
||||
"typography-sz-bm": true,
|
||||
"layout-as-n": true,
|
||||
"color-c-n10": true,
|
||||
};
|
||||
|
||||
const orderedList = {
|
||||
"typography-f-s": true,
|
||||
"typography-fs-n": true,
|
||||
"typography-w-400": true,
|
||||
"layout-m-0": true,
|
||||
"typography-sz-bm": true,
|
||||
"layout-as-n": true,
|
||||
};
|
||||
|
||||
const unorderedList = {
|
||||
"typography-f-s": true,
|
||||
"typography-fs-n": true,
|
||||
"typography-w-400": true,
|
||||
"layout-m-0": true,
|
||||
"typography-sz-bm": true,
|
||||
"layout-as-n": true,
|
||||
};
|
||||
|
||||
const listItem = {
|
||||
"typography-f-s": true,
|
||||
"typography-fs-n": true,
|
||||
"typography-w-400": true,
|
||||
"layout-m-0": true,
|
||||
"typography-sz-bm": true,
|
||||
"layout-as-n": true,
|
||||
};
|
||||
|
||||
const pre = {
|
||||
"typography-f-c": true,
|
||||
"typography-fs-n": true,
|
||||
"typography-w-400": true,
|
||||
"typography-sz-bm": true,
|
||||
"typography-ws-p": true,
|
||||
"layout-as-n": true,
|
||||
};
|
||||
|
||||
const textarea = {
|
||||
...input,
|
||||
"layout-r-none": true,
|
||||
"layout-fs-c": true,
|
||||
};
|
||||
|
||||
const video = {
|
||||
"layout-el-cv": true,
|
||||
};
|
||||
|
||||
const aLight = Styles.merge(a, { "color-c-n5": true });
|
||||
const inputLight = Styles.merge(input, { "color-c-n5": true });
|
||||
const textareaLight = Styles.merge(textarea, { "color-c-n5": true });
|
||||
const buttonLight = Styles.merge(button, { "color-c-n100": true });
|
||||
const h1Light = Styles.merge(h1, { "color-c-n5": true });
|
||||
const h2Light = Styles.merge(h2, { "color-c-n5": true });
|
||||
const h3Light = Styles.merge(h3, { "color-c-n5": true });
|
||||
const h4Light = Styles.merge(h4, { "color-c-n5": true });
|
||||
const h5Light = Styles.merge(h5, { "color-c-n5": true });
|
||||
const bodyLight = Styles.merge(body, { "color-c-n5": true });
|
||||
const pLight = Styles.merge(p, { "color-c-n35": true });
|
||||
const preLight = Styles.merge(pre, { "color-c-n35": true });
|
||||
const orderedListLight = Styles.merge(orderedList, {
|
||||
"color-c-n35": true,
|
||||
});
|
||||
const unorderedListLight = Styles.merge(unorderedList, {
|
||||
"color-c-n35": true,
|
||||
});
|
||||
const listItemLight = Styles.merge(listItem, {
|
||||
"color-c-n35": true,
|
||||
});
|
||||
|
||||
export const theme: Types.Theme = {
|
||||
additionalStyles: {
|
||||
Button: {
|
||||
"--n-35": "var(--n-100)",
|
||||
},
|
||||
},
|
||||
components: {
|
||||
AudioPlayer: {},
|
||||
Button: {
|
||||
"layout-pt-2": true,
|
||||
"layout-pb-2": true,
|
||||
"layout-pl-3": true,
|
||||
"layout-pr-3": true,
|
||||
"border-br-12": true,
|
||||
"border-bw-0": true,
|
||||
"border-bs-s": true,
|
||||
"color-bgc-p30": true,
|
||||
"color-c-n100": true,
|
||||
"behavior-ho-70": true,
|
||||
},
|
||||
Card: { "border-br-9": true, "color-bgc-p100": true, "layout-p-4": true },
|
||||
CheckBox: {
|
||||
element: {
|
||||
"layout-m-0": true,
|
||||
"layout-mr-2": true,
|
||||
"layout-p-2": true,
|
||||
"border-br-12": true,
|
||||
"border-bw-1": true,
|
||||
"border-bs-s": true,
|
||||
"color-bgc-p100": true,
|
||||
"color-bc-p60": true,
|
||||
"color-c-n30": true,
|
||||
"color-c-p30": true,
|
||||
},
|
||||
label: {
|
||||
"color-c-p30": true,
|
||||
"typography-f-sf": true,
|
||||
"typography-v-r": true,
|
||||
"typography-w-400": true,
|
||||
"layout-flx-1": true,
|
||||
"typography-sz-ll": true,
|
||||
},
|
||||
container: {
|
||||
"layout-dsp-iflex": true,
|
||||
"layout-al-c": true,
|
||||
},
|
||||
},
|
||||
Column: {
|
||||
"layout-g-2": true,
|
||||
},
|
||||
DateTimeInput: {
|
||||
container: {
|
||||
"typography-sz-bm": true,
|
||||
"layout-w-100": true,
|
||||
"layout-g-2": true,
|
||||
"layout-dsp-flexhor": true,
|
||||
"layout-al-c": true,
|
||||
},
|
||||
label: {
|
||||
"layout-flx-0": true,
|
||||
},
|
||||
element: {
|
||||
"layout-pt-2": true,
|
||||
"layout-pb-2": true,
|
||||
"layout-pl-3": true,
|
||||
"layout-pr-3": true,
|
||||
"border-br-12": true,
|
||||
"border-bw-1": true,
|
||||
"border-bs-s": true,
|
||||
"color-bgc-p100": true,
|
||||
"color-bc-p60": true,
|
||||
"color-c-n30": true,
|
||||
"color-c-p30": true,
|
||||
},
|
||||
},
|
||||
Divider: {},
|
||||
Image: {
|
||||
all: {
|
||||
"border-br-5": true,
|
||||
"layout-el-cv": true,
|
||||
"layout-w-100": true,
|
||||
"layout-h-100": true,
|
||||
},
|
||||
avatar: {},
|
||||
header: {},
|
||||
icon: {},
|
||||
largeFeature: {},
|
||||
mediumFeature: {},
|
||||
smallFeature: {},
|
||||
},
|
||||
Icon: {},
|
||||
List: {
|
||||
"layout-g-4": true,
|
||||
"layout-p-2": true,
|
||||
},
|
||||
Modal: {
|
||||
backdrop: { "color-bbgc-p60_20": true },
|
||||
element: {
|
||||
"border-br-2": true,
|
||||
"color-bgc-p100": true,
|
||||
"layout-p-4": true,
|
||||
"border-bw-1": true,
|
||||
"border-bs-s": true,
|
||||
"color-bc-p80": true,
|
||||
},
|
||||
},
|
||||
MultipleChoice: {
|
||||
container: {},
|
||||
label: {},
|
||||
element: {},
|
||||
},
|
||||
Row: {
|
||||
"layout-g-4": true,
|
||||
},
|
||||
Slider: {
|
||||
container: {},
|
||||
label: {},
|
||||
element: {},
|
||||
},
|
||||
Tabs: {
|
||||
container: {},
|
||||
controls: { all: {}, selected: {} },
|
||||
element: {},
|
||||
},
|
||||
Text: {
|
||||
all: {
|
||||
"layout-w-100": true,
|
||||
"layout-g-2": true,
|
||||
"color-c-p30": true,
|
||||
},
|
||||
h1: {
|
||||
"typography-f-sf": true,
|
||||
"typography-v-r": true,
|
||||
"typography-w-400": true,
|
||||
"layout-m-0": true,
|
||||
"layout-p-0": true,
|
||||
"typography-sz-tl": true,
|
||||
},
|
||||
h2: {
|
||||
"typography-f-sf": true,
|
||||
"typography-v-r": true,
|
||||
"typography-w-400": true,
|
||||
"layout-m-0": true,
|
||||
"layout-p-0": true,
|
||||
"typography-sz-tm": true,
|
||||
},
|
||||
h3: {
|
||||
"typography-f-sf": true,
|
||||
"typography-v-r": true,
|
||||
"typography-w-400": true,
|
||||
"layout-m-0": true,
|
||||
"layout-p-0": true,
|
||||
"typography-sz-ts": true,
|
||||
},
|
||||
h4: {
|
||||
"typography-f-sf": true,
|
||||
"typography-v-r": true,
|
||||
"typography-w-400": true,
|
||||
"layout-m-0": true,
|
||||
"layout-p-0": true,
|
||||
"typography-sz-bl": true,
|
||||
},
|
||||
h5: {
|
||||
"typography-f-sf": true,
|
||||
"typography-v-r": true,
|
||||
"typography-w-400": true,
|
||||
"layout-m-0": true,
|
||||
"layout-p-0": true,
|
||||
"typography-sz-bm": true,
|
||||
},
|
||||
body: {},
|
||||
caption: {},
|
||||
},
|
||||
TextField: {
|
||||
container: {
|
||||
"typography-sz-bm": true,
|
||||
"layout-w-100": true,
|
||||
"layout-g-2": true,
|
||||
"layout-dsp-flexhor": true,
|
||||
"layout-al-c": true,
|
||||
},
|
||||
label: {
|
||||
"layout-flx-0": true,
|
||||
},
|
||||
element: {
|
||||
"typography-sz-bm": true,
|
||||
"layout-pt-2": true,
|
||||
"layout-pb-2": true,
|
||||
"layout-pl-3": true,
|
||||
"layout-pr-3": true,
|
||||
"border-br-12": true,
|
||||
"border-bw-1": true,
|
||||
"border-bs-s": true,
|
||||
"color-bgc-p100": true,
|
||||
"color-bc-p60": true,
|
||||
"color-c-n30": true,
|
||||
"color-c-p30": true,
|
||||
},
|
||||
},
|
||||
Video: {
|
||||
"border-br-5": true,
|
||||
"layout-el-cv": true,
|
||||
},
|
||||
},
|
||||
elements: {
|
||||
a: aLight,
|
||||
audio,
|
||||
body: bodyLight,
|
||||
button: buttonLight,
|
||||
h1: h1Light,
|
||||
h2: h2Light,
|
||||
h3: h3Light,
|
||||
h4: h4Light,
|
||||
h5: h5Light,
|
||||
iframe,
|
||||
input: inputLight,
|
||||
p: pLight,
|
||||
pre: preLight,
|
||||
textarea: textareaLight,
|
||||
video,
|
||||
},
|
||||
markdown: {
|
||||
p: Object.keys(pLight),
|
||||
h1: Object.keys(h1Light),
|
||||
h2: Object.keys(h2Light),
|
||||
h3: Object.keys(h3Light),
|
||||
h4: Object.keys(h4Light),
|
||||
h5: Object.keys(h5Light),
|
||||
ul: Object.keys(unorderedListLight),
|
||||
ol: Object.keys(orderedListLight),
|
||||
li: Object.keys(listItemLight),
|
||||
a: Object.keys(aLight),
|
||||
strong: [],
|
||||
em: [],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: "standalone",
|
||||
serverExternalPackages: ["@copilotkit/runtime"],
|
||||
env: {
|
||||
NEXT_PUBLIC_COPILOTKIT_THREADS_ENABLED: process.env.COPILOTKIT_LICENSE_TOKEN
|
||||
? "true"
|
||||
: "false",
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "with-a2a-a2ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm run dev:ui\" \"npm run dev:agent\" --names ui,agent --prefix-colors blue,green --kill-others",
|
||||
"dev:debug": "LOG_LEVEL=debug npm run dev",
|
||||
"dev:agent": "./scripts/run-agent.sh || scripts\\run-agent.bat",
|
||||
"dev:ui": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"install:agent": "./scripts/setup-agent.sh || scripts\\setup-agent.bat",
|
||||
"postinstall": "npm run install:agent"
|
||||
},
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "0.2.5",
|
||||
"@a2ui/lit": "^0.8.1",
|
||||
"@ag-ui/a2a": "0.00.6",
|
||||
"@ag-ui/client": "0.0.57",
|
||||
"@ag-ui/core": "0.0.57",
|
||||
"@copilotkit/a2ui-renderer": "1.61.0",
|
||||
"@copilotkit/react-core": "1.61.0",
|
||||
"@copilotkit/runtime": "1.61.0",
|
||||
"hono": "^4.6.18",
|
||||
"lucide-react": "^0.577.0",
|
||||
"next": "^16.0.10",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"zod": "^3.25.75"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.13",
|
||||
"@types/node": "^22.15.3",
|
||||
"@types/react": "19.2.3",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"concurrently": "^9.2.1",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^4.1.13",
|
||||
"typescript": "5.9.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.5 13.5V6.5V5.41421C14.5 5.149 14.3946 4.89464 14.2071 4.70711L9.79289 0.292893C9.60536 0.105357 9.351 0 9.08579 0H8H3H1.5V1.5V13.5C1.5 14.8807 2.61929 16 4 16H12C13.3807 16 14.5 14.8807 14.5 13.5ZM13 13.5V6.5H9.5H8V5V1.5H3V13.5C3 14.0523 3.44772 14.5 4 14.5H12C12.5523 14.5 13 14.0523 13 13.5ZM9.5 5V2.12132L12.3787 5H9.5ZM5.13 5.00062H4.505V6.25062H5.13H6H6.625V5.00062H6H5.13ZM4.505 8H5.13H11H11.625V9.25H11H5.13H4.505V8ZM5.13 11H4.505V12.25H5.13H11H11.625V11H11H5.13Z" fill="#666666"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 645 B |
@@ -0,0 +1,10 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_868_525)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.268 14.0934C11.9051 13.4838 13.2303 12.2333 13.9384 10.6469C13.1192 10.7941 12.2138 10.9111 11.2469 10.9925C11.0336 12.2005 10.695 13.2621 10.268 14.0934ZM8 16C12.4183 16 16 12.4183 16 8C16 3.58172 12.4183 0 8 0C3.58172 0 0 3.58172 0 8C0 12.4183 3.58172 16 8 16ZM8.48347 14.4823C8.32384 14.494 8.16262 14.5 8 14.5C7.83738 14.5 7.67616 14.494 7.51654 14.4823C7.5132 14.4791 7.50984 14.4759 7.50647 14.4726C7.2415 14.2165 6.94578 13.7854 6.67032 13.1558C6.41594 12.5744 6.19979 11.8714 6.04101 11.0778C6.67605 11.1088 7.33104 11.125 8 11.125C8.66896 11.125 9.32395 11.1088 9.95899 11.0778C9.80021 11.8714 9.58406 12.5744 9.32968 13.1558C9.05422 13.7854 8.7585 14.2165 8.49353 14.4726C8.49016 14.4759 8.4868 14.4791 8.48347 14.4823ZM11.4187 9.72246C12.5137 9.62096 13.5116 9.47245 14.3724 9.28806C14.4561 8.87172 14.5 8.44099 14.5 8C14.5 7.55901 14.4561 7.12828 14.3724 6.71194C13.5116 6.52755 12.5137 6.37904 11.4187 6.27753C11.4719 6.83232 11.5 7.40867 11.5 8C11.5 8.59133 11.4719 9.16768 11.4187 9.72246ZM10.1525 6.18401C10.2157 6.75982 10.25 7.36805 10.25 8C10.25 8.63195 10.2157 9.24018 10.1525 9.81598C9.46123 9.85455 8.7409 9.875 8 9.875C7.25909 9.875 6.53877 9.85455 5.84749 9.81598C5.7843 9.24018 5.75 8.63195 5.75 8C5.75 7.36805 5.7843 6.75982 5.84749 6.18401C6.53877 6.14545 7.25909 6.125 8 6.125C8.74091 6.125 9.46123 6.14545 10.1525 6.18401ZM11.2469 5.00748C12.2138 5.08891 13.1191 5.20593 13.9384 5.35306C13.2303 3.7667 11.9051 2.51622 10.268 1.90662C10.695 2.73788 11.0336 3.79953 11.2469 5.00748ZM8.48347 1.51771C8.4868 1.52089 8.49016 1.52411 8.49353 1.52737C8.7585 1.78353 9.05422 2.21456 9.32968 2.84417C9.58406 3.42562 9.80021 4.12856 9.95899 4.92219C9.32395 4.89118 8.66896 4.875 8 4.875C7.33104 4.875 6.67605 4.89118 6.04101 4.92219C6.19978 4.12856 6.41594 3.42562 6.67032 2.84417C6.94578 2.21456 7.2415 1.78353 7.50647 1.52737C7.50984 1.52411 7.51319 1.52089 7.51653 1.51771C7.67615 1.50597 7.83738 1.5 8 1.5C8.16262 1.5 8.32384 1.50597 8.48347 1.51771ZM5.73202 1.90663C4.0949 2.51622 2.76975 3.7667 2.06159 5.35306C2.88085 5.20593 3.78617 5.08891 4.75309 5.00748C4.96639 3.79953 5.30497 2.73788 5.73202 1.90663ZM4.58133 6.27753C3.48633 6.37904 2.48837 6.52755 1.62761 6.71194C1.54392 7.12828 1.5 7.55901 1.5 8C1.5 8.44099 1.54392 8.87172 1.62761 9.28806C2.48837 9.47245 3.48633 9.62096 4.58133 9.72246C4.52807 9.16768 4.5 8.59133 4.5 8C4.5 7.40867 4.52807 6.83232 4.58133 6.27753ZM4.75309 10.9925C3.78617 10.9111 2.88085 10.7941 2.06159 10.6469C2.76975 12.2333 4.0949 13.4838 5.73202 14.0934C5.30497 13.2621 4.96639 12.2005 4.75309 10.9925Z" fill="#666666"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_868_525">
|
||||
<rect width="16" height="16" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,19 @@
|
||||
<svg width="473" height="76" viewBox="0 0 473 76" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M130.998 30.6565V22.3773H91.0977V30.6565H106.16V58.1875H115.935V30.6565H130.998Z" fill="black"/>
|
||||
<path d="M153.542 58.7362C165.811 58.7362 172.544 52.5018 172.544 42.2275V22.3773H162.768V41.2799C162.768 47.0155 159.776 50.2574 153.542 50.2574C147.307 50.2574 144.315 47.0155 144.315 41.2799V22.3773H134.539V42.2275C134.539 52.5018 141.272 58.7362 153.542 58.7362Z" fill="black"/>
|
||||
<path d="M187.508 46.3173H197.234L204.914 58.1875H216.136L207.458 45.2699C212.346 43.5243 215.338 39.634 215.338 34.3473C215.338 26.6665 209.603 22.3773 200.874 22.3773H177.732V58.1875H187.508V46.3173ZM187.508 38.5867V30.5568H200.376C203.817 30.5568 205.712 32.053 205.712 34.5967C205.712 36.9907 203.817 38.5867 200.376 38.5867H187.508Z" fill="black"/>
|
||||
<path d="M219.887 58.1875H245.472C253.452 58.1875 258.041 54.397 258.041 48.0629C258.041 43.8235 255.348 40.9308 252.156 39.634C254.35 38.5867 257.043 36.0929 257.043 32.1528C257.043 25.8187 252.555 22.3773 244.625 22.3773H219.887V58.1875ZM229.263 36.3922V30.3074H243.627C246.32 30.3074 247.817 31.3548 247.817 33.3498C247.817 35.3448 246.32 36.3922 243.627 36.3922H229.263ZM229.263 43.7238H244.525C247.168 43.7238 248.615 45.0205 248.615 46.9657C248.615 48.9108 247.168 50.2075 244.525 50.2075H229.263V43.7238Z" fill="black"/>
|
||||
<path d="M281.942 21.7788C269.423 21.7788 260.396 29.6092 260.396 40.2824C260.396 50.9557 269.423 58.786 281.942 58.786C294.461 58.786 303.438 50.9557 303.438 40.2824C303.438 29.6092 294.461 21.7788 281.942 21.7788ZM281.942 30.2575C288.525 30.2575 293.463 34.1478 293.463 40.2824C293.463 46.417 288.525 50.3073 281.942 50.3073C275.359 50.3073 270.421 46.417 270.421 40.2824C270.421 34.1478 275.359 30.2575 281.942 30.2575Z" fill="black"/>
|
||||
<path d="M317.526 46.3173H327.251L334.932 58.1875H346.154L337.476 45.2699C342.364 43.5243 345.356 39.634 345.356 34.3473C345.356 26.6665 339.62 22.3773 330.892 22.3773H307.75V58.1875H317.526V46.3173ZM317.526 38.5867V30.5568H330.394C333.835 30.5568 335.73 32.053 335.73 34.5967C335.73 36.9907 333.835 38.5867 330.394 38.5867H317.526Z" fill="black"/>
|
||||
<path d="M349.904 22.3773V58.1875H384.717V49.9083H359.48V44.0729H381.874V35.9932H359.48V30.6565H384.717V22.3773H349.904Z" fill="black"/>
|
||||
<path d="M399.204 46.7662H412.221C420.95 46.7662 426.685 42.5767 426.685 34.5967C426.685 26.5668 420.95 22.3773 412.221 22.3773H389.428V58.1875H399.204V46.7662ZM399.204 38.6365V30.5568H411.673C415.164 30.5568 417.059 32.053 417.059 34.5967C417.059 37.0904 415.164 38.6365 411.673 38.6365H399.204Z" fill="black"/>
|
||||
<path d="M450.948 21.7788C438.43 21.7788 429.402 29.6092 429.402 40.2824C429.402 50.9557 438.43 58.786 450.948 58.786C463.467 58.786 472.444 50.9557 472.444 40.2824C472.444 29.6092 463.467 21.7788 450.948 21.7788ZM450.948 30.2575C457.532 30.2575 462.469 34.1478 462.469 40.2824C462.469 46.417 457.532 50.3073 450.948 50.3073C444.365 50.3073 439.427 46.417 439.427 40.2824C439.427 34.1478 444.365 30.2575 450.948 30.2575Z" fill="black"/>
|
||||
<path d="M38.5017 18.0956C27.2499 18.0956 18.0957 27.2498 18.0957 38.5016C18.0957 49.7534 27.2499 58.9076 38.5017 58.9076C49.7535 58.9076 58.9077 49.7534 58.9077 38.5016C58.9077 27.2498 49.7535 18.0956 38.5017 18.0956ZM38.5017 49.0618C32.6687 49.0618 27.9415 44.3346 27.9415 38.5016C27.9415 32.6686 32.6687 27.9414 38.5017 27.9414C44.3347 27.9414 49.0619 32.6686 49.0619 38.5016C49.0619 44.3346 44.3347 49.0618 38.5017 49.0618Z" fill="black"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M40.2115 14.744V7.125C56.7719 8.0104 69.9275 21.7208 69.9275 38.5016C69.9275 55.2824 56.7719 68.989 40.2115 69.8782V62.2592C52.5539 61.3776 62.3275 51.0644 62.3275 38.5016C62.3275 25.9388 52.5539 15.6256 40.2115 14.744ZM20.5048 54.0815C17.233 50.3043 15.124 45.4935 14.7478 40.2115H7.125C7.5202 47.6025 10.4766 54.3095 15.1088 59.4737L20.501 54.0815H20.5048ZM36.7916 69.8782V62.2592C31.5058 61.883 26.695 59.7778 22.9178 56.5022L17.5256 61.8944C22.6936 66.5304 29.4006 69.483 36.7878 69.8782H36.7916Z" fill="url(#paint0_linear_2028_278)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_2028_278" x1="41.443" y1="11.5372" x2="10.5567" y2="42.4236" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#0096FF"/>
|
||||
<stop offset="1" stop-color="#FF1E56"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,19 @@
|
||||
<svg width="473" height="76" viewBox="0 0 473 76" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M130.998 30.6566V22.3773H91.0977V30.6566H106.16V58.1876H115.935V30.6566H130.998Z" fill="white"/>
|
||||
<path d="M153.542 58.7362C165.811 58.7362 172.544 52.5018 172.544 42.2276V22.3773H162.768V41.2799C162.768 47.0156 159.776 50.2574 153.542 50.2574C147.307 50.2574 144.315 47.0156 144.315 41.2799V22.3773H134.539V42.2276C134.539 52.5018 141.272 58.7362 153.542 58.7362Z" fill="white"/>
|
||||
<path d="M187.508 46.3173H197.234L204.914 58.1876H216.136L207.458 45.2699C212.346 43.5243 215.338 39.6341 215.338 34.3473C215.338 26.6666 209.603 22.3773 200.874 22.3773H177.732V58.1876H187.508V46.3173ZM187.508 38.5867V30.5568H200.376C203.817 30.5568 205.712 32.0531 205.712 34.5967C205.712 36.9907 203.817 38.5867 200.376 38.5867H187.508Z" fill="white"/>
|
||||
<path d="M219.887 58.1876H245.472C253.452 58.1876 258.041 54.3971 258.041 48.0629C258.041 43.8236 255.348 40.9308 252.156 39.6341C254.35 38.5867 257.043 36.0929 257.043 32.1528C257.043 25.8187 252.555 22.3773 244.625 22.3773H219.887V58.1876ZM229.263 36.3922V30.3074H243.627C246.32 30.3074 247.817 31.3548 247.817 33.3498C247.817 35.3448 246.32 36.3922 243.627 36.3922H229.263ZM229.263 43.7238H244.525C247.168 43.7238 248.615 45.0206 248.615 46.9657C248.615 48.9108 247.168 50.2076 244.525 50.2076H229.263V43.7238Z" fill="white"/>
|
||||
<path d="M281.942 21.7788C269.423 21.7788 260.396 29.6092 260.396 40.2824C260.396 50.9557 269.423 58.7861 281.942 58.7861C294.461 58.7861 303.438 50.9557 303.438 40.2824C303.438 29.6092 294.461 21.7788 281.942 21.7788ZM281.942 30.2576C288.525 30.2576 293.463 34.1478 293.463 40.2824C293.463 46.4171 288.525 50.3073 281.942 50.3073C275.359 50.3073 270.421 46.4171 270.421 40.2824C270.421 34.1478 275.359 30.2576 281.942 30.2576Z" fill="white"/>
|
||||
<path d="M317.526 46.3173H327.251L334.932 58.1876H346.154L337.476 45.2699C342.364 43.5243 345.356 39.6341 345.356 34.3473C345.356 26.6666 339.62 22.3773 330.892 22.3773H307.75V58.1876H317.526V46.3173ZM317.526 38.5867V30.5568H330.394C333.835 30.5568 335.73 32.0531 335.73 34.5967C335.73 36.9907 333.835 38.5867 330.394 38.5867H317.526Z" fill="white"/>
|
||||
<path d="M349.904 22.3773V58.1876H384.717V49.9083H359.48V44.0729H381.874V35.9932H359.48V30.6566H384.717V22.3773H349.904Z" fill="white"/>
|
||||
<path d="M399.204 46.7662H412.221C420.95 46.7662 426.685 42.5767 426.685 34.5967C426.685 26.5668 420.95 22.3773 412.221 22.3773H389.428V58.1876H399.204V46.7662ZM399.204 38.6366V30.5568H411.673C415.164 30.5568 417.059 32.0531 417.059 34.5967C417.059 37.0904 415.164 38.6366 411.673 38.6366H399.204Z" fill="white"/>
|
||||
<path d="M450.948 21.7788C438.43 21.7788 429.402 29.6092 429.402 40.2824C429.402 50.9557 438.43 58.7861 450.948 58.7861C463.467 58.7861 472.444 50.9557 472.444 40.2824C472.444 29.6092 463.467 21.7788 450.948 21.7788ZM450.948 30.2576C457.532 30.2576 462.469 34.1478 462.469 40.2824C462.469 46.4171 457.532 50.3073 450.948 50.3073C444.365 50.3073 439.427 46.4171 439.427 40.2824C439.427 34.1478 444.365 30.2576 450.948 30.2576Z" fill="white"/>
|
||||
<path d="M38.5017 18.0956C27.2499 18.0956 18.0957 27.2498 18.0957 38.5016C18.0957 49.7534 27.2499 58.9076 38.5017 58.9076C49.7535 58.9076 58.9077 49.7534 58.9077 38.5016C58.9077 27.2498 49.7535 18.0956 38.5017 18.0956ZM38.5017 49.0618C32.6687 49.0618 27.9415 44.3346 27.9415 38.5016C27.9415 32.6686 32.6687 27.9414 38.5017 27.9414C44.3347 27.9414 49.0619 32.6686 49.0619 38.5016C49.0619 44.3346 44.3347 49.0618 38.5017 49.0618Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M40.2115 14.744V7.125C56.7719 8.0104 69.9275 21.7208 69.9275 38.5016C69.9275 55.2824 56.7719 68.989 40.2115 69.8782V62.2592C52.5539 61.3776 62.3275 51.0644 62.3275 38.5016C62.3275 25.9388 52.5539 15.6256 40.2115 14.744ZM20.5048 54.0815C17.233 50.3043 15.124 45.4935 14.7478 40.2115H7.125C7.5202 47.6025 10.4766 54.3095 15.1088 59.4737L20.501 54.0815H20.5048ZM36.7916 69.8782V62.2592C31.5058 61.883 26.695 59.7778 22.9178 56.5022L17.5256 61.8944C22.6936 66.5304 29.4006 69.483 36.7878 69.8782H36.7916Z" fill="url(#paint0_linear_2028_477)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_2028_477" x1="41.443" y1="11.5372" x2="10.5567" y2="42.4236" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#0096FF"/>
|
||||
<stop offset="1" stop-color="#FF1E56"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_977_547)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.5 3L18.5 17H2.5L10.5 3Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_977_547">
|
||||
<rect width="16" height="16" fill="white" transform="translate(2.5 2)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 367 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5H14.5V12.5C14.5 13.0523 14.0523 13.5 13.5 13.5H2.5C1.94772 13.5 1.5 13.0523 1.5 12.5V2.5ZM0 1H1.5H14.5H16V2.5V12.5C16 13.8807 14.8807 15 13.5 15H2.5C1.11929 15 0 13.8807 0 12.5V2.5V1ZM3.75 5.5C4.16421 5.5 4.5 5.16421 4.5 4.75C4.5 4.33579 4.16421 4 3.75 4C3.33579 4 3 4.33579 3 4.75C3 5.16421 3.33579 5.5 3.75 5.5ZM7 4.75C7 5.16421 6.66421 5.5 6.25 5.5C5.83579 5.5 5.5 5.16421 5.5 4.75C5.5 4.33579 5.83579 4 6.25 4C6.66421 4 7 4.33579 7 4.75ZM8.75 5.5C9.16421 5.5 9.5 5.16421 9.5 4.75C9.5 4.33579 9.16421 4 8.75 4C8.33579 4 8 4.33579 8 4.75C8 5.16421 8.33579 5.5 8.75 5.5Z" fill="#666666"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 750 B |
@@ -0,0 +1,13 @@
|
||||
[project]
|
||||
name = "with-a2a-a2ui"
|
||||
version = "0.1.0"
|
||||
description = "A top-level project to manage python libraries and samples."
|
||||
dependencies = []
|
||||
|
||||
[tool.uv.workspace]
|
||||
members = ["a2ui_extension", "agent"]
|
||||
|
||||
[tool.uv.sources]
|
||||
a2ui = { path = "a2ui_extension", editable = true }
|
||||
# The key must match the package name
|
||||
a2ui-restaurant-finder = { path = "agent", editable = true }
|
||||
@@ -0,0 +1,6 @@
|
||||
@echo off
|
||||
REM Navigate to the agent directory
|
||||
cd /d %~dp0\..\agent
|
||||
|
||||
REM Run the agent using uv
|
||||
uv run src/main.py
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Navigate to the agent directory
|
||||
cd "$(dirname "$0")/../agent" || exit 1
|
||||
|
||||
# Run the agent using uv
|
||||
uv run .
|
||||
@@ -0,0 +1,6 @@
|
||||
@echo off
|
||||
REM Navigate to the agent directory
|
||||
cd /d "%~dp0\..\agent" || exit /b 1
|
||||
|
||||
REM Install dependencies and create virtual environment using uv
|
||||
uv sync
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Navigate to the agent directory
|
||||
cd "$(dirname "$0")/../agent" || exit 1
|
||||
|
||||
# Install dependencies and create virtual environment using uv
|
||||
uv sync
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config = {
|
||||
content: [
|
||||
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"../../packages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
} satisfies Config;
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"esModuleInterop": true,
|
||||
"incremental": false,
|
||||
"isolatedModules": true,
|
||||
"lib": ["es2022", "DOM", "DOM.Iterable"],
|
||||
"moduleDetection": "force",
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"target": "ES2022",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowJs": true,
|
||||
"jsx": "react-jsx",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"next-env.d.ts",
|
||||
"next.config.js",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||