diff --git a/.github/workflows/deploy-langgraph.yml b/.github/workflows/deploy-langgraph.yml new file mode 100644 index 0000000..f2539b6 --- /dev/null +++ b/.github/workflows/deploy-langgraph.yml @@ -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 }} diff --git a/langgraph/.codespellignore b/langgraph/.codespellignore new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/langgraph/.codespellignore @@ -0,0 +1 @@ + diff --git a/langgraph/.dockerignore b/langgraph/.dockerignore new file mode 100644 index 0000000..af27966 --- /dev/null +++ b/langgraph/.dockerignore @@ -0,0 +1,6 @@ +node_modules +.git +dist +.env +*.local +.DS_Store diff --git a/langgraph/.gitignore b/langgraph/.gitignore new file mode 100644 index 0000000..ec4d0f6 --- /dev/null +++ b/langgraph/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.env +*.local +.DS_Store diff --git a/langgraph/.prettierrc b/langgraph/.prettierrc new file mode 100644 index 0000000..222861c --- /dev/null +++ b/langgraph/.prettierrc @@ -0,0 +1,4 @@ +{ + "tabWidth": 2, + "useTabs": false +} diff --git a/langgraph/Dockerfile b/langgraph/Dockerfile new file mode 100644 index 0000000..48f569f --- /dev/null +++ b/langgraph/Dockerfile @@ -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"] diff --git a/langgraph/LICENSE b/langgraph/LICENSE new file mode 100644 index 0000000..19b767d --- /dev/null +++ b/langgraph/LICENSE @@ -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. diff --git a/langgraph/README.md b/langgraph/README.md new file mode 100644 index 0000000..9fe6e3c --- /dev/null +++ b/langgraph/README.md @@ -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)). + +![Generative UI Example](./static/gen_ui.gif) + +# 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 ` - Will trigger a generative UI travel agent which renders a UI to select accommodations. + - `Recommend some restaurants for me in ` - Will trigger a generative UI travel agent which renders a UI to select restaurants. + - `What's the current price of ` - Will trigger a generative UI stockbroker agent which renders the current price of the stock. + - `I want to buy shares of .` - 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 in ` - 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 about ` - 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 ` - Will trigger a generative UI stockbroker agent which renders the current price of the stock. +- `I want to buy shares of .` - 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 ` - Will trigger a generative UI travel agent which renders a UI to select accommodations. +- `Recommend some restaurants for me in ` - 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 in ` + +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 about ` + +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 ` + +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. diff --git a/langgraph/components.json b/langgraph/components.json new file mode 100644 index 0000000..6296a74 --- /dev/null +++ b/langgraph/components.json @@ -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" +} diff --git a/langgraph/eslint.config.js b/langgraph/eslint.config.js new file mode 100644 index 0000000..af6a2b5 --- /dev/null +++ b/langgraph/eslint.config.js @@ -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 }, + ], + }, + }, +); diff --git a/langgraph/index.html b/langgraph/index.html new file mode 100644 index 0000000..2aa4ced --- /dev/null +++ b/langgraph/index.html @@ -0,0 +1,13 @@ + + + + + + Chat LangGraph + + + +
+ + + diff --git a/langgraph/langgraph.json b/langgraph/langgraph.json new file mode 100644 index 0000000..e7f0bba --- /dev/null +++ b/langgraph/langgraph.json @@ -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": ["."] +} diff --git a/langgraph/package.json b/langgraph/package.json new file mode 100644 index 0000000..23dc00b --- /dev/null +++ b/langgraph/package.json @@ -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" +} diff --git a/langgraph/src/agent-uis/index.tsx b/langgraph/src/agent-uis/index.tsx new file mode 100644 index 0000000..f830d3a --- /dev/null +++ b/langgraph/src/agent-uis/index.tsx @@ -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; diff --git a/langgraph/src/agent-uis/open-code/plan/index.css b/langgraph/src/agent-uis/open-code/plan/index.css new file mode 100644 index 0000000..f1d8c73 --- /dev/null +++ b/langgraph/src/agent-uis/open-code/plan/index.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/langgraph/src/agent-uis/open-code/plan/index.tsx b/langgraph/src/agent-uis/open-code/plan/index.tsx new file mode 100644 index 0000000..cbfe51e --- /dev/null +++ b/langgraph/src/agent-uis/open-code/plan/index.tsx @@ -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 ( +
+
+

Code Plan

+
+ +
+
+

+ Remaining Plans +

+ {props.remainingPlans.map((step, index) => ( +

+ {index + 1}. {step} +

+ ))} +
+
+

+ Executed Plans +

+ {props.executedPlans.map((step, index) => ( +

+ {step} +

+ ))} +
+
+

+ Rejected Plans +

+ {props.rejectedPlans.map((step, index) => ( +

+ {step} +

+ ))} +
+
+
+ setIsExpanded(!isExpanded)} + > + + + + +
+ ); +} diff --git a/langgraph/src/agent-uis/open-code/proposed-change/index.css b/langgraph/src/agent-uis/open-code/proposed-change/index.css new file mode 100644 index 0000000..e69504a --- /dev/null +++ b/langgraph/src/agent-uis/open-code/proposed-change/index.css @@ -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; + } +} diff --git a/langgraph/src/agent-uis/open-code/proposed-change/index.tsx b/langgraph/src/agent-uis/open-code/proposed-change/index.tsx new file mode 100644 index 0000000..302960a --- /dev/null +++ b/langgraph/src/agent-uis/open-code/proposed-change/index.tsx @@ -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 ( + <> +
setOpen((open) => !open)} + > +
+

