├── .gitignore ├── README.md ├── backend ├── .env.example ├── LICENSE ├── Makefile ├── README.md ├── gen_ui_backend │ ├── __init__.py │ ├── __pycache__ │ │ ├── __init__.cpython-310.pyc │ │ ├── chain.cpython-310.pyc │ │ ├── server.cpython-310.pyc │ │ └── types.cpython-310.pyc │ ├── chain.py │ ├── charts │ │ ├── chain.py │ │ └── schema.py │ ├── py.typed │ ├── requirements.txt │ ├── server.py │ ├── tools │ │ ├── __pycache__ │ │ │ ├── github.cpython-310.pyc │ │ │ ├── invoice.cpython-310.pyc │ │ │ └── weather.cpython-310.pyc │ │ ├── github.py │ │ ├── invoice.py │ │ └── weather.py │ └── types.py ├── langgraph.json ├── poetry.lock ├── pyproject.toml └── scripts │ ├── check_imports.py │ ├── check_pydantic.sh │ └── lint_imports.sh └── frontend ├── .eslintrc.json ├── .gitignore ├── ai └── message.tsx ├── app ├── agent.tsx ├── charts │ ├── README.md │ ├── agent.tsx │ ├── filters.tsx │ ├── generate-orders.ts │ ├── layout.tsx │ ├── page.tsx │ └── schema.ts ├── favicon.ico ├── globals.css ├── layout.tsx ├── page.tsx └── shared.tsx ├── components.json ├── components ├── prebuilt │ ├── chat.tsx │ ├── display-types-dialog.tsx │ ├── filter-options-dialog.tsx │ ├── filter.tsx │ ├── github.tsx │ ├── invoice.tsx │ ├── loading-charts.tsx │ ├── message.tsx │ └── weather.tsx └── ui │ ├── avatar.tsx │ ├── badge.tsx │ ├── button.tsx │ ├── calendar.tsx │ ├── card.tsx │ ├── dialog.tsx │ ├── dropdown-menu.tsx │ ├── input.tsx │ ├── label.tsx │ ├── pagination.tsx │ ├── popover.tsx │ ├── progress.tsx │ ├── resizable.tsx │ ├── scroll-area.tsx │ ├── select.tsx │ ├── separator.tsx │ ├── skeleton.tsx │ ├── switch.tsx │ ├── tabs.tsx │ ├── textarea.tsx │ └── tooltip.tsx ├── lib ├── mui.ts └── utils.ts ├── next.config.mjs ├── package.json ├── postcss.config.mjs ├── public ├── gen_ui_charts_diagram.png ├── gen_ui_diagram.png ├── next.svg └── vercel.svg ├── tailwind.config.ts ├── tsconfig.json ├── utils ├── client.tsx └── server.tsx └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .mypy_cache -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Generative UI with LangChain Python 🦜🔗 2 | 3 | ![Generative UI with LangChain Python](./frontend/public/gen_ui_diagram.png) 4 | 5 | ## Overview 6 | 7 | This application aims to provide a template for building generative UI applications with LangChain Python. 8 | It comes pre-built with a few UI features which you can use to play about with gen ui. The UI components are built using [Shadcn](https://ui.shadcn.com/). 9 | 10 | ## Getting Started 11 | 12 | ### Installation 13 | 14 | First, clone the repository and install dependencies: 15 | 16 | ```bash 17 | git clone https://github.com/bracesproul/gen-ui-python.git 18 | 19 | cd gen-ui-python 20 | ``` 21 | 22 | Install dependencies in the `frontend` and `backend` directories: 23 | 24 | ```bash 25 | cd ./frontend 26 | 27 | yarn install 28 | ``` 29 | 30 | ```bash 31 | cd ../backend 32 | 33 | poetry install 34 | ``` 35 | 36 | ### Secrets 37 | 38 | Next, if you plan on using the existing pre-built UI components, you'll need to set a few environment variables: 39 | 40 | Copy the [`.env.example`](./backend/.env.example) file to `.env` inside the `backend` directory. 41 | 42 | LangSmith keys are optional, but highly recommended if you plan on developing this application further. 43 | 44 | The `OPENAI_API_KEY` is required. Get your OpenAI API key from the [OpenAI dashboard](https://platform.openai.com/login?launch). 45 | 46 | [Sign up/in to LangSmith](https://smith.langchain.com/) and get your API key. 47 | 48 | Create a new [GitHub PAT (Personal Access Token)](https://github.com/settings/tokens/new) with the `repo` scope. 49 | 50 | [Create a free Geocode account](https://geocode.xyz/api). 51 | 52 | ```bash 53 | # ------------------LangSmith tracing------------------ 54 | LANGCHAIN_API_KEY=... 55 | LANGCHAIN_CALLBACKS_BACKGROUND=true 56 | LANGCHAIN_TRACING_V2=true 57 | # ----------------------------------------------------- 58 | 59 | GITHUB_TOKEN=... 60 | OPENAI_API_KEY=... 61 | GEOCODE_API_KEY=... 62 | ``` 63 | 64 | ### Running the Application 65 | 66 | ```bash 67 | cd ./frontend 68 | 69 | yarn dev 70 | ``` 71 | 72 | This will start a development server on [`http://localhost:3000`](http://localhost:3000). 73 | 74 | Then, in a new terminal window: 75 | 76 | ```bash 77 | cd ../backend 78 | 79 | poetry run start 80 | ``` 81 | 82 | ### Go further 83 | 84 | If you're interested in ways to take this demo application further, I'd consider the following: 85 | 86 | - Generating entire React components to be rendered, instead of relying on pre-built components. 87 | - Using the LLM to build custom components using a UI library like [Shadcn](https://ui.shadcn.com/). 88 | - Multi-tool and component usage. 89 | - Update the LangGraph agent to call multiple tools, and appending multiple different UI components to the client rendered UI. 90 | - Generative UI outside of the chatbot window: Have the UI dynamically render in different areas on the screen. E.g a dashboard, where the components are dynamically rendered based on the LLMs output. 91 | -------------------------------------------------------------------------------- /backend/.env.example: -------------------------------------------------------------------------------- 1 | # ------------------LangSmith tracing------------------ 2 | LANGCHAIN_API_KEY= 3 | LANGCHAIN_CALLBACKS_BACKGROUND=true 4 | LANGCHAIN_TRACING_V2=true 5 | # ----------------------------------------------------- 6 | 7 | GITHUB_TOKEN= 8 | OPENAI_API_KEY= 9 | GEOCODE_API_KEY= -------------------------------------------------------------------------------- /backend/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 LangChain, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /backend/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all format lint test tests integration_tests docker_tests help extended_tests 2 | 3 | # Default target executed when no arguments are given to make. 4 | all: help 5 | 6 | # Define a variable for the test file path. 7 | TEST_FILE ?= tests/unit_tests/ 8 | integration_test integration_tests: TEST_FILE=tests/integration_tests/ 9 | 10 | test tests integration_test integration_tests: 11 | poetry run pytest $(TEST_FILE) 12 | 13 | 14 | ###################### 15 | # LINTING AND FORMATTING 16 | ###################### 17 | 18 | # Define a variable for Python and notebook files. 19 | PYTHON_FILES=. 20 | MYPY_CACHE=.mypy_cache 21 | lint format: PYTHON_FILES=. 22 | lint_diff format_diff: PYTHON_FILES=$(shell git diff --relative=libs/partners/mongo-rag-cli --name-only --diff-filter=d master | grep -E '\.py$$|\.ipynb$$') 23 | lint_package: PYTHON_FILES=gen_ui_backend 24 | lint_tests: PYTHON_FILES=tests 25 | lint_tests: MYPY_CACHE=.mypy_cache_test 26 | 27 | lint lint_diff lint_package lint_tests: 28 | poetry run ruff . 29 | poetry run ruff format $(PYTHON_FILES) --diff 30 | poetry run ruff --select I $(PYTHON_FILES) 31 | mkdir $(MYPY_CACHE); poetry run mypy $(PYTHON_FILES) --cache-dir $(MYPY_CACHE) 32 | 33 | format format_diff: 34 | poetry run ruff format $(PYTHON_FILES) 35 | poetry run ruff --select I --fix $(PYTHON_FILES) 36 | 37 | spell_check: 38 | poetry run codespell --toml pyproject.toml 39 | 40 | spell_fix: 41 | poetry run codespell --toml pyproject.toml -w 42 | 43 | check_imports: $(shell find gen_ui_backend -name '*.py') 44 | poetry run python ./scripts/check_imports.py $^ 45 | 46 | ###################### 47 | # HELP 48 | ###################### 49 | 50 | help: 51 | @echo '----' 52 | @echo 'check_imports - check imports' 53 | @echo 'format - run code formatters' 54 | @echo 'lint - run linters' 55 | @echo 'test - run unit tests' 56 | @echo 'tests - run unit tests' 57 | @echo 'test TEST_FILE= - run all tests in file' 58 | -------------------------------------------------------------------------------- /backend/README.md: -------------------------------------------------------------------------------- 1 | # Generative UI - Backend -------------------------------------------------------------------------------- /backend/gen_ui_backend/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bracesproul/gen-ui-python/4e9a04c2fd3581f9f400e3d151127166d8300728/backend/gen_ui_backend/__init__.py -------------------------------------------------------------------------------- /backend/gen_ui_backend/__pycache__/__init__.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bracesproul/gen-ui-python/4e9a04c2fd3581f9f400e3d151127166d8300728/backend/gen_ui_backend/__pycache__/__init__.cpython-310.pyc -------------------------------------------------------------------------------- /backend/gen_ui_backend/__pycache__/chain.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bracesproul/gen-ui-python/4e9a04c2fd3581f9f400e3d151127166d8300728/backend/gen_ui_backend/__pycache__/chain.cpython-310.pyc -------------------------------------------------------------------------------- /backend/gen_ui_backend/__pycache__/server.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bracesproul/gen-ui-python/4e9a04c2fd3581f9f400e3d151127166d8300728/backend/gen_ui_backend/__pycache__/server.cpython-310.pyc -------------------------------------------------------------------------------- /backend/gen_ui_backend/__pycache__/types.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bracesproul/gen-ui-python/4e9a04c2fd3581f9f400e3d151127166d8300728/backend/gen_ui_backend/__pycache__/types.cpython-310.pyc -------------------------------------------------------------------------------- /backend/gen_ui_backend/chain.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional, TypedDict 2 | 3 | from langchain.output_parsers.openai_tools import JsonOutputToolsParser 4 | from langchain_core.messages import AIMessage, HumanMessage 5 | from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder 6 | from langchain_core.runnables import RunnableConfig 7 | from langchain_openai import ChatOpenAI 8 | from langgraph.graph import END, StateGraph 9 | from langgraph.graph.graph import CompiledGraph 10 | 11 | from gen_ui_backend.tools.github import github_repo 12 | from gen_ui_backend.tools.invoice import invoice_parser 13 | from gen_ui_backend.tools.weather import weather_data 14 | 15 | 16 | class GenerativeUIState(TypedDict, total=False): 17 | input: HumanMessage 18 | result: Optional[str] 19 | """Plain text response if no tool was used.""" 20 | tool_calls: Optional[List[dict]] 21 | """A list of parsed tool calls.""" 22 | tool_result: Optional[dict] 23 | """The result of a tool call.""" 24 | 25 | 26 | def invoke_model(state: GenerativeUIState, config: RunnableConfig) -> GenerativeUIState: 27 | tools_parser = JsonOutputToolsParser() 28 | initial_prompt = ChatPromptTemplate.from_messages( 29 | [ 30 | ( 31 | "system", 32 | "You are a helpful assistant. You're provided a list of tools, and an input from the user.\n" 33 | + "Your job is to determine whether or not you have a tool which can handle the users input, or respond with plain text.", 34 | ), 35 | MessagesPlaceholder("input"), 36 | ] 37 | ) 38 | model = ChatOpenAI(model="gpt-4o", temperature=0, streaming=True) 39 | tools = [github_repo, invoice_parser, weather_data] 40 | model_with_tools = model.bind_tools(tools) 41 | chain = initial_prompt | model_with_tools 42 | result = chain.invoke({"input": state["input"]}, config) 43 | 44 | if not isinstance(result, AIMessage): 45 | raise ValueError("Invalid result from model. Expected AIMessage.") 46 | 47 | if isinstance(result.tool_calls, list) and len(result.tool_calls) > 0: 48 | parsed_tools = tools_parser.invoke(result, config) 49 | return {"tool_calls": parsed_tools} 50 | else: 51 | return {"result": str(result.content)} 52 | 53 | 54 | def invoke_tools_or_return(state: GenerativeUIState) -> str: 55 | if "result" in state and isinstance(state["result"], str): 56 | return END 57 | elif "tool_calls" in state and isinstance(state["tool_calls"], list): 58 | return "invoke_tools" 59 | else: 60 | raise ValueError("Invalid state. No result or tool calls found.") 61 | 62 | 63 | def invoke_tools(state: GenerativeUIState) -> GenerativeUIState: 64 | tools_map = { 65 | "github-repo": github_repo, 66 | "invoice-parser": invoice_parser, 67 | "weather-data": weather_data, 68 | } 69 | 70 | if state["tool_calls"] is not None: 71 | tool = state["tool_calls"][0] 72 | selected_tool = tools_map[tool["type"]] 73 | return {"tool_result": selected_tool.invoke(tool["args"])} 74 | else: 75 | raise ValueError("No tool calls found in state.") 76 | 77 | 78 | def create_graph() -> CompiledGraph: 79 | workflow = StateGraph(GenerativeUIState) 80 | 81 | workflow.add_node("invoke_model", invoke_model) # type: ignore 82 | workflow.add_node("invoke_tools", invoke_tools) 83 | workflow.add_conditional_edges("invoke_model", invoke_tools_or_return) 84 | workflow.set_entry_point("invoke_model") 85 | workflow.set_finish_point("invoke_tools") 86 | 87 | graph = workflow.compile() 88 | return graph 89 | -------------------------------------------------------------------------------- /backend/gen_ui_backend/charts/chain.py: -------------------------------------------------------------------------------- 1 | from typing import List, Literal, Optional, TypedDict 2 | 3 | from langchain_core.messages import HumanMessage 4 | from langchain_core.prompts import ChatPromptTemplate 5 | from langchain_core.pydantic_v1 import BaseModel, Field 6 | from langchain_openai import ChatOpenAI 7 | from langgraph.graph import StateGraph 8 | from langgraph.graph.graph import CompiledGraph 9 | 10 | from gen_ui_backend.charts.schema import ( 11 | ChartType, 12 | DataDisplayTypeAndDescription, 13 | Filter, 14 | Order, 15 | filter_schema, 16 | ) 17 | 18 | 19 | class AgentExecutorState(TypedDict, total=False): 20 | input: HumanMessage 21 | """The user input""" 22 | display_formats: List[DataDisplayTypeAndDescription] 23 | """The types of display formats available for the chart.""" 24 | orders: List[Order] 25 | """List of orders to process.""" 26 | selected_filters: Optional[List[Filter]] 27 | """The filters generated by the LLM to apply to the orders.""" 28 | chart_type: Optional[ChartType] 29 | """The type of chart which this format can be displayed on.""" 30 | display_format: Optional[str] 31 | """The format to display the data in.""" 32 | props: Optional[dict] 33 | """The props to pass to the chart component.""" 34 | 35 | 36 | def format_data_display_types_and_descriptions( 37 | data_display_types_and_descriptions: List[DataDisplayTypeAndDescription], 38 | selected_chart_type: Optional[ChartType] = None, 39 | ) -> List[str]: 40 | return [ 41 | f"Key: {item['key']}. Title: {item['title']}. Chart type: {item['chartType']}. Description: {item['description']}" 42 | for item in data_display_types_and_descriptions 43 | if selected_chart_type is None or item["chartType"] == selected_chart_type 44 | ] 45 | 46 | 47 | def generate_filters(state: AgentExecutorState) -> AgentExecutorState: 48 | prompt = ChatPromptTemplate.from_messages( 49 | [ 50 | ( 51 | "system", 52 | """You are a helpful assistant. Your task is to determine the proper filters to apply, give a user input. 53 | The user input is in response to a 'magic filter' prompt. They expect their natural language description of the filters 54 | to be converted into a structured query.""", 55 | ), 56 | ("human", "{input}"), 57 | ] 58 | ) 59 | unique_product_names: List[str] = list( 60 | set(order["productName"].lower() for order in state["orders"]) 61 | ) 62 | schema = filter_schema(unique_product_names) 63 | model = ChatOpenAI(model="gpt-4-turbo", temperature=0).with_structured_output( 64 | schema 65 | ) 66 | chain = prompt | model 67 | result = chain.invoke(input=state["input"]["content"]) 68 | 69 | return { 70 | "selected_filters": result, 71 | } 72 | 73 | 74 | def generate_chart_type(state: AgentExecutorState) -> AgentExecutorState: 75 | prompt = ChatPromptTemplate.from_messages( 76 | [ 77 | ( 78 | "system", 79 | """You are an expert data analyst. Your task is to determine the best type of chart to display the data based on the filters and user input. 80 | You are provided with three chart types: 'bar', 'line', and 'pie'. 81 | The data which is being filtered is a set of orders from an online store. 82 | The user has submitted an input that describes the filters they'd like to apply to the data. 83 | 84 | Keep in mind that each chart type has set formats to display the data. You should consider the best display format when selecting your chart type. 85 | 86 | Data display types: {data_display_types_and_descriptions} 87 | 88 | Based on their input and the filters that have been generated, select the best type of chart to display the data.""", 89 | ), 90 | ( 91 | "human", 92 | """Magic filter input: {magic_filter_input} 93 | 94 | Generated filters: {selected_filters}""", 95 | ), 96 | ] 97 | ) 98 | 99 | class ChartTypeSchema(BaseModel): 100 | """Choose the best type of chart to display the data, based on the filters, user request, and ways to display the data on a given chart.""" 101 | 102 | chart_type: Literal["bar", "line", "pie"] = Field( 103 | ..., description="The type of chart to display the data." 104 | ) 105 | 106 | model = ChatOpenAI(model="gpt-4-turbo", temperature=0).with_structured_output( 107 | ChartTypeSchema 108 | ) 109 | chain = prompt | model 110 | result = chain.invoke( 111 | input={ 112 | "magic_filter_input": state["input"]["content"], 113 | "selected_filters": state["selected_filters"], 114 | "data_display_types_and_descriptions": format_data_display_types_and_descriptions( 115 | state["display_formats"] 116 | ), 117 | } 118 | ) 119 | 120 | return { 121 | "chart_type": result.chart_type, 122 | } 123 | 124 | 125 | def generate_data_display_format(state: AgentExecutorState) -> AgentExecutorState: 126 | prompt = ChatPromptTemplate.from_messages( 127 | [ 128 | ( 129 | "system", 130 | """You are an expert data analyst. Your task is to determine the best format to display the data based on the filters, chart type and user input. 131 | 132 | The type of chart which the data will be displayed on is: {chart_type}. 133 | 134 | This chart has the following formats of which it can display the data: {data_display_types_and_descriptions}. 135 | 136 | The user will provide you with their original input to the 'magic filter' prompt, and the filters which have been generated based on their input. 137 | You should use these inputs as context when making a decision on the best format to display the data. 138 | 139 | Select the best display format to show the data based on the filters, chart type and user input. You should always use the display type 'key' when selecting the format.""", 140 | ), 141 | ( 142 | "human", 143 | """Magic filter input: {magic_filter_input} 144 | 145 | Generated filters: {selected_filters}""", 146 | ), 147 | ] 148 | ) 149 | 150 | class DataDisplayFormatSchema(BaseModel): 151 | """Choose the best format to display the data based on the filters and chart type.""" 152 | 153 | display_key: str = Field( 154 | ..., 155 | description=f"The key of the format to display the data in. Must be one of {', '.join([item['key'] for item in state['display_formats'] if item['chartType'] == state['chart_type']])}", 156 | ) 157 | 158 | model = ChatOpenAI(model="gpt-4-turbo", temperature=0).with_structured_output( 159 | DataDisplayFormatSchema 160 | ) 161 | chain = prompt | model 162 | result = chain.invoke( 163 | input={ 164 | "chart_type": state["chart_type"], 165 | "magic_filter_input": state["input"]["content"], 166 | "selected_filters": state["selected_filters"], 167 | "data_display_types_and_descriptions": format_data_display_types_and_descriptions( 168 | state["display_formats"], state["chart_type"] 169 | ), 170 | } 171 | ) 172 | 173 | return { 174 | "display_format": result.display_key, 175 | } 176 | 177 | 178 | def filter_data(state: AgentExecutorState) -> AgentExecutorState: 179 | selected_filters = state["selected_filters"] 180 | orders = state["orders"] 181 | 182 | product_names = selected_filters.product_names 183 | before_date = selected_filters.before_date 184 | after_date = selected_filters.after_date 185 | min_amount = selected_filters.min_amount 186 | max_amount = selected_filters.max_amount 187 | order_state = selected_filters.state 188 | discount = selected_filters.discount 189 | min_discount_percentage = selected_filters.min_discount_percentage 190 | status = selected_filters.status 191 | 192 | if min_discount_percentage is not None and discount is False: 193 | raise ValueError( 194 | "Can not filter by min_discount_percentage when discount is False." 195 | ) 196 | 197 | filtered_orders = [] 198 | for order in orders: 199 | is_match = True 200 | 201 | if product_names and order.get("productName", "").lower() not in product_names: 202 | is_match = False 203 | if before_date and order.get("orderedAt", "") > before_date: 204 | is_match = False 205 | if after_date and order.get("orderedAt", "") < after_date: 206 | is_match = False 207 | if min_amount is not None and order.get("amount", 0) < min_amount: 208 | is_match = False 209 | if max_amount is not None and order.get("amount", 0) > max_amount: 210 | is_match = False 211 | if order_state: 212 | order_state_lower = order.get("address", {}).get("state", "").lower() 213 | if not any(state.lower() == order_state_lower for state in order_state): 214 | is_match = False 215 | if discount is not None: 216 | order_has_discount = "discount" in order and order["discount"] is not None 217 | if order_has_discount != discount: 218 | is_match = False 219 | if min_discount_percentage is not None: 220 | order_discount = order.get("discount") 221 | if order_discount is None or order_discount < min_discount_percentage: 222 | is_match = False 223 | if status: 224 | order_status_lower = order.get("status", "").lower() 225 | if not any(s.lower() == order_status_lower for s in status): 226 | is_match = False 227 | 228 | if is_match: 229 | filtered_orders.append(order) 230 | 231 | return {"orders": filtered_orders} 232 | 233 | 234 | def create_graph() -> CompiledGraph: 235 | workflow = StateGraph(AgentExecutorState) 236 | 237 | # Add nodes 238 | workflow.add_node("generate_filters", generate_filters) 239 | workflow.add_node("generate_chart_type", generate_chart_type) 240 | workflow.add_node("generate_data_display_format", generate_data_display_format) 241 | workflow.add_node("filter_data", filter_data) 242 | 243 | # Add edges 244 | workflow.add_edge("generate_filters", "generate_chart_type") 245 | workflow.add_edge("generate_chart_type", "generate_data_display_format") 246 | workflow.add_edge("generate_data_display_format", "filter_data") 247 | 248 | # Set entry and finish points 249 | workflow.set_entry_point("generate_filters") 250 | workflow.set_finish_point("filter_data") 251 | 252 | graph = workflow.compile() 253 | return graph 254 | 255 | 256 | graph = create_graph() 257 | -------------------------------------------------------------------------------- /backend/gen_ui_backend/charts/schema.py: -------------------------------------------------------------------------------- 1 | from typing import List, Literal, Optional, Type 2 | 3 | from langchain_core.pydantic_v1 import BaseModel, Field 4 | 5 | ChartType = Literal["bar", "line", "pie"] 6 | 7 | 8 | class Address(BaseModel): 9 | street: str = Field(..., description="The address street.", example="123 Main St") 10 | city: str = Field( 11 | ..., description="The city the order was shipped to.", example="San Francisco" 12 | ) 13 | state: str = Field( 14 | ..., description="The state the order was shipped to.", example="California" 15 | ) 16 | zip: str = Field( 17 | ..., description="The zip code the order was shipped to.", example="94105" 18 | ) 19 | 20 | 21 | class Order(BaseModel): 22 | """CamelCase is used here to match the schema used in the frontend.""" 23 | 24 | id: str = Field(..., description="A UUID for the order.") 25 | productName: str = Field(..., description="The name of the product purchased.") 26 | amount: float = Field(..., description="The amount of the order.") 27 | discount: Optional[float] = Field( 28 | None, 29 | description="The percentage of the discount applied to the order. This is between 0 and 100. Not defined if no discount was applied.", 30 | ) 31 | address: Address = Field(..., description="The address the order was shipped to.") 32 | status: str = Field( 33 | ..., 34 | description="The current status of the order.", 35 | enum=["pending", "processing", "shipped", "delivered", "cancelled", "returned"], 36 | ) 37 | orderedAt: str = Field( 38 | ..., description="The date the order was placed. Must be a valid date string." 39 | ) 40 | 41 | 42 | class Filter(BaseModel): 43 | product_names: Optional[List[str]] = Field( 44 | None, description="List of product names to filter by" 45 | ) 46 | before_date: Optional[str] = Field( 47 | None, description="Filter orders before this date. Must be a valid date string." 48 | ) 49 | after_date: Optional[str] = Field( 50 | None, description="Filter orders after this date. Must be a valid date string." 51 | ) 52 | min_amount: Optional[float] = Field(None, description="Minimum order amount") 53 | max_amount: Optional[float] = Field(None, description="Maximum order amount") 54 | state: Optional[str] = Field(None, description="State to filter by") 55 | city: Optional[str] = Field(None, description="City to filter by") 56 | discount: Optional[bool] = Field( 57 | None, description="Filter for orders with discounts" 58 | ) 59 | min_discount_percentage: Optional[float] = Field( 60 | None, description="Minimum discount percentage" 61 | ) 62 | status: Optional[str] = Field( 63 | None, 64 | description="Order status to filter by", 65 | enum=["pending", "processing", "shipped", "delivered", "cancelled", "returned"], 66 | ) 67 | 68 | 69 | def filter_schema(product_names: List[str]) -> Type[BaseModel]: 70 | product_names_as_string = ", ".join(name.lower() for name in product_names) 71 | 72 | class FilterSchema(BaseModel): 73 | """Available filters to apply to orders.""" 74 | 75 | product_names: Optional[List[str]] = Field( 76 | None, 77 | description=f"Filter orders by the product name. Lowercase only. MUST only be a list of the following products: {product_names_as_string}", 78 | ) 79 | before_date: Optional[str] = Field( 80 | None, 81 | description="Filter orders placed before this date. Must be a valid date in the format 'YYYY-MM-DD'", 82 | ) 83 | after_date: Optional[str] = Field( 84 | None, 85 | description="Filter orders placed after this date. Must be a valid date in the format 'YYYY-MM-DD'", 86 | ) 87 | min_amount: Optional[float] = Field( 88 | None, description="The minimum amount of the order to filter by." 89 | ) 90 | max_amount: Optional[float] = Field( 91 | None, description="The maximum amount of the order to filter by." 92 | ) 93 | state: Optional[List[str]] = Field( 94 | None, 95 | description="Filter orders by the state(s) the order was placed in. Example: ['California', 'New York']", 96 | ) 97 | discount: Optional[bool] = Field( 98 | None, 99 | description="Filter orders by whether or not it had a discount applied.", 100 | ) 101 | min_discount_percentage: Optional[float] = Field( 102 | None, 103 | ge=0, 104 | le=100, 105 | description="Filter orders which had at least this amount discounted (in percentage)", 106 | ) 107 | status: Optional[List[str]] = Field( 108 | None, 109 | description="The current status(es) of the order to filter by. This field should only be populated if a user mentions a specific status. If a specific status was not mentioned, do NOT populate this field. If populated, this field should ALWAYS be a list.", 110 | enum=[ 111 | "pending", 112 | "processing", 113 | "shipped", 114 | "delivered", 115 | "cancelled", 116 | "returned", 117 | ], 118 | ) 119 | 120 | return FilterSchema 121 | 122 | 123 | class DataDisplayTypeAndDescription(BaseModel): 124 | title: str = Field(..., description="The title of the data display type.") 125 | chartType: ChartType = Field( 126 | ..., description="The type of chart which this format can be displayed on." 127 | ) 128 | description: str = Field( 129 | ..., description="The description of the data display type." 130 | ) 131 | key: str = Field(..., description="The key of the data display type.") 132 | 133 | class Config: 134 | allow_population_by_field_name = True 135 | -------------------------------------------------------------------------------- /backend/gen_ui_backend/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bracesproul/gen-ui-python/4e9a04c2fd3581f9f400e3d151127166d8300728/backend/gen_ui_backend/py.typed -------------------------------------------------------------------------------- /backend/gen_ui_backend/requirements.txt: -------------------------------------------------------------------------------- 1 | langchain-core>=0.2.10,<0.3 2 | langchain==0.2.5 3 | langchain-openai==0.1.9 4 | python-dotenv==1.0.1 5 | langgraph==0.1.1 6 | pydantic>=1.10.13,<2 7 | langchain-community==0.2.5 8 | langchain-anthropic==0.1.16 -------------------------------------------------------------------------------- /backend/gen_ui_backend/server.py: -------------------------------------------------------------------------------- 1 | import uvicorn 2 | from dotenv import load_dotenv 3 | from fastapi import FastAPI 4 | from fastapi.middleware.cors import CORSMiddleware 5 | from langserve import add_routes 6 | 7 | from gen_ui_backend.chain import create_graph 8 | from gen_ui_backend.types import ChatInputType 9 | 10 | # Load environment variables from .env file 11 | load_dotenv() 12 | 13 | 14 | def start() -> None: 15 | app = FastAPI( 16 | title="Gen UI Backend", 17 | version="1.0", 18 | description="A simple api server using Langchain's Runnable interfaces", 19 | ) 20 | 21 | # Configure CORS 22 | origins = [ 23 | "http://localhost", 24 | "http://localhost:3000", 25 | ] 26 | 27 | app.add_middleware( 28 | CORSMiddleware, 29 | allow_origins=origins, 30 | allow_credentials=True, 31 | allow_methods=["*"], 32 | allow_headers=["*"], 33 | ) 34 | 35 | graph = create_graph() 36 | 37 | runnable = graph.with_types(input_type=ChatInputType, output_type=dict) 38 | 39 | add_routes(app, runnable, path="/chat", playground_type="chat") 40 | print("Starting server...") 41 | uvicorn.run(app, host="0.0.0.0", port=8000) 42 | -------------------------------------------------------------------------------- /backend/gen_ui_backend/tools/__pycache__/github.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bracesproul/gen-ui-python/4e9a04c2fd3581f9f400e3d151127166d8300728/backend/gen_ui_backend/tools/__pycache__/github.cpython-310.pyc -------------------------------------------------------------------------------- /backend/gen_ui_backend/tools/__pycache__/invoice.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bracesproul/gen-ui-python/4e9a04c2fd3581f9f400e3d151127166d8300728/backend/gen_ui_backend/tools/__pycache__/invoice.cpython-310.pyc -------------------------------------------------------------------------------- /backend/gen_ui_backend/tools/__pycache__/weather.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bracesproul/gen-ui-python/4e9a04c2fd3581f9f400e3d151127166d8300728/backend/gen_ui_backend/tools/__pycache__/weather.cpython-310.pyc -------------------------------------------------------------------------------- /backend/gen_ui_backend/tools/github.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Dict, Union 3 | 4 | import requests 5 | from langchain.pydantic_v1 import BaseModel, Field 6 | from langchain_core.tools import tool 7 | 8 | 9 | class GithubRepoInput(BaseModel): 10 | owner: str = Field(..., description="The name of the repository owner.") 11 | repo: str = Field(..., description="The name of the repository.") 12 | 13 | 14 | @tool("github-repo", args_schema=GithubRepoInput, return_direct=True) 15 | def github_repo(owner: str, repo: str) -> Union[Dict, str]: 16 | """Get information about a GitHub repository.""" 17 | if not os.environ.get("GITHUB_TOKEN"): 18 | raise ValueError("Missing GITHUB_TOKEN secret.") 19 | 20 | headers = { 21 | "Accept": "application/vnd.github+json", 22 | "Authorization": f"Bearer {os.environ['GITHUB_TOKEN']}", 23 | "X-GitHub-Api-Version": "2022-11-28", 24 | } 25 | 26 | url = f"https://api.github.com/repos/{owner}/{repo}" 27 | 28 | try: 29 | response = requests.get(url, headers=headers) 30 | response.raise_for_status() 31 | repo_data = response.json() 32 | return { 33 | "owner": owner, 34 | "repo": repo, 35 | "description": repo_data.get("description", ""), 36 | "stars": repo_data.get("stargazers_count", 0), 37 | "language": repo_data.get("language", ""), 38 | } 39 | except requests.exceptions.RequestException as err: 40 | print(err) 41 | return "There was an error fetching the repository. Please check the owner and repo names." 42 | -------------------------------------------------------------------------------- /backend/gen_ui_backend/tools/invoice.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | from uuid import uuid4 3 | 4 | from langchain.pydantic_v1 import BaseModel, Field 5 | from langchain_core.tools import tool 6 | 7 | 8 | class LineItem(BaseModel): 9 | id: str = Field( 10 | default_factory=uuid4, description="Unique identifier for the line item" 11 | ) 12 | name: str = Field(..., description="Name or description of the line item") 13 | quantity: int = Field(..., gt=0, description="Quantity of the line item") 14 | price: float = Field(..., gt=0, description="Price per unit of the line item") 15 | 16 | 17 | class ShippingAddress(BaseModel): 18 | name: str = Field(..., description="Name of the recipient") 19 | street: str = Field(..., description="Street address for shipping") 20 | city: str = Field(..., description="City for shipping") 21 | state: str = Field(..., description="State or province for shipping") 22 | zip: str = Field(..., description="ZIP or postal code for shipping") 23 | 24 | 25 | class CustomerInfo(BaseModel): 26 | name: str = Field(..., description="Name of the customer") 27 | email: str = Field(..., description="Email address of the customer") 28 | phone: Optional[str] = Field(None, description="Phone number of the customer") 29 | 30 | 31 | class PaymentInfo(BaseModel): 32 | cardType: str = Field(..., description="Type of credit card used for payment") 33 | cardNumberLastFour: str = Field( 34 | ..., description="Last four digits of the credit card number" 35 | ) 36 | 37 | 38 | class Invoice(BaseModel): 39 | """Parse an invoice and return it's values. This tool should ALWAYS be called if an image is provided.""" 40 | 41 | orderId: str = Field(..., description="The order ID") 42 | lineItems: List[LineItem] = Field( 43 | ..., description="List of line items in the invoice" 44 | ) 45 | shippingAddress: Optional[ShippingAddress] = Field( 46 | None, description="Shipping address for the order" 47 | ) 48 | customerInfo: Optional[CustomerInfo] = Field( 49 | None, description="Information about the customer" 50 | ) 51 | paymentInfo: Optional[PaymentInfo] = Field( 52 | None, description="Payment information for the order" 53 | ) 54 | 55 | 56 | @tool("invoice-parser", args_schema=Invoice, return_direct=True) 57 | def invoice_parser( 58 | orderId: str, 59 | lineItems: List[LineItem], 60 | shippingAddress: Optional[ShippingAddress], 61 | customerInfo: Optional[CustomerInfo], 62 | paymentInfo: Optional[PaymentInfo], 63 | ) -> Invoice: 64 | """Parse an invoice and return it without modification.""" 65 | return Invoice( 66 | orderId=orderId, 67 | lineItems=lineItems, 68 | shippingAddress=shippingAddress, 69 | customerInfo=customerInfo, 70 | paymentInfo=paymentInfo, 71 | ) 72 | -------------------------------------------------------------------------------- /backend/gen_ui_backend/tools/weather.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Optional 3 | 4 | import requests 5 | from langchain.pydantic_v1 import BaseModel, Field 6 | from langchain_core.tools import tool 7 | 8 | 9 | class WeatherInput(BaseModel): 10 | city: str = Field(..., description="The city name to get weather for") 11 | state: str = Field( 12 | ..., description="The two letter state abbreviation to get weather for" 13 | ) 14 | country: Optional[str] = Field( 15 | "usa", description="The two letter country abbreviation to get weather for" 16 | ) 17 | 18 | 19 | @tool("weather-data", args_schema=WeatherInput, return_direct=True) 20 | def weather_data(city: str, state: str, country: str = "usa") -> dict: 21 | """Get the current temperature for a city.""" 22 | geocode_api_key = os.environ.get("GEOCODE_API_KEY") 23 | if not geocode_api_key: 24 | raise ValueError("Missing GEOCODE_API_KEY secret.") 25 | 26 | geocode_url = f"https://geocode.xyz/{city.lower()},{state.lower()},{country.lower()}?json=1&auth={geocode_api_key}" 27 | geocode_response = requests.get(geocode_url) 28 | if not geocode_response.ok: 29 | print("No geocode data found.") 30 | raise ValueError("Failed to get geocode data.") 31 | geocode_data = geocode_response.json() 32 | latt = geocode_data["latt"] 33 | longt = geocode_data["longt"] 34 | 35 | weather_gov_url = f"https://api.weather.gov/points/{latt},{longt}" 36 | weather_gov_response = requests.get(weather_gov_url) 37 | if not weather_gov_response.ok: 38 | print("No weather data found.") 39 | raise ValueError("Failed to get weather data.") 40 | weather_gov_data = weather_gov_response.json() 41 | properties = weather_gov_data["properties"] 42 | 43 | forecast_url = properties["forecast"] 44 | forecast_response = requests.get(forecast_url) 45 | if not forecast_response.ok: 46 | print("No forecast data found.") 47 | raise ValueError("Failed to get forecast data.") 48 | forecast_data = forecast_response.json() 49 | periods = forecast_data["properties"]["periods"] 50 | today_forecast = periods[0] 51 | 52 | return { 53 | "city": city, 54 | "state": state, 55 | "country": country, 56 | "temperature": today_forecast["temperature"], 57 | } 58 | -------------------------------------------------------------------------------- /backend/gen_ui_backend/types.py: -------------------------------------------------------------------------------- 1 | from typing import List, Union 2 | 3 | from langchain_core.messages import AIMessage, HumanMessage, SystemMessage 4 | from langchain_core.pydantic_v1 import BaseModel 5 | 6 | 7 | class ChatInputType(BaseModel): 8 | input: List[Union[HumanMessage, AIMessage, SystemMessage]] 9 | -------------------------------------------------------------------------------- /backend/langgraph.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": ["./gen_ui_backend"], 3 | "graphs": { 4 | "gen_ui_charts": "./gen_ui_backend/charts/chain.py:graph" 5 | }, 6 | "env": ".env" 7 | } -------------------------------------------------------------------------------- /backend/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "gen-ui-backend" 3 | version = "0.0.0" 4 | description = "" 5 | authors = [] 6 | readme = "README.md" 7 | license = "MIT" 8 | 9 | [tool.poetry.dependencies] 10 | python = "<3.12,>=3.9.0" 11 | langchain-core = "^0.2.4" 12 | typer = ">=0.9.0,<0.10.0" 13 | langchain = "^0.2.2" 14 | langchain-openai = "^0.1.8" 15 | pymongo = "^4.6.3" 16 | python-dotenv = "^1.0.1" 17 | motor = "^3.4.0" 18 | langgraph = "^0.0.62" 19 | langserve = { version = "^0.2.1", extras = ["all"] } 20 | fastapi = ">=0.110.2,<1" 21 | uvicorn = ">=0.23.2,<0.24.0" 22 | pydantic = ">=1.10.13,<2" 23 | rich = "^13.7.1" 24 | langchain-community = "^0.2.3" 25 | unstructured = {extras = ["all-docs"], version = "^0.13.4"} 26 | langgraph-cli = "^0.1.46" 27 | langchain-anthropic = "^0.1.16" 28 | 29 | [tool.poetry.scripts] 30 | start = "gen_ui_backend.server:start" 31 | 32 | [tool.poetry.group.test] 33 | optional = true 34 | 35 | [tool.poetry.group.test.dependencies] 36 | pytest = "^7.3.0" 37 | freezegun = "^1.2.2" 38 | pytest-mock = "^3.10.0" 39 | syrupy = "^4.0.2" 40 | pytest-watcher = "^0.3.4" 41 | pytest-asyncio = "^0.21.1" 42 | 43 | [tool.poetry.group.codespell] 44 | optional = true 45 | 46 | [tool.poetry.group.codespell.dependencies] 47 | codespell = "^2.2.0" 48 | 49 | [tool.poetry.group.lint] 50 | optional = true 51 | 52 | [tool.poetry.group.lint.dependencies] 53 | ruff = "^0.1.5" 54 | 55 | [tool.poetry.group.typing.dependencies] 56 | mypy = "^1" 57 | 58 | [tool.poetry.group.test_integration] 59 | optional = true 60 | 61 | [tool.poetry.group.test_integration.dependencies] 62 | 63 | [tool.ruff] 64 | select = [ 65 | "E", # pycodestyle 66 | "F", # pyflakes 67 | "I", # isort 68 | ] 69 | ignore = ["E501"] 70 | 71 | [tool.mypy] 72 | disallow_untyped_defs = "True" 73 | 74 | [tool.coverage.run] 75 | omit = ["tests/*"] 76 | 77 | [build-system] 78 | requires = ["poetry-core>=1.0.0"] 79 | build-backend = "poetry.core.masonry.api" 80 | 81 | [tool.pytest.ini_options] 82 | # --strict-markers will raise errors on unknown marks. 83 | # https://docs.pytest.org/en/7.1.x/how-to/mark.html#raising-errors-on-unknown-marks 84 | # 85 | # https://docs.pytest.org/en/7.1.x/reference/reference.html 86 | # --strict-config any warnings encountered while parsing the `pytest` 87 | # section of the configuration file raise errors. 88 | # 89 | # https://github.com/tophat/syrupy 90 | # --snapshot-warn-unused Prints a warning on unused snapshots rather than fail the test suite. 91 | addopts = "--snapshot-warn-unused --strict-markers --strict-config --durations=5" 92 | # Registering custom markers. 93 | # https://docs.pytest.org/en/7.1.x/example/markers.html#registering-markers 94 | markers = [ 95 | "requires: mark tests as requiring a specific library", 96 | "asyncio: mark tests as requiring asyncio", 97 | "compile: mark placeholder test used to compile integration tests without running them", 98 | ] 99 | asyncio_mode = "auto" 100 | -------------------------------------------------------------------------------- /backend/scripts/check_imports.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import traceback 3 | from importlib.machinery import SourceFileLoader 4 | 5 | if __name__ == "__main__": 6 | files = sys.argv[1:] 7 | has_failure = False 8 | for file in files: 9 | try: 10 | SourceFileLoader("x", file).load_module() 11 | except Exception: 12 | has_faillure = True 13 | print(file) 14 | traceback.print_exc() 15 | print() 16 | 17 | sys.exit(1 if has_failure else 0) 18 | -------------------------------------------------------------------------------- /backend/scripts/check_pydantic.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # This script searches for lines starting with "import pydantic" or "from pydantic" 4 | # in tracked files within a Git repository. 5 | # 6 | # Usage: ./scripts/check_pydantic.sh /path/to/repository 7 | 8 | # Check if a path argument is provided 9 | if [ $# -ne 1 ]; then 10 | echo "Usage: $0 /path/to/repository" 11 | exit 1 12 | fi 13 | 14 | repository_path="$1" 15 | 16 | # Search for lines matching the pattern within the specified repository 17 | result=$(git -C "$repository_path" grep -E '^import pydantic|^from pydantic') 18 | 19 | # Check if any matching lines were found 20 | if [ -n "$result" ]; then 21 | echo "ERROR: The following lines need to be updated:" 22 | echo "$result" 23 | echo "Please replace the code with an import from langchain_core.pydantic_v1." 24 | echo "For example, replace 'from pydantic import BaseModel'" 25 | echo "with 'from langchain_core.pydantic_v1 import BaseModel'" 26 | exit 1 27 | fi 28 | -------------------------------------------------------------------------------- /backend/scripts/lint_imports.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eu 4 | 5 | # Initialize a variable to keep track of errors 6 | errors=0 7 | 8 | # make sure not importing from langchain or langchain_experimental 9 | git --no-pager grep '^from langchain\.' . && errors=$((errors+1)) 10 | git --no-pager grep '^from langchain_experimental\.' . && errors=$((errors+1)) 11 | 12 | # Decide on an exit status based on the errors 13 | if [ "$errors" -gt 0 ]; then 14 | exit 1 15 | else 16 | exit 0 17 | fi 18 | -------------------------------------------------------------------------------- /frontend/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | .env 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | -------------------------------------------------------------------------------- /frontend/ai/message.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { AIMessageText } from "@/components/prebuilt/message"; 4 | import { StreamableValue, useStreamableValue } from "ai/rsc"; 5 | 6 | export function AIMessage(props: { value: StreamableValue }) { 7 | const [data] = useStreamableValue(props.value); 8 | 9 | if (!data) { 10 | return null; 11 | } 12 | return ; 13 | } 14 | -------------------------------------------------------------------------------- /frontend/app/agent.tsx: -------------------------------------------------------------------------------- 1 | import { RemoteRunnable } from "@langchain/core/runnables/remote"; 2 | import { exposeEndpoints, streamRunnableUI } from "@/utils/server"; 3 | import "server-only"; 4 | import { StreamEvent } from "@langchain/core/tracers/log_stream"; 5 | import { EventHandlerFields } from "@/utils/server"; 6 | import { Github, GithubLoading } from "@/components/prebuilt/github"; 7 | import { InvoiceLoading, Invoice } from "@/components/prebuilt/invoice"; 8 | import { 9 | CurrentWeatherLoading, 10 | CurrentWeather, 11 | } from "@/components/prebuilt/weather"; 12 | import { createStreamableUI, createStreamableValue } from "ai/rsc"; 13 | import { AIMessage } from "@/ai/message"; 14 | 15 | const API_URL = "http://localhost:8000/chat"; 16 | 17 | type ToolComponent = { 18 | loading: (props?: any) => JSX.Element; 19 | final: (props?: any) => JSX.Element; 20 | }; 21 | 22 | type ToolComponentMap = { 23 | [tool: string]: ToolComponent; 24 | }; 25 | 26 | const TOOL_COMPONENT_MAP: ToolComponentMap = { 27 | "github-repo": { 28 | loading: (props?: any) => , 29 | final: (props?: any) => , 30 | }, 31 | "invoice-parser": { 32 | loading: (props?: any) => , 33 | final: (props?: any) => , 34 | }, 35 | "weather-data": { 36 | loading: (props?: any) => , 37 | final: (props?: any) => , 38 | }, 39 | }; 40 | 41 | async function agent(inputs: { 42 | input: string; 43 | chat_history: [role: string, content: string][]; 44 | file?: { 45 | base64: string; 46 | extension: string; 47 | }; 48 | }) { 49 | "use server"; 50 | const remoteRunnable = new RemoteRunnable({ 51 | url: API_URL, 52 | }); 53 | 54 | let selectedToolComponent: ToolComponent | null = null; 55 | let selectedToolUI: ReturnType | null = null; 56 | 57 | /** 58 | * Handles the 'invoke_model' event by checking for tool calls in the output. 59 | * If a tool call is found and no tool component is selected yet, it sets the 60 | * selected tool component based on the tool type and appends its loading state to the UI. 61 | * 62 | * @param output - The output object from the 'invoke_model' event 63 | */ 64 | const handleInvokeModelEvent = ( 65 | event: StreamEvent, 66 | fields: EventHandlerFields, 67 | ) => { 68 | const [type] = event.event.split("_").slice(2); 69 | if ( 70 | type !== "end" || 71 | !event.data.output || 72 | typeof event.data.output !== "object" || 73 | event.name !== "invoke_model" 74 | ) { 75 | return; 76 | } 77 | 78 | if ( 79 | "tool_calls" in event.data.output && 80 | event.data.output.tool_calls.length > 0 81 | ) { 82 | const toolCall = event.data.output.tool_calls[0]; 83 | if (!selectedToolComponent && !selectedToolUI) { 84 | selectedToolComponent = TOOL_COMPONENT_MAP[toolCall.type]; 85 | selectedToolUI = createStreamableUI(selectedToolComponent.loading()); 86 | fields.ui.append(selectedToolUI?.value); 87 | } 88 | } 89 | }; 90 | 91 | /** 92 | * Handles the 'invoke_tools' event by updating the selected tool's UI 93 | * with the final state and tool result data. 94 | * 95 | * @param output - The output object from the 'invoke_tools' event 96 | */ 97 | const handleInvokeToolsEvent = (event: StreamEvent) => { 98 | const [type] = event.event.split("_").slice(2); 99 | if ( 100 | type !== "end" || 101 | !event.data.output || 102 | typeof event.data.output !== "object" || 103 | event.name !== "invoke_tools" 104 | ) { 105 | return; 106 | } 107 | 108 | if (selectedToolUI && selectedToolComponent) { 109 | const toolData = event.data.output.tool_result; 110 | selectedToolUI.done(selectedToolComponent.final(toolData)); 111 | } 112 | }; 113 | 114 | /** 115 | * Handles the 'on_chat_model_stream' event by creating a new text stream 116 | * for the AI message if one doesn't exist for the current run ID. 117 | * It then appends the chunk content to the corresponding text stream. 118 | * 119 | * @param streamEvent - The stream event object 120 | * @param chunk - The chunk object containing the content 121 | */ 122 | const handleChatModelStreamEvent = ( 123 | event: StreamEvent, 124 | fields: EventHandlerFields, 125 | ) => { 126 | if ( 127 | event.event !== "on_chat_model_stream" || 128 | !event.data.chunk || 129 | typeof event.data.chunk !== "object" 130 | ) 131 | return; 132 | if (!fields.callbacks[event.run_id]) { 133 | const textStream = createStreamableValue(); 134 | fields.ui.append(); 135 | fields.callbacks[event.run_id] = textStream; 136 | } 137 | 138 | if (fields.callbacks[event.run_id]) { 139 | fields.callbacks[event.run_id].append(event.data.chunk.content); 140 | } 141 | }; 142 | 143 | return streamRunnableUI( 144 | remoteRunnable, 145 | { 146 | input: [ 147 | ...inputs.chat_history.map(([role, content]) => ({ 148 | type: role, 149 | content, 150 | })), 151 | { 152 | type: "human", 153 | content: inputs.input, 154 | }, 155 | ], 156 | }, 157 | { 158 | eventHandlers: [ 159 | handleInvokeModelEvent, 160 | handleInvokeToolsEvent, 161 | handleChatModelStreamEvent, 162 | ], 163 | }, 164 | ); 165 | } 166 | 167 | export const EndpointsContext = exposeEndpoints({ agent }); 168 | -------------------------------------------------------------------------------- /frontend/app/charts/README.md: -------------------------------------------------------------------------------- 1 | ![Generative UI Charts Diagram](../../public/gen_ui_charts_diagram.png) 2 | -------------------------------------------------------------------------------- /frontend/app/charts/agent.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | streamRunnableUI, 3 | exposeEndpoints, 4 | EventHandlerFields, 5 | } from "@/utils/server"; 6 | import { Filter, Order } from "./schema"; 7 | import { Client } from "@langchain/langgraph-sdk"; 8 | import { RunnableLambda } from "@langchain/core/runnables"; 9 | import { StreamEvent } from "@langchain/core/tracers/log_stream"; 10 | import { 11 | ChartType, 12 | DISPLAY_FORMATS, 13 | DataDisplayTypeAndDescription, 14 | } from "./filters"; 15 | import { FilterButton } from "@/components/prebuilt/filter"; 16 | import { format } from "date-fns"; 17 | import { createStreamableUI } from "ai/rsc"; 18 | import { 19 | LoadingBarChart, 20 | LoadingPieChart, 21 | LoadingLineChart, 22 | } from "@/components/prebuilt/loading-charts"; 23 | import { 24 | BarChart, 25 | BarChartProps, 26 | LineChart, 27 | LineChartProps, 28 | PieChart, 29 | PieChartProps, 30 | } from "@/lib/mui"; 31 | import { LAMBDA_STREAM_WRAPPER_NAME } from "@/utils/server"; 32 | 33 | type FilterGraphInput = { 34 | input: string; 35 | orders: Order[]; 36 | display_formats: Omit[]; 37 | }; 38 | type FilterGraphRunnableInput = Omit & { 39 | input: { content: string }; 40 | }; 41 | type CreateStreamableUIReturnType = ReturnType; 42 | 43 | function handleSelectedFilters( 44 | selectedFilters: Partial, 45 | ui: CreateStreamableUIReturnType, 46 | ) { 47 | const filtersWithValues: Partial = Object.fromEntries( 48 | Object.entries(selectedFilters).filter(([key, value]) => { 49 | return value !== undefined && value !== null && key in selectedFilters; 50 | }), 51 | ); 52 | const filterButtons = Object.entries(filtersWithValues).flatMap( 53 | ([key, value]) => { 54 | if (["string", "number"].includes(typeof value)) { 55 | return ( 56 | 61 | ); 62 | } else if (Array.isArray(value)) { 63 | const values = value.join(", "); 64 | return ; 65 | } else if (typeof value === "object") { 66 | const formattedDate = format(new Date(value), "yyyy-MM-dd"); 67 | return ( 68 | 69 | ); 70 | } else if (typeof value === "boolean") { 71 | return ( 72 | 77 | ); 78 | } 79 | return []; 80 | }, 81 | ); 82 | const buttonsDiv = ( 83 |
{filterButtons}
84 | ); 85 | ui.update(buttonsDiv); 86 | } 87 | 88 | function handleChartType( 89 | chartType: ChartType, 90 | ui: CreateStreamableUIReturnType, 91 | ) { 92 | if (chartType === "bar") { 93 | ui.append(); 94 | } else if (chartType === "pie") { 95 | ui.append(); 96 | } else if (chartType === "line") { 97 | ui.append(); 98 | } 99 | } 100 | 101 | function handleConstructingCharts( 102 | input: { 103 | orders: Order[]; 104 | chartType: ChartType; 105 | displayFormat: string; 106 | }, 107 | ui: CreateStreamableUIReturnType, 108 | ) { 109 | const displayDataObj = DISPLAY_FORMATS.find( 110 | (d) => d.key === input.displayFormat, 111 | ); 112 | if (!displayDataObj) { 113 | throw new Error( 114 | `Display format ${input.displayFormat} not found in DISPLAY_FORMATS`, 115 | ); 116 | } 117 | let barChart; 118 | const props = displayDataObj.propsFn(input.orders); 119 | if (input.chartType === "bar") { 120 | barChart = ; 121 | } else if (input.chartType === "pie") { 122 | barChart = ; 123 | } else if (input.chartType === "line") { 124 | barChart = ; 125 | } 126 | ui.update( 127 | <> 128 |
129 |

130 | {displayDataObj.title} 131 |

132 |

133 | {displayDataObj.description} 134 |

135 |
136 | {barChart} 137 | , 138 | ); 139 | } 140 | 141 | function handleDisplayFormat( 142 | displayFormat: string, 143 | selectedChart: ChartType, 144 | ui: CreateStreamableUIReturnType, 145 | ) { 146 | const displayDataObj = DISPLAY_FORMATS.find((d) => d.key === displayFormat); 147 | if (!displayDataObj) { 148 | throw new Error( 149 | `Display format ${displayFormat} not found in DISPLAY_FORMATS`, 150 | ); 151 | } 152 | let loadingChart; 153 | if (selectedChart === "bar") { 154 | loadingChart = ; 155 | } else if (selectedChart === "pie") { 156 | loadingChart = ; 157 | } else if (selectedChart === "line") { 158 | loadingChart = ; 159 | } else { 160 | throw new Error("Invalid chart type"); 161 | } 162 | ui.update( 163 | <> 164 |
165 |

166 | {displayDataObj.title} 167 |

168 |

169 | {displayDataObj.description} 170 |

171 |
172 | {loadingChart} 173 | , 174 | ); 175 | } 176 | 177 | async function filterGraph(inputs: FilterGraphInput) { 178 | "use server"; 179 | 180 | const client = new Client({ 181 | apiUrl: process.env.LANGGRAPH_CLOUD_API_URL, 182 | defaultHeaders: { 183 | "X-API-KEY": process.env.LANGGRAPH_CLOUD_API_KEY, 184 | }, 185 | }); 186 | const assistants = await client.assistants.search({ 187 | metadata: null, 188 | offset: 0, 189 | limit: 1, 190 | }); 191 | // We don't do any persisting, so we can just grab the first assistant 192 | const agent = assistants[0]; 193 | 194 | const streamEventsRunnable = RunnableLambda.from(async function* ( 195 | input: FilterGraphRunnableInput, 196 | ) { 197 | const streamResponse = client.runs.stream(null, agent.assistant_id, { 198 | streamMode: "events", 199 | input, 200 | }); 201 | for await (const event of streamResponse) { 202 | yield event.data; 203 | } 204 | }).withConfig({ runName: LAMBDA_STREAM_WRAPPER_NAME }); 205 | 206 | let displayFormat = ""; 207 | let chartType: ChartType; 208 | const eventHandlerOne = ( 209 | streamEvent: StreamEvent, 210 | fields: EventHandlerFields, 211 | ) => { 212 | const langGraphEvent: StreamEvent = streamEvent.data.chunk; 213 | if (!langGraphEvent) { 214 | return; 215 | } 216 | const { event, name, data } = langGraphEvent; 217 | if (event !== "on_chain_end") { 218 | return; 219 | } 220 | if (name === "generate_filters") { 221 | const { selected_filters }: { selected_filters: Partial } = 222 | data.output; 223 | return handleSelectedFilters(selected_filters, fields.ui); 224 | } else if (name === "generate_chart_type") { 225 | chartType = data.output.chart_type; 226 | return handleChartType(chartType, fields.ui); 227 | } else if (name === "generate_data_display_format") { 228 | displayFormat = data.output.display_format; 229 | return handleDisplayFormat(displayFormat, chartType, fields.ui); 230 | } else if (name === "filter_data") { 231 | const { orders } = data.output; 232 | if (!chartType || !displayFormat) { 233 | throw new Error( 234 | "Chart type and display format must be set before filtering data", 235 | ); 236 | } 237 | return handleConstructingCharts( 238 | { 239 | orders, 240 | chartType, 241 | displayFormat, 242 | }, 243 | fields.ui, 244 | ); 245 | } 246 | }; 247 | 248 | const processedInputs = { 249 | ...inputs, 250 | input: { 251 | content: inputs.input, 252 | }, 253 | }; 254 | 255 | return streamRunnableUI(streamEventsRunnable, processedInputs, { 256 | eventHandlers: [eventHandlerOne], 257 | }); 258 | } 259 | 260 | export const EndpointsContext = exposeEndpoints({ filterGraph }); 261 | -------------------------------------------------------------------------------- /frontend/app/charts/filters.tsx: -------------------------------------------------------------------------------- 1 | import { PieChartProps, BarChartProps, LineChartProps } from "@/lib/mui"; 2 | import { Filter, Order } from "./schema"; 3 | 4 | export type ChartType = "bar" | "line" | "pie"; 5 | 6 | export type DataDisplayTypeAndDescription = { 7 | /** 8 | * A unique key to identify the data display type. 9 | */ 10 | key: string; 11 | /** 12 | * The title of the data display type. 13 | */ 14 | title: string; 15 | /** 16 | * The type of chart which this format can be displayed on. 17 | */ 18 | chartType: ChartType; 19 | /** 20 | * The description of the data display type. 21 | */ 22 | description: string; 23 | /** 24 | * The function to use to construct the props for the chart. 25 | */ 26 | propsFn: (orders: Order[]) => BarChartProps | PieChartProps | LineChartProps; 27 | }; 28 | 29 | export const DISPLAY_FORMATS: Array = [ 30 | { 31 | key: "bar_order_amount_by_product", 32 | title: "Order Amount by Product Name", 33 | chartType: "bar", 34 | description: 35 | "X-axis: Product Name (productName)\nY-axis: Order Amount (amount)\nThis chart would show the total sales for each product.", 36 | propsFn: constructProductSalesBarChartProps, 37 | }, 38 | { 39 | key: "bar_order_count_by_status", 40 | title: "Order Count by Status", 41 | chartType: "bar", 42 | description: 43 | "X-axis: Order Status (status)\nY-axis: Number of Orders\nThis chart would display the distribution of orders across different statuses.", 44 | propsFn: constructOrderCountByStatusBarChartProps, 45 | }, 46 | { 47 | key: "bar_average_discount_by_product", 48 | title: "Average Discount by Product Name", 49 | chartType: "bar", 50 | description: 51 | "X-axis: Product Name (productName)\nY-axis: Average Discount Percentage (discount)\nThis chart would show which products have the highest average discounts.", 52 | propsFn: constructAverageDiscountByProductBarChartProps, 53 | }, 54 | { 55 | key: "bar_order_count_by_state", 56 | title: "Order Count by State", 57 | chartType: "bar", 58 | description: 59 | "X-axis: State (address.state)\nY-axis: Number of Orders\nThis chart would visualize the geographic distribution of orders by state.", 60 | propsFn: constructOrderCountByStateBarChartProps, 61 | }, 62 | { 63 | key: "bar_weekly_order_volume", 64 | title: "Weekly Order Volume", 65 | chartType: "bar", 66 | description: 67 | "X-axis: Date (orderedAt, grouped by week)\nY-axis: Number of Orders\nThis chart would show the trend of order volume over time, allowing you to identify peak ordering weeks.", 68 | propsFn: constructWeeklyOrderVolumeBarChartProps, 69 | }, 70 | { 71 | key: "line_order_amount_over_time", 72 | title: "Order Amount Over Time", 73 | chartType: "line", 74 | description: 75 | "X-axis: orderedAt (Date)\nY-axis: amount (Number)\nThis chart would show the trend of order amounts over time.", 76 | propsFn: constructOrderAmountOverTimeLineChartProps, 77 | }, 78 | { 79 | key: "line_discount_percentage_distribution", 80 | title: "Discount Percentage Distribution", 81 | chartType: "line", 82 | description: 83 | "X-axis: discount (Number, 0-100)\nY-axis: Count of orders with that discount (Number)\nThis chart would show the distribution of discounts across orders.\nExcludes orders which do not have a discount.", 84 | propsFn: constructDiscountDistributionLineChartProps, 85 | }, 86 | { 87 | key: "line_average_order_amount_by_month", 88 | title: "Average Order Amount by Month", 89 | chartType: "line", 90 | description: 91 | "X-axis: Month (derived from orderedAt)\nY-axis: Average amount (Number)\nThis chart would show how the average order amount changes month by month.", 92 | propsFn: constructAverageOrderAmountByMonthLineChartProps, 93 | }, 94 | { 95 | key: "pie_order_status_distribution", 96 | title: "Order Status Distribution", 97 | chartType: "pie", 98 | description: 99 | "Display each status (pending, processing, shipped, delivered, cancelled, returned) as a slice of the pie, with the size of each slice representing the number of orders in that status.\nThis provides a quick overview of the current state of all orders.", 100 | propsFn: constructOrderStatusDistributionPieChartProps, 101 | }, 102 | { 103 | key: "pie_product_name_popularity", 104 | title: "Product Name Popularity", 105 | chartType: "pie", 106 | description: 107 | "Show each unique productName as a slice, with the size representing the number of orders for that product.\nThis helps identify the most popular products in your store.", 108 | propsFn: constructProductPopularityPieChartProps, 109 | }, 110 | { 111 | key: "pie_state_wise_order_distribution", 112 | title: "State-wise Order Distribution", 113 | chartType: "pie", 114 | description: 115 | "Use the address.state field to create slices for each state, with slice sizes representing the number of orders from that state.\nThis visualizes which cities generate the most orders.", 116 | propsFn: constructDiscountDistributionPieChartProps, 117 | }, 118 | { 119 | key: "pie_quarterly_order_distribution", 120 | title: "Quarterly Order Distribution", 121 | chartType: "pie", 122 | description: 123 | "Groups orders by quarter using the orderedAt field, with each slice representing a quarter and its size showing the number of orders in that quarter.\nThis visualizes seasonal trends in order volume on a quarterly basis.", 124 | propsFn: constructQuarterlyOrderDistributionPieChartProps, 125 | }, 126 | ]; 127 | 128 | export function filterOrders(state: { 129 | selectedFilters: Partial; 130 | orders: Order[]; 131 | }): { orders: Order[] } { 132 | const { 133 | productNames, 134 | beforeDate, 135 | afterDate, 136 | minAmount, 137 | maxAmount, 138 | state: orderState, 139 | city, 140 | discount, 141 | minDiscountPercentage, 142 | status, 143 | } = state.selectedFilters; 144 | 145 | if (minDiscountPercentage !== undefined && discount === false) { 146 | throw new Error( 147 | "Can not filter by minDiscountPercentage when discount is false.", 148 | ); 149 | } 150 | 151 | let filteredOrders = state.orders.filter((order) => { 152 | let isMatch = true; 153 | 154 | if ( 155 | productNames && 156 | !productNames.includes(order.productName.toLowerCase()) 157 | ) { 158 | isMatch = false; 159 | } 160 | 161 | if (beforeDate && order.orderedAt > beforeDate) { 162 | isMatch = false; 163 | } 164 | if (afterDate && order.orderedAt < afterDate) { 165 | isMatch = false; 166 | } 167 | if (minAmount && order.amount < minAmount) { 168 | isMatch = false; 169 | } 170 | if (maxAmount && order.amount > maxAmount) { 171 | isMatch = false; 172 | } 173 | if ( 174 | orderState && 175 | order.address.state.toLowerCase() !== orderState.toLowerCase() 176 | ) { 177 | isMatch = false; 178 | } 179 | if (city && order.address.city.toLowerCase() !== city.toLowerCase()) { 180 | isMatch = false; 181 | } 182 | if (discount !== undefined && (order.discount === undefined) !== discount) { 183 | isMatch = false; 184 | } 185 | if ( 186 | minDiscountPercentage !== undefined && 187 | (order.discount === undefined || order.discount < minDiscountPercentage) 188 | ) { 189 | isMatch = false; 190 | } 191 | if (status && order.status.toLowerCase() !== status) { 192 | isMatch = false; 193 | } 194 | 195 | return isMatch; 196 | }); 197 | 198 | return { 199 | orders: filteredOrders, 200 | }; 201 | } 202 | 203 | /** 204 | * Order Amount by Product Name 205 | X-axis: Product Name (productName) 206 | Y-axis: Order Amount (amount) 207 | This chart would show the total sales for each product. 208 | */ 209 | export function constructProductSalesBarChartProps( 210 | orders: Order[], 211 | ): BarChartProps { 212 | const salesByProduct = orders.reduce( 213 | (acc, order) => { 214 | if (!acc[order.productName]) { 215 | acc[order.productName] = 0; 216 | } 217 | acc[order.productName] += order.amount; 218 | return acc; 219 | }, 220 | {} as Record, 221 | ); 222 | 223 | const dataset = Object.entries(salesByProduct) 224 | .map(([productName, totalSales]) => ({ productName, totalSales })) 225 | .sort((a, b) => b.totalSales - a.totalSales); 226 | 227 | return { 228 | xAxis: [{ scaleType: "band", dataKey: "productName" }], 229 | series: [ 230 | { 231 | dataKey: "totalSales", 232 | label: "Total Sales", 233 | }, 234 | ], 235 | dataset, 236 | }; 237 | } 238 | 239 | /** 240 | * Order Count by Status 241 | X-axis: Order Status (status) 242 | Y-axis: Number of Orders 243 | This chart would display the distribution of orders across different statuses. 244 | */ 245 | export function constructOrderCountByStatusBarChartProps( 246 | orders: Order[], 247 | ): BarChartProps { 248 | const orderCountByStatus = orders.reduce( 249 | (acc, order) => { 250 | acc[order.status] = (acc[order.status] || 0) + 1; 251 | return acc; 252 | }, 253 | {} as Record, 254 | ); 255 | 256 | const dataset = Object.entries(orderCountByStatus) 257 | .map(([status, count]) => ({ status, count })) 258 | .sort((a, b) => b.count - a.count); 259 | 260 | return { 261 | xAxis: [{ scaleType: "band", dataKey: "status" }], 262 | yAxis: [ 263 | { 264 | scaleType: "linear", 265 | }, 266 | ], 267 | series: [ 268 | { 269 | dataKey: "count", 270 | label: "Number of Orders", 271 | }, 272 | ], 273 | dataset, 274 | }; 275 | } 276 | 277 | /** 278 | * Average Discount by Product Name 279 | X-axis: Product Name (productName) 280 | Y-axis: Average Discount Percentage (discount) 281 | This chart would show which products have the highest average discounts. 282 | */ 283 | export function constructAverageDiscountByProductBarChartProps( 284 | orders: Order[], 285 | ): BarChartProps { 286 | const discountsByProduct = orders.reduce( 287 | (acc, order) => { 288 | if (!acc[order.productName]) { 289 | acc[order.productName] = { totalDiscount: 0, count: 0 }; 290 | } 291 | if (order.discount !== undefined) { 292 | acc[order.productName].totalDiscount += order.discount; 293 | acc[order.productName].count++; 294 | } 295 | return acc; 296 | }, 297 | {} as Record, 298 | ); 299 | 300 | const dataset = Object.entries(discountsByProduct) 301 | .map(([productName, { totalDiscount, count }]) => ({ 302 | productName, 303 | averageDiscount: count > 0 ? totalDiscount / count : 0, 304 | })) 305 | .sort((a, b) => b.averageDiscount - a.averageDiscount); 306 | 307 | return { 308 | xAxis: [{ scaleType: "band", dataKey: "productName" }], 309 | yAxis: [ 310 | { 311 | scaleType: "linear", 312 | max: 100, 313 | }, 314 | ], 315 | series: [ 316 | { 317 | dataKey: "averageDiscount", 318 | label: "Average Discount", 319 | }, 320 | ], 321 | dataset, 322 | }; 323 | } 324 | 325 | /** 326 | * Order Count by State 327 | X-axis: State (address.state) 328 | Y-axis: Number of Orders 329 | This chart would visualize the geographic distribution of orders by state. 330 | */ 331 | export function constructOrderCountByStateBarChartProps( 332 | orders: Order[], 333 | ): BarChartProps { 334 | const orderCountByState = orders.reduce( 335 | (acc, order) => { 336 | const state = order.address.state; 337 | acc[state] = (acc[state] || 0) + 1; 338 | return acc; 339 | }, 340 | {} as Record, 341 | ); 342 | 343 | const dataset = Object.entries(orderCountByState) 344 | .map(([state, count]) => ({ state, count })) 345 | .sort((a, b) => b.count - a.count); 346 | 347 | return { 348 | xAxis: [{ scaleType: "band", dataKey: "state" }], 349 | yAxis: [ 350 | { 351 | scaleType: "linear", 352 | }, 353 | ], 354 | series: [ 355 | { 356 | dataKey: "count", 357 | label: "Number of Orders", 358 | }, 359 | ], 360 | dataset, 361 | }; 362 | } 363 | 364 | /** 365 | * Weekly Order Volume 366 | X-axis: Date (orderedAt, grouped by week) 367 | Y-axis: Number of Orders 368 | This chart would show the trend of order volume over time, allowing you to identify peak ordering weeks. 369 | */ 370 | export function constructWeeklyOrderVolumeBarChartProps( 371 | orders: Order[], 372 | ): BarChartProps { 373 | // Helper function to get the start of the week (Sunday) for a given date 374 | const getWeekStart = (date: Date): Date => { 375 | const d = new Date(date); 376 | d.setDate(d.getDate() - d.getDay()); // Set to Sunday 377 | d.setHours(0, 0, 0, 0); // Set to midnight 378 | return d; 379 | }; 380 | 381 | // Group orders by week 382 | const ordersByWeek = orders.reduce( 383 | (acc, order) => { 384 | const weekStart = getWeekStart(order.orderedAt); 385 | const weekKey = weekStart.toISOString().split("T")[0]; // Get YYYY-MM-DD of week start 386 | if (!acc[weekKey]) { 387 | acc[weekKey] = 0; 388 | } 389 | acc[weekKey]++; 390 | return acc; 391 | }, 392 | {} as Record, 393 | ); 394 | 395 | // Convert to array and sort by week 396 | const dataset = Object.entries(ordersByWeek) 397 | .map(([weekStart, count]) => ({ weekStart, count })) 398 | .sort((a, b) => a.weekStart.localeCompare(b.weekStart)); 399 | 400 | return { 401 | xAxis: [ 402 | { 403 | scaleType: "band", 404 | dataKey: "weekStart", 405 | tickLabelStyle: { 406 | angle: 45, 407 | textAnchor: "start", 408 | dominantBaseline: "hanging", 409 | }, 410 | }, 411 | ], 412 | yAxis: [ 413 | { 414 | scaleType: "linear", 415 | label: "Number of Orders", 416 | }, 417 | ], 418 | series: [ 419 | { 420 | dataKey: "count", 421 | label: "Order Count", 422 | }, 423 | ], 424 | dataset, 425 | }; 426 | } 427 | 428 | /** 429 | *Order Amount Over Time 430 | X-axis: orderedAt (Date) 431 | Y-axis: amount (Number) 432 | This chart would show the trend of order amounts over time. 433 | */ 434 | export function constructOrderAmountOverTimeLineChartProps( 435 | orders: Order[], 436 | ): LineChartProps { 437 | if (orders.length === 0) { 438 | return { series: [], xAxis: [] }; 439 | } 440 | 441 | // Sort orders by date 442 | const sortedOrders = [...orders].sort( 443 | (a, b) => new Date(a.orderedAt).getTime() - new Date(b.orderedAt).getTime(), 444 | ); 445 | 446 | // Create dataset 447 | const dataset = sortedOrders.map((order) => ({ 448 | date: new Date(order.orderedAt), 449 | amount: order.amount, 450 | })); 451 | 452 | return { 453 | series: [ 454 | { 455 | dataKey: "amount", 456 | label: "Order Amount", 457 | type: "line", 458 | }, 459 | ], 460 | xAxis: [ 461 | { 462 | dataKey: "date", 463 | scaleType: "time", 464 | }, 465 | ], 466 | yAxis: [ 467 | { 468 | label: "Amount ($)", 469 | }, 470 | ], 471 | dataset, 472 | }; 473 | } 474 | 475 | /** 476 | * Discount Percentage Distribution 477 | X-axis: discount (Number, 0-100) 478 | Y-axis: Count of orders with that discount (Number) 479 | This chart would show the distribution of discounts across orders. 480 | Excludes orders which do not have a discount. 481 | */ 482 | export function constructDiscountDistributionLineChartProps( 483 | orders: Order[], 484 | ): LineChartProps { 485 | // Filter orders with discounts 486 | const ordersWithDiscount = orders.filter( 487 | (order) => order.discount !== undefined, 488 | ); 489 | const discountCounts: Record = {}; 490 | 491 | // Count orders for each discount percentage 492 | ordersWithDiscount.forEach((order) => { 493 | const roundedDiscount = Math.round(order.discount!); 494 | discountCounts[roundedDiscount] = 495 | (discountCounts[roundedDiscount] || 0) + 1; 496 | }); 497 | 498 | // Create dataset with only the discount percentages that appear in the data 499 | const dataset = Object.entries(discountCounts) 500 | .map(([discountPercentage, count]) => ({ 501 | discountPercentage: parseInt(discountPercentage), 502 | count, 503 | })) 504 | .sort((a, b) => a.discountPercentage - b.discountPercentage); 505 | 506 | return { 507 | series: [ 508 | { 509 | dataKey: "count", 510 | label: "Number of Orders", 511 | type: "line", 512 | curve: "linear", 513 | }, 514 | ], 515 | xAxis: [ 516 | { 517 | dataKey: "discountPercentage", 518 | label: "Discount Percentage", 519 | scaleType: "linear", 520 | }, 521 | ], 522 | yAxis: [ 523 | { 524 | label: "Number of Orders", 525 | scaleType: "linear", 526 | }, 527 | ], 528 | dataset, 529 | }; 530 | } 531 | 532 | /** 533 | * Average Order Amount by Month 534 | X-axis: Month (derived from orderedAt) 535 | Y-axis: Average amount (Number) 536 | This chart would show how the average order amount changes month by month. 537 | */ 538 | export function constructAverageOrderAmountByMonthLineChartProps( 539 | orders: Order[], 540 | ): LineChartProps { 541 | if (orders.length === 0) { 542 | return { series: [], xAxis: [] }; 543 | } 544 | 545 | // Preprocess orders and sort by date 546 | const processedOrders = orders 547 | .map((order) => ({ 548 | ...order, 549 | orderedAt: new Date(order.orderedAt), 550 | })) 551 | .sort((a, b) => a.orderedAt.getTime() - b.orderedAt.getTime()); 552 | 553 | // Group orders by month and calculate average amount 554 | const monthlyAverages: { [key: string]: { total: number; count: number } } = 555 | {}; 556 | 557 | processedOrders.forEach((order) => { 558 | const monthKey = `${order.orderedAt.getFullYear()}-${String(order.orderedAt.getMonth() + 1).padStart(2, "0")}`; 559 | if (!monthlyAverages[monthKey]) { 560 | monthlyAverages[monthKey] = { total: 0, count: 0 }; 561 | } 562 | monthlyAverages[monthKey].total += order.amount; 563 | monthlyAverages[monthKey].count += 1; 564 | }); 565 | 566 | // Create dataset 567 | const dataset = Object.entries(monthlyAverages) 568 | .map(([month, { total, count }]) => ({ 569 | month: new Date(`${month}-01`), 570 | averageAmount: total / count, 571 | })) 572 | .sort((a, b) => a.month.getTime() - b.month.getTime()); 573 | 574 | return { 575 | series: [ 576 | { 577 | dataKey: "averageAmount", 578 | label: "Average Order Amount", 579 | type: "line", 580 | curve: "linear", 581 | }, 582 | ], 583 | xAxis: [ 584 | { 585 | dataKey: "month", 586 | scaleType: "time", 587 | }, 588 | ], 589 | width: 800, 590 | height: 400, 591 | dataset, 592 | }; 593 | } 594 | 595 | /** 596 | * Order Status Distribution: 597 | Display each status (pending, processing, shipped, delivered, cancelled, returned) as a slice of the pie, with the size of each slice representing the number of orders in that status. This provides a quick overview of the current state of all orders. 598 | */ 599 | export function constructOrderStatusDistributionPieChartProps( 600 | orders: Order[], 601 | ): PieChartProps { 602 | const statusCounts = orders.reduce( 603 | (acc, order) => { 604 | acc[order.status] = (acc[order.status] || 0) + 1; 605 | return acc; 606 | }, 607 | {} as Record, 608 | ); 609 | 610 | const data = Object.entries(statusCounts).map(([status, count], index) => ({ 611 | id: index, 612 | value: count, 613 | label: status.charAt(0).toUpperCase() + status.slice(1), // Capitalize first letter 614 | })); 615 | 616 | return { 617 | series: [ 618 | { 619 | data, 620 | highlightScope: { faded: "global", highlighted: "item" }, 621 | faded: { innerRadius: 30, additionalRadius: -30 }, 622 | }, 623 | ], 624 | margin: { top: 10, bottom: 10, left: 10, right: 10 }, 625 | legend: { hidden: false }, 626 | }; 627 | } 628 | 629 | /** 630 | * Product Name Popularity: 631 | Show each unique productName as a slice, with the size representing the number of orders for that product. This helps identify the most popular products in your store. 632 | */ 633 | export function constructProductPopularityPieChartProps( 634 | orders: Order[], 635 | ): PieChartProps { 636 | const productCounts = orders.reduce( 637 | (acc, order) => { 638 | acc[order.productName] = (acc[order.productName] || 0) + 1; 639 | return acc; 640 | }, 641 | {} as Record, 642 | ); 643 | 644 | const data = Object.entries(productCounts) 645 | .map(([productName, count], index) => ({ 646 | id: index, 647 | value: count, 648 | label: productName, 649 | })) 650 | .sort((a, b) => b.value - a.value); // Sort by count in descending order 651 | 652 | return { 653 | series: [ 654 | { 655 | data, 656 | highlightScope: { faded: "global", highlighted: "item" }, 657 | faded: { innerRadius: 30, additionalRadius: -30 }, 658 | }, 659 | ], 660 | margin: { top: 10, bottom: 10, left: 10, right: 10 }, 661 | legend: { hidden: false }, 662 | slotProps: { 663 | legend: { 664 | direction: "column", 665 | position: { vertical: "middle", horizontal: "right" }, 666 | padding: 0, 667 | }, 668 | }, 669 | }; 670 | } 671 | 672 | /** 673 | * State-wise Order Distribution: 674 | Use the address.state field to create slices for each state, with slice sizes representing the number of orders from that state. This visualizes which cities generate the most orders. 675 | */ 676 | export function constructDiscountDistributionPieChartProps( 677 | orders: Order[], 678 | ): PieChartProps { 679 | const discountCounts = orders.reduce( 680 | (acc, order) => { 681 | if (order.discount !== undefined) { 682 | acc.discounted++; 683 | } else { 684 | acc.nonDiscounted++; 685 | } 686 | return acc; 687 | }, 688 | { discounted: 0, nonDiscounted: 0 }, 689 | ); 690 | 691 | const data = [ 692 | { id: 0, value: discountCounts.discounted, label: "Discounted" }, 693 | { id: 1, value: discountCounts.nonDiscounted, label: "Non-discounted" }, 694 | ]; 695 | 696 | return { 697 | series: [ 698 | { 699 | data, 700 | highlightScope: { faded: "global", highlighted: "item" }, 701 | faded: { innerRadius: 30, additionalRadius: -30 }, 702 | }, 703 | ], 704 | margin: { top: 10, bottom: 10, left: 10, right: 10 }, 705 | legend: { hidden: false }, 706 | }; 707 | } 708 | 709 | /** 710 | * Quarterly Order Distribution: 711 | * Groups orders by quarter using the orderedAt field, with each slice representing 712 | * a quarter and its size showing the number of orders in that quarter. This 713 | * visualizes seasonal trends in order volume on a quarterly basis. 714 | */ 715 | export function constructQuarterlyOrderDistributionPieChartProps( 716 | orders: Order[], 717 | ): PieChartProps { 718 | const quarterlyOrderCounts = orders.reduce( 719 | (acc, order) => { 720 | const date = new Date(order.orderedAt); 721 | const year = date.getFullYear(); 722 | const quarter = Math.floor(date.getMonth() / 3) + 1; 723 | const quarterKey = `Q${quarter} ${year}`; 724 | acc[quarterKey] = (acc[quarterKey] || 0) + 1; 725 | return acc; 726 | }, 727 | {} as Record, 728 | ); 729 | 730 | const data = Object.entries(quarterlyOrderCounts) 731 | .map(([quarterYear, count], index) => ({ 732 | id: index, 733 | value: count, 734 | label: quarterYear, 735 | })) 736 | .sort((a, b) => { 737 | const [aQuarter, aYear] = a.label.split(" "); 738 | const [bQuarter, bYear] = b.label.split(" "); 739 | return ( 740 | parseInt(aYear) - parseInt(bYear) || 741 | parseInt(aQuarter.slice(1)) - parseInt(bQuarter.slice(1)) 742 | ); 743 | }); 744 | 745 | return { 746 | series: [ 747 | { 748 | data, 749 | highlightScope: { faded: "global", highlighted: "item" }, 750 | faded: { innerRadius: 30, additionalRadius: -30 }, 751 | }, 752 | ], 753 | margin: { top: 10, bottom: 10, left: 10, right: 10 }, 754 | legend: { hidden: false }, 755 | slotProps: { 756 | legend: { 757 | direction: "column", 758 | position: { vertical: "middle", horizontal: "right" }, 759 | padding: 0, 760 | }, 761 | }, 762 | }; 763 | } 764 | -------------------------------------------------------------------------------- /frontend/app/charts/generate-orders.ts: -------------------------------------------------------------------------------- 1 | import { faker } from "@faker-js/faker"; 2 | import { Order } from "./schema"; 3 | 4 | export function generateOrders(): Order[] { 5 | const orders: Order[] = []; 6 | 7 | const products = Array.from({ length: 5 }).map((_) => ({ 8 | productName: faker.commerce.product(), 9 | amount: faker.number.int({ 10 | min: 10, 11 | max: 1000, 12 | }), 13 | })); 14 | 15 | for (let i = 0; i < 250; i++) { 16 | const product = faker.helpers.arrayElement(products); 17 | // 1 in 5 orders (ish) should have a discount 18 | const shouldApplyDiscount = faker.helpers.arrayElement([ 19 | ...Array.from({ length: 4 }).map((_) => "no"), 20 | "yes", 21 | ]); 22 | const order: Order = { 23 | ...product, 24 | id: faker.string.uuid(), 25 | discount: 26 | shouldApplyDiscount === "yes" 27 | ? faker.number.int({ min: 10, max: 75 }) 28 | : undefined, 29 | address: { 30 | street: faker.location.streetAddress(), 31 | city: faker.location.city(), 32 | state: faker.location.state(), 33 | zip: faker.location.zipCode(), 34 | }, 35 | status: faker.helpers.arrayElement([ 36 | "pending", 37 | "processing", 38 | "shipped", 39 | "delivered", 40 | "cancelled", 41 | "returned", 42 | ]), 43 | orderedAt: faker.date.past(), 44 | }; 45 | 46 | orders.push(order); 47 | } 48 | 49 | return orders; 50 | } 51 | -------------------------------------------------------------------------------- /frontend/app/charts/layout.tsx: -------------------------------------------------------------------------------- 1 | import "../globals.css"; 2 | import type { Metadata } from "next"; 3 | 4 | import { EndpointsContext } from "./agent"; 5 | import { ReactNode } from "react"; 6 | 7 | export const metadata: Metadata = { 8 | title: "LangChain.js Gen UI", 9 | description: "Generative UI application with LangChain.js", 10 | }; 11 | 12 | export default function RootLayout(props: { children: ReactNode }) { 13 | return {props.children}; 14 | } 15 | -------------------------------------------------------------------------------- /frontend/app/charts/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { Input } from "@/components/ui/input"; 5 | import { 6 | BarChart, 7 | BarChartProps, 8 | LineChart, 9 | LineChartProps, 10 | PieChart, 11 | PieChartProps, 12 | } from "@/lib/mui"; 13 | import { Suspense, useEffect, useState } from "react"; 14 | import { useActions } from "@/utils/client"; 15 | import { EndpointsContext } from "./agent"; 16 | import { Filter, Order, filterSchema } from "./schema"; 17 | import { LocalContext } from "../shared"; 18 | import { generateOrders } from "./generate-orders"; 19 | import { 20 | ChartType, 21 | DISPLAY_FORMATS, 22 | constructProductSalesBarChartProps, 23 | constructOrderStatusDistributionPieChartProps, 24 | constructOrderAmountOverTimeLineChartProps, 25 | DataDisplayTypeAndDescription, 26 | } from "./filters"; 27 | import { useSearchParams, useRouter } from "next/navigation"; 28 | import { filterOrders } from "./filters"; 29 | import { snakeCase } from "lodash"; 30 | import { DisplayTypesDialog } from "@/components/prebuilt/display-types-dialog"; 31 | import { FilterOptionsDialog } from "@/components/prebuilt/filter-options-dialog"; 32 | 33 | const LOCAL_STORAGE_ORDERS_KEY = "orders"; 34 | 35 | const getFiltersFromUrl = ( 36 | searchParams: URLSearchParams, 37 | orders: Order[], 38 | ): Partial => { 39 | const productNames = Array.from( 40 | new Set(orders.map(({ productName }) => productName)), 41 | ); 42 | const possibleFilters = filterSchema(productNames); 43 | const filterKeys = Object.keys(possibleFilters.shape); 44 | const filters: Record = {}; 45 | 46 | filterKeys.forEach((key) => { 47 | const value = searchParams.get(snakeCase(key)); 48 | if (value) { 49 | try { 50 | filters[key as any] = decodeURIComponent(value); 51 | } catch (error) { 52 | console.error(`Error parsing URL parameter for ${key}:`, error); 53 | } 54 | } 55 | }); 56 | 57 | return filters; 58 | }; 59 | 60 | const SparklesIcon = () => ( 61 | 69 | 74 | 75 | ); 76 | 77 | interface SmartFilterProps { 78 | onSubmit: (value: string) => Promise; 79 | loading: boolean; 80 | } 81 | 82 | function SmartFilter(props: SmartFilterProps) { 83 | const [input, setInput] = useState(""); 84 | 85 | const handleSubmit = async (e: React.FormEvent) => { 86 | e.preventDefault(); 87 | await props.onSubmit(input); 88 | setInput(""); 89 | }; 90 | 91 | const ButtonContent = () => { 92 | if (props.loading) { 93 | return ( 94 | 95 | Loading 96 | 97 | . 98 | . 99 | . 100 | 101 | 102 | ); 103 | } 104 | return ( 105 | 106 | Submit 107 | 108 | ); 109 | }; 110 | 111 | return ( 112 |
113 | setInput(e.target.value)} 117 | placeholder="Magic filter" 118 | /> 119 | 122 |
123 | ); 124 | } 125 | 126 | function ChartContent() { 127 | const actions = useActions(); 128 | const searchParams = useSearchParams(); 129 | const { push } = useRouter(); 130 | 131 | const [loading, setLoading] = useState(false); 132 | const [elements, setElements] = useState([]); 133 | const [orders, setOrders] = useState([]); 134 | const [selectedFilters, setSelectedFilters] = useState>(); 135 | const [selectedChartType, setSelectedChartType] = useState("bar"); 136 | const [currentFilter, setCurrentFilter] = useState(""); 137 | const [currentDisplayFormat, setCurrentDisplayFormat] = 138 | useState(); 139 | 140 | // Load the orders from local storage or generate them if they don't exist. 141 | useEffect(() => { 142 | if (orders.length > 0) { 143 | return; 144 | } 145 | const localStorageOrders = localStorage.getItem(LOCAL_STORAGE_ORDERS_KEY); 146 | let ordersV: Order[] = []; 147 | if (!localStorageOrders || JSON.parse(localStorageOrders).length === 0) { 148 | const fakeOrders = generateOrders(); 149 | ordersV = fakeOrders; 150 | setOrders(fakeOrders); 151 | localStorage.setItem( 152 | LOCAL_STORAGE_ORDERS_KEY, 153 | JSON.stringify(fakeOrders), 154 | ); 155 | } else { 156 | ordersV = JSON.parse(localStorageOrders); 157 | setOrders(ordersV); 158 | } 159 | 160 | // Set the chart on fresh load. Use either the chartType from the URL or the default. 161 | // Also extract any filters to apply to the chart. 162 | const selectedChart = searchParams.get("chartType") || selectedChartType; 163 | const filters = getFiltersFromUrl(searchParams, ordersV); 164 | const { orders: filteredOrders } = filterOrders({ 165 | orders: ordersV, 166 | selectedFilters: filters, 167 | }); 168 | switch (selectedChart) { 169 | case "bar": 170 | const displayFormatKeyBar = "bar_order_amount_by_product"; 171 | const displayFormatBar = DISPLAY_FORMATS.find( 172 | (d) => d.key === displayFormatKeyBar, 173 | ); 174 | if (!displayFormatBar) { 175 | throw new Error("Something went wrong."); 176 | } 177 | return setElements([ 178 |
179 |

180 | {displayFormatBar.title} 181 |

182 |

183 | {displayFormatBar.description} 184 |

185 |
, 186 | , 192 | ]); 193 | case "pie": 194 | const displayFormatKeyPie = "pie_order_status_distribution"; 195 | const displayFormatPie = DISPLAY_FORMATS.find( 196 | (d) => d.key === displayFormatKeyPie, 197 | ); 198 | if (!displayFormatPie) { 199 | throw new Error("Something went wrong."); 200 | } 201 | return setElements([ 202 |
203 |

204 | {displayFormatPie.title} 205 |

206 |

207 | {displayFormatPie.description} 208 |

209 |
, 210 | , 216 | ]); 217 | case "line": 218 | const displayFormatKeyLine = "line_order_amount_over_time"; 219 | const displayFormatLine = DISPLAY_FORMATS.find( 220 | (d) => d.key === displayFormatKeyLine, 221 | ); 222 | if (!displayFormatLine) { 223 | throw new Error("Something went wrong."); 224 | } 225 | return setElements([ 226 |
227 |

228 | {displayFormatLine.title} 229 |

230 |

231 | {displayFormatLine.description} 232 |

233 |
, 234 | , 240 | ]); 241 | } 242 | }, [orders.length, searchParams, selectedChartType]); 243 | 244 | // Update the URL with the selected filters and chart type. 245 | useEffect(() => { 246 | if (!selectedFilters) return; 247 | 248 | const params = Object.fromEntries(searchParams.entries()); 249 | let paramsToAdd: { [key: string]: string } = {}; 250 | 251 | Object.entries({ 252 | ...selectedFilters, 253 | chartType: searchParams.get("chartType") ?? selectedChartType, 254 | }).forEach(([key, value]) => { 255 | const searchValue = params[key]; 256 | let encodedValue: string | undefined = undefined; 257 | if (Array.isArray(value)) { 258 | encodedValue = encodeURIComponent(JSON.stringify(value)); 259 | } else if (typeof value === "object") { 260 | if (Object.keys(value).length > 0) { 261 | encodedValue = encodeURIComponent(value.toISOString()); 262 | } 263 | // no-op if value is empty 264 | } else if (["string", "number", "boolean"].includes(typeof value)) { 265 | encodedValue = encodeURIComponent(value as string | number | boolean); 266 | } else { 267 | throw new Error(`Invalid value type ${JSON.stringify(value)}`); 268 | } 269 | if ( 270 | (encodedValue !== undefined && !searchValue) || 271 | searchValue !== encodedValue 272 | ) { 273 | paramsToAdd[key] = encodedValue as string; 274 | } 275 | }); 276 | 277 | if (Object.keys(paramsToAdd).length === 0) return; 278 | push(`/charts?${new URLSearchParams({ ...paramsToAdd })}`); 279 | }, [selectedFilters, searchParams, selectedChartType, push]); 280 | 281 | const handleSubmitSmartFilter = async (input: string) => { 282 | setLoading(true); 283 | setCurrentFilter(input); 284 | const element = await actions.filterGraph({ 285 | input, 286 | orders, 287 | display_formats: DISPLAY_FORMATS.map((d) => ({ 288 | title: d.title, 289 | description: d.description, 290 | chartType: d.chartType, 291 | key: d.key, 292 | })), 293 | }); 294 | 295 | const newElements = [ 296 |
297 | {element.ui} 298 |
, 299 | ]; 300 | 301 | // consume the value stream so we can be sure the graph has finished. 302 | (async () => { 303 | const lastEvent = await element.lastEvent; 304 | if (typeof lastEvent === "string") { 305 | throw new Error("lastEvent is a string. Something has gone wrong."); 306 | } else if (Array.isArray(lastEvent)) { 307 | throw new Error("lastEvent is an array. Something has gone wrong."); 308 | } 309 | 310 | const { selected_filters, chart_type, display_format } = lastEvent; 311 | if (selected_filters) { 312 | setSelectedFilters( 313 | Object.fromEntries( 314 | Object.entries(selected_filters).filter(([key, value]) => { 315 | return ( 316 | value !== undefined && value !== null && key in selected_filters 317 | ); 318 | }), 319 | ), 320 | ); 321 | } 322 | const displayFormat = DISPLAY_FORMATS.find( 323 | (d) => d.key === display_format, 324 | ); 325 | if (displayFormat) { 326 | setCurrentDisplayFormat(displayFormat); 327 | } 328 | setSelectedChartType(chart_type); 329 | setLoading(false); 330 | })(); 331 | 332 | setElements(newElements); 333 | }; 334 | 335 | return ( 336 |
337 | 338 |
339 | 340 | 341 |
342 | 343 |
344 |
345 |
346 |

