implement components

This commit is contained in:
bracesproul
2025-03-07 10:47:08 -08:00
parent 066b219107
commit 7ebcbb3a28
19 changed files with 472 additions and 37 deletions

View File

@@ -6,9 +6,11 @@ import { stockbrokerGraph } from "./stockbroker";
import { ChatOpenAI } from "@langchain/openai"; import { ChatOpenAI } from "@langchain/openai";
import { tripPlannerGraph } from "./trip-planner"; import { tripPlannerGraph } from "./trip-planner";
import { formatMessages } from "./utils/format-messages"; 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 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( async function router(
state: GenerativeUIState, state: GenerativeUIState,
@@ -19,7 +21,7 @@ ${allToolDescriptions}
`; `;
const routerSchema = z.object({ const routerSchema = z.object({
route: z route: z
.enum(["stockbroker", "tripPlanner", "generalInput"]) .enum(["stockbroker", "tripPlanner", "openCode", "generalInput"])
.describe(routerDescription), .describe(routerDescription),
}); });
const routerTool = { const routerTool = {
@@ -73,7 +75,7 @@ Please pick the proper route based on the most recent message, in the context of
function handleRoute( function handleRoute(
state: GenerativeUIState, state: GenerativeUIState,
): "stockbroker" | "tripPlanner" | "generalInput" { ): "stockbroker" | "tripPlanner" | "openCode" | "generalInput" {
return state.next; return state.next;
} }
@@ -104,16 +106,19 @@ const builder = new StateGraph(GenerativeUIAnnotation)
.addNode("router", router) .addNode("router", router)
.addNode("stockbroker", stockbrokerGraph) .addNode("stockbroker", stockbrokerGraph)
.addNode("tripPlanner", tripPlannerGraph) .addNode("tripPlanner", tripPlannerGraph)
.addNode("openCode", openCodeGraph)
.addNode("generalInput", handleGeneralInput) .addNode("generalInput", handleGeneralInput)
.addConditionalEdges("router", handleRoute, [ .addConditionalEdges("router", handleRoute, [
"stockbroker", "stockbroker",
"tripPlanner", "tripPlanner",
"openCode",
"generalInput", "generalInput",
]) ])
.addEdge(START, "router") .addEdge(START, "router")
.addEdge("stockbroker", END) .addEdge("stockbroker", END)
.addEdge("tripPlanner", END) .addEdge("tripPlanner", END)
.addEdge("openCode", END)
.addEdge("generalInput", END); .addEdge("generalInput", END);
export const graph = builder.compile(); export const graph = builder.compile();

View File

@@ -1,21 +1,14 @@
import { END, START, StateGraph } from "@langchain/langgraph"; import { END, START, StateGraph } from "@langchain/langgraph";
import { OpenCodeAnnotation, OpenCodeState } from "./types"; import { OpenCodeAnnotation } from "./types";
import { planner } from "./nodes/planner"; import { planner } from "./nodes/planner";
import { interrupt } from "./nodes/interrupt";
import { executor } from "./nodes/executor"; 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) const workflow = new StateGraph(OpenCodeAnnotation)
.addNode("planner", planner) .addNode("planner", planner)
.addNode("executor", executor) .addNode("executor", executor)
.addNode("interrupt", interrupt)
.addEdge(START, "planner") .addEdge(START, "planner")
.addEdge("planner", "executor") .addEdge("planner", "executor")
.addConditionalEdges("executor", handleRoutingFromExecutor, ["executor", "interrupt"]) .addEdge("executor", END);
.addConditionalEdges("interrupt", handleRoutingFromInterrupt, ["executor", END])
export const graph = workflow.compile();
graph.name = "Open Code Graph";

View File

@@ -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 { 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<OpenCodeUpdate> { export async function executor(
throw new Error("Not implemented" + state); state: OpenCodeState,
} config: LangGraphRunnableConfig,
): Promise<OpenCodeUpdate> {
const ui = typedUi<typeof ComponentMap>(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(),
};
}

View File

@@ -1,5 +0,0 @@
import { OpenCodeState, OpenCodeUpdate } from "../types";
export async function interrupt(state: OpenCodeState): Promise<OpenCodeUpdate> {
throw new Error("Not implemented" + state);
}

View File

@@ -0,0 +1,5 @@
```bash
npx create-react-app todo-app --template typescript
cd todo-app
mkdir -p src/{components,styles,utils}
```

View File

@@ -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<TodoItemProps> = ({ id, text, completed, onToggle, onDelete }) => (
<div className={styles.todoItem}>
<input type='checkbox' checked={completed} onChange={() => onToggle(id)} />
<span className={completed ? styles.completed : ''}>{text}</span>
<button onClick={() => onDelete(id)}>Delete</button>
</div>
);
```

View File

@@ -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<TodoAction>;
} | undefined>(undefined);
export const TodoProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [state, dispatch] = useReducer(todoReducer, { todos: [] });
return <TodoContext.Provider value={{ state, dispatch }}>{children}</TodoContext.Provider>;
};
```

View File

@@ -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 (
<form onSubmit={handleSubmit} className={styles.form}>
<input
value={text}
onChange={(e) => setText(e.target.value)}
placeholder='Add a new todo'
/>
{error && <div className={styles.error}>{error}</div>}
<button type='submit'>Add Todo</button>
</form>
);
};
```

View File

@@ -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 }) => (
<div>
<select value={currentFilter} onChange={(e) => onFilterChange(e.target.value as FilterType)}>
<option value='all'>All</option>
<option value='active'>Active</option>
<option value='completed'>Completed</option>
</select>
<button onClick={() => onSortChange(true)}>Sort A-Z</button>
<button onClick={() => onSortChange(false)}>Sort Z-A</button>
</div>
);
```

View File

@@ -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) : [];
};
```

View File

@@ -1,25 +1,60 @@
import { v4 as uuidv4 } from "uuid"; 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 { 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<OpenCodeUpdate> { 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<OpenCodeUpdate> {
const ui = typedUi<typeof ComponentMap>(config);
const toolCallId = uuidv4();
const aiMessage: AIMessage = { const aiMessage: AIMessage = {
type: "ai", type: "ai",
id: uuidv4(), id: uuidv4(),
content: "", content: "I've come up with a detailed plan for building the todo app.",
tool_calls: [ tool_calls: [
{ {
name: "update_file", name: "plan",
args: { args: {
args: { args: {
new_file_content: "ADD_CODE_HERE" plan: PLAN,
}, },
}, },
id: uuidv4(), id: toolCallId,
type: "tool_call", type: "tool_call",
} },
] ],
} };
const toolMessage = {} 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(),
};
}

View File

@@ -5,8 +5,7 @@ export const OpenCodeAnnotation = Annotation.Root({
messages: GenerativeUIAnnotation.spec.messages, messages: GenerativeUIAnnotation.spec.messages,
ui: GenerativeUIAnnotation.spec.ui, ui: GenerativeUIAnnotation.spec.ui,
timestamp: GenerativeUIAnnotation.spec.timestamp, timestamp: GenerativeUIAnnotation.spec.timestamp,
next: Annotation<"executor" | "interrupt">
}); });
export type OpenCodeState = typeof OpenCodeAnnotation.State; export type OpenCodeState = typeof OpenCodeAnnotation.State;
export type OpenCodeUpdate = typeof OpenCodeAnnotation.Update; export type OpenCodeUpdate = typeof OpenCodeAnnotation.Update;

View File

@@ -12,7 +12,9 @@ export const GenerativeUIAnnotation = Annotation.Root({
UIMessage | RemoveUIMessage | (UIMessage | RemoveUIMessage)[] UIMessage | RemoveUIMessage | (UIMessage | RemoveUIMessage)[]
>({ default: () => [], reducer: uiMessageReducer }), >({ default: () => [], reducer: uiMessageReducer }),
timestamp: Annotation<number>, timestamp: Annotation<number>,
next: Annotation<"stockbroker" | "tripPlanner" | "generalInput">(), next: Annotation<
"stockbroker" | "tripPlanner" | "openCode" | "generalInput"
>(),
}); });
export type GenerativeUIState = typeof GenerativeUIAnnotation.State; export type GenerativeUIState = typeof GenerativeUIAnnotation.State;

View File

@@ -5,6 +5,8 @@ import BookAccommodation from "./trip-planner/book-accommodation";
import RestaurantsList from "./trip-planner/restaurants-list"; import RestaurantsList from "./trip-planner/restaurants-list";
import BookRestaurant from "./trip-planner/book-restaurant"; import BookRestaurant from "./trip-planner/book-restaurant";
import BuyStock from "./stockbroker/buy-stock"; import BuyStock from "./stockbroker/buy-stock";
import Plan from "./open-code/plan";
import ProposedChange from "./open-code/proposed-change";
const ComponentMap = { const ComponentMap = {
"stock-price": StockPrice, "stock-price": StockPrice,
@@ -14,5 +16,7 @@ const ComponentMap = {
"restaurants-list": RestaurantsList, "restaurants-list": RestaurantsList,
"book-restaurant": BookRestaurant, "book-restaurant": BookRestaurant,
"buy-stock": BuyStock, "buy-stock": BuyStock,
"code-plan": Plan,
"proposed-change": ProposedChange,
} as const; } as const;
export default ComponentMap; export default ComponentMap;

View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -0,0 +1,21 @@
import "./index.css";
interface PlanProps {
toolCallId: string;
plan: string[];
}
export default function Plan(props: PlanProps) {
return (
<div className="flex flex-col gap-4 w-full max-w-xl p-4 border-[1px] rounded-xl border-slate-500">
<p className="text-lg font-medium">Plan</p>
<div className="flex flex-col gap-2">
{props.plan.map((step, index) => (
<p key={index} className="font-mono">
{index + 1}. {step}
</p>
))}
</div>
</div>
);
}

View File

@@ -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;
}
}

View File

@@ -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 (
<div className="flex flex-col gap-4 w-full max-w-xl p-4 border-[1px] rounded-xl border-slate-200">
<p className="text-lg font-medium">Proposed Change</p>
<ReactMarkdown
children={props.change}
components={{
code(props) {
const { children, className, node: _node } = props;
const match = /language-(\w+)/.exec(className || "");
return match ? (
<SyntaxHighlighter
children={String(children).replace(/\n$/, "")}
language={match[1]}
style={coldarkDark}
/>
) : (
<code className={className}>{children}</code>
);
},
}}
/>
<div className="flex gap-2 items-center w-full">
<Button variant="destructive" onClick={handleReject}>
Reject
</Button>
<Button onClick={handleAccept}>Accept</Button>
</div>
</div>
);
}