+ {isAccepted ? "Accepted" : "Rejected"} Change +

+

{props.planItem}

+
+
+ + + ) : ( + {children} + ); + }, + }} + /> + + + ); + } + + return ( + <> +
setOpen((open) => !open)} + > +
+

Proposed Change

+

{props.planItem}

+
+
+ + + ) : ( + {children} + ); + }, + }} + /> + {!props.fullWriteAccess && ( +
+ + + +
+ )} +
+ + ); +} diff --git a/langgraph/src/agent-uis/stockbroker/buy-stock/index.css b/langgraph/src/agent-uis/stockbroker/buy-stock/index.css new file mode 100644 index 0000000..f1d8c73 --- /dev/null +++ b/langgraph/src/agent-uis/stockbroker/buy-stock/index.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/langgraph/src/agent-uis/stockbroker/buy-stock/index.tsx b/langgraph/src/agent-uis/stockbroker/buy-stock/index.tsx new file mode 100644 index 0000000..2748e8b --- /dev/null +++ b/langgraph/src/agent-uis/stockbroker/buy-stock/index.tsx @@ -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 ( +
+

Purchase Executed - {ticker}

+
+
+

Number of Shares

+

Market Price

+

Total Cost

+
+
+

{quantity}

+

${price}

+

${(quantity * price).toFixed(2)}

+
+
+
+ ); +} + +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 ; + } + + return ( +
+

Buy {snapshot.ticker}

+
+
+

Number of Shares

+

Market Price

+

Total Cost

+
+
+ setQuantity(Number(e.target.value))} + min={1} + /> +

${snapshot.price}

+

${(quantity * snapshot.price).toFixed(2)}

