feat: A2UI renderer for filters, tables, cards, forms

This commit is contained in:
Ken
2026-05-26 20:29:42 +00:00
parent a3635a9eb7
commit ab99259bcc
2 changed files with 631 additions and 0 deletions
+252
View File
@@ -0,0 +1,252 @@
import { useState } from 'react'
import { IconFilter, IconTable, IconCards, IconForms } from '@tabler/icons-react'
import type { A2UIBlock } from '../lib/types'
interface A2UIRendererProps {
block: A2UIBlock
onAction?: (action: string, data: Record<string, unknown>) => void
}
// ─── Filters ─────────────────────────────────────────────────────────────────
function FiltersRenderer({ block, onAction }: A2UIRendererProps & { block: Extract<A2UIBlock, { type: 'filters' }> }) {
const [activeLabels, setActiveLabels] = useState<Set<string>>(new Set())
function handleToggle(label: string) {
const isActive = activeLabels.has(label)
setActiveLabels(prev => {
const next = new Set(prev)
if (isActive) next.delete(label)
else next.add(label)
return next
})
onAction?.('filter_toggle', { label, active: !isActive })
}
return (
<div
className='tool-call-card'
style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem', alignItems: 'center' }}
>
<IconFilter size={16} color='#7aa2f7' style={{ marginRight: 4 }} />
{block.filters.map(option => {
const active = activeLabels.has(option.label)
return (
<button
key={option.value}
onClick={() => handleToggle(option.label)}
style={{
padding: '0.3rem 0.6rem',
borderRadius: '12px',
border: '1px solid',
borderColor: active ? '#5a7aba' : '#3a3a5a',
background: active ? '#2a3a6a' : 'transparent',
color: active ? '#ffffff' : '#8888aa',
fontSize: '0.8rem',
cursor: 'pointer',
}}
>
{option.label}
</button>
)
})}
</div>
)
}
// ─── Table ───────────────────────────────────────────────────────────────────
function TableRenderer({ block }: { block: Extract<A2UIBlock, { type: 'table' }> }) {
return (
<div className='tool-call-card' style={{ overflowX: 'auto' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', marginBottom: '0.4rem' }}>
<IconTable size={16} color='#7aa2f7' />
<span style={{ color: '#8888aa', fontSize: '0.8rem' }}>Data Table</span>
</div>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8rem' }}>
<thead>
<tr>
{block.headers.map((header, i) => (
<th
key={i}
style={{
textAlign: 'left',
padding: '0.3rem 0.5rem',
borderBottom: '1px solid #2a2a4a',
color: '#aaaacc',
fontWeight: 600,
}}
>
{header}
</th>
))}
</tr>
</thead>
<tbody>
{block.rows.map((row, ri) => (
<tr key={ri}>
{row.map((cell, ci) => (
<td
key={ci}
style={{
padding: '0.3rem 0.5rem',
borderBottom: '1px solid #1a1a2e',
color: '#c0c0d0',
}}
>
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}
// ─── Cards ───────────────────────────────────────────────────────────────────
function CardsRenderer({ block }: { block: Extract<A2UIBlock, { type: 'cards' }> }) {
return (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
gap: '0.5rem',
margin: '0.5rem 0',
}}
>
{block.items.map((item, i) => (
<div
key={i}
className='tool-call-card'
style={{ cursor: item.url ? 'pointer' : undefined }}
onClick={item.url ? () => window.open(item.url, '_blank') : undefined}
>
<div style={{ fontWeight: 600, color: '#e0e0e0' }}>{item.title}</div>
{item.price != null && (
<div style={{ color: '#7af7a2', fontWeight: 600, fontSize: '0.9rem' }}>{item.price}</div>
)}
{item.description && (
<div style={{ color: '#8888aa', fontSize: '0.75rem', marginTop: 4 }}>{item.description}</div>
)}
</div>
))}
</div>
)
}
// ─── Form ────────────────────────────────────────────────────────────────────
function FormRenderer({
block,
onAction,
}: {
block: Extract<A2UIBlock, { type: 'form' }>
onAction?: (action: string, data: Record<string, unknown>) => void
}) {
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
const formData = new FormData(e.currentTarget)
const data: Record<string, unknown> = {}
formData.forEach((value, key) => {
data[key] = value
})
onAction?.('form_submit', data)
}
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '0.3rem',
background: '#1e2a4a',
border: '1px solid #3a3a5a',
borderRadius: '4px',
color: '#e0e0e0',
fontSize: '0.8rem',
boxSizing: 'border-box',
}
return (
<div className='tool-call-card'>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', marginBottom: '0.4rem' }}>
<IconForms size={16} color='#7aa2f7' />
<span style={{ color: '#8888aa', fontSize: '0.8rem' }}>Input Form</span>
</div>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
{block.fields.map(field => (
<div key={field.name}>
<label style={{ fontSize: '0.75rem', color: '#8888aa', display: 'block' }}>
{field.label || field.name}
</label>
{field.type === 'select' ? (
<select name={field.name} style={inputStyle}>
{field.options?.map(opt => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
) : (
<input name={field.name} type={field.type} style={inputStyle} />
)}
</div>
))}
<button
type='submit'
style={{
padding: '0.4rem 0.8rem',
background: '#3a5aba',
color: '#ffffff',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '0.8rem',
marginTop: '0.3rem',
alignSelf: 'flex-start',
}}
>
{block.submitLabel || 'Submit'}
</button>
</form>
</div>
)
}
// ─── Main Renderer ───────────────────────────────────────────────────────────
export function A2UIRenderer({ block, onAction }: A2UIRendererProps) {
switch (block.type) {
case 'filters':
return <FiltersRenderer block={block} onAction={onAction} />
case 'table':
return <TableRenderer block={block} />
case 'cards':
return <CardsRenderer block={block} />
case 'form':
return <FormRenderer block={block} onAction={onAction} />
default:
return null
}
}
// ─── parseA2UIBlocks ─────────────────────────────────────────────────────────
export function parseA2UIBlocks(content: string): A2UIBlock[] {
const blocks: A2UIBlock[] = []
const regex = /```a2ui\s*\n([\s\S]*?)```/g
let match: RegExpExecArray | null
while ((match = regex.exec(content)) !== null) {
try {
const parsed = JSON.parse(match[1])
if (parsed && parsed.type) {
blocks.push(parsed as A2UIBlock)
}
} catch {
// Silently skip invalid JSON
}
}
return blocks
}