fix: Remove agent

This commit is contained in:
bracesproul
2025-03-10 10:48:26 -07:00
parent efdd119cf2
commit 05cee1b32a
46 changed files with 3 additions and 4376 deletions

3
agent/.gitignore vendored
View File

@@ -1,3 +0,0 @@
# LangGraph API
.langgraph_api
dist

View File

@@ -1,136 +0,0 @@
import { StateGraph, START, END } from "@langchain/langgraph";
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
import { z } from "zod";
import { GenerativeUIAnnotation, GenerativeUIState } from "./types";
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.
- 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,
): Promise<Partial<GenerativeUIState>> {
const routerDescription = `The route to take based on the user's input.
${allToolDescriptions}
- generalInput: handles all other cases where the above tools don't apply
`;
const routerSchema = z.object({
route: z
.enum([
"stockbroker",
"tripPlanner",
"openCode",
"orderPizza",
"generalInput",
])
.describe(routerDescription),
});
const routerTool = {
name: "router",
description: "A tool to route the user's query to the appropriate tool.",
schema: routerSchema,
};
const llm = new ChatGoogleGenerativeAI({
model: "gemini-2.0-flash",
temperature: 0,
})
.bindTools([routerTool], { tool_choice: "router" })
.withConfig({ tags: ["langsmith:nostream"] });
const prompt = `You're a highly helpful AI assistant, tasked with routing the user's query to the appropriate tool.
You should analyze the user's input, and choose the appropriate tool to use.`;
const allMessagesButLast = state.messages.slice(0, -1);
const lastMessage = state.messages.at(-1);
const formattedPreviousMessages = formatMessages(allMessagesButLast);
const formattedLastMessage = lastMessage ? formatMessages([lastMessage]) : "";
const humanMessage = `Here is the full conversation, excluding the most recent message:
${formattedPreviousMessages}
Here is the most recent message:
${formattedLastMessage}
Please pick the proper route based on the most recent message, in the context of the entire conversation.`;
const response = await llm.invoke([
{ role: "system", content: prompt },
{ role: "user", content: humanMessage },
]);
const toolCall = response.tool_calls?.[0]?.args as
| z.infer<typeof routerSchema>
| undefined;
if (!toolCall) {
throw new Error("No tool call found in response");
}
return {
next: toolCall.route,
};
}
function handleRoute(
state: GenerativeUIState,
): "stockbroker" | "tripPlanner" | "openCode" | "orderPizza" | "generalInput" {
return state.next;
}
const GENERAL_INPUT_SYSTEM_PROMPT = `You are an AI assistant.
If the user asks what you can do, describe these tools.
${allToolDescriptions}
If the last message is a tool result, describe what the action was, congratulate the user, or send a friendly followup in response to the tool action. Ensure this is a clear and concise message.
Otherwise, just answer as normal.`;
async function handleGeneralInput(state: GenerativeUIState) {
const llm = new ChatOpenAI({ model: "gpt-4o-mini", temperature: 0 });
const response = await llm.invoke([
{
role: "system",
content: GENERAL_INPUT_SYSTEM_PROMPT,
},
...state.messages,
]);
return {
messages: [response],
};
}
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();
graph.name = "Generative UI Agent";

View File

@@ -1,14 +0,0 @@
import { z, ZodTypeAny } from "zod";
interface ToolCall {
name: string;
args: Record<string, any>;
id?: string;
type?: "tool_call";
}
export function findToolCall<Name extends string>(name: Name) {
return <Args extends ZodTypeAny>(
x: ToolCall,
): x is { name: Name; args: z.infer<Args>; id?: string } => x.name === name;
}

View File

@@ -1,45 +0,0 @@
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";

View File

@@ -1,127 +0,0 @@
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<OpenCodeUpdate> {
const ui = typedUi<typeof ComponentMap>(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(),
};
}

View File

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

View File

@@ -1,21 +0,0 @@
```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

@@ -1,22 +0,0 @@
```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

@@ -1,33 +0,0 @@
```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

@@ -1,22 +0,0 @@
```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