+
+
+ +
+ ); +} diff --git a/langgraph/src/agent-uis/stockbroker/portfolio-view/index.css b/langgraph/src/agent-uis/stockbroker/portfolio-view/index.css new file mode 100644 index 0000000..f1d8c73 --- /dev/null +++ b/langgraph/src/agent-uis/stockbroker/portfolio-view/index.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/langgraph/src/agent-uis/stockbroker/stock-price/index.css b/langgraph/src/agent-uis/stockbroker/stock-price/index.css new file mode 100644 index 0000000..f1d8c73 --- /dev/null +++ b/langgraph/src/agent-uis/stockbroker/stock-price/index.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/langgraph/src/agent-uis/stockbroker/stock-price/index.tsx b/langgraph/src/agent-uis/stockbroker/stock-price/index.tsx new file mode 100644 index 0000000..5ce8702 --- /dev/null +++ b/langgraph/src/agent-uis/stockbroker/stock-price/index.tsx @@ -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 ( +
+ +

|

+ +

|

+ +
+ ); +} + +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("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 ( +
+
+

{ticker}

+

${currentPrice}

+
+
+

+ ${dollarChange.toFixed(2)} (${percentChange.toFixed(2)}%) +

+
+
+
+

Open

+

High

+

Low

+
+
+

${openPrice}

+

${highPrice}

+

${lowPrice}

+
+
+ + + + + formatDateByDisplayRange(v)} + /> + `${value.toFixed(2)}`} + /> + formatDateByDisplayRange(v, true)} + /> + } + /> + + + +
+ ); +} diff --git a/langgraph/src/agent-uis/trip-planner/accommodations-list/index.css b/langgraph/src/agent-uis/trip-planner/accommodations-list/index.css new file mode 100644 index 0000000..f1d8c73 --- /dev/null +++ b/langgraph/src/agent-uis/trip-planner/accommodations-list/index.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/langgraph/src/agent-uis/trip-planner/accommodations-list/index.tsx b/langgraph/src/agent-uis/trip-planner/accommodations-list/index.tsx new file mode 100644 index 0000000..f864425 --- /dev/null +++ b/langgraph/src/agent-uis/trip-planner/accommodations-list/index.tsx @@ -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 }) => ( + + + +); + +function AccommodationCard({ + accommodation, +}: { + accommodation: Accommodation; +}) { + return ( +
+
+

{accommodation.name}

+
+

+ + {accommodation.rating} +

+

ยท

+

{accommodation.price}

+
+

{capitalizeSentence(accommodation.city)}

+
+
+ ); +} + +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 ( +
+
+ {accommodation.name} +
+
+
+

{accommodation.name}

+ +
+
+
+ + + {accommodation.rating} + +

+ {capitalizeSentence(accommodation.city)} +

+
+
+
+ Check-in + {format(startDate, "MMM d, yyyy")} +
+
+ Check-out + {format(endDate, "MMM d, yyyy")} +
+
+ Guests + {tripDetails.numberOfGuests} +
+
+ Total Price + ${totalPrice.toLocaleString()} +
+
+
+ +
+
+ ); +} + +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 ( +
+
+

Booked Accommodation

+ +
+

+
+
+
+ Address: +
+
+ + {accommodation.name}, {capitalizeSentence(accommodation.city)} + +
+ +
+ Rating: +
+
+ + + {accommodation.rating} + +
+ +
+ Dates: +
+
+ + {format(startDate, "MMM d, yyyy")} -{" "} + {format(endDate, "MMM d, yyyy")} + +
+ +
+ Guests: +
+
+ {tripDetails.numberOfGuests} +
+ +
+ Total Price: +
+
+ ${totalPrice.toLocaleString()} +
+
+
+
+ ); +} + +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 ( + + ); + } else if (accommodationBooked) { + return
Successfully booked accommodation!
; + } + + if (selectedAccommodation) { + return ( + setSelectedAccommodation(undefined)} + accommodation={selectedAccommodation} + onBook={handleBookAccommodation} + /> + ); + } + + return ( +
+ + + {accommodations.map((accommodation) => ( + setSelectedAccommodation(accommodation)} + > + + + ))} + + + + +
+ ); +} diff --git a/langgraph/src/agent-uis/trip-planner/restaurants-list/index.css b/langgraph/src/agent-uis/trip-planner/restaurants-list/index.css new file mode 100644 index 0000000..f1d8c73 --- /dev/null +++ b/langgraph/src/agent-uis/trip-planner/restaurants-list/index.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/langgraph/src/agent-uis/trip-planner/restaurants-list/index.tsx b/langgraph/src/agent-uis/trip-planner/restaurants-list/index.tsx new file mode 100644 index 0000000..3289825 --- /dev/null +++ b/langgraph/src/agent-uis/trip-planner/restaurants-list/index.tsx @@ -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(null); + const [filter, setFilter] = useState(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 ( +
+
+
+

+ Restaurants in {tripDetails.location} +

+ {selectedId && ( + + )} +
+

+ For your trip {new Date(tripDetails.startDate).toLocaleDateString()} -{" "} + {new Date(tripDetails.endDate).toLocaleDateString()} +

+
+ + {!selectedId ? ( +
+
+
+ + {cuisines.map((cuisine) => ( + + ))} +
+

+ Showing {filteredRestaurants.length} restaurants{" "} + {filter ? `in ${filter}` : ""} +

+
+ +
+ {filteredRestaurants.map((restaurant) => ( +
setSelectedId(restaurant.id)} + className="border rounded-lg p-3 cursor-pointer hover:border-orange-300 hover:shadow-md transition-all" + > +
+
+ {restaurant.name} +
+
+
+
+

+ {restaurant.name} +

+

+ {restaurant.cuisine} +

+
+ + {restaurant.priceRange} + +
+
+ + + + + {restaurant.rating} + +
+
+ + {restaurant.distance} + + {restaurant.popular && ( + + Popular + + )} +
+
+
+
+ ))} +
+
+ ) : ( +
+ {selectedRestaurant && ( +
+
+ {selectedRestaurant.name} +
+ +
+
+
+

+ {selectedRestaurant.name} +

+

+ {selectedRestaurant.cuisine} +

+
+ + {selectedRestaurant.priceRange} + +
+ +
+ + + + + {selectedRestaurant.rating} rating + +
+ +
+ {selectedRestaurant.distance} + โ€ข + {selectedRestaurant.openingHours} +
+ +

+ {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. +

+ +
+ + +
+
+
+ )} +
+ )} +
+ ); +} diff --git a/langgraph/src/agent-uis/utils/get-tool-response.ts b/langgraph/src/agent-uis/utils/get-tool-response.ts new file mode 100644 index 0000000..be5191c --- /dev/null +++ b/langgraph/src/agent-uis/utils/get-tool-response.ts @@ -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; +} diff --git a/langgraph/src/agent-uis/utils/use-artifact.tsx b/langgraph/src/agent-uis/utils/use-artifact.tsx new file mode 100644 index 0000000..2a1ed86 --- /dev/null +++ b/langgraph/src/agent-uis/utils/use-artifact.tsx @@ -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>() { + 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; +} diff --git a/langgraph/src/agent-uis/writer/index.tsx b/langgraph/src/agent-uis/writer/index.tsx new file mode 100644 index 0000000..b2e952f --- /dev/null +++ b/langgraph/src/agent-uis/writer/index.tsx @@ -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 ( + <> +
setOpen(!open)} + className="border p-4 rounded-lg cursor-pointer" + > +

{props.title}

+

{props.description}

+ + {props.isGenerating && ( +

+ + Generating... +

+ )} +
+ + +