├── .eslintrc.json ├── .gitignore ├── README.md ├── components.json ├── next.config.mjs ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── public ├── next.svg └── vercel.svg ├── src ├── agents │ └── finance.ts ├── app │ ├── action.tsx │ ├── favicon.ico │ ├── layout.tsx │ └── page.tsx ├── components │ ├── llm │ │ ├── chart.tsx │ │ ├── chat-input.tsx │ │ ├── chat.tsx │ │ ├── fcall.tsx │ │ ├── financials.tsx │ │ ├── message-list.tsx │ │ ├── message.tsx │ │ ├── news.tsx │ │ └── submit.tsx │ ├── markdown.tsx │ ├── scroll-to-bottom-btn.tsx │ └── ui │ │ ├── accordion.tsx │ │ ├── alert-dialog.tsx │ │ ├── alert.tsx │ │ ├── aspect-ratio.tsx │ │ ├── avatar.tsx │ │ ├── badge.tsx │ │ ├── breadcrumb.tsx │ │ ├── button.tsx │ │ ├── calendar.tsx │ │ ├── card.tsx │ │ ├── carousel.tsx │ │ ├── checkbox.tsx │ │ ├── codeblock.tsx │ │ ├── collapsible.tsx │ │ ├── command.tsx │ │ ├── context-menu.tsx │ │ ├── dialog.tsx │ │ ├── drawer.tsx │ │ ├── dropdown-menu.tsx │ │ ├── form.tsx │ │ ├── hover-card.tsx │ │ ├── input-otp.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── menubar.tsx │ │ ├── navigation-menu.tsx │ │ ├── pagination.tsx │ │ ├── popover.tsx │ │ ├── progress.tsx │ │ ├── radio-group.tsx │ │ ├── resizable.tsx │ │ ├── scroll-area.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ ├── sheet.tsx │ │ ├── skeleton.tsx │ │ ├── slider.tsx │ │ ├── sonner.tsx │ │ ├── spinner.tsx │ │ ├── switch.tsx │ │ ├── table.tsx │ │ ├── tabs.tsx │ │ ├── textarea.tsx │ │ ├── toast.tsx │ │ ├── toaster.tsx │ │ ├── toggle-group.tsx │ │ ├── toggle.tsx │ │ ├── tooltip.tsx │ │ └── use-toast.ts ├── hooks │ ├── use-enter-submit.tsx │ ├── use-scroll-anchor.tsx │ └── use-streamable-text.ts ├── lib │ ├── polygon.ts │ └── utils.ts └── styles │ └── globals.css ├── tailwind.config.ts └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.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 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FinGen 2 | 3 | ### Experiment with RSC and langchain to do financial analysis with gpt-4-turbo 4 | 5 | Leverages RSC from vercel's ai sdk as well as LangChain agents, and the Polygon finance api to analyze companies 6 | 7 | 8 | ## Disclaimer 9 | 10 | Please note that this finance agent is **not intended as financial advice**. It is designed to provide users with tools and information to explore financial concepts and scenarios. Users should conduct their own research or consult with a financial advisor before making any financial decisions. 11 | 12 | ## Getting Started 13 | 14 | You'll need a polygon API key and openai api key 15 | ``` 16 | OPENAI_API_KEY= 17 | POLY_API_KEY= 18 | ``` 19 | 20 | Install with npm or pnpm 21 | `pnpm i` 22 | 23 | Then 24 | `pnpm dev` 25 | 26 | -------------------------------------------------------------------------------- /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": "src/app/globals.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fingen", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@headlessui/react": "^1.7.18", 13 | "@headlessui/tailwindcss": "^0.2.0", 14 | "@hookform/resolvers": "^3.3.4", 15 | "@langchain/core": "^0.1.49", 16 | "@langchain/openai": "^0.0.23", 17 | "@polygon.io/client-js": "^7.3.2", 18 | "@radix-ui/react-accordion": "^1.1.2", 19 | "@radix-ui/react-alert-dialog": "^1.0.5", 20 | "@radix-ui/react-aspect-ratio": "^1.0.3", 21 | "@radix-ui/react-avatar": "^1.0.4", 22 | "@radix-ui/react-checkbox": "^1.0.4", 23 | "@radix-ui/react-collapsible": "^1.0.3", 24 | "@radix-ui/react-context-menu": "^2.1.5", 25 | "@radix-ui/react-dialog": "^1.0.5", 26 | "@radix-ui/react-dropdown-menu": "^2.0.6", 27 | "@radix-ui/react-hover-card": "^1.0.7", 28 | "@radix-ui/react-icons": "^1.3.0", 29 | "@radix-ui/react-label": "^2.0.2", 30 | "@radix-ui/react-menubar": "^1.0.4", 31 | "@radix-ui/react-navigation-menu": "^1.1.4", 32 | "@radix-ui/react-popover": "^1.0.7", 33 | "@radix-ui/react-progress": "^1.0.3", 34 | "@radix-ui/react-radio-group": "^1.1.3", 35 | "@radix-ui/react-scroll-area": "^1.0.5", 36 | "@radix-ui/react-select": "^2.0.0", 37 | "@radix-ui/react-separator": "^1.0.3", 38 | "@radix-ui/react-slider": "^1.1.2", 39 | "@radix-ui/react-slot": "^1.0.2", 40 | "@radix-ui/react-switch": "^1.0.3", 41 | "@radix-ui/react-tabs": "^1.0.4", 42 | "@radix-ui/react-toast": "^1.1.5", 43 | "@radix-ui/react-toggle": "^1.0.3", 44 | "@radix-ui/react-toggle-group": "^1.0.4", 45 | "@radix-ui/react-tooltip": "^1.0.7", 46 | "@remixicon/react": "^4.2.0", 47 | "@tailwindcss/forms": "^0.5.7", 48 | "@tremor/react": "^3.14.1", 49 | "ai": "^3.0.13", 50 | "axios": "^1.6.8", 51 | "class-variance-authority": "^0.7.0", 52 | "clsx": "^2.1.0", 53 | "cmdk": "^1.0.0", 54 | "date-fns": "^3.6.0", 55 | "embla-carousel-react": "^8.0.0", 56 | "input-otp": "^1.2.2", 57 | "langchain": "^0.1.28", 58 | "lucide-react": "^0.363.0", 59 | "next": "14.1.4", 60 | "next-themes": "^0.3.0", 61 | "openai": "^4.29.2", 62 | "react": "^18", 63 | "react-day-picker": "^8.10.0", 64 | "react-dom": "^18", 65 | "react-hook-form": "^7.51.1", 66 | "react-markdown": "^9.0.1", 67 | "react-resizable-panels": "^2.0.14", 68 | "react-syntax-highlighter": "^15.5.0", 69 | "remark-gfm": "^4.0.0", 70 | "remark-math": "^6.0.0", 71 | "sonner": "^1.4.41", 72 | "tailwind-merge": "^2.2.2", 73 | "tailwindcss-animate": "^1.0.7", 74 | "vaul": "^0.9.0", 75 | "zod": "^3.22.4" 76 | }, 77 | "devDependencies": { 78 | "@types/node": "^20", 79 | "@types/react": "^18", 80 | "@types/react-dom": "^18", 81 | "@types/react-syntax-highlighter": "^15.5.11", 82 | "autoprefixer": "^10.0.1", 83 | "eslint": "^8", 84 | "eslint-config-next": "14.1.4", 85 | "postcss": "^8", 86 | "tailwindcss": "^3.3.0", 87 | "typescript": "^5" 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/agents/finance.ts: -------------------------------------------------------------------------------- 1 | import { ChatOpenAI } from "@langchain/openai"; 2 | import { BytesOutputParser } from "@langchain/core/output_parsers"; 3 | import { RunnableSequence } from "@langchain/core/runnables"; 4 | import { 5 | SystemMessage, 6 | AIMessage, 7 | HumanMessage, 8 | ToolMessage, 9 | } from "@langchain/core/messages"; 10 | import { Message } from "@/app/action"; 11 | 12 | import { 13 | ChatPromptTemplate, 14 | MessagesPlaceholder, 15 | } from "@langchain/core/prompts"; 16 | import { AgentExecutor, createOpenAIToolsAgent } from "langchain/agents"; 17 | import { z } from "zod"; 18 | import { DynamicStructuredTool } from "@langchain/core/tools"; 19 | import { BaseMessage } from "@langchain/core/messages"; 20 | import { getFinancials, getNews, getAggregates } from "@/lib/polygon"; 21 | 22 | const llm = new ChatOpenAI({ 23 | modelName: "gpt-4-turbo-preview", 24 | temperature: 0.1, 25 | }); 26 | 27 | const tools = [ 28 | new DynamicStructuredTool({ 29 | name: "getFinancials", 30 | description: "Retrieves financial data for a given stock ticker.", 31 | schema: z.object({ 32 | ticker: z.string().describe("The stock ticker symbol"), 33 | }), 34 | func: async ({ ticker }) => { 35 | const data = await getFinancials(ticker); 36 | return JSON.stringify(data); 37 | }, 38 | }), 39 | new DynamicStructuredTool({ 40 | name: "getNews", 41 | description: 42 | "Retrieves news articles for a given stock ticker. Use this information to answer concisely", 43 | schema: z.object({ 44 | ticker: z.string().describe("The stock ticker symbol"), 45 | }), 46 | func: async ({ ticker }) => { 47 | const data = await getNews(ticker); 48 | return JSON.stringify(data); 49 | }, 50 | }), 51 | 52 | new DynamicStructuredTool({ 53 | name: "getStockPriceHistory", 54 | description: 55 | "Retrieves historical stock price data for a given stock ticker over a specified time period.", 56 | schema: z.object({ 57 | ticker: z.string().describe("The stock ticker symbol"), 58 | from: z.string().describe("The start date for the stock price data"), 59 | to: z.string().describe("The end date for the stock price data"), 60 | }), 61 | func: async ({ ticker, from, to }) => { 62 | const data = await getAggregates(ticker, from, to); 63 | return JSON.stringify(data); 64 | }, 65 | }), 66 | ]; 67 | const systemPrompt = ` 68 | You are a highly capable financial assistant named FinanceGPT. Your purpose is to provide insightful and concise analysis to help users make informed financial decisions. 69 | 70 | When a user asks a question, follow these steps: 71 | 1. Identify the relevant financial data needed to answer the query. 72 | 2. Use the available tools to retrieve the necessary data, such as stock financials, news, or aggregate data. 73 | 3. Analyze the retrieved data and any generated charts to extract key insights and trends. 74 | 4. Formulate a concise response that directly addresses the user's question, focusing on the most important findings from your analysis. 75 | 76 | Remember: 77 | - Today's date is ${new Date().toLocaleDateString()}. 78 | - Avoid simply regurgitating the raw data from the tools. Instead, provide a thoughtful interpretation and summary. 79 | - If the query cannot be satisfactorily answered using the available tools, kindly inform the user and suggest alternative resources or information they may need. 80 | 81 | Your ultimate goal is to empower users with clear, actionable insights to navigate the financial landscape effectively. 82 | 83 | Remember your goal is to answer the users query and provide a clear, actionable answer. 84 | `; 85 | 86 | const MEMORY_KEY = "chat_history"; 87 | const prompt = ChatPromptTemplate.fromMessages([ 88 | ["system", systemPrompt], 89 | new MessagesPlaceholder(MEMORY_KEY), 90 | ["user", "{input}"], 91 | new MessagesPlaceholder("agent_scratchpad"), 92 | ]); 93 | export async function runAgent(messages: BaseMessage[]) { 94 | const lastMessage = messages[messages.length - 1]; 95 | 96 | const agent = await createOpenAIToolsAgent({ 97 | llm, 98 | tools, 99 | prompt, 100 | }); 101 | const agentExecutor = new AgentExecutor({ 102 | agent, 103 | tools, 104 | }).withConfig({ runName: "Agent" }); 105 | const eventStream = await agentExecutor.streamEvents( 106 | { 107 | input: lastMessage.content, 108 | chat_history: [...messages.slice(0, -1)], 109 | }, 110 | { version: "v1" } 111 | ); 112 | return eventStream; 113 | } 114 | 115 | const chatModel = new ChatOpenAI({}); 116 | const outputParser = new BytesOutputParser(); 117 | 118 | export const chain = RunnableSequence.from([ 119 | new ChatOpenAI({ temperature: 0 }), 120 | new BytesOutputParser(), 121 | ]); 122 | 123 | // converts to langchain messages: system, ai, human, tool 124 | export const convertMessages = (messages: Message[]) => { 125 | return messages.map((message) => { 126 | switch (message.role) { 127 | case "system": 128 | return new SystemMessage(message.content); 129 | case "user": 130 | return new HumanMessage(message.content); 131 | case "assistant": 132 | return new AIMessage(message.content); 133 | case "tool": 134 | const toolMessageWithContent = new ToolMessage({ 135 | tool_call_id: message.id, 136 | content: message.content, 137 | additional_kwargs: { 138 | tool_calls: message.toolCalls, 139 | }, 140 | }); 141 | return new ToolMessage(toolMessageWithContent); 142 | default: 143 | throw new Error(`Unsupported message role: ${message.role}`); 144 | } 145 | }); 146 | }; 147 | -------------------------------------------------------------------------------- /src/app/action.tsx: -------------------------------------------------------------------------------- 1 | //@ts-ignore 2 | import { OpenAI } from "openai"; 3 | import { 4 | createAI, 5 | createStreamableUI, 6 | createStreamableValue, 7 | getMutableAIState, 8 | readStreamableValue, 9 | render, 10 | } from "ai/rsc"; 11 | import { z } from "zod"; 12 | import { nanoid } from "ai"; 13 | import { BotCard, BotMessage, SpinnerMessage } from "@/components/llm/message"; 14 | import { sleep } from "openai/core.mjs"; 15 | import { chain, convertMessages, runAgent } from "@/agents/finance"; 16 | import { 17 | FunctionToolCall, 18 | ToolCall, 19 | } from "openai/resources/beta/threads/runs/steps.mjs"; 20 | 21 | import { NewsCarousel } from "@/components/llm/news"; 22 | import { StockChart } from "@/components/llm/chart"; 23 | import FunctionCallBadge from "@/components/llm/fcall"; 24 | import { Financials } from "@/components/llm/financials"; 25 | 26 | const openai = new OpenAI({ 27 | apiKey: process.env.OPENAI_API_KEY, 28 | }); 29 | 30 | async function submitUserMessage(content: string) { 31 | "use server"; 32 | 33 | const aiState = getMutableAIState(); 34 | 35 | aiState.update({ 36 | ...aiState.get(), 37 | messages: [ 38 | ...aiState.get().messages, 39 | { 40 | id: nanoid(), 41 | role: "user", 42 | content, 43 | }, 44 | ], 45 | }); 46 | let textStream: ReturnType> | undefined; 47 | let textNode: React.ReactNode | undefined; 48 | let toolNode: React.ReactNode | undefined; 49 | const ui = createStreamableUI(); 50 | let assistantMessage = ""; 51 | async function handleEvent(event: any) { 52 | const eventType = event.event; 53 | 54 | if (eventType === "on_llm_start" || eventType === "on_llm_stream") { 55 | const content = event.data?.chunk?.message?.content; 56 | if (content !== undefined && content !== "") { 57 | if (!textStream) { 58 | textStream = createStreamableValue(""); 59 | textNode = ; 60 | ui.append(textNode); 61 | } 62 | textStream.update(content); 63 | } 64 | } else if (eventType === "on_llm_end") { 65 | if (textStream) { 66 | textStream.done(); 67 | assistantMessage += event.data.output; 68 | textStream = undefined; 69 | } 70 | } else if (eventType === "on_tool_start") { 71 | toolNode = ( 72 | 73 | ); 74 | assistantMessage += event.data.output; 75 | ui.append(toolNode); 76 | } else if (eventType === "on_tool_end") { 77 | assistantMessage += event.data.output; 78 | const parsedOutput = JSON.parse(event.data.output); 79 | if (event.name === "getNews" && parsedOutput) { 80 | toolNode = ; 81 | } else if (event.name === "getStockPriceHistory") { 82 | toolNode = ; 83 | } else { 84 | toolNode = ; 85 | } 86 | ui.append(toolNode); 87 | } 88 | } 89 | 90 | async function processEvents() { 91 | const eventStream = await runAgent(convertMessages(aiState.get().messages)); 92 | 93 | for await (const event of eventStream) { 94 | await handleEvent(event); 95 | } 96 | 97 | ui.done(); 98 | 99 | aiState.done({ 100 | ...aiState.get(), 101 | messages: [ 102 | ...aiState.get().messages, 103 | { 104 | id: nanoid(), 105 | role: "assistant", 106 | content: assistantMessage, 107 | }, 108 | ], 109 | }); 110 | } 111 | 112 | processEvents(); 113 | 114 | return { 115 | id: nanoid(), 116 | display: ui?.value, 117 | }; 118 | } 119 | 120 | export type Message = { 121 | role: "user" | "assistant" | "system" | "tool"; 122 | content: string; 123 | toolCalls?: FunctionToolCall[]; 124 | id: string; 125 | name?: string; 126 | }; 127 | export type AIState = { 128 | chatId: string; 129 | messages: Message[]; 130 | }; 131 | 132 | export type UIState = { 133 | id: string; 134 | display: React.ReactNode; 135 | }[]; 136 | 137 | // The initial UI state that the client will keep track of, which contains the message IDs and their UI nodes. 138 | const initialUIState: { 139 | id: number; 140 | display: React.ReactNode; 141 | }[] = []; 142 | 143 | // AI is a provider you wrap your application with so you can access AI and UI state in your components. 144 | export const AI = createAI({ 145 | actions: { 146 | submitUserMessage, 147 | }, 148 | // Each state can be any shape of object, but for chat applications 149 | // it makes sense to have an array of messages. Or you may prefer something like { id: number, messages: Message[] } 150 | initialUIState: [], 151 | initialAIState: { chatId: nanoid(), messages: [] }, 152 | }); 153 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sullyo/fingen/e879778d8e2a421eb81bcfabd5e402755cd422eb/src/app/favicon.ico -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "@/styles/globals.css"; 2 | import { Inter as FontSans } from "next/font/google"; 3 | import { AI } from "./action"; 4 | import { cn } from "@/lib/utils"; 5 | import { TooltipProvider } from "@radix-ui/react-tooltip"; 6 | 7 | const fontSans = FontSans({ 8 | subsets: ["latin"], 9 | variable: "--font-sans", 10 | }); 11 | 12 | export default function RootLayout({ 13 | children, 14 | }: Readonly<{ 15 | children: React.ReactNode; 16 | }>) { 17 | return ( 18 | 19 | 20 | 26 | {children} 27 | 28 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { AI } from "@/app/action"; 2 | import { Chat } from "@/components/llm/chat"; 3 | 4 | export default function Home() { 5 | return ( 6 | 7 |
8 | 9 |
10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /src/components/llm/chart.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { AreaChart } from "@tremor/react"; 3 | 4 | import { IgrFinancialChart } from "igniteui-react-charts"; 5 | import { IgrFinancialChartModule } from "igniteui-react-charts"; 6 | import { Card } from "@/components/ui/card"; 7 | 8 | import React from "react"; 9 | export interface StockData { 10 | ticker: string; 11 | queryCount: number; 12 | resultsCount: number; 13 | adjusted: boolean; 14 | results: StockResult[]; 15 | count: number; 16 | } 17 | 18 | interface StockResult { 19 | v: number; 20 | vw: number; 21 | o: number; 22 | c: number; 23 | h: number; 24 | l: number; 25 | t: number; 26 | n: number; 27 | } 28 | 29 | export function StockChart({ stockData }: { stockData: StockData }) { 30 | const data = stockData.results.map((result) => ({ 31 | date: new Date(result.t).toLocaleString("en-US", { 32 | month: "short", 33 | day: "numeric", 34 | }), 35 | Open: result.o, 36 | })); 37 | const dataFormatter = (number: number) => 38 | `$${Intl.NumberFormat("us").format(number).toString()}`; 39 | return ( 40 | console.log(v)} 49 | /> 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /src/components/llm/chat-input.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { AI } from "@/app/action"; 3 | import { UserMessage } from "@/components/llm/message"; 4 | import { ButtonScrollToBottom } from "@/components/scroll-to-bottom-btn"; 5 | import { Button } from "@/components/ui/button"; 6 | import { Label } from "@/components/ui/label"; 7 | import { Textarea } from "@/components/ui/textarea"; 8 | 9 | import { useEnterSubmit } from "@/hooks/use-enter-submit"; 10 | import { nanoid } from "ai"; 11 | import { useAIState, useUIState, useActions } from "ai/rsc"; 12 | import { CornerDownLeft } from "lucide-react"; 13 | import * as React from "react"; 14 | 15 | export interface ChatInputProps { 16 | input: string; 17 | setInput: (value: string) => void; 18 | } 19 | 20 | export function ChatInput({ input, setInput }: ChatInputProps) { 21 | const [aiState] = useAIState(); 22 | const [messages, setMessages] = useUIState(); 23 | const { submitUserMessage } = useActions(); 24 | const inputRef = React.useRef(null); 25 | 26 | const { formRef, onKeyDown } = useEnterSubmit(); 27 | 28 | return ( 29 |
30 |
31 |
32 |
{ 35 | e.preventDefault(); 36 | 37 | // Blur focus on mobile 38 | if (window.innerWidth < 600) { 39 | e.target["message"]?.blur(); 40 | } 41 | 42 | const value = input.trim(); 43 | setInput(""); 44 | if (!value) return; 45 | 46 | // Optimistically add user message UI 47 | setMessages((currentMessages) => [ 48 | ...currentMessages, 49 | { 50 | id: nanoid(), 51 | display: {value}, 52 | }, 53 | ]); 54 | 55 | // Submit and get response message 56 | const responseMessage = await submitUserMessage(value); 57 | setMessages((currentMessages) => [ 58 | ...currentMessages, 59 | responseMessage, 60 | ]); 61 | }} 62 | > 63 | 66 |