From b3575222290877d6c36e8ec161402c900f2717f3 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Sun, 11 Jan 2026 12:31:14 +0100 Subject: [PATCH 1/8] WIP Signed-off-by: Peter Wielander --- flight-booking-app/app/api/chat/[id]/route.ts | 13 ++ .../app/api/chat/[id]/stream/route.ts | 30 +-- flight-booking-app/app/api/chat/route.ts | 42 ++-- .../app/api/hooks/approval/route.ts | 12 ++ flight-booking-app/app/page.tsx | 198 ++++++++++++------ .../components/booking-approval.tsx | 93 ++++++++ flight-booking-app/components/chat-input.tsx | 2 + .../components/use-multi-turn-chat.ts | 157 ++++++++++++++ flight-booking-app/instrumentation.ts | 2 +- .../workflows/chat/hooks/approval.ts | 9 + .../workflows/chat/hooks/chat-message.ts | 8 + flight-booking-app/workflows/chat/index.ts | 102 +++++++-- .../workflows/chat/steps/tools.ts | 43 +++- 13 files changed, 594 insertions(+), 117 deletions(-) create mode 100644 flight-booking-app/app/api/chat/[id]/route.ts create mode 100644 flight-booking-app/app/api/hooks/approval/route.ts create mode 100644 flight-booking-app/components/booking-approval.tsx create mode 100644 flight-booking-app/components/use-multi-turn-chat.ts create mode 100644 flight-booking-app/workflows/chat/hooks/approval.ts create mode 100644 flight-booking-app/workflows/chat/hooks/chat-message.ts diff --git a/flight-booking-app/app/api/chat/[id]/route.ts b/flight-booking-app/app/api/chat/[id]/route.ts new file mode 100644 index 0000000..b540281 --- /dev/null +++ b/flight-booking-app/app/api/chat/[id]/route.ts @@ -0,0 +1,13 @@ +import { chatMessageHook } from '@/workflows/chat/hooks/chat-message'; + +export async function POST( + req: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { message } = await req.json(); + const { id: threadId } = await params; + + await chatMessageHook.resume(`thread:${threadId}`, { message }); + + return Response.json({ success: true }); +} diff --git a/flight-booking-app/app/api/chat/[id]/stream/route.ts b/flight-booking-app/app/api/chat/[id]/stream/route.ts index 658b6a6..56d94e1 100644 --- a/flight-booking-app/app/api/chat/[id]/stream/route.ts +++ b/flight-booking-app/app/api/chat/[id]/stream/route.ts @@ -1,5 +1,5 @@ -import { createUIMessageStreamResponse } from "ai"; -import { getRun } from "workflow/api"; +import { createUIMessageStreamResponse } from 'ai'; +import { getRun } from 'workflow/api'; // Uncomment to simulate a long running Vercel Function timing // out due to a long running agent. The client-side will @@ -7,18 +7,20 @@ import { getRun } from "workflow/api"; //export const maxDuration = 5; export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> }, + request: Request, + { params }: { params: Promise<{ id: string }> } ) { - const { id } = await params; - const { searchParams } = new URL(request.url); - const startIndexParam = searchParams.get("startIndex"); - const startIndex = - startIndexParam !== null ? parseInt(startIndexParam, 10) : undefined; - const run = getRun(id); - const stream = run.getReadable({ startIndex }); + const { id } = await params; + const { searchParams } = new URL(request.url); - return createUIMessageStreamResponse({ - stream, - }); + const startIndexParam = searchParams.get('startIndex'); + const startIndex = + startIndexParam !== null ? parseInt(startIndexParam, 10) : undefined; + + const run = getRun(id); + const stream = run.getReadable({ startIndex }); + + return createUIMessageStreamResponse({ + stream, + }); } diff --git a/flight-booking-app/app/api/chat/route.ts b/flight-booking-app/app/api/chat/route.ts index 17e8430..4a4369f 100644 --- a/flight-booking-app/app/api/chat/route.ts +++ b/flight-booking-app/app/api/chat/route.ts @@ -1,6 +1,6 @@ -import { createUIMessageStreamResponse, type UIMessage } from "ai"; -import { start } from "workflow/api"; -import { chat } from "@/workflows/chat"; +import { createUIMessageStreamResponse, type UIMessage } from 'ai'; +import { start } from 'workflow/api'; +import { chat } from '@/workflows/chat'; // Uncomment to simulate a long running Vercel Function timing // out due to a long running agent. The client-side will @@ -8,17 +8,31 @@ import { chat } from "@/workflows/chat"; //export const maxDuration = 8; export async function POST(req: Request) { - const { messages }: { messages: UIMessage[] } = await req.json(); + const body = await req.json(); - const run = await start(chat, [messages]); - const workflowStream = run.readable; + // Extract threadId from body or generate one if not provided + const threadId: string = + body.threadId || + body.messages?.[0]?.metadata?.threadId || + crypto.randomUUID(); + const messages: UIMessage[] = body.messages || []; - return createUIMessageStreamResponse({ - stream: workflowStream, - headers: { - // The workflow run ID is stored into `localStorage` on the client side, - // which influences the `resume` flag in the `useChat` hook. - "x-workflow-run-id": run.runId, - }, - }); + console.log( + 'Starting chat workflow for thread:', + threadId, + 'with', + messages.length, + 'messages' + ); + + const run = await start(chat, [threadId, messages]); + const workflowStream = run.readable; + + return createUIMessageStreamResponse({ + stream: workflowStream, + headers: { + // The workflow run ID is stored on the client side for reconnection + 'x-workflow-run-id': run.runId, + }, + }); } diff --git a/flight-booking-app/app/api/hooks/approval/route.ts b/flight-booking-app/app/api/hooks/approval/route.ts new file mode 100644 index 0000000..51e4e89 --- /dev/null +++ b/flight-booking-app/app/api/hooks/approval/route.ts @@ -0,0 +1,12 @@ +import { bookingApprovalHook } from '@/workflows/chat/hooks/approval'; + +export async function POST(request: Request) { + const { toolCallId, approved, comment } = await request.json(); + // Schema validation happens automatically + // Can throw a zod schema validation error, or a + await bookingApprovalHook.resume(toolCallId, { + approved, + comment, + }); + return Response.json({ success: true }); +} diff --git a/flight-booking-app/app/page.tsx b/flight-booking-app/app/page.tsx index d32cc73..34ffa1a 100644 --- a/flight-booking-app/app/page.tsx +++ b/flight-booking-app/app/page.tsx @@ -1,55 +1,56 @@ -"use client"; +'use client'; -import { useChat } from "@ai-sdk/react"; -import { WorkflowChatTransport } from "@workflow/ai"; -import { useEffect, useMemo, useRef } from "react"; +import { WorkflowChatTransport } from '@workflow/ai'; +import { useEffect, useMemo, useRef } from 'react'; import { Conversation, ConversationContent, ConversationScrollButton, -} from "@/components/ai-elements/conversation"; -import { Message, MessageContent } from "@/components/ai-elements/message"; -import { Response } from "@/components/ai-elements/response"; -import { Shimmer } from "@/components/ai-elements/shimmer"; -import { Suggestion, Suggestions } from "@/components/ai-elements/suggestion"; +} from '@/components/ai-elements/conversation'; +import { Message, MessageContent } from '@/components/ai-elements/message'; +import { Response } from '@/components/ai-elements/response'; +import { Shimmer } from '@/components/ai-elements/shimmer'; +import { Suggestion, Suggestions } from '@/components/ai-elements/suggestion'; import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput, -} from "@/components/ai-elements/tool"; -import ChatInput from "@/components/chat-input"; -import type { MyUIMessage } from "@/schemas/chat"; +} from '@/components/ai-elements/tool'; +import ChatInput from '@/components/chat-input'; +import type { MyUIMessage } from '@/schemas/chat'; +import { BookingApproval } from '@/components/booking-approval'; +import { useMultiTurnChat } from '@/components/use-multi-turn-chat'; const SUGGESTIONS = [ - "Find me flights from San Francisco to Los Angeles", + 'Find me flights from San Francisco to Los Angeles', "What's the status of flight UA123?", - "Tell me about SFO airport", + 'Tell me about SFO airport', "What's the baggage allowance for United Airlines economy?", - "Book a flight from New York to Miami", + 'Book a flight from New York to Miami', ]; export default function ChatPage() { const textareaRef = useRef(null); const activeWorkflowRunId = useMemo(() => { - if (typeof window === "undefined") return; - return localStorage.getItem("active-workflow-run-id") ?? undefined; + if (typeof window === 'undefined') return; + return localStorage.getItem('active-workflow-run-id') ?? undefined; }, []); - const { stop, messages, sendMessage, status, setMessages } = - useChat({ + const { stop, messages, sendMessage, status, setMessages, error } = + useMultiTurnChat({ resume: !!activeWorkflowRunId, onError(error) { - console.error("onError", error); + console.error('onError', error); }, onFinish(data) { - console.log("onFinish", data); + console.log('onFinish', data); // Update the chat history in `localStorage` to include the latest bot message - console.log("Saving chat history to localStorage", data.messages); - localStorage.setItem("chat-history", JSON.stringify(data.messages)); + console.log('Saving chat history to localStorage', data.messages); + localStorage.setItem('chat-history', JSON.stringify(data.messages)); requestAnimationFrame(() => { textareaRef.current?.focus(); @@ -58,36 +59,36 @@ export default function ChatPage() { transport: new WorkflowChatTransport({ onChatSendMessage: (response, options) => { - console.log("onChatSendMessage", response, options); + console.log('onChatSendMessage', response, options); // Update the chat history in `localStorage` to include the latest user message localStorage.setItem( - "chat-history", - JSON.stringify(options.messages), + 'chat-history', + JSON.stringify(options.messages) ); // We'll store the workflow run ID in `localStorage` to allow the client // to resume the chat session after a page refresh or network interruption - const workflowRunId = response.headers.get("x-workflow-run-id"); + const workflowRunId = response.headers.get('x-workflow-run-id'); if (!workflowRunId) { throw new Error( - 'Workflow run ID not found in "x-workflow-run-id" response header', + 'Workflow run ID not found in "x-workflow-run-id" response header' ); } - localStorage.setItem("active-workflow-run-id", workflowRunId); + localStorage.setItem('active-workflow-run-id', workflowRunId); }, onChatEnd: ({ chatId, chunkIndex }) => { - console.log("onChatEnd", chatId, chunkIndex); + console.log('onChatEnd', chatId, chunkIndex); // Once the chat stream ends, we can remove the workflow run ID from `localStorage` - localStorage.removeItem("active-workflow-run-id"); + localStorage.removeItem('active-workflow-run-id'); }, // Configure reconnection to use the stored workflow run ID prepareReconnectToStreamRequest: ({ id, api, ...rest }) => { - console.log("prepareReconnectToStreamRequest", id); - const workflowRunId = localStorage.getItem("active-workflow-run-id"); + console.log('prepareReconnectToStreamRequest', id); + const workflowRunId = localStorage.getItem('active-workflow-run-id'); if (!workflowRunId) { - throw new Error("No active workflow run ID found"); + throw new Error('No active workflow run ID found'); } // Use the workflow run ID instead of the chat ID for reconnection return { @@ -104,7 +105,7 @@ export default function ChatPage() { // this would likely be done on the server side and loaded from a database, // but for the purposes of this demo, we'll load it from `localStorage`. useEffect(() => { - const chatHistory = localStorage.getItem("chat-history"); + const chatHistory = localStorage.getItem('chat-history'); if (!chatHistory) return; setMessages(JSON.parse(chatHistory) as MyUIMessage[]); }, [setMessages]); @@ -121,6 +122,16 @@ export default function ChatPage() {

