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,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"agent": "langgraphjs dev --no-browser",
|
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
@@ -15,15 +16,11 @@
|
|||||||
"@assistant-ui/react": "^0.8.0",
|
"@assistant-ui/react": "^0.8.0",
|
||||||
"@assistant-ui/react-markdown": "^0.8.0",
|
"@assistant-ui/react-markdown": "^0.8.0",
|
||||||
"@assistant-ui/react-syntax-highlighter": "^0.7.2",
|
"@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/core": "^0.3.41",
|
||||||
"@langchain/google-genai": "^0.1.10",
|
|
||||||
"@langchain/langgraph": "^0.2.49",
|
"@langchain/langgraph": "^0.2.49",
|
||||||
"@langchain/langgraph-api": "^0.0.14",
|
"@langchain/langgraph-api": "^0.0.14",
|
||||||
"@langchain/langgraph-cli": "^0.0.14",
|
"@langchain/langgraph-cli": "^0.0.14",
|
||||||
"@langchain/langgraph-sdk": "^0.0.52",
|
"@langchain/langgraph-sdk": "^0.0.52",
|
||||||
"@langchain/openai": "^0.4.4",
|
|
||||||
"@radix-ui/react-avatar": "^1.1.3",
|
"@radix-ui/react-avatar": "^1.1.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.6",
|
"@radix-ui/react-dialog": "^1.1.6",
|
||||||
"@radix-ui/react-label": "^2.1.2",
|
"@radix-ui/react-label": "^2.1.2",
|
||||||
@@ -34,7 +31,6 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"embla-carousel-react": "^8.5.2",
|
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"esbuild-plugin-tailwindcss": "^2.0.1",
|
"esbuild-plugin-tailwindcss": "^2.0.1",
|
||||||
"framer-motion": "^12.4.9",
|
"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