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",
|
"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=="],
|
||||||
|
|||||||
@ -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
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 @@
|
|||||||
/**
|
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()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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
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"],
|
"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