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", "name": "dashboard-app-template",
"dependencies": { "dependencies": {
"@ai-sdk/react": "^3.0.74",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
@ -13,6 +14,7 @@
"@json-render/core": "^0.4.0", "@json-render/core": "^0.4.0",
"@json-render/react": "^0.4.0", "@json-render/react": "^0.4.0",
"@opennextjs/cloudflare": "^1.14.4", "@opennextjs/cloudflare": "^1.14.4",
"@openrouter/ai-sdk-provider": "^2.1.1",
"@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.8", "@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/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=="], "@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=="], "@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=="], "@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=="], "@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=="], "@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=="], "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=="], "tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], "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=="], "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=="], "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=="], "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],

View File

@ -17,6 +17,7 @@
"prepare": "husky" "prepare": "husky"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/react": "^3.0.74",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
@ -25,6 +26,7 @@
"@json-render/core": "^0.4.0", "@json-render/core": "^0.4.0",
"@json-render/react": "^0.4.0", "@json-render/react": "^0.4.0",
"@opennextjs/cloudflare": "^1.14.4", "@opennextjs/cloudflare": "^1.14.4",
"@openrouter/ai-sdk-provider": "^2.1.1",
"@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.8", "@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 @@
/** import {
* Agent API Route - Proxy to ElizaOS Server streamText,
* stepCountIs,
* POST /api/agent - Send message to the Compass agent convertToModelMessages,
* GET /api/agent - Get conversation history type UIMessage,
* } from "ai"
* This route proxies requests to the ElizaOS sidecar server, import { getAgentModel } from "@/lib/agent/provider"
* handling auth on the Next.js side and forwarding messages import { agentTools } from "@/lib/agent/tools"
* to the agent's sessions API. import { buildSystemPrompt } from "@/lib/agent/system-prompt"
*/
import { NextResponse } from "next/server"
import { getCurrentUser } from "@/lib/auth" import { getCurrentUser } from "@/lib/auth"
const ELIZAOS_URL = export async function POST(req: Request): Promise<Response> {
process.env.ELIZAOS_API_URL ?? "http://localhost:3001" const user = await getCurrentUser()
if (!user) {
interface RequestBody { return new Response("Unauthorized", { status: 401 })
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() const body = await req.json() as {
return data.id messages: UIMessage[]
}
export async function POST(request: Request): Promise<Response> {
try {
const user = await getCurrentUser()
if (!user) {
return NextResponse.json(
{ error: "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 sessionId = await getOrCreateSession(
user.id,
body.conversationId
)
// 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,
userName: user.displayName ?? user.email,
},
}),
}
)
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,
})
} 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 }
)
} }
const currentPage =
req.headers.get("x-current-page") ?? undefined
const model = await getAgentModel()
const result = streamText({
model,
system: buildSystemPrompt({
userName: user.displayName ?? user.email,
userRole: user.role,
currentPage,
}),
messages: await convertToModelMessages(body.messages),
tools: agentTools,
stopWhen: stepCountIs(10),
})
return result.toUIMessageStreamResponse()
} }

View File

