diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d97359f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +node_modules +.next +.git +.env \ No newline at end of file diff --git a/README.md b/README.md index 387bf84..aceef52 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,7 @@ -# agent-ui demo +# Chat LangGraph + +> [!WARNING] +> This repo is still a work in progress and is not intended for use. Estimated launch date 03/11. Thank you for your patience. ## Setup diff --git a/agent/agent.tsx b/agent/agent.ts similarity index 84% rename from agent/agent.tsx rename to agent/agent.ts index dfacb6f..484b232 100644 --- a/agent/agent.tsx +++ b/agent/agent.ts @@ -6,9 +6,13 @@ import { stockbrokerGraph } from "./stockbroker"; import { ChatOpenAI } from "@langchain/openai"; import { tripPlannerGraph } from "./trip-planner"; import { formatMessages } from "./utils/format-messages"; +import { graph as openCodeGraph } from "./open-code"; +import { graph as orderPizzaGraph } from "./pizza-orderer"; const allToolDescriptions = `- stockbroker: can fetch the price of a ticker, purchase/sell a ticker, or get the user's portfolio -- tripPlanner: helps the user plan their trip. it can suggest restaurants, and places to stay in any given location.`; +- tripPlanner: helps the user plan their trip. it can suggest restaurants, and places to stay in any given location. +- openCode: can write code for the user. call this tool when the user asks you to write code +- orderPizza: can order a pizza for the user`; async function router( state: GenerativeUIState, @@ -19,7 +23,13 @@ ${allToolDescriptions} `; const routerSchema = z.object({ route: z - .enum(["stockbroker", "tripPlanner", "generalInput"]) + .enum([ + "stockbroker", + "tripPlanner", + "openCode", + "orderPizza", + "generalInput", + ]) .describe(routerDescription), }); const routerTool = { @@ -73,7 +83,7 @@ Please pick the proper route based on the most recent message, in the context of function handleRoute( state: GenerativeUIState, -): "stockbroker" | "tripPlanner" | "generalInput" { +): "stockbroker" | "tripPlanner" | "openCode" | "orderPizza" | "generalInput" { return state.next; } @@ -104,16 +114,22 @@ const builder = new StateGraph(GenerativeUIAnnotation) .addNode("router", router) .addNode("stockbroker", stockbrokerGraph) .addNode("tripPlanner", tripPlannerGraph) + .addNode("openCode", openCodeGraph) + .addNode("orderPizza", orderPizzaGraph) .addNode("generalInput", handleGeneralInput) .addConditionalEdges("router", handleRoute, [ "stockbroker", "tripPlanner", + "openCode", + "orderPizza", "generalInput", ]) .addEdge(START, "router") .addEdge("stockbroker", END) .addEdge("tripPlanner", END) + .addEdge("openCode", END) + .addEdge("orderPizza", END) .addEdge("generalInput", END); export const graph = builder.compile(); diff --git a/agent/open-code/index.ts b/agent/open-code/index.ts new file mode 100644 index 0000000..2707cff --- /dev/null +++ b/agent/open-code/index.ts @@ -0,0 +1,45 @@ +import { + END, + LangGraphRunnableConfig, + START, + StateGraph, +} from "@langchain/langgraph"; +import { OpenCodeAnnotation, OpenCodeState } from "./types"; +import { planner } from "./nodes/planner"; +import { + executor, + SUCCESSFULLY_COMPLETED_STEPS_CONTENT, +} from "./nodes/executor"; +import { AIMessage } from "@langchain/langgraph-sdk"; + +function conditionallyEnd( + state: OpenCodeState, + config: LangGraphRunnableConfig, +): typeof END | "planner" { + const fullWriteAccess = !!config.configurable?.permissions?.full_write_access; + const lastAiMessage = state.messages.findLast( + (m) => m.getType() === "ai", + ) as unknown as AIMessage; + + // If the user did not grant full write access, or the last AI message is the success message, end + // otherwise, loop back to the start. + if ( + (typeof lastAiMessage.content === "string" && + lastAiMessage.content === SUCCESSFULLY_COMPLETED_STEPS_CONTENT) || + !fullWriteAccess + ) { + return END; + } + + return "planner"; +} + +const workflow = new StateGraph(OpenCodeAnnotation) + .addNode("planner", planner) + .addNode("executor", executor) + .addEdge(START, "planner") + .addEdge("planner", "executor") + .addConditionalEdges("executor", conditionallyEnd, ["planner", END]); + +export const graph = workflow.compile(); +graph.name = "Open Code Graph"; diff --git a/agent/open-code/nodes/executor.ts b/agent/open-code/nodes/executor.ts new file mode 100644 index 0000000..9158f96 --- /dev/null +++ b/agent/open-code/nodes/executor.ts @@ -0,0 +1,127 @@ +import fs from "fs/promises"; +import { v4 as uuidv4 } from "uuid"; +import { AIMessage } from "@langchain/langgraph-sdk"; +import { OpenCodeState, OpenCodeUpdate } from "../types"; +import { LangGraphRunnableConfig } from "@langchain/langgraph"; +import ComponentMap from "../../uis"; +import { typedUi } from "@langchain/langgraph-sdk/react-ui/server"; + +export const SUCCESSFULLY_COMPLETED_STEPS_CONTENT = + "Successfully completed all the steps in the plan. Please let me know if you need anything else!"; + +export async function executor( + state: OpenCodeState, + config: LangGraphRunnableConfig, +): Promise { + const ui = typedUi(config); + + const lastPlanToolCall = state.messages.findLast( + (m) => + m.getType() === "ai" && + (m as unknown as AIMessage).tool_calls?.some((tc) => tc.name === "plan"), + ) as AIMessage | undefined; + const planToolCallArgs = lastPlanToolCall?.tool_calls?.[0]?.args; + const nextPlanItem = planToolCallArgs?.remainingPlans?.[0] as + | string + | undefined; + const numSeenPlans = + [ + ...(planToolCallArgs?.executedPlans ?? []), + ...(planToolCallArgs?.rejectedPlans ?? []), + ]?.length ?? 0; + + if (!nextPlanItem) { + // All plans have been executed + const successfullyFinishedMsg: AIMessage = { + type: "ai", + id: uuidv4(), + content: SUCCESSFULLY_COMPLETED_STEPS_CONTENT, + }; + return { messages: [successfullyFinishedMsg] }; + } + + let updateFileContents = ""; + switch (numSeenPlans) { + case 0: + updateFileContents = await fs.readFile( + "agent/open-code/nodes/plan-code/step-1.txt", + "utf-8", + ); + break; + case 1: + updateFileContents = await fs.readFile( + "agent/open-code/nodes/plan-code/step-2.txt", + "utf-8", + ); + break; + case 2: + updateFileContents = await fs.readFile( + "agent/open-code/nodes/plan-code/step-3.txt", + "utf-8", + ); + break; + case 3: + updateFileContents = await fs.readFile( + "agent/open-code/nodes/plan-code/step-4.txt", + "utf-8", + ); + break; + case 4: + updateFileContents = await fs.readFile( + "agent/open-code/nodes/plan-code/step-5.txt", + "utf-8", + ); + break; + case 5: + updateFileContents = await fs.readFile( + "agent/open-code/nodes/plan-code/step-6.txt", + "utf-8", + ); + break; + default: + updateFileContents = ""; + } + + if (!updateFileContents) { + throw new Error("No file updates found!"); + } + + const toolCallId = uuidv4(); + const aiMessage: AIMessage = { + type: "ai", + id: uuidv4(), + content: "", + tool_calls: [ + { + name: "update_file", + args: { + new_file_content: updateFileContents, + executed_plan_item: nextPlanItem, + }, + id: toolCallId, + type: "tool_call", + }, + ], + }; + + const fullWriteAccess = !!config.configurable?.permissions?.full_write_access; + + ui.push( + { + name: "proposed-change", + content: { + toolCallId, + change: updateFileContents, + planItem: nextPlanItem, + fullWriteAccess, + }, + }, + { message: aiMessage }, + ); + + return { + messages: [aiMessage], + ui: ui.items, + timestamp: Date.now(), + }; +} diff --git a/agent/open-code/nodes/plan-code/step-1.txt b/agent/open-code/nodes/plan-code/step-1.txt new file mode 100644 index 0000000..e4776f4 --- /dev/null +++ b/agent/open-code/nodes/plan-code/step-1.txt @@ -0,0 +1,5 @@ +```bash +npx create-react-app todo-app --template typescript +cd todo-app +mkdir -p src/{components,styles,utils} +``` \ No newline at end of file diff --git a/agent/open-code/nodes/plan-code/step-2.txt b/agent/open-code/nodes/plan-code/step-2.txt new file mode 100644 index 0000000..93c51c9 --- /dev/null +++ b/agent/open-code/nodes/plan-code/step-2.txt @@ -0,0 +1,21 @@ +```tsx +// src/components/TodoItem.tsx +import React from 'react'; +import styles from '../styles/TodoItem.module.css'; + +interface TodoItemProps { + id: string; + text: string; + completed: boolean; + onToggle: (id: string) => void; + onDelete: (id: string) => void; +} + +export const TodoItem: React.FC = ({ id, text, completed, onToggle, onDelete }) => ( +
+ onToggle(id)} /> + {text} + +
+); +``` \ No newline at end of file diff --git a/agent/open-code/nodes/plan-code/step-3.txt b/agent/open-code/nodes/plan-code/step-3.txt new file mode 100644 index 0000000..3e6d746 --- /dev/null +++ b/agent/open-code/nodes/plan-code/step-3.txt @@ -0,0 +1,22 @@ +```tsx +// src/context/TodoContext.tsx +import React, { createContext, useContext, useReducer } from 'react'; + +type Todo = { id: string; text: string; completed: boolean; }; + +type TodoState = { todos: Todo[]; }; +type TodoAction = + | { type: 'ADD_TODO'; payload: string } + | { type: 'TOGGLE_TODO'; payload: string } + | { type: 'DELETE_TODO'; payload: string }; + +const TodoContext = createContext<{ + state: TodoState; + dispatch: React.Dispatch; +} | undefined>(undefined); + +export const TodoProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [state, dispatch] = useReducer(todoReducer, { todos: [] }); + return {children}; +}; +``` \ No newline at end of file diff --git a/agent/open-code/nodes/plan-code/step-4.txt b/agent/open-code/nodes/plan-code/step-4.txt new file mode 100644 index 0000000..b3c2a37 --- /dev/null +++ b/agent/open-code/nodes/plan-code/step-4.txt @@ -0,0 +1,33 @@ +```tsx +// src/components/AddTodo.tsx +import React, { useState } from 'react'; +import styles from '../styles/AddTodo.module.css'; + +export const AddTodo: React.FC<{ onAdd: (text: string) => void }> = ({ onAdd }) => { + const [text, setText] = useState(''); + const [error, setError] = useState(''); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!text.trim()) { + setError('Todo text cannot be empty'); + return; + } + onAdd(text.trim()); + setText(''); + setError(''); + }; + + return ( +
+ setText(e.target.value)} + placeholder='Add a new todo' + /> + {error &&
{error}
} + +
+ ); +}; +``` \ No newline at end of file diff --git a/agent/open-code/nodes/plan-code/step-5.txt b/agent/open-code/nodes/plan-code/step-5.txt new file mode 100644 index 0000000..2082d9d --- /dev/null +++ b/agent/open-code/nodes/plan-code/step-5.txt @@ -0,0 +1,22 @@ +```tsx +// src/components/TodoFilters.tsx +import React from 'react'; + +type FilterType = 'all' | 'active' | 'completed'; + +export const TodoFilters: React.FC<{ + currentFilter: FilterType; + onFilterChange: (filter: FilterType) => void; + onSortChange: (ascending: boolean) => void; +}> = ({ currentFilter, onFilterChange, onSortChange }) => ( +
+ + + +
+); +``` \ No newline at end of file diff --git a/agent/open-code/nodes/plan-code/step-6.txt b/agent/open-code/nodes/plan-code/step-6.txt new file mode 100644 index 0000000..84d96fa --- /dev/null +++ b/agent/open-code/nodes/plan-code/step-6.txt @@ -0,0 +1,13 @@ +```tsx +// src/utils/storage.ts +const STORAGE_KEY = 'todos'; + +export const saveTodos = (todos: Todo[]) => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(todos)); +}; + +export const loadTodos = (): Todo[] => { + const stored = localStorage.getItem(STORAGE_KEY); + return stored ? JSON.parse(stored) : []; +}; +``` \ No newline at end of file diff --git a/agent/open-code/nodes/planner.ts b/agent/open-code/nodes/planner.ts new file mode 100644 index 0000000..b7f2615 --- /dev/null +++ b/agent/open-code/nodes/planner.ts @@ -0,0 +1,114 @@ +import { v4 as uuidv4 } from "uuid"; +import { AIMessage, ToolMessage } from "@langchain/langgraph-sdk"; +import { OpenCodeState, OpenCodeUpdate } from "../types"; +import { DO_NOT_RENDER_ID_PREFIX } from "@/lib/ensure-tool-responses"; +import { LangGraphRunnableConfig } from "@langchain/langgraph"; +import ComponentMap from "../../uis"; +import { typedUi } from "@langchain/langgraph-sdk/react-ui/server"; + +const PLAN = [ + "Set up project scaffolding using Create React App and implement basic folder structure for components, styles, and utilities.", + "Create reusable UI components for TodoItem, including styling with CSS modules.", + "Implement state management using React Context to handle todo items, including actions for adding, updating, and deleting todos.", + "Add form functionality for creating new todos with input validation and error handling.", + "Create filtering and sorting capabilities to allow users to view completed, active, or all todos.", + "Implement local storage integration to persist todo items between page refreshes.", +]; + +export async function planner( + state: OpenCodeState, + config: LangGraphRunnableConfig, +): Promise { + const ui = typedUi(config); + + const lastUpdateCodeToolCall = state.messages.findLast( + (m) => + m.getType() === "ai" && + (m as unknown as AIMessage).tool_calls?.some( + (tc) => tc.name === "update_file", + ), + ) as AIMessage | undefined; + const lastUpdateToolCallResponse = state.messages.findLast( + (m) => + m.getType() === "tool" && + (m as unknown as ToolMessage).tool_call_id === + lastUpdateCodeToolCall?.tool_calls?.[0]?.id, + ) as ToolMessage | undefined; + const lastPlanToolCall = state.messages.findLast( + (m) => + m.getType() === "ai" && + (m as unknown as AIMessage).tool_calls?.some((tc) => tc.name === "plan"), + ) as AIMessage | undefined; + + const wasPlanRejected = ( + lastUpdateToolCallResponse?.content as string | undefined + ) + ?.toLowerCase() + .includes("rejected"); + + const planToolCallArgs = lastPlanToolCall?.tool_calls?.[0]?.args; + const executedPlans: string[] = planToolCallArgs?.executedPlans ?? []; + const rejectedPlans: string[] = planToolCallArgs?.rejectedPlans ?? []; + let remainingPlans: string[] = planToolCallArgs?.remainingPlans ?? PLAN; + + const proposedChangePlanItem: string | undefined = + lastUpdateCodeToolCall?.tool_calls?.[0]?.args?.executed_plan_item; + if (proposedChangePlanItem) { + if (wasPlanRejected) { + rejectedPlans.push(proposedChangePlanItem); + } else { + executedPlans.push(proposedChangePlanItem); + } + + remainingPlans = remainingPlans.filter((p) => p !== proposedChangePlanItem); + } + + const content = proposedChangePlanItem + ? `I've updated the plan list based on the last proposed change.` + : `I've come up with a detailed plan for building the todo app.`; + + const toolCallId = uuidv4(); + const aiMessage: AIMessage = { + type: "ai", + id: uuidv4(), + content, + tool_calls: [ + { + name: "plan", + args: { + executedPlans, + rejectedPlans, + remainingPlans, + }, + id: toolCallId, + type: "tool_call", + }, + ], + }; + + ui.push( + { + name: "code-plan", + content: { + toolCallId, + executedPlans, + rejectedPlans, + remainingPlans, + }, + }, + { message: aiMessage }, + ); + + const toolMessage: ToolMessage = { + type: "tool", + id: `${DO_NOT_RENDER_ID_PREFIX}${uuidv4()}`, + tool_call_id: toolCallId, + content: "User has approved the plan.", + }; + + return { + messages: [aiMessage, toolMessage], + ui: ui.items, + timestamp: Date.now(), + }; +} diff --git a/agent/open-code/types.ts b/agent/open-code/types.ts new file mode 100644 index 0000000..3a32290 --- /dev/null +++ b/agent/open-code/types.ts @@ -0,0 +1,11 @@ +import { Annotation } from "@langchain/langgraph"; +import { GenerativeUIAnnotation } from "../types"; + +export const OpenCodeAnnotation = Annotation.Root({ + messages: GenerativeUIAnnotation.spec.messages, + ui: GenerativeUIAnnotation.spec.ui, + timestamp: GenerativeUIAnnotation.spec.timestamp, +}); + +export type OpenCodeState = typeof OpenCodeAnnotation.State; +export type OpenCodeUpdate = typeof OpenCodeAnnotation.Update; diff --git a/agent/pizza-orderer/index.ts b/agent/pizza-orderer/index.ts new file mode 100644 index 0000000..948cbfe --- /dev/null +++ b/agent/pizza-orderer/index.ts @@ -0,0 +1,113 @@ +import { ChatAnthropic } from "@langchain/anthropic"; +import { Annotation, END, START, StateGraph } from "@langchain/langgraph"; +import { GenerativeUIAnnotation } from "../types"; +import { z } from "zod"; +import { AIMessage, ToolMessage } from "@langchain/langgraph-sdk"; +import { v4 as uuidv4 } from "uuid"; + +const PizzaOrdererAnnotation = Annotation.Root({ + messages: GenerativeUIAnnotation.spec.messages, +}); + +async function sleep(ms = 5000) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +const workflow = new StateGraph(PizzaOrdererAnnotation) + .addNode("findStore", async (state) => { + const findShopSchema = z + .object({ + location: z + .string() + .describe( + "The location the user is in. E.g. 'San Francisco' or 'New York'", + ), + pizza_company: z + .string() + .optional() + .describe( + "The name of the pizza company. E.g. 'Dominos' or 'Papa John's'. Optional, if not defined it will search for all pizza shops", + ), + }) + .describe("The schema for finding a pizza shop for the user"); + const model = new ChatAnthropic({ + model: "claude-3-5-sonnet-latest", + temperature: 0, + }).withStructuredOutput(findShopSchema, { + name: "find_pizza_shop", + includeRaw: true, + }); + + const response = await model.invoke([ + { + role: "system", + content: + "You are a helpful AI assistant, tasked with extracting information from the conversation between you, and the user, in order to find a pizza shop for them.", + }, + ...state.messages, + ]); + + await sleep(); + + const toolResponse: ToolMessage = { + type: "tool", + id: uuidv4(), + content: + "I've found a pizza shop at 1119 19th St, San Francisco, CA 94107. The phone number for the shop is 415-555-1234.", + tool_call_id: + (response.raw as unknown as AIMessage).tool_calls?.[0].id ?? "", + }; + + return { + messages: [response.raw, toolResponse], + }; + }) + .addNode("orderPizza", async (state) => { + await sleep(1500); + + const placeOrderSchema = z + .object({ + address: z + .string() + .describe("The address of the store to order the pizza from"), + phone_number: z + .string() + .describe("The phone number of the store to order the pizza from"), + order: z.string().describe("The full pizza order for the user"), + }) + .describe("The schema for ordering a pizza for the user"); + const model = new ChatAnthropic({ + model: "claude-3-5-sonnet-latest", + temperature: 0, + }).withStructuredOutput(placeOrderSchema, { + name: "place_pizza_order", + includeRaw: true, + }); + + const response = await model.invoke([ + { + role: "system", + content: + "You are a helpful AI assistant, tasked with placing an order for a pizza for the user.", + }, + ...state.messages, + ]); + + const toolResponse: ToolMessage = { + type: "tool", + id: uuidv4(), + content: "Pizza order successfully placed.", + tool_call_id: + (response.raw as unknown as AIMessage).tool_calls?.[0].id ?? "", + }; + + return { + messages: [response.raw, toolResponse], + }; + }) + .addEdge(START, "findStore") + .addEdge("findStore", "orderPizza") + .addEdge("orderPizza", END); + +export const graph = workflow.compile(); +graph.name = "Order Pizza Graph"; diff --git a/agent/stockbroker/nodes/tools.tsx b/agent/stockbroker/nodes/tools.tsx index 1b1f252..4fca505 100644 --- a/agent/stockbroker/nodes/tools.tsx +++ b/agent/stockbroker/nodes/tools.tsx @@ -8,6 +8,23 @@ import { findToolCall } from "../../find-tool-call"; import { format, subDays } from "date-fns"; import { Price, Snapshot } from "../../types"; +async function getNextPageData(url: string) { + if (!process.env.FINANCIAL_DATASETS_API_KEY) { + throw new Error("Financial datasets API key not set"); + } + + const options = { + method: "GET", + headers: { "X-API-KEY": process.env.FINANCIAL_DATASETS_API_KEY }, + }; + + const response = await fetch(url, options); + if (!response.ok) { + throw new Error("Failed to fetch prices"); + } + return await response.json(); +} + async function getPricesForTicker(ticker: string): Promise<{ oneDayPrices: Price[]; thirtyDayPrices: Price[]; @@ -54,7 +71,21 @@ async function getPricesForTicker(ticker: string): Promise<{ } const { prices: pricesOneDay } = await resOneDay.json(); - const { prices: pricesThirtyDays } = await resThirtyDays.json(); + const { prices: pricesThirtyDays, next_page_url } = + await resThirtyDays.json(); + + let nextPageUrlThirtyDays = next_page_url; + + let iters = 0; + while (nextPageUrlThirtyDays) { + if (iters > 10) { + throw new Error("MAX ITERS REACHED"); + } + const nextPageData = await getNextPageData(nextPageUrlThirtyDays); + pricesThirtyDays.push(...nextPageData.prices); + nextPageUrlThirtyDays = nextPageData.next_page_url; + iters += 1; + } return { oneDayPrices: pricesOneDay, @@ -145,28 +176,37 @@ export async function callTools( if (stockbrokerToolCall) { const prices = await getPricesForTicker(stockbrokerToolCall.args.ticker); - ui.write("stock-price", { - ticker: stockbrokerToolCall.args.ticker, - ...prices, - }); + ui.push( + { + name: "stock-price", + content: { ticker: stockbrokerToolCall.args.ticker, ...prices }, + }, + { message }, + ); } if (portfolioToolCall) { - ui.write("portfolio", {}); + ui.push({ name: "portfolio", content: {} }, { message }); } if (buyStockToolCall) { const snapshot = await getPriceSnapshotForTicker( buyStockToolCall.args.ticker, ); - ui.write("buy-stock", { - toolCallId: buyStockToolCall.id ?? "", - snapshot, - quantity: buyStockToolCall.args.quantity, - }); + ui.push( + { + name: "buy-stock", + content: { + toolCallId: buyStockToolCall.id ?? "", + snapshot, + quantity: buyStockToolCall.args.quantity, + }, + }, + { message }, + ); } return { messages: [message], - ui: ui.collect as StockbrokerUpdate["ui"], + ui: ui.items, timestamp: Date.now(), }; } diff --git a/agent/trip-planner/nodes/extraction.tsx b/agent/trip-planner/nodes/extraction.tsx index f578a54..8493d10 100644 --- a/agent/trip-planner/nodes/extraction.tsx +++ b/agent/trip-planner/nodes/extraction.tsx @@ -63,7 +63,9 @@ export async function extraction( .describe("The end date of the trip. Should be in YYYY-MM-DD format"), numberOfGuests: z .number() - .describe("The number of guests for the trip. Should default to 2 if not specified"), + .describe( + "The number of guests for the trip. Should default to 2 if not specified", + ), }); const model = new ChatOpenAI({ model: "gpt-4o", temperature: 0 }).bindTools([ @@ -126,6 +128,6 @@ Extract only what is specified by the user. It is okay to leave fields blank if return { tripDetails: extractionDetailsWithDefaults, - messages: [response, extractToolResponse] + messages: [response, extractToolResponse], }; } diff --git a/agent/trip-planner/nodes/tools.tsx b/agent/trip-planner/nodes/tools.tsx index 8966bbc..a704776 100644 --- a/agent/trip-planner/nodes/tools.tsx +++ b/agent/trip-planner/nodes/tools.tsx @@ -7,14 +7,12 @@ import { LangGraphRunnableConfig } from "@langchain/langgraph"; import { getAccommodationsListProps } from "../utils/get-accommodations"; import { findToolCall } from "../../find-tool-call"; -const listAccommodationsSchema = z.object({}).describe("A tool to list accommodations for the user") -const bookAccommodationSchema = z.object({ - accommodationName: z.string().describe("The name of the accommodation to book a reservation for"), -}).describe("A tool to book a reservation for an accommodation"); -const listRestaurantsSchema = z.object({}).describe("A tool to list restaurants for the user"); -const bookRestaurantSchema = z.object({ - restaurantName: z.string().describe("The name of the restaurant to book a reservation for"), -}).describe("A tool to book a reservation for a restaurant"); +const listAccommodationsSchema = z + .object({}) + .describe("A tool to list accommodations for the user"); +const listRestaurantsSchema = z + .object({}) + .describe("A tool to list restaurants for the user"); const ACCOMMODATIONS_TOOLS = [ { @@ -22,21 +20,11 @@ const ACCOMMODATIONS_TOOLS = [ description: "A tool to list accommodations for the user", schema: listAccommodationsSchema, }, - { - name: "book-accommodation", - description: "A tool to book a reservation for an accommodation", - schema: bookAccommodationSchema, - }, { name: "list-restaurants", description: "A tool to list restaurants for the user", schema: listRestaurantsSchema, }, - { - name: "book-restaurant", - description: "A tool to book a reservation for a restaurant", - schema: bookRestaurantSchema, - }, ]; export async function callTools( @@ -49,7 +37,9 @@ export async function callTools( const ui = typedUi(config); - const llm = new ChatOpenAI({ model: "gpt-4o", temperature: 0 }).bindTools(ACCOMMODATIONS_TOOLS); + const llm = new ChatOpenAI({ model: "gpt-4o", temperature: 0 }).bindTools( + ACCOMMODATIONS_TOOLS, + ); const response = await llm.invoke([ { @@ -63,47 +53,40 @@ export async function callTools( const listAccommodationsToolCall = response.tool_calls?.find( findToolCall("list-accommodations"), ); - const bookAccommodationToolCall = response.tool_calls?.find( - findToolCall("book-accommodation"), - ); const listRestaurantsToolCall = response.tool_calls?.find( findToolCall("list-restaurants"), ); - const bookRestaurantToolCall = response.tool_calls?.find( - findToolCall("book-restaurant"), - ); - if (!listAccommodationsToolCall && !bookAccommodationToolCall && !listRestaurantsToolCall && !bookRestaurantToolCall) { + if (!listAccommodationsToolCall && !listRestaurantsToolCall) { throw new Error("No tool calls found"); } if (listAccommodationsToolCall) { - ui.write("accommodations-list", { - toolCallId: listAccommodationsToolCall.id ?? "", - ...getAccommodationsListProps(state.tripDetails), - }); - } - if (bookAccommodationToolCall && bookAccommodationToolCall.args.accommodationName) { - ui.write("book-accommodation", { - tripDetails: state.tripDetails, - accommodationName: bookAccommodationToolCall.args.accommodationName, - }); + ui.push( + { + name: "accommodations-list", + content: { + toolCallId: listAccommodationsToolCall.id ?? "", + ...getAccommodationsListProps(state.tripDetails), + }, + }, + { message: response }, + ); } if (listRestaurantsToolCall) { - ui.write("restaurants-list", { tripDetails: state.tripDetails }); - } - - if (bookRestaurantToolCall && bookRestaurantToolCall.args.restaurantName) { - ui.write("book-restaurant", { - tripDetails: state.tripDetails, - restaurantName: bookRestaurantToolCall.args.restaurantName, - }); + ui.push( + { + name: "restaurants-list", + content: { tripDetails: state.tripDetails }, + }, + { message: response }, + ); } return { messages: [response], - ui: ui.collect as TripPlannerUpdate["ui"], + ui: ui.items, timestamp: Date.now(), }; } diff --git a/agent/types.ts b/agent/types.ts index 20f7017..8002509 100644 --- a/agent/types.ts +++ b/agent/types.ts @@ -12,7 +12,9 @@ export const GenerativeUIAnnotation = Annotation.Root({ UIMessage | RemoveUIMessage | (UIMessage | RemoveUIMessage)[] >({ default: () => [], reducer: uiMessageReducer }), timestamp: Annotation, - next: Annotation<"stockbroker" | "tripPlanner" | "generalInput">(), + next: Annotation< + "stockbroker" | "tripPlanner" | "openCode" | "orderPizza" | "generalInput" + >(), }); export type GenerativeUIState = typeof GenerativeUIAnnotation.State; diff --git a/agent/uis/index.tsx b/agent/uis/index.tsx index 89d151c..f932995 100644 --- a/agent/uis/index.tsx +++ b/agent/uis/index.tsx @@ -5,6 +5,8 @@ import BookAccommodation from "./trip-planner/book-accommodation"; import RestaurantsList from "./trip-planner/restaurants-list"; import BookRestaurant from "./trip-planner/book-restaurant"; import BuyStock from "./stockbroker/buy-stock"; +import Plan from "./open-code/plan"; +import ProposedChange from "./open-code/proposed-change"; const ComponentMap = { "stock-price": StockPrice, @@ -14,5 +16,7 @@ const ComponentMap = { "restaurants-list": RestaurantsList, "book-restaurant": BookRestaurant, "buy-stock": BuyStock, + "code-plan": Plan, + "proposed-change": ProposedChange, } as const; export default ComponentMap; diff --git a/agent/uis/trip-planner/book-accommodation/index.css b/agent/uis/open-code/plan/index.css similarity index 100% rename from agent/uis/trip-planner/book-accommodation/index.css rename to agent/uis/open-code/plan/index.css diff --git a/agent/uis/open-code/plan/index.tsx b/agent/uis/open-code/plan/index.tsx new file mode 100644 index 0000000..2b524ee --- /dev/null +++ b/agent/uis/open-code/plan/index.tsx @@ -0,0 +1,76 @@ +import "./index.css"; +import { motion } from "framer-motion"; +import { ChevronDown } from "lucide-react"; +import { useState } from "react"; + +interface PlanProps { + toolCallId: string; + executedPlans: string[]; + rejectedPlans: string[]; + remainingPlans: string[]; +} + +export default function Plan(props: PlanProps) { + const [isExpanded, setIsExpanded] = useState(false); + + return ( +
+
+

