compassmock/src/components/agent/dynamic-ui.tsx
Nicholai e9faea5596
feat(agent): replace ElizaOS with AI SDK v6 harness (#36)
* feat(agent): replace ElizaOS with AI SDK v6 harness

Replace custom ElizaOS sidecar proxy with Vercel AI SDK v6 +
OpenRouter provider for a proper agentic harness with multi-step
tool loops, streaming, and D1 conversation persistence.

- Add AI SDK agent library (provider, tools, system prompt, catalog)
- Rewrite API route to use streamText with 10-step tool loop
- Add server actions for conversation save/load/delete
- Migrate chat-panel and dashboard-chat to useChat hook
- Add action handler dispatch for navigate/toast/render tools
- Use qwen/qwen3-coder-next via OpenRouter (fallbacks disabled)
- Delete src/lib/eliza/ (replaced entirely)
- Exclude references/ from tsconfig build

* fix(chat): improve dashboard chat scroll and text size

- Rewrite auto-scroll: pin user message 75% out of
  frame after send, then follow bottom during streaming
- Use useEffect for scroll timing (DOM guaranteed ready)
  instead of rAF which fired before React commit
- Add user scroll detection to disengage auto-scroll
- Bump assistant text from 13px back to 14px (text-sm)
- Tighten prose spacing for headings and lists

---------

Co-authored-by: Nicholai <nicholaivogelfilms@gmail.com>
2026-02-05 18:07:25 -07:00

623 lines
16 KiB
TypeScript
Executable File

/**
* Dynamic UI Renderer
*
* Renders agent-generated UI specs using shadcn/ui components.
* Handles action callbacks for interactive elements.
*/
"use client"
import { useCallback } from "react"
import { executeAction } from "@/lib/agent/chat-adapter"
import type { ComponentSpec } from "@/lib/agent/catalog"
// Import shadcn components
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Progress } from "@/components/ui/progress"
import { cn } from "@/lib/utils"
interface DynamicUIProps {
spec: ComponentSpec
className?: string
}
export function DynamicUI({ spec, className }: DynamicUIProps) {
const handleAction = useCallback(
async (action: { type: string; payload?: Record<string, unknown> }) => {
await executeAction(action)
},
[]
)
return (
<div className={cn("dynamic-ui", className)}>
<ComponentRenderer spec={spec} onAction={handleAction} />
</div>
)
}
interface RendererProps {
spec: ComponentSpec
onAction: (action: { type: string; payload?: Record<string, unknown> }) => void
}
function ComponentRenderer({ spec, onAction }: RendererProps) {
switch (spec.type) {
case "DataTable":
return <DataTableRenderer {...spec.props} onAction={onAction} />
case "Card":
return <CardRenderer {...spec.props} onAction={onAction} />
case "Badge":
return <Badge variant={spec.props.variant}>{spec.props.label}</Badge>
case "StatCard":
return <StatCardRenderer {...spec.props} />
case "Button":
return (
<Button
variant={spec.props.variant}
size={spec.props.size}
onClick={() => onAction(spec.props.action)}
>
{spec.props.label}
</Button>
)
case "ButtonGroup":
return (
<div className="flex gap-2">
{spec.props.buttons.map((btn, i) => (
<Button
key={i}
variant={btn.variant}
size={btn.size}
onClick={() => onAction(btn.action)}
>
{btn.label}
</Button>
))}
</div>
)
case "InvoiceTable":
return <InvoiceTableRenderer {...spec.props} onAction={onAction} />
case "CustomerCard":
return <CustomerCardRenderer {...spec.props} onAction={onAction} />
case "VendorCard":
return <VendorCardRenderer {...spec.props} onAction={onAction} />
case "SchedulePreview":
return <SchedulePreviewRenderer {...spec.props} onAction={onAction} />
case "ProjectSummary":
return <ProjectSummaryRenderer {...spec.props} onAction={onAction} />
case "Grid":
return (
<div
className={cn(
"grid gap-4",
spec.props.columns === 1 && "grid-cols-1",
spec.props.columns === 2 && "grid-cols-2",
spec.props.columns === 3 && "grid-cols-3",
spec.props.columns === 4 && "grid-cols-4"
)}
style={{ gap: spec.props.gap }}
>
{(spec.props.children as ComponentSpec[])?.map((child, i) => (
<ComponentRenderer key={i} spec={child} onAction={onAction} />
))}
</div>
)
case "Stack":
return (
<div
className={cn(
"flex",
spec.props.direction === "horizontal" ? "flex-row" : "flex-col",
"gap-4"
)}
style={{ gap: spec.props.gap }}
>
{(spec.props.children as ComponentSpec[])?.map((child, i) => (
<ComponentRenderer key={i} spec={child} onAction={onAction} />
))}
</div>
)
default:
return (
<div className="text-muted-foreground text-sm">
Unknown component type: {(spec as { type: string }).type}
</div>
)
}
}
// DataTable renderer
function DataTableRenderer({
columns,
data,
onRowClick,
onAction,
}: {
columns: Array<{ key: string; header: string; format?: string }>
data: Array<Record<string, unknown>>
onRowClick?: { type: string; payload?: Record<string, unknown> }
onAction: RendererProps["onAction"]
}) {
return (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
{columns.map((col) => (
<TableHead key={col.key}>{col.header}</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{data.map((row, i) => (
<TableRow
key={i}
className={onRowClick ? "cursor-pointer hover:bg-muted" : ""}
onClick={() =>
onRowClick &&
onAction({
...onRowClick,
payload: { ...onRowClick.payload, rowData: row },
})
}
>
{columns.map((col) => (
<TableCell key={col.key}>
{formatValue(row[col.key], col.format)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}
// Card renderer
function CardRenderer({
title,
description,
children,
footer,
onAction,
}: {
title: string
description?: string
children?: unknown[]
footer?: string
onAction: RendererProps["onAction"]
}) {
return (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
{description && <CardDescription>{description}</CardDescription>}
</CardHeader>
{children && children.length > 0 && (
<CardContent>
{(children as ComponentSpec[]).map((child, i) => (
<ComponentRenderer key={i} spec={child} onAction={onAction} />
))}
</CardContent>
)}
{footer && (
<CardFooter>
<p className="text-sm text-muted-foreground">{footer}</p>
</CardFooter>
)}
</Card>
)
}
// StatCard renderer
function StatCardRenderer({
title,
value,
change,
changeLabel,
}: {
title: string
value: string | number
change?: number
changeLabel?: string
}) {
return (
<Card>
<CardHeader className="pb-2">
<CardDescription>{title}</CardDescription>
<CardTitle className="text-2xl">{value}</CardTitle>
</CardHeader>
{change !== undefined && (
<CardContent>
<div className="text-xs text-muted-foreground">
<span className={change >= 0 ? "text-green-600" : "text-red-600"}>
{change >= 0 ? "+" : ""}
{change}%
</span>
{changeLabel && ` ${changeLabel}`}
</div>
</CardContent>
)}
</Card>
)
}
// Invoice table renderer
function InvoiceTableRenderer({
invoices,
onRowClick,
onAction,
}: {
invoices: Array<{
id: string
number: string
customer: string
amount: number
dueDate: string
status: string
}>
onRowClick?: { type: string; payload?: Record<string, unknown> }
onAction: RendererProps["onAction"]
}) {
const statusVariant = (status: string) => {
switch (status) {
case "paid":
return "default"
case "overdue":
return "destructive"
default:
return "secondary"
}
}
return (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Invoice #</TableHead>
<TableHead>Customer</TableHead>
<TableHead className="text-right">Amount</TableHead>
<TableHead>Due Date</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invoices.map((invoice) => (
<TableRow
key={invoice.id}
className={onRowClick ? "cursor-pointer hover:bg-muted" : ""}
onClick={() =>
onRowClick &&
onAction({
...onRowClick,
payload: { ...onRowClick.payload, invoiceId: invoice.id },
})
}
>
<TableCell className="font-medium">{invoice.number}</TableCell>
<TableCell>{invoice.customer}</TableCell>
<TableCell className="text-right">
{formatCurrency(invoice.amount)}
</TableCell>
<TableCell>{formatDate(invoice.dueDate)}</TableCell>
<TableCell>
<Badge variant={statusVariant(invoice.status)}>
{invoice.status}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}
// Customer card renderer
function CustomerCardRenderer({
customer,
actions,
onAction,
}: {
customer: {
id: string
name: string
company?: string
email?: string
phone?: string
}
actions?: Array<{ type: string; payload?: Record<string, unknown> }>
onAction: RendererProps["onAction"]
}) {
return (
<Card>
<CardHeader>
<CardTitle>{customer.name}</CardTitle>
{customer.company && (
<CardDescription>{customer.company}</CardDescription>
)}
</CardHeader>
<CardContent className="space-y-1 text-sm">
{customer.email && <p>Email: {customer.email}</p>}
{customer.phone && <p>Phone: {customer.phone}</p>}
</CardContent>
{actions && actions.length > 0 && (
<CardFooter className="gap-2">
{actions.map((action, i) => (
<Button
key={i}
variant="outline"
size="sm"
onClick={() => onAction(action)}
>
{action.type.replace(/_/g, " ")}
</Button>
))}
</CardFooter>
)}
</Card>
)
}
// Vendor card renderer
function VendorCardRenderer({
vendor,
actions,
onAction,
}: {
vendor: {
id: string
name: string
category: string
email?: string
phone?: string
}
actions?: Array<{ type: string; payload?: Record<string, unknown> }>
onAction: RendererProps["onAction"]
}) {
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>{vendor.name}</CardTitle>
<Badge variant="outline">{vendor.category}</Badge>
</div>
</CardHeader>
<CardContent className="space-y-1 text-sm">
{vendor.email && <p>Email: {vendor.email}</p>}
{vendor.phone && <p>Phone: {vendor.phone}</p>}
</CardContent>
{actions && actions.length > 0 && (
<CardFooter className="gap-2">
{actions.map((action, i) => (
<Button
key={i}
variant="outline"
size="sm"
onClick={() => onAction(action)}
>
{action.type.replace(/_/g, " ")}
</Button>
))}
</CardFooter>
)}
</Card>
)
}
// Schedule preview renderer
function SchedulePreviewRenderer({
projectName,
tasks,
onTaskClick,
onAction,
}: {
projectId: string
projectName: string
tasks: Array<{
id: string
title: string
startDate: string
endDate: string
phase: string
status: string
percentComplete: number
isCriticalPath?: boolean
}>
onTaskClick?: { type: string; payload?: Record<string, unknown> }
onAction: RendererProps["onAction"]
}) {
return (
<Card>
<CardHeader>
<CardTitle>{projectName} Schedule</CardTitle>
<CardDescription>{tasks.length} tasks</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{tasks.slice(0, 5).map((task) => (
<div
key={task.id}
className={cn(
"p-2 rounded border",
task.isCriticalPath && "border-red-500",
onTaskClick && "cursor-pointer hover:bg-muted"
)}
onClick={() =>
onTaskClick &&
onAction({
...onTaskClick,
payload: { ...onTaskClick.payload, taskId: task.id },
})
}
>
<div className="flex items-center justify-between">
<span className="font-medium">{task.title}</span>
<Badge variant="outline">{task.phase}</Badge>
</div>
<div className="mt-2">
<Progress value={task.percentComplete} className="h-1" />
<div className="flex justify-between text-xs text-muted-foreground mt-1">
<span>{task.percentComplete}% complete</span>
<span>
{formatDate(task.startDate)} - {formatDate(task.endDate)}
</span>
</div>
</div>
</div>
))}
{tasks.length > 5 && (
<p className="text-sm text-muted-foreground text-center">
+{tasks.length - 5} more tasks
</p>
)}
</CardContent>
</Card>
)
}
// Project summary renderer
function ProjectSummaryRenderer({
project,
stats,
actions,
onAction,
}: {
project: {
id: string
name: string
status: string
address?: string
clientName?: string
projectManager?: string
}
stats?: {
tasksTotal: number
tasksComplete: number
daysRemaining?: number
budgetUsed?: number
}
actions?: Array<{ type: string; payload?: Record<string, unknown> }>
onAction: RendererProps["onAction"]
}) {
const completion = stats
? Math.round((stats.tasksComplete / stats.tasksTotal) * 100)
: 0
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>{project.name}</CardTitle>
<Badge>{project.status}</Badge>
</div>
{project.address && (
<CardDescription>{project.address}</CardDescription>
)}
</CardHeader>
<CardContent className="space-y-3">
{project.clientName && (
<p className="text-sm">Client: {project.clientName}</p>
)}
{project.projectManager && (
<p className="text-sm">PM: {project.projectManager}</p>
)}
{stats && (
<div>
<div className="flex justify-between text-sm mb-1">
<span>Progress</span>
<span>
{stats.tasksComplete}/{stats.tasksTotal} tasks ({completion}%)
</span>
</div>
<Progress value={completion} />
</div>
)}
</CardContent>
{actions && actions.length > 0 && (
<CardFooter className="gap-2">
{actions.map((action, i) => (
<Button
key={i}
variant="outline"
size="sm"
onClick={() => onAction(action)}
>
{action.type.replace(/_/g, " ")}
</Button>
))}
</CardFooter>
)}
</Card>
)
}
// Utility functions
function formatValue(value: unknown, format?: string): React.ReactNode {
if (value === null || value === undefined) return "-"
switch (format) {
case "currency":
return formatCurrency(Number(value))
case "date":
return formatDate(String(value))
case "badge":
return <Badge variant="outline">{String(value)}</Badge>
default:
return String(value)
}
}
function formatCurrency(amount: number): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(amount)
}
function formatDate(dateStr: string): string {
try {
return new Date(dateStr).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})
} catch {
return dateStr
}
}
export default DynamicUI