feat: add langgraph/ — LangGraph.js gen-ui server with CI/CD to Azure Container App
## Azure Resources Created - ACR: socsocacr.azurecr.io (Operation, southeastasia) - Container App Environment: soc-cae (Operation, southeastasia) - Container App: soc-langgraph (Operation, southeastasia, port 2024) - Scale: 0-2 replicas, 0.5 vCPU / 1GB - Registry: socsocacr.azurecr.io (system-assigned managed identity) - OIDC: oidc-msi-8ac6 granted Contributor on Operation resource group ## What's included - Full langgraphjs-gen-ui-examples codebase under langgraph/ - Dockerfile: node:20-slim + pnpm, exposes port 2024 - .dockerignore, .gitignore - GitHub Actions: .github/workflows/deploy-langgraph.yml - Trigger: push to main, paths langgraph/** - Uses az acr build (cloud build, no local Docker needed) - Deploys to soc-langgraph Container App Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
c6ca9dc126
commit
ac388b3c07
@@ -0,0 +1,44 @@
|
|||||||
|
name: Deploy LangGraph Server to Azure Container App
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- "langgraph/**"
|
||||||
|
- ".github/workflows/deploy-langgraph.yml"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Login to Azure
|
||||||
|
uses: azure/login@v2
|
||||||
|
with:
|
||||||
|
client-id: ${{ secrets.AZUREAPPSERVICE_CLIENTID_E361D63054AD449285A5D65AA4425533 }}
|
||||||
|
tenant-id: ${{ secrets.AZUREAPPSERVICE_TENANTID_62D66D4E65AC4A6C872064AD668AC691 }}
|
||||||
|
subscription-id: ${{ secrets.AZUREAPPSERVICE_SUBSCRIPTIONID_5D7B0564A55F4209A91149F2642D3F69 }}
|
||||||
|
|
||||||
|
- name: Build and push image to ACR (cloud build)
|
||||||
|
run: |
|
||||||
|
az acr build \
|
||||||
|
--registry socsocacr \
|
||||||
|
--resource-group Operation \
|
||||||
|
--image soc-langgraph:${{ github.sha }} \
|
||||||
|
--image soc-langgraph:latest \
|
||||||
|
langgraph/
|
||||||
|
|
||||||
|
- name: Deploy to Container App
|
||||||
|
run: |
|
||||||
|
az containerapp update \
|
||||||
|
--name soc-langgraph \
|
||||||
|
--resource-group Operation \
|
||||||
|
--image socsocacr.azurecr.io/soc-langgraph:${{ github.sha }}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
*.local
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
*.local
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
FROM node:20-slim
|
||||||
|
|
||||||
|
# Install pnpm
|
||||||
|
RUN npm install -g pnpm@10
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Expose LangGraph server port
|
||||||
|
EXPOSE 2024
|
||||||
|
|
||||||
|
# Start LangGraph server in production mode
|
||||||
|
CMD ["pnpm", "exec", "langgraphjs", "dev", "--host", "0.0.0.0", "--no-browser"]
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 Brace Sproul
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
# LangGraph Generative UI Examples
|
||||||
|
|
||||||
|
This repository contains a series of agents intended to be used with the [Agent Chat UI](https://agentchat.vercel.app) ([repo](https://github.com/langchain-ai/agent-chat-ui)).
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
# Setup
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> Want to watch a video walkthrough instead? Click [here](https://youtu.be/sCqN01R8nIQ).
|
||||||
|
|
||||||
|
First, clone this repository:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/langchain-ai/langgraphjs-gen-ui-examples.git
|
||||||
|
|
||||||
|
cd langgraphjs-gen-ui-examples
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# pnpm is the default package manager in this project
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
Next, copy the `.env.example` file, and set the necessary environment variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Only [OpenAI](https://platform.openai.com/), and [Google GenAI](https://aistudio.google.com/), API keys are required ([Financial Datasets](https://www.financialdatasets.ai/) is only required if you want to call the stockbroker graph, and [Anthropic](https://console.anthropic.com) is only used in the pizza ordering agent).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Required
|
||||||
|
OPENAI_API_KEY=""
|
||||||
|
GOOGLE_API_KEY=""
|
||||||
|
|
||||||
|
# Optional, but recommended for best in class tracing and observability.
|
||||||
|
# LANGSMITH_PROJECT="default"
|
||||||
|
# LANGSMITH_API_KEY=""
|
||||||
|
# LANGSMITH_TRACING_V2=true
|
||||||
|
|
||||||
|
# Optional
|
||||||
|
# ANTHROPIC_API_KEY=""
|
||||||
|
# FINANCIAL_DATASETS_API_KEY=""
|
||||||
|
```
|
||||||
|
|
||||||
|
Start the LangGraph server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm agent
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see output similar to:
|
||||||
|
|
||||||
|
```
|
||||||
|
Welcome to
|
||||||
|
|
||||||
|
LangGraph.js
|
||||||
|
|
||||||
|
- API: http://localhost:2024
|
||||||
|
- Studio UI: https://smith.langchain.com/studio?baseUrl=http://localhost:2024
|
||||||
|
```
|
||||||
|
|
||||||
|
# Example usage
|
||||||
|
|
||||||
|
The following are some prompts, and corresponding graph IDs you can use to test the agents:
|
||||||
|
|
||||||
|
- Graph ID: `agent`:
|
||||||
|
- `What can you do?` - Will list all of the tools/actions it has available
|
||||||
|
- `Show me places to stay in <insert location here>` - Will trigger a generative UI travel agent which renders a UI to select accommodations.
|
||||||
|
- `Recommend some restaurants for me in <insert location here>` - Will trigger a generative UI travel agent which renders a UI to select restaurants.
|
||||||
|
- `What's the current price of <insert company/stock ticker here>` - Will trigger a generative UI stockbroker agent which renders the current price of the stock.
|
||||||
|
- `I want to buy <insert quantity here> shares of <insert company/stock ticker here>.` - Will trigger a generative UI stockbroker agent which renders a UI to buy a stock at its current price.
|
||||||
|
- `Show me my portfolio` - Will trigger a generative UI stockbroker agent which renders a UI to show the user's portfolio.
|
||||||
|
- `Write a React TODO app for me` - Will trigger the `Open Code` agent, which is a dummy re-implementation of Anthropic's Claude Code CLI. This agent is solely used to demonstrate different UI components you can render with LangGraph, and will not actually generate new code. The planning steps & generated code are all static values.
|
||||||
|
- `Order me a pizza <include optional topping instructions> in <include location here>` - Used to demonstrate how tool calls/results are rendered.
|
||||||
|
- Graph ID: `chat`:
|
||||||
|
- This is a plain chat agent, which simply passes the conversation to an LLM and generates a text response. This does not have access to any tools, or generative UI components.
|
||||||
|
- Graph ID: `email_agent`:
|
||||||
|
- `Write me an email to <insert email here> about <insert email description here>` - Will generate an email for you, addressed to the email address you specified. Used to demonstrate how you can trigger the built in Human in the Loop (HITL) UI in the Agent Chat UI. This agent will throw an `interrupt`, with the standard [`HumanInterrupt`](https://github.com/langchain-ai/langgraph/blob/84c956bc8c3b2643819677bea962425e02e15ba4/libs/prebuilt/langgraph/prebuilt/interrupt.py#L42) schema, which the Agent Chat UI is able to automatically detect, and render a HITL UI component to manage the interrupt.
|
||||||
|
|
||||||
|
# Agents
|
||||||
|
|
||||||
|
Key
|
||||||
|
|
||||||
|
- [Supervisor](#supervisor)
|
||||||
|
- [Stockbroker](#stockbroker)
|
||||||
|
- [Trip Planner](#trip-planner)
|
||||||
|
- [Open Code](#open-code)
|
||||||
|
- [Order Pizza](#order-pizza)
|
||||||
|
- [Chat Agent](#chat-agent)
|
||||||
|
- [Email Agent](#email-agent)
|
||||||
|
|
||||||
|
## Supervisor
|
||||||
|
|
||||||
|
This is the default agent, which has access to a series of subgraphs it can call, depending on the context of the conversation. This includes the following agents:
|
||||||
|
|
||||||
|
- [Stockbroker](#stockbroker)
|
||||||
|
- [Trip Planner](#trip-planner)
|
||||||
|
- [Open Code](#open-code)
|
||||||
|
- [Order Pizza](#order-pizza)
|
||||||
|
|
||||||
|
This agent works by taking in the input, and passing it, along with the rest of the chat history to a `router` node. This node passes the entire chat history to Gemini 2.0 Flash, and forces it to call a tool, with the route to take based on the conversation.
|
||||||
|
|
||||||
|
If the context does not have a clear subgraph which should be called, it routes to the `General Input` node, which contains a single LLM call used to respond to the user's input.
|
||||||
|
|
||||||
|
### Stockbroker
|
||||||
|
|
||||||
|
The stockbroker agent has a series of tools available to it which will render generative UI components in the Agent Chat UI. It should be accessed via the `agent` graph ID, which means you'll need to go through the Supervisor agent to access it. The following are the prompts you can use to test the stockbroker agent:
|
||||||
|
|
||||||
|
- `What's the current price of <insert company/stock ticker here>` - Will trigger a generative UI stockbroker agent which renders the current price of the stock.
|
||||||
|
- `I want to buy <insert quantity here> shares of <insert company/stock ticker here>.` - Will trigger a generative UI stockbroker agent which renders a UI to buy a stock at its current price.
|
||||||
|
- `Show me my portfolio` - Will trigger a generative UI stockbroker agent which renders a UI to show the user's portfolio.
|
||||||
|
|
||||||
|
### Trip Planner
|
||||||
|
|
||||||
|
The trip planner agent has tools available to it which can render generative UI components for planning/booking trips. It should be accessed via the `agent` graph ID, which means you'll need to go through the Supervisor agent to access it. The following prompts will trigger the trip planner agent:
|
||||||
|
|
||||||
|
- `Show me places to stay in <insert location here>` - Will trigger a generative UI travel agent which renders a UI to select accommodations.
|
||||||
|
- `Recommend some restaurants for me in <insert location here>` - Will trigger a generative UI travel agent which renders a UI to select restaurants.
|
||||||
|
|
||||||
|
The agent will first extract the following information from your input, if present:
|
||||||
|
|
||||||
|
- `location` - Required field. This can be the city, state, or some other location for the trip.
|
||||||
|
- `startDate` - Optional field. The start date of the trip. Defaults to 4 weeks from now.
|
||||||
|
- `endDate` - Optional field. The end date of the trip. Defaults to 5 weeks from now.
|
||||||
|
- `numberOfGuests` - Optional field. The number of guests attending the trip. Defaults to 2.
|
||||||
|
|
||||||
|
The only field, `location`, is required, and the rest are optional.
|
||||||
|
|
||||||
|
### Open Code
|
||||||
|
|
||||||
|
This is a dummy code writing agent, used to demonstrate how you can implement generative UI components in agents. It should be accessed via the `agent` graph ID, which means you'll need to go through the Supervisor agent to access it. It is triggered by requesting the agent to write a React TODO app, like this:
|
||||||
|
|
||||||
|
- `Write a React TODO app for me`
|
||||||
|
|
||||||
|
This will then render a plan (these steps are static, and will always be the same). After that, it'll "generate" code (each plan item has a corresponding "generated code output") for each item in the plan. It only does this one at a time, and will not suggest the next part of generated code until after the previous suggestion has been accepted, rejected, or accepted for all future requests in this session. If you select that button, it will resume the graph, and continue through the rest of the steps, and suggest code without pausing to wait for your approval.
|
||||||
|
|
||||||
|
### Order Pizza
|
||||||
|
|
||||||
|
The order pizza agent is used to demonstrate how tool calls/results are rendered in the UI. It should be accessed via the `agent` graph ID, which means you'll need to go through the Supervisor agent to access it. You can trigger it via the following query:
|
||||||
|
|
||||||
|
- `Order me a pizza <include optional topping instructions> in <include location here>`
|
||||||
|
|
||||||
|
It will then call two tools, once to extract the fields from your input for the pizza order (order details, and location). After that, it calls the tool to "order" the pizza. Each of these tool calls will have corresponding tool call/result UI components rendered in the Agent Chat UI. These are the default UI components rendered when your graph calls a tool/returns a tool result.
|
||||||
|
|
||||||
|
## Chat Agent
|
||||||
|
|
||||||
|
The chat agent is a single LLM call, used to demonstrate the plain back and forth of a chat agent. It should be accessed via the `chat` graph ID. It does not have access to any tools, or generative UI components.
|
||||||
|
|
||||||
|
## Email Agent
|
||||||
|
|
||||||
|
The email agent is a dummy implementation of how you'd implement an email assistant with the Agent Chat UI. It is accessed via the `email_agent` graph ID. You can trigger it via the following query:
|
||||||
|
|
||||||
|
- `Write me an email to <insert email here> about <insert email description here>`
|
||||||
|
|
||||||
|
This will then call the graph which extracts fields from your input (or responds with a request for more information). Once it's extracted all of the required information it will interrupt, passing the standardized [`HumanInterrupt`](https://github.com/langchain-ai/langgraph/blob/84c956bc8c3b2643819677bea962425e02e15ba4/libs/prebuilt/langgraph/prebuilt/interrupt.py#L42) schema. The Agent Chat UI is able to detect when interrupts with this schema are thrown, and when it finds one it renders a UI component to handle actions by the user which are used to resume the graph.
|
||||||
|
|
||||||
|
The allowed actions are:
|
||||||
|
|
||||||
|
- `Accept` - If you accept the email as is, without making changes to any fields, it will "send" the email (emails aren't actually sent, just a message is displayed indicating the email was sent).
|
||||||
|
- `Edit` - If you edit any of the email fields and submit, it will "send" the email with the new values.
|
||||||
|
- `Respond` - If you send a text response back, it will be used to rewrite the email in some way, then interrupt again and wait for you to take an action.
|
||||||
|
- `Ignore` - This will send back an `ignore` response, and the graph will end without taking any actions.
|
||||||
|
- `Mark as resolved` - If you select this, it will resume the graph, but starting at the `__end__` node, causing the graph to end without taking any actions.
|
||||||
|
|
||||||
|
## Writer Agent
|
||||||
|
|
||||||
|
This is a dummy agent used to demonstrate how you can stream generative UI components as an artifact. It should be accessed via the `writer` graph ID. It should be accessed via the `agent` graph ID, which means you'll need to go through the Supervisor agent to access it. The following prompts will trigger the writer agent:
|
||||||
|
|
||||||
|
- `Write me a short story about a <insert topic here>`
|
||||||
|
|
||||||
|
This will render a generative UI component that contains the title and content of your short story. The generative UI component will be rendered in a side panel to the right of the chat and the contents of the story will be streamed to the UI as it is generated.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.js",
|
||||||
|
"css": "src/index.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide"
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import js from "@eslint/js";
|
||||||
|
import globals from "globals";
|
||||||
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
|
import reactRefresh from "eslint-plugin-react-refresh";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ["dist"] },
|
||||||
|
{
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
|
files: ["**/*.{ts,tsx}"],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
"react-hooks": reactHooks,
|
||||||
|
"react-refresh": reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
"@typescript-eslint/no-explicit-any": 0,
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"warn",
|
||||||
|
{ args: "none", argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||||
|
],
|
||||||
|
"react-refresh/only-export-components": [
|
||||||
|
"warn",
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Chat LangGraph</title>
|
||||||
|
<link href="/src/index.css" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"node_version": "20",
|
||||||
|
"graphs": {
|
||||||
|
"agent": "./src/agent/supervisor/index.ts:graph",
|
||||||
|
"email_agent": "./src/agent/email-agent/index.ts:agent",
|
||||||
|
"chat": "./src/agent/chat-agent/index.ts:agent"
|
||||||
|
},
|
||||||
|
"ui": {
|
||||||
|
"agent": "./src/agent-uis/index.tsx"
|
||||||
|
},
|
||||||
|
"env": ".env",
|
||||||
|
"dependencies": ["."]
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
{
|
||||||
|
"name": "langgraphjs-gen-ui-examples",
|
||||||
|
"homepage": "https://github.com/langchain-ai/langgraphjs-gen-ui-examples/blob/main/README.md",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/langchain-ai/langgraphjs-gen-ui-examples.git"
|
||||||
|
},
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"agent": "langgraphjs dev --no-browser",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"format:check": "prettier --check ."
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@assistant-ui/react": "^0.8.0",
|
||||||
|
"@assistant-ui/react-markdown": "^0.8.0",
|
||||||
|
"@assistant-ui/react-syntax-highlighter": "^0.7.2",
|
||||||
|
"@faker-js/faker": "^9.5.1",
|
||||||
|
"@langchain/anthropic": "^0.3.18",
|
||||||
|
"@langchain/core": "^0.3.45",
|
||||||
|
"@langchain/google-genai": "^0.1.10",
|
||||||
|
"@langchain/langgraph": "^0.2.64",
|
||||||
|
"@langchain/langgraph-checkpoint": "^0.0.17",
|
||||||
|
"@langchain/langgraph-cli": "^0.0.30",
|
||||||
|
"@langchain/langgraph-sdk": "^0.0.73",
|
||||||
|
"@langchain/openai": "^0.5.5",
|
||||||
|
"@radix-ui/react-avatar": "^1.1.3",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.6",
|
||||||
|
"@radix-ui/react-label": "^2.1.2",
|
||||||
|
"@radix-ui/react-slot": "^1.1.2",
|
||||||
|
"@radix-ui/react-tooltip": "^1.1.8",
|
||||||
|
"@tailwindcss/postcss": "^4.0.9",
|
||||||
|
"@tailwindcss/vite": "^4.0.9",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"embla-carousel-react": "^8.5.2",
|
||||||
|
"esbuild": "^0.25.0",
|
||||||
|
"esbuild-plugin-tailwindcss": "^2.0.1",
|
||||||
|
"framer-motion": "^12.4.9",
|
||||||
|
"katex": "^0.16.21",
|
||||||
|
"lucide-react": "^0.476.0",
|
||||||
|
"next-themes": "^0.4.4",
|
||||||
|
"prettier": "^3.5.2",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-markdown": "^10.0.1",
|
||||||
|
"react-router-dom": "^6.17.0",
|
||||||
|
"react-syntax-highlighter": "^15.5.0",
|
||||||
|
"recharts": "^2.15.1",
|
||||||
|
"rehype-katex": "^7.0.1",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
|
"remark-math": "^6.0.0",
|
||||||
|
"sonner": "^2.0.1",
|
||||||
|
"tailwind-merge": "^3.0.2",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"use-query-params": "^2.2.1",
|
||||||
|
"use-stick-to-bottom": "^1.0.46",
|
||||||
|
"uuid": "^11.0.5",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.19.0",
|
||||||
|
"@types/node": "^22.13.5",
|
||||||
|
"@types/react": "^19.0.8",
|
||||||
|
"@types/react-dom": "^19.0.3",
|
||||||
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
|
"eslint": "^9.19.0",
|
||||||
|
"eslint-plugin-react-hooks": "^5.0.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.18",
|
||||||
|
"globals": "^15.14.0",
|
||||||
|
"tailwind-scrollbar": "^4.0.1",
|
||||||
|
"tailwindcss": "^4.0.6",
|
||||||
|
"typescript": "~5.7.2",
|
||||||
|
"typescript-eslint": "^8.22.0",
|
||||||
|
"vite": "^6.1.0"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"react-is": "^19.0.0-rc-69d4b800-20241021"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@10.5.1+sha512.c424c076bd25c1a5b188c37bb1ca56cc1e136fbf530d98bcb3289982a08fd25527b8c9c4ec113be5e3393c39af04521dd647bcf1d0801eaf8ac6a7b14da313af"
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
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";
|
||||||
|
import { Writer } from "./writer";
|
||||||
|
|
||||||
|
const ComponentMap = {
|
||||||
|
"stock-price": StockPrice,
|
||||||
|
portfolio: PortfolioView,
|
||||||
|
"accommodations-list": AccommodationsList,
|
||||||
|
"restaurants-list": RestaurantsList,
|
||||||
|
"buy-stock": BuyStock,
|
||||||
|
"code-plan": Plan,
|
||||||
|
"proposed-change": ProposedChange,
|
||||||
|
writer: Writer,
|
||||||
|
} as const;
|
||||||
|
export default ComponentMap;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
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)}
|
||||||
|
>
|
||||||
|
<motion.span
|
||||||
|
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.span>
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.145 0 0);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.145 0 0);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
|
--primary: oklch(0.205 0 0);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.97 0 0);
|
||||||
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
|
--muted: oklch(0.97 0 0);
|
||||||
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
|
--accent: oklch(0.97 0 0);
|
||||||
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--destructive-foreground: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.922 0 0);
|
||||||
|
--input: oklch(0.922 0 0);
|
||||||
|
--ring: oklch(0.708 0 0);
|
||||||
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.145 0 0);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.145 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.145 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.985 0 0);
|
||||||
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
|
--secondary: oklch(0.269 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.269 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: oklch(0.269 0 0);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.396 0.141 25.723);
|
||||||
|
--destructive-foreground: oklch(0.637 0.237 25.331);
|
||||||
|
--border: oklch(0.269 0 0);
|
||||||
|
--input: oklch(0.269 0 0);
|
||||||
|
--ring: oklch(0.439 0 0);
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
--sidebar: oklch(0.205 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(0.269 0 0);
|
||||||
|
--sidebar-ring: oklch(0.439 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
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 { useEffect, useState } from "react";
|
||||||
|
import { getToolResponse } from "../../utils/get-tool-response";
|
||||||
|
import { useArtifact } from "../../utils/use-artifact";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { DO_NOT_RENDER_ID_PREFIX } from "@/constants";
|
||||||
|
|
||||||
|
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 [Artifact, { open, setOpen }] = useArtifact();
|
||||||
|
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",
|
||||||
|
)}
|
||||||
|
onClick={() => setOpen((open) => !open)}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<Artifact title={props.planItem}>
|
||||||
|
<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}
|
||||||
|
customStyle={{ margin: 0 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<code className={className}>{children}</code>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Artifact>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-4 w-full max-w-4xl p-4 border-[1px] rounded-xl border-slate-200 transition-all cursor-pointer",
|
||||||
|
open && "border-blue-400",
|
||||||
|
)}
|
||||||
|
onClick={() => setOpen((open) => !open)}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<Artifact title={props.planItem}>
|
||||||
|
<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}
|
||||||
|
customStyle={{ margin: 0 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</Artifact>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import "./index.css";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
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 { Snapshot } from "@/agent/types";
|
||||||
|
import { DO_NOT_RENDER_ID_PREFIX } from "@/constants";
|
||||||
|
import { getToolResponse } from "@/agent-uis/utils/get-tool-response";
|
||||||
|
|
||||||
|
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(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
command: {
|
||||||
|
update: {
|
||||||
|
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`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
goto: "generalInput",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
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 { format } from "date-fns";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Price } from "@/agent/types";
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
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 {
|
||||||
|
Carousel,
|
||||||
|
CarouselContent,
|
||||||
|
CarouselItem,
|
||||||
|
CarouselNext,
|
||||||
|
CarouselPrevious,
|
||||||
|
} from "@/components/ui/carousel";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { Message } from "@langchain/langgraph-sdk";
|
||||||
|
import { getToolResponse } from "../../utils/get-tool-response";
|
||||||
|
import { capitalizeSentence } from "@/agent/utils/capitalize";
|
||||||
|
import { TripDetails } from "@/agent/trip-planner/types";
|
||||||
|
import { DO_NOT_RENDER_ID_PREFIX } from "@/constants";
|
||||||
|
import { Accommodation } from "@/agent/types";
|
||||||
|
|
||||||
|
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(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
command: {
|
||||||
|
update: {
|
||||||
|
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}.`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
goto: "generalInput",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import { TripDetails } from "@/agent/trip-planner/types";
|
||||||
|
import "./index.css";
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import type { Message } from "@langchain/langgraph-sdk";
|
||||||
|
import {
|
||||||
|
useStreamContext,
|
||||||
|
type UIMessage,
|
||||||
|
} from "@langchain/langgraph-sdk/react-ui";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook that obtains the artifact context provided by the `LoadExternalComponent`
|
||||||
|
* found in the `meta.artifact` field.
|
||||||
|
*
|
||||||
|
* @see https://github.com/langchain-ai/agent-chat-ui/blob/main/src/components/thread/messages/ai.tsx
|
||||||
|
*/
|
||||||
|
export function useArtifact<TContext = Record<string, unknown>>() {
|
||||||
|
type Component = (props: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
title?: React.ReactNode;
|
||||||
|
}) => React.ReactNode;
|
||||||
|
|
||||||
|
type Context = TContext | undefined;
|
||||||
|
|
||||||
|
type Bag = {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
|
||||||
|
|
||||||
|
context: Context;
|
||||||
|
setContext: (value: Context | ((prev: Context) => Context)) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const thread = useStreamContext<
|
||||||
|
{ messages: Message[]; ui: UIMessage[] },
|
||||||
|
{ MetaType: { artifact: [Component, Bag] } }
|
||||||
|
>();
|
||||||
|
|
||||||
|
const noop = useMemo(
|
||||||
|
() =>
|
||||||
|
[
|
||||||
|
() => null,
|
||||||
|
{
|
||||||
|
open: false,
|
||||||
|
setOpen: () => void 0,
|
||||||
|
|
||||||
|
context: {} as TContext,
|
||||||
|
setContext: () => void 0,
|
||||||
|
},
|
||||||
|
] as [Component, Bag],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return thread.meta?.artifact ?? noop;
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useArtifact } from "../utils/use-artifact";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { LoaderIcon } from "lucide-react";
|
||||||
|
|
||||||
|
export function Writer(props: {
|
||||||
|
title?: string;
|
||||||
|
content?: string;
|
||||||
|
description?: string;
|
||||||
|
isGenerating: boolean;
|
||||||
|
}) {
|
||||||
|
const [Artifact, { open, setOpen, setContext }] = useArtifact<{
|
||||||
|
writer?: { selected?: string };
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const [content, setContent] = useState(props.content ?? "");
|
||||||
|
useEffect(() => setContent(props.content ?? ""), [props.content]);
|
||||||
|
|
||||||
|
const prevOpened = useRef(false);
|
||||||
|
const shouldAutoOpen = !open && content.length > 0 && props.isGenerating;
|
||||||
|
useEffect(() => {
|
||||||
|
if (shouldAutoOpen && !prevOpened.current) {
|
||||||
|
prevOpened.current = true;
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
}, [shouldAutoOpen, setOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
className="border p-4 rounded-lg cursor-pointer"
|
||||||
|
>
|
||||||
|
<p className="font-medium">{props.title}</p>
|
||||||
|
<p className="text-sm text-gray-500">{props.description}</p>
|
||||||
|
|
||||||
|
{props.isGenerating && (
|
||||||
|
<p className="flex items-center gap-2">
|
||||||
|
<LoaderIcon className="animate-spin" />
|
||||||
|
<span>Generating...</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Artifact title={props.title}>
|
||||||
|
<textarea
|
||||||
|
className="absolute inset-0 w-full h-full p-4 outline-none"
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
onSelect={(e) => {
|
||||||
|
const selectedText = e.currentTarget.value.substring(
|
||||||
|
e.currentTarget.selectionStart,
|
||||||
|
e.currentTarget.selectionEnd,
|
||||||
|
);
|
||||||
|
setContext((prevContext) => ({
|
||||||
|
...prevContext,
|
||||||
|
writer: { ...prevContext?.writer, selected: selectedText },
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Artifact>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import {
|
||||||
|
Annotation,
|
||||||
|
MessagesAnnotation,
|
||||||
|
START,
|
||||||
|
StateGraph,
|
||||||
|
} from "@langchain/langgraph";
|
||||||
|
import { ChatOpenAI } from "@langchain/openai";
|
||||||
|
|
||||||
|
const ChatAgentAnnotation = Annotation.Root({
|
||||||
|
messages: MessagesAnnotation.spec["messages"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const graph = new StateGraph(ChatAgentAnnotation)
|
||||||
|
.addNode("chat", async (state) => {
|
||||||
|
const model = new ChatOpenAI({
|
||||||
|
model: "gpt-4o-mini",
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await model.invoke([
|
||||||
|
{ role: "system", content: "You are a helpful assistant." },
|
||||||
|
...state.messages,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages: response,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.addEdge(START, "chat");
|
||||||
|
|
||||||
|
export const agent = graph.compile();
|
||||||
|
agent.name = "Chat Agent";
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { END, START, StateGraph } from "@langchain/langgraph";
|
||||||
|
import { EmailAgentAnnotation, EmailAgentState } from "./types";
|
||||||
|
import { writeEmail } from "./nodes/write-email";
|
||||||
|
import { interruptNode } from "./nodes/interrupt";
|
||||||
|
import { sendEmail } from "./nodes/send-email";
|
||||||
|
import { rewriteEmail } from "./nodes/rewrite-email";
|
||||||
|
|
||||||
|
function routeAfterInterrupt(
|
||||||
|
state: EmailAgentState,
|
||||||
|
): typeof END | "sendEmail" | "rewriteEmail" {
|
||||||
|
const responseType = state.humanResponse?.type;
|
||||||
|
if (!responseType || responseType === "ignore") {
|
||||||
|
return END;
|
||||||
|
}
|
||||||
|
if (responseType === "response") {
|
||||||
|
return "rewriteEmail";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "sendEmail";
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeAfterWritingEmail(
|
||||||
|
state: EmailAgentState,
|
||||||
|
): typeof END | "interrupt" {
|
||||||
|
if (!state.email) {
|
||||||
|
return END;
|
||||||
|
}
|
||||||
|
return "interrupt";
|
||||||
|
}
|
||||||
|
|
||||||
|
const graph = new StateGraph(EmailAgentAnnotation)
|
||||||
|
.addNode("writeEmail", writeEmail)
|
||||||
|
.addNode("interrupt", interruptNode)
|
||||||
|
.addNode("sendEmail", sendEmail)
|
||||||
|
.addNode("rewriteEmail", rewriteEmail)
|
||||||
|
.addEdge(START, "writeEmail")
|
||||||
|
.addConditionalEdges("writeEmail", routeAfterWritingEmail, [END, "interrupt"])
|
||||||
|
.addConditionalEdges("interrupt", routeAfterInterrupt, [
|
||||||
|
"sendEmail",
|
||||||
|
"rewriteEmail",
|
||||||
|
END,
|
||||||
|
])
|
||||||
|
.addEdge("rewriteEmail", "interrupt")
|
||||||
|
.addEdge("sendEmail", END);
|
||||||
|
|
||||||
|
export const agent = graph.compile();
|
||||||
|
agent.name = "Email Assistant Agent";
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { Email, EmailAgentState, EmailAgentUpdate } from "../types";
|
||||||
|
import { HumanInterrupt, HumanResponse } from "@langchain/langgraph/prebuilt";
|
||||||
|
import { interrupt } from "@langchain/langgraph";
|
||||||
|
|
||||||
|
export async function interruptNode(
|
||||||
|
state: EmailAgentState,
|
||||||
|
): Promise<EmailAgentUpdate> {
|
||||||
|
if (!state.email) {
|
||||||
|
throw new Error("Can not interrupt if email is undefined.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const description = `# New Email
|
||||||
|
|
||||||
|
## Subject
|
||||||
|
${state.email.subject}
|
||||||
|
|
||||||
|
## To
|
||||||
|
${state.email.to}
|
||||||
|
|
||||||
|
## Body
|
||||||
|
${state.email.body}
|
||||||
|
|
||||||
|
## Response Instructions
|
||||||
|
|
||||||
|
- **Response**: Any response submitted will be passed to an LLM to rewrite the email. It can rewrite the email body, subject, or recipient.
|
||||||
|
|
||||||
|
- **Edit or Accept**: Editing/Accepting the email will send the email.
|
||||||
|
|
||||||
|
- **Ignore**: Ignoring the email will end the conversation, and the email will not be sent.`;
|
||||||
|
|
||||||
|
const res = interrupt<HumanInterrupt[], HumanResponse[]>([
|
||||||
|
{
|
||||||
|
action_request: {
|
||||||
|
action: "New Email Draft",
|
||||||
|
args: {
|
||||||
|
...state.email,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
description,
|
||||||
|
config: {
|
||||||
|
allow_ignore: true,
|
||||||
|
allow_respond: true,
|
||||||
|
allow_edit: true,
|
||||||
|
allow_accept: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])[0];
|
||||||
|
|
||||||
|
if (["ignore", "response", "accept"].includes(res.type)) {
|
||||||
|
return {
|
||||||
|
humanResponse: res,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof res.args !== "object" ||
|
||||||
|
!res.args ||
|
||||||
|
!("subject" in res.args) ||
|
||||||
|
!("body" in res.args) ||
|
||||||
|
!("to" in res.args)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"If response type is edit, args must be an object with 'subject', 'body', and 'to' fields.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { subject, body, to } = res.args as Email;
|
||||||
|
|
||||||
|
return {
|
||||||
|
email: {
|
||||||
|
subject,
|
||||||
|
body,
|
||||||
|
to,
|
||||||
|
},
|
||||||
|
humanResponse: res,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { EmailAgentState, EmailAgentUpdate } from "../types";
|
||||||
|
import { ChatOpenAI } from "@langchain/openai";
|
||||||
|
|
||||||
|
const REWRITE_EMAIL_PROMPT = `You're an AI email assistant, tasked with rewriting an email for the user.
|
||||||
|
Here is the current state of the email for the user:
|
||||||
|
<email>
|
||||||
|
<subject>
|
||||||
|
{SUBJECT}
|
||||||
|
</subject>
|
||||||
|
<body>
|
||||||
|
{BODY}
|
||||||
|
</body>
|
||||||
|
<to>
|
||||||
|
{TO}
|
||||||
|
</to>
|
||||||
|
</email>
|
||||||
|
|
||||||
|
Here is the user's response, which should contain some request for changes to the email:
|
||||||
|
<user-response>
|
||||||
|
{USER_RESPONSE}
|
||||||
|
</user-response>
|
||||||
|
|
||||||
|
Given that, please rewrite the email. Do NOT modify anything the user does not request to be changed.`;
|
||||||
|
|
||||||
|
const sendEmailSchema = z.object({
|
||||||
|
subject: z.string().describe("The subject of the email"),
|
||||||
|
body: z.string().describe("The body of the email"),
|
||||||
|
to: z.string().describe("The recipient of the email"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function rewriteEmail(
|
||||||
|
state: EmailAgentState,
|
||||||
|
): Promise<EmailAgentUpdate> {
|
||||||
|
if (
|
||||||
|
!state.humanResponse?.args ||
|
||||||
|
typeof state.humanResponse.args !== "string"
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Can not rewrite email if human response args is not defined, or type string.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!state.email) {
|
||||||
|
throw new Error("Can not rewrite email if email is undefined.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = new ChatOpenAI({
|
||||||
|
model: "gpt-4o",
|
||||||
|
temperature: 0,
|
||||||
|
}).bindTools(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
name: "write_email",
|
||||||
|
description: "Write an email based on the conversation history",
|
||||||
|
schema: sendEmailSchema,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
tool_choice: "write_email",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const prompt = REWRITE_EMAIL_PROMPT.replace("{SUBJECT}", state.email.subject)
|
||||||
|
.replace("{BODY}", state.email.body)
|
||||||
|
.replace("{TO}", state.email.to)
|
||||||
|
.replace("{USER_RESPONSE}", state.humanResponse.args);
|
||||||
|
|
||||||
|
const response = await model.invoke([{ role: "user", content: prompt }]);
|
||||||
|
|
||||||
|
const toolCall = response.tool_calls?.[0]?.args as
|
||||||
|
| z.infer<typeof sendEmailSchema>
|
||||||
|
| undefined;
|
||||||
|
if (!toolCall) {
|
||||||
|
throw new Error("Failed to generate email");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
email: toolCall,
|
||||||
|
messages: [response],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import { AIMessage } from "@langchain/langgraph-sdk";
|
||||||
|
import { EmailAgentState, EmailAgentUpdate } from "../types";
|
||||||
|
|
||||||
|
export async function sendEmail(
|
||||||
|
_state: EmailAgentState,
|
||||||
|
): Promise<EmailAgentUpdate> {
|
||||||
|
// Should yield a gen ui component rendering a 'sent' email.
|
||||||
|
const tmpAiMessage: AIMessage = {
|
||||||
|
type: "ai",
|
||||||
|
id: uuidv4(),
|
||||||
|
content: "Successfully sent email.",
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
messages: [tmpAiMessage],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { EmailAgentState, EmailAgentUpdate } from "../types";
|
||||||
|
import { ChatOpenAI } from "@langchain/openai";
|
||||||
|
import { formatMessages } from "@/agent/utils/format-messages";
|
||||||
|
|
||||||
|
const SEND_EMAIL_PROMPT = `You're an AI email assistant, tasked with writing an email for the user.
|
||||||
|
Use the entire conversation history between you, and the user to craft the email for them.
|
||||||
|
|
||||||
|
<conversation>
|
||||||
|
{CONVERSATION}
|
||||||
|
</conversation>
|
||||||
|
|
||||||
|
If there is NOT enough information to send an email, respond to the user requesting the missing information.
|
||||||
|
Required fields:
|
||||||
|
- subject - The subject of the email
|
||||||
|
- body - The body of the email
|
||||||
|
- to - The recipient of the email`;
|
||||||
|
|
||||||
|
const sendEmailSchema = z.object({
|
||||||
|
subject: z.string().describe("The subject of the email"),
|
||||||
|
body: z.string().describe("The body of the email"),
|
||||||
|
to: z.string().describe("The recipient of the email"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function writeEmail(
|
||||||
|
state: EmailAgentState,
|
||||||
|
): Promise<EmailAgentUpdate> {
|
||||||
|
const model = new ChatOpenAI({
|
||||||
|
model: "gpt-4o",
|
||||||
|
temperature: 0,
|
||||||
|
}).bindTools([
|
||||||
|
{
|
||||||
|
name: "write_email",
|
||||||
|
description: "Write an email based on the conversation history",
|
||||||
|
schema: sendEmailSchema,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const prompt = SEND_EMAIL_PROMPT.replace(
|
||||||
|
"{CONVERSATION}",
|
||||||
|
formatMessages(state.messages),
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await model.invoke([{ role: "user", content: prompt }]);
|
||||||
|
|
||||||
|
const toolCall = response.tool_calls?.[0]?.args as
|
||||||
|
| z.infer<typeof sendEmailSchema>
|
||||||
|
| undefined;
|
||||||
|
if (!toolCall) {
|
||||||
|
return {
|
||||||
|
messages: [response],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
email: toolCall,
|
||||||
|
messages: [response],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Annotation } from "@langchain/langgraph";
|
||||||
|
import { GenerativeUIAnnotation } from "../types";
|
||||||
|
import { HumanResponse } from "@langchain/langgraph/prebuilt";
|
||||||
|
|
||||||
|
export type Email = {
|
||||||
|
subject: string;
|
||||||
|
body: string;
|
||||||
|
to: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EmailAgentAnnotation = Annotation.Root({
|
||||||
|
messages: GenerativeUIAnnotation.spec.messages,
|
||||||
|
email: Annotation<Email | undefined>(),
|
||||||
|
humanResponse: Annotation<HumanResponse | undefined>(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type EmailAgentState = typeof EmailAgentAnnotation.State;
|
||||||
|
export type EmailAgentUpdate = typeof EmailAgentAnnotation.Update;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
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";
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
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 type ComponentMap from "../../../agent-uis/index";
|
||||||
|
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(
|
||||||
|
"src/agent/open-code/nodes/plan-code/step-1.txt",
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
updateFileContents = await fs.readFile(
|
||||||
|
"src/agent/open-code/nodes/plan-code/step-2.txt",
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
updateFileContents = await fs.readFile(
|
||||||
|
"src/agent/open-code/nodes/plan-code/step-3.txt",
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 3:
|
||||||
|
updateFileContents = await fs.readFile(
|
||||||
|
"src/agent/open-code/nodes/plan-code/step-4.txt",
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 4:
|
||||||
|
updateFileContents = await fs.readFile(
|
||||||
|
"src/agent/open-code/nodes/plan-code/step-5.txt",
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 5:
|
||||||
|
updateFileContents = await fs.readFile(
|
||||||
|
"src/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",
|
||||||
|
props: {
|
||||||
|
toolCallId,
|
||||||
|
change: updateFileContents,
|
||||||
|
planItem: nextPlanItem,
|
||||||
|
fullWriteAccess,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ message: aiMessage },
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages: [aiMessage],
|
||||||
|
ui: ui.items,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
```bash
|
||||||
|
npx create-react-app todo-app --template typescript
|
||||||
|
cd todo-app
|
||||||
|
mkdir -p src/{components,styles,utils}
|
||||||
|
```
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
```tsx
|
||||||
|
// src/components/TodoItem.tsx
|
||||||
|
import React from 'react';
|
||||||
|
import styles from '../styles/TodoItem.module.css';
|
||||||
|
|
||||||
|
interface TodoItemProps {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
completed: boolean;
|
||||||
|
onToggle: (id: string) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TodoItem: React.FC<TodoItemProps> = ({ id, text, completed, onToggle, onDelete }) => (
|
||||||
|
<div className={styles.todoItem}>
|
||||||
|
<input type='checkbox' checked={completed} onChange={() => onToggle(id)} />
|
||||||
|
<span className={completed ? styles.completed : ''}>{text}</span>
|
||||||
|
<button onClick={() => onDelete(id)}>Delete</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
```
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
```tsx
|
||||||
|
// src/context/TodoContext.tsx
|
||||||
|
import React, { createContext, useContext, useReducer } from 'react';
|
||||||
|
|
||||||
|
type Todo = { id: string; text: string; completed: boolean; };
|
||||||
|
|
||||||
|
type TodoState = { todos: Todo[]; };
|
||||||
|
type TodoAction =
|
||||||
|
| { type: 'ADD_TODO'; payload: string }
|
||||||
|
| { type: 'TOGGLE_TODO'; payload: string }
|
||||||
|
| { type: 'DELETE_TODO'; payload: string };
|
||||||
|
|
||||||
|
const TodoContext = createContext<{
|
||||||
|
state: TodoState;
|
||||||
|
dispatch: React.Dispatch<TodoAction>;
|
||||||
|
} | undefined>(undefined);
|
||||||
|
|
||||||
|
export const TodoProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [state, dispatch] = useReducer(todoReducer, { todos: [] });
|
||||||
|
return <TodoContext.Provider value={{ state, dispatch }}>{children}</TodoContext.Provider>;
|
||||||
|
};
|
||||||
|
```
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
```tsx
|
||||||
|
// src/components/AddTodo.tsx
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import styles from '../styles/AddTodo.module.css';
|
||||||
|
|
||||||
|
export const AddTodo: React.FC<{ onAdd: (text: string) => void }> = ({ onAdd }) => {
|
||||||
|
const [text, setText] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!text.trim()) {
|
||||||
|
setError('Todo text cannot be empty');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onAdd(text.trim());
|
||||||
|
setText('');
|
||||||
|
setError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className={styles.form}>
|
||||||
|
<input
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
placeholder='Add a new todo'
|
||||||
|
/>
|
||||||
|
{error && <div className={styles.error}>{error}</div>}
|
||||||
|
<button type='submit'>Add Todo</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
```tsx
|
||||||
|
// src/components/TodoFilters.tsx
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
type FilterType = 'all' | 'active' | 'completed';
|
||||||
|
|
||||||
|
export const TodoFilters: React.FC<{
|
||||||
|
currentFilter: FilterType;
|
||||||
|
onFilterChange: (filter: FilterType) => void;
|
||||||
|
onSortChange: (ascending: boolean) => void;
|
||||||
|
}> = ({ currentFilter, onFilterChange, onSortChange }) => (
|
||||||
|
<div>
|
||||||
|
<select value={currentFilter} onChange={(e) => onFilterChange(e.target.value as FilterType)}>
|
||||||
|
<option value='all'>All</option>
|
||||||
|
<option value='active'>Active</option>
|
||||||
|
<option value='completed'>Completed</option>
|
||||||
|
</select>
|
||||||
|
<button onClick={() => onSortChange(true)}>Sort A-Z</button>
|
||||||
|
<button onClick={() => onSortChange(false)}>Sort Z-A</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
```
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
```tsx
|
||||||
|
// src/utils/storage.ts
|
||||||
|
const STORAGE_KEY = 'todos';
|
||||||
|
|
||||||
|
export const saveTodos = (todos: Todo[]) => {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loadTodos = (): Todo[] => {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
return stored ? JSON.parse(stored) : [];
|
||||||
|
};
|
||||||
|
```
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import { AIMessage, ToolMessage } from "@langchain/langgraph-sdk";
|
||||||
|
import { OpenCodeState, OpenCodeUpdate } from "../types";
|
||||||
|
import { LangGraphRunnableConfig } from "@langchain/langgraph";
|
||||||
|
import type ComponentMap from "../../../agent-uis/index";
|
||||||
|
import { typedUi } from "@langchain/langgraph-sdk/react-ui/server";
|
||||||
|
import { DO_NOT_RENDER_ID_PREFIX } from "@/constants";
|
||||||
|
|
||||||
|
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",
|
||||||
|
props: {
|
||||||
|
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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
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";
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
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";
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import { StockbrokerState, StockbrokerUpdate } from "../types";
|
||||||
|
import { ChatOpenAI } from "@langchain/openai";
|
||||||
|
import { typedUi } from "@langchain/langgraph-sdk/react-ui/server";
|
||||||
|
import type ComponentMap from "../../../agent-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) {
|
||||||
|
const status = response.status;
|
||||||
|
const statusText = response.statusText;
|
||||||
|
throw new Error(
|
||||||
|
`Failed to next page data prices.\nURL: ${url}\nStatus: ${status} ${statusText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const nextPageData = await getNextPageData(nextPageUrlThirtyDays);
|
||||||
|
pricesThirtyDays.push(...nextPageData.prices);
|
||||||
|
nextPageUrlThirtyDays = nextPageData.next_page_url;
|
||||||
|
iters += 1;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
props: { ticker: stockbrokerToolCall.args.ticker, ...prices },
|
||||||
|
},
|
||||||
|
{ message },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (portfolioToolCall) {
|
||||||
|
ui.push({ name: "portfolio", props: {} }, { message });
|
||||||
|
}
|
||||||
|
if (buyStockToolCall) {
|
||||||
|
const snapshot = await getPriceSnapshotForTicker(
|
||||||
|
buyStockToolCall.args.ticker,
|
||||||
|
);
|
||||||
|
ui.push(
|
||||||
|
{
|
||||||
|
name: "buy-stock",
|
||||||
|
props: {
|
||||||
|
toolCallId: buyStockToolCall.id ?? "",
|
||||||
|
snapshot,
|
||||||
|
quantity: buyStockToolCall.args.quantity,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ message },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages: [message],
|
||||||
|
ui: ui.items,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { StateGraph, START, END } from "@langchain/langgraph";
|
||||||
|
import { stockbrokerGraph } from "../stockbroker";
|
||||||
|
import { tripPlannerGraph } from "../trip-planner";
|
||||||
|
import { graph as openCodeGraph } from "../open-code";
|
||||||
|
import { graph as orderPizzaGraph } from "../pizza-orderer";
|
||||||
|
import {
|
||||||
|
SupervisorAnnotation,
|
||||||
|
SupervisorState,
|
||||||
|
SupervisorZodConfiguration,
|
||||||
|
} from "./types";
|
||||||
|
import { generalInput } from "./nodes/general-input";
|
||||||
|
import { router } from "./nodes/router";
|
||||||
|
import { graph as writerAgentGraph } from "../writer-agent";
|
||||||
|
|
||||||
|
export const ALL_TOOL_DESCRIPTIONS = `- 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 a React TODO app for the user. Only call this tool if they request a TODO app.
|
||||||
|
- orderPizza: can order a pizza for the user
|
||||||
|
- writerAgent: can write a text document for the user. Only call this tool if they request a text document.`;
|
||||||
|
|
||||||
|
function handleRoute(
|
||||||
|
state: SupervisorState,
|
||||||
|
):
|
||||||
|
| "stockbroker"
|
||||||
|
| "tripPlanner"
|
||||||
|
| "openCode"
|
||||||
|
| "orderPizza"
|
||||||
|
| "generalInput"
|
||||||
|
| "writerAgent" {
|
||||||
|
return state.next;
|
||||||
|
}
|
||||||
|
|
||||||
|
const builder = new StateGraph(SupervisorAnnotation, SupervisorZodConfiguration)
|
||||||
|
.addNode("router", router)
|
||||||
|
.addNode("stockbroker", stockbrokerGraph)
|
||||||
|
.addNode("tripPlanner", tripPlannerGraph)
|
||||||
|
.addNode("openCode", openCodeGraph)
|
||||||
|
.addNode("orderPizza", orderPizzaGraph)
|
||||||
|
.addNode("generalInput", generalInput)
|
||||||
|
.addNode("writerAgent", writerAgentGraph)
|
||||||
|
.addConditionalEdges("router", handleRoute, [
|
||||||
|
"stockbroker",
|
||||||
|
"tripPlanner",
|
||||||
|
"openCode",
|
||||||
|
"orderPizza",
|
||||||
|
"generalInput",
|
||||||
|
"writerAgent",
|
||||||
|
])
|
||||||
|
.addEdge(START, "router")
|
||||||
|
.addEdge("stockbroker", END)
|
||||||
|
.addEdge("tripPlanner", END)
|
||||||
|
.addEdge("openCode", END)
|
||||||
|
.addEdge("orderPizza", END)
|
||||||
|
.addEdge("generalInput", END)
|
||||||
|
.addEdge("writerAgent", END);
|
||||||
|
|
||||||
|
export const graph = builder.compile();
|
||||||
|
graph.name = "Generative UI Agent";
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { SupervisorState, SupervisorUpdate } from "../types";
|
||||||
|
import { ALL_TOOL_DESCRIPTIONS } from "../index";
|
||||||
|
import { ChatOpenAI } from "@langchain/openai";
|
||||||
|
|
||||||
|
export async function generalInput(
|
||||||
|
state: SupervisorState,
|
||||||
|
): Promise<SupervisorUpdate> {
|
||||||
|
const GENERAL_INPUT_SYSTEM_PROMPT = `You are an AI assistant.
|
||||||
|
If the user asks what you can do, describe these tools.
|
||||||
|
${ALL_TOOL_DESCRIPTIONS}
|
||||||
|
|
||||||
|
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.`;
|
||||||
|
|
||||||
|
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],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
|
||||||
|
import { ALL_TOOL_DESCRIPTIONS } from "../index";
|
||||||
|
import { SupervisorState, SupervisorUpdate } from "../types";
|
||||||
|
import { formatMessages } from "@/agent/utils/format-messages";
|
||||||
|
|
||||||
|
export async function router(
|
||||||
|
state: SupervisorState,
|
||||||
|
): Promise<Partial<SupervisorUpdate>> {
|
||||||
|
const routerDescription = `The route to take based on the user's input.
|
||||||
|
${ALL_TOOL_DESCRIPTIONS}
|
||||||
|
- generalInput: handles all other cases where the above tools don't apply
|
||||||
|
`;
|
||||||
|
const routerSchema = z.object({
|
||||||
|
route: z
|
||||||
|
.enum([
|
||||||
|
"stockbroker",
|
||||||
|
"tripPlanner",
|
||||||
|
"openCode",
|
||||||
|
"orderPizza",
|
||||||
|
"generalInput",
|
||||||
|
"writerAgent",
|
||||||
|
])
|
||||||
|
.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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import "@langchain/langgraph/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Annotation } from "@langchain/langgraph";
|
||||||
|
import { GenerativeUIAnnotation } from "../types";
|
||||||
|
|
||||||
|
export const SupervisorAnnotation = Annotation.Root({
|
||||||
|
...GenerativeUIAnnotation.spec,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SupervisorState = typeof SupervisorAnnotation.State;
|
||||||
|
export type SupervisorUpdate = typeof SupervisorAnnotation.Update;
|
||||||
|
|
||||||
|
export const SupervisorZodConfiguration = z.object({
|
||||||
|
/**
|
||||||
|
* The model ID to use for the reflection generation.
|
||||||
|
* Should be in the format `provider/model_name`.
|
||||||
|
* Defaults to `anthropic/claude-3-7-sonnet-latest`.
|
||||||
|
*/
|
||||||
|
model: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.langgraph.metadata({
|
||||||
|
type: "select",
|
||||||
|
default: "anthropic/claude-3-7-sonnet-latest",
|
||||||
|
description: "The model to use in all generations",
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: "Claude 3.7 Sonnet",
|
||||||
|
value: "anthropic/claude-3-7-sonnet-latest",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Claude 3.5 Sonnet",
|
||||||
|
value: "anthropic/claude-3-5-sonnet-latest",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "GPT 4o",
|
||||||
|
value: "openai/gpt-4o",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "GPT 4.1",
|
||||||
|
value: "openai/gpt-4.1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "o3",
|
||||||
|
value: "openai/o3",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "o3 mini",
|
||||||
|
value: "openai/o3-mini",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "o4",
|
||||||
|
value: "openai/o4",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
/**
|
||||||
|
* The temperature to use for the reflection generation.
|
||||||
|
* Defaults to `0.7`.
|
||||||
|
*/
|
||||||
|
temperature: z.number().optional().langgraph.metadata({
|
||||||
|
type: "slider",
|
||||||
|
default: 0.7,
|
||||||
|
min: 0,
|
||||||
|
max: 2,
|
||||||
|
step: 0.1,
|
||||||
|
description: "Controls randomness (0 = deterministic, 2 = creative)",
|
||||||
|
}),
|
||||||
|
/**
|
||||||
|
* The maximum number of tokens to generate.
|
||||||
|
* Defaults to `1000`.
|
||||||
|
*/
|
||||||
|
maxTokens: z.number().optional().langgraph.metadata({
|
||||||
|
type: "number",
|
||||||
|
default: 1000,
|
||||||
|
min: 1,
|
||||||
|
description: "The maximum number of tokens to generate",
|
||||||
|
}),
|
||||||
|
systemPrompt: z.string().optional().langgraph.metadata({
|
||||||
|
type: "textarea",
|
||||||
|
placeholder: "Enter a system prompt...",
|
||||||
|
description: "The system prompt to use in all generations",
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
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";
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
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 {};
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import { ChatOpenAI } from "@langchain/openai";
|
||||||
|
import { TripDetails, TripPlannerState, TripPlannerUpdate } from "../types";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { ToolMessage } from "@langchain/langgraph-sdk";
|
||||||
|
import { formatMessages } from "@/agent/utils/format-messages";
|
||||||
|
import { DO_NOT_RENDER_ID_PREFIX } from "@/constants";
|
||||||
|
|
||||||
|
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 && extractedDetails.numberOfGuests > 0
|
||||||
|
? 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],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { TripPlannerState, TripPlannerUpdate } from "../types";
|
||||||
|
import { ChatOpenAI } from "@langchain/openai";
|
||||||
|
import { typedUi } from "@langchain/langgraph-sdk/react-ui/server";
|
||||||
|
import type ComponentMap from "../../../agent-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",
|
||||||
|
props: {
|
||||||
|
toolCallId: listAccommodationsToolCall.id ?? "",
|
||||||
|
...getAccommodationsListProps(state.tripDetails),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ message: response },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listRestaurantsToolCall) {
|
||||||
|
ui.push(
|
||||||
|
{
|
||||||
|
name: "restaurants-list",
|
||||||
|
props: { tripDetails: state.tripDetails },
|
||||||
|
},
|
||||||
|
{ message: response },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages: [response],
|
||||||
|
ui: ui.items,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
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 }),
|
||||||
|
context: Annotation<Record<string, unknown> | undefined>,
|
||||||
|
timestamp: Annotation<number>,
|
||||||
|
next: Annotation<
|
||||||
|
| "stockbroker"
|
||||||
|
| "tripPlanner"
|
||||||
|
| "openCode"
|
||||||
|
| "orderPizza"
|
||||||
|
| "writerAgent"
|
||||||
|
| "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;
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import {
|
||||||
|
Annotation,
|
||||||
|
START,
|
||||||
|
StateGraph,
|
||||||
|
type LangGraphRunnableConfig,
|
||||||
|
} from "@langchain/langgraph";
|
||||||
|
import { ChatAnthropic } from "@langchain/anthropic";
|
||||||
|
import { typedUi } from "@langchain/langgraph-sdk/react-ui/server";
|
||||||
|
import {
|
||||||
|
isAIMessage,
|
||||||
|
isBaseMessage,
|
||||||
|
type AIMessageChunk,
|
||||||
|
type BaseMessageLike,
|
||||||
|
} from "@langchain/core/messages";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { findToolCall } from "../find-tool-call";
|
||||||
|
import { GenerativeUIAnnotation } from "../types";
|
||||||
|
|
||||||
|
import type ComponentMap from "../../agent-uis/index";
|
||||||
|
|
||||||
|
const MODEL_NAME = "claude-3-5-sonnet-latest";
|
||||||
|
|
||||||
|
const WriterAnnotation = Annotation.Root({
|
||||||
|
messages: GenerativeUIAnnotation.spec.messages,
|
||||||
|
ui: GenerativeUIAnnotation.spec.ui,
|
||||||
|
context: Annotation<{ writer?: { selected?: string } } | undefined>(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type WriterState = typeof WriterAnnotation.State;
|
||||||
|
type WriterUpdate = Promise<typeof WriterAnnotation.Update>;
|
||||||
|
|
||||||
|
async function prepare(
|
||||||
|
state: WriterState,
|
||||||
|
config: LangGraphRunnableConfig,
|
||||||
|
): WriterUpdate {
|
||||||
|
const ui = typedUi<typeof ComponentMap>(config);
|
||||||
|
const model = new ChatAnthropic({ model: MODEL_NAME });
|
||||||
|
|
||||||
|
// create an initial draft of the document
|
||||||
|
const CreateTextDocumentTool = z.object({
|
||||||
|
title: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const initStream = await model
|
||||||
|
.bindTools([
|
||||||
|
{
|
||||||
|
name: "draft_text_document",
|
||||||
|
description:
|
||||||
|
"Prepare a text document for the user with a short title and short description for browsing purposes. " +
|
||||||
|
"Can be also used when creating a new version of the document.",
|
||||||
|
schema: CreateTextDocumentTool,
|
||||||
|
} as const,
|
||||||
|
])
|
||||||
|
.stream([
|
||||||
|
...(state.context?.writer?.selected
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
type: "system" as const,
|
||||||
|
content: state.context.writer?.selected
|
||||||
|
? `Selected text in question: ${state.context.writer?.selected}`
|
||||||
|
: "",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...state.messages,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const id = uuidv4();
|
||||||
|
let message: AIMessageChunk | undefined;
|
||||||
|
|
||||||
|
for await (const chunk of initStream) {
|
||||||
|
message = message?.concat(chunk) ?? chunk;
|
||||||
|
|
||||||
|
const tool = message.tool_calls?.find(
|
||||||
|
findToolCall("draft_text_document")<typeof CreateTextDocumentTool>,
|
||||||
|
)?.args;
|
||||||
|
|
||||||
|
if (tool) {
|
||||||
|
ui.push(
|
||||||
|
{ id, name: "writer", props: { ...tool, isGenerating: true } },
|
||||||
|
{ message, merge: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { messages: message ? [message] : [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writer(
|
||||||
|
state: WriterState,
|
||||||
|
config: LangGraphRunnableConfig,
|
||||||
|
): WriterUpdate {
|
||||||
|
const ui = typedUi<typeof ComponentMap>(config);
|
||||||
|
|
||||||
|
const lastMessage = state.messages.at(-1);
|
||||||
|
const lastUi = state.ui.findLast(
|
||||||
|
(i) => i.name === "writer" && i.metadata.message_id === lastMessage?.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!lastUi || !lastMessage) return {};
|
||||||
|
const { id } = lastUi;
|
||||||
|
|
||||||
|
const contentStream = await new ChatAnthropic({ model: MODEL_NAME })
|
||||||
|
.withConfig({ tags: ["nostream"] }) // do not stream to the UI
|
||||||
|
.stream([
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content:
|
||||||
|
"Write a text document based on the user's request. " +
|
||||||
|
"Only output the content, do not ask any additional questions." +
|
||||||
|
(state.context?.writer?.selected
|
||||||
|
? `\n\nSelected text in question: ${state.context.writer?.selected}`
|
||||||
|
: ""),
|
||||||
|
},
|
||||||
|
...state.messages.slice(0, -1),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let contentMessage: AIMessageChunk | undefined;
|
||||||
|
for await (const chunk of contentStream) {
|
||||||
|
contentMessage = contentMessage?.concat(chunk) ?? chunk;
|
||||||
|
const content = contentMessage?.text ?? "";
|
||||||
|
|
||||||
|
ui.push(
|
||||||
|
{ id, name: "writer", props: { content, isGenerating: true } },
|
||||||
|
{ message: lastMessage, merge: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.push(
|
||||||
|
{ id, name: "writer", props: { isGenerating: false } },
|
||||||
|
{ message: lastMessage, merge: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
return { messages: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function suggestions(state: WriterState): WriterUpdate {
|
||||||
|
const messages: BaseMessageLike[] = state.messages.slice();
|
||||||
|
const lastMessage = messages.at(-1);
|
||||||
|
|
||||||
|
if (!isBaseMessage(lastMessage) || !isAIMessage(lastMessage)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const tool of lastMessage.tool_calls ?? []) {
|
||||||
|
if (!tool.id) continue;
|
||||||
|
messages.push({ type: "tool", content: "Finished", tool_call_id: tool.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = new ChatAnthropic({ model: MODEL_NAME });
|
||||||
|
const finish = await model.invoke(messages);
|
||||||
|
messages.push(finish);
|
||||||
|
|
||||||
|
return { messages: messages };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const graph = new StateGraph(WriterAnnotation)
|
||||||
|
.addNode("prepare", prepare)
|
||||||
|
.addNode("writer", writer)
|
||||||
|
.addNode("suggestions", suggestions)
|
||||||
|
.addEdge(START, "prepare")
|
||||||
|
.addEdge("prepare", "writer")
|
||||||
|
.addEdge("writer", "suggestions")
|
||||||
|
.compile();
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
|
icon: "size-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
type ButtonProps = React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: ButtonProps) {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants, type ButtonProps };
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"border-input file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input };
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const DO_NOT_RENDER_ID_PREFIX = "do-not-render-";
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@plugin "tailwindcss-animate";
|
||||||
|
|
||||||
|
@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.87 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.87 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--chart-1: 12 76% 61%;
|
||||||
|
--chart-2: 173 58% 39%;
|
||||||
|
--chart-3: 197 37% 24%;
|
||||||
|
--chart-4: 43 74% 66%;
|
||||||
|
--chart-5: 27 87% 67%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--chart-1: 220 70% 50%;
|
||||||
|
--chart-2: 160 60% 45%;
|
||||||
|
--chart-3: 30 80% 55%;
|
||||||
|
--chart-4: 280 65% 60%;
|
||||||
|
--chart-5: 340 75% 55%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.shadow-inner-right {
|
||||||
|
box-shadow: inset -9px 0 6px -1px rgb(0 0 0 / 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shadow-inner-left {
|
||||||
|
box-shadow: inset 9px 0 6px -1px rgb(0 0 0 / 0.02);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(<div>Hello world</div>);
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
darkMode: ["class"],
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{ts,tsx,js,jsx}",
|
||||||
|
"./agent/**/*.{ts,tsx,js,jsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
borderRadius: {
|
||||||
|
lg: "var(--radius)",
|
||||||
|
md: "calc(var(--radius) - 2px)",
|
||||||
|
sm: "calc(var(--radius) - 4px)",
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
background: "hsl(var(--background))",
|
||||||
|
foreground: "hsl(var(--foreground))",
|
||||||
|
card: {
|
||||||
|
DEFAULT: "hsl(var(--card))",
|
||||||
|
foreground: "hsl(var(--card-foreground))",
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: "hsl(var(--popover))",
|
||||||
|
foreground: "hsl(var(--popover-foreground))",
|
||||||
|
},
|
||||||
|
primary: {
|
||||||
|
DEFAULT: "hsl(var(--primary))",
|
||||||
|
foreground: "hsl(var(--primary-foreground))",
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: "hsl(var(--secondary))",
|
||||||
|
foreground: "hsl(var(--secondary-foreground))",
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: "hsl(var(--muted))",
|
||||||
|
foreground: "hsl(var(--muted-foreground))",
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: "hsl(var(--accent))",
|
||||||
|
foreground: "hsl(var(--accent-foreground))",
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: "hsl(var(--destructive))",
|
||||||
|
foreground: "hsl(var(--destructive-foreground))",
|
||||||
|
},
|
||||||
|
border: "hsl(var(--border))",
|
||||||
|
input: "hsl(var(--input))",
|
||||||
|
ring: "hsl(var(--ring))",
|
||||||
|
chart: {
|
||||||
|
1: "hsl(var(--chart-1))",
|
||||||
|
2: "hsl(var(--chart-2))",
|
||||||
|
3: "hsl(var(--chart-3))",
|
||||||
|
4: "hsl(var(--chart-4))",
|
||||||
|
5: "hsl(var(--chart-5))",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [require("tailwindcss-animate"), require("tailwind-scrollbar")],
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ES2022",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src", "agent"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import path from "path";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user