diff --git a/agent/agent.tsx b/agent/agent.tsx index dfacb6f..30f3db9 100644 --- a/agent/agent.tsx +++ b/agent/agent.tsx @@ -6,9 +6,11 @@ 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"; 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`; async function router( state: GenerativeUIState, @@ -19,7 +21,7 @@ ${allToolDescriptions} `; const routerSchema = z.object({ route: z - .enum(["stockbroker", "tripPlanner", "generalInput"]) + .enum(["stockbroker", "tripPlanner", "openCode", "generalInput"]) .describe(routerDescription), }); const routerTool = { @@ -73,7 +75,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" | "generalInput" { return state.next; } @@ -104,16 +106,19 @@ const builder = new StateGraph(GenerativeUIAnnotation) .addNode("router", router) .addNode("stockbroker", stockbrokerGraph) .addNode("tripPlanner", tripPlannerGraph) + .addNode("openCode", openCodeGraph) .addNode("generalInput", handleGeneralInput) .addConditionalEdges("router", handleRoute, [ "stockbroker", "tripPlanner", + "openCode", "generalInput", ]) .addEdge(START, "router") .addEdge("stockbroker", END) .addEdge("tripPlanner", END) + .addEdge("openCode", END) .addEdge("generalInput", END); export const graph = builder.compile(); diff --git a/agent/open-code/index.ts b/agent/open-code/index.ts index 4247e1e..91f0a9e 100644 --- a/agent/open-code/index.ts +++ b/agent/open-code/index.ts @@ -1,21 +1,14 @@ import { END, START, StateGraph } from "@langchain/langgraph"; -import { OpenCodeAnnotation, OpenCodeState } from "./types"; +import { OpenCodeAnnotation } from "./types"; import { planner } from "./nodes/planner"; -import { interrupt } from "./nodes/interrupt"; import { executor } from "./nodes/executor"; -function handleRoutingFromExecutor(state: OpenCodeState): "executor" | "interrupt" { - const lastAIMessage = state.messages.findLast((m) => m.getType() === "ai"); - if (lastAIMessage) -} - -function handleRoutingFromInterrupt(state: OpenCodeState): "executor" | typeof END {} - const workflow = new StateGraph(OpenCodeAnnotation) .addNode("planner", planner) .addNode("executor", executor) - .addNode("interrupt", interrupt) .addEdge(START, "planner") .addEdge("planner", "executor") - .addConditionalEdges("executor", handleRoutingFromExecutor, ["executor", "interrupt"]) - .addConditionalEdges("interrupt", handleRoutingFromInterrupt, ["executor", END]) \ No newline at end of file + .addEdge("executor", 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 index e7e7023..a9e50c9 100644 --- a/agent/open-code/nodes/executor.ts +++ b/agent/open-code/nodes/executor.ts @@ -1,5 +1,101 @@ +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"; +import { PLAN } from "./planner"; -export async function executor(state: OpenCodeState): Promise { - throw new Error("Not implemented" + state); -} \ No newline at end of file +export async function executor( + state: OpenCodeState, + config: LangGraphRunnableConfig, +): Promise { + const ui = typedUi(config); + + const numOfUpdateFileCalls = state.messages.filter( + (m) => + m.getType() === "ai" && + (m as unknown as AIMessage).tool_calls?.some( + (tc) => tc.name === "update_file", + ), + ).length; + const planItem = PLAN[numOfUpdateFileCalls - 1]; + + let updateFileContents = ""; + switch (numOfUpdateFileCalls) { + 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: { + args: { + new_file_content: updateFileContents, + }, + }, + id: toolCallId, + type: "tool_call", + }, + ], + }; + + ui.write("proposed-change", { + toolCallId, + change: updateFileContents, + planItem, + }); + + return { + messages: [aiMessage], + ui: ui.collect as OpenCodeUpdate["ui"], + timestamp: Date.now(), + }; +} diff --git a/agent/open-code/nodes/interrupt.ts b/agent/open-code/nodes/interrupt.ts deleted file mode 100644 index 8d73de4..0000000 --- a/agent/open-code/nodes/interrupt.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { OpenCodeState, OpenCodeUpdate } from "../types"; - -export async function interrupt(state: OpenCodeState): Promise { - throw new Error("Not implemented" + state); -} \ No newline at end of file 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/plan-code/step-7.txt b/agent/open-code/nodes/plan-code/step-7.txt new file mode 100644 index 0000000..e69de29 diff --git a/agent/open-code/nodes/planner.ts b/agent/open-code/nodes/planner.ts index b978741..29a2361 100644 --- a/agent/open-code/nodes/planner.ts +++ b/agent/open-code/nodes/planner.ts @@ -1,25 +1,60 @@ import { v4 as uuidv4 } from "uuid"; -import { AIMessage } from "@langchain/langgraph-sdk"; +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"; -export async function planner(state: OpenCodeState): Promise { +export 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 toolCallId = uuidv4(); const aiMessage: AIMessage = { type: "ai", id: uuidv4(), - content: "", + content: "I've come up with a detailed plan for building the todo app.", tool_calls: [ { - name: "update_file", + name: "plan", args: { args: { - new_file_content: "ADD_CODE_HERE" + plan: PLAN, }, }, - id: uuidv4(), + id: toolCallId, type: "tool_call", - } - ] - } + }, + ], + }; - const toolMessage = {} -} \ No newline at end of file + ui.write("code-plan", { + toolCallId, + plan: PLAN, + }); + + 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.collect as OpenCodeUpdate["ui"], + timestamp: Date.now(), + }; +} diff --git a/agent/open-code/types.ts b/agent/open-code/types.ts index a9594e3..3a32290 100644 --- a/agent/open-code/types.ts +++ b/agent/open-code/types.ts @@ -5,8 +5,7 @@ export const OpenCodeAnnotation = Annotation.Root({ messages: GenerativeUIAnnotation.spec.messages, ui: GenerativeUIAnnotation.spec.ui, timestamp: GenerativeUIAnnotation.spec.timestamp, - next: Annotation<"executor" | "interrupt"> }); export type OpenCodeState = typeof OpenCodeAnnotation.State; -export type OpenCodeUpdate = typeof OpenCodeAnnotation.Update; \ No newline at end of file +export type OpenCodeUpdate = typeof OpenCodeAnnotation.Update; diff --git a/agent/types.ts b/agent/types.ts index 20f7017..74c0f47 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" | "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/open-code/plan/index.css b/agent/uis/open-code/plan/index.css new file mode 100644 index 0000000..f1d8c73 --- /dev/null +++ b/agent/uis/open-code/plan/index.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/agent/uis/open-code/plan/index.tsx b/agent/uis/open-code/plan/index.tsx new file mode 100644 index 0000000..c1f2bba --- /dev/null +++ b/agent/uis/open-code/plan/index.tsx @@ -0,0 +1,21 @@ +import "./index.css"; + +interface PlanProps { + toolCallId: string; + plan: string[]; +} + +export default function Plan(props: PlanProps) { + return ( +
+

Plan

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

+ {index + 1}. {step} +

+ ))} +
+
+ ); +} 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..d47df48 --- /dev/null +++ b/agent/uis/open-code/proposed-change/index.tsx @@ -0,0 +1,46 @@ +import { Button } from "@/components/ui/button"; +import "./index.css"; +import ReactMarkdown from "react-markdown"; +import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; +import { coldarkDark } from "react-syntax-highlighter/dist/cjs/styles/prism"; + +interface ProposedChangeProps { + toolCallId: string; + change: string; + planItem: string; +} + +export default function ProposedChange(props: ProposedChangeProps) { + const handleReject = () => {}; + const handleAccept = () => {}; + + return ( +
+

Proposed Change

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