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>
This commit is contained in:
Nicholai 2026-02-05 18:07:25 -07:00 committed by GitHub
parent abb2ac6780
commit e9faea5596
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 1170 additions and 665 deletions

View File

@ -5,6 +5,7 @@
"": {
"name": "dashboard-app-template",
"dependencies": {
"@ai-sdk/react": "^3.0.74",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
@ -13,6 +14,7 @@
"@json-render/core": "^0.4.0",
"@json-render/react": "^0.4.0",
"@opennextjs/cloudflare": "^1.14.4",
"@openrouter/ai-sdk-provider": "^2.1.1",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.8",
@ -97,6 +99,8 @@
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.13", "", { "dependencies": { "@ai-sdk/provider": "3.0.7", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HHG72BN4d+OWTcq2NwTxOm/2qvk1duYsnhCDtsbYwn/h/4zeqURu1S0+Cn0nY2Ysq9a9HGKvrYuMn9bgFhR2Og=="],
"@ai-sdk/react": ["@ai-sdk/react@3.0.74", "", { "dependencies": { "@ai-sdk/provider-utils": "4.0.13", "ai": "6.0.72", "swr": "^2.2.5", "throttleit": "2.1.0" }, "peerDependencies": { "react": "^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" } }, "sha512-L8N9HNM9Vt3rxORhX6+KCrsYRI6ZXGz1q8o/ysw6+Sx3MC0pqSZLiaKYifIYe2TSWgLP5mWcGlA5hHPuq5Jdfw=="],
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
"@ast-grep/napi": ["@ast-grep/napi@0.40.0", "", { "optionalDependencies": { "@ast-grep/napi-darwin-arm64": "0.40.0", "@ast-grep/napi-darwin-x64": "0.40.0", "@ast-grep/napi-linux-arm64-gnu": "0.40.0", "@ast-grep/napi-linux-arm64-musl": "0.40.0", "@ast-grep/napi-linux-x64-gnu": "0.40.0", "@ast-grep/napi-linux-x64-musl": "0.40.0", "@ast-grep/napi-win32-arm64-msvc": "0.40.0", "@ast-grep/napi-win32-ia32-msvc": "0.40.0", "@ast-grep/napi-win32-x64-msvc": "0.40.0" } }, "sha512-tq6nO/8KwUF/mHuk1ECaAOSOlz2OB/PmygnvprJzyAHGRVzdcffblaOOWe90M9sGz5MAasXoF+PTcayQj9TKKA=="],
@ -479,6 +483,8 @@
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.15.0", "", { "dependencies": { "@ast-grep/napi": "0.40.0", "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "3.9.11", "cloudflare": "^4.4.1", "enquirer": "^2.4.1", "glob": "^12.0.0", "ts-tqdm": "^0.8.6", "yargs": "^18.0.0" }, "peerDependencies": { "next": "^14.2.35 || ~15.0.7 || ~15.1.11 || ~15.2.8 || ~15.3.8 || ~15.4.10 || ~15.5.9 || ^16.0.10", "wrangler": "^4.59.2" }, "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-AZPaqk25XUBxtdkfjUZQBbY3ovifVLC4GgSRHuejqsIWfv8KjTRNFVdaCaaPmbLkrgymqxNhkbfJS5sD28AK/g=="],
"@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.1.1", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-UypPbVnSExxmG/4Zg0usRiit3auvQVrjUXSyEhm0sZ9GQnW/d8p/bKgCk2neh1W5YyRSo7PNQvCrAEBHZnqQkQ=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.6.0", "", { "dependencies": { "asn1js": "^3.0.6", "pvtsutils": "^1.3.6", "tslib": "^2.8.1" } }, "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg=="],
@ -1921,6 +1927,8 @@
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"swr": ["swr@2.4.0", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-sUlC20T8EOt1pHmDiqueUWMmRRX03W7w5YxovWX7VR2KHEPCTMly85x05vpkP5i6Bu4h44ePSMD9Tc+G2MItFw=="],
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
@ -1929,6 +1937,8 @@
"terser": ["terser@5.16.9", "", { "dependencies": { "@jridgewell/source-map": "^0.3.2", "acorn": "^8.5.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-HPa/FdTB9XGI2H1/keLFZHxl6WNvAI4YalHGtDQTlMnJcoqSab1UwL4l1hGEhs6/GmLHBZIg/YgB++jcbzoOEg=="],
"throttleit": ["throttleit@2.1.0", "", {}, "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],

View File

@ -17,6 +17,7 @@
"prepare": "husky"
},
"dependencies": {
"@ai-sdk/react": "^3.0.74",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
@ -25,6 +26,7 @@
"@json-render/core": "^0.4.0",
"@json-render/react": "^0.4.0",
"@opennextjs/cloudflare": "^1.14.4",
"@openrouter/ai-sdk-provider": "^2.1.1",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.8",

204
src/app/actions/agent.ts Executable file
View File

@ -0,0 +1,204 @@
"use server"
import { getCloudflareContext } from "@opennextjs/cloudflare"
import { getDb } from "@/db"
import { agentConversations, agentMemories } from "@/db/schema"
import { eq, desc } from "drizzle-orm"
import { getCurrentUser } from "@/lib/auth"
interface SerializedMessage {
readonly id: string
readonly role: string
readonly content: string
readonly parts?: ReadonlyArray<unknown>
readonly createdAt?: string
}
export async function saveConversation(
conversationId: string,
messages: ReadonlyArray<SerializedMessage>,
title?: string
): Promise<{ success: boolean; error?: string }> {
try {
const user = await getCurrentUser()
if (!user) return { success: false, error: "Unauthorized" }
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const now = new Date().toISOString()
const existing = await db.query.agentConversations.findFirst({
where: (c, { eq: e }) => e(c.id, conversationId),
})
if (existing) {
await db
.update(agentConversations)
.set({
lastMessageAt: now,
...(title ? { title } : {}),
})
.where(eq(agentConversations.id, conversationId))
.run()
} else {
await db
.insert(agentConversations)
.values({
id: conversationId,
userId: user.id,
title: title ?? null,
lastMessageAt: now,
createdAt: now,
})
.run()
}
// delete old memories for this conversation and re-insert
await db
.delete(agentMemories)
.where(eq(agentMemories.conversationId, conversationId))
.run()
for (const msg of messages) {
await db
.insert(agentMemories)
.values({
id: msg.id,
conversationId,
userId: user.id,
role: msg.role,
content: msg.content,
metadata: msg.parts
? JSON.stringify(msg.parts)
: null,
createdAt: msg.createdAt ?? now,
})
.run()
}
return { success: true }
} catch (error) {
console.error("Failed to save conversation:", error)
return {
success: false,
error:
error instanceof Error
? error.message
: "Unknown error",
}
}
}
export async function loadConversations(): Promise<{
success: boolean
data?: ReadonlyArray<{
id: string
title: string | null
lastMessageAt: string
createdAt: string
}>
error?: string
}> {
try {
const user = await getCurrentUser()
if (!user) return { success: false, error: "Unauthorized" }
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const rows = await db
.select({
id: agentConversations.id,
title: agentConversations.title,
lastMessageAt: agentConversations.lastMessageAt,
createdAt: agentConversations.createdAt,
})
.from(agentConversations)
.where(eq(agentConversations.userId, user.id))
.orderBy(desc(agentConversations.lastMessageAt))
.limit(20)
.all()
return { success: true, data: rows }
} catch (error) {
console.error("Failed to load conversations:", error)
return {
success: false,
error:
error instanceof Error
? error.message
: "Unknown error",
}
}
}
export async function loadConversation(
conversationId: string
): Promise<{
success: boolean
data?: ReadonlyArray<SerializedMessage>
error?: string
}> {
try {
const user = await getCurrentUser()
if (!user) return { success: false, error: "Unauthorized" }
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const rows = await db
.select()
.from(agentMemories)
.where(eq(agentMemories.conversationId, conversationId))
.orderBy(agentMemories.createdAt)
.all()
const messages: SerializedMessage[] = rows.map((r) => ({
id: r.id,
role: r.role,
content: r.content,
parts: r.metadata ? JSON.parse(r.metadata) : undefined,
createdAt: r.createdAt,
}))
return { success: true, data: messages }
} catch (error) {
console.error("Failed to load conversation:", error)
return {
success: false,
error:
error instanceof Error
? error.message
: "Unknown error",
}
}
}
export async function deleteConversation(
conversationId: string
): Promise<{ success: boolean; error?: string }> {
try {
const user = await getCurrentUser()
if (!user) return { success: false, error: "Unauthorized" }
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
// cascade delete handles memories
await db
.delete(agentConversations)
.where(eq(agentConversations.id, conversationId))
.run()
return { success: true }
} catch (error) {
console.error("Failed to delete conversation:", error)
return {
success: false,
error:
error instanceof Error
? error.message
: "Unknown error",
}
}
}

View File

@ -1,196 +1,40 @@
/**
* Agent API Route - Proxy to ElizaOS Server
*
* POST /api/agent - Send message to the Compass agent
* GET /api/agent - Get conversation history
*
* This route proxies requests to the ElizaOS sidecar server,
* handling auth on the Next.js side and forwarding messages
* to the agent's sessions API.
*/
import { NextResponse } from "next/server"
import {
streamText,
stepCountIs,
convertToModelMessages,
type UIMessage,
} from "ai"
import { getAgentModel } from "@/lib/agent/provider"
import { agentTools } from "@/lib/agent/tools"
import { buildSystemPrompt } from "@/lib/agent/system-prompt"
import { getCurrentUser } from "@/lib/auth"
const ELIZAOS_URL =
process.env.ELIZAOS_API_URL ?? "http://localhost:3001"
interface RequestBody {
message: string
conversationId?: string
context?: {
view?: string
projectId?: string
}
}
interface ElizaSessionResponse {
id: string
agentId?: string
userId?: string
}
interface ElizaMessageResponse {
id: string
content: string
authorId?: string
createdAt?: string
metadata?: Record<string, unknown>
sessionStatus?: Record<string, unknown>
}
async function getOrCreateSession(
userId: string,
conversationId?: string
): Promise<string> {
if (conversationId) return conversationId
const response = await fetch(
`${ELIZAOS_URL}/api/messaging/sessions`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId }),
}
)
if (!response.ok) {
throw new Error(
`Failed to create session: ${response.status} ${response.statusText}`
)
}
const data: ElizaSessionResponse = await response.json()
return data.id
}
export async function POST(request: Request): Promise<Response> {
try {
export async function POST(req: Request): Promise<Response> {
const user = await getCurrentUser()
if (!user) {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
)
return new Response("Unauthorized", { status: 401 })
}
const body: RequestBody = await request.json()
if (!body.message || typeof body.message !== "string") {
return NextResponse.json(
{ error: "Message is required" },
{ status: 400 }
)
const body = await req.json() as {
messages: UIMessage[]
}
const sessionId = await getOrCreateSession(
user.id,
body.conversationId
)
const currentPage =
req.headers.get("x-current-page") ?? undefined
// Send message to ElizaOS sessions API
const response = await fetch(
`${ELIZAOS_URL}/api/messaging/sessions/${sessionId}/messages`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
content: body.message,
metadata: {
source: body.context?.view ?? "dashboard",
projectId: body.context?.projectId,
userId: user.id,
userRole: user.role,
const model = await getAgentModel()
const result = streamText({
model,
system: buildSystemPrompt({
userName: user.displayName ?? user.email,
},
userRole: user.role,
currentPage,
}),
}
)
if (!response.ok) {
const errorText = await response.text()
console.error("ElizaOS error:", errorText)
return NextResponse.json(
{ error: "Agent unavailable" },
{ status: 502 }
)
}
const data: ElizaMessageResponse = await response.json()
// Extract action data from metadata if present
const actionData = data.metadata?.action as
| { type: string; payload?: Record<string, unknown> }
| undefined
const actions = actionData ? [actionData] : undefined
return NextResponse.json({
id: data.id ?? crypto.randomUUID(),
text: data.content ?? "",
actions,
ui: data.metadata?.ui,
conversationId: sessionId,
messages: await convertToModelMessages(body.messages),
tools: agentTools,
stopWhen: stepCountIs(10),
})
} catch (error) {
console.error("Agent API error:", error)
return NextResponse.json(
{
error:
error instanceof Error
? error.message
: "Internal server error",
},
{ status: 500 }
)
}
}
export async function GET(request: Request): Promise<Response> {
try {
const user = await getCurrentUser()
if (!user) {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
)
}
const { searchParams } = new URL(request.url)
const sessionId = searchParams.get("conversationId")
if (!sessionId) {
// No session listing support via proxy yet
return NextResponse.json({ conversations: [] })
}
// Get messages from ElizaOS session
const response = await fetch(
`${ELIZAOS_URL}/api/messaging/sessions/${sessionId}/messages?limit=100`
)
if (!response.ok) {
return NextResponse.json(
{ error: "Session not found" },
{ status: 404 }
)
}
const messages = await response.json()
return NextResponse.json({
conversation: { id: sessionId },
messages,
})
} catch (error) {
console.error("Agent API error:", error)
return NextResponse.json(
{
error:
error instanceof Error
? error.message
: "Internal server error",
},
{ status: 500 }
)
}
return result.toUIMessageStreamResponse()
}

View File

@ -6,22 +6,40 @@ import { MessageSquare } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Chat } from "@/components/ui/chat"
import { cn } from "@/lib/utils"
import { useChat } from "@ai-sdk/react"
import { DefaultChatTransport, type UIMessage } from "ai"
import {
useElizaChat,
initializeActionHandlers,
executeAction,
unregisterActionHandler,
dispatchToolActions,
ALL_HANDLER_TYPES,
type AgentAction,
} from "@/lib/eliza/chat-adapter"
} from "@/lib/agent/chat-adapter"
import {
saveConversation,
loadConversation,
loadConversations,
} from "@/app/actions/agent"
import { DynamicUI } from "./dynamic-ui"
import { useAgentOptional } from "./agent-provider"
import { toast } from "sonner"
import type { ComponentSpec } from "@/lib/agent/catalog"
interface ChatPanelProps {
className?: string
}
function getTextFromParts(
parts: ReadonlyArray<{ type: string; text?: string }>
): string {
return parts
.filter(
(p): p is { type: "text"; text: string } =>
p.type === "text"
)
.map((p) => p.text)
.join("")
}
export function ChatPanel({ className }: ChatPanelProps) {
const agentContext = useAgentOptional()
const isOpen = agentContext?.isOpen ?? false
@ -36,19 +54,74 @@ export function ChatPanel({ className }: ChatPanelProps) {
const routerRef = useRef(router)
routerRef.current = router
const onAction = useCallback((action: AgentAction) => {
executeAction(action)
}, [])
const [conversationId, setConversationId] = useState<
string | null
>(null)
const [resumeLoaded, setResumeLoaded] = useState(false)
const onError = useCallback((error: Error) => {
toast.error(error.message)
}, [])
const {
messages,
setMessages,
sendMessage,
stop,
status,
error,
} = useChat({
transport: new DefaultChatTransport({
api: "/api/agent",
headers: { "x-current-page": pathname },
}),
onFinish: async ({ messages: finalMessages }) => {
if (finalMessages.length === 0) return
const id =
conversationId ?? crypto.randomUUID()
if (!conversationId) setConversationId(id)
const serialized = finalMessages.map((m) => ({
id: m.id,
role: m.role,
content: getTextFromParts(
m.parts as ReadonlyArray<{
type: string
text?: string
}>
),
parts: m.parts,
createdAt: new Date().toISOString(),
}))
await saveConversation(id, serialized)
},
onError: (err) => {
toast.error(err.message)
},
})
// dispatch tool-based client actions when messages update
useEffect(() => {
const last = messages.at(-1)
if (last?.role !== "assistant") return
const parts = last.parts as ReadonlyArray<{
type: string
toolInvocation?: {
toolName: string
state: string
result?: unknown
}
}>
dispatchToolActions(parts)
}, [messages])
// initialize action handlers
useEffect(() => {
initializeActionHandlers(() => routerRef.current)
const handleToast = (event: CustomEvent) => {
const { message, type = "default" } = event.detail ?? {}
const { message, type = "default" } =
event.detail ?? {}
if (message) {
if (type === "success") toast.success(message)
else if (type === "error") toast.error(message)
@ -72,18 +145,52 @@ export function ChatPanel({ className }: ChatPanelProps) {
}
}, [])
const {
messages,
isGenerating,
stop,
append,
setMessages,
} = useElizaChat({
context: { view: pathname },
onAction,
onError,
})
// resume last conversation when panel opens
useEffect(() => {
if (!isOpen || resumeLoaded) return
const resume = async () => {
const result = await loadConversations()
if (
!result.success ||
!result.data ||
result.data.length === 0
) {
setResumeLoaded(true)
return
}
const lastConv = result.data[0]
const msgResult = await loadConversation(lastConv.id)
if (
!msgResult.success ||
!msgResult.data ||
msgResult.data.length === 0
) {
setResumeLoaded(true)
return
}
setConversationId(lastConv.id)
const restored: UIMessage[] = msgResult.data.map(
(m) => ({
id: m.id,
role: m.role as "user" | "assistant",
parts:
(m.parts as UIMessage["parts"]) ?? [
{ type: "text" as const, text: m.content },
],
})
)
setMessages(restored)
setResumeLoaded(true)
}
resume()
}, [isOpen, resumeLoaded, setMessages])
// keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === ".") {
@ -96,17 +203,44 @@ export function ChatPanel({ className }: ChatPanelProps) {
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
return () =>
window.removeEventListener("keydown", handleKeyDown)
}, [isOpen, setIsOpen, agentContext])
const suggestions = getSuggestionsForPath(pathname)
const chatMessages = messages.map((msg) => ({
const isGenerating =
status === "streaming" || status === "submitted"
// map UIMessage to the legacy Message format for Chat
const chatMessages = messages.map((msg) => {
const parts = msg.parts as ReadonlyArray<{
type: string
text?: string
}>
return {
id: msg.id,
role: msg.role,
content: msg.content,
createdAt: msg.createdAt,
}))
role: msg.role as "user" | "assistant",
content: getTextFromParts(parts),
parts: msg.parts as Array<{
type: "text"
text: string
}>,
}
})
const handleAppend = useCallback(
(message: { role: "user"; content: string }) => {
sendMessage({ text: message.content })
},
[sendMessage]
)
const handleNewChat = useCallback(() => {
setMessages([])
setConversationId(null)
setResumeLoaded(true)
}, [setMessages])
const handleRateResponse = useCallback(
(
@ -118,6 +252,7 @@ export function ChatPanel({ className }: ChatPanelProps) {
[]
)
// resize state
const [panelWidth, setPanelWidth] = useState(420)
const [isResizing, setIsResizing] = useState(false)
const dragStartX = useRef(0)
@ -127,7 +262,10 @@ export function ChatPanel({ className }: ChatPanelProps) {
const onMouseMove = (e: MouseEvent) => {
if (!dragStartWidth.current) return
const delta = dragStartX.current - e.clientX
const next = Math.min(720, Math.max(320, dragStartWidth.current + delta))
const next = Math.min(
720,
Math.max(320, dragStartWidth.current + delta)
)
setPanelWidth(next)
}
const onMouseUp = () => {
@ -157,12 +295,42 @@ export function ChatPanel({ className }: ChatPanelProps) {
[panelWidth]
)
// Dashboard has its own inline chat — skip the side panel
// extract last render component spec from tool results
const lastRenderSpec = (() => {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]
if (msg.role !== "assistant") continue
for (const part of msg.parts) {
const p = part as {
type: string
toolInvocation?: {
toolName: string
state: string
result?: {
action?: string
spec?: unknown
}
}
}
if (
p.type?.startsWith("tool-") &&
p.toolInvocation?.state === "result" &&
p.toolInvocation?.result?.action === "render"
) {
return p.toolInvocation.result.spec as
| ComponentSpec
| undefined
}
}
}
return undefined
})()
// Dashboard has its own inline chat
if (pathname === "/dashboard") return null
return (
<>
{/* Panel — mobile: full-screen overlay, desktop: integrated flex child */}
<div
className={cn(
"flex flex-col bg-background",
@ -186,40 +354,47 @@ export function ChatPanel({ className }: ChatPanelProps) {
/>
<div className="flex h-full w-full flex-col">
{/* Header with new chat button */}
{messages.length > 0 && (
<div className="flex items-center justify-end border-b px-3 py-2">
<Button
variant="ghost"
size="sm"
onClick={handleNewChat}
>
New chat
</Button>
</div>
)}
{/* Chat */}
<div className="flex-1 overflow-hidden">
<Chat
messages={chatMessages}
isGenerating={isGenerating}
stop={stop}
append={append}
append={handleAppend}
suggestions={
messages.length === 0 ? suggestions : []
}
onRateResponse={handleRateResponse}
setMessages={setMessages as never}
setMessages={
setMessages as unknown as (
messages: Array<{
id: string
role: string
content: string
}>
) => void
}
className="h-full"
/>
</div>
{/* Dynamic UI for agent-generated components */}
{messages.some((m) => m.actions) && (
{/* Dynamic UI for agent-rendered components */}
{lastRenderSpec && (
<div className="max-h-64 overflow-auto border-t p-4">
{messages
.filter((m) => m.actions)
.slice(-1)
.map((m) => {
const uiAction = m.actions?.find(
(a) => a.type === "RENDER_UI"
)
if (!uiAction?.payload?.spec) return null
return (
<DynamicUI
key={m.id}
spec={uiAction.payload.spec as never}
/>
)
})}
<DynamicUI spec={lastRenderSpec} />
</div>
)}
</div>
@ -234,7 +409,7 @@ export function ChatPanel({ className }: ChatPanelProps) {
/>
)}
{/* Mobile FAB trigger (desktop uses header button) */}
{/* Mobile FAB trigger */}
{!isOpen && (
<Button
size="icon"

View File

@ -8,8 +8,8 @@
"use client"
import { useCallback } from "react"
import { executeAction } from "@/lib/eliza/chat-adapter"
import type { ComponentSpec } from "@/lib/eliza/json-render/catalog"
import { executeAction } from "@/lib/agent/chat-adapter"
import type { ComponentSpec } from "@/lib/agent/catalog"
// Import shadcn components
import { Button } from "@/components/ui/button"

View File

@ -6,7 +6,7 @@ import {
useRef,
useEffect,
} from "react"
import { usePathname } from "next/navigation"
import { usePathname, useRouter } from "next/navigation"
import {
ArrowUp,
Plus,
@ -26,11 +26,14 @@ import { PromptSuggestions } from "@/components/ui/prompt-suggestions"
import {
useAutosizeTextArea,
} from "@/hooks/use-autosize-textarea"
import { useChat } from "@ai-sdk/react"
import { DefaultChatTransport } from "ai"
import {
useElizaChat,
executeAction,
type AgentAction,
} from "@/lib/eliza/chat-adapter"
dispatchToolActions,
initializeActionHandlers,
unregisterActionHandler,
ALL_HANDLER_TYPES,
} from "@/lib/agent/chat-adapter"
import {
IconBrandGithub,
IconExternalLink,
@ -61,12 +64,12 @@ const SUGGESTIONS = [
]
const ANIMATED_PLACEHOLDERS = [
"Show me invoices from the Johnson project",
"What tasks are due this week?",
"Which vendors need payment?",
"Navigate to the schedule view",
"Find overdue invoices for Highland",
"Who is assigned to concrete pour?",
"Show me open invoices",
"What's on the schedule for next week?",
"Which subcontractors are waiting on payment?",
"Pull up the current project timeline",
"Find outstanding invoices over 30 days",
"Who's assigned to the foundation work?",
]
const LOGO_MASK = {
@ -78,10 +81,25 @@ const LOGO_MASK = {
WebkitMaskRepeat: "no-repeat",
} as React.CSSProperties
function getTextFromParts(
parts: ReadonlyArray<{ type: string; text?: string }>
): string {
return parts
.filter(
(p): p is { type: "text"; text: string } =>
p.type === "text"
)
.map((p) => p.text)
.join("")
}
export function DashboardChat({ stats }: DashboardChatProps) {
const [isActive, setIsActive] = useState(false)
const [idleInput, setIdleInput] = useState("")
const scrollRef = useRef<HTMLDivElement>(null)
const router = useRouter()
const routerRef = useRef(router)
routerRef.current = router
const pathname = usePathname()
const [chatInput, setChatInput] = useState("")
const chatTextareaRef = useRef<HTMLTextAreaElement>(null)
@ -93,25 +111,72 @@ export function DashboardChat({ stats }: DashboardChatProps) {
dependencies: [chatInput],
})
const onAction = useCallback((action: AgentAction) => {
executeAction(action)
}, [])
const onError = useCallback((error: Error) => {
toast.error(error.message)
}, [])
const {
messages,
isGenerating,
sendMessage,
regenerate,
stop,
append,
} = useElizaChat({
context: { view: pathname },
onAction,
onError,
status,
} = useChat({
transport: new DefaultChatTransport({
api: "/api/agent",
headers: { "x-current-page": pathname },
}),
onError: (err) => {
toast.error(err.message)
},
})
const isGenerating =
status === "streaming" || status === "submitted"
// initialize action handlers for navigation, toasts, etc
useEffect(() => {
initializeActionHandlers(() => routerRef.current)
const handleToast = (event: CustomEvent) => {
const { message, type = "default" } =
event.detail ?? {}
if (message) {
if (type === "success") toast.success(message)
else if (type === "error") toast.error(message)
else toast(message)
}
}
window.addEventListener(
"agent-toast",
handleToast as EventListener
)
return () => {
window.removeEventListener(
"agent-toast",
handleToast as EventListener
)
for (const type of ALL_HANDLER_TYPES) {
unregisterActionHandler(type)
}
}
}, [])
// dispatch tool actions when messages update
useEffect(() => {
const last = messages.at(-1)
if (last?.role !== "assistant") return
const parts = last.parts as ReadonlyArray<{
type: string
toolInvocation?: {
toolName: string
state: string
result?: unknown
}
}>
dispatchToolActions(parts)
}, [messages])
const [copiedId, setCopiedId] = useState<string | null>(
null
)
@ -154,7 +219,6 @@ export function DashboardChat({ stats }: DashboardChatProps) {
setAnimFading(true)
animTimerRef.current = setTimeout(tick, 400)
} else {
// faded out — swap to next message while invisible
msgIdx =
(msgIdx + 1) % ANIMATED_PLACEHOLDERS.length
charIdx = 1
@ -175,14 +239,92 @@ export function DashboardChat({ stats }: DashboardChatProps) {
}
}, [isIdleFocused, idleInput, isActive])
// auto-scroll on new messages
// auto-scroll state
const autoScrollRef = useRef(true)
const justSentRef = useRef(false)
const pinCooldownRef = useRef(false)
const prevLenRef = useRef(0)
// called imperatively from send handlers to flag
// that the next render should do the pin-scroll
const markSent = useCallback(() => {
justSentRef.current = true
autoScrollRef.current = true
}, [])
// runs after every render caused by message changes.
// the DOM is guaranteed to be up-to-date here.
useEffect(() => {
if (!isActive) return
const el = scrollRef.current
if (!el) return
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" })
}, [messages.length, isActive])
// pin-scroll: fires once right after user sends
if (justSentRef.current) {
justSentRef.current = false
const bubbles = el.querySelectorAll(
"[data-role='user']"
)
const last = bubbles[
bubbles.length - 1
] as HTMLElement | undefined
if (last) {
const cRect = el.getBoundingClientRect()
const bRect = last.getBoundingClientRect()
const topInContainer = bRect.top - cRect.top
if (topInContainer > cRect.height / 2) {
const absTop =
bRect.top - cRect.top + el.scrollTop
const target = absTop - bRect.height * 0.25
el.scrollTo({
top: Math.max(0, target),
behavior: "smooth",
})
// don't let follow-bottom fight the smooth
// scroll for the next 600ms
pinCooldownRef.current = true
setTimeout(() => {
pinCooldownRef.current = false
}, 600)
return
}
}
}
// follow-bottom: keep the latest content visible
if (!autoScrollRef.current || pinCooldownRef.current)
return
const gap =
el.scrollHeight - el.scrollTop - el.clientHeight
if (gap > 0) {
el.scrollTop = el.scrollHeight - el.clientHeight
}
}, [messages, isActive])
// user scroll detection
useEffect(() => {
const el = scrollRef.current
if (!el) return
const onScroll = () => {
const gap =
el.scrollHeight - el.scrollTop - el.clientHeight
if (gap > 100) autoScrollRef.current = false
if (gap < 20) autoScrollRef.current = true
}
el.addEventListener("scroll", onScroll, {
passive: true,
})
return () =>
el.removeEventListener("scroll", onScroll)
}, [isActive, messages.length])
// Escape to return to idle when no messages
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
@ -212,11 +354,11 @@ export function DashboardChat({ stats }: DashboardChatProps) {
const value = idleInput.trim()
setIsActive(true)
if (value) {
append({ role: "user", content: value })
sendMessage({ text: value })
setIdleInput("")
}
},
[idleInput, append]
[idleInput, sendMessage]
)
const handleCopy = useCallback(
@ -231,9 +373,9 @@ export function DashboardChat({ stats }: DashboardChatProps) {
const handleSuggestion = useCallback(
(message: { role: "user"; content: string }) => {
setIsActive(true)
append(message)
sendMessage({ text: message.content })
},
[append]
[sendMessage]
)
return (
@ -371,14 +513,22 @@ export function DashboardChat({ stats }: DashboardChatProps) {
>
<div className="mx-auto w-full max-w-3xl px-4 py-4 space-y-6">
{messages.map((msg) => {
const textContent = getTextFromParts(
msg.parts as ReadonlyArray<{
type: string
text?: string
}>
)
if (msg.role === "user") {
return (
<div
key={msg.id}
data-role="user"
className="flex justify-end"
>
<div className="rounded-2xl border bg-background px-4 py-2.5 text-sm max-w-[80%] shadow-sm">
{msg.content}
{textContent}
</div>
</div>
)
@ -388,11 +538,11 @@ export function DashboardChat({ stats }: DashboardChatProps) {
key={msg.id}
className="flex flex-col items-start"
>
{msg.content ? (
{textContent ? (
<>
<div className="w-full text-sm leading-relaxed prose prose-sm prose-neutral dark:prose-invert max-w-none">
<div className="w-full text-sm leading-[1.6] prose prose-sm prose-neutral dark:prose-invert max-w-none [&_h1]:text-lg [&_h2]:text-base [&_h3]:text-[15px] [&_p]:my-2.5 [&_ul]:my-2.5 [&_ol]:my-2.5 [&_li]:my-1">
<MarkdownRenderer>
{msg.content}
{textContent}
</MarkdownRenderer>
</div>
<div className="mt-2 flex items-center gap-1">
@ -401,7 +551,7 @@ export function DashboardChat({ stats }: DashboardChatProps) {
onClick={() =>
handleCopy(
msg.id,
msg.content
textContent
)
}
className="rounded-md p-1.5 text-muted-foreground/60 transition-colors hover:bg-muted hover:text-foreground"
@ -429,16 +579,7 @@ export function DashboardChat({ stats }: DashboardChatProps) {
</button>
<button
type="button"
onClick={() =>
append({
role: "user",
content:
messages.findLast(
(m) =>
m.role === "user"
)?.content ?? "",
})
}
onClick={() => regenerate()}
className="rounded-md p-1.5 text-muted-foreground/60 transition-colors hover:bg-muted hover:text-foreground"
aria-label="Regenerate"
>
@ -483,8 +624,9 @@ export function DashboardChat({ stats }: DashboardChatProps) {
e.preventDefault()
const trimmed = chatInput.trim()
if (!trimmed || isGenerating) return
append({ role: "user", content: trimmed })
sendMessage({ text: trimmed })
setChatInput("")
markSent()
}}
>
<div
@ -503,11 +645,11 @@ export function DashboardChat({ stats }: DashboardChatProps) {
e.preventDefault()
const trimmed = chatInput.trim()
if (!trimmed || isGenerating) return
append({
role: "user",
content: trimmed,
sendMessage({
text: trimmed,
})
setChatInput("")
markSent()
}
}}
placeholder="Ask follow-up..."

157
src/lib/agent/chat-adapter.ts Executable file
View File

@ -0,0 +1,157 @@
"use client"
export { useChat } from "@ai-sdk/react"
// --- Action handler registry ---
export interface AgentAction {
readonly type: string
readonly payload?: Record<string, unknown>
}
export type ActionHandler = (
payload?: Record<string, unknown>
) => void | Promise<void>
const actionHandlers = new Map<string, ActionHandler>()
export function registerActionHandler(
type: string,
handler: ActionHandler
): void {
actionHandlers.set(type, handler)
}
export function unregisterActionHandler(
type: string
): void {
actionHandlers.delete(type)
}
export async function executeAction(
action: AgentAction
): Promise<void> {
const handler = actionHandlers.get(action.type)
if (handler) {
await handler(action.payload)
} else {
console.warn(
`No handler registered for action type: ${action.type}`
)
}
}
export function initializeActionHandlers(
getRouter: () => { push: (path: string) => void }
): void {
registerActionHandler("NAVIGATE_TO", (payload) => {
if (payload?.path && typeof payload.path === "string") {
getRouter().push(payload.path)
}
})
registerActionHandler("SHOW_TOAST", (payload) => {
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("agent-toast", { detail: payload })
)
}
})
registerActionHandler("OPEN_MODAL", (payload) => {
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("agent-modal", { detail: payload })
)
}
})
registerActionHandler("CLOSE_MODAL", () => {
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("agent-modal-close")
)
}
})
registerActionHandler("SCROLL_TO", (payload) => {
if (
payload?.target &&
typeof payload.target === "string"
) {
const el = document.querySelector(
`[data-section="${payload.target}"], #${payload.target}`
)
el?.scrollIntoView({ behavior: "smooth" })
}
})
registerActionHandler("FOCUS_ELEMENT", (payload) => {
if (
payload?.selector &&
typeof payload.selector === "string"
) {
const el = document.querySelector(
payload.selector
) as HTMLElement | null
el?.focus()
}
})
}
export const ALL_HANDLER_TYPES = [
"NAVIGATE_TO",
"SHOW_TOAST",
"OPEN_MODAL",
"CLOSE_MODAL",
"SCROLL_TO",
"FOCUS_ELEMENT",
] as const
/**
* Interpret tool result parts from AI SDK messages
* as client-side actions and dispatch them.
*/
export function dispatchToolActions(
parts: ReadonlyArray<{
type: string
toolInvocation?: {
toolName: string
state: string
result?: unknown
}
}>
): void {
for (const part of parts) {
if (
part.type !== "tool-invocation" ||
part.toolInvocation?.state !== "result"
) {
continue
}
const result = part.toolInvocation.result as
| Record<string, unknown>
| undefined
if (!result?.action) continue
switch (result.action) {
case "navigate":
executeAction({
type: "NAVIGATE_TO",
payload: { path: result.path },
})
break
case "toast":
executeAction({
type: "SHOW_TOAST",
payload: {
message: result.message,
type: result.type,
},
})
break
}
}
}

23
src/lib/agent/provider.ts Executable file
View File

@ -0,0 +1,23 @@
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
import { getCloudflareContext } from "@opennextjs/cloudflare"
const MODEL_ID = "qwen/qwen3-coder-next"
export async function getAgentModel() {
const { env } = await getCloudflareContext()
const apiKey = (env as unknown as Record<string, string>)
.OPENROUTER_API_KEY
if (!apiKey) {
throw new Error(
"OPENROUTER_API_KEY not configured"
)
}
const openrouter = createOpenRouter({ apiKey })
return openrouter(MODEL_ID, {
provider: {
allow_fallbacks: false,
},
})
}

64
src/lib/agent/system-prompt.ts Executable file
View File

@ -0,0 +1,64 @@
import { getComponentCatalogPrompt } from "@/lib/agent/catalog"
interface PromptContext {
readonly userName: string
readonly userRole: string
readonly currentPage?: string
readonly projectId?: string
}
export function buildSystemPrompt(ctx: PromptContext): string {
const catalogSection = getComponentCatalogPrompt()
return `You are Compass, an AI assistant for a construction project management platform.
## User Context
- Name: ${ctx.userName}
- Role: ${ctx.userRole}
- Current page: ${ctx.currentPage ?? "dashboard"}
${ctx.projectId ? `- Active project ID: ${ctx.projectId}` : ""}
## Domain
You help with construction project management: tracking projects, \
schedules, customers, vendors, invoices, and vendor bills. You \
understand construction terminology (phases, change orders, \
submittals, RFIs, punch lists, etc).
## Available Tools
### queryData
Query the application database using predefined query types. \
Pass a natural language description and the system will match it \
to available queries. Good for looking up customers, vendors, \
projects, invoices, tasks, and other records.
### navigateTo
Navigate the user to a page in the application. Use this when \
the user asks to "go to", "show me", "open", or "navigate to" \
something. Available paths:
- /dashboard - main dashboard
- /dashboard/projects - all projects
- /dashboard/projects/[id] - specific project
- /dashboard/customers - customers
- /dashboard/vendors - vendors
- /dashboard/schedule - project schedule
- /dashboard/finances - invoices and bills
### showNotification
Show a toast notification to the user. Use sparingly -- only for \
confirmations or important alerts.
### renderComponent
Render a UI component from the catalog. Use when the user wants \
to see structured data (tables, cards, charts). Available:
${catalogSection}
## Guidelines
- Be concise and helpful. Construction managers are busy.
- When asked about data, use queryData to fetch real information.
- For navigation requests, use navigateTo immediately.
- For data display, prefer renderComponent over plain text tables.
- If you don't know something, say so rather than guessing.
- Use metric and imperial units as appropriate for construction.
- Never fabricate data. Only present what queryData returns.`
}

224
src/lib/agent/tools.ts Executable file
View File

@ -0,0 +1,224 @@
import { tool } from "ai"
import { z } from "zod/v4"
import { getCloudflareContext } from "@opennextjs/cloudflare"
import { getDb } from "@/db"
const queryDataInputSchema = z.object({
queryType: z.enum([
"customers",
"vendors",
"projects",
"invoices",
"vendor_bills",
"schedule_tasks",
"project_detail",
"customer_detail",
"vendor_detail",
]),
id: z
.string()
.optional()
.describe("Record ID for detail queries"),
search: z
.string()
.optional()
.describe("Search term to filter results"),
limit: z
.number()
.optional()
.describe("Max results to return (default 20)"),
})
type QueryDataInput = z.infer<typeof queryDataInputSchema>
const navigateInputSchema = z.object({
path: z
.string()
.describe("The URL path to navigate to"),
reason: z
.string()
.optional()
.describe("Brief explanation of why navigating"),
})
type NavigateInput = z.infer<typeof navigateInputSchema>
const notificationInputSchema = z.object({
message: z.string().describe("The notification message"),
type: z
.enum(["default", "success", "error"])
.optional()
.describe("Notification style"),
})
type NotificationInput = z.infer<
typeof notificationInputSchema
>
const renderInputSchema = z.object({
componentType: z.string().describe(
"Component type from the catalog " +
"(DataTable, Card, StatCard, InvoiceTable, etc)"
),
props: z
.record(z.string(), z.unknown())
.describe("Component props matching the catalog schema"),
})
type RenderInput = z.infer<typeof renderInputSchema>
async function executeQueryData(input: QueryDataInput) {
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const cap = input.limit ?? 20
switch (input.queryType) {
case "customers": {
const rows = await db.query.customers.findMany({
limit: cap,
...(input.search
? {
where: (c, { like }) =>
like(c.name, `%${input.search}%`),
}
: {}),
})
return { data: rows, count: rows.length }
}
case "vendors": {
const rows = await db.query.vendors.findMany({
limit: cap,
...(input.search
? {
where: (v, { like }) =>
like(v.name, `%${input.search}%`),
}
: {}),
})
return { data: rows, count: rows.length }
}
case "projects": {
const rows = await db.query.projects.findMany({
limit: cap,
...(input.search
? {
where: (p, { like }) =>
like(p.name, `%${input.search}%`),
}
: {}),
})
return { data: rows, count: rows.length }
}
case "invoices": {
const rows = await db.query.invoices.findMany({
limit: cap,
})
return { data: rows, count: rows.length }
}
case "vendor_bills": {
const rows = await db.query.vendorBills.findMany({
limit: cap,
})
return { data: rows, count: rows.length }
}
case "schedule_tasks": {
const rows = await db.query.scheduleTasks.findMany({
limit: cap,
...(input.search
? {
where: (t, { like }) =>
like(t.title, `%${input.search}%`),
}
: {}),
})
return { data: rows, count: rows.length }
}
case "project_detail": {
if (!input.id) {
return { error: "id required for detail query" }
}
const row = await db.query.projects.findFirst({
where: (p, { eq }) => eq(p.id, input.id!),
})
return row ? { data: row } : { error: "not found" }
}
case "customer_detail": {
if (!input.id) {
return { error: "id required for detail query" }
}
const row = await db.query.customers.findFirst({
where: (c, { eq }) => eq(c.id, input.id!),
})
return row ? { data: row } : { error: "not found" }
}
case "vendor_detail": {
if (!input.id) {
return { error: "id required for detail query" }
}
const row = await db.query.vendors.findFirst({
where: (v, { eq }) => eq(v.id, input.id!),
})
return row ? { data: row } : { error: "not found" }
}
default:
return { error: "unknown query type" }
}
}
export const agentTools = {
queryData: tool({
description:
"Query the application database. Describe what data " +
"you need in natural language and provide a query type.",
inputSchema: queryDataInputSchema,
execute: async (input: QueryDataInput) =>
executeQueryData(input),
}),
navigateTo: tool({
description:
"Navigate the user to a page in the application. " +
"Returns navigation data for the client to execute.",
inputSchema: navigateInputSchema,
execute: async (input: NavigateInput) => ({
action: "navigate" as const,
path: input.path,
reason: input.reason ?? null,
}),
}),
showNotification: tool({
description:
"Show a toast notification to the user. Use for " +
"confirmations or important alerts.",
inputSchema: notificationInputSchema,
execute: async (input: NotificationInput) => ({
action: "toast" as const,
message: input.message,
type: input.type ?? "default",
}),
}),
renderComponent: tool({
description:
"Render a UI component from the catalog. Use to " +
"display structured data like tables, cards, or charts.",
inputSchema: renderInputSchema,
execute: async (input: RenderInput) => ({
action: "render" as const,
spec: {
type: input.componentType,
props: input.props,
},
}),
}),
}

View File

@ -1,340 +0,0 @@
/**
* ElizaOS Chat Adapter
*
* useChat-like hook for the shadcn Chat component.
* Communicates with ElizaOS via the /api/agent proxy route.
*
* Bug fixes from original:
* 1. initializeActionHandlers accepts getter fn (not stale router ref)
* 2. context option passed in POST body
* 3. useEffect cleanup for handler unregistration
* 4. options stored in ref to avoid stale closures in sendMessage
*/
"use client"
import { useState, useCallback, useRef } from "react"
export interface ChatMessage {
id: string
role: "user" | "assistant"
content: string
createdAt?: Date
actions?: ReadonlyArray<AgentAction>
isLoading?: boolean
}
export interface AgentAction {
type: string
payload?: Record<string, unknown>
}
export interface UseElizaChatOptions {
conversationId?: string
context?: { view?: string; projectId?: string }
onConversationCreate?: (id: string) => void
onAction?: (action: AgentAction) => void
onError?: (error: Error) => void
}
export interface UseElizaChatReturn {
messages: ReadonlyArray<ChatMessage>
input: string
setInput: (value: string) => void
handleInputChange: (
e: React.ChangeEvent<HTMLTextAreaElement>
) => void
handleSubmit: (e?: React.FormEvent) => Promise<void>
isGenerating: boolean
stop: () => void
append: (message: { role: "user"; content: string }) => Promise<void>
setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>
conversationId: string | null
reload: () => Promise<void>
}
interface AgentResponse {
id: string
text: string
actions?: ReadonlyArray<AgentAction>
conversationId: string
}
export function useElizaChat(
options: UseElizaChatOptions = {}
): UseElizaChatReturn {
const [messages, setMessages] = useState<ChatMessage[]>([])
const [input, setInput] = useState("")
const [isGenerating, setIsGenerating] = useState(false)
const [conversationId, setConversationId] = useState<string | null>(
options.conversationId ?? null
)
const abortControllerRef = useRef<AbortController | null>(null)
// Fix bug 4: store options in ref so sendMessage doesn't
// close over a stale options object
const optionsRef = useRef(options)
optionsRef.current = options
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
setInput(e.target.value)
},
[]
)
const sendMessage = useCallback(
async (content: string) => {
if (!content.trim()) return
const userMessage: ChatMessage = {
id: crypto.randomUUID(),
role: "user",
content,
createdAt: new Date(),
}
setMessages((prev) => [...prev, userMessage])
const loadingMessage: ChatMessage = {
id: crypto.randomUUID(),
role: "assistant",
content: "",
isLoading: true,
createdAt: new Date(),
}
setMessages((prev) => [...prev, loadingMessage])
setIsGenerating(true)
abortControllerRef.current = new AbortController()
try {
const response = await fetch("/api/agent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
message: content,
conversationId,
// Fix bug 2: include context in POST body
context: optionsRef.current.context,
}),
signal: abortControllerRef.current.signal,
})
if (!response.ok) {
const errorData = (await response.json()) as {
error?: string
}
throw new Error(errorData.error ?? "Failed to get response")
}
const data: AgentResponse = await response.json()
if (
data.conversationId &&
data.conversationId !== conversationId
) {
setConversationId(data.conversationId)
optionsRef.current.onConversationCreate?.(
data.conversationId
)
}
setMessages((prev) =>
prev.map((msg) =>
msg.id === loadingMessage.id
? {
...msg,
id: data.id,
content: data.text,
actions: data.actions
? [...data.actions]
: undefined,
isLoading: false,
}
: msg
)
)
if (data.actions) {
for (const action of data.actions) {
optionsRef.current.onAction?.(action)
}
}
} catch (err) {
const error = err as Error
if (error.name === "AbortError") {
setMessages((prev) =>
prev.filter((msg) => msg.id !== loadingMessage.id)
)
} else {
setMessages((prev) =>
prev.map((msg) =>
msg.id === loadingMessage.id
? {
...msg,
content:
"Sorry, I encountered an error. Please try again.",
isLoading: false,
}
: msg
)
)
optionsRef.current.onError?.(error)
}
} finally {
setIsGenerating(false)
abortControllerRef.current = null
}
},
// Fix bug 4: only depend on conversationId, not options
[conversationId]
)
const handleSubmit = useCallback(
async (e?: React.FormEvent) => {
e?.preventDefault()
const content = input.trim()
setInput("")
await sendMessage(content)
},
[input, sendMessage]
)
const stop = useCallback(() => {
abortControllerRef.current?.abort()
setIsGenerating(false)
}, [])
const append = useCallback(
async (message: { role: "user"; content: string }) => {
await sendMessage(message.content)
},
[sendMessage]
)
const reload = useCallback(async () => {
const lastUserMessage = [...messages]
.reverse()
.find((m) => m.role === "user")
if (lastUserMessage) {
setMessages((prev) => {
const lastIndex = prev.findLastIndex(
(m) => m.role === "assistant"
)
if (lastIndex >= 0) {
return prev.filter((_, i) => i !== lastIndex)
}
return prev
})
await sendMessage(lastUserMessage.content)
}
}, [messages, sendMessage])
return {
messages,
input,
setInput,
handleInputChange,
handleSubmit,
isGenerating,
stop,
append,
setMessages,
conversationId,
reload,
}
}
// --- Action handler registry ---
export type ActionHandler = (
payload?: Record<string, unknown>
) => void | Promise<void>
const actionHandlers = new Map<string, ActionHandler>()
export function registerActionHandler(
type: string,
handler: ActionHandler
): void {
actionHandlers.set(type, handler)
}
export function unregisterActionHandler(type: string): void {
actionHandlers.delete(type)
}
export async function executeAction(
action: AgentAction
): Promise<void> {
const handler = actionHandlers.get(action.type)
if (handler) {
await handler(action.payload)
} else {
console.warn(
`No handler registered for action type: ${action.type}`
)
}
}
// Fix bug 1: accept getter function instead of direct router ref
// so the handler always uses the current router instance
export function initializeActionHandlers(
getRouter: () => { push: (path: string) => void }
): void {
registerActionHandler("NAVIGATE_TO", (payload) => {
if (payload?.path && typeof payload.path === "string") {
getRouter().push(payload.path)
}
})
registerActionHandler("SHOW_TOAST", (payload) => {
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("agent-toast", { detail: payload })
)
}
})
registerActionHandler("OPEN_MODAL", (payload) => {
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("agent-modal", { detail: payload })
)
}
})
registerActionHandler("CLOSE_MODAL", () => {
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("agent-modal-close"))
}
})
registerActionHandler("SCROLL_TO", (payload) => {
if (payload?.target && typeof payload.target === "string") {
const el = document.querySelector(
`[data-section="${payload.target}"], #${payload.target}`
)
el?.scrollIntoView({ behavior: "smooth" })
}
})
registerActionHandler("FOCUS_ELEMENT", (payload) => {
if (payload?.selector && typeof payload.selector === "string") {
const el = document.querySelector(
payload.selector
) as HTMLElement | null
el?.focus()
}
})
}
// All registered handler types for cleanup
export const ALL_HANDLER_TYPES = [
"NAVIGATE_TO",
"SHOW_TOAST",
"OPEN_MODAL",
"CLOSE_MODAL",
"SCROLL_TO",
"FOCUS_ELEMENT",
] as const

View File

@ -27,5 +27,5 @@
]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"exclude": ["node_modules", "references"]
}