feat(a2ui): Implement A2UI backend with question handling and WebSocket support

- Added A2UITypes for defining question structures and answers.
- Created A2UIWebSocketHandler for managing WebSocket connections and message handling.
- Developed ask-question tool for interactive user questions via A2UI.
- Introduced platformUtils for platform detection and shell command handling.
- Centralized TypeScript types in index.ts for better organization.
- Implemented compatibility checks for hook templates based on platform requirements.
This commit is contained in:
catlog22
2026-01-31 15:27:12 +08:00
parent 4e009bb03a
commit 715ef12c92
163 changed files with 19495 additions and 715 deletions

View File

@@ -0,0 +1,110 @@
// ========================================
// A2UI Component Registry
// ========================================
// Maps A2UI component types to React renderer functions
import type { A2UIComponent, A2UIComponentType } from './A2UITypes';
// ========== Renderer Types ==========
/** State object for A2UI surfaces */
export type A2UIState = Record<string, unknown>;
/** Action handler callback */
export type ActionHandler = (actionId: string, params: Record<string, unknown>) => void | Promise<void>;
/** Binding resolver function */
export type BindingResolver = (binding: { path: string }) => unknown;
/** Component renderer function */
export type ComponentRenderer = (props: {
component: A2UIComponent;
state: A2UIState;
onAction: ActionHandler;
resolveBinding: BindingResolver;
}) => JSX.Element | null;
// ========== Registry Class ==========
/**
* A2UI Component Registry
* Maps A2UI component types to React renderer functions
*/
export class A2UIComponentRegistry {
private readonly renderers = new Map<A2UIComponentType, ComponentRenderer>();
/**
* Register a component renderer
* @param type - Component type name (e.g., 'Text', 'Button')
* @param renderer - React component function
*/
register(type: A2UIComponentType, renderer: ComponentRenderer): void {
this.renderers.set(type, renderer);
}
/**
* Unregister a component renderer
* @param type - Component type name
*/
unregister(type: A2UIComponentType): void {
this.renderers.delete(type);
}
/**
* Get a component renderer
* @param type - Component type name
* @returns Renderer function or undefined if not registered
*/
get(type: A2UIComponentType): ComponentRenderer | undefined {
return this.renderers.get(type);
}
/**
* Check if a component type is registered
* @param type - Component type name
* @returns True if renderer exists
*/
has(type: A2UIComponentType): boolean {
return this.renderers.has(type);
}
/**
* Get all registered component types
* @returns Array of registered type names
*/
getRegisteredTypes(): A2UIComponentType[] {
return Array.from(this.renderers.keys());
}
/**
* Clear all registered renderers
*/
clear(): void {
this.renderers.clear();
}
/**
* Get the number of registered renderers
* @returns Count of registered renderers
*/
get size(): number {
return this.renderers.size;
}
}
// ========== Singleton Export ==========
/** Global component registry instance */
export const a2uiRegistry = new A2UIComponentRegistry();
// ========== Built-in Component Registration ==========
/**
* Initialize built-in component renderers
* Called from renderer components index to avoid circular dependencies
*/
export function initializeBuiltInComponents(): void {
// Deferred import to avoid circular dependencies
// This will be called from renderer/components/index.ts
// after all component implementations are loaded
}

View File

