diff --git a/bun.lock b/bun.lock index 5b1c2d6..6820e0b 100755 --- a/bun.lock +++ b/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=="], diff --git a/package.json b/package.json index 47e2fab..673a88f 100755 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/actions/agent.ts b/src/app/actions/agent.ts new file mode 100755 index 0000000..83f0ba3 --- /dev/null +++ b/src/app/actions/agent.ts @@ -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 + readonly createdAt?: string +} + +export async function saveConversation( + conversationId: string, + messages: ReadonlyArray, + 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 + 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", + } + } +} diff --git a/src/app/api/agent/route.ts b/src/app/api/agent/route.ts index 9137da6..075246b 100755 --- a/src/app/api/agent/route.ts +++ b/src/app/api/agent/route.ts @@ -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 - sessionStatus?: Record -} - -async function getOrCreateSession( - userId: string, - conversationId?: string -): Promise { - 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 { + 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 { - 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 } - | 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 { - 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() } diff --git a/src/components/agent/chat-panel.tsx b/src/components/agent/chat-panel.tsx index 2fcf829..bbbf081 100755 --- a/src/components/agent/chat-panel.tsx +++ b/src/components/agent/chat-panel.tsx @@ -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 */}
+ {/* Header with new chat button */} + {messages.length > 0 && ( +
+ +
+ )} + {/* Chat */}
+ ) => void + } className="h-full" />
- {/* Dynamic UI for agent-generated components */} - {messages.some((m) => m.actions) && ( + {/* Dynamic UI for agent-rendered components */} + {lastRenderSpec && (
- {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 ( - - ) - })} +
)}
@@ -234,7 +409,7 @@ export function ChatPanel({ className }: ChatPanelProps) { /> )} - {/* Mobile FAB trigger (desktop uses header button) */} + {/* Mobile FAB trigger */} {!isOpen && (