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
377 lines
9.2 KiB
TypeScript
377 lines
9.2 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/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";
|
|
|
|
// Demo elements.
|
|
import "./ui/ui.js";
|
|
import { registerContactComponents } from "./ui/custom-components/register-components.js";
|
|
|
|
// Register custom components for the contact app
|
|
registerContactComponents();
|
|
|
|
@customElement("a2ui-contact")
|
|
export class A2UIContactFinder 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[] = [];
|
|
|
|
static styles = [
|
|
unsafeCSS(v0_8.Styles.structuralStyles),
|
|
css`
|
|
:host {
|
|
display: block;
|
|
max-width: 640px;
|
|
margin: 0 auto;
|
|
min-height: 100%;
|
|
}
|
|
|
|
#surfaces {
|
|
display: flex;
|
|
flex-direction: column;
|
|
width: 100%;
|
|
padding: var(--bb-grid-size-3) 0;
|
|
animation: fadeIn 1s cubic-bezier(0, 0, 0.3, 1) 0.3s backwards;
|
|
|
|
& a2ui-surface {
|
|
align-items: center;
|
|
}
|
|
}
|
|
|
|
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;
|
|
|
|
& > 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);
|
|
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;
|
|
align-items: center;
|
|
justify-content: center;
|
|
animation: fadeIn 1s cubic-bezier(0, 0, 0.3, 1) 0.3s backwards;
|
|
|
|
& .g-icon {
|
|
margin-right: 8px;
|
|
}
|
|
}
|
|
|
|
.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;
|
|
}> = [];
|
|
|
|
render() {
|
|
return [
|
|
this.#maybeRenderForm(),
|
|
this.#maybeRenderData(),
|
|
this.#maybeRenderError(),
|
|
];
|
|
}
|
|
|
|
#maybeRenderError() {
|
|
if (!this.#error) return nothing;
|
|
|
|
return html`<div class="error">${this.#error}</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);
|
|
}}
|
|
>
|
|
<h1 class="typography-f-sf typography-v-r typography-w-400 color-c-p30">
|
|
Contact Finder
|
|
</h1>
|
|
<div>
|
|
<input
|
|
required
|
|
value="Casey Smith"
|
|
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>`;
|
|
}
|
|
|
|
#maybeRenderData() {
|
|
if (this.#requesting) {
|
|
return html` <div class="pending">
|
|
<span class="g-icon filled-heavy rotate">progress_activity</span>
|
|
Awaiting an answer...
|
|
</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: {
|
|
surfaceId: surfaceId,
|
|
name: evt.detail.action.name,
|
|
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);
|
|
|
|
this.#lastMessages = messages;
|
|
this.#processor.clearSurfaces();
|
|
this.#processor.processMessages(messages);
|
|
}
|
|
|
|
async #sendMessage(
|
|
message: v0_8.Types.A2UIClientEventMessage
|
|
): Promise<v0_8.Types.ServerToClientMessage[]> {
|
|
try {
|
|
this.#requesting = true;
|
|
const response = this.#a2uiClient.send(message);
|
|
await response;
|
|
this.#requesting = false;
|
|
|
|
return response;
|
|
} catch (err) {
|
|
this.snackbar(err as string, SnackType.ERROR);
|
|
} finally {
|
|
this.#requesting = false;
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|