@@ -1,13 +0,0 @@
```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,114 +0,0 @@
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<OpenCodeUpdate> {
const ui = typedUi<typeof ComponentMap>(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(),
};
}

View File

@@ -1,11 +0,0 @@
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;

View File

@@ -1,113 +0,0 @@
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";

View File

@@ -1,11 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { StateGraph, START } from "@langchain/langgraph";
import { StockbrokerAnnotation } from "./types";
import { callTools } from "./nodes/tools";
const builder = new StateGraph(StockbrokerAnnotation)
.addNode("agent", callTools)
.addEdge(START, "agent");
export const stockbrokerGraph = builder.compile();
stockbrokerGraph.name = "Stockbroker";

View File

@@ -1,212 +0,0 @@
import { StockbrokerState, StockbrokerUpdate } from "../types";
import { ChatOpenAI } from "@langchain/openai";
import { typedUi } from "@langchain/langgraph-sdk/react-ui/server";
import type ComponentMap from "../../uis/index";
import { z } from "zod";
import { LangGraphRunnableConfig } from "@langchain/langgraph";
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[];
}> {
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 url = "https://api.financialdatasets.ai/prices";
const oneMonthAgo = format(subDays(new Date(), 30), "yyyy-MM-dd");
const now = format(new Date(), "yyyy-MM-dd");
const queryParamsOneDay = new URLSearchParams({
ticker,
interval: "minute",
interval_multiplier: "5",
start_date: now,
end_date: now,
limit: "5000",
});
const queryParamsThirtyDays = new URLSearchParams({
ticker,
interval: "minute",
interval_multiplier: "30",
start_date: oneMonthAgo,
end_date: now,
limit: "5000",
});
const [resOneDay, resThirtyDays] = await Promise.all([
fetch(`${url}?${queryParamsOneDay.toString()}`, options),
fetch(`${url}?${queryParamsThirtyDays.toString()}`, options),
]);
if (!resOneDay.ok || !resThirtyDays.ok) {
throw new Error("Failed to fetch prices");
}
const { prices: pricesOneDay } = await resOneDay.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,
thirtyDayPrices: pricesThirtyDays,
};
}
async function getPriceSnapshotForTicker(ticker: string): Promise<Snapshot> {
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 url = "https://api.financialdatasets.ai/prices/snapshot";
const queryParams = new URLSearchParams({
ticker,
});
const response = await fetch(`${url}?${queryParams.toString()}`, options);
if (!response.ok) {
throw new Error("Failed to fetch price snapshot");
}
const { snapshot } = await response.json();
return snapshot;
}
const llm = new ChatOpenAI({ model: "gpt-4o-mini", temperature: 0 });
const getStockPriceSchema = z.object({
ticker: z.string().describe("The ticker symbol of the company"),
});
const getPortfolioSchema = z.object({
get_portfolio: z.boolean().describe("Should be true."),
});
const buyStockSchema = z.object({
ticker: z.string().describe("The ticker symbol of the company"),
quantity: z.number().describe("The quantity of the stock to buy"),
});
const STOCKBROKER_TOOLS = [
{
name: "stock-price",
description: "A tool to get the stock price of a company",
schema: getStockPriceSchema,
},
{
name: "portfolio",
description:
"A tool to get the user's portfolio details. Only call this tool if the user requests their portfolio details.",
schema: getPortfolioSchema,
},
{
name: "buy-stock",
description: "A tool to buy a stock",
schema: buyStockSchema,
},
];
export async function callTools(
state: StockbrokerState,
config: LangGraphRunnableConfig,
): Promise<StockbrokerUpdate> {
const ui = typedUi<typeof ComponentMap>(config);
const message = await llm.bindTools(STOCKBROKER_TOOLS).invoke([
{
role: "system",
content:
"You are a stockbroker agent that uses tools to get the stock price of a company",
},
...state.messages,
]);
const stockbrokerToolCall = message.tool_calls?.find(
findToolCall("stock-price")<typeof getStockPriceSchema>,
);
const portfolioToolCall = message.tool_calls?.find(
findToolCall("portfolio")<typeof getPortfolioSchema>,
);
const buyStockToolCall = message.tool_calls?.find(
findToolCall("buy-stock")<typeof buyStockSchema>,
);
if (stockbrokerToolCall) {
const prices = await getPricesForTicker(stockbrokerToolCall.args.ticker);
ui.push(
{
name: "stock-price",
content: { ticker: stockbrokerToolCall.args.ticker, ...prices },
},
{ message },
);
}
if (portfolioToolCall) {
ui.push({ name: "portfolio", content: {} }, { message });
}
if (buyStockToolCall) {
const snapshot = await getPriceSnapshotForTicker(
buyStockToolCall.args.ticker,
);
ui.push(
{
name: "buy-stock",
content: {
toolCallId: buyStockToolCall.id ?? "",
snapshot,
quantity: buyStockToolCall.args.quantity,
},
},
{ message },
);
}
return {
messages: [message],
ui: ui.items,
timestamp: Date.now(),
};
}

View File

@@ -1,11 +0,0 @@
import { Annotation } from "@langchain/langgraph";
import { GenerativeUIAnnotation } from "../types";
export const StockbrokerAnnotation = Annotation.Root({
messages: GenerativeUIAnnotation.spec.messages,
ui: GenerativeUIAnnotation.spec.ui,
timestamp: GenerativeUIAnnotation.spec.timestamp,
});
export type StockbrokerState = typeof StockbrokerAnnotation.State;
export type StockbrokerUpdate = typeof StockbrokerAnnotation.Update;

View File

@@ -1,51 +0,0 @@
import { StateGraph, START, END } from "@langchain/langgraph";
import { TripPlannerAnnotation, TripPlannerState } from "./types";
import { extraction } from "./nodes/extraction";
import { callTools } from "./nodes/tools";
import { classify } from "./nodes/classify";
function routeStart(state: TripPlannerState): "classify" | "extraction" {
if (!state.tripDetails) {
return "extraction";
}
return "classify";
}
function routeAfterClassifying(
state: TripPlannerState,
): "callTools" | "extraction" {
// if `tripDetails` is undefined, this means they are not relevant to the conversation
if (!state.tripDetails) {
return "extraction";
}
// otherwise, they are relevant, and we should route to callTools
return "callTools";
}
function routeAfterExtraction(
state: TripPlannerState,
): "callTools" | typeof END {
// if `tripDetails` is undefined, this means they're missing some fields.
if (!state.tripDetails) {
return END;
}
return "callTools";
}
const builder = new StateGraph(TripPlannerAnnotation)
.addNode("classify", classify)
.addNode("extraction", extraction)
.addNode("callTools", callTools)
.addConditionalEdges(START, routeStart, ["classify", "extraction"])
.addConditionalEdges("classify", routeAfterClassifying, [
"callTools",
"extraction",
])
.addConditionalEdges("extraction", routeAfterExtraction, ["callTools", END])
.addEdge("callTools", END);
export const tripPlannerGraph = builder.compile();
tripPlannerGraph.name = "Trip Planner";

View File

@@ -1,73 +0,0 @@
import { ChatOpenAI } from "@langchain/openai";
import { TripPlannerState, TripPlannerUpdate } from "../types";
import { z } from "zod";
import { formatMessages } from "agent/utils/format-messages";
export async function classify(
state: TripPlannerState,
): Promise<TripPlannerUpdate> {
if (!state.tripDetails) {
// Can not classify if tripDetails are undefined
return {};
}
const schema = z.object({
isRelevant: z
.boolean()
.describe(
"Whether the trip details are still relevant to the user's request.",
),
});
const model = new ChatOpenAI({ model: "gpt-4o", temperature: 0 }).bindTools(
[
{
name: "classify",
description:
"A tool to classify whether or not the trip details are still relevant to the user's request.",
schema,
},
],
{
tool_choice: "classify",
},
);
const prompt = `You're an AI assistant for planning trips. The user has already specified the following details for their trip:
- location - ${state.tripDetails.location}
- startDate - ${state.tripDetails.startDate}
- endDate - ${state.tripDetails.endDate}
- numberOfGuests - ${state.tripDetails.numberOfGuests}
Your task is to carefully read over the user's conversation, and determine if their trip details are still relevant to their most recent request.
You should set is relevant to false if they are now asking about a new location, trip duration, or number of guests.
If they do NOT change their request details (or they never specified them), please set is relevant to true.
`;
const humanMessage = `Here is the entire conversation so far:\n${formatMessages(state.messages)}`;
const response = await model.invoke(
[
{ role: "system", content: prompt },
{ role: "human", content: humanMessage },
],
{ tags: ["langsmith:nostream"] },
);
const classificationDetails = response.tool_calls?.[0]?.args as
| z.infer<typeof schema>
| undefined;
if (!classificationDetails) {
throw new Error("Could not classify trip details");
}
if (!classificationDetails.isRelevant) {
return {
tripDetails: undefined,
};
}
// If it is relevant, return the state unchanged
return {};
}

View File

@@ -1,133 +0,0 @@
import { v4 as uuidv4 } from "uuid";
import { ChatOpenAI } from "@langchain/openai";
import { TripDetails, TripPlannerState, TripPlannerUpdate } from "../types";
import { z } from "zod";
import { formatMessages } from "agent/utils/format-messages";
import { ToolMessage } from "@langchain/langgraph-sdk";
import { DO_NOT_RENDER_ID_PREFIX } from "@/lib/ensure-tool-responses";
function calculateDates(
startDate: string | undefined,
endDate: string | undefined,
): { startDate: Date; endDate: Date } {
const now = new Date();
if (!startDate && !endDate) {
// Both undefined: 4 and 5 weeks in future
const start = new Date(now);
start.setDate(start.getDate() + 28); // 4 weeks
const end = new Date(now);
end.setDate(end.getDate() + 35); // 5 weeks
return { startDate: start, endDate: end };
}
if (startDate && !endDate) {
// Only start defined: end is 1 week after
const start = new Date(startDate);
const end = new Date(start);
end.setDate(end.getDate() + 7);
return { startDate: start, endDate: end };
}
if (!startDate && endDate) {
// Only end defined: start is 1 week before
const end = new Date(endDate);
const start = new Date(end);
start.setDate(start.getDate() - 7);
return { startDate: start, endDate: end };
}
// Both defined: use as is
return {
startDate: new Date(startDate!),
endDate: new Date(endDate!),
};
}
export async function extraction(
state: TripPlannerState,
): Promise<TripPlannerUpdate> {
const schema = z.object({
location: z
.string()
.describe(
"The location to plan the trip for. Can be a city, state, or country.",
),
startDate: z
.string()
.optional()
.describe("The start date of the trip. Should be in YYYY-MM-DD format"),
endDate: z
.string()
.optional()
.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",
),
});
const model = new ChatOpenAI({ model: "gpt-4o", temperature: 0 }).bindTools([
{
name: "extract",
description: "A tool to extract information from a user's request.",
schema: schema,
},
]);
const prompt = `You're an AI assistant for planning trips. The user has requested information about a trip they want to go on.
Before you can help them, you need to extract the following information from their request:
- location - The location to plan the trip for. Can be a city, state, or country.
- startDate - The start date of the trip. Should be in YYYY-MM-DD format. Optional
- endDate - The end date of the trip. Should be in YYYY-MM-DD format. Optional
- numberOfGuests - The number of guests for the trip. Optional
You are provided with the ENTIRE conversation history between you, and the user. Use these messages to extract the necessary information.
Do NOT guess, or make up any information. If the user did NOT specify a location, please respond with a request for them to specify the location.
You should ONLY send a clarification message if the user did not provide the location. You do NOT need any of the other fields, so if they're missing, proceed without them.
It should be a single sentence, along the lines of "Please specify the location for the trip you want to go on".
Extract only what is specified by the user. It is okay to leave fields blank if the user did not specify them.
`;
const humanMessage = `Here is the entire conversation so far:\n${formatMessages(state.messages)}`;
const response = await model.invoke([
{ role: "system", content: prompt },
{ role: "human", content: humanMessage },
]);
const toolCall = response.tool_calls?.[0];
if (!toolCall) {
return {
messages: [response],
};
}
const extractedDetails = toolCall.args as z.infer<typeof schema>;
const { startDate, endDate } = calculateDates(
extractedDetails.startDate,
extractedDetails.endDate,
);
const extractionDetailsWithDefaults: TripDetails = {
startDate,
endDate,
numberOfGuests: extractedDetails.numberOfGuests ?? 2,
location: extractedDetails.location,
};
const extractToolResponse: ToolMessage = {
type: "tool",
id: `${DO_NOT_RENDER_ID_PREFIX}${uuidv4()}`,
tool_call_id: toolCall.id ?? "",
content: "Successfully extracted trip details",
};
return {
tripDetails: extractionDetailsWithDefaults,
messages: [response, extractToolResponse],
};
}

View File

@@ -1,92 +0,0 @@
import { TripPlannerState, TripPlannerUpdate } from "../types";
import { ChatOpenAI } from "@langchain/openai";
import { typedUi } from "@langchain/langgraph-sdk/react-ui/server";
import type ComponentMap from "../../uis/index";
import { z } from "zod";
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 listRestaurantsSchema = z
.object({})
.describe("A tool to list restaurants for the user");
const ACCOMMODATIONS_TOOLS = [
{
name: "list-accommodations",
description: "A tool to list accommodations for the user",
schema: listAccommodationsSchema,
},
{
name: "list-restaurants",
description: "A tool to list restaurants for the user",
schema: listRestaurantsSchema,
},
];
export async function callTools(
state: TripPlannerState,
config: LangGraphRunnableConfig,
): Promise<TripPlannerUpdate> {
if (!state.tripDetails) {
throw new Error("No trip details found");
}
const ui = typedUi<typeof ComponentMap>(config);
const llm = new ChatOpenAI({ model: "gpt-4o", temperature: 0 }).bindTools(
ACCOMMODATIONS_TOOLS,
);
const response = await llm.invoke([
{
role: "system",
content:
"You are an AI assistant who helps users book trips. Use the user's most recent message(s) to contextually generate a response.",
},
...state.messages,
]);
const listAccommodationsToolCall = response.tool_calls?.find(
findToolCall("list-accommodations")<typeof listAccommodationsSchema>,
);
const listRestaurantsToolCall = response.tool_calls?.find(
findToolCall("list-restaurants")<typeof listRestaurantsSchema>,
);
if (!listAccommodationsToolCall && !listRestaurantsToolCall) {
throw new Error("No tool calls found");
}
if (listAccommodationsToolCall) {
ui.push(
{
name: "accommodations-list",
content: {
toolCallId: listAccommodationsToolCall.id ?? "",
...getAccommodationsListProps(state.tripDetails),
},
},
{ message: response },
);
}
if (listRestaurantsToolCall) {
ui.push(
{
name: "restaurants-list",
content: { tripDetails: state.tripDetails },
},
{ message: response },
);
}
return {
messages: [response],
ui: ui.items,
timestamp: Date.now(),
};
}

View File

@@ -1,19 +0,0 @@
import { Annotation } from "@langchain/langgraph";
import { GenerativeUIAnnotation } from "../types";
export type TripDetails = {
location: string;
startDate: Date;
endDate: Date;
numberOfGuests: number;
};
export const TripPlannerAnnotation = Annotation.Root({
messages: GenerativeUIAnnotation.spec.messages,
ui: GenerativeUIAnnotation.spec.ui,
timestamp: GenerativeUIAnnotation.spec.timestamp,
tripDetails: Annotation<TripDetails | undefined>(),
});
export type TripPlannerState = typeof TripPlannerAnnotation.State;
export type TripPlannerUpdate = typeof TripPlannerAnnotation.Update;

View File

@@ -1,67 +0,0 @@
import { faker } from "@faker-js/faker";
import { Accommodation } from "../../types";
import { TripDetails } from "../types";
export function getAccommodationsListProps(tripDetails: TripDetails) {
const IMAGE_URLS = [
"https://a0.muscache.com/im/pictures/c88d4356-9e33-4277-83fd-3053e5695333.jpg?im_w=1200&im_format=avif",
"https://a0.muscache.com/im/pictures/miso/Hosting-999231834211657440/original/fa140513-cc51-48a6-83c9-ef4e11e69bc2.jpeg?im_w=1200&im_format=avif",
"https://a0.muscache.com/im/pictures/miso/Hosting-5264493/original/10d2c21f-84c2-46c5-b20b-b51d1c2c971a.jpeg?im_w=1200&im_format=avif",
"https://a0.muscache.com/im/pictures/d0e3bb05-a96a-45cf-af92-980269168096.jpg?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/miso/Hosting-50597302/original/eb1bb383-4b70-45ae-b3ce-596f83436e6f.jpeg?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/miso/Hosting-900891950206269231/original/7cc71402-9430-48b4-b4f1-e8cac69fd7d3.jpeg?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/460efdcd-1286-431d-b4e5-e316d6427707.jpg?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/prohost-api/Hosting-51234810/original/5231025a-4c39-4a96-ac9c-b088fceb5531.jpeg?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/miso/Hosting-14886949/original/a9d72542-cd1f-418d-b070-a73035f94fe4.jpeg?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/2011683a-c045-4b5a-97a8-37bca4b98079.jpg?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/11bcbeec-749c-4897-8593-1ec6f6dc04ad.jpg?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/prohost-api/Hosting-18327626/original/fba2e4e8-9d68-47a8-838e-dab5353e5209.jpeg?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/miso/Hosting-813949239894880001/original/b2abe806-b60f-4c0b-b4e6-46808024e5b6.jpeg?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/prohost-api/Hosting-894877242638354447/original/29e50d48-1733-4c5b-9068-da4443dd7757.jpeg?im_w=720&im_format=avif",
,
"https://a0.muscache.com/im/pictures/hosting/Hosting-1079897686805296552/original/b24bd803-52f2-4ca7-9389-f73c9d9b3c64.jpeg?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/miso/Hosting-43730011/original/29f90186-4f83-408a-89ce-a82e520b4e36.png?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/300ae0e1-fc7e-4a05-93a4-26809311ef19.jpg?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/0c7b03c9-8907-437f-8874-628e89e00679.jpg?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/prohost-api/Hosting-1040593515802997386/original/0c910b31-03d3-450f-8dc3-2d7f7902b93e.jpeg?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/d336587a-a4bf-44c9-b4a6-68b71c359be0.jpg?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/prohost-api/Hosting-50345540/original/f8e911bb-8021-4edd-aca4-913d6f41fc6f.jpeg?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/prohost-api/Hosting-46122096/original/1bd27f94-cf00-4864-8ad9-bc1cd6c5e10d.jpeg?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/574424e1-4935-45f5-a5f0-e960b16a3fcc.jpg?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/181d4be2-6cb2-4306-94bf-89aa45c5de66.jpg?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/miso/Hosting-50545526/original/af14ce0b-481e-41be-88d1-b84758f578e5.jpeg?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/10d8309a-8ae6-492b-b1d5-20a543242c68.jpg?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/miso/Hosting-813727499556203528/original/12c1b750-4bea-40d9-9a10-66804df0530a.jpeg?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/83e4c0a0-65ce-4c5d-967e-d378ed1bfe15.jpg?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/852f2d4d-6786-47b5-a3ca-ff7f21bcac2d.jpg?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/92534e36-d67a-4346-b3cf-7371b1985aca.jpg?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/ecbfed18-29d0-4f86-b6aa-4325b076dfb3.jpg?im_w=720&im_format=avif",
"https://a0.muscache.com/im/pictures/prohost-api/Hosting-52443635/original/05f084c6-60d0-4945-81ff-d23dfb89c3ca.jpeg?im_w=720&im_format=avif",
];
const getAccommodations = (city: string): Accommodation[] => {
// Shuffle the image URLs array and take the first 6
const shuffledImages = [...IMAGE_URLS]
.sort(() => Math.random() - 0.5)
.slice(0, 6)
.filter((i): i is string => typeof i === "string");
return Array.from({ length: 6 }, (_, index) => ({
id: faker.string.uuid(),
name: faker.location.streetAddress(),
price: faker.number.int({ min: 100, max: 1000 }),
rating: Number(
faker.number
.float({ min: 4.0, max: 5.0, fractionDigits: 2 })
.toFixed(2),
),
city: city,
image: shuffledImages[index],
}));
};
return {
tripDetails,
accommodations: getAccommodations(tripDetails.location),
};
}

View File

@@ -1,48 +0,0 @@
import { MessagesAnnotation, Annotation } from "@langchain/langgraph";
import {
RemoveUIMessage,
UIMessage,
uiMessageReducer,
} from "@langchain/langgraph-sdk/react-ui/server";
export const GenerativeUIAnnotation = Annotation.Root({
messages: MessagesAnnotation.spec["messages"],
ui: Annotation<
UIMessage[],
UIMessage | RemoveUIMessage | (UIMessage | RemoveUIMessage)[]
>({ default: () => [], reducer: uiMessageReducer }),
timestamp: Annotation<number>,
next: Annotation<
"stockbroker" | "tripPlanner" | "openCode" | "orderPizza" | "generalInput"
>(),
});
export type GenerativeUIState = typeof GenerativeUIAnnotation.State;
export type Accommodation = {
id: string;
name: string;
price: number;
rating: number;
city: string;
image: string;
};
export type Price = {
ticker: string;
open: number;
close: number;
high: number;
low: number;
volume: number;
time: string;
};
export type Snapshot = {
price: number;
ticker: string;
day_change: number;
day_change_percent: number;
market_cap: number;
time: string;
};

View File

@@ -1,18 +0,0 @@
import StockPrice from "./stockbroker/stock-price";
import PortfolioView from "./stockbroker/portfolio-view";
import AccommodationsList from "./trip-planner/accommodations-list";
import RestaurantsList from "./trip-planner/restaurants-list";
import BuyStock from "./stockbroker/buy-stock";
import Plan from "./open-code/plan";
import ProposedChange from "./open-code/proposed-change";
const ComponentMap = {
"stock-price": StockPrice,
portfolio: PortfolioView,
"accommodations-list": AccommodationsList,
"restaurants-list": RestaurantsList,
"buy-stock": BuyStock,
"code-plan": Plan,
"proposed-change": ProposedChange,
} as const;
export default ComponentMap;

View File

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

View File

@@ -1,76 +0,0 @@
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 (
<div className="flex flex-col w-full max-w-4xl border-[1px] rounded-xl border-slate-200 overflow-hidden">
<div className="p-6">
<h2 className="text-2xl font-semibold text-left">Code Plan</h2>
</div>
<motion.div
className="relative overflow-hidden"
animate={{
height: isExpanded ? "auto" : "200px",
opacity: isExpanded ? 1 : 0.7,
}}
transition={{
height: { duration: 0.3, ease: [0.4, 0, 0.2, 1] },
opacity: { duration: 0.2 },
}}
initial={false}
>
<div className="grid grid-cols-3 divide-x divide-slate-300 w-full border-t border-slate-200 px-6 pt-4 pb-4">
<div className="flex flex-col gap-2">
<h3 className="text-lg font-medium mb-4 text-slate-700">
Remaining Plans
</h3>
{props.remainingPlans.map((step, index) => (
<p key={index} className="font-mono text-sm">
{index + 1}. {step}
</p>
))}
</div>
<div className="flex flex-col gap-2 px-6">
<h3 className="text-lg font-medium mb-4 text-slate-700">
Executed Plans
</h3>
{props.executedPlans.map((step, index) => (
<p key={index} className="font-mono text-sm">
{step}
</p>
))}
</div>
<div className="flex flex-col gap-2 px-6">
<h3 className="text-lg font-medium mb-4 text-slate-700">
Rejected Plans
</h3>
{props.rejectedPlans.map((step, index) => (
<p key={index} className="font-mono text-sm">
{step}
</p>
))}
</div>
</div>
</motion.div>
<motion.button
className="w-full py-2 border-t border-slate-200 flex items-center justify-center hover:bg-slate-50 transition-colors"
onClick={() => setIsExpanded(!isExpanded)}
animate={{ rotate: isExpanded ? 180 : 0 }}
transition={{ duration: 0.3, ease: [0.4, 0, 0.2, 1] }}
>
<ChevronDown className="w-5 h-5 text-slate-600" />
</motion.button>
</div>
);
}

View File

@@ -1,122 +0,0 @@
@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

@@ -1,189 +0,0 @@
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 (
<div
className={cn(
"flex flex-col gap-4 w-full max-w-4xl p-4 border-[1px] rounded-xl",
isAccepted ? "border-green-300" : "border-red-300",
)}
>
<div className="flex flex-col items-start justify-start gap-2">
<p className="text-lg font-medium">
{isAccepted ? "Accepted" : "Rejected"} Change
</p>
<p className="text-sm font-mono">{props.planItem}</p>
</div>
<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>
);
}
return (
<div className="flex flex-col gap-4 w-full max-w-4xl p-4 border-[1px] rounded-xl border-slate-200">
<div className="flex flex-col items-start justify-start gap-2">
<p className="text-lg font-medium">Proposed Change</p>
<p className="text-sm font-mono">{props.planItem}</p>
</div>
<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>
);
},
}}
/>
{!props.fullWriteAccess && (
<div className="flex gap-2 items-center w-full">
<Button
className="cursor-pointer w-full"
variant="destructive"
onClick={handleReject}
>
Reject
</Button>
<Button
className="cursor-pointer w-full"
onClick={() => handleAccept()}
>
Accept
</Button>
<Button
className="cursor-pointer w-full bg-blue-500 hover:bg-blue-500/90"
onClick={() => handleAccept(true)}
>
Accept, don&apos;t ask again
</Button>
</div>
)}
</div>
);
}

View File

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

View File

@@ -1,139 +0,0 @@
import "./index.css";
import { v4 as uuidv4 } from "uuid";
import { Snapshot } from "../../../types";
import { Button } from "@/components/ui/button";
import { useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
import { UIMessage, useStreamContext } from "@langchain/langgraph-sdk/react-ui";
import { Message } from "@langchain/langgraph-sdk";
import { getToolResponse } from "agent/uis/utils/get-tool-response";
import { DO_NOT_RENDER_ID_PREFIX } from "@/lib/ensure-tool-responses";
function Purchased({
ticker,
quantity,
price,
}: {
ticker: string;
quantity: number;
price: number;
}) {
return (
<div className="w-full md:w-lg rounded-xl shadow-md overflow-hidden border border-gray-200 flex flex-col gap-4 p-3">
<h1 className="text-xl font-medium mb-2">Purchase Executed - {ticker}</h1>
<div className="grid grid-cols-2 gap-4 text-sm mb-4">
<div className="flex flex-col gap-2">
<p>Number of Shares</p>
<p>Market Price</p>
<p>Total Cost</p>
</div>
<div className="flex flex-col gap-2 items-end justify-end">
<p>{quantity}</p>
<p>${price}</p>
<p>${(quantity * price).toFixed(2)}</p>
</div>
</div>
</div>
);
}
export default function BuyStock(props: {
toolCallId: string;
snapshot: Snapshot;
quantity: number;
}) {
const { snapshot, toolCallId } = props;
const [quantity, setQuantity] = useState(props.quantity);
const [finalPurchase, setFinalPurchase] = useState<{
ticker: string;
quantity: number;
price: number;
}>();
const thread = useStreamContext<
{ messages: Message[]; ui: UIMessage[] },
{ MetaType: { ui: UIMessage | undefined } }
>();
useEffect(() => {
if (typeof window === "undefined" || finalPurchase) return;
const toolResponse = getToolResponse(toolCallId, thread);
if (toolResponse) {
try {
const parsedContent: {
purchaseDetails: {
ticker: string;
quantity: number;
price: number;
};
} = JSON.parse(toolResponse.content as string);
setFinalPurchase(parsedContent.purchaseDetails);
} catch {
console.error("Failed to parse tool response content.");
}
}
}, []);
function handleBuyStock() {
const orderDetails = {
message: "Successfully purchased stock",
purchaseDetails: {
ticker: snapshot.ticker,
quantity: quantity,
price: snapshot.price,
},
};
thread.submit({
messages: [
{
type: "tool",
tool_call_id: toolCallId,
id: `${DO_NOT_RENDER_ID_PREFIX}${uuidv4()}`,
name: "buy-stock",
content: JSON.stringify(orderDetails),
},
{
type: "human",
content: `Purchased ${quantity} shares of ${snapshot.ticker} at ${snapshot.price} per share`,
},
],
});
setFinalPurchase(orderDetails.purchaseDetails);
}
if (finalPurchase) {
return <Purchased {...finalPurchase} />;
}
return (
<div className="w-full md:w-lg rounded-xl shadow-md overflow-hidden border border-gray-200 flex flex-col gap-4 p-3">
<h1 className="text-xl font-medium mb-2">Buy {snapshot.ticker}</h1>
<div className="grid grid-cols-2 gap-4 text-sm mb-4">
<div className="flex flex-col gap-2">
<p>Number of Shares</p>
<p>Market Price</p>
<p>Total Cost</p>
</div>
<div className="flex flex-col gap-2 items-end justify-end">
<Input
type="number"
className="max-w-[100px] border-0 border-b focus:border-b-2 rounded-none shadow-none focus:ring-0"
value={quantity}
onChange={(e) => setQuantity(Number(e.target.value))}
min={1}
/>
<p>${snapshot.price}</p>
<p>${(quantity * snapshot.price).toFixed(2)}</p>
</div>
</div>
<Button
className="w-full bg-green-600 hover:bg-green-700 transition-colors ease-in-out duration-200 cursor-pointer text-white"
onClick={handleBuyStock}
>
Buy
</Button>
</div>
);
}

View File

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

View File

@@ -1,959 +0,0 @@
import "./index.css";
import { useState } from "react";
export default function PortfolioView() {
// Placeholder portfolio data - ideally would come from props
const [portfolio] = useState({
totalValue: 156842.75,
cashBalance: 12467.32,
performance: {
daily: 1.24,
weekly: -0.52,
monthly: 3.87,
yearly: 14.28,
},
holdings: [
{
symbol: "AAPL",
name: "Apple Inc.",
shares: 45,
price: 187.32,
value: 8429.4,
change: 1.2,
allocation: 5.8,
avgCost: 162.5,
},
{
symbol: "MSFT",
name: "Microsoft Corporation",
shares: 30,
price: 403.78,
value: 12113.4,
change: 0.5,
allocation: 8.4,
avgCost: 340.25,
},
{
symbol: "AMZN",
name: "Amazon.com Inc.",
shares: 25,
price: 178.75,
value: 4468.75,
change: -0.8,
allocation: 3.1,
avgCost: 145.3,
},
{
symbol: "GOOGL",
name: "Alphabet Inc.",
shares: 20,
price: 164.85,
value: 3297.0,
change: 2.1,
allocation: 2.3,
avgCost: 125.75,
},
{
symbol: "NVDA",
name: "NVIDIA Corporation",
shares: 35,
price: 875.28,
value: 30634.8,
change: 3.4,
allocation: 21.3,
avgCost: 520.4,
},
{
symbol: "TSLA",
name: "Tesla, Inc.",
shares: 40,
price: 175.9,
value: 7036.0,
change: -1.2,
allocation: 4.9,
avgCost: 190.75,
},
],
});
const [activeTab, setActiveTab] = useState<"holdings" | "performance">(
"holdings",
);
const [sortConfig, setSortConfig] = useState<{
key: string;
direction: "asc" | "desc";
}>({
key: "allocation",
direction: "desc",
});
const [selectedHolding, setSelectedHolding] = useState<string | null>(null);
const sortedHoldings = [...portfolio.holdings].sort((a, b) => {
if (
a[sortConfig.key as keyof typeof a] < b[sortConfig.key as keyof typeof b]
) {
return sortConfig.direction === "asc" ? -1 : 1;
}
if (
a[sortConfig.key as keyof typeof a] > b[sortConfig.key as keyof typeof b]
) {
return sortConfig.direction === "asc" ? 1 : -1;
}
return 0;
});
const requestSort = (key: string) => {
let direction: "asc" | "desc" = "asc";
if (sortConfig.key === key && sortConfig.direction === "asc") {
direction = "desc";
}
setSortConfig({ key, direction });
};
const formatCurrency = (value: number) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(value);
};
const formatPercent = (value: number) => {
return `${value > 0 ? "+" : ""}${value.toFixed(2)}%`;
};
// Faux chart data for selected holding
const generateChartData = (symbol: string) => {
const data = [];
const basePrice =
portfolio.holdings.find((h) => h.symbol === symbol)?.price || 100;
for (let i = 0; i < 30; i++) {
const date = new Date();
date.setDate(date.getDate() - 30 + i);
const randomFactor = (Math.sin(i / 5) + Math.random() - 0.5) * 0.05;
const price = basePrice * (1 + randomFactor * (i / 3));
data.push({
date: date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
}),
price: parseFloat(price.toFixed(2)),
});
}
return data;
};
// Calculate total value and percent change for display
const totalChange = portfolio.holdings.reduce(
(acc, curr) => acc + (curr.price - curr.avgCost) * curr.shares,
0,
);
const totalPercentChange =
(totalChange / (portfolio.totalValue - totalChange)) * 100;
const selectedStock = selectedHolding
? portfolio.holdings.find((h) => h.symbol === selectedHolding)
: null;
const chartData = selectedHolding ? generateChartData(selectedHolding) : [];
return (
<div className="w-full max-w-3xl bg-white rounded-xl shadow-lg overflow-hidden border border-gray-200">
<div className="bg-gradient-to-r from-indigo-700 to-indigo-500 px-6 py-4">
<div className="flex justify-between items-center">
<h2 className="text-white font-bold text-xl tracking-tight flex items-center">
<svg
className="w-6 h-6 mr-2"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M2 10a8 8 0 018-8v8h8a8 8 0 11-16 0z"></path>
<path d="M12 2.252A8.014 8.014 0 0117.748 8H12V2.252z"></path>
</svg>
Portfolio Summary
</h2>
<div className="bg-indigo-800/50 text-white px-3 py-1 rounded-md text-sm backdrop-blur-sm border border-indigo-400/30 flex items-center">
<svg
className="w-3 h-3 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z"
clipRule="evenodd"
></path>
</svg>
Updated: {new Date().toLocaleString()}
</div>
</div>
</div>
<div className="p-6 bg-gradient-to-b from-indigo-50 to-white">
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="bg-white rounded-xl p-4 shadow-sm border border-gray-100 hover:shadow-md transition-shadow">
<div className="flex justify-between">
<p className="text-gray-500 text-sm font-medium">Total Value</p>
<svg
className="w-5 h-5 text-indigo-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M4 4a2 2 0 00-2 2v4a2 2 0 002 2V6h10a2 2 0 00-2-2H4zm2 6a2 2 0 012-2h8a2 2 0 012 2v4a2 2 0 01-2 2H8a2 2 0 01-2-2v-4zm6 4a2 2 0 100-4 2 2 0 000 4z"
clipRule="evenodd"
></path>
</svg>
</div>
<p className="text-2xl font-bold text-gray-900 mt-1">
{formatCurrency(portfolio.totalValue)}
</p>
<p
className={`text-xs mt-1 flex items-center ${totalPercentChange >= 0 ? "text-green-600" : "text-red-600"}`}
>
{totalPercentChange >= 0 ? (
<svg
className="w-3 h-3 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 7.414V15a1 1 0 11-2 0V7.414L6.707 9.707a1 1 0 01-1.414 0z"
clipRule="evenodd"
></path>
</svg>
) : (
<svg
className="w-3 h-3 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M14.707 10.293a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L9 12.586V5a1 1 0 012 0v7.586l2.293-2.293a1 1 0 011.414 0z"
clipRule="evenodd"
></path>
</svg>
)}
{formatPercent(totalPercentChange)} All Time
</p>
</div>
<div className="bg-white rounded-xl p-4 shadow-sm border border-gray-100 hover:shadow-md transition-shadow">
<div className="flex justify-between">
<p className="text-gray-500 text-sm font-medium">Cash Balance</p>
<svg
className="w-5 h-5 text-indigo-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M8.433 7.418c.155-.103.346-.196.567-.267v1.698a2.305 2.305 0 01-.567-.267C8.07 8.34 8 8.114 8 8c0-.114.07-.34.433-.582zM11 12.849v-1.698c.22.071.412.164.567.267.364.243.433.468.433.582 0 .114-.07.34-.433.582a2.305 2.305 0 01-.567.267z"></path>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-13a1 1 0 10-2 0v.092a4.535 4.535 0 00-1.676.662C6.602 6.234 6 7.009 6 8c0 .99.602 1.765 1.324 2.246.48.32 1.054.545 1.676.662v1.941c-.391-.127-.68-.317-.843-.504a1 1 0 10-1.51 1.31c.562.649 1.413 1.076 2.353 1.253V15a1 1 0 102 0v-.092a4.535 4.535 0 001.676-.662C13.398 13.766 14 12.991 14 12c0-.99-.602-1.765-1.324-2.246A4.535 4.535 0 0011 9.092V7.151c.391.127.68.317.843.504a1 1 0 101.511-1.31c-.563-.649-1.413-1.076-2.354-1.253V5z"
clipRule="evenodd"
></path>
</svg>
</div>
<p className="text-2xl font-bold text-gray-900 mt-1">
{formatCurrency(portfolio.cashBalance)}
</p>
<p className="text-xs mt-1 text-gray-500">
{((portfolio.cashBalance / portfolio.totalValue) * 100).toFixed(
1,
)}
% of portfolio
</p>
</div>
<div className="bg-white rounded-xl p-4 shadow-sm border border-gray-100 hover:shadow-md transition-shadow">
<div className="flex justify-between">
<p className="text-gray-500 text-sm font-medium">Daily Change</p>
<svg
className="w-5 h-5 text-indigo-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M12 7a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0V8.414l-4.293 4.293a1 1 0 01-1.414 0L8 10.414l-4.293 4.293a1 1 0 01-1.414-1.414l5-5a1 1 0 011.414 0L11 10.586 14.586 7H12z"
clipRule="evenodd"
></path>
</svg>
</div>
<p
className={`text-2xl font-bold mt-1 ${portfolio.performance.daily >= 0 ? "text-green-600" : "text-red-600"}`}
>
{formatPercent(portfolio.performance.daily)}
</p>
<p
className={`text-xs mt-1 ${portfolio.performance.daily >= 0 ? "text-green-600" : "text-red-600"}`}
>
{formatCurrency(
(portfolio.totalValue * portfolio.performance.daily) / 100,
)}
</p>
</div>
</div>
<div className="border-b border-gray-200 mb-4">
<div className="flex space-x-4">
<button
onClick={() => {
setActiveTab("holdings");
setSelectedHolding(null);
}}
className={`px-4 py-2 font-medium text-sm focus:outline-none ${
activeTab === "holdings"
? "text-indigo-600 border-b-2 border-indigo-600 font-semibold"
: "text-gray-500 hover:text-gray-700"
}`}
>
Holdings
</button>
<button
onClick={() => {
setActiveTab("performance");
setSelectedHolding(null);
}}
className={`px-4 py-2 font-medium text-sm focus:outline-none ${
activeTab === "performance"
? "text-indigo-600 border-b-2 border-indigo-600 font-semibold"
: "text-gray-500 hover:text-gray-700"
}`}
>
Performance
</button>
</div>
</div>
{activeTab === "holdings" && !selectedHolding && (
<div className="overflow-x-auto rounded-lg border border-gray-200 shadow-sm">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th
onClick={() => requestSort("symbol")}
className="group px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
>
<div className="flex items-center">
<span>Symbol</span>
<span className="ml-1 text-gray-400 group-hover:text-gray-700">
{sortConfig.key === "symbol"
? sortConfig.direction === "asc"
? "↑"
: "↓"
: "↕"}
</span>
</div>
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Company
</th>
<th
onClick={() => requestSort("shares")}
className="group px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
>
<div className="flex items-center justify-end">
<span>Shares</span>
<span className="ml-1 text-gray-400 group-hover:text-gray-700">
{sortConfig.key === "shares"
? sortConfig.direction === "asc"
? "↑"
: "↓"
: "↕"}
</span>
</div>
</th>
<th
onClick={() => requestSort("price")}
className="group px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
>
<div className="flex items-center justify-end">
<span>Price</span>
<span className="ml-1 text-gray-400 group-hover:text-gray-700">
{sortConfig.key === "price"
? sortConfig.direction === "asc"
? "↑"
: "↓"
: "↕"}
</span>
</div>
</th>
<th
onClick={() => requestSort("change")}
className="group px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
>
<div className="flex items-center justify-end">
<span>Change</span>
<span className="ml-1 text-gray-400 group-hover:text-gray-700">
{sortConfig.key === "change"
? sortConfig.direction === "asc"
? "↑"
: "↓"
: "↕"}
</span>
</div>
</th>
<th
onClick={() => requestSort("value")}
className="group px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
>
<div className="flex items-center justify-end">
<span>Value</span>
<span className="ml-1 text-gray-400 group-hover:text-gray-700">
{sortConfig.key === "value"
? sortConfig.direction === "asc"
? "↑"
: "↓"
: "↕"}
</span>
</div>
</th>
<th
onClick={() => requestSort("allocation")}
className="group px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
>
<div className="flex items-center justify-end">
<span>Allocation</span>
<span className="ml-1 text-gray-400 group-hover:text-gray-700">
{sortConfig.key === "allocation"
? sortConfig.direction === "asc"
? "↑"
: "↓"
: "↕"}
</span>
</div>
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{sortedHoldings.map((holding) => (
<tr
key={holding.symbol}
className="hover:bg-indigo-50 cursor-pointer transition-colors"
onClick={() => setSelectedHolding(holding.symbol)}
>
<td className="px-4 py-4 text-sm font-medium text-indigo-600">
{holding.symbol}
</td>
<td className="px-4 py-4 text-sm text-gray-900">
{holding.name}
</td>
<td className="px-4 py-4 text-sm text-gray-900 text-right">
{holding.shares.toLocaleString()}
</td>
<td className="px-4 py-4 text-sm text-gray-900 text-right">
{formatCurrency(holding.price)}
</td>
<td
className={`px-4 py-4 text-sm text-right font-medium flex items-center justify-end ${holding.change >= 0 ? "text-green-600" : "text-red-600"}`}
>
{holding.change >= 0 ? (
<svg
className="w-3 h-3 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 7.414V15a1 1 0 11-2 0V7.414L6.707 9.707a1 1 0 01-1.414 0z"
clipRule="evenodd"
></path>
</svg>
) : (
<svg
className="w-3 h-3 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M14.707 10.293a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L9 12.586V5a1 1 0 012 0v7.586l2.293-2.293a1 1 0 011.414 0z"
clipRule="evenodd"
></path>
</svg>
)}
{formatPercent(holding.change)}
</td>
<td className="px-4 py-4 text-sm text-gray-900 text-right font-medium">
{formatCurrency(holding.value)}
</td>
<td className="px-4 py-4 text-right">
<div className="flex items-center justify-end">
<div className="w-16 bg-gray-200 h-2 rounded-full overflow-hidden mr-2">
<div
className={`h-2 ${holding.change >= 0 ? "bg-green-500" : "bg-red-500"}`}
style={{
width: `${Math.min(100, holding.allocation * 3)}%`,
}}
></div>
</div>
<span className="text-sm text-gray-900">
{holding.allocation.toFixed(1)}%
</span>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{activeTab === "holdings" && selectedHolding && selectedStock && (
<div className="rounded-lg border border-gray-200 shadow-sm bg-white">
<div className="p-4 flex justify-between items-start">
<div>
<div className="flex items-center">
<h3 className="text-xl font-bold text-gray-900">
{selectedStock.symbol}
</h3>
<span className="ml-2 text-gray-600">
{selectedStock.name}
</span>
</div>
<div className="flex items-center mt-1">
<span className="text-2xl font-bold text-gray-900">
{formatCurrency(selectedStock.price)}
</span>
<span
className={`ml-2 text-sm font-medium ${selectedStock.change >= 0 ? "text-green-600" : "text-red-600"}`}
>
{selectedStock.change >= 0 ? "▲" : "▼"}{" "}
{formatPercent(selectedStock.change)}
</span>
</div>
</div>
<button
onClick={() => setSelectedHolding(null)}
className="bg-gray-100 hover:bg-gray-200 p-1 rounded-md"
>
<svg
className="w-5 h-5 text-gray-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M6 18L18 6M6 6l12 12"
></path>
</svg>
</button>
</div>
<div className="border-t border-gray-200 p-4">
<div className="h-40 bg-white">
<div className="flex items-end h-full space-x-1">
{chartData.map((point, index) => {
const maxPrice = Math.max(...chartData.map((d) => d.price));
const minPrice = Math.min(...chartData.map((d) => d.price));
const range = maxPrice - minPrice;
const heightPercent =
range === 0
? 50
: ((point.price - minPrice) / range) * 80 + 10;
return (
<div
key={index}
className="flex flex-col items-center flex-1"
>
<div
className={`w-full rounded-sm ${point.price >= chartData[Math.max(0, index - 1)].price ? "bg-green-500" : "bg-red-500"}`}
style={{ height: `${heightPercent}%` }}
></div>
{index % 5 === 0 && (
<span className="text-xs text-gray-500 mt-1">
{point.date}
</span>
)}
</div>
);
})}
</div>
</div>
</div>
<div className="border-t border-gray-200 p-4">
<div className="grid grid-cols-3 gap-4">
<div>
<p className="text-xs text-gray-500">Shares Owned</p>
<p className="text-sm font-medium">
{selectedStock.shares.toLocaleString()}
</p>
</div>
<div>
<p className="text-xs text-gray-500">Market Value</p>
<p className="text-sm font-medium">
{formatCurrency(selectedStock.value)}
</p>
</div>
<div>
<p className="text-xs text-gray-500">Avg. Cost</p>
<p className="text-sm font-medium">
{formatCurrency(selectedStock.avgCost)}
</p>
</div>
<div>
<p className="text-xs text-gray-500">Cost Basis</p>
<p className="text-sm font-medium">
{formatCurrency(
selectedStock.avgCost * selectedStock.shares,
)}
</p>
</div>
<div>
<p className="text-xs text-gray-500">Gain/Loss</p>
<p
className={`text-sm font-medium ${selectedStock.price - selectedStock.avgCost >= 0 ? "text-green-600" : "text-red-600"}`}
>
{formatCurrency(
(selectedStock.price - selectedStock.avgCost) *
selectedStock.shares,
)}
</p>
</div>
<div>
<p className="text-xs text-gray-500">Allocation</p>
<p className="text-sm font-medium">
{selectedStock.allocation.toFixed(2)}%
</p>
</div>
</div>
</div>
<div className="border-t border-gray-200 p-4 flex space-x-2">
<button className="flex-1 bg-green-600 hover:bg-green-700 text-white font-medium py-2 px-4 rounded-md transition-colors text-sm">
Buy More
</button>
<button className="flex-1 bg-red-600 hover:bg-red-700 text-white font-medium py-2 px-4 rounded-md transition-colors text-sm">
Sell
</button>
<button className="flex items-center justify-center w-10 h-10 border border-gray-300 rounded-md hover:bg-gray-100 transition-colors">
<svg
className="w-5 h-5 text-gray-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M8 12h.01M12 12h.01M16 12h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
</button>
</div>
</div>
)}
{activeTab === "performance" && (
<div className="space-y-6">
<div className="bg-white rounded-xl p-5 shadow-sm border border-gray-200">
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
<svg
className="w-5 h-5 mr-2 text-indigo-500"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M2 11a1 1 0 011-1h2a1 1 0 011 1v5a1 1 0 01-1 1H3a1 1 0 01-1-1v-5zM8 7a1 1 0 011-1h2a1 1 0 011 1v9a1 1 0 01-1 1H9a1 1 0 01-1-1V7zM14 4a1 1 0 011-1h2a1 1 0 011 1v12a1 1 0 01-1 1h-2a1 1 0 01-1-1V4z"></path>
</svg>
Performance Overview
</h3>
<div className="grid grid-cols-4 gap-4">
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-gray-500 text-sm font-medium">Daily</p>
<p
className={`text-lg font-bold flex items-center ${portfolio.performance.daily >= 0 ? "text-green-600" : "text-red-600"}`}
>
{portfolio.performance.daily >= 0 ? (
<svg
className="w-4 h-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M12 7a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0V8.414l-4.293 4.293a1 1 0 01-1.414 0L8 10.414l-4.293 4.293a1 1 0 01-1.414-1.414l5-5a1 1 0 011.414 0L11 10.586 14.586 7H12z"
clipRule="evenodd"
></path>
</svg>
) : (
<svg
className="w-4 h-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M12 13a1 1 0 100 2h5a1 1 0 001-1V9a1 1 0 10-2 0v2.586l-4.293-4.293a1 1 0 00-1.414 0L8 9.586 3.707 5.293a1 1 0 00-1.414 1.414l5 5a1 1 0 001.414 0L11 9.414 14.586 13H12z"
clipRule="evenodd"
></path>
</svg>
)}
{formatPercent(portfolio.performance.daily)}
</p>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-gray-500 text-sm font-medium">Weekly</p>
<p
className={`text-lg font-bold flex items-center ${portfolio.performance.weekly >= 0 ? "text-green-600" : "text-red-600"}`}
>
{portfolio.performance.weekly >= 0 ? (
<svg
className="w-4 h-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M12 7a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0V8.414l-4.293 4.293a1 1 0 01-1.414 0L8 10.414l-4.293 4.293a1 1 0 01-1.414-1.414l5-5a1 1 0 011.414 0L11 10.586 14.586 7H12z"
clipRule="evenodd"
></path>
</svg>
) : (
<svg
className="w-4 h-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M12 13a1 1 0 100 2h5a1 1 0 001-1V9a1 1 0 10-2 0v2.586l-4.293-4.293a1 1 0 00-1.414 0L8 9.586 3.707 5.293a1 1 0 00-1.414 1.414l5 5a1 1 0 001.414 0L11 9.414 14.586 13H12z"
clipRule="evenodd"
></path>
</svg>
)}
{formatPercent(portfolio.performance.weekly)}
</p>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-gray-500 text-sm font-medium">Monthly</p>
<p
className={`text-lg font-bold flex items-center ${portfolio.performance.monthly >= 0 ? "text-green-600" : "text-red-600"}`}
>
{portfolio.performance.monthly >= 0 ? (
<svg
className="w-4 h-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M12 7a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0V8.414l-4.293 4.293a1 1 0 01-1.414 0L8 10.414l-4.293 4.293a1 1 0 01-1.414-1.414l5-5a1 1 0 011.414 0L11 10.586 14.586 7H12z"
clipRule="evenodd"
></path>
</svg>
) : (
<svg
className="w-4 h-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M12 13a1 1 0 100 2h5a1 1 0 001-1V9a1 1 0 10-2 0v2.586l-4.293-4.293a1 1 0 00-1.414 0L8 9.586 3.707 5.293a1 1 0 00-1.414 1.414l5 5a1 1 0 001.414 0L11 9.414 14.586 13H12z"
clipRule="evenodd"
></path>
</svg>
)}
{formatPercent(portfolio.performance.monthly)}
</p>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-gray-500 text-sm font-medium">Yearly</p>
<p
className={`text-lg font-bold flex items-center ${portfolio.performance.yearly >= 0 ? "text-green-600" : "text-red-600"}`}
>
{portfolio.performance.yearly >= 0 ? (
<svg
className="w-4 h-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M12 7a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0V8.414l-4.293 4.293a1 1 0 01-1.414 0L8 10.414l-4.293 4.293a1 1 0 01-1.414-1.414l5-5a1 1 0 011.414 0L11 10.586 14.586 7H12z"
clipRule="evenodd"
></path>
</svg>
) : (
<svg
className="w-4 h-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M12 13a1 1 0 100 2h5a1 1 0 001-1V9a1 1 0 10-2 0v2.586l-4.293-4.293a1 1 0 00-1.414 0L8 9.586 3.707 5.293a1 1 0 00-1.414 1.414l5 5a1 1 0 001.414 0L11 9.414 14.586 13H12z"
clipRule="evenodd"
></path>
</svg>
)}
{formatPercent(portfolio.performance.yearly)}
</p>
</div>
</div>
</div>
<div className="bg-white rounded-xl p-5 shadow-sm border border-gray-200">
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
<svg
className="w-5 h-5 mr-2 text-indigo-500"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M2 10a8 8 0 018-8v8h8a8 8 0 11-16 0z"></path>
<path d="M12 2.252A8.014 8.014 0 0117.748 8H12V2.252z"></path>
</svg>
Portfolio Allocation
</h3>
<div className="space-y-3">
{sortedHoldings.map((holding) => (
<div
key={holding.symbol}
className="flex items-center group hover:bg-indigo-50 p-2 rounded-lg transition-colors"
>
<div className="w-24 text-sm font-medium text-indigo-600 flex items-center">
<div
className={`w-3 h-3 rounded-full mr-2 ${holding.change >= 0 ? "bg-green-500" : "bg-red-500"}`}
></div>
{holding.symbol}
</div>
<div className="flex-grow">
<div className="bg-gray-200 h-4 rounded-full overflow-hidden shadow-inner">
<div
className="h-4 bg-gradient-to-r from-indigo-500 to-indigo-600"
style={{ width: `${holding.allocation}%` }}
></div>
</div>
</div>
<div className="w-16 text-sm font-medium text-gray-900 text-right ml-3">
{holding.allocation.toFixed(1)}%
</div>
<div className="opacity-0 group-hover:opacity-100 transition-opacity ml-2">
<button className="p-1 text-gray-400 hover:text-indigo-600">
<svg
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z"></path>
<path
fillRule="evenodd"
d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z"
clipRule="evenodd"
></path>
</svg>
</button>
</div>
</div>
))}
</div>
<div className="mt-6 bg-gray-50 p-3 rounded-lg">
<h4 className="text-sm font-medium text-gray-700 mb-2">
Portfolio Diversification
</h4>
<div className="flex h-4 rounded-full overflow-hidden">
{[
"Technology",
"Consumer Cyclical",
"Communication Services",
"Financial",
"Other",
].map((sector, index) => {
const widths = [42, 23, 18, 10, 7]; // example percentages
const colors = [
"bg-indigo-600",
"bg-blue-500",
"bg-green-500",
"bg-yellow-500",
"bg-red-500",
];
return (
<div
key={sector}
className={`${colors[index]} h-full`}
style={{ width: `${widths[index]}%` }}
title={`${sector}: ${widths[index]}%`}
></div>
);
})}
</div>
<div className="flex flex-wrap mt-2 text-xs">
{[
"Technology",
"Consumer Cyclical",
"Communication Services",
"Financial",
"Other",
].map((sector, index) => {
const widths = [42, 23, 18, 10, 7]; // example percentages
const colors = [
"text-indigo-600",
"text-blue-500",
"text-green-500",
"text-yellow-500",
"text-red-500",
];
return (
<div key={sector} className="mr-3 flex items-center">
<div
className={`w-2 h-2 rounded-full ${colors[index].replace("text", "bg")} mr-1`}
></div>
<span className={`${colors[index]} font-medium`}>
{sector} {widths[index]}%
</span>
</div>
);
})}
</div>
</div>
</div>
<div className="flex justify-end space-x-2">
<button className="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50 shadow-sm flex items-center">
<svg
className="w-4 h-4 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
clipRule="evenodd"
></path>
</svg>
Export Data
</button>
<button className="px-4 py-2 bg-indigo-600 border border-indigo-600 rounded-md text-sm font-medium text-white hover:bg-indigo-700 shadow-sm flex items-center">
<svg
className="w-4 h-4 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
></path>
</svg>
View Full Report
</button>
</div>
</div>
)}
</div>
</div>
);
}

View File

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

View File

@@ -1,217 +0,0 @@
import "./index.css";
import { useState, useMemo } from "react";
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
import { Price } from "../../../types";
import { format } from "date-fns";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
const chartConfig = {
price: {
label: "Price",
color: "hsl(var(--chart-1))",
},
} satisfies ChartConfig;
type DisplayRange = "1d" | "5d" | "1m";
function DisplayRangeSelector({
displayRange,
setDisplayRange,
}: {
displayRange: DisplayRange;
setDisplayRange: (range: DisplayRange) => void;
}) {
const sharedClass =
" bg-transparent text-gray-500 hover:bg-gray-50 transition-colors ease-in-out duration-200 p-2 cursor-pointer";
const selectedClass = `text-black bg-gray-100 hover:bg-gray-50`;
return (
<div className="flex flex-row items-center justify-start gap-2">
<Button
className={cn(sharedClass, displayRange === "1d" && selectedClass)}
variant={displayRange === "1d" ? "default" : "ghost"}
onClick={() => setDisplayRange("1d")}
>
1D
</Button>
<p className="text-gray-300">|</p>
<Button
className={cn(sharedClass, displayRange === "5d" && selectedClass)}
variant={displayRange === "5d" ? "default" : "ghost"}
onClick={() => setDisplayRange("5d")}
>
5D
</Button>
<p className="text-gray-300">|</p>
<Button
className={cn(sharedClass, displayRange === "1m" && selectedClass)}
variant={displayRange === "1m" ? "default" : "ghost"}
onClick={() => setDisplayRange("1m")}
>
1M
</Button>
</div>
);
}
function getPropsForDisplayRange(
displayRange: DisplayRange,
oneDayPrices: Price[],
thirtyDayPrices: Price[],
) {
const now = new Date();
const fiveDays = 5 * 24 * 60 * 60 * 1000; // 5 days in milliseconds
switch (displayRange) {
case "1d":
return oneDayPrices;
case "5d":
return thirtyDayPrices.filter(
(p) => new Date(p.time).getTime() >= now.getTime() - fiveDays,
);
case "1m":
return thirtyDayPrices;
default:
return [];
}
}
export default function StockPrice(props: {
ticker: string;
oneDayPrices: Price[];
thirtyDayPrices: Price[];
}) {
const { ticker } = props;
const { oneDayPrices, thirtyDayPrices } = props;
const [displayRange, setDisplayRange] = useState<DisplayRange>("1d");
const {
currentPrice,
openPrice,
dollarChange,
percentChange,
highPrice,
lowPrice,
chartData,
change,
} = useMemo(() => {
const prices = getPropsForDisplayRange(
displayRange,
oneDayPrices,
thirtyDayPrices,
);
const firstPrice = prices[0];
const lastPrice = prices[prices.length - 1];
const currentPrice = lastPrice?.close;
const openPrice = firstPrice?.open;
const dollarChange = currentPrice - openPrice;
const percentChange = ((currentPrice - openPrice) / openPrice) * 100;
const highPrice = prices.reduce(
(acc, p) => Math.max(acc, p.high),
-Infinity,
);
const lowPrice = prices.reduce((acc, p) => Math.min(acc, p.low), Infinity);
const chartData = prices.map((p) => ({
time: p.time,
price: p.close,
}));
const change: "up" | "down" = dollarChange > 0 ? "up" : "down";
return {
currentPrice,
openPrice,
dollarChange,
percentChange,
highPrice,
lowPrice,
chartData,
change,
};
}, [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 (
<div className="w-full max-w-3xl rounded-xl shadow-md overflow-hidden border border-gray-200 flex flex-col gap-4 p-3">
<div className="flex items-center justify-start gap-4 mb-2 text-lg font-medium text-gray-700">
<p>{ticker}</p>
<p>${currentPrice}</p>
</div>
<div className="flex flex-col gap-2">
<p className={change === "up" ? "text-green-500" : "text-red-500"}>
${dollarChange.toFixed(2)} (${percentChange.toFixed(2)}%)
</p>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="flex flex-col gap-2">
<p>Open</p>
<p>High</p>
<p>Low</p>
</div>
<div className="flex flex-col gap-2">
<p>${openPrice}</p>
<p>${highPrice}</p>
<p>${lowPrice}</p>
</div>
</div>
<DisplayRangeSelector
displayRange={displayRange}
setDisplayRange={setDisplayRange}
/>
<ChartContainer config={chartConfig}>
<LineChart
accessibilityLayer
data={chartData}
margin={{
left: 0,
right: 0,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="time"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(v) => formatDateByDisplayRange(v)}
/>
<YAxis
domain={[lowPrice - 2, highPrice + 2]}
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) => `${value.toFixed(2)}`}
/>
<ChartTooltip
cursor={false}
wrapperStyle={{ backgroundColor: "white" }}
content={
<ChartTooltipContent
hideLabel={false}
labelFormatter={(v) => formatDateByDisplayRange(v, true)}
/>
}
/>
<Line dataKey="price" type="natural" strokeWidth={2} dot={false} />
</LineChart>
</ChartContainer>
</div>
);
}

View File

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

View File

@@ -1,341 +0,0 @@
import "./index.css";
import { v4 as uuidv4 } from "uuid";
import {
useStreamContext,
type UIMessage,
} from "@langchain/langgraph-sdk/react-ui";
import { useEffect, useState } from "react";
import { X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { TripDetails } from "../../../trip-planner/types";
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "@/components/ui/carousel";
import { format } from "date-fns";
import { Accommodation } from "agent/types";
import { capitalizeSentence } from "../../../utils/capitalize";
import { Message } from "@langchain/langgraph-sdk";
import { getToolResponse } from "../../utils/get-tool-response";
import { DO_NOT_RENDER_ID_PREFIX } from "@/lib/ensure-tool-responses";
const StarSVG = ({ fill = "white" }: { fill?: string }) => (
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4.73158 0.80127L6.26121 3.40923L9.23158 4.04798L7.20658 6.29854L7.51273 9.30127L4.73158 8.08423L1.95043 9.30127L2.25658 6.29854L0.23158 4.04798L3.20195 3.40923L4.73158 0.80127Z"
fill={fill}
/>
</svg>
);
function AccommodationCard({
accommodation,
}: {
accommodation: Accommodation;
}) {
return (
<div
className="relative w-[161px] h-[256px] rounded-2xl shadow-md overflow-hidden"
style={{
backgroundImage: `url(${accommodation.image})`,
backgroundSize: "cover",
backgroundPosition: "center",
}}
>
<div className="absolute bottom-0 left-0 right-0 flex flex-col gap-1 p-3 text-white bg-gradient-to-t from-black/70 to-transparent">
<p className="text-sm font-semibold">{accommodation.name}</p>
<div className="flex items-center gap-1 text-xs">
<p className="flex items-center justify-center">
<StarSVG />
{accommodation.rating}
</p>
<p>·</p>
<p>{accommodation.price}</p>
</div>
<p className="text-sm">{capitalizeSentence(accommodation.city)}</p>
</div>
</div>
);
}
function SelectedAccommodation({
accommodation,
onHide,
tripDetails,
onBook,
}: {
accommodation: Accommodation;
onHide: () => void;
tripDetails: TripDetails;
onBook: (accommodation: Accommodation) => void;
}) {
const startDate = new Date(tripDetails.startDate);
const endDate = new Date(tripDetails.endDate);
const totalTripDurationDays = Math.max(
startDate.getDate() - endDate.getDate(),
1,
);
const totalPrice = totalTripDurationDays * accommodation.price;
return (
<div className="w-full flex gap-6 rounded-2xl overflow-hidden bg-white shadow-lg">
<div className="w-2/3 h-[400px]">
<img
src={accommodation.image}
alt={accommodation.name}
className="w-full h-full object-cover"
/>
</div>
<div className="w-1/3 p-4 flex flex-col">
<div className="flex justify-between items-center mb-4 gap-3">
<h3 className="text-xl font-semibold">{accommodation.name}</h3>
<Button
variant="ghost"
size="sm"
onClick={onHide}
className="cursor-pointer hover:bg-gray-50 transition-colors ease-in-out duration-200 text-gray-500 w-5 h-5"
>
<X className="w-3 h-3" />
</Button>
</div>
<div className="flex-1 space-y-4">
<div className="flex items-center justify-between gap-2">
<span className="flex items-center gap-1">
<StarSVG fill="black" />
{accommodation.rating}
</span>
<p className="text-gray-600">
{capitalizeSentence(accommodation.city)}
</p>
</div>
<div className="space-y-2 text-sm text-gray-600">
<div className="flex justify-between">
<span>Check-in</span>
<span>{format(startDate, "MMM d, yyyy")}</span>
</div>
<div className="flex justify-between">
<span>Check-out</span>
<span>{format(endDate, "MMM d, yyyy")}</span>
</div>
<div className="flex justify-between">
<span>Guests</span>
<span>{tripDetails.numberOfGuests}</span>
</div>
<div className="flex justify-between font-semibold text-black">
<span>Total Price</span>
<span>${totalPrice.toLocaleString()}</span>
</div>
</div>
</div>
<Button
onClick={() => onBook(accommodation)}
variant="secondary"
className="w-full bg-gray-800 text-white hover:bg-gray-900 cursor-pointer transition-colors ease-in-out duration-200"
>
Book
</Button>
</div>
</div>
);
}
function BookedAccommodation({
accommodation,
tripDetails,
}: {
accommodation: Accommodation;
tripDetails: TripDetails;
}) {
const startDate = new Date(tripDetails.startDate);
const endDate = new Date(tripDetails.endDate);
const totalTripDurationDays = Math.max(
startDate.getDate() - endDate.getDate(),
1,
);
const totalPrice = totalTripDurationDays * accommodation.price;
return (
<div
className="relative w-full h-[400px] rounded-2xl shadow-md overflow-hidden"
style={{
backgroundImage: `url(${accommodation.image})`,
backgroundSize: "cover",
backgroundPosition: "center",
}}
>
<div className="absolute bottom-0 left-0 right-0 flex flex-col gap-2 p-6 text-white bg-gradient-to-t from-black/90 via-black/70 to-transparent">
<p className="text-lg font-medium">Booked Accommodation</p>
<div className="flex justify-between items-baseline">
<h3 className="text-xl font-semibold"></h3>
</div>
<div className="grid grid-cols-2 gap-x-12 gap-y-2 text-sm">
<div className="flex justify-between">
<span>Address:</span>
</div>
<div className="flex justify-between">
<span>
{accommodation.name}, {capitalizeSentence(accommodation.city)}
</span>
</div>
<div className="flex justify-between">
<span>Rating:</span>
</div>
<div className="flex justify-between">
<span className="flex items-center gap-1">
<StarSVG />
{accommodation.rating}
</span>
</div>
<div className="flex justify-between">
<span>Dates:</span>
</div>
<div className="flex justify-between">
<span>
{format(startDate, "MMM d, yyyy")} -{" "}
{format(endDate, "MMM d, yyyy")}
</span>
</div>
<div className="flex justify-between">
<span>Guests:</span>
</div>
<div className="flex justify-between">
<span>{tripDetails.numberOfGuests}</span>
</div>
<div className="flex justify-between font-semibold">
<span>Total Price:</span>
</div>
<div className="flex justify-between font-semibold">
<span>${totalPrice.toLocaleString()}</span>
</div>
</div>
</div>
</div>
);
}
export default function AccommodationsList({
toolCallId,
tripDetails,
accommodations,
}: {
toolCallId: string;
tripDetails: TripDetails;
accommodations: Accommodation[];
}) {
const thread = useStreamContext<
{ messages: Message[]; ui: UIMessage[] },
{ MetaType: { ui: UIMessage | undefined } }
>();
const [selectedAccommodation, setSelectedAccommodation] = useState<
Accommodation | undefined
>();
const [accommodationBooked, setAccommodationBooked] = useState(false);
useEffect(() => {
if (typeof window === "undefined" || accommodationBooked) return;
const toolResponse = getToolResponse(toolCallId, thread);
if (toolResponse) {
setAccommodationBooked(true);
try {
const parsedContent: {
accommodation: Accommodation;
tripDetails: TripDetails;
} = JSON.parse(toolResponse.content as string);
setSelectedAccommodation(parsedContent.accommodation);
} catch {
console.error("Failed to parse tool response content.");
}
}
}, []);
function handleBookAccommodation(accommodation: Accommodation) {
const orderDetails = {
accommodation,
tripDetails,
};
thread.submit({
messages: [
{
type: "tool",
tool_call_id: toolCallId,
id: `${DO_NOT_RENDER_ID_PREFIX}${uuidv4()}`,
name: "book-accommodation",
content: JSON.stringify(orderDetails),
},
{
type: "human",
content: `Booked ${accommodation.name} for ${tripDetails.numberOfGuests}.`,
},
],
});
setAccommodationBooked(true);
if (selectedAccommodation?.id !== accommodation.id) {
setSelectedAccommodation(accommodation);
}
}
if (accommodationBooked && selectedAccommodation) {
return (
<BookedAccommodation
tripDetails={tripDetails}
accommodation={selectedAccommodation}
/>
);
} else if (accommodationBooked) {
return <div>Successfully booked accommodation!</div>;
}
if (selectedAccommodation) {
return (
<SelectedAccommodation
tripDetails={tripDetails}
onHide={() => setSelectedAccommodation(undefined)}
accommodation={selectedAccommodation}
onBook={handleBookAccommodation}
/>
);
}
return (
<div className="space-y-8">
<Carousel
opts={{
align: "start",
loop: true,
}}
className="w-full sm:max-w-sm md:max-w-3xl lg:max-w-3xl"
>
<CarouselContent>
{accommodations.map((accommodation) => (
<CarouselItem
key={accommodation.id}
className="basis-1/2 md:basis-1/4"
onClick={() => setSelectedAccommodation(accommodation)}
>
<AccommodationCard accommodation={accommodation} />
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>
</div>
);
}

View File

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

View File

@@ -1,250 +0,0 @@
import "./index.css";
import { TripDetails } from "../../../trip-planner/types";
import { useState } from "react";
export default function RestaurantsList({
tripDetails,
}: {
tripDetails: TripDetails;
}) {
// Placeholder data - ideally would come from props
const [restaurants] = useState([
{
id: "1",
name: "The Local Grill",
cuisine: "Steakhouse",
priceRange: "$$",
rating: 4.7,
distance: "0.5 miles from center",
image: "https://placehold.co/300x200?text=Restaurant1",
openingHours: "5:00 PM - 10:00 PM",
popular: true,
},
{
id: "2",
name: "Ocean Breeze",
cuisine: "Seafood",
priceRange: "$$$",
rating: 4.9,
distance: "0.8 miles from center",
image: "https://placehold.co/300x200?text=Restaurant2",
openingHours: "12:00 PM - 11:00 PM",
popular: true,
},
{
id: "3",
name: "Pasta Paradise",
cuisine: "Italian",
priceRange: "$$",
rating: 4.5,
distance: "1.2 miles from center",
image: "https://placehold.co/300x200?text=Restaurant3",
openingHours: "11:30 AM - 9:30 PM",
popular: false,
},
{
id: "4",
name: "Spice Garden",
cuisine: "Indian",
priceRange: "$$",
rating: 4.6,
distance: "0.7 miles from center",
image: "https://placehold.co/300x200?text=Restaurant4",
openingHours: "12:00 PM - 10:00 PM",
popular: false,
},
]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [filter, setFilter] = useState<string | null>(null);
const selectedRestaurant = restaurants.find((r) => r.id === selectedId);
const filteredRestaurants = filter
? restaurants.filter((r) => r.cuisine === filter)
: restaurants;
const cuisines = Array.from(new Set(restaurants.map((r) => r.cuisine)));
return (
<div className="w-full max-w-md bg-white rounded-lg shadow-md overflow-hidden">
<div className="bg-orange-600 px-4 py-3">
<div className="flex justify-between items-center">
<h3 className="text-white font-medium">
Restaurants in {tripDetails.location}
</h3>
{selectedId && (
<button
onClick={() => setSelectedId(null)}
className="text-white text-sm bg-orange-700 hover:bg-orange-800 px-2 py-1 rounded"
>
Back to list
</button>
)}
</div>
<p className="text-orange-100 text-xs">
For your trip {new Date(tripDetails.startDate).toLocaleDateString()} -{" "}
{new Date(tripDetails.endDate).toLocaleDateString()}
</p>
</div>
{!selectedId ? (
<div className="p-4">
<div className="mb-3">
<div className="flex flex-wrap gap-1 mb-1">
<button
onClick={() => setFilter(null)}
className={`px-2 py-1 text-xs rounded-full ${
filter === null
? "bg-orange-600 text-white"
: "bg-gray-100 text-gray-800 hover:bg-gray-200"
}`}
>
All
</button>
{cuisines.map((cuisine) => (
<button
key={cuisine}
onClick={() => setFilter(cuisine)}
className={`px-2 py-1 text-xs rounded-full ${
filter === cuisine
? "bg-orange-600 text-white"
: "bg-gray-100 text-gray-800 hover:bg-gray-200"
}`}
>
{cuisine}
</button>
))}
</div>
<p className="text-xs text-gray-500">
Showing {filteredRestaurants.length} restaurants{" "}
{filter ? `in ${filter}` : ""}
</p>
</div>
<div className="space-y-3">
{filteredRestaurants.map((restaurant) => (
<div
key={restaurant.id}
onClick={() => setSelectedId(restaurant.id)}
className="border rounded-lg p-3 cursor-pointer hover:border-orange-300 hover:shadow-md transition-all"
>
<div className="flex">
<div className="w-20 h-20 bg-gray-200 rounded-md flex-shrink-0 overflow-hidden">
<img
src={restaurant.image}
alt={restaurant.name}
className="w-full h-full object-cover"
/>
</div>
<div className="ml-3 flex-1">
<div className="flex justify-between items-start">
<div>
<h4 className="font-medium text-gray-900">
{restaurant.name}
</h4>
<p className="text-sm text-gray-500">
{restaurant.cuisine}
</p>
</div>
<span className="text-sm text-gray-700">
{restaurant.priceRange}
</span>
</div>
<div className="flex items-center mt-1">
<svg
className="w-4 h-4 text-yellow-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path>
</svg>
<span className="text-xs text-gray-500 ml-1">
{restaurant.rating}
</span>
</div>
<div className="flex justify-between items-center mt-1">
<span className="text-xs text-gray-500">
{restaurant.distance}
</span>
{restaurant.popular && (
<span className="text-xs bg-orange-100 text-orange-800 px-1.5 py-0.5 rounded-sm">
Popular
</span>
)}
</div>
</div>
</div>
</div>
))}
</div>
</div>
) : (
<div className="p-4">
{selectedRestaurant && (
<div className="space-y-4">
<div className="w-full h-40 bg-gray-200 rounded-lg overflow-hidden">
<img
src={selectedRestaurant.image}
alt={selectedRestaurant.name}
className="w-full h-full object-cover"
/>
</div>
<div className="space-y-2">
<div className="flex justify-between items-start">
<div>
<h3 className="font-medium text-lg text-gray-900">
{selectedRestaurant.name}
</h3>
<p className="text-sm text-gray-600">
{selectedRestaurant.cuisine}
</p>
</div>
<span className="text-gray-700 font-medium">
{selectedRestaurant.priceRange}
</span>
</div>
<div className="flex items-center">
<svg
className="w-4 h-4 text-yellow-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"></path>
</svg>
<span className="text-sm text-gray-600 ml-1">
{selectedRestaurant.rating} rating
</span>
</div>
<div className="flex items-center text-sm text-gray-600 space-x-4">
<span>{selectedRestaurant.distance}</span>
<span></span>
<span>{selectedRestaurant.openingHours}</span>
</div>
<p className="text-sm text-gray-600 pt-2 border-t">
{selectedRestaurant.name} offers a wonderful dining experience
in {tripDetails.location}. Perfect for a group of{" "}
{tripDetails.numberOfGuests} guests. Enjoy authentic{" "}
{selectedRestaurant.cuisine} cuisine in a relaxed atmosphere.
</p>
<div className="pt-3 flex flex-col space-y-2">
<button className="w-full bg-orange-600 hover:bg-orange-700 text-white font-medium py-2 px-4 rounded-md transition-colors">
Reserve a Table
</button>
<button className="w-full bg-white border border-gray-300 text-gray-700 font-medium py-2 px-4 rounded-md hover:bg-gray-50 transition-colors">
View Menu
</button>
</div>
</div>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -1,23 +0,0 @@
import {
useStreamContext,
type UIMessage,
} from "@langchain/langgraph-sdk/react-ui";
import { Message, ToolMessage } from "@langchain/langgraph-sdk";
type StreamContextType = ReturnType<
typeof useStreamContext<
{ messages: Message[]; ui: UIMessage[] },
{ MetaType: { ui: UIMessage | undefined } }
>
>;
export function getToolResponse(
toolCallId: string,
thread: StreamContextType,
): ToolMessage | undefined {
const toolResponse = thread.messages.findLast(
(message): message is ToolMessage =>
message.type === "tool" && message.tool_call_id === toolCallId,
);
return toolResponse;
}

View File

@@ -1,18 +0,0 @@
/**
* Capitalizes the first letter of each word in a string.
*/
export function capitalizeSentence(string: string): string {
return string
.split(" ")
.map((word) => {
return word.charAt(0).toUpperCase() + word.slice(1);
})
.join(" ");
}
/**
* Capitalizes the first letter of a string.
*/
export function capitalize(string: string): string {
return string.charAt(0).toUpperCase() + string.slice(1);
}

View File

@@ -1,12 +0,0 @@
import { BaseMessage } from "@langchain/core/messages";
export function formatMessages(messages: BaseMessage[]): string {
return messages
.map((m, i) => {
const role = m.getType();
const contentString =
typeof m.content === "string" ? m.content : JSON.stringify(m.content);
return `<${role} index="${i}">\n${contentString}\n</${role}>`;
})
.join("\n");
}

View File

@@ -1,11 +0,0 @@
{
"node_version": "20",
"graphs": {
"agent": "./agent/agent.ts:graph"
},
"ui": {
"agent": "./agent/uis/index.tsx"
},
"env": ".env",
"dependencies": ["."]
}

View File

@@ -1,10 +1,11 @@
{
"name": "agent-ui-client",
"name": "chat-langgraph",
"homepage": "https://github.com/langchain-ai/chat-langgraph/blob/main/README.md",
"repository": "https://github.com/langchain-ai/chat-langgraph",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"agent": "langgraphjs dev --no-browser",
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
@@ -15,15 +16,11 @@
"@assistant-ui/react": "^0.8.0",
"@assistant-ui/react-markdown": "^0.8.0",
"@assistant-ui/react-syntax-highlighter": "^0.7.2",
"@faker-js/faker": "^9.5.1",
"@langchain/anthropic": "^0.3.15",
"@langchain/core": "^0.3.41",
"@langchain/google-genai": "^0.1.10",
"@langchain/langgraph": "^0.2.49",
"@langchain/langgraph-api": "^0.0.14",
"@langchain/langgraph-cli": "^0.0.14",
"@langchain/langgraph-sdk": "^0.0.52",
"@langchain/openai": "^0.4.4",
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-label": "^2.1.2",
@@ -34,7 +31,6 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.5.2",
"esbuild": "^0.25.0",
"esbuild-plugin-tailwindcss": "^2.0.1",
"framer-motion": "^12.4.9",

View File

@@ -1,239 +0,0 @@
import * as React from "react";
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: "horizontal" | "vertical";
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />");
}
return context;
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins,
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return;
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
scrollPrev();
} else if (event.key === "ArrowRight") {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext],
);
React.useEffect(() => {
if (!api || !setApi) return;
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) return;
onSelect(api);
api.on("reInit", onSelect);
api.on("select", onSelect);
return () => {
api?.off("select", onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel();
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className,
)}
{...props}
/>
</div>
);
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel();
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className,
)}
{...props}
/>
);
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft />
<span className="sr-only">Previous slide</span>
</Button>
);
}
function CarouselNext({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight />
<span className="sr-only">Next slide</span>
</Button>
);
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
};

View File

@@ -1,353 +0,0 @@
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { cn } from "@/lib/utils";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"];
}) {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color,
);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`,
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
}) {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
}
if (!value) {
return null;
}
return (
<div className={cn("font-medium bg-white", labelClassName)}>{value}</div>
);
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
className={cn(
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center",
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
},
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center",
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
const ChartLegend = RechartsPrimitive.Legend;
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}) {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className,
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3",
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string,
) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
};