@ -6,22 +6,40 @@ import { MessageSquare } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Chat } from "@/components/ui/chat" import { Chat } from "@/components/ui/chat"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { useChat } from "@ai-sdk/react"
import { DefaultChatTransport, type UIMessage } from "ai"
import { import {
useElizaChat,
initializeActionHandlers, initializeActionHandlers,
executeAction,
unregisterActionHandler, unregisterActionHandler,
dispatchToolActions,
ALL_HANDLER_TYPES, ALL_HANDLER_TYPES,
type AgentAction, } from "@/lib/agent/chat-adapter"
} from "@/lib/eliza/chat-adapter" import {
saveConversation,
loadConversation,
loadConversations,
} from "@/app/actions/agent"
import { DynamicUI } from "./dynamic-ui" import { DynamicUI } from "./dynamic-ui"
import { useAgentOptional } from "./agent-provider" import { useAgentOptional } from "./agent-provider"
import { toast } from "sonner" import { toast } from "sonner"
import type { ComponentSpec } from "@/lib/agent/catalog"
interface ChatPanelProps { interface ChatPanelProps {
className?: string 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) { export function ChatPanel({ className }: ChatPanelProps) {
const agentContext = useAgentOptional() const agentContext = useAgentOptional()
const isOpen = agentContext?.isOpen ?? false const isOpen = agentContext?.isOpen ?? false
@ -36,19 +54,74 @@ export function ChatPanel({ className }: ChatPanelProps) {
const routerRef = useRef(router) const routerRef = useRef(router)
routerRef.current = router routerRef.current = router
const onAction = useCallback((action: AgentAction) => { const [conversationId, setConversationId] = useState<
executeAction(action) string | null
}, []) >(null)
const [resumeLoaded, setResumeLoaded] = useState(false)
const onError = useCallback((error: Error) => { const {
toast.error(error.message) 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(() => { useEffect(() => {
initializeActionHandlers(() => routerRef.current) initializeActionHandlers(() => routerRef.current)
const handleToast = (event: CustomEvent) => { const handleToast = (event: CustomEvent) => {
const { message, type = "default" } = event.detail ?? {} const { message, type = "default" } =
event.detail ?? {}
if (message) { if (message) {
if (type === "success") toast.success(message) if (type === "success") toast.success(message)
else if (type === "error") toast.error(message) else if (type === "error") toast.error(message)
@ -72,18 +145,52 @@ export function ChatPanel({ className }: ChatPanelProps) {
} }
}, []) }, [])
const { // resume last conversation when panel opens
messages, useEffect(() => {
isGenerating, if (!isOpen || resumeLoaded) return
stop,
append,
setMessages,
} = useElizaChat({
context: { view: pathname },
onAction,
onError,
})
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(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === ".") { if ((e.metaKey || e.ctrlKey) && e.key === ".") {
@ -96,17 +203,44 @@ export function ChatPanel({ className }: ChatPanelProps) {
} }
window.addEventListener("keydown", handleKeyDown) window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown) return () =>
window.removeEventListener("keydown", handleKeyDown)
}, [isOpen, setIsOpen, agentContext]) }, [isOpen, setIsOpen, agentContext])
const suggestions = getSuggestionsForPath(pathname) const suggestions = getSuggestionsForPath(pathname)
const chatMessages = messages.map((msg) => ({ const isGenerating =
id: msg.id, status === "streaming" || status === "submitted"
role: msg.role,
content: msg.content, // map UIMessage to the legacy Message format for Chat
createdAt: msg.createdAt, const chatMessages = messages.map((msg) => {
})) const parts = msg.parts as ReadonlyArray<{
type: string
text?: string
}>
return {
id: msg.id,
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( const handleRateResponse = useCallback(
( (
@ -118,6 +252,7 @@ export function ChatPanel({ className }: ChatPanelProps) {
[] []
) )
// resize state
const [panelWidth, setPanelWidth] = useState(420) const [panelWidth, setPanelWidth] = useState(420)
const [isResizing, setIsResizing] = useState(false) const [isResizing, setIsResizing] = useState(false)
const dragStartX = useRef(0) const dragStartX = useRef(0)
@ -127,7 +262,10 @@ export function ChatPanel({ className }: ChatPanelProps) {
const onMouseMove = (e: MouseEvent) => { const onMouseMove = (e: MouseEvent) => {
if (!dragStartWidth.current) return if (!dragStartWidth.current) return
const delta = dragStartX.current - e.clientX 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) setPanelWidth(next)
} }
const onMouseUp = () => { const onMouseUp = () => {
@ -157,12 +295,42 @@ export function ChatPanel({ className }: ChatPanelProps) {
[panelWidth] [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 if (pathname === "/dashboard") return null
return ( return (
<> <>
{/* Panel — mobile: full-screen overlay, desktop: integrated flex child */}
<div <div
className={cn( className={cn(
"flex flex-col bg-background", "flex flex-col bg-background",
@ -186,40 +354,47 @@ export function ChatPanel({ className }: ChatPanelProps) {
/> />
<div className="flex h-full w-full flex-col"> <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 */} {/* Chat */}
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
<Chat <Chat
messages={chatMessages} messages={chatMessages}
isGenerating={isGenerating} isGenerating={isGenerating}
stop={stop} stop={stop}
append={append} append={handleAppend}
suggestions={ suggestions={
messages.length === 0 ? suggestions : [] messages.length === 0 ? suggestions : []
} }
onRateResponse={handleRateResponse} onRateResponse={handleRateResponse}
setMessages={setMessages as never} setMessages={
setMessages as unknown as (
messages: Array<{
id: string
role: string
content: string
}>
) => void
}
className="h-full" className="h-full"
/> />
</div> </div>
{/* Dynamic UI for agent-generated components */} {/* Dynamic UI for agent-rendered components */}
{messages.some((m) => m.actions) && ( {lastRenderSpec && (
<div className="max-h-64 overflow-auto border-t p-4"> <div className="max-h-64 overflow-auto border-t p-4">
{messages <DynamicUI spec={lastRenderSpec} />
.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}
/>
)
})}
</div> </div>
)} )}
</div> </div>
@ -234,7 +409,7 @@ export function ChatPanel({ className }: ChatPanelProps) {
/> />
)} )}
{/* Mobile FAB trigger (desktop uses header button) */} {/* Mobile FAB trigger */}
{!isOpen && ( {!isOpen && (
<Button <Button
size="icon" size="icon"

View File

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

View File

@ -1,22 +1,22 @@
"use client" "use client"
import { import {
useState, useState,
useCallback, useCallback,
useRef, useRef,
useEffect, useEffect,
} from "react" } from "react"
import { usePathname } from "next/navigation" import { usePathname, useRouter } from "next/navigation"
import { import {
ArrowUp, ArrowUp,
Plus, Plus,
SendHorizonal, SendHorizonal,
Square, Square,
Copy, Copy,
ThumbsUp, ThumbsUp,
ThumbsDown, ThumbsDown,
RefreshCw, RefreshCw,
Check, Check,
} from "lucide-react" } from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@ -24,49 +24,52 @@ import { MarkdownRenderer } from "@/components/ui/markdown-renderer"
import { TypingIndicator } from "@/components/ui/typing-indicator" import { TypingIndicator } from "@/components/ui/typing-indicator"
import { PromptSuggestions } from "@/components/ui/prompt-suggestions" import { PromptSuggestions } from "@/components/ui/prompt-suggestions"
import { import {
useAutosizeTextArea, useAutosizeTextArea,
} from "@/hooks/use-autosize-textarea" } from "@/hooks/use-autosize-textarea"
import { useChat } from "@ai-sdk/react"
import { DefaultChatTransport } from "ai"
import { import {
useElizaChat, dispatchToolActions,
executeAction, initializeActionHandlers,
type AgentAction, unregisterActionHandler,
} from "@/lib/eliza/chat-adapter" ALL_HANDLER_TYPES,
} from "@/lib/agent/chat-adapter"
import { import {
IconBrandGithub, IconBrandGithub,
IconExternalLink, IconExternalLink,
IconGitFork, IconGitFork,
IconStar, IconStar,
IconAlertCircle, IconAlertCircle,
IconEye, IconEye,
} from "@tabler/icons-react" } from "@tabler/icons-react"
type RepoStats = { type RepoStats = {
readonly stargazers_count: number readonly stargazers_count: number
readonly forks_count: number readonly forks_count: number
readonly open_issues_count: number readonly open_issues_count: number
readonly subscribers_count: number readonly subscribers_count: number
} }
const REPO = "High-Performance-Structures/compass" const REPO = "High-Performance-Structures/compass"
const GITHUB_URL = `https://github.com/${REPO}` const GITHUB_URL = `https://github.com/${REPO}`
interface DashboardChatProps { interface DashboardChatProps {
readonly stats: RepoStats | null readonly stats: RepoStats | null
} }
const SUGGESTIONS = [ const SUGGESTIONS = [
"What can you help me with?", "What can you help me with?",
"Show me today's tasks", "Show me today's tasks",
"Navigate to customers", "Navigate to customers",
] ]
const ANIMATED_PLACEHOLDERS = [ const ANIMATED_PLACEHOLDERS = [
"Show me invoices from the Johnson project", "Show me open invoices",
"What tasks are due this week?", "What's on the schedule for next week?",
"Which vendors need payment?", "Which subcontractors are waiting on payment?",
"Navigate to the schedule view", "Pull up the current project timeline",
"Find overdue invoices for Highland", "Find outstanding invoices over 30 days",
"Who is assigned to concrete pour?", "Who's assigned to the foundation work?",
] ]
const LOGO_MASK = { const LOGO_MASK = {
@ -78,10 +81,25 @@ const LOGO_MASK = {
WebkitMaskRepeat: "no-repeat", WebkitMaskRepeat: "no-repeat",
} as React.CSSProperties } 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) { export function DashboardChat({ stats }: DashboardChatProps) {
const [isActive, setIsActive] = useState(false) const [isActive, setIsActive] = useState(false)
const [idleInput, setIdleInput] = useState("") const [idleInput, setIdleInput] = useState("")
const scrollRef = useRef<HTMLDivElement>(null) const scrollRef = useRef<HTMLDivElement>(null)
const router = useRouter()
const routerRef = useRef(router)
routerRef.current = router
const pathname = usePathname() const pathname = usePathname()
const [chatInput, setChatInput] = useState("") const [chatInput, setChatInput] = useState("")
const chatTextareaRef = useRef<HTMLTextAreaElement>(null) const chatTextareaRef = useRef<HTMLTextAreaElement>(null)
@ -93,25 +111,72 @@ export function DashboardChat({ stats }: DashboardChatProps) {
dependencies: [chatInput], dependencies: [chatInput],
}) })
const onAction = useCallback((action: AgentAction) => {
executeAction(action)
}, [])
const onError = useCallback((error: Error) => {
toast.error(error.message)
}, [])
const { const {
messages, messages,
isGenerating, sendMessage,
regenerate,
stop, stop,
append, status,
} = useElizaChat({ } = useChat({
context: { view: pathname }, transport: new DefaultChatTransport({
onAction, api: "/api/agent",
onError, 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>( const [copiedId, setCopiedId] = useState<string | null>(
null null
) )
@ -154,7 +219,6 @@ export function DashboardChat({ stats }: DashboardChatProps) {
setAnimFading(true) setAnimFading(true)
animTimerRef.current = setTimeout(tick, 400) animTimerRef.current = setTimeout(tick, 400)
} else { } else {
// faded out — swap to next message while invisible
msgIdx = msgIdx =
(msgIdx + 1) % ANIMATED_PLACEHOLDERS.length (msgIdx + 1) % ANIMATED_PLACEHOLDERS.length
charIdx = 1 charIdx = 1
@ -175,14 +239,92 @@ export function DashboardChat({ stats }: DashboardChatProps) {
} }
}, [isIdleFocused, idleInput, isActive]) }, [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(() => { useEffect(() => {
if (!isActive) return if (!isActive) return
const el = scrollRef.current const el = scrollRef.current
if (!el) return 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 // Escape to return to idle when no messages
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
@ -212,11 +354,11 @@ export function DashboardChat({ stats }: DashboardChatProps) {
const value = idleInput.trim() const value = idleInput.trim()
setIsActive(true) setIsActive(true)
if (value) { if (value) {
append({ role: "user", content: value }) sendMessage({ text: value })
setIdleInput("") setIdleInput("")
} }
}, },
[idleInput, append] [idleInput, sendMessage]
) )
const handleCopy = useCallback( const handleCopy = useCallback(
@ -231,9 +373,9 @@ export function DashboardChat({ stats }: DashboardChatProps) {
const handleSuggestion = useCallback( const handleSuggestion = useCallback(
(message: { role: "user"; content: string }) => { (message: { role: "user"; content: string }) => {
setIsActive(true) setIsActive(true)
append(message) sendMessage({ text: message.content })
}, },
[append] [sendMessage]
) )
return ( 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"> <div className="mx-auto w-full max-w-3xl px-4 py-4 space-y-6">
{messages.map((msg) => { {messages.map((msg) => {
const textContent = getTextFromParts(
msg.parts as ReadonlyArray<{
type: string
text?: string
}>
)
if (msg.role === "user") { if (msg.role === "user") {
return ( return (
<div <div
key={msg.id} key={msg.id}
data-role="user"
className="flex justify-end" className="flex justify-end"
> >
<div className="rounded-2xl border bg-background px-4 py-2.5 text-sm max-w-[80%] shadow-sm"> <div className="rounded-2xl border bg-background px-4 py-2.5 text-sm max-w-[80%] shadow-sm">
{msg.content} {textContent}
</div> </div>
</div> </div>
) )
@ -388,11 +538,11 @@ export function DashboardChat({ stats }: DashboardChatProps) {
key={msg.id} key={msg.id}
className="flex flex-col items-start" 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> <MarkdownRenderer>
{msg.content} {textContent}
</MarkdownRenderer> </MarkdownRenderer>
</div> </div>
<div className="mt-2 flex items-center gap-1"> <div className="mt-2 flex items-center gap-1">
@ -401,7 +551,7 @@ export function DashboardChat({ stats }: DashboardChatProps) {
onClick={() => onClick={() =>
handleCopy( handleCopy(
msg.id, msg.id,
msg.content textContent
) )
} }
className="rounded-md p-1.5 text-muted-foreground/60 transition-colors hover:bg-muted hover:text-foreground" 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>
<button <button
type="button" type="button"
onClick={() => onClick={() => regenerate()}
append({
role: "user",
content:
messages.findLast(
(m) =>
m.role === "user"
)?.content ?? "",
})
}
className="rounded-md p-1.5 text-muted-foreground/60 transition-colors hover:bg-muted hover:text-foreground" className="rounded-md p-1.5 text-muted-foreground/60 transition-colors hover:bg-muted hover:text-foreground"
aria-label="Regenerate" aria-label="Regenerate"
> >
@ -483,8 +624,9 @@ export function DashboardChat({ stats }: DashboardChatProps) {
e.preventDefault() e.preventDefault()
const trimmed = chatInput.trim() const trimmed = chatInput.trim()
if (!trimmed || isGenerating) return if (!trimmed || isGenerating) return
append({ role: "user", content: trimmed }) sendMessage({ text: trimmed })
setChatInput("") setChatInput("")
markSent()
}} }}
> >
<div <div
@ -503,11 +645,11 @@ export function DashboardChat({ stats }: DashboardChatProps) {
e.preventDefault() e.preventDefault()
const trimmed = chatInput.trim() const trimmed = chatInput.trim()
if (!trimmed || isGenerating) return if (!trimmed || isGenerating) return
append({ sendMessage({
role: "user", text: trimmed,
content: trimmed,
}) })
setChatInput("") setChatInput("")
markSent()
} }
}} }}
placeholder="Ask follow-up..." 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"], "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"] "exclude": ["node_modules", "references"]
} }