Code Plan

+
+ +
+
+

+ Remaining Plans +

+ {props.remainingPlans.map((step, index) => ( +

+ {index + 1}. {step} +

+ ))} +
+
+

+ Executed Plans +

+ {props.executedPlans.map((step, index) => ( +

+ {step} +

+ ))} +
+
+

+ Rejected Plans +

+ {props.rejectedPlans.map((step, index) => ( +

+ {step} +

+ ))} +
+
+
+ setIsExpanded(!isExpanded)} + animate={{ rotate: isExpanded ? 180 : 0 }} + transition={{ duration: 0.3, ease: [0.4, 0, 0.2, 1] }} + > + + +
+ ); +} diff --git a/agent/uis/open-code/proposed-change/index.css b/agent/uis/open-code/proposed-change/index.css new file mode 100644 index 0000000..e69504a --- /dev/null +++ b/agent/uis/open-code/proposed-change/index.css @@ -0,0 +1,122 @@ +@import "tailwindcss"; + +@custom-variant dark (&:is(.dark *)); + +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --destructive-foreground: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.145 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.145 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.985 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.396 0.141 25.723); + --destructive-foreground: oklch(0.637 0.237 25.331); + --border: oklch(0.269 0 0); + --input: oklch(0.269 0 0); + --ring: oklch(0.439 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(0.269 0 0); + --sidebar-ring: oklch(0.439 0 0); +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/agent/uis/open-code/proposed-change/index.tsx b/agent/uis/open-code/proposed-change/index.tsx new file mode 100644 index 0000000..402c00c --- /dev/null +++ b/agent/uis/open-code/proposed-change/index.tsx @@ -0,0 +1,189 @@ +import "./index.css"; +import { v4 as uuidv4 } from "uuid"; +import { Button } from "@/components/ui/button"; +import ReactMarkdown from "react-markdown"; +import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; +import { coldarkDark } from "react-syntax-highlighter/dist/cjs/styles/prism"; +import { UIMessage, useStreamContext } from "@langchain/langgraph-sdk/react-ui"; +import { Message } from "@langchain/langgraph-sdk"; +import { DO_NOT_RENDER_ID_PREFIX } from "@/lib/ensure-tool-responses"; +import { useEffect, useState } from "react"; +import { getToolResponse } from "../../utils/get-tool-response"; +import { cn } from "@/lib/utils"; + +interface ProposedChangeProps { + toolCallId: string; + change: string; + planItem: string; + /** + * Whether or not to show the "Accept"/"Reject" buttons + * If true, this means the user selected the "Accept, don't ask again" + * button for this session. + */ + fullWriteAccess: boolean; +} + +const ACCEPTED_CHANGE_CONTENT = + "User accepted the proposed change. Please continue."; +const REJECTED_CHANGE_CONTENT = + "User rejected the proposed change. Please continue."; + +export default function ProposedChange(props: ProposedChangeProps) { + const [isAccepted, setIsAccepted] = useState(false); + const [isRejected, setIsRejected] = useState(false); + + const thread = useStreamContext< + { messages: Message[]; ui: UIMessage[] }, + { MetaType: { ui: UIMessage | undefined } } + >(); + + const handleReject = () => { + thread.submit({ + messages: [ + { + type: "tool", + tool_call_id: props.toolCallId, + id: `${DO_NOT_RENDER_ID_PREFIX}${uuidv4()}`, + name: "update_file", + content: REJECTED_CHANGE_CONTENT, + }, + { + type: "human", + content: `Rejected change.`, + }, + ], + }); + + setIsRejected(true); + }; + + const handleAccept = (shouldGrantFullWriteAccess = false) => { + const humanMessageContent = `Accepted change. ${shouldGrantFullWriteAccess ? "Granted full write access." : ""}`; + thread.submit( + { + messages: [ + { + type: "tool", + tool_call_id: props.toolCallId, + id: `${DO_NOT_RENDER_ID_PREFIX}${uuidv4()}`, + name: "update_file", + content: ACCEPTED_CHANGE_CONTENT, + }, + { + type: "human", + content: humanMessageContent, + }, + ], + }, + { + config: { + configurable: { + permissions: { + full_write_access: shouldGrantFullWriteAccess, + }, + }, + }, + }, + ); + + setIsAccepted(true); + }; + + useEffect(() => { + if (typeof window === "undefined" || isAccepted) return; + const toolResponse = getToolResponse(props.toolCallId, thread); + if (toolResponse) { + if (toolResponse.content === ACCEPTED_CHANGE_CONTENT) { + setIsAccepted(true); + } else if (toolResponse.content === REJECTED_CHANGE_CONTENT) { + setIsRejected(true); + } + } + }, []); + + if (isAccepted || isRejected) { + return ( +
+
+

+ {isAccepted ? "Accepted" : "Rejected"} Change +

+

{props.planItem}

+
+ + ) : ( + {children} + ); + }, + }} + /> +
+ ); + } + + return ( +
+
+

Proposed Change

+

{props.planItem}

+
+ + ) : ( + {children} + ); + }, + }} + /> + {!props.fullWriteAccess && ( +
+ + + +
+ )} +
+ ); +} diff --git a/agent/uis/stockbroker/portfolio-view/index.tsx b/agent/uis/stockbroker/portfolio-view/index.tsx index 42bb140..2eff0be 100644 --- a/agent/uis/stockbroker/portfolio-view/index.tsx +++ b/agent/uis/stockbroker/portfolio-view/index.tsx @@ -160,7 +160,7 @@ export default function PortfolioView() { const chartData = selectedHolding ? generateChartData(selectedHolding) : []; return ( -
+

diff --git a/agent/uis/stockbroker/stock-price/index.tsx b/agent/uis/stockbroker/stock-price/index.tsx index f4caae8..8bcceca 100644 --- a/agent/uis/stockbroker/stock-price/index.tsx +++ b/agent/uis/stockbroker/stock-price/index.tsx @@ -138,8 +138,18 @@ export default function StockPrice(props: { }; }, [oneDayPrices, thirtyDayPrices, displayRange]); + const formatDateByDisplayRange = (value: string, isTooltip?: boolean) => { + if (displayRange === "1d") { + return format(value, "h:mm a"); + } + if (isTooltip) { + return format(value, "LLL do h:mm a"); + } + return format(value, "LLL do"); + }; + return ( -
+

{ticker}

${currentPrice}

@@ -180,7 +190,7 @@ export default function StockPrice(props: { tickLine={false} axisLine={false} tickMargin={8} - tickFormatter={(value) => format(value, "h:mm a")} + tickFormatter={(v) => formatDateByDisplayRange(v)} /> format(value, "h:mm a")} + labelFormatter={(v) => formatDateByDisplayRange(v, true)} /> } /> diff --git a/agent/uis/trip-planner/accommodations-list/index.tsx b/agent/uis/trip-planner/accommodations-list/index.tsx index 0a46ede..39d979b 100644 --- a/agent/uis/trip-planner/accommodations-list/index.tsx +++ b/agent/uis/trip-planner/accommodations-list/index.tsx @@ -320,7 +320,7 @@ export default function AccommodationsList({ align: "start", loop: true, }} - className="w-full sm:max-w-sm md:max-w-2xl lg:max-w-3xl" + className="w-full sm:max-w-sm md:max-w-3xl lg:max-w-3xl" > {accommodations.map((accommodation) => ( diff --git a/agent/uis/trip-planner/book-accommodation/index.tsx b/agent/uis/trip-planner/book-accommodation/index.tsx deleted file mode 100644 index 68d6d7e..0000000 --- a/agent/uis/trip-planner/book-accommodation/index.tsx +++ /dev/null @@ -1,403 +0,0 @@ -import "./index.css"; -import { TripDetails } from "../../../trip-planner/types"; -import { useState } from "react"; - -export default function BookAccommodation({ - tripDetails, - accommodationName, -}: { - tripDetails: TripDetails; - accommodationName: string; -}) { - // Placeholder data - ideally would come from props - const [accommodation] = useState({ - name: accommodationName, - type: "Hotel", - price: "$150/night", - rating: 4.8, - totalPrice: - "$" + - 150 * - Math.ceil( - (new Date(tripDetails.endDate).getTime() - - new Date(tripDetails.startDate).getTime()) / - (1000 * 60 * 60 * 24), - ), - image: "https://placehold.co/300x200?text=Accommodation", - roomTypes: ["Standard", "Deluxe", "Suite"], - checkInTime: "3:00 PM", - checkOutTime: "11:00 AM", - }); - - const [selectedRoom, setSelectedRoom] = useState("Standard"); - const [bookingStep, setBookingStep] = useState< - "details" | "payment" | "confirmed" - >("details"); - const [formData, setFormData] = useState({ - name: "", - email: "", - phone: "", - specialRequests: "", - }); - - const handleInputChange = ( - e: React.ChangeEvent, - ) => { - setFormData({ ...formData, [e.target.name]: e.target.value }); - }; - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - setBookingStep("payment"); - }; - - const handlePayment = (e: React.FormEvent) => { - e.preventDefault(); - setBookingStep("confirmed"); - }; - - return ( -
-
-

Book {accommodation.name}

-

- {new Date(tripDetails.startDate).toLocaleDateString()} -{" "} - {new Date(tripDetails.endDate).toLocaleDateString()} ·{" "} - {tripDetails.numberOfGuests} guests -

-
- -
- {bookingStep === "details" && ( - <> -
-
- {accommodation.name} -
-
-

- {accommodation.name} -

-
- - - - - {accommodation.rating} - -
-
- - {accommodation.type} - - - {accommodation.price} - -
-
-
- -
-
- Check-in - - {new Date(tripDetails.startDate).toLocaleDateString()} ( - {accommodation.checkInTime}) - -
-
- Check-out - - {new Date(tripDetails.endDate).toLocaleDateString()} ( - {accommodation.checkOutTime}) - -
-
- Guests - - {tripDetails.numberOfGuests} - -
-
- -
- -
- {accommodation.roomTypes.map((room) => ( - - ))} -
-
- -
-
- - -
- -
- - -
- -
- - -
- -
- -