fix: Remove agent
This commit is contained in:
3
agent/.gitignore
vendored
3
agent/.gitignore
vendored
@@ -1,3 +0,0 @@
|
||||
# LangGraph API
|
||||
.langgraph_api
|
||||
dist
|
||||
136
agent/agent.ts
136
agent/agent.ts
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
```bash
|
||||
npx create-react-app todo-app --template typescript
|
||||
cd todo-app
|
||||
mkdir -p src/{components,styles,utils}
|
||||
```
|
||||
@@ -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>
|
||||
);
|
||||
```
|
||||
@@ -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>;
|
||||
};
|
||||
```
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
```
|
||||
@@ -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>
|
||||
);
|
||||
```
|
||||
@@ -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) : [];
|
||||
};
|
||||
```
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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 {};
|
||||
}
|
||||
@@ -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],
|
||||
};
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
@@ -1 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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't ask again
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"node_version": "20",
|
||||
"graphs": {
|
||||
"agent": "./agent/agent.ts:graph"
|
||||
},
|
||||
"ui": {
|
||||
"agent": "./agent/uis/index.tsx"
|
||||
},
|
||||
"env": ".env",
|
||||
"dependencies": ["."]
|
||||
}
|
||||
10
package.json
10
package.json
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user