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:
parent
abb2ac6780
commit
e9faea5596
10
bun.lock
10
bun.lock
@ -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=="],
|
||||
|
||||
@ -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
204
src/app/actions/agent.ts
Executable 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",
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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}`
|
||||
)
|
||||
export async function POST(req: Request): Promise<Response> {
|
||||
const user = await getCurrentUser()
|
||||
if (!user) {
|
||||
return new Response("Unauthorized", { status: 401 })
|
||||
}
|
||||
|
||||
const data: ElizaSessionResponse = await response.json()
|
||||
return data.id
|
||||
}
|
||||
|
||||
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 body = await req.json() as {
|
||||
messages: UIMessage[]
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
@ -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) => ({
|
||||
id: msg.id,
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
createdAt: msg.createdAt,
|
||||
}))
|
||||
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 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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -1,22 +1,22 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
useState,
|
||||
useCallback,
|
||||
useRef,
|
||||
useEffect,
|
||||
useState,
|
||||
useCallback,
|
||||
useRef,
|
||||
useEffect,
|
||||
} from "react"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import {
|
||||
ArrowUp,
|
||||
Plus,
|
||||
SendHorizonal,
|
||||
Square,
|
||||
Copy,
|
||||
ThumbsUp,
|
||||
ThumbsDown,
|
||||
RefreshCw,
|
||||
Check,
|
||||
ArrowUp,
|
||||
Plus,
|
||||
SendHorizonal,
|
||||
Square,
|
||||
Copy,
|
||||
ThumbsUp,
|
||||
ThumbsDown,
|
||||
RefreshCw,
|
||||
Check,
|
||||
} from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { cn } from "@/lib/utils"
|
||||
@ -24,49 +24,52 @@ import { MarkdownRenderer } from "@/components/ui/markdown-renderer"
|
||||
import { TypingIndicator } from "@/components/ui/typing-indicator"
|
||||
import { PromptSuggestions } from "@/components/ui/prompt-suggestions"
|
||||
import {
|
||||
useAutosizeTextArea,
|
||||
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,
|
||||
IconGitFork,
|
||||
IconStar,
|
||||
IconAlertCircle,
|
||||
IconEye,
|
||||
IconBrandGithub,
|
||||
IconExternalLink,
|
||||
IconGitFork,
|
||||
IconStar,
|
||||
IconAlertCircle,
|
||||
IconEye,
|
||||
} from "@tabler/icons-react"
|
||||
|
||||
type RepoStats = {
|
||||
readonly stargazers_count: number
|
||||
readonly forks_count: number
|
||||
readonly open_issues_count: number
|
||||
readonly subscribers_count: number
|
||||
readonly stargazers_count: number
|
||||
readonly forks_count: number
|
||||
readonly open_issues_count: number
|
||||
readonly subscribers_count: number
|
||||
}
|
||||
|
||||
const REPO = "High-Performance-Structures/compass"
|
||||
const GITHUB_URL = `https://github.com/${REPO}`
|
||||
|
||||
interface DashboardChatProps {
|
||||
readonly stats: RepoStats | null
|
||||
readonly stats: RepoStats | null
|
||||
}
|
||||
|
||||
const SUGGESTIONS = [
|
||||
"What can you help me with?",
|
||||
"Show me today's tasks",
|
||||
"Navigate to customers",
|
||||
"What can you help me with?",
|
||||
"Show me today's tasks",
|
||||
"Navigate to customers",
|
||||
]
|
||||
|
||||
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
157
src/lib/agent/chat-adapter.ts
Executable 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
23
src/lib/agent/provider.ts
Executable 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
64
src/lib/agent/system-prompt.ts
Executable 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
224
src/lib/agent/tools.ts
Executable 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,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}
|
||||
@ -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
|
||||
@ -27,5 +27,5 @@
|
||||
]
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": ["node_modules", "references"]
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user