{currentFilter}

347 |
348 |
{elements}
349 |
350 |
351 | ); 352 | } 353 | 354 | export default function DynamicCharts() { 355 | return ( 356 | Loading...}> 357 | 358 | 359 | ); 360 | } 361 | -------------------------------------------------------------------------------- /frontend/app/charts/schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export interface Order { 4 | /** 5 | * A UUID for the order. 6 | */ 7 | id: string; 8 | /** 9 | * The name of the product purchased. 10 | */ 11 | productName: string; 12 | /** 13 | * The amount of the order. 14 | */ 15 | amount: number; 16 | /** 17 | * The percentage of the discount applied to the order. 18 | * This is between 0 and 100. 19 | * Not defined if no discount was applied. 20 | */ 21 | discount?: number; 22 | /** 23 | * The address the order was shipped to. 24 | */ 25 | address: { 26 | /** 27 | * The address street. 28 | * @example "123 Main St" 29 | */ 30 | street: string; 31 | /** 32 | * The city the order was shipped to. 33 | * @example "San Francisco" 34 | */ 35 | city: string; 36 | /** 37 | * The state the order was shipped to. 38 | * @example "California" 39 | */ 40 | state: string; 41 | /** 42 | * The zip code the order was shipped to. 43 | * @example "94105" 44 | */ 45 | zip: string; 46 | }; 47 | /** 48 | * The current status of the order. 49 | */ 50 | status: 51 | | "pending" 52 | | "processing" 53 | | "shipped" 54 | | "delivered" 55 | | "cancelled" 56 | | "returned"; 57 | /** 58 | * The date the order was placed. 59 | */ 60 | orderedAt: Date; 61 | } 62 | 63 | export const filterSchema = (productNames: string[]) => { 64 | const productNamesAsString = productNames 65 | .map((p) => p.toLowerCase()) 66 | .join(", "); 67 | return z 68 | .object({ 69 | productNames: z 70 | .array( 71 | z.enum([ 72 | productNames[0], 73 | ...productNames.slice(1, productNames.length), 74 | ]), 75 | ) 76 | .optional() 77 | .describe( 78 | `Filter orders by the product name. Lowercase only. MUST only be a list of the following products: ${productNamesAsString}`, 79 | ), 80 | beforeDate: z 81 | .string() 82 | .transform((str) => new Date(str)) 83 | .optional() 84 | .describe( 85 | "Filter orders placed before this date. Must be a valid date in the format 'YYYY-MM-DD'", 86 | ), 87 | afterDate: z 88 | .string() 89 | .transform((str) => new Date(str)) 90 | .optional() 91 | .describe( 92 | "Filter orders placed after this date. Must be a valid date in the format 'YYYY-MM-DD'", 93 | ), 94 | minAmount: z 95 | .number() 96 | .optional() 97 | .describe("The minimum amount of the order to filter by."), 98 | maxAmount: z 99 | .number() 100 | .optional() 101 | .describe("The maximum amount of the order to filter by."), 102 | state: z 103 | .string() 104 | .optional() 105 | .describe( 106 | "Filter orders by the state the order was placed in. Example: 'California'", 107 | ), 108 | discount: z 109 | .boolean() 110 | .optional() 111 | .describe("Filter orders by whether or not it had a discount applied."), 112 | minDiscountPercentage: z 113 | .number() 114 | .min(0) 115 | .max(100) 116 | .optional() 117 | .describe( 118 | "Filter orders which had at least this amount discounted (in percentage)", 119 | ), 120 | status: z 121 | .enum([ 122 | "pending", 123 | "processing", 124 | "shipped", 125 | "delivered", 126 | "cancelled", 127 | "returned", 128 | ]) 129 | .optional() 130 | .describe("The current status of the order."), 131 | }) 132 | .describe("Available filters to apply to orders."); 133 | }; 134 | 135 | export interface Filter { 136 | productNames?: string[]; 137 | beforeDate?: Date; 138 | afterDate?: Date; 139 | minAmount?: number; 140 | maxAmount?: number; 141 | state?: string; 142 | city?: string; 143 | discount?: boolean; 144 | minDiscountPercentage?: number; 145 | status?: 146 | | "pending" 147 | | "processing" 148 | | "shipped" 149 | | "delivered" 150 | | "cancelled" 151 | | "returned"; 152 | } 153 | -------------------------------------------------------------------------------- /frontend/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bracesproul/gen-ui-python/4e9a04c2fd3581f9f400e3d151127166d8300728/frontend/app/favicon.ico -------------------------------------------------------------------------------- /frontend/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 84% 4.9%; 9 | 10 | --card: 0 0% 100%; 11 | --card-foreground: 222.2 84% 4.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 222.2 84% 4.9%; 15 | 16 | --primary: 222.2 47.4% 11.2%; 17 | --primary-foreground: 210 40% 98%; 18 | 19 | --secondary: 210 40% 96.1%; 20 | --secondary-foreground: 222.2 47.4% 11.2%; 21 | 22 | --muted: 210 40% 96.1%; 23 | --muted-foreground: 215.4 16.3% 46.9%; 24 | 25 | --accent: 210 40% 96.1%; 26 | --accent-foreground: 222.2 47.4% 11.2%; 27 | 28 | --destructive: 0 84.2% 60.2%; 29 | --destructive-foreground: 210 40% 98%; 30 | 31 | --border: 214.3 31.8% 91.4%; 32 | --input: 214.3 31.8% 91.4%; 33 | --ring: 222.2 84% 4.9%; 34 | 35 | --radius: 0.5rem; 36 | } 37 | 38 | .dark { 39 | --background: 222.2 84% 4.9%; 40 | --foreground: 210 40% 98%; 41 | 42 | --card: 222.2 84% 4.9%; 43 | --card-foreground: 210 40% 98%; 44 | 45 | --popover: 222.2 84% 4.9%; 46 | --popover-foreground: 210 40% 98%; 47 | 48 | --primary: 210 40% 98%; 49 | --primary-foreground: 222.2 47.4% 11.2%; 50 | 51 | --secondary: 217.2 32.6% 17.5%; 52 | --secondary-foreground: 210 40% 98%; 53 | 54 | --muted: 217.2 32.6% 17.5%; 55 | --muted-foreground: 215 20.2% 65.1%; 56 | 57 | --accent: 217.2 32.6% 17.5%; 58 | --accent-foreground: 210 40% 98%; 59 | 60 | --destructive: 0 62.8% 30.6%; 61 | --destructive-foreground: 210 40% 98%; 62 | 63 | --border: 217.2 32.6% 17.5%; 64 | --input: 217.2 32.6% 17.5%; 65 | --ring: 212.7 26.8% 83.9%; 66 | } 67 | } 68 | 69 | @layer base { 70 | * { 71 | @apply border-border; 72 | } 73 | body { 74 | @apply bg-background text-foreground; 75 | } 76 | } 77 | 78 | .dot-typing { 79 | position: relative; 80 | left: -9999px; 81 | width: 10px; 82 | height: 10px; 83 | border-radius: 5px; 84 | background-color: #fff; 85 | color: #fff; 86 | box-shadow: 87 | 9984px 0 0 0 #fff, 88 | 9999px 0 0 0 #fff, 89 | 10014px 0 0 0 #fff; 90 | animation: dotTyping 1.5s infinite linear; 91 | } 92 | 93 | @keyframes dotTyping { 94 | 0% { 95 | box-shadow: 96 | 9984px 0 0 0 #fff, 97 | 9999px 0 0 0 #fff, 98 | 10014px 0 0 0 #fff; 99 | } 100 | 16.667% { 101 | box-shadow: 102 | 9984px -10px 0 0 #fff, 103 | 9999px 0 0 0 #fff, 104 | 10014px 0 0 0 #fff; 105 | } 106 | 33.333% { 107 | box-shadow: 108 | 9984px 0 0 0 #fff, 109 | 9999px 0 0 0 #fff, 110 | 10014px 0 0 0 #fff; 111 | } 112 | 50% { 113 | box-shadow: 114 | 9984px 0 0 0 #fff, 115 | 9999px -10px 0 0 #fff, 116 | 10014px 0 0 0 #fff; 117 | } 118 | 66.667% { 119 | box-shadow: 120 | 9984px 0 0 0 #fff, 121 | 9999px 0 0 0 #fff, 122 | 10014px 0 0 0 #fff; 123 | } 124 | 83.333% { 125 | box-shadow: 126 | 9984px 0 0 0 #fff, 127 | 9999px 0 0 0 #fff, 128 | 10014px -10px 0 0 #fff; 129 | } 130 | 100% { 131 | box-shadow: 132 | 9984px 0 0 0 #fff, 133 | 9999px 0 0 0 #fff, 134 | 10014px 0 0 0 #fff; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /frontend/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "./globals.css"; 2 | import type { Metadata } from "next"; 3 | 4 | import { EndpointsContext } from "./agent"; 5 | import { ReactNode } from "react"; 6 | 7 | export const metadata: Metadata = { 8 | title: "LangChain Gen UI", 9 | description: "Generative UI application with LangChain Python", 10 | }; 11 | 12 | export default function RootLayout(props: { children: ReactNode }) { 13 | return ( 14 | 15 | 16 |
17 | {props.children} 18 |
19 | 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /frontend/app/page.tsx: -------------------------------------------------------------------------------- 1 | import Chat from "@/components/prebuilt/chat"; 2 | 3 | export default function Home() { 4 | return ( 5 |
6 |
7 |

8 | Generative UI with{" "} 9 | 14 | LangChain Python 🦜🔗 15 | 16 |

17 | 18 |
19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /frontend/app/shared.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { createContext } from "react"; 3 | 4 | export const LocalContext = createContext<(value: string) => void>( 5 | () => void 0, 6 | ); 7 | -------------------------------------------------------------------------------- /frontend/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /frontend/components/prebuilt/chat.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import { Input } from "../ui/input"; 5 | import { Button } from "../ui/button"; 6 | import { EndpointsContext } from "@/app/agent"; 7 | import { useActions } from "@/utils/client"; 8 | import { LocalContext } from "@/app/shared"; 9 | import { RemoteRunnable } from "@langchain/core/runnables/remote"; 10 | import { Github, GithubLoading } from "./github"; 11 | import { Invoice, InvoiceLoading } from "./invoice"; 12 | import { CurrentWeather, CurrentWeatherLoading } from "./weather"; 13 | import { createStreamableUI, createStreamableValue } from "ai/rsc"; 14 | import { StreamEvent } from "@langchain/core/tracers/log_stream"; 15 | import { AIMessage } from "@/ai/message"; 16 | import { HumanMessageText } from "./message"; 17 | 18 | export interface ChatProps {} 19 | 20 | function convertFileToBase64(file: File): Promise { 21 | return new Promise((resolve, reject) => { 22 | const reader = new FileReader(); 23 | reader.onload = () => { 24 | const base64String = reader.result as string; 25 | resolve(base64String.split(",")[1]); // Remove the data URL prefix 26 | }; 27 | reader.onerror = (error) => { 28 | reject(error); 29 | }; 30 | reader.readAsDataURL(file); 31 | }); 32 | } 33 | 34 | function FileUploadMessage({ file }: { file: File }) { 35 | return ( 36 |
37 |

File uploaded: {file.name}

38 |
39 | ); 40 | } 41 | 42 | export default function Chat() { 43 | const actions = useActions(); 44 | 45 | const [elements, setElements] = useState([]); 46 | const [history, setHistory] = useState<[role: string, content: string][]>([]); 47 | const [input, setInput] = useState(""); 48 | const [selectedFile, setSelectedFile] = useState(); 49 | 50 | async function onSubmit(input: string) { 51 | const newElements = [...elements]; 52 | let base64File: string | undefined = undefined; 53 | let fileExtension = selectedFile?.type.split("/")[1]; 54 | if (selectedFile) { 55 | base64File = await convertFileToBase64(selectedFile); 56 | } 57 | const element = await actions.agent({ 58 | input, 59 | chat_history: history, 60 | file: 61 | base64File && fileExtension 62 | ? { 63 | base64: base64File, 64 | extension: fileExtension, 65 | } 66 | : undefined, 67 | }); 68 | 69 | newElements.push( 70 |
71 | {selectedFile && } 72 | 73 |
74 | {element.ui} 75 |
76 |
, 77 | ); 78 | 79 | // consume the value stream to obtain the final string value 80 | // after which we can append to our chat history state 81 | (async () => { 82 | let lastEvent = await element.lastEvent; 83 | if (Array.isArray(lastEvent)) { 84 | if (lastEvent[0].invoke_model && lastEvent[0].invoke_model.result) { 85 | setHistory((prev) => [ 86 | ...prev, 87 | ["human", input], 88 | ["ai", lastEvent[0].invoke_model.result], 89 | ]); 90 | } else if (lastEvent[1].invoke_tools) { 91 | setHistory((prev) => [ 92 | ...prev, 93 | ["human", input], 94 | [ 95 | "ai", 96 | `Tool result: ${JSON.stringify(lastEvent[1].invoke_tools.tool_result, null)}`, 97 | ], 98 | ]); 99 | } else { 100 | setHistory((prev) => [...prev, ["human", input]]); 101 | } 102 | } else if (lastEvent.invoke_model && lastEvent.invoke_model.result) { 103 | setHistory((prev) => [ 104 | ...prev, 105 | ["human", input], 106 | ["ai", lastEvent.invoke_model.result], 107 | ]); 108 | } 109 | })(); 110 | 111 | setElements(newElements); 112 | setInput(""); 113 | setSelectedFile(undefined); 114 | } 115 | 116 | return ( 117 |
118 | 119 |
{elements}
120 |
121 |
{ 123 | e.stopPropagation(); 124 | e.preventDefault(); 125 | await onSubmit(input); 126 | }} 127 | className="w-full flex flex-row gap-2" 128 | > 129 | setInput(e.target.value)} 133 | /> 134 |
135 | { 141 | if (e.target.files && e.target.files.length > 0) { 142 | setSelectedFile(e.target.files[0]); 143 | } 144 | }} 145 | /> 146 |
147 | 148 |
149 |
150 | ); 151 | } 152 | -------------------------------------------------------------------------------- /frontend/components/prebuilt/display-types-dialog.tsx: -------------------------------------------------------------------------------- 1 | import { DataDisplayTypeAndDescription } from "@/app/charts/filters"; 2 | import { Button } from "@/components/ui/button"; 3 | import { 4 | Dialog, 5 | DialogClose, 6 | DialogContent, 7 | DialogDescription, 8 | DialogFooter, 9 | DialogHeader, 10 | DialogTitle, 11 | DialogTrigger, 12 | } from "@/components/ui/dialog"; 13 | 14 | export interface DisplayTypesDialogProps { 15 | displayTypes: DataDisplayTypeAndDescription[]; 16 | } 17 | 18 | export function DisplayTypesDialog(props: DisplayTypesDialogProps) { 19 | const lineChartDisplayTypes = props.displayTypes.filter( 20 | ({ chartType }) => chartType === "line", 21 | ); 22 | const barChartDisplayTypes = props.displayTypes.filter( 23 | ({ chartType }) => chartType === "bar", 24 | ); 25 | const pieChartDisplayTypes = props.displayTypes.filter( 26 | ({ chartType }) => chartType === "pie", 27 | ); 28 | return ( 29 | 30 | 31 | 32 | 33 | 34 | 35 | Display Types 36 | 37 | All the ways the LLM can display data in the UI. 38 | 39 | 40 |
41 |
42 | {/* Bar Charts section */} 43 |

Bar Charts

44 | {barChartDisplayTypes.map(({ title, description }, idx) => ( 45 |
49 |

{title}

50 |

{description}

51 |
52 | ))} 53 | {/* Pie Charts section */} 54 |

Pie Charts

55 | {pieChartDisplayTypes.map(({ title, description }, idx) => ( 56 |
60 |

{title}

61 |

{description}

62 |
63 | ))} 64 | {/* Line Charts section */} 65 |

Line Charts

66 | {lineChartDisplayTypes.map(({ title, description }, idx) => ( 67 |
71 |

{title}

72 |

{description}

73 |
74 | ))} 75 |
76 |
77 | 78 | 79 | 82 | 83 | 84 |
85 |
86 | ); 87 | } 88 | -------------------------------------------------------------------------------- /frontend/components/prebuilt/filter-options-dialog.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { 3 | Dialog, 4 | DialogClose, 5 | DialogContent, 6 | DialogDescription, 7 | DialogFooter, 8 | DialogHeader, 9 | DialogTitle, 10 | DialogTrigger, 11 | } from "@/components/ui/dialog"; 12 | 13 | export function FilterOptionsDialog() { 14 | return ( 15 | 16 | 17 | 18 | 19 | 20 | 21 | Filters 22 | 23 | Available filters for the Magic Filter input: 24 | 25 | 26 |
27 | {filterOptions.map((option, index) => ( 28 |
29 |

{option.title}

30 |

{option.description}

31 |
32 | ))} 33 |
34 | 35 | 36 | 39 | 40 | 41 |
42 |
43 | ); 44 | } 45 | 46 | const filterOptions = [ 47 | { 48 | title: "Product Names", 49 | description: "Filter orders by product name (lowercase only).", 50 | }, 51 | { 52 | title: "Before Date", 53 | description: "Filter orders placed before this date (format: YYYY-MM-DD).", 54 | }, 55 | { 56 | title: "After Date", 57 | description: "Filter orders placed after this date (format: YYYY-MM-DD).", 58 | }, 59 | { 60 | title: "Min Amount", 61 | description: "The minimum amount of the order to filter by.", 62 | }, 63 | { 64 | title: "Max Amount", 65 | description: "The maximum amount of the order to filter by.", 66 | }, 67 | { 68 | title: "State", 69 | description: 70 | "Filter orders by the state the order was placed in (e.g., 'California').", 71 | }, 72 | { 73 | title: "Discount", 74 | description: "Filter orders by whether or not a discount was applied.", 75 | }, 76 | { 77 | title: "Min Discount Percentage", 78 | description: 79 | "Filter orders which had at least this amount discounted (in percentage).", 80 | }, 81 | { 82 | title: "Status", 83 | description: 84 | "The current status of the order (pending, processing, shipped, delivered, cancelled, returned).", 85 | }, 86 | ]; 87 | -------------------------------------------------------------------------------- /frontend/components/prebuilt/filter.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { startCase } from "lodash"; 4 | 5 | export interface FilterButtonProps { 6 | filterKey: string; 7 | filterValue: string | number; 8 | } 9 | 10 | export function FilterButton(props: FilterButtonProps): JSX.Element { 11 | return ( 12 |
13 |

14 | {startCase(props.filterKey)}: 15 |

16 |

{props.filterValue}

17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /frontend/components/prebuilt/github.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { CircleIcon, StarIcon } from "@radix-ui/react-icons"; 4 | 5 | import { Button } from "@/components/ui/button"; 6 | import { 7 | Card, 8 | CardContent, 9 | CardDescription, 10 | CardHeader, 11 | CardTitle, 12 | } from "@/components/ui/card"; 13 | import { Skeleton } from "../ui/skeleton"; 14 | import { format } from "date-fns"; 15 | 16 | export interface DemoGithubProps { 17 | owner: string; 18 | repo: string; 19 | description: string; 20 | stars: number; 21 | language: string; 22 | } 23 | 24 | export function GithubLoading(): JSX.Element { 25 | return ( 26 | 27 | 28 |
29 | 30 | 31 | 32 | 33 |
34 | {Array.from({ length: 3 }).map((_, i) => ( 35 | 39 | ))} 40 |
41 |
42 |
43 |
44 | 45 |
46 |
47 | 48 | 49 | 50 |
51 | ); 52 | } 53 | 54 | export function Github(props: DemoGithubProps): JSX.Element { 55 | const currentMonth = format(new Date(), "MMMM"); 56 | const currentYear = format(new Date(), "yyyy"); 57 | return ( 58 | 59 | 60 |
61 | 62 | {props.owner}/{props.repo} 63 | 64 | {props.description} 65 |
66 |
67 | 77 |
78 |
79 | 80 |
81 |
82 | 83 | {props.language} 84 |
85 |
86 | 87 | {props.stars.toLocaleString()} 88 |
89 |
90 | Updated {currentMonth} {currentYear} 91 |
92 |
93 |
94 |
95 | ); 96 | } 97 | -------------------------------------------------------------------------------- /frontend/components/prebuilt/invoice.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Copy, CreditCard, MoreVertical, Truck } from "lucide-react"; 4 | 5 | import { Button } from "@/components/ui/button"; 6 | import { 7 | Card, 8 | CardContent, 9 | CardDescription, 10 | CardFooter, 11 | CardHeader, 12 | CardTitle, 13 | } from "@/components/ui/card"; 14 | import { 15 | DropdownMenu, 16 | DropdownMenuContent, 17 | DropdownMenuItem, 18 | DropdownMenuSeparator, 19 | DropdownMenuTrigger, 20 | } from "@/components/ui/dropdown-menu"; 21 | import { Separator } from "@/components/ui/separator"; 22 | import { useEffect, useState } from "react"; 23 | import { Skeleton } from "../ui/skeleton"; 24 | import { format } from "date-fns"; 25 | 26 | export type LineItem = { 27 | id: string; 28 | name: string; 29 | quantity: number; 30 | price: number; 31 | }; 32 | 33 | export type ShippingAddress = { 34 | name: string; 35 | street: string; 36 | city: string; 37 | state: string; 38 | zip: string; 39 | }; 40 | 41 | export type CustomerInfo = { 42 | name: string; 43 | email?: string; 44 | phone?: string; 45 | }; 46 | 47 | export type PaymentInfo = { 48 | cardType: string; 49 | cardNumberLastFour: string; 50 | }; 51 | 52 | export interface InvoiceProps { 53 | orderId: string; 54 | lineItems: LineItem[]; 55 | shippingAddress?: ShippingAddress; 56 | customerInfo?: CustomerInfo; 57 | paymentInfo?: PaymentInfo; 58 | } 59 | 60 | export function InvoiceLoading(): JSX.Element { 61 | return ( 62 | 63 | 64 |
65 |
66 | 67 |
68 |
Order Details
69 |
    70 | {Array.from({ length: 3 }).map((_, i) => ( 71 |
  • 75 | 76 | 77 | 78 | 79 | 80 | 81 |
  • 82 | ))} 83 |
84 | 85 |
    86 |
  • 87 | Subtotal 88 | 89 | 90 | 91 |
  • 92 |
  • 93 | Shipping 94 | 95 | 96 | 97 |
  • 98 |
  • 99 | Tax 100 | 101 | 102 | 103 |
  • 104 |
  • 105 | Total 106 | 107 | 108 | 109 |
  • 110 |
111 |
112 | 113 |
114 |
115 |
Shipping Information
116 |
117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 |
127 |
128 |
129 |
Billing Information
130 |
131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 |
141 |
142 |
143 | 144 |
145 |
Customer Information
146 |
147 |
148 |
Customer
149 |
150 | 151 |
152 |
153 |
154 |
Email
155 |
156 | 157 | 158 | 159 |
160 |
161 |
162 |
Phone
163 |
164 | 165 | 166 | 167 |
168 |
169 |
170 |
171 | 172 |
173 |
Payment Information
174 |
175 |
176 |
177 | 178 | 179 |
180 |
181 | 182 |
183 |
184 |
185 |
186 |
187 | 188 |
189 | Updated 190 |
191 |
192 |
193 | ); 194 | } 195 | 196 | export function Invoice(props: InvoiceProps): JSX.Element { 197 | const [priceDetails, setPriceDetails] = useState({ 198 | shipping: 5.0, 199 | tax: 0.0, 200 | total: 0.0, 201 | lineItemTotal: 0.0, 202 | }); 203 | const currentMonth = format(new Date(), "MMMM"); 204 | const currentDay = format(new Date(), "EEEE"); 205 | const currentYear = format(new Date(), "yyyy"); 206 | 207 | useEffect(() => { 208 | if (props.lineItems.length > 0) { 209 | const totalPriceLineItems = props.lineItems 210 | .reduce((acc, lineItem) => { 211 | return acc + lineItem.price * lineItem.quantity; 212 | }, 0) 213 | .toFixed(2); 214 | const shipping = 5.0; 215 | const tax = Number(totalPriceLineItems) * 0.075; 216 | const total = (Number(totalPriceLineItems) + shipping + tax).toFixed(2); 217 | setPriceDetails({ 218 | shipping, 219 | tax, 220 | total: Number(total), 221 | lineItemTotal: Number(totalPriceLineItems), 222 | }); 223 | } 224 | }, [props.lineItems]); 225 | 226 | return ( 227 | 228 | 229 |
230 | 231 | Order {props.orderId} 232 | 240 | 241 | 242 | Date: {currentMonth} {currentDay}, {currentYear} 243 | 244 |
245 |
246 | 252 | 253 | 254 | 258 | 259 | 260 | Edit 261 | Export 262 | 263 | Trash 264 | 265 | 266 |
267 |
268 | 269 |
270 |
Order Details
271 |
    272 | {props.lineItems.map((lineItem) => { 273 | const totalPrice = (lineItem.price * lineItem.quantity).toFixed( 274 | 2, 275 | ); 276 | return ( 277 |
  • 281 | 282 | {lineItem.name} x {lineItem.quantity} 283 | 284 | ${totalPrice} 285 |
  • 286 | ); 287 | })} 288 |
289 | 290 |
    291 |
  • 292 | Subtotal 293 | ${priceDetails.lineItemTotal} 294 |
  • 295 |
  • 296 | Shipping 297 | ${priceDetails.shipping} 298 |
  • 299 |
  • 300 | Tax 301 | ${priceDetails.tax} 302 |
  • 303 |
  • 304 | Total 305 | ${priceDetails.total} 306 |
  • 307 |
308 |
309 | 310 | {props.shippingAddress && ( 311 | <> 312 | 313 |
314 |
315 |
Shipping Information
316 |
317 | {props.shippingAddress.name} 318 | {props.shippingAddress.street} 319 | 320 | {props.shippingAddress.city} {props.shippingAddress.state},{" "} 321 | {props.shippingAddress.zip} 322 | 323 |
324 |
325 |
326 |
Billing Information
327 |
328 | Same as shipping address 329 |
330 |
331 |
332 | 333 | )} 334 | 335 | {props.customerInfo && ( 336 | <> 337 | 338 |
339 |
Customer Information
340 |
341 |
342 |
Customer
343 |
{props.customerInfo.name}
344 |
345 | {props.customerInfo.email && ( 346 |
347 |
Email
348 |
349 | {props.customerInfo.email} 350 |
351 |
352 | )} 353 | {props.customerInfo.phone && ( 354 |
355 |
Phone
356 |
357 | {props.customerInfo.phone} 358 |
359 |
360 | )} 361 |
362 |
363 | 364 | )} 365 | 366 | {props.paymentInfo && ( 367 | <> 368 | 369 |
370 |
Payment Information
371 |
372 |
373 |
374 | 375 | {props.paymentInfo.cardType} 376 |
377 |
**** **** **** {props.paymentInfo.cardNumberLastFour}
378 |
379 |
380 |
381 | 382 | )} 383 |
384 | 385 |
386 | Updated 387 |
388 |
389 |
390 | ); 391 | } 392 | -------------------------------------------------------------------------------- /frontend/components/prebuilt/loading-charts.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from "@/components/ui/skeleton"; 2 | 3 | export function LoadingPieChart(): JSX.Element { 4 | return ( 5 |
6 | 7 |
8 | ); 9 | } 10 | 11 | export function LoadingBarChart(): JSX.Element { 12 | return ( 13 |
14 | 15 | 16 | 17 | 18 | 19 |
20 | ); 21 | } 22 | 23 | export function LoadingLineChart(): JSX.Element { 24 | return ( 25 |
26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 |
44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /frontend/components/prebuilt/message.tsx: -------------------------------------------------------------------------------- 1 | import Markdown from "react-markdown"; 2 | 3 | export interface MessageTextProps { 4 | content: string; 5 | } 6 | 7 | export function AIMessageText(props: MessageTextProps) { 8 | return ( 9 |
10 |

11 | {props.content} 12 |

13 |
14 | ); 15 | } 16 | 17 | export function HumanMessageText(props: MessageTextProps) { 18 | return ( 19 |
20 |

21 | {props.content} 22 |

23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /frontend/components/prebuilt/weather.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Card } from "../ui/card"; 4 | import { format } from "date-fns"; 5 | import { Progress } from "../ui/progress"; 6 | import { Skeleton } from "../ui/skeleton"; 7 | 8 | export interface CurrentWeatherProps { 9 | temperature: number; 10 | city: string; 11 | state: string; 12 | } 13 | 14 | export function CurrentWeatherLoading(): JSX.Element { 15 | return ( 16 | 17 |
18 | 19 | 20 |
21 |
22 | 23 |
24 |
25 |
26 | 27 | 28 | 29 |
30 |
31 |
32 | 33 |
34 |
35 | ); 36 | } 37 | 38 | export function CurrentWeather(props: CurrentWeatherProps): JSX.Element { 39 | const currentTime = format(new Date(), "hh:mm:ss a"); 40 | const currentDay = format(new Date(), "EEEE"); 41 | // assume the maximum temperature is 130 and the minium is -20 42 | const weatherAsPercentage = (props.temperature + 20) / 150; 43 | return ( 44 | 45 |
46 |

{currentDay}

47 |

{currentTime}

48 |
49 |
50 |

51 | {props.city}, {props.state} 52 |

53 |
54 |
55 |
56 |

{props.temperature}°

57 |
58 |
59 |
60 | 61 |
62 |
63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /frontend/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as AvatarPrimitive from "@radix-ui/react-avatar"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | const Avatar = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )); 21 | Avatar.displayName = AvatarPrimitive.Root.displayName; 22 | 23 | const AvatarImage = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 32 | )); 33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName; 34 | 35 | const AvatarFallback = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | )); 48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; 49 | 50 | export { Avatar, AvatarImage, AvatarFallback }; 51 | -------------------------------------------------------------------------------- /frontend/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { cva, type VariantProps } from "class-variance-authority"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | }, 24 | ); 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
33 | ); 34 | } 35 | 36 | export { Badge, badgeVariants }; 37 | -------------------------------------------------------------------------------- /frontend/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Slot } from "@radix-ui/react-slot"; 3 | import { cva, type VariantProps } from "class-variance-authority"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | }, 34 | ); 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean; 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button"; 45 | return ( 46 | 51 | ); 52 | }, 53 | ); 54 | Button.displayName = "Button"; 55 | 56 | export { Button, buttonVariants }; 57 | -------------------------------------------------------------------------------- /frontend/components/ui/calendar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { ChevronLeft, ChevronRight } from "lucide-react"; 5 | import { DayPicker } from "react-day-picker"; 6 | 7 | import { cn } from "@/lib/utils"; 8 | import { buttonVariants } from "@/components/ui/button"; 9 | 10 | export type CalendarProps = React.ComponentProps; 11 | 12 | function Calendar({ 13 | className, 14 | classNames, 15 | showOutsideDays = true, 16 | ...props 17 | }: CalendarProps) { 18 | return ( 19 | , 58 | IconRight: ({ ...props }) => , 59 | }} 60 | {...props} 61 | /> 62 | ); 63 | } 64 | Calendar.displayName = "Calendar"; 65 | 66 | export { Calendar }; 67 | -------------------------------------------------------------------------------- /frontend/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )); 18 | Card.displayName = "Card"; 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )); 30 | CardHeader.displayName = "CardHeader"; 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |

44 | )); 45 | CardTitle.displayName = "CardTitle"; 46 | 47 | const CardDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |

56 | )); 57 | CardDescription.displayName = "CardDescription"; 58 | 59 | const CardContent = React.forwardRef< 60 | HTMLDivElement, 61 | React.HTMLAttributes 62 | >(({ className, ...props }, ref) => ( 63 |

64 | )); 65 | CardContent.displayName = "CardContent"; 66 | 67 | const CardFooter = React.forwardRef< 68 | HTMLDivElement, 69 | React.HTMLAttributes 70 | >(({ className, ...props }, ref) => ( 71 |
76 | )); 77 | CardFooter.displayName = "CardFooter"; 78 | 79 | export { 80 | Card, 81 | CardHeader, 82 | CardFooter, 83 | CardTitle, 84 | CardDescription, 85 | CardContent, 86 | }; 87 | -------------------------------------------------------------------------------- /frontend/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as DialogPrimitive from "@radix-ui/react-dialog"; 5 | import { X } from "lucide-react"; 6 | 7 | import { cn } from "@/lib/utils"; 8 | 9 | const Dialog = DialogPrimitive.Root; 10 | 11 | const DialogTrigger = DialogPrimitive.Trigger; 12 | 13 | const DialogPortal = DialogPrimitive.Portal; 14 | 15 | const DialogClose = DialogPrimitive.Close; 16 | 17 | const DialogOverlay = React.forwardRef< 18 | React.ElementRef, 19 | React.ComponentPropsWithoutRef 20 | >(({ className, ...props }, ref) => ( 21 | 29 | )); 30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; 31 | 32 | const DialogContent = React.forwardRef< 33 | React.ElementRef, 34 | React.ComponentPropsWithoutRef 35 | >(({ className, children, ...props }, ref) => ( 36 | 37 | 38 | 46 | {children} 47 | 48 | 49 | Close 50 | 51 | 52 | 53 | )); 54 | DialogContent.displayName = DialogPrimitive.Content.displayName; 55 | 56 | const DialogHeader = ({ 57 | className, 58 | ...props 59 | }: React.HTMLAttributes) => ( 60 |
67 | ); 68 | DialogHeader.displayName = "DialogHeader"; 69 | 70 | const DialogFooter = ({ 71 | className, 72 | ...props 73 | }: React.HTMLAttributes) => ( 74 |
81 | ); 82 | DialogFooter.displayName = "DialogFooter"; 83 | 84 | const DialogTitle = React.forwardRef< 85 | React.ElementRef, 86 | React.ComponentPropsWithoutRef 87 | >(({ className, ...props }, ref) => ( 88 | 96 | )); 97 | DialogTitle.displayName = DialogPrimitive.Title.displayName; 98 | 99 | const DialogDescription = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )); 109 | DialogDescription.displayName = DialogPrimitive.Description.displayName; 110 | 111 | export { 112 | Dialog, 113 | DialogPortal, 114 | DialogOverlay, 115 | DialogClose, 116 | DialogTrigger, 117 | DialogContent, 118 | DialogHeader, 119 | DialogFooter, 120 | DialogTitle, 121 | DialogDescription, 122 | }; 123 | -------------------------------------------------------------------------------- /frontend/components/ui/dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; 5 | import { Check, ChevronRight, Circle } from "lucide-react"; 6 | 7 | import { cn } from "@/lib/utils"; 8 | 9 | const DropdownMenu = DropdownMenuPrimitive.Root; 10 | 11 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; 12 | 13 | const DropdownMenuGroup = DropdownMenuPrimitive.Group; 14 | 15 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal; 16 | 17 | const DropdownMenuSub = DropdownMenuPrimitive.Sub; 18 | 19 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; 20 | 21 | const DropdownMenuSubTrigger = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef & { 24 | inset?: boolean; 25 | } 26 | >(({ className, inset, children, ...props }, ref) => ( 27 | 36 | {children} 37 | 38 | 39 | )); 40 | DropdownMenuSubTrigger.displayName = 41 | DropdownMenuPrimitive.SubTrigger.displayName; 42 | 43 | const DropdownMenuSubContent = React.forwardRef< 44 | React.ElementRef, 45 | React.ComponentPropsWithoutRef 46 | >(({ className, ...props }, ref) => ( 47 | 55 | )); 56 | DropdownMenuSubContent.displayName = 57 | DropdownMenuPrimitive.SubContent.displayName; 58 | 59 | const DropdownMenuContent = React.forwardRef< 60 | React.ElementRef, 61 | React.ComponentPropsWithoutRef 62 | >(({ className, sideOffset = 4, ...props }, ref) => ( 63 | 64 | 73 | 74 | )); 75 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; 76 | 77 | const DropdownMenuItem = React.forwardRef< 78 | React.ElementRef, 79 | React.ComponentPropsWithoutRef & { 80 | inset?: boolean; 81 | } 82 | >(({ className, inset, ...props }, ref) => ( 83 | 92 | )); 93 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; 94 | 95 | const DropdownMenuCheckboxItem = React.forwardRef< 96 | React.ElementRef, 97 | React.ComponentPropsWithoutRef 98 | >(({ className, children, checked, ...props }, ref) => ( 99 | 108 | 109 | 110 | 111 | 112 | 113 | {children} 114 | 115 | )); 116 | DropdownMenuCheckboxItem.displayName = 117 | DropdownMenuPrimitive.CheckboxItem.displayName; 118 | 119 | const DropdownMenuRadioItem = React.forwardRef< 120 | React.ElementRef, 121 | React.ComponentPropsWithoutRef 122 | >(({ className, children, ...props }, ref) => ( 123 | 131 | 132 | 133 | 134 | 135 | 136 | {children} 137 | 138 | )); 139 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; 140 | 141 | const DropdownMenuLabel = React.forwardRef< 142 | React.ElementRef, 143 | React.ComponentPropsWithoutRef & { 144 | inset?: boolean; 145 | } 146 | >(({ className, inset, ...props }, ref) => ( 147 | 156 | )); 157 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; 158 | 159 | const DropdownMenuSeparator = React.forwardRef< 160 | React.ElementRef, 161 | React.ComponentPropsWithoutRef 162 | >(({ className, ...props }, ref) => ( 163 | 168 | )); 169 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; 170 | 171 | const DropdownMenuShortcut = ({ 172 | className, 173 | ...props 174 | }: React.HTMLAttributes) => { 175 | return ( 176 | 180 | ); 181 | }; 182 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; 183 | 184 | export { 185 | DropdownMenu, 186 | DropdownMenuTrigger, 187 | DropdownMenuContent, 188 | DropdownMenuItem, 189 | DropdownMenuCheckboxItem, 190 | DropdownMenuRadioItem, 191 | DropdownMenuLabel, 192 | DropdownMenuSeparator, 193 | DropdownMenuShortcut, 194 | DropdownMenuGroup, 195 | DropdownMenuPortal, 196 | DropdownMenuSub, 197 | DropdownMenuSubContent, 198 | DropdownMenuSubTrigger, 199 | DropdownMenuRadioGroup, 200 | }; 201 | -------------------------------------------------------------------------------- /frontend/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const Input = React.forwardRef( 9 | ({ className, type, ...props }, ref) => { 10 | return ( 11 | 20 | ); 21 | }, 22 | ); 23 | Input.displayName = "Input"; 24 | 25 | export { Input }; 26 | -------------------------------------------------------------------------------- /frontend/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as LabelPrimitive from "@radix-ui/react-label"; 5 | import { cva, type VariantProps } from "class-variance-authority"; 6 | 7 | import { cn } from "@/lib/utils"; 8 | 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", 11 | ); 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )); 24 | Label.displayName = LabelPrimitive.Root.displayName; 25 | 26 | export { Label }; 27 | -------------------------------------------------------------------------------- /frontend/components/ui/pagination.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | import { ButtonProps, buttonVariants } from "@/components/ui/button"; 6 | 7 | const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => ( 8 |