Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,21 +1,30 @@
import {
DefaultChatTransport,
getToolName,
isToolUIPart,
lastAssistantMessageIsCompleteWithToolCalls,
TextUIPart,
UIMessage,
} from "ai";
import { Loader2, Send, Trash2 } from "lucide-react";
import { useState } from "react";

import { useChat } from "@ai-sdk/react";
import { useInterruptions } from "@auth0/ai-vercel/react";
import { FederatedConnectionInterrupt } from "@auth0/ai/interrupts";
import { HITL_APPROVAL } from "@auth0/auth0-ai-js-examples-react-hono-ai-sdk-shared";

import { useAuth0 } from "../hooks/useAuth0";
import {
AddToolResultFn,
PendingToolInputPart,
ToolPartWithOutput,
} from "../types/tool-parts";
import { FederatedConnectionPopup } from "./FederatedConnectionPopup";
import { Button } from "./ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
import { Input } from "./ui/input";

import type { TextUIPart, UIMessage } from "ai";
const SERVER_URL = import.meta.env.VITE_SERVER_URL || "http://localhost:3000";

export function Chat() {
Expand Down Expand Up @@ -49,7 +58,6 @@ export function Chat() {
chatHelpers;

const clearMessages = () => {
// Use setMessages to properly clear the chat history
setMessages([]);
};

Expand All @@ -71,6 +79,12 @@ export function Chat() {
)}
</CardHeader>
<CardContent className="space-y-4">
{/* Approval banner for callProtectedApi */}
<ApprovalPrompt
messages={messages}
addToolResult={chatHelpers.addToolResult}
sendMessage={() => sendMessage()}
/>
{/* Messages */}
<div className="space-y-4 max-h-96 overflow-y-auto">
{messages.length === 0 ? (
Expand Down Expand Up @@ -142,11 +156,24 @@ export function Chat() {
function MessageBubble({ message }: { message: UIMessage }) {
const isUser = message.role === "user";

// Get all text content from the message parts
const textContent = message.parts
.filter((part) => part.type === "text")
.map((part) => (part as TextUIPart).text)
.join("");
const combinedContent = (message.parts || [])
.map((part) => {
if (part.type === "text") return (part as TextUIPart).text;
if (isToolUIPart(part)) {
const toolName = getToolName(part);
const p = part as ToolPartWithOutput; // narrowed custom type
if (
toolName === "callProtectedApi" &&
p.state === "output-available" &&
typeof p.output === "string"
) {
return p.output;
}
}
return "";
})
.filter(Boolean)
.join("\n");

return (
<div className={`flex ${isUser ? "justify-end" : "justify-start"}`}>
Expand All @@ -155,9 +182,80 @@ function MessageBubble({ message }: { message: UIMessage }) {
isUser ? "bg-primary text-primary-foreground" : "bg-muted"
}`}
>
<p className="text-sm whitespace-pre-wrap">
{textContent}
</p>
<p className="text-sm whitespace-pre-wrap">{combinedContent}</p>
</div>
</div>
);
}

function ApprovalPrompt({
messages,
addToolResult,
sendMessage,
}: {
messages: UIMessage[];
addToolResult: AddToolResultFn;
sendMessage: () => Promise<unknown> | void;
}) {
let pending: PendingToolInputPart | null = null;
outer: for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i];
for (const part of m.parts || []) {
if (isToolUIPart(part)) {
const toolName = getToolName(part);
if (
toolName === "callProtectedApi" &&
part.state === "input-available"
) {
pending = part as unknown as PendingToolInputPart;
break outer;
}
}
}
}
if (!pending) return null;
const reason =
typeof pending.input === "object" &&
pending.input &&
"reason" in pending.input
? String((pending.input as { reason: unknown }).reason)
: "(none provided)";
const toolCallId = pending.toolCallId;

return (
<div className="border border-amber-400 bg-amber-50 text-amber-900 rounded-md p-3 text-sm flex flex-col gap-2">
<div className="font-medium">Protected API access requested</div>
<div>The assistant wants to access protected data.</div>
<div className="text-xs">Reason: {reason}</div>
<div className="flex gap-2 mt-1">
<Button
size="sm"
variant="success"
onClick={async () => {
await addToolResult({
tool: "callProtectedApi",
toolCallId,
output: HITL_APPROVAL.YES,
});
sendMessage();
}}
>
Approve
</Button>
<Button
size="sm"
variant="destructive"
onClick={async () => {
await addToolResult({
tool: "callProtectedApi",
toolCallId,
output: HITL_APPROVAL.NO,
});
sendMessage();
}}
>
Deny
</Button>
</div>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cva, VariantProps } from "class-variance-authority";
import * as React from "react";

import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
import { Slot } from "@radix-ui/react-slot";

const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
Expand All @@ -20,6 +20,8 @@ const buttonVariants = cva(
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
success:
"bg-green-600 text-white shadow-xs hover:bg-green-600/90 focus-visible:ring-green-600/20 dark:focus-visible:ring-green-400/40",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
Expand All @@ -33,7 +35,7 @@ const buttonVariants = cva(
size: "default",
},
}
)
);

function Button({
className,
Expand All @@ -43,17 +45,18 @@ function Button({
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button"
const Comp = asChild ? Slot : "button";

return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
);
}

export { Button, buttonVariants }
export { Button, buttonVariants };
export default Button;
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { UIMessage } from "ai";

// Generic extraction of any tool part from UIMessage parts
export type AnyToolPart = Extract<
NonNullable<UIMessage["parts"]>[number],
{ type: `tool-${string}` }
> & {
toolCallId?: string; // some SDK versions always include this, make optional to be safe
state?: string;
input?: Record<string, unknown>;
output?: unknown;
};

// Pending tool input part (awaiting human approval or execution)
export type PendingToolInputPart = AnyToolPart & {
toolCallId: string;
state: "input-available";
input?: Record<string, unknown>;
};

// Tool part with an output available
export type ToolPartWithOutput = AnyToolPart & {
toolCallId: string;
state: "output-available";
output?: unknown;
};

// Narrow helper type guards if needed later
export function isPendingToolInputPart(
part: AnyToolPart
): part is PendingToolInputPart {
return (
part.type.startsWith("tool-") &&
part.state === "input-available" &&
typeof part.toolCallId === "string"
);
}

export function isToolPartWithOutput(
part: AnyToolPart
): part is ToolPartWithOutput {
return (
part.type.startsWith("tool-") &&
part.state === "output-available" &&
typeof part.toolCallId === "string"
);
}

// addToolResult payload shape (re-usable)
export type AddToolResultPayload = {
tool: string;
toolCallId: string;
output: string | Record<string, unknown>;
};

export type AddToolResultFn = (
payload: AddToolResultPayload
) => Promise<unknown> | void;
Original file line number Diff line number Diff line change
Expand Up @@ -19,62 +19,49 @@ import {
import { serve } from "@hono/node-server";

import { createGoogleCalendarTool } from "./lib/auth";
import { processProtectedApprovals } from "./lib/hitl/processProtectedApprovals";
import { createCallProtectedApiTool } from "./lib/tools/callProtectedApi";
import { createListNearbyEventsTool } from "./lib/tools/listNearbyEvents";
import { createListUserCalendarsTool } from "./lib/tools/listUserCalendars";
import { jwtAuthMiddleware } from "./middleware/auth";

import type { UIMessage } from "ai";
import type { ApiResponse } from "@auth0/auth0-ai-js-examples-react-hono-ai-sdk-shared";

const getAllowedOrigins = (): string[] => {
const allowedOrigins = process.env.ALLOWED_ORIGINS;
if (!allowedOrigins) {
// Fallback to default origins if not set
return ["http://localhost:5173", "http://localhost:3000"];
}
return allowedOrigins.split(",").map((origin) => origin.trim());
};

export const app = new Hono()

.use(
cors({
origin: getAllowedOrigins(),
allowHeaders: ["Content-Type", "Authorization"],
allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
})
)

.get("/", (c) => {
return c.text("Hello Hono!");
})

.get("/", (c) => c.text("Hello Hono!"))
.get("/hello", async (c) => {
const data: ApiResponse = {
message: "Hello BHVR!",
success: true,
};
const data: ApiResponse = { message: "Hello BHVR!", success: true };
console.log("✅ Success! Public /hello route called!");
return c.json(data, { status: 200 });
})

// Protected API route
.get("/api/external", jwtAuthMiddleware(), async (c) => {
const auth = c.get("auth");

const data: ApiResponse = {
message: `Your access token was successfully validated! Welcome ${auth?.jwtPayload.sub}`,
success: true,
};

return c.json(data, { status: 200 });
})

.post("/chat", jwtAuthMiddleware(), async (c) => {
const auth = c.get("auth");

console.log("🔐 Authenticated user:", auth?.jwtPayload.sub);

const { messages: requestMessages } = await c.req.json();
const { messages: requestMessages }: { messages: UIMessage[] } =
await c.req.json();

// Generate a thread ID for this conversation
const threadID = generateId();
Expand All @@ -91,23 +78,28 @@ export const app = new Hono()
googleCalendarWrapper
);

// Use the messages from the request directly
const tools = { listNearbyEvents, listUserCalendars };
// Create Protected API tool with Human In the Loop approval configuration
const callProtectedApi = createCallProtectedApiTool(c);

// note: you can see more examples of Hono API consumption with AI SDK here:
// https://ai-sdk.dev/cookbook/api-servers/hono?utm_source=chatgpt.com#hono
const tools = { listNearbyEvents, listUserCalendars, callProtectedApi };

const stream = createUIMessageStream({
originalMessages: requestMessages,
execute: withInterruptions(
async ({ writer }) => {
// Process any approved / denied protected API invocation (post-approval step)
await processProtectedApprovals({
messages: requestMessages,
writer,
auth,
});

const result = streamText({
model: openai("gpt-4o-mini"),
system:
"You are a helpful calendar assistant! You can help users with their calendar events and schedules. Keep your responses concise and helpful. Always format your responses as plain text. Do not use markdown formatting like **bold**, ##headers, or -bullet points. Use simple text formatting with line breaks and indentation only.",
"You are a helpful calendar assistant! Keep your responses concise and helpful. Always format your responses as plain text. Do not use markdown formatting like **bold**, ##headers, or -bullet points. Use simple text formatting with line breaks and indentation only. When a user asks to access protected data you MUST call callProtectedApi with a reason.",
messages: convertToModelMessages(requestMessages),
tools,

onFinish: (output) => {
if (output.finishReason === "tool-calls") {
const lastMessage = output.content[output.content.length - 1];
Expand Down Expand Up @@ -147,11 +139,6 @@ export const app = new Hono()

// Start the server for Node.js
const port = Number(process.env.PORT) || 3000;

console.log(`🚀 Server starting on port ${port}`);
serve({
fetch: app.fetch,
port,
});

serve({ fetch: app.fetch, port });
console.log(`✅ Server running on http://localhost:${port}`);
Loading