-
-
Save shricodev/595a4570477bee4c99c4872f0801037d to your computer and use it in GitHub Desktop.
Google's Gemioni 3 Pro Calendar AI Agent Test - Blog Demo
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import React, { useState, useRef, useEffect } from "react"; | |
| import { SendHorizontal } from "lucide-react"; | |
| import { cn } from "@/lib/utils"; | |
| interface ChatInputProps { | |
| onSend: (content: string) => void; | |
| disabled?: boolean; | |
| } | |
| export const ChatInput: React.FC<ChatInputProps> = ({ onSend, disabled }) => { | |
| const [input, setInput] = useState(""); | |
| const textareaRef = useRef<HTMLTextAreaElement>(null); | |
| useEffect(() => { | |
| if (textareaRef.current) { | |
| textareaRef.current.style.height = "auto"; | |
| textareaRef.current.style.height = `${Math.min( | |
| textareaRef.current.scrollHeight, | |
| 128, | |
| )}px`; | |
| } | |
| }, [input]); | |
| const handleSubmit = (e?: React.FormEvent) => { | |
| e?.preventDefault(); | |
| if (!input.trim() || disabled) return; | |
| onSend(input); | |
| setInput(""); | |
| }; | |
| const handleKeyDown = (e: React.KeyboardEvent) => { | |
| if (e.key === "Enter" && !e.shiftKey) { | |
| e.preventDefault(); | |
| handleSubmit(); | |
| } | |
| }; | |
| return ( | |
| <div className="w-full bg-white dark:bg-zinc-950 border-t border-zinc-200 dark:border-zinc-800 p-4"> | |
| <form | |
| onSubmit={handleSubmit} | |
| className="max-w-3xl mx-auto relative flex items-end gap-2 bg-zinc-100 dark:bg-zinc-900 p-2 rounded-2xl border border-transparent focus-within:border-blue-500/50 focus-within:ring-2 focus-within:ring-blue-500/20 transition-all shadow-sm" | |
| > | |
| <textarea | |
| ref={textareaRef} | |
| value={input} | |
| onChange={(e) => setInput(e.target.value)} | |
| onKeyDown={handleKeyDown} | |
| placeholder="Ask to schedule a meeting..." | |
| disabled={disabled} | |
| rows={1} | |
| className="w-full bg-transparent border-none focus:ring-0 resize-none max-h-32 py-3 px-2 text-sm text-zinc-900 dark:text-zinc-100 placeholder:text-zinc-500 dark:placeholder:text-zinc-400 leading-normal" | |
| style={{ minHeight: "44px" }} | |
| /> | |
| <button | |
| type="submit" | |
| disabled={!input.trim() || disabled} | |
| className={cn( | |
| "p-2 rounded-xl mb-1 transition-all duration-200 ease-in-out flex-shrink-0", | |
| input.trim() && !disabled | |
| ? "bg-blue-600 text-white hover:bg-blue-700 shadow-md shadow-blue-600/20 active:scale-95" | |
| : "bg-zinc-200 text-zinc-400 dark:bg-zinc-800 dark:text-zinc-600 cursor-not-allowed", | |
| )} | |
| aria-label="Send message" | |
| > | |
| <SendHorizontal className="w-5 h-5" /> | |
| </button> | |
| </form> | |
| <div className="text-center mt-3"> | |
| <p className="text-[10px] uppercase tracking-widest text-zinc-400 dark:text-zinc-600 font-semibold"> | |
| Powered by Composio Tool Router | |
| </p> | |
| </div> | |
| </div> | |
| ); | |
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import React from "react"; | |
| import { Message } from "@/app/types"; | |
| import { MeetingCard } from "./MeetingCard"; | |
| import { cn } from "@/lib/utils"; | |
| import { Bot, User } from "lucide-react"; | |
| interface ChatMessageProps { | |
| message: Message; | |
| } | |
| export const ChatMessage: React.FC<ChatMessageProps> = ({ message }) => { | |
| const isUser = message.role === "user"; | |
| return ( | |
| <div className={cn( | |
| "flex w-full mb-6", | |
| isUser ? "justify-end" : "justify-start" | |
| )}> | |
| <div className={cn( | |
| "flex max-w-[80%] md:max-w-[70%] gap-3", | |
| isUser ? "flex-row-reverse" : "flex-row" | |
| )}> | |
| {/* Avatar */} | |
| <div className={cn( | |
| "w-8 h-8 rounded-full flex items-center justify-center shrink-0", | |
| isUser ? "bg-blue-500 text-white" : "bg-zinc-200 text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300" | |
| )}> | |
| {isUser ? <User className="w-5 h-5" /> : <Bot className="w-5 h-5" />} | |
| </div> | |
| {/* Content */} | |
| <div className="flex flex-col items-start"> | |
| <div className={cn( | |
| "px-4 py-3 rounded-2xl text-sm whitespace-pre-wrap", | |
| isUser | |
| ? "bg-blue-500 text-white rounded-tr-none" | |
| : "bg-zinc-100 dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 rounded-tl-none" | |
| )}> | |
| {message.content} | |
| {message.isPending && ( | |
| <span className="inline-flex ml-2"> | |
| <span className="animate-bounce mx-0.5">.</span> | |
| <span className="animate-bounce mx-0.5 delay-100">.</span> | |
| <span className="animate-bounce mx-0.5 delay-200">.</span> | |
| </span> | |
| )} | |
| </div> | |
| {/* Tool Response (Meeting Card) */} | |
| {message.toolResponse && ( | |
| <MeetingCard toolResponse={message.toolResponse} className="mt-3" /> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import type { Metadata } from "next"; | |
| import { Geist, Geist_Mono } from "next/font/google"; | |
| import "./globals.css"; | |
| const geistSans = Geist({ | |
| variable: "--font-geist-sans", | |
| subsets: ["latin"], | |
| }); | |
| const geistMono = Geist_Mono({ | |
| variable: "--font-geist-mono", | |
| subsets: ["latin"], | |
| }); | |
| export const metadata: Metadata = { | |
| title: "Create Next App", | |
| description: "Generated by create next app", | |
| }; | |
| export default function RootLayout({ | |
| children, | |
| }: Readonly<{ | |
| children: React.ReactNode; | |
| }>) { | |
| return ( | |
| <html lang="en"> | |
| <body | |
| className={`${geistSans.variable} ${geistMono.variable} antialiased`} | |
| > | |
| {children} | |
| </body> | |
| </html> | |
| ); | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import React from "react"; | |
| import { GoogleCalendarEventData, ToolResponse } from "@/app/types"; | |
| import { format, parseISO } from "date-fns"; | |
| import { Calendar, Clock, User, Users, ExternalLink } from "lucide-react"; | |
| import { cn } from "@/lib/utils"; | |
| interface MeetingCardProps { | |
| toolResponse: ToolResponse; | |
| className?: string; | |
| } | |
| export const MeetingCard: React.FC<MeetingCardProps> = ({ | |
| toolResponse, | |
| className, | |
| }) => { | |
| // Recursive function to find the Google Calendar event object | |
| // It looks for an object that has 'kind': 'calendar#event' or keys like 'summary' + 'start' | |
| const findEventData = (obj: unknown): GoogleCalendarEventData | null => { | |
| if (!obj || typeof obj !== "object") return null; | |
| const record = obj as Record<string, unknown>; | |
| // Check if this object IS the event | |
| // We cast to unknown first then verify structure, but for simplicity in this recursive search | |
| // we just check for key existence. | |
| if (record.kind === "calendar#event") return record as GoogleCalendarEventData; | |
| if (record.summary && record.start && record.end) return record as GoogleCalendarEventData; | |
| // Check specific keys that might hold it | |
| if (record.response_data) return findEventData(record.response_data); | |
| if (record.data) return findEventData(record.data); | |
| if (record.result) return findEventData(record.result); | |
| // Check arrays (Composio often returns arrays of results) | |
| if (Array.isArray(obj)) { | |
| for (const item of obj) { | |
| const found = findEventData(item); | |
| if (found) return found; | |
| } | |
| return null; | |
| } | |
| // Check 'results' explicitly | |
| if (Array.isArray(record.results)) { | |
| for (const item of record.results) { | |
| const found = findEventData(item); | |
| if (found) return found; | |
| } | |
| } | |
| if (record.response) return findEventData(record.response); | |
| return null; | |
| }; | |
| const event = findEventData(toolResponse); | |
| if (!event) { | |
| return null; | |
| } | |
| // Determine Status | |
| // 1. Check explicit status in event | |
| // 2. Check if toolType (if we had it) implied deletion | |
| // 3. Check if the event status itself is 'cancelled' | |
| let statusLabel = "Created"; | |
| if (event.status === "cancelled") { | |
| statusLabel = "Cancelled"; | |
| } else if (toolResponse.toolType?.toLowerCase().includes("delete")) { | |
| statusLabel = "Cancelled"; | |
| } | |
| const startTime = event.start?.dateTime | |
| ? parseISO(event.start.dateTime) | |
| : null; | |
| const endTime = event.end?.dateTime ? parseISO(event.end.dateTime) : null; | |
| return ( | |
| <div | |
| className={cn( | |
| "bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-lg shadow-sm overflow-hidden w-full max-w-md my-2", | |
| className, | |
| )} | |
| > | |
| {/* Header with Status */} | |
| <div | |
| className={cn( | |
| "px-4 py-2 text-xs font-semibold uppercase tracking-wider border-b", | |
| statusLabel === "Cancelled" | |
| ? "bg-red-50 text-red-700 border-red-100 dark:bg-red-900/20 dark:text-red-400 dark:border-red-900/50" | |
| : "bg-green-50 text-green-700 border-green-100 dark:bg-green-900/20 dark:text-green-400 dark:border-green-900/50", | |
| )} | |
| > | |
| Status: {statusLabel} | |
| </div> | |
| <div className="p-4 space-y-4"> | |
| {/* Title */} | |
| <h3 className="text-lg font-bold text-zinc-900 dark:text-zinc-100"> | |
| {event.summary || "No Title"} | |
| </h3> | |
| {/* Time */} | |
| <div className="flex items-start gap-3 text-sm text-zinc-600 dark:text-zinc-400"> | |
| <Calendar className="w-4 h-4 mt-0.5 shrink-0" /> | |
| <div className="flex flex-col"> | |
| {startTime && ( | |
| <span>{format(startTime, "EEEE, MMMM d, yyyy")}</span> | |
| )} | |
| <div className="flex items-center gap-1 mt-0.5"> | |
| <Clock className="w-3 h-3" /> | |
| <span> | |
| {startTime ? format(startTime, "h:mm a") : "--"} -{" "} | |
| {endTime ? format(endTime, "h:mm a") : "--"} | |
| </span> | |
| {event.start?.timeZone && ( | |
| <span className="text-xs text-zinc-500 ml-1"> | |
| ({event.start.timeZone}) | |
| </span> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| {/* Organizer */} | |
| {event.organizer && ( | |
| <div className="flex items-center gap-3 text-sm text-zinc-600 dark:text-zinc-400"> | |
| <User className="w-4 h-4 shrink-0" /> | |
| <div className="flex flex-col"> | |
| <span className="text-xs font-medium text-zinc-500 uppercase"> | |
| Organizer | |
| </span> | |
| <span>{event.organizer.email}</span> | |
| </div> | |
| </div> | |
| )} | |
| {/* Attendees */} | |
| {event.attendees && event.attendees.length > 0 && ( | |
| <div className="flex items-start gap-3 text-sm text-zinc-600 dark:text-zinc-400"> | |
| <Users className="w-4 h-4 mt-0.5 shrink-0" /> | |
| <div className="flex flex-col w-full"> | |
| <span className="text-xs font-medium text-zinc-500 uppercase mb-1"> | |
| Attendees | |
| </span> | |
| <ul className="space-y-1"> | |
| {event.attendees.map((attendee, idx) => ( | |
| <li | |
| key={idx} | |
| className="flex items-center justify-between w-full" | |
| > | |
| <span | |
| className="truncate max-w-[180px]" | |
| title={attendee.email} | |
| > | |
| {attendee.email} | |
| </span> | |
| {attendee.responseStatus && ( | |
| <span | |
| className={cn( | |
| "text-[10px] px-1.5 py-0.5 rounded-full capitalize", | |
| attendee.responseStatus === "accepted" | |
| ? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" | |
| : attendee.responseStatus === "declined" | |
| ? "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400" | |
| : "bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400", | |
| )} | |
| > | |
| {attendee.responseStatus} | |
| </span> | |
| )} | |
| </li> | |
| ))} | |
| </ul> | |
| </div> | |
| </div> | |
| )} | |
| {/* Link */} | |
| {event.htmlLink && ( | |
| <div className="pt-2 mt-2 border-t border-zinc-100 dark:border-zinc-800"> | |
| <a | |
| href={event.htmlLink} | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| className="flex items-center justify-center gap-2 w-full py-2 text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 transition-colors" | |
| > | |
| View in Google Calendar | |
| <ExternalLink className="w-3 h-3" /> | |
| </a> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| "use client"; | |
| import React, { useState } from "react"; | |
| import { Message } from "./types"; | |
| import { ChatMessage } from "@/components/ChatMessage"; | |
| import { ChatInput } from "@/components/ChatInput"; | |
| import { Bot } from "lucide-react"; | |
| export default function ChatPage() { | |
| const [messages, setMessages] = useState<Message[]>([ | |
| { | |
| id: "welcome", | |
| role: "assistant", | |
| content: "Hello! I can help you schedule or cancel Google Calendar meetings. What would you like to do?", | |
| }, | |
| ]); | |
| const [loading, setLoading] = useState(false); | |
| const handleSend = async (content: string) => { | |
| const userMsg: Message = { | |
| id: Date.now().toString(), | |
| role: "user", | |
| content, | |
| }; | |
| const newMessages = [...messages, userMsg]; | |
| setMessages(newMessages); | |
| setLoading(true); | |
| // Add a temporary pending message | |
| const pendingId = "pending-" + Date.now(); | |
| setMessages((prev) => [ | |
| ...prev, | |
| { | |
| id: pendingId, | |
| role: "assistant", | |
| content: "Thinking...", | |
| isPending: true, | |
| }, | |
| ]); | |
| try { | |
| const response = await fetch("/api/chat", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ messages: newMessages }), | |
| }); | |
| if (!response.ok) { | |
| throw new Error("Failed to fetch response"); | |
| } | |
| const data = await response.json(); | |
| // Remove pending message and add actual response | |
| setMessages((prev) => { | |
| const filtered = prev.filter((m) => m.id !== pendingId); | |
| return [ | |
| ...filtered, | |
| { | |
| id: Date.now().toString(), | |
| role: "assistant", | |
| content: data.content, | |
| toolResponse: data.toolResponse, | |
| }, | |
| ]; | |
| }); | |
| } catch (error) { | |
| console.error(error); | |
| setMessages((prev) => { | |
| const filtered = prev.filter((m) => m.id !== pendingId); | |
| return [ | |
| ...filtered, | |
| { | |
| id: Date.now().toString(), | |
| role: "assistant", | |
| content: "Sorry, something went wrong. Please try again.", | |
| }, | |
| ]; | |
| }); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }; | |
| return ( | |
| <div className="flex flex-col h-screen bg-white dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100 font-sans"> | |
| {/* Header */} | |
| <header className="flex items-center px-6 py-4 border-b border-zinc-200 dark:border-zinc-800 bg-white/80 dark:bg-zinc-950/80 backdrop-blur-md sticky top-0 z-10"> | |
| <div className="w-10 h-10 bg-blue-600 rounded-xl flex items-center justify-center text-white shadow-lg shadow-blue-600/20 mr-4"> | |
| <Bot className="w-6 h-6" /> | |
| </div> | |
| <div> | |
| <h1 className="text-xl font-bold tracking-tight">Calendar Agent</h1> | |
| <p className="text-sm text-zinc-500 dark:text-zinc-400">Powered by Composio Tool Router</p> | |
| </div> | |
| </header> | |
| {/* Chat Area */} | |
| <main className="flex-1 overflow-y-auto p-4 md:p-6 scroll-smooth"> | |
| <div className="max-w-3xl mx-auto space-y-6 pb-4"> | |
| {messages.map((msg) => ( | |
| <ChatMessage key={msg.id} message={msg} /> | |
| ))} | |
| </div> | |
| </main> | |
| {/* Input Area */} | |
| <ChatInput onSend={handleSend} disabled={loading} /> | |
| </div> | |
| ); | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { NextResponse } from "next/server"; | |
| import { Composio } from "@composio/core"; | |
| import { OpenAIAgentsProvider } from "@composio/openai-agents"; | |
| import { Agent, hostedMcpTool, run } from "@openai/agents"; | |
| // Ensure API keys are present | |
| const COMPOSIO_API_KEY = process.env.COMPOSIO_API_KEY; | |
| const OPENAI_API_KEY = process.env.OPENAI_API_KEY; | |
| export async function POST(req: Request) { | |
| try { | |
| if (!COMPOSIO_API_KEY || !OPENAI_API_KEY) { | |
| return NextResponse.json({ error: "Missing API Keys" }, { status: 500 }); | |
| } | |
| const body = await req.json(); | |
| const { messages } = body; | |
| // Simple session ID or user ID | |
| const userId = "user-session-" + new Date().toISOString().split("T")[0]; | |
| const composio = new Composio({ | |
| apiKey: COMPOSIO_API_KEY, | |
| provider: new OpenAIAgentsProvider(), | |
| }); | |
| // Create Tool Router Session | |
| // We limit to google_calendar as per instructions to "create/delete calendar meetings" | |
| // The prompt example used 'gmail', 'github'. I'll use 'google_calendar'. | |
| const session = await composio.experimental.toolRouter.createSession( | |
| userId, | |
| { | |
| toolkits: ["googlecalendar"], | |
| }, | |
| ); | |
| const agent = new Agent({ | |
| name: "Calendar Assistant", | |
| model: "gpt-4o", | |
| instructions: `You are a helpful assistant that manages Google Calendar. | |
| You can create and delete meetings. | |
| RULES: | |
| 1. When asked to create a meeting, identify: attendees, date/time, duration, title. | |
| 2. Before calling the tool, confirm the details with the user. | |
| 3. When asked to delete, identify the meeting (time, attendee, etc). Confirm if ambiguous. | |
| 4. If the tool fails, describe the error clearly. | |
| `, | |
| tools: [ | |
| hostedMcpTool({ | |
| serverLabel: "tool_router", | |
| serverUrl: session.url, | |
| }), | |
| ], | |
| }); | |
| // Construct the "task" from the conversation history | |
| const transcript = messages | |
| .map((m: { role: string; content: string }) => `${m.role.toUpperCase()}: ${m.content}`) | |
| .join("\n\n"); | |
| const result = await run(agent, transcript); | |
| console.log("result finaloutput:", result.finalOutput); | |
| console.log("result newItems:", result.newItems); | |
| let toolResponseData = null; | |
| // Look for tool execution results in the new items | |
| if (result.newItems && Array.isArray(result.newItems)) { | |
| // We look for the last tool message to show the most relevant card | |
| const toolItem = result.newItems | |
| .slice() | |
| .reverse() | |
| .find( | |
| (item: { type: string; rawItem?: { output?: string } }) => | |
| item.type === "tool_call_item" && | |
| item.rawItem && | |
| item.rawItem.output | |
| ); | |
| if (toolItem && toolItem.rawItem?.output) { | |
| try { | |
| const parsedContent = JSON.parse(toolItem.rawItem.output); | |
| toolResponseData = { | |
| toolType: "google_calendar", // Assuming this based on the toolkit | |
| result: parsedContent, | |
| }; | |
| } catch (e) { | |
| console.error("Error parsing tool response:", e); | |
| // If it's not JSON, we might pass it as raw text or ignore it | |
| // For now, we ignore non-JSON tool outputs for the card | |
| } | |
| } | |
| } | |
| return NextResponse.json({ | |
| role: "assistant", | |
| content: result.finalOutput, | |
| toolResponse: toolResponseData, | |
| }); | |
| } catch (error: unknown) { | |
| console.error("Error in chat route:", error); | |
| const errorMessage = | |
| error instanceof Error ? error.message : "Internal Server Error"; | |
| return NextResponse.json({ error: errorMessage }, { status: 500 }); | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| export interface GoogleCalendarStartEnd { | |
| dateTime?: string; | |
| timeZone?: string; | |
| } | |
| export interface GoogleCalendarAttendee { | |
| email?: string; | |
| organizer?: boolean; | |
| responseStatus?: string; | |
| self?: boolean; | |
| } | |
| export interface GoogleCalendarOrganizer { | |
| email?: string; | |
| self?: boolean; | |
| } | |
| export interface GoogleCalendarEventData { | |
| summary?: string; | |
| htmlLink?: string; | |
| start?: GoogleCalendarStartEnd; | |
| end?: GoogleCalendarStartEnd; | |
| attendees?: GoogleCalendarAttendee[]; | |
| organizer?: GoogleCalendarOrganizer; | |
| status?: string; // Add status field | |
| } | |
| export interface ToolResponse { | |
| toolType?: string; | |
| result?: unknown; | |
| } | |
| export type Role = "user" | "assistant" | "system"; | |
| export interface Message { | |
| id: string; | |
| role: Role; | |
| content: string; | |
| toolResponse?: ToolResponse; | |
| isPending?: boolean; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment