Some checks failed
Pre-commit / run (ubuntu-latest) (push) Has been cancelled
Deploy Sphinx documentation to Pages / build_en (ubuntu-latest, 3.10) (push) Has been cancelled
Deploy Sphinx documentation to Pages / build_zh (ubuntu-latest, 3.10) (push) Has been cancelled
Python Unittest Coverage / test (macos-15, 3.10) (push) Has been cancelled
Python Unittest Coverage / test (macos-15, 3.11) (push) Has been cancelled
Python Unittest Coverage / test (macos-15, 3.12) (push) Has been cancelled
Python Unittest Coverage / test (ubuntu-latest, 3.10) (push) Has been cancelled
Python Unittest Coverage / test (ubuntu-latest, 3.11) (push) Has been cancelled
Python Unittest Coverage / test (ubuntu-latest, 3.12) (push) Has been cancelled
Python Unittest Coverage / test (windows-latest, 3.10) (push) Has been cancelled
Python Unittest Coverage / test (windows-latest, 3.11) (push) Has been cancelled
Python Unittest Coverage / test (windows-latest, 3.12) (push) Has been cancelled
561 lines
14 KiB
TypeScript
561 lines
14 KiB
TypeScript
/*
|
|
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 { SignalWatcher } from "@lit-labs/signals";
|
|
import { provide } from "@lit/context";
|
|
import {
|
|
LitElement,
|
|
html,
|
|
css,
|
|
nothing,
|
|
HTMLTemplateResult,
|
|
unsafeCSS,
|
|
} from "lit";
|
|
import { customElement, state } from "lit/decorators.js";
|
|
import { theme as uiTheme } from "./theme/default-theme.js";
|
|
import { A2UIClient } from "./client.js";
|
|
import {
|
|
SnackbarAction,
|
|
SnackbarMessage,
|
|
SnackbarUUID,
|
|
SnackType,
|
|
} from "./types/types.js";
|
|
import { type Snackbar } from "./ui/snackbar.js";
|
|
import { repeat } from "lit/directives/repeat.js";
|
|
import { v0_8 } from "@a2ui/lit";
|
|
import * as UI from "@a2ui/lit/ui";
|
|
|
|
// App elements.
|
|
import "./ui/ui.js";
|
|
|
|
// Configurations
|
|
import { AppConfig } from "./configs/types.js";
|
|
import { config as restaurantConfig } from "./configs/restaurant.js";
|
|
import { config as contactsConfig } from "./configs/contacts.js";
|
|
import { styleMap } from "lit/directives/style-map.js";
|
|
|
|
const configs: Record<string, AppConfig> = {
|
|
restaurant: restaurantConfig,
|
|
contacts: contactsConfig,
|
|
};
|
|
|
|
@customElement("a2ui-shell")
|
|
export class A2UILayoutEditor extends SignalWatcher(LitElement) {
|
|
@provide({ context: UI.Context.themeContext })
|
|
accessor theme: v0_8.Types.Theme = uiTheme;
|
|
|
|
@state()
|
|
accessor #requesting = false;
|
|
|
|
@state()
|
|
accessor #error: string | null = null;
|
|
|
|
@state()
|
|
accessor #lastMessages: v0_8.Types.ServerToClientMessage[] = [];
|
|
|
|
@state()
|
|
accessor config: AppConfig = configs.restaurant;
|
|
|
|
@state()
|
|
accessor #loadingTextIndex = 0;
|
|
#loadingInterval: number | undefined;
|
|
|
|
static styles = [
|
|
unsafeCSS(v0_8.Styles.structuralStyles),
|
|
css`
|
|
* {
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
:host {
|
|
display: block;
|
|
max-width: 640px;
|
|
margin: 0 auto;
|
|
min-height: 100%;
|
|
color: light-dark(var(--n-10), var(--n-90));
|
|
font-family: var(--font-family);
|
|
}
|
|
|
|
#hero-img {
|
|
width: 100%;
|
|
max-width: 400px;
|
|
aspect-ratio: 1280/720;
|
|
height: auto;
|
|
margin-bottom: var(--bb-grid-size-6);
|
|
display: block;
|
|
margin: 0 auto;
|
|
background: var(--background-image-light) center center / contain
|
|
no-repeat;
|
|
}
|
|
|
|
#surfaces {
|
|
width: 100%;
|
|
max-width: 100svw;
|
|
padding: var(--bb-grid-size-3);
|
|
animation: fadeIn 1s cubic-bezier(0, 0, 0.3, 1) 0.3s backwards;
|
|
}
|
|
|
|
form {
|
|
display: flex;
|
|
flex-direction: column;
|
|
flex: 1;
|
|
gap: 16px;
|
|
align-items: center;
|
|
padding: 16px 0;
|
|
animation: fadeIn 1s cubic-bezier(0, 0, 0.3, 1) 1s backwards;
|
|
|
|
& h1 {
|
|
color: light-dark(var(--p-40), var(--n-90));
|
|
}
|
|
|
|
& > div {
|
|
display: flex;
|
|
flex: 1;
|
|
gap: 16px;
|
|
align-items: center;
|
|
width: 100%;
|
|
|
|
& > input {
|
|
display: block;
|
|
flex: 1;
|
|
border-radius: 32px;
|
|
padding: 16px 24px;
|
|
border: 1px solid var(--p-60);
|
|
background: light-dark(var(--n-100), var(--n-10));
|
|
font-size: 16px;
|
|
}
|
|
|
|
& > button {
|
|
display: flex;
|
|
align-items: center;
|
|
background: var(--p-40);
|
|
color: var(--n-100);
|
|
border: none;
|
|
padding: 8px 16px;
|
|
border-radius: 32px;
|
|
opacity: 0.5;
|
|
|
|
&:not([disabled]) {
|
|
cursor: pointer;
|
|
opacity: 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
.rotate {
|
|
animation: rotate 1s linear infinite;
|
|
}
|
|
|
|
.pending {
|
|
width: 100%;
|
|
min-height: 200px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
animation: fadeIn 1s cubic-bezier(0, 0, 0.3, 1) 0.3s backwards;
|
|
gap: 16px;
|
|
}
|
|
|
|
.spinner {
|
|
width: 48px;
|
|
height: 48px;
|
|
border: 4px solid rgba(255, 255, 255, 0.1);
|
|
border-left-color: var(--p-60);
|
|
border-radius: 50%;
|
|
animation: spin 1s linear infinite;
|
|
}
|
|
|
|
.theme-toggle {
|
|
padding: 0;
|
|
margin: 0;
|
|
border: none;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
position: fixed;
|
|
top: var(--bb-grid-size-3);
|
|
right: var(--bb-grid-size-4);
|
|
background: light-dark(var(--n-100), var(--n-0));
|
|
border-radius: 50%;
|
|
color: var(--p-30);
|
|
cursor: pointer;
|
|
width: 48px;
|
|
height: 48px;
|
|
font-size: 32px;
|
|
|
|
& .g-icon {
|
|
pointer-events: none;
|
|
|
|
&::before {
|
|
content: "dark_mode";
|
|
}
|
|
}
|
|
}
|
|
|
|
@container style(--color-scheme: dark) {
|
|
.theme-toggle .g-icon::before {
|
|
content: "light_mode";
|
|
color: var(--n-90);
|
|
}
|
|
|
|
#hero-img {
|
|
background-image: var(--background-image-dark);
|
|
}
|
|
}
|
|
|
|
@keyframes spin {
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
|
|
@keyframes pulse {
|
|
0% {
|
|
opacity: 0.6;
|
|
}
|
|
50% {
|
|
opacity: 1;
|
|
}
|
|
100% {
|
|
opacity: 0.6;
|
|
}
|
|
}
|
|
|
|
.error {
|
|
color: var(--e-40);
|
|
background-color: var(--e-95);
|
|
border: 1px solid var(--e-80);
|
|
padding: 16px;
|
|
border-radius: 8px;
|
|
}
|
|
|
|
@keyframes fadeIn {
|
|
from {
|
|
opacity: 0;
|
|
}
|
|
|
|
to {
|
|
opacity: 1;
|
|
}
|
|
}
|
|
|
|
@keyframes rotate {
|
|
from {
|
|
rotate: 0deg;
|
|
}
|
|
|
|
to {
|
|
rotate: 360deg;
|
|
}
|
|
}
|
|
`,
|
|
];
|
|
|
|
#processor = v0_8.Data.createSignalA2uiMessageProcessor();
|
|
#a2uiClient = new A2UIClient();
|
|
#snackbar: Snackbar | undefined = undefined;
|
|
#pendingSnackbarMessages: Array<{
|
|
message: SnackbarMessage;
|
|
replaceAll: boolean;
|
|
}> = [];
|
|
|
|
#maybeRenderError() {
|
|
if (!this.#error) return nothing;
|
|
|
|
return html`<div class="error">${this.#error}</div>`;
|
|
}
|
|
|
|
connectedCallback() {
|
|
super.connectedCallback();
|
|
|
|
// Load config from URL
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const appKey = urlParams.get("app") || "restaurant";
|
|
this.config = configs[appKey] || configs.restaurant;
|
|
|
|
// Apply the theme directly, which will use the Lit context.
|
|
if (this.config.theme) {
|
|
this.theme = this.config.theme;
|
|
}
|
|
|
|
window.document.title = this.config.title;
|
|
window.document.documentElement.style.setProperty(
|
|
"--background",
|
|
this.config.background
|
|
);
|
|
|
|
// Initialize client with configured URL
|
|
this.#a2uiClient = new A2UIClient(this.config.serverUrl);
|
|
}
|
|
|
|
render() {
|
|
return [
|
|
this.#renderThemeToggle(),
|
|
this.#maybeRenderForm(),
|
|
this.#maybeRenderData(),
|
|
this.#maybeRenderError(),
|
|
];
|
|
}
|
|
|
|
#renderThemeToggle() {
|
|
return html` <div>
|
|
<button
|
|
@click=${(evt: Event) => {
|
|
if (!(evt.target instanceof HTMLButtonElement)) return;
|
|
const { colorScheme } = window.getComputedStyle(evt.target);
|
|
if (colorScheme === "dark") {
|
|
document.body.classList.add("light");
|
|
document.body.classList.remove("dark");
|
|
} else {
|
|
document.body.classList.add("dark");
|
|
document.body.classList.remove("light");
|
|
}
|
|
}}
|
|
class="theme-toggle"
|
|
>
|
|
<span class="g-icon filled-heavy"></span>
|
|
</button>
|
|
</div>`;
|
|
}
|
|
|
|
#maybeRenderForm() {
|
|
if (this.#requesting) return nothing;
|
|
if (this.#lastMessages.length > 0) return nothing;
|
|
|
|
return html` <form
|
|
@submit=${async (evt: Event) => {
|
|
evt.preventDefault();
|
|
if (!(evt.target instanceof HTMLFormElement)) {
|
|
return;
|
|
}
|
|
const data = new FormData(evt.target);
|
|
const body = data.get("body") ?? null;
|
|
if (!body) {
|
|
return;
|
|
}
|
|
const message = body as v0_8.Types.A2UIClientEventMessage;
|
|
await this.#sendAndProcessMessage(message);
|
|
}}
|
|
>
|
|
${this.config.heroImage
|
|
? html`<div
|
|
style=${styleMap({
|
|
"--background-image-light": `url(${this.config.heroImage})`,
|
|
"--background-image-dark": `url(${this.config.heroImageDark ?? this.config.heroImage
|
|
})`,
|
|
})}
|
|
id="hero-img"
|
|
></div>`
|
|
: nothing}
|
|
<h1 class="app-title">${this.config.title}</h1>
|
|
<div>
|
|
<input
|
|
required
|
|
value="${this.config.placeholder}"
|
|
autocomplete="off"
|
|
id="body"
|
|
name="body"
|
|
type="text"
|
|
?disabled=${this.#requesting}
|
|
/>
|
|
<button type="submit" ?disabled=${this.#requesting}>
|
|
<span class="g-icon filled-heavy">send</span>
|
|
</button>
|
|
</div>
|
|
</form>`;
|
|
}
|
|
|
|
#startLoadingAnimation() {
|
|
if (
|
|
Array.isArray(this.config.loadingText) &&
|
|
this.config.loadingText.length > 1
|
|
) {
|
|
this.#loadingTextIndex = 0;
|
|
this.#loadingInterval = window.setInterval(() => {
|
|
this.#loadingTextIndex =
|
|
(this.#loadingTextIndex + 1) %
|
|
(this.config.loadingText as string[]).length;
|
|
}, 2000);
|
|
}
|
|
}
|
|
|
|
#stopLoadingAnimation() {
|
|
if (this.#loadingInterval) {
|
|
clearInterval(this.#loadingInterval);
|
|
this.#loadingInterval = undefined;
|
|
}
|
|
}
|
|
|
|
async #sendMessage(
|
|
message: v0_8.Types.A2UIClientEventMessage
|
|
): Promise<v0_8.Types.ServerToClientMessage[]> {
|
|
try {
|
|
this.#requesting = true;
|
|
this.#startLoadingAnimation();
|
|
const response = this.#a2uiClient.send(message);
|
|
await response;
|
|
this.#requesting = false;
|
|
this.#stopLoadingAnimation();
|
|
|
|
return response;
|
|
} catch (err) {
|
|
this.snackbar(err as string, SnackType.ERROR);
|
|
} finally {
|
|
this.#requesting = false;
|
|
this.#stopLoadingAnimation();
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
#maybeRenderData() {
|
|
if (this.#requesting) {
|
|
let text = "Awaiting an answer...";
|
|
if (this.config.loadingText) {
|
|
if (Array.isArray(this.config.loadingText)) {
|
|
text = this.config.loadingText[this.#loadingTextIndex];
|
|
} else {
|
|
text = this.config.loadingText;
|
|
}
|
|
}
|
|
|
|
return html` <div class="pending">
|
|
<div class="spinner"></div>
|
|
<div class="loading-text">${text}</div>
|
|
</div>`;
|
|
}
|
|
|
|
const surfaces = this.#processor.getSurfaces();
|
|
if (surfaces.size === 0) {
|
|
return nothing;
|
|
}
|
|
|
|
return html`<section id="surfaces">
|
|
${repeat(
|
|
this.#processor.getSurfaces(),
|
|
([surfaceId]) => surfaceId,
|
|
([surfaceId, surface]) => {
|
|
return html`<a2ui-surface
|
|
@a2uiaction=${async (
|
|
evt: v0_8.Events.StateEvent<"a2ui.action">
|
|
) => {
|
|
const [target] = evt.composedPath();
|
|
if (!(target instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
const context: v0_8.Types.A2UIClientEventMessage["userAction"]["context"] =
|
|
{};
|
|
if (evt.detail.action.context) {
|
|
const srcContext = evt.detail.action.context;
|
|
for (const item of srcContext) {
|
|
if (item.value.literalBoolean) {
|
|
context[item.key] = item.value.literalBoolean;
|
|
} else if (item.value.literalNumber) {
|
|
context[item.key] = item.value.literalNumber;
|
|
} else if (item.value.literalString) {
|
|
context[item.key] = item.value.literalString;
|
|
} else if (item.value.path) {
|
|
const path = this.#processor.resolvePath(
|
|
item.value.path,
|
|
evt.detail.dataContextPath
|
|
);
|
|
const value = this.#processor.getData(
|
|
evt.detail.sourceComponent,
|
|
path,
|
|
surfaceId
|
|
);
|
|
context[item.key] = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
const message: v0_8.Types.A2UIClientEventMessage = {
|
|
userAction: {
|
|
name: evt.detail.action.name,
|
|
surfaceId,
|
|
sourceComponentId: target.id,
|
|
timestamp: new Date().toISOString(),
|
|
context,
|
|
},
|
|
};
|
|
|
|
await this.#sendAndProcessMessage(message);
|
|
}}
|
|
.surfaceId=${surfaceId}
|
|
.surface=${surface}
|
|
.processor=${this.#processor}
|
|
></a2-uisurface>`;
|
|
}
|
|
)}
|
|
</section>`;
|
|
}
|
|
|
|
async #sendAndProcessMessage(request) {
|
|
const messages = await this.#sendMessage(request);
|
|
|
|
console.log(messages);
|
|
|
|
this.#lastMessages = messages;
|
|
this.#processor.clearSurfaces();
|
|
this.#processor.processMessages(messages);
|
|
}
|
|
|
|
snackbar(
|
|
message: string | HTMLTemplateResult,
|
|
type: SnackType,
|
|
actions: SnackbarAction[] = [],
|
|
persistent = false,
|
|
id = globalThis.crypto.randomUUID(),
|
|
replaceAll = false
|
|
) {
|
|
if (!this.#snackbar) {
|
|
this.#pendingSnackbarMessages.push({
|
|
message: {
|
|
id,
|
|
message,
|
|
type,
|
|
persistent,
|
|
actions,
|
|
},
|
|
replaceAll,
|
|
});
|
|
return;
|
|
}
|
|
|
|
return this.#snackbar.show(
|
|
{
|
|
id,
|
|
message,
|
|
type,
|
|
persistent,
|
|
actions,
|
|
},
|
|
replaceAll
|
|
);
|
|
}
|
|
|
|
unsnackbar(id?: SnackbarUUID) {
|
|
if (!this.#snackbar) {
|
|
return;
|
|
}
|
|
|
|
this.#snackbar.hide(id);
|
|
}
|
|
}
|