@@ -0,0 +1,142 @@
// ========================================
// A2UI Protocol Parser
// ========================================
// Parses and validates A2UI surface update JSON
import { z } from 'zod';
import {
SurfaceUpdateSchema,
SurfaceUpdate,
ComponentSchema,
A2UIComponent,
} from './A2UITypes';
// ========== Error Class ==========
/** Custom error for A2UI parsing failures */
export class A2UIParseError extends Error {
public readonly originalError?: unknown;
constructor(message: string, originalError?: unknown) {
super(message);
this.name = 'A2UIParseError';
this.originalError = originalError;
}
/** Get detailed error information */
getDetails(): string {
if (this.originalError instanceof z.ZodError) {
return this.originalError.issues
.map((issue) => `${issue.path.join('.')}: ${issue.message}`)
.join('; ');
}
if (this.originalError instanceof Error) {
return this.originalError.message;
}
return this.message;
}
}
// ========== Parser Class ==========
/**
* A2UI Protocol Parser
* Parses JSON strings into validated SurfaceUpdate objects
*/
export class A2UIParser {
/**
* Parse JSON string into SurfaceUpdate
* @param json - JSON string to parse
* @returns Validated SurfaceUpdate object
* @throws A2UIParseError if JSON is invalid or doesn't match schema
*/
parse(json: string): SurfaceUpdate {
try {
// First, parse JSON
const data = JSON.parse(json);
// Then validate against schema
return SurfaceUpdateSchema.parse(data);
} catch (error) {
if (error instanceof SyntaxError) {
throw new A2UIParseError(`Invalid JSON: ${error.message}`, error);
}
if (error instanceof z.ZodError) {
throw new A2UIParseError(
`A2UI validation failed: ${error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join(', ')}`,
error
);
}
throw new A2UIParseError(`Failed to parse A2UI surface: ${error instanceof Error ? error.message : String(error)}`, error);
}
}
/**
* Parse object into SurfaceUpdate
* @param data - Object to validate
* @returns Validated SurfaceUpdate object
* @throws A2UIParseError if object doesn't match schema
*/
parseObject(data: unknown): SurfaceUpdate {
try {
return SurfaceUpdateSchema.parse(data);
} catch (error) {
if (error instanceof z.ZodError) {
throw new A2UIParseError(
`A2UI validation failed: ${error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join(', ')}`,
error
);
}
throw new A2UIParseError(`Failed to validate A2UI surface: ${error instanceof Error ? error.message : String(error)}`, error);
}
}
/**
* Type guard to check if value is a valid SurfaceUpdate
* @param value - Value to check
* @returns True if value is a valid SurfaceUpdate
*/
validate(value: unknown): value is SurfaceUpdate {
return SurfaceUpdateSchema.safeParse(value).success;
}
/**
* Safe parse that returns result instead of throwing
* @param json - JSON string to parse
* @returns Result object with success flag and data or error
*/
safeParse(json: string): z.SafeParseReturnType<SurfaceUpdate, SurfaceUpdate> {
try {
const data = JSON.parse(json);
return SurfaceUpdateSchema.safeParse(data);
} catch (error) {
return {
success: false,
error: error instanceof Error ? error : new Error(String(error)),
};
}
}
/**
* Safe parse object that returns result instead of throwing
* @param data - Object to validate
* @returns Result object with success flag and data or error
*/
safeParseObject(data: unknown): z.SafeParseReturnType<SurfaceUpdate, SurfaceUpdate> {
return SurfaceUpdateSchema.safeParse(data);
}
/**
* Validate a component against the component schema
* @param component - Component to validate
* @returns True if component is valid
*/
validateComponent(component: unknown): component is A2UIComponent {
return ComponentSchema.safeParse(component).success;
}
}
// ========== Singleton Export ==========
/** Default parser instance */
export const a2uiParser = new A2UIParser();

View File

@@ -0,0 +1,212 @@
// ========================================
// A2UI Runtime Type Definitions
// ========================================
// Zod schemas and TypeScript interfaces for A2UI protocol
// Based on Google's A2UI specification
import { z } from 'zod';
// ========== Primitive Content Schemas ==========
/** Literal string content */
export const LiteralStringSchema = z.object({
literalString: z.string(),
});
/** Binding content - references state by path */
export const BindingSchema = z.object({
path: z.string(),
});
/** Text content can be literal or bound to state */
export const TextContentSchema = z.union([
LiteralStringSchema,
BindingSchema,
]);
/** Number content can be literal or bound to state */
export const NumberContentSchema = z.union([
z.object({ literalNumber: z.number() }),
BindingSchema,
]);
/** Boolean content can be literal or bound to state */
export const BooleanContentSchema = z.union([
z.object({ literalBoolean: z.boolean() }),
BindingSchema,
]);
// ========== Component Schemas ==========
/** Action trigger */
export const ActionSchema = z.object({
actionId: z.string(),
parameters: z.record(z.unknown()).optional(),
});
/** Text component */
export const TextComponentSchema = z.object({
Text: z.object({
text: TextContentSchema,
usageHint: z.enum(['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'span', 'code', 'small']).optional(),
}),
});
/** Button component */
export const ButtonComponentSchema = z.object({
Button: z.object({
onClick: ActionSchema,
content: z.lazy(() => ComponentSchema),
variant: z.enum(['primary', 'secondary', 'destructive', 'ghost', 'outline']).optional(),
disabled: BooleanContentSchema.optional(),
}),
});
/** Dropdown/Select component */
export const DropdownComponentSchema = z.object({
Dropdown: z.object({
options: z.array(z.object({
label: TextContentSchema,
value: z.string(),
})),
selectedValue: TextContentSchema.optional(),
onChange: ActionSchema,
placeholder: z.string().optional(),
}),
});
/** Text input component */
export const TextFieldComponentSchema = z.object({
TextField: z.object({
value: TextContentSchema.optional(),
onChange: ActionSchema,
placeholder: z.string().optional(),
type: z.enum(['text', 'email', 'password', 'number', 'url']).optional(),
}),
});
/** Text area component */
export const TextAreaComponentSchema = z.object({
TextArea: z.object({
value: TextContentSchema.optional(),
onChange: ActionSchema,
placeholder: z.string().optional(),
rows: z.number().optional(),
}),
});
/** Checkbox component */
export const CheckboxComponentSchema = z.object({
Checkbox: z.object({
checked: BooleanContentSchema.optional(),
onChange: ActionSchema,
label: TextContentSchema.optional(),
}),
});
/** Code block component */
export const CodeBlockComponentSchema = z.object({
CodeBlock: z.object({
code: TextContentSchema,
language: z.string().optional(),
}),
});
/** Progress component */
export const ProgressComponentSchema = z.object({
Progress: z.object({
value: NumberContentSchema.optional(),
max: z.number().optional(),
}),
});
/** Card container component */
export const CardComponentSchema = z.object({
Card: z.object({
content: z.array(z.lazy(() => ComponentSchema)),
title: TextContentSchema.optional(),
description: TextContentSchema.optional(),
}),
});
// ========== Component Union ==========
/** All A2UI component types */
export const ComponentSchema: z.ZodType<
| z.infer<typeof TextComponentSchema>
| z.infer<typeof ButtonComponentSchema>
| z.infer<typeof DropdownComponentSchema>
| z.infer<typeof TextFieldComponentSchema>
| z.infer<typeof TextAreaComponentSchema>
| z.infer<typeof CheckboxComponentSchema>
| z.infer<typeof CodeBlockComponentSchema>
| z.infer<typeof ProgressComponentSchema>
| z.infer<typeof CardComponentSchema>
> = z.union([
TextComponentSchema,
ButtonComponentSchema,
DropdownComponentSchema,
TextFieldComponentSchema,
TextAreaComponentSchema,
CheckboxComponentSchema,
CodeBlockComponentSchema,
ProgressComponentSchema,
CardComponentSchema,
]);
// ========== Surface Schemas ==========
/** Surface component with ID */
export const SurfaceComponentSchema = z.object({
id: z.string(),
component: ComponentSchema,
});
/** Surface update message */
export const SurfaceUpdateSchema = z.object({
surfaceId: z.string(),
components: z.array(SurfaceComponentSchema),
initialState: z.record(z.unknown()).optional(),
});
// ========== TypeScript Types ==========
export type LiteralString = z.infer<typeof LiteralStringSchema>;
export type Binding = z.infer<typeof BindingSchema>;
export type TextContent = z.infer<typeof TextContentSchema>;
export type NumberContent = z.infer<typeof NumberContentSchema>;
export type BooleanContent = z.infer<typeof BooleanContentSchema>;
export type Action = z.infer<typeof ActionSchema>;
export type TextComponent = z.infer<typeof TextComponentSchema>;
export type ButtonComponent = z.infer<typeof ButtonComponentSchema>;
export type DropdownComponent = z.infer<typeof DropdownComponentSchema>;
export type TextFieldComponent = z.infer<typeof TextFieldComponentSchema>;
export type TextAreaComponent = z.infer<typeof TextAreaComponentSchema>;
export type CheckboxComponent = z.infer<typeof CheckboxComponentSchema>;
export type CodeBlockComponent = z.infer<typeof CodeBlockComponentSchema>;
export type ProgressComponent = z.infer<typeof ProgressComponentSchema>;
export type CardComponent = z.infer<typeof CardComponentSchema>;
export type A2UIComponent = z.infer<typeof ComponentSchema>;
export type SurfaceComponent = z.infer<typeof SurfaceComponentSchema>;
export type SurfaceUpdate = z.infer<typeof SurfaceUpdateSchema>;
// ========== Helper Types ==========
/** Discriminated union for component type detection */
export type A2UIComponentType =
| 'Text'
| 'Button'
| 'Dropdown'
| 'TextField'
| 'TextArea'
| 'Checkbox'
| 'CodeBlock'
| 'Progress'
| 'Card';
/** Get component type from discriminated union */
export function getComponentType(component: A2UIComponent): A2UIComponentType {
return Object.keys(component)[0] as A2UIComponentType;
}

View File

@@ -0,0 +1,7 @@
// ========================================
// A2UI Runtime Core - Index
// ========================================
export * from './A2UITypes';
export * from './A2UIParser';
export * from './A2UIComponentRegistry';

View File

@@ -0,0 +1,6 @@
// ========================================
// A2UI Runtime - Main Index
// ========================================
export * from './core';
export * from './renderer';

View File

@@ -0,0 +1,184 @@
// ========================================
// A2UI Renderer Component
// ========================================
// React component that renders A2UI surfaces
import React, { useState, useCallback, useMemo } from 'react';
import type { SurfaceUpdate, SurfaceComponent, A2UIComponent, LiteralString, Binding } from '../core/A2UITypes';
import { a2uiRegistry, type A2UIState, type ActionHandler, type BindingResolver } from '../core/A2UIComponentRegistry';
// ========== Renderer Props ==========
interface A2UIRendererProps {
/** Surface update to render */
surface: SurfaceUpdate;
/** Optional external action handler */
onAction?: ActionHandler;
/** Optional className for the container */
className?: string;
}
// ========== Main Renderer Component ==========
/**
* A2UI Surface Renderer
* Renders A2UI surface updates as interactive React components
*/
export function A2UIRenderer({ surface, onAction, className = '' }: A2UIRendererProps) {
// Local state initialized with surface's initial state
const [localState, setLocalState] = useState<A2UIState>(surface.initialState || {});
// Handle action from components
const handleAction = useCallback<ActionHandler>(
async (actionId, params) => {
if (onAction) {
await onAction(actionId, { ...params, ...localState });
}
},
[onAction, localState]
);
// Resolve binding path to state value
const resolveBinding = useCallback<BindingResolver>(
(binding) => {
const path = binding.path.replace(/^\//, '').split('/');
let value: unknown = localState;
for (const key of path) {
if (value && typeof value === 'object' && key in value) {
value = (value as Record<string, unknown>)[key];
} else {
return undefined;
}
}
return value;
},
[localState]
);
// Update state from external source
const updateState = useCallback((updates: Partial<A2UIState>) => {
setLocalState((prev) => ({ ...prev, ...updates }));
}, []);
// Memoize context for components
const contextValue = useMemo(
() => ({
state: localState,
resolveBinding,
updateState,
}),
[localState, resolveBinding, updateState]
);
return (
<div className={`a2ui-surface ${className}`} data-surface-id={surface.surfaceId}>
{surface.components.map((comp) => (
<ComponentRenderer
key={comp.id}
id={comp.id}
component={comp.component}
state={localState}
onAction={handleAction}
resolveBinding={resolveBinding}
/>
))}
</div>
);
}
// ========== Component Renderer ==========
interface ComponentRendererProps {
id: string;
component: A2UIComponent;
state: A2UIState;
onAction: ActionHandler;
resolveBinding: BindingResolver;
}
function ComponentRenderer({ id, component, state, onAction, resolveBinding }: ComponentRendererProps): JSX.Element | null {
// Get component type (discriminated union key)
const componentType = Object.keys(component)[0];
// Get renderer from registry
const renderer = a2uiRegistry.get(componentType as any);
if (!renderer) {
if (process.env.NODE_ENV === 'development') {
console.warn(`[A2UIRenderer] Unknown component type: ${componentType}`);
}
return (
<div className="a2ui-error" data-component-id={id}>
Unknown component type: {componentType}
</div>
);
}
// Render component
try {
return renderer({ component, state, onAction, resolveBinding });
} catch (error) {
if (process.env.NODE_ENV === 'development') {
console.error(`[A2UIRenderer] Error rendering component ${id}:`, error);
}
return (
<div className="a2ui-error" data-component-id={id}>
Error rendering component: {error instanceof Error ? error.message : String(error)}
</div>
);
}
}
// ========== Helper Functions ==========
/**
* Resolve literal or binding to actual value
* @param content - Content to resolve (literal or binding)
* @param resolveBinding - Binding resolver function
* @returns Resolved value
*/
export function resolveLiteralOrBinding(
content: LiteralString | Binding | { literalNumber?: number; literalBoolean?: boolean },
resolveBinding: BindingResolver
): string | number | boolean {
// Check for literal string
if ('literalString' in content) {
return content.literalString;
}
// Check for literal number
if ('literalNumber' in content && typeof content.literalNumber === 'number') {
return content.literalNumber;
}
// Check for literal boolean
if ('literalBoolean' in content && typeof content.literalBoolean === 'boolean') {
return content.literalBoolean;
}
// Resolve binding
const value = resolveBinding(content as Binding);
// Return resolved value or empty string as fallback
return value ?? '';
}
/**
* Resolve text content to string
* @param content - Text content to resolve
* @param resolveBinding - Binding resolver function
* @returns Resolved string value
*/
export function resolveTextContent(
content: LiteralString | Binding,
resolveBinding: BindingResolver
): string {
const value = resolveLiteralOrBinding(content, resolveBinding);
return String(value ?? '');
}
// ========== Export Helper Types ==========
export type { A2UIState, ActionHandler, BindingResolver };

View File

@@ -0,0 +1,73 @@
// ========================================
// A2UI Button Component Renderer
// ========================================
// Maps A2UI Button component to shadcn/ui Button
import React from 'react';
import { Button } from '@/components/ui/Button';
import type { ComponentRenderer } from '../../core/A2UIComponentRegistry';
import type { A2UIState, ActionHandler, BindingResolver } from '../../core/A2UIComponentRegistry';
import type { ButtonComponent, A2UIComponent } from '../../core/A2UITypes';
interface A2UIButtonRendererProps {
component: A2UIComponent;
state: A2UIState;
onAction: ActionHandler;
resolveBinding: BindingResolver;
}
/**
* A2UI Button Component Renderer
* Maps A2UI variants (primary/secondary/destructive) to shadcn/ui variants (default/secondary/destructive/ghost)
*/
export const A2UIButton: ComponentRenderer = ({ component, state, onAction, resolveBinding }) => {
const buttonComp = component as ButtonComponent;
const { Button: buttonConfig } = buttonComp;
// Resolve content (nested component - typically Text)
const ContentComponent = () => {
const contentType = Object.keys(buttonConfig.content)[0];
if (contentType === 'Text') {
const text = buttonConfig.content.Text.text;
const resolved = typeof text === 'string' && text.startsWith('{')
? resolveBinding({ path: JSON.parse(text).path })
: text;
return <>{resolved}</>;
}
return <>{contentType}</>;
};
// Map A2UI variants to shadcn/ui variants
// A2UI: primary, secondary, destructive, ghost, outline
// shadcn/ui: default, secondary, destructive, ghost, outline
const variantMap: Record<string, 'default' | 'secondary' | 'destructive' | 'ghost' | 'outline'> = {
primary: 'default',
secondary: 'secondary',
destructive: 'destructive',
ghost: 'ghost',
outline: 'outline',
};
const variant = buttonConfig.variant ? variantMap[buttonConfig.variant] ?? 'default' : 'default';
// Resolve disabled state
let disabled = false;
if (buttonConfig.disabled) {
if (typeof buttonConfig.disabled === 'object' && 'literalBoolean' in buttonConfig.disabled) {
disabled = buttonConfig.disabled.literalBoolean === false;
} else if (typeof buttonConfig.disabled === 'object' && 'path' in buttonConfig.disabled) {
const resolved = resolveBinding(buttonConfig.disabled);
disabled = resolved !== true;
}
}
const handleClick = () => {
onAction(buttonConfig.onClick.actionId, buttonConfig.onClick.parameters || {});
};
return (
<Button variant={variant} disabled={disabled} onClick={handleClick}>
<ContentComponent />
</Button>
);
};

View File

@@ -0,0 +1,54 @@
// ========================================
// A2UI Card Component Renderer
// ========================================
// Maps A2UI Card component to shadcn/ui Card
import React from 'react';
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/Card';
import type { ComponentRenderer } from '../../core/A2UIComponentRegistry';
import { resolveTextContent } from '../A2UIRenderer';
import type { CardComponent } from '../../core/A2UITypes';
interface A2UICardProps {
component: CardComponent;
state: Record<string, unknown>;
onAction: (actionId: string, params: Record<string, unknown>) => void | Promise<void>;
resolveBinding: (binding: { path: string }) => unknown;
}
/**
* A2UI Card Component Renderer
* Container component with optional title and description
*/
export const A2UICard: ComponentRenderer = ({ component, state, onAction, resolveBinding }) => {
const cardComp = component as CardComponent;
const { Card: cardConfig } = cardComp;
// Resolve title and description
const title = cardConfig.title ? resolveTextContent(cardConfig.title, resolveBinding) : undefined;
const description = cardConfig.description ? resolveTextContent(cardConfig.description, resolveBinding) : undefined;
return (
<Card>
{(title || description) && (
<CardHeader>
{title && <CardTitle>{title}</CardTitle>}
{description && <CardDescription>{description}</CardDescription>}
</CardHeader>
)}
<CardContent>
{cardConfig.content.map((childComp, index) => {
// For nested components, we would typically use the registry
// But for simplicity in this renderer, we just render a placeholder
const childType = Object.keys(childComp)[0];
return (
<div key={index} data-component-type={childType}>
{/* Nested components would be rendered here via A2UIRenderer */}
{childType}
</div>
);
})}
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,67 @@
// ========================================
// A2UI Checkbox Component Renderer
// ========================================
// Maps A2UI Checkbox component to shadcn/ui Checkbox
import React, { useState, useCallback } from 'react';
import { Checkbox } from '@/components/ui/Checkbox';
import { Label } from '@/components/ui/Label';
import type { ComponentRenderer } from '../../core/A2UIComponentRegistry';
import { resolveLiteralOrBinding, resolveTextContent } from '../A2UIRenderer';
import type { CheckboxComponent } from '../../core/A2UITypes';
interface A2UICheckboxProps {
component: CheckboxComponent;
state: Record<string, unknown>;
onAction: (actionId: string, params: Record<string, unknown>) => void | Promise<void>;
resolveBinding: (binding: { path: string }) => unknown;
}
/**
* A2UI Checkbox Component Renderer
* Boolean state binding with onChange handler
*/
export const A2UICheckbox: ComponentRenderer = ({ component, state, onAction, resolveBinding }) => {
const checkboxComp = component as CheckboxComponent;
const { Checkbox: checkboxConfig } = checkboxComp;
// Resolve initial checked state from binding
const getInitialChecked = (): boolean => {
if (!checkboxConfig.checked) return false;
const resolved = resolveLiteralOrBinding(checkboxConfig.checked, resolveBinding);
return Boolean(resolved);
};
// Local state for controlled checkbox
const [checked, setChecked] = useState(getInitialChecked());
// Handle change with two-way binding
const handleChange = useCallback((newChecked: boolean) => {
setChecked(newChecked);
// Trigger action with new checked state
onAction(checkboxConfig.onChange.actionId, {
checked: newChecked,
...(checkboxConfig.onChange.parameters || {}),
});
}, [checkboxConfig.onChange, onAction]);
// Resolve label text
const labelText = checkboxConfig.label
? resolveTextContent(checkboxConfig.label, resolveBinding)
: '';
return (
<div className="flex items-center space-x-2">
<Checkbox
checked={checked}
onCheckedChange={handleChange}
/>
{labelText && (
<Label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{labelText}
</Label>
)}
</div>
);
};

View File

@@ -0,0 +1,76 @@
// ========================================
// A2UI Dropdown Component Renderer
// ========================================
// Maps A2UI Dropdown component to shadcn/ui Select
import React, { useState, useCallback, useEffect } from 'react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/Select';
import type { ComponentRenderer } from '../../core/A2UIComponentRegistry';
import { resolveTextContent } from '../A2UIRenderer';
import type { DropdownComponent } from '../../core/A2UITypes';
interface A2UIDropdownProps {
component: DropdownComponent;
state: Record<string, unknown>;
onAction: (actionId: string, params: Record<string, unknown>) => void | Promise<void>;
resolveBinding: (binding: { path: string }) => unknown;
}
/**
* A2UI Dropdown Component Renderer
* Using shadcn/ui Select with options array mapping to SelectItem
*/
export const A2UIDropdown: ComponentRenderer = ({ component, state, onAction, resolveBinding }) => {
const dropdownComp = component as DropdownComponent;
const { Dropdown: dropdownConfig } = dropdownComp;
// Resolve initial selected value from binding
const getInitialValue = (): string => {
if (!dropdownConfig.selectedValue) return '';
const resolved = resolveTextContent(dropdownConfig.selectedValue, resolveBinding);
return resolved;
};
// Local state for controlled select
const [selectedValue, setSelectedValue] = useState(getInitialValue);
// Update local state when selectedValue binding changes
useEffect(() => {
setSelectedValue(getInitialValue());
}, [dropdownConfig.selectedValue, state]);
// Handle change with two-way binding
const handleChange = useCallback((newValue: string) => {
setSelectedValue(newValue);
// Trigger action with new selected value
onAction(dropdownConfig.onChange.actionId, {
value: newValue,
...(dropdownConfig.onChange.parameters || {}),
});
}, [dropdownConfig.onChange, onAction]);
return (
<Select value={selectedValue} onValueChange={handleChange}>
<SelectTrigger className="w-full">
<SelectValue placeholder={dropdownConfig.placeholder} />
</SelectTrigger>
<SelectContent>
{dropdownConfig.options.map((option) => {
const label = resolveTextContent(option.label, resolveBinding);
return (
<SelectItem key={option.value} value={option.value}>
{label}
</SelectItem>
);
})}
</SelectContent>
</Select>
);
};

View File

@@ -0,0 +1,45 @@
// ========================================
// A2UI Progress Component Renderer
// ========================================
// Maps A2UI Progress component to shadcn/ui Progress
import React from 'react';
import { Progress } from '@/components/ui/Progress';
import type { ComponentRenderer } from '../../core/A2UIComponentRegistry';
import { resolveLiteralOrBinding } from '../A2UIRenderer';
import type { ProgressComponent } from '../../core/A2UITypes';
interface A2UIProgressProps {
component: ProgressComponent;
state: Record<string, unknown>;
onAction: (actionId: string, params: Record<string, unknown>) => void | Promise<void>;
resolveBinding: (binding: { path: string }) => unknown;
}
/**
* A2UI Progress Component Renderer
* For CLI output progress display
*/
export const A2UIProgress: ComponentRenderer = ({ component, state, onAction, resolveBinding }) => {
const progressComp = component as ProgressComponent;
const { Progress: progressConfig } = progressComp;
// Resolve value and max from bindings or use defaults
const value = progressConfig.value
? Number(resolveLiteralOrBinding(progressConfig.value, resolveBinding) ?? 0)
: 0;
const max = progressConfig.max ?? 100;
// Calculate percentage for display (0-100)
const percentage = max > 0 ? Math.min(100, Math.max(0, (value / max) * 100)) : 0;
return (
<div className="w-full space-y-1">
<Progress value={percentage} max={100} />
<div className="flex justify-between text-xs text-muted-foreground">
<span>Progress</span>
<span>{Math.round(percentage)}%</span>
</div>
</div>
);
};

View File

@@ -0,0 +1,61 @@
// ========================================
// A2UI Text Component Renderer
// ========================================
// Maps A2UI Text component to HTML elements
import React from 'react';
import type { ComponentRenderer } from '../../core/A2UIComponentRegistry';
import { resolveTextContent } from '../A2UIRenderer';
import type { TextComponent } from '../../core/A2UITypes';
interface A2UITextProps {
component: TextComponent;
state: Record<string, unknown>;
onAction: (actionId: string, params: Record<string, unknown>) => void | Promise<void>;
resolveBinding: (binding: { path: string }) => unknown;
}
/**
* A2UI Text Component Renderer
* Maps A2UI Text usageHint to HTML elements (h1, h2, h3, p, span, code)
*/
export const A2UIText: ComponentRenderer = ({ component, state, onAction, resolveBinding }) => {
const { Text } = component as { Text: { text: unknown; usageHint?: string } };
// Resolve text content
const text = resolveTextContent(Text.text, resolveBinding);
const usageHint = Text.usageHint || 'span';
// Map usageHint to HTML elements
const elementMap: Record<string, React.ElementType> = {
h1: 'h1',
h2: 'h2',
h3: 'h3',
h4: 'h4',
h5: 'h5',
h6: 'h6',
p: 'p',
span: 'span',
code: 'code',
small: 'small',
};
// Map usageHint to Tailwind classes
const classMap: Record<string, string> = {
h1: 'text-2xl font-bold leading-tight',
h2: 'text-xl font-bold leading-tight',
h3: 'text-lg font-semibold leading-tight',
h4: 'text-base font-semibold leading-tight',
h5: 'text-sm font-semibold leading-tight',
h6: 'text-xs font-semibold leading-tight',
p: 'text-sm leading-relaxed',
span: 'text-sm',
code: 'font-mono text-sm bg-muted px-1 py-0.5 rounded',
small: 'text-xs text-muted-foreground',
};
const ElementType = elementMap[usageHint] || 'span';
const className = classMap[usageHint] || classMap.span;
return <ElementType className={className}>{text}</ElementType>;
};

View File

@@ -0,0 +1,55 @@
// ========================================
// A2UI TextArea Component Renderer
// ========================================
// Maps A2UI TextArea component to shadcn/ui Textarea
import React, { useState, useCallback } from 'react';
import { Textarea } from '@/components/ui/Textarea';
import type { ComponentRenderer } from '../../core/A2UIComponentRegistry';
import { resolveLiteralOrBinding } from '../A2UIRenderer';
import type { TextAreaComponent } from '../../core/A2UITypes';
interface A2UITextAreaProps {
component: TextAreaComponent;
state: Record<string, unknown>;
onAction: (actionId: string, params: Record<string, unknown>) => void | Promise<void>;
resolveBinding: (binding: { path: string }) => unknown;
}
/**
* A2UI TextArea Component Renderer
* Two-way binding via onChange updates to local state
*/
export const A2UITextArea: ComponentRenderer = ({ component, state, onAction, resolveBinding }) => {
const areaComp = component as TextAreaComponent;
const { TextArea: areaConfig } = areaComp;
// Resolve initial value from binding or use empty string
const initialValue = areaConfig.value
? String(resolveLiteralOrBinding(areaConfig.value, resolveBinding) ?? '')
: '';
// Local state for controlled input
const [localValue, setLocalValue] = useState(initialValue);
// Handle change with two-way binding
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value;
setLocalValue(newValue);
// Trigger action with new value
onAction(areaConfig.onChange.actionId, {
value: newValue,
...(areaConfig.onChange.parameters || {}),
});
}, [areaConfig.onChange, onAction]);
return (
<Textarea
value={localValue}
onChange={handleChange}
placeholder={areaConfig.placeholder}
rows={areaConfig.rows}
/>
);
};

View File

@@ -0,0 +1,55 @@
// ========================================
// A2UI TextField Component Renderer
// ========================================
// Maps A2UI TextField component to shadcn/ui Input
import React, { useState, useCallback } from 'react';
import { Input } from '@/components/ui/Input';
import type { ComponentRenderer } from '../../core/A2UIComponentRegistry';
import { resolveLiteralOrBinding } from '../A2UIRenderer';
import type { TextFieldComponent } from '../../core/A2UITypes';
interface A2UITextFieldProps {
component: TextFieldComponent;
state: Record<string, unknown>;
onAction: (actionId: string, params: Record<string, unknown>) => void | Promise<void>;
resolveBinding: (binding: { path: string }) => unknown;
}
/**
* A2UI TextField Component Renderer
* Two-way binding via onChange updates to local state
*/
export const A2UITextField: ComponentRenderer = ({ component, state, onAction, resolveBinding }) => {
const fieldComp = component as TextFieldComponent;
const { TextField: fieldConfig } = fieldComp;
// Resolve initial value from binding or use empty string
const initialValue = fieldConfig.value
? String(resolveLiteralOrBinding(fieldConfig.value, resolveBinding) ?? '')
: '';
// Local state for controlled input
const [localValue, setLocalValue] = useState(initialValue);
// Handle change with two-way binding
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setLocalValue(newValue);
// Trigger action with new value
onAction(fieldConfig.onChange.actionId, {
value: newValue,
...(fieldConfig.onChange.parameters || {}),
});
}, [fieldConfig.onChange, onAction]);
return (
<Input
type={fieldConfig.type || 'text'}
value={localValue}
onChange={handleChange}
placeholder={fieldConfig.placeholder}
/>
);
};

View File

@@ -0,0 +1,6 @@
// ========================================
// A2UI Component Renderers Index
// ========================================
// Exports all A2UI component renderers
export * from './registry';

View File

@@ -0,0 +1,44 @@
// ========================================
// A2UI Component Registry Initialization
// ========================================
// Registers all A2UI component renderers
import { a2uiRegistry } from '../../core/A2UIComponentRegistry';
import { A2UIText } from './A2UIText';
import { A2UIButton } from './A2UIButton';
import { A2UIDropdown } from './A2UIDropdown';
import { A2UITextField } from './A2UITextField';
import { A2UITextArea } from './A2UITextArea';
import { A2UICheckbox } from './A2UICheckbox';
import { A2UIProgress } from './A2UIProgress';
import { A2UICard } from './A2UICard';
/**
* Initialize and register all built-in A2UI component renderers
*/
export function registerBuiltInComponents(): void {
// Register all component types
a2uiRegistry.register('Text', A2UIText);
a2uiRegistry.register('Button', A2UIButton);
a2uiRegistry.register('Dropdown', A2UIDropdown);
a2uiRegistry.register('TextField', A2UITextField);
a2uiRegistry.register('TextArea', A2UITextArea);
a2uiRegistry.register('Checkbox', A2UICheckbox);
a2uiRegistry.register('Progress', A2UIProgress);
a2uiRegistry.register('Card', A2UICard);
}
/**
* Auto-initialize on import
* This ensures all components are registered when the renderer package is loaded
*/
registerBuiltInComponents();
export * from './A2UIText';
export * from './A2UIButton';
export * from './A2UIDropdown';
export * from './A2UITextField';
export * from './A2UITextArea';
export * from './A2UICheckbox';
export * from './A2UIProgress';
export * from './A2UICard';

View File

@@ -0,0 +1,5 @@
// ========================================
// A2UI Runtime Renderer - Index
// ========================================
export * from './A2UIRenderer';