Book a flight using workflows

+ {/* Error display */} + {error && ( +
+
+ Error: + {error.message} +
+
+ )} + {messages.length === 0 && (
@@ -154,7 +165,7 @@ export default function ChatPage() { type="button" onClick={() => { sendMessage({ - text: "Book me the cheapest flight from San Francisco to Los Angeles for July 27 2025. My name is Pranay Prakash. I like window seats. Don't ask me for confirmation.", + text: "Book me the cheapest flight from San Francisco to Los Angeles for July 27 2025. My name is Pranay Prakash. I like window seats. Don't ask me for confirmation until it's time for final approval.", metadata: { createdAt: Date.now() }, }); }} @@ -162,7 +173,7 @@ export default function ChatPage() { > Book me the cheapest flight from San Francisco to Los Angeles for July 27 2025. My name is Pranay Prakash. I like window seats. - Don't ask me for confirmation. + Don't ask me for confirmation until it's time for final approval.
@@ -170,21 +181,16 @@ export default function ChatPage() { {messages.map((message, index) => { - const hasText = message.parts.some((part) => part.type === "text"); + const hasText = message.parts.some((part) => part.type === 'text'); + const isLastMessage = index === messages.length - 1; return (
- {message.role === "assistant" && - index === messages.length - 1 && - (status === "submitted" || status === "streaming") && - !hasText && ( - Thinking... - )} {message.parts.map((part, partIndex) => { // Render text parts - if (part.type === "text") { + if (part.type === 'text') { return ( {part.text} @@ -193,7 +199,7 @@ export default function ChatPage() { } // Render workflow data messages - if (part.type === "data-workflow" && "data" in part) { + if (part.type === 'data-workflow' && 'data' in part) { const data = part.data as any; return (
); } + if (part.type === 'tool-bookingApproval') { + return ( + + ); + } return null; })} + {/* Loading indicators */} + {message.role === 'assistant' && + isLastMessage && + !hasText && ( + <> + {status === 'submitted' && ( + + Sending message... + + )} + {status === 'streaming' && ( + + Waiting for response... + + )} + + )}
); })} + {/* Show loading indicator when message is sent but no assistant response yet */} + {messages.length > 0 && + messages[messages.length - 1].role === 'user' && + status === 'submitted' && ( + + + + Processing your request... + + + + )} @@ -257,7 +309,7 @@ export default function ChatPage() { setMessages={setMessages} sendMessage={(message) => { sendMessage({ - text: message.text || "", + text: message.text || '', metadata: message.metadata, }); }} @@ -278,7 +330,7 @@ function renderToolOutput(part: any) { const parsedOutput = JSON.parse(output); switch (part.type) { - case "tool-searchFlights": { + case 'tool-searchFlights': { const flights = parsedOutput?.flights || []; return (
@@ -298,12 +350,12 @@ function renderToolOutput(part: any) { Departure: {new Date(flight.departure).toLocaleString()}
- Status:{" "} + Status:{' '} {flight.status} @@ -316,18 +368,18 @@ function renderToolOutput(part: any) { ); } - case "tool-checkFlightStatus": { + case 'tool-checkFlightStatus': { const status = parsedOutput; return (
Flight {status.flightNumber}
- Status:{" "} + Status:{' '} {status.status} @@ -348,7 +400,7 @@ function renderToolOutput(part: any) { ); } - case "tool-getAirportInfo": { + case 'tool-getAirportInfo': { const airport = parsedOutput; if (airport.error) { return ( @@ -377,7 +429,7 @@ function renderToolOutput(part: any) { ); } - case "tool-bookFlight": { + case 'tool-bookFlight': { const booking = parsedOutput; return ( @@ -385,7 +437,7 @@ function renderToolOutput(part: any) {
✅ Booking Confirmed!
- Confirmation #:{" "} + Confirmation #:{' '} {booking.confirmationNumber} @@ -399,7 +451,7 @@ function renderToolOutput(part: any) { ); } - case "tool-checkBaggageAllowance": { + case 'tool-checkBaggageAllowance': { const baggage = parsedOutput; return (
@@ -414,6 +466,16 @@ function renderToolOutput(part: any) { ); } + case 'tool-sleep': { + return ( +
+

+ Sleeping for {part.input.durationMs}ms... +

+
+ ); + } + default: return null; } diff --git a/flight-booking-app/components/booking-approval.tsx b/flight-booking-app/components/booking-approval.tsx new file mode 100644 index 0000000..b3d8a5d --- /dev/null +++ b/flight-booking-app/components/booking-approval.tsx @@ -0,0 +1,93 @@ +'use client'; +import { useState } from 'react'; +interface BookingApprovalProps { + toolCallId: string; + input?: { + flightNumber: string; + passengerName: string; + price: number; + }; + output?: string; +} +export function BookingApproval({ + toolCallId, + input, + output, +}: BookingApprovalProps) { + const [comment, setComment] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + + // If we have output, the approval has been processed + if (output) { + try { + const json = JSON.parse(output) as { output: { value: string } }; + return ( +
+

{json.output.value}

+
+ ); + } catch (error) { + return ( +
+

+ Error parsing approval result: {(error as Error).message} +

+
+ ); + } + } + + const handleSubmit = async (approved: boolean) => { + setIsSubmitting(true); + try { + await fetch('/api/hooks/approval', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ toolCallId, approved, comment }), + }); + } finally { + setIsSubmitting(false); + } + }; + return ( +
+
+

Approve this booking?

+
+ {input && ( + <> +
Flight: {input.flightNumber}
+
Passenger: {input.passengerName}
+
Price: ${input.price}
+ + )} +
+
+