├── .env.example ├── .eslintrc.json ├── .gitignore ├── README.md ├── api ├── index.py └── utils │ ├── __init__.py │ ├── attachment.py │ ├── prompt.py │ └── tools.py ├── app ├── (chat) │ └── page.tsx ├── favicon.ico ├── globals.css ├── icons.tsx ├── layout.tsx └── og │ ├── background.png │ └── route.tsx ├── assets ├── geist-semibold.ttf └── geist.ttf ├── components.json ├── components ├── chat.tsx ├── icons.tsx ├── markdown.tsx ├── message.tsx ├── multimodal-input.tsx ├── navbar.tsx ├── overview.tsx ├── preview-attachment.tsx ├── ui │ ├── button.tsx │ └── textarea.tsx └── weather.tsx ├── hooks └── use-scroll-to-bottom.tsx ├── lib └── utils.ts ├── next.config.js ├── package-lock.json ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── public ├── next.svg └── vercel.svg ├── requirements.txt ├── tailwind.config.js ├── tsconfig.json └── vercel.json /.env.example: -------------------------------------------------------------------------------- 1 | # Get your OpenAI API Key here: https://platform.openai.com/account/api-keys 2 | OPENAI_API_KEY=**** 3 | -------------------------------------------------------------------------------- /.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 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # python 16 | venv/ 17 | .env 18 | __pycache__ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | 32 | # local env files 33 | .env*.local 34 | 35 | # vercel 36 | .vercel 37 | 38 | # typescript 39 | *.tsbuildinfo 40 | next-env.d.ts 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AI SDK Python Streaming Preview 2 | 3 | This template demonstrates the usage of [Data Stream Protocol](https://sdk.vercel.ai/docs/ai-sdk-ui/stream-protocol#data-stream-protocol) to stream chat completions from a Python endpoint ([FastAPI](https://fastapi.tiangolo.com)) and display them using the [useChat](https://sdk.vercel.ai/docs/ai-sdk-ui/chatbot#chatbot) hook in your Next.js application. 4 | 5 | ## Deploy your own 6 | 7 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel-labs%2Fai-sdk-preview-python-streaming&env=OPENAI_API_KEY&envDescription=API%20keys%20needed%20for%20application&envLink=https%3A%2F%2Fgithub.com%2Fvercel-labs%2Fai-sdk-preview-python-streaming%2Fblob%2Fmain%2F.env.example) 8 | 9 | ## How to use 10 | 11 | Run [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app) with [npm](https://docs.npmjs.com/cli/init), [Yarn](https://yarnpkg.com/lang/en/docs/cli/create/), or [pnpm](https://pnpm.io) to bootstrap the example: 12 | 13 | ```bash 14 | npx create-next-app --example https://github.com/vercel-labs/ai-sdk-preview-python-streaming ai-sdk-preview-python-streaming-example 15 | ``` 16 | 17 | ```bash 18 | yarn create next-app --example https://github.com/vercel-labs/ai-sdk-preview-python-streaming ai-sdk-preview-python-streaming-example 19 | ``` 20 | 21 | ```bash 22 | pnpm create next-app --example https://github.com/vercel-labs/ai-sdk-preview-python-streaming ai-sdk-preview-python-streaming-example 23 | ``` 24 | 25 | To run the example locally you need to: 26 | 27 | 1. Sign up for accounts with the AI providers you want to use (e.g., OpenAI, Anthropic). 28 | 2. Obtain API keys for each provider. 29 | 3. Set the required environment variables as shown in the `.env.example` file, but in a new file called `.env`. 30 | 4. `pnpm install` to install the required Node dependencies. 31 | 5. `virtualenv venv` to create a virtual environment. 32 | 6. `source venv/bin/activate` to activate the virtual environment. 33 | 7. `pip install -r requirements.txt` to install the required Python dependencies. 34 | 8. `pnpm dev` to launch the development server. 35 | 36 | ## Learn More 37 | 38 | To learn more about the AI SDK or Next.js by Vercel, take a look at the following resources: 39 | 40 | - [AI SDK Documentation](https://sdk.vercel.ai/docs) 41 | - [Next.js Documentation](https://nextjs.org/docs) 42 | -------------------------------------------------------------------------------- /api/index.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | from typing import List 4 | from openai.types.chat.chat_completion_message_param import ChatCompletionMessageParam 5 | from pydantic import BaseModel 6 | from dotenv import load_dotenv 7 | from fastapi import FastAPI, Query 8 | from fastapi.responses import StreamingResponse 9 | from openai import OpenAI 10 | from .utils.prompt import ClientMessage, convert_to_openai_messages 11 | from .utils.tools import get_current_weather 12 | 13 | 14 | load_dotenv(".env.local") 15 | 16 | app = FastAPI() 17 | 18 | client = OpenAI( 19 | api_key=os.environ.get("OPENAI_API_KEY"), 20 | ) 21 | 22 | 23 | class Request(BaseModel): 24 | messages: List[ClientMessage] 25 | 26 | 27 | available_tools = { 28 | "get_current_weather": get_current_weather, 29 | } 30 | 31 | def do_stream(messages: List[ChatCompletionMessageParam]): 32 | stream = client.chat.completions.create( 33 | messages=messages, 34 | model="gpt-4o", 35 | stream=True, 36 | tools=[{ 37 | "type": "function", 38 | "function": { 39 | "name": "get_current_weather", 40 | "description": "Get the current weather at a location", 41 | "parameters": { 42 | "type": "object", 43 | "properties": { 44 | "latitude": { 45 | "type": "number", 46 | "description": "The latitude of the location", 47 | }, 48 | "longitude": { 49 | "type": "number", 50 | "description": "The longitude of the location", 51 | }, 52 | }, 53 | "required": ["latitude", "longitude"], 54 | }, 55 | }, 56 | }] 57 | ) 58 | 59 | return stream 60 | 61 | def stream_text(messages: List[ChatCompletionMessageParam], protocol: str = 'data'): 62 | draft_tool_calls = [] 63 | draft_tool_calls_index = -1 64 | 65 | stream = client.chat.completions.create( 66 | messages=messages, 67 | model="gpt-4o", 68 | stream=True, 69 | tools=[{ 70 | "type": "function", 71 | "function": { 72 | "name": "get_current_weather", 73 | "description": "Get the current weather at a location", 74 | "parameters": { 75 | "type": "object", 76 | "properties": { 77 | "latitude": { 78 | "type": "number", 79 | "description": "The latitude of the location", 80 | }, 81 | "longitude": { 82 | "type": "number", 83 | "description": "The longitude of the location", 84 | }, 85 | }, 86 | "required": ["latitude", "longitude"], 87 | }, 88 | }, 89 | }] 90 | ) 91 | 92 | for chunk in stream: 93 | for choice in chunk.choices: 94 | if choice.finish_reason == "stop": 95 | continue 96 | 97 | elif choice.finish_reason == "tool_calls": 98 | for tool_call in draft_tool_calls: 99 | yield '9:{{"toolCallId":"{id}","toolName":"{name}","args":{args}}}\n'.format( 100 | id=tool_call["id"], 101 | name=tool_call["name"], 102 | args=tool_call["arguments"]) 103 | 104 | for tool_call in draft_tool_calls: 105 | tool_result = available_tools[tool_call["name"]]( 106 | **json.loads(tool_call["arguments"])) 107 | 108 | yield 'a:{{"toolCallId":"{id}","toolName":"{name}","args":{args},"result":{result}}}\n'.format( 109 | id=tool_call["id"], 110 | name=tool_call["name"], 111 | args=tool_call["arguments"], 112 | result=json.dumps(tool_result)) 113 | 114 | elif choice.delta.tool_calls: 115 | for tool_call in choice.delta.tool_calls: 116 | id = tool_call.id 117 | name = tool_call.function.name 118 | arguments = tool_call.function.arguments 119 | 120 | if (id is not None): 121 | draft_tool_calls_index += 1 122 | draft_tool_calls.append( 123 | {"id": id, "name": name, "arguments": ""}) 124 | 125 | else: 126 | draft_tool_calls[draft_tool_calls_index]["arguments"] += arguments 127 | 128 | else: 129 | yield '0:{text}\n'.format(text=json.dumps(choice.delta.content)) 130 | 131 | if chunk.choices == []: 132 | usage = chunk.usage 133 | prompt_tokens = usage.prompt_tokens 134 | completion_tokens = usage.completion_tokens 135 | 136 | yield 'e:{{"finishReason":"{reason}","usage":{{"promptTokens":{prompt},"completionTokens":{completion}}},"isContinued":false}}\n'.format( 137 | reason="tool-calls" if len( 138 | draft_tool_calls) > 0 else "stop", 139 | prompt=prompt_tokens, 140 | completion=completion_tokens 141 | ) 142 | 143 | 144 | 145 | 146 | @app.post("/api/chat") 147 | async def handle_chat_data(request: Request, protocol: str = Query('data')): 148 | messages = request.messages 149 | openai_messages = convert_to_openai_messages(messages) 150 | 151 | response = StreamingResponse(stream_text(openai_messages, protocol)) 152 | response.headers['x-vercel-ai-data-stream'] = 'v1' 153 | return response 154 | -------------------------------------------------------------------------------- /api/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel-labs/ai-sdk-preview-python-streaming/07a632d6fb60c2c09e725fc3d5be1c2a353855bd/api/utils/__init__.py -------------------------------------------------------------------------------- /api/utils/attachment.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class ClientAttachment(BaseModel): 5 | name: str 6 | contentType: str 7 | url: str 8 | -------------------------------------------------------------------------------- /api/utils/prompt.py: -------------------------------------------------------------------------------- 1 | import json 2 | from enum import Enum 3 | from openai.types.chat.chat_completion_message_param import ChatCompletionMessageParam 4 | from pydantic import BaseModel 5 | import base64 6 | from typing import List, Optional, Any 7 | from .attachment import ClientAttachment 8 | 9 | class ToolInvocationState(str, Enum): 10 | CALL = 'call' 11 | PARTIAL_CALL = 'partial-call' 12 | RESULT = 'result' 13 | 14 | class ToolInvocation(BaseModel): 15 | state: ToolInvocationState 16 | toolCallId: str 17 | toolName: str 18 | args: Any 19 | result: Any 20 | 21 | 22 | class ClientMessage(BaseModel): 23 | role: str 24 | content: str 25 | experimental_attachments: Optional[List[ClientAttachment]] = None 26 | toolInvocations: Optional[List[ToolInvocation]] = None 27 | 28 | def convert_to_openai_messages(messages: List[ClientMessage]) -> List[ChatCompletionMessageParam]: 29 | openai_messages = [] 30 | 31 | for message in messages: 32 | parts = [] 33 | tool_calls = [] 34 | 35 | parts.append({ 36 | 'type': 'text', 37 | 'text': message.content 38 | }) 39 | 40 | if (message.experimental_attachments): 41 | for attachment in message.experimental_attachments: 42 | if (attachment.contentType.startswith('image')): 43 | parts.append({ 44 | 'type': 'image_url', 45 | 'image_url': { 46 | 'url': attachment.url 47 | } 48 | }) 49 | 50 | elif (attachment.contentType.startswith('text')): 51 | parts.append({ 52 | 'type': 'text', 53 | 'text': attachment.url 54 | }) 55 | 56 | if(message.toolInvocations): 57 | for toolInvocation in message.toolInvocations: 58 | tool_calls.append({ 59 | "id": toolInvocation.toolCallId, 60 | "type": "function", 61 | "function": { 62 | "name": toolInvocation.toolName, 63 | "arguments": json.dumps(toolInvocation.args) 64 | } 65 | }) 66 | 67 | tool_calls_dict = {"tool_calls": tool_calls} if tool_calls else {"tool_calls": None} 68 | 69 | openai_messages.append({ 70 | "role": message.role, 71 | "content": parts, 72 | **tool_calls_dict, 73 | }) 74 | 75 | if(message.toolInvocations): 76 | for toolInvocation in message.toolInvocations: 77 | tool_message = { 78 | "role": "tool", 79 | "tool_call_id": toolInvocation.toolCallId, 80 | "content": json.dumps(toolInvocation.result), 81 | } 82 | 83 | openai_messages.append(tool_message) 84 | 85 | return openai_messages 86 | -------------------------------------------------------------------------------- /api/utils/tools.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | def get_current_weather(latitude, longitude): 4 | # Format the URL with proper parameter substitution 5 | url = f"https://api.open-meteo.com/v1/forecast?latitude={latitude}&longitude={longitude}¤t=temperature_2m&hourly=temperature_2m&daily=sunrise,sunset&timezone=auto" 6 | 7 | try: 8 | # Make the API call 9 | response = requests.get(url) 10 | 11 | # Raise an exception for bad status codes 12 | response.raise_for_status() 13 | 14 | # Return the JSON response 15 | return response.json() 16 | 17 | except requests.RequestException as e: 18 | # Handle any errors that occur during the request 19 | print(f"Error fetching weather data: {e}") 20 | return None 21 | -------------------------------------------------------------------------------- /app/(chat)/page.tsx: -------------------------------------------------------------------------------- 1 | import { Chat } from "@/components/chat"; 2 | 3 | export default function Page() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel-labs/ai-sdk-preview-python-streaming/07a632d6fb60c2c09e725fc3d5be1c2a353855bd/app/favicon.ico -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --foreground-rgb: 0, 0, 0; 7 | --background-start-rgb: 214, 219, 220; 8 | --background-end-rgb: 255, 255, 255; 9 | } 10 | 11 | @layer base { 12 | :root { 13 | --background: 0 0% 100%; 14 | --foreground: 240 10% 3.9%; 15 | --card: 0 0% 100%; 16 | --card-foreground: 240 10% 3.9%; 17 | --popover: 0 0% 100%; 18 | --popover-foreground: 240 10% 3.9%; 19 | --primary: 240 5.9% 10%; 20 | --primary-foreground: 0 0% 98%; 21 | --secondary: 240 4.8% 95.9%; 22 | --secondary-foreground: 240 5.9% 10%; 23 | --muted: 240 4.8% 95.9%; 24 | --muted-foreground: 240 3.8% 46.1%; 25 | --accent: 240 4.8% 95.9%; 26 | --accent-foreground: 240 5.9% 10%; 27 | --destructive: 0 84.2% 60.2%; 28 | --destructive-foreground: 0 0% 98%; 29 | --border: 240 5.9% 90%; 30 | --input: 240 5.9% 90%; 31 | --ring: 240 10% 3.9%; 32 | --chart-1: 12 76% 61%; 33 | --chart-2: 173 58% 39%; 34 | --chart-3: 197 37% 24%; 35 | --chart-4: 43 74% 66%; 36 | --chart-5: 27 87% 67%; 37 | --radius: 0.5rem; 38 | } 39 | .dark { 40 | --background: 240 10% 3.9%; 41 | --foreground: 0 0% 98%; 42 | --card: 240 10% 3.9%; 43 | --card-foreground: 0 0% 98%; 44 | --popover: 240 10% 3.9%; 45 | --popover-foreground: 0 0% 98%; 46 | --primary: 0 0% 98%; 47 | --primary-foreground: 240 5.9% 10%; 48 | --secondary: 240 3.7% 15.9%; 49 | --secondary-foreground: 0 0% 98%; 50 | --muted: 240 3.7% 15.9%; 51 | --muted-foreground: 240 5% 64.9%; 52 | --accent: 240 3.7% 15.9%; 53 | --accent-foreground: 0 0% 98%; 54 | --destructive: 0 62.8% 30.6%; 55 | --destructive-foreground: 0 0% 98%; 56 | --border: 240 3.7% 15.9%; 57 | --input: 240 3.7% 15.9%; 58 | --ring: 240 4.9% 83.9%; 59 | --chart-1: 220 70% 50%; 60 | --chart-2: 160 60% 45%; 61 | --chart-3: 30 80% 55%; 62 | --chart-4: 280 65% 60%; 63 | --chart-5: 340 75% 55%; 64 | } 65 | } 66 | 67 | @layer base { 68 | * { 69 | @apply border-border; 70 | } 71 | body { 72 | @apply bg-background text-foreground; 73 | } 74 | } 75 | 76 | .skeleton { 77 | * { 78 | pointer-events: none !important; 79 | } 80 | 81 | *[class^="text-"] { 82 | color: transparent; 83 | @apply rounded-md bg-foreground/20 select-none animate-pulse; 84 | } 85 | 86 | .skeleton-bg { 87 | @apply bg-foreground/10; 88 | } 89 | 90 | .skeleton-div { 91 | @apply bg-foreground/20 animate-pulse; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /app/icons.tsx: -------------------------------------------------------------------------------- 1 | export const LogoPython = ({ size = 16 }: { size?: number }) => ( 2 | 9 | 13 | 17 | 18 | 26 | 27 | 28 | 29 | 37 | 38 | 39 | 40 | 41 | 42 | ); 43 | 44 | export const LogoNext = () => ( 45 | 52 | 53 | 63 | 69 | 75 | 76 | 77 | 85 | 86 | 87 | 88 | 89 | 90 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | ); 107 | 108 | export const MoonIcon = ({ size = 16 }: { size?: number }) => { 109 | return ( 110 | 117 | 118 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | ); 132 | }; 133 | 134 | export const SunIcon = ({ size = 16 }: { size?: number }) => { 135 | return ( 136 | 143 | 149 | 150 | ); 151 | }; 152 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "./globals.css"; 2 | import { GeistSans } from "geist/font/sans"; 3 | import { Toaster } from "sonner"; 4 | import { cn } from "@/lib/utils"; 5 | import { Navbar } from "@/components/navbar"; 6 | 7 | export const metadata = { 8 | title: "AI SDK Python Streaming Preview", 9 | description: 10 | "Use the Data Stream Protocol to stream chat completions from a Python endpoint (FastAPI) and display them using the useChat hook in your Next.js application.", 11 | openGraph: { 12 | images: [ 13 | { 14 | url: "/og?title=AI SDK Python Streaming Preview", 15 | }, 16 | ], 17 | }, 18 | twitter: { 19 | card: "summary_large_image", 20 | images: [ 21 | { 22 | url: "/og?title=AI SDK Python Streaming Preview", 23 | }, 24 | ], 25 | }, 26 | }; 27 | 28 | export default function RootLayout({ 29 | children, 30 | }: { 31 | children: React.ReactNode; 32 | }) { 33 | return ( 34 | 35 | 36 | 37 | 38 | 39 | {children} 40 | 41 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /app/og/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel-labs/ai-sdk-preview-python-streaming/07a632d6fb60c2c09e725fc3d5be1c2a353855bd/app/og/background.png -------------------------------------------------------------------------------- /app/og/route.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @next/next/no-img-element */ 2 | 3 | import { ImageResponse } from "next/server"; 4 | 5 | export const runtime = "edge"; 6 | export const preferredRegion = ["iad1"]; 7 | 8 | export async function GET(request: Request) { 9 | const { searchParams } = new URL(request.url); 10 | 11 | const title = searchParams.get("title"); 12 | const description = searchParams.get("description"); 13 | 14 | const imageData = await fetch( 15 | new URL("./background.png", import.meta.url) 16 | ).then((res) => res.arrayBuffer()); 17 | 18 | const geistSemibold = await fetch( 19 | new URL("../../assets/geist-semibold.ttf", import.meta.url) 20 | ).then((res) => res.arrayBuffer()); 21 | 22 | return new ImageResponse( 23 | ( 24 |
28 | {/* @ts-expect-error */} 29 | vercel opengraph background 30 |
31 |
41 | {title} 42 |
43 |
44 | {description} 45 |
46 |
47 |
48 | ), 49 | { 50 | width: 1200, 51 | height: 628, 52 | fonts: [ 53 | { 54 | name: "geist", 55 | data: geistSemibold, 56 | style: "normal", 57 | }, 58 | ], 59 | } 60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /assets/geist-semibold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel-labs/ai-sdk-preview-python-streaming/07a632d6fb60c2c09e725fc3d5be1c2a353855bd/assets/geist-semibold.ttf -------------------------------------------------------------------------------- /assets/geist.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel-labs/ai-sdk-preview-python-streaming/07a632d6fb60c2c09e725fc3d5be1c2a353855bd/assets/geist.ttf -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "app/globals.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /components/chat.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { PreviewMessage, ThinkingMessage } from "@/components/message"; 4 | import { MultimodalInput } from "@/components/multimodal-input"; 5 | import { Overview } from "@/components/overview"; 6 | import { useScrollToBottom } from "@/hooks/use-scroll-to-bottom"; 7 | import { ToolInvocation } from "ai"; 8 | import { useChat } from "ai/react"; 9 | import { toast } from "sonner"; 10 | 11 | export function Chat() { 12 | const chatId = "001"; 13 | 14 | const { 15 | messages, 16 | setMessages, 17 | handleSubmit, 18 | input, 19 | setInput, 20 | append, 21 | isLoading, 22 | stop, 23 | } = useChat({ 24 | maxSteps: 4, 25 | onError: (error) => { 26 | if (error.message.includes("Too many requests")) { 27 | toast.error( 28 | "You are sending too many messages. Please try again later.", 29 | ); 30 | } 31 | }, 32 | }); 33 | 34 | const [messagesContainerRef, messagesEndRef] = 35 | useScrollToBottom(); 36 | 37 | return ( 38 |
39 |
43 | {messages.length === 0 && } 44 | 45 | {messages.map((message, index) => ( 46 | 52 | ))} 53 | 54 | {isLoading && 55 | messages.length > 0 && 56 | messages[messages.length - 1].role === "user" && } 57 | 58 |
62 |
63 | 64 |
65 | 76 | 77 |
78 | ); 79 | } 80 | -------------------------------------------------------------------------------- /components/icons.tsx: -------------------------------------------------------------------------------- 1 | export const BotIcon = () => { 2 | return ( 3 | 10 | 16 | 17 | ); 18 | }; 19 | 20 | export const UserIcon = () => { 21 | return ( 22 | 30 | 36 | 37 | ); 38 | }; 39 | 40 | export const AttachmentIcon = () => { 41 | return ( 42 | 49 | 55 | 56 | ); 57 | }; 58 | 59 | export const VercelIcon = ({ size = 17 }: { size?: number }) => { 60 | return ( 61 | 68 | 74 | 75 | ); 76 | }; 77 | 78 | export const GitIcon = () => { 79 | return ( 80 | 87 | 88 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | ); 102 | }; 103 | 104 | export const BoxIcon = ({ size = 16 }: { size: number }) => { 105 | return ( 106 | 113 | 119 | 120 | ); 121 | }; 122 | 123 | export const HomeIcon = ({ size = 16 }: { size: number }) => { 124 | return ( 125 | 132 | 138 | 139 | ); 140 | }; 141 | 142 | export const GPSIcon = ({ size = 16 }: { size: number }) => { 143 | return ( 144 | 151 | 159 | 160 | ); 161 | }; 162 | 163 | export const InvoiceIcon = ({ size = 16 }: { size: number }) => { 164 | return ( 165 | 172 | 178 | 179 | ); 180 | }; 181 | 182 | export const LogoOpenAI = ({ size = 16 }: { size?: number }) => { 183 | return ( 184 | 191 | 195 | 196 | ); 197 | }; 198 | 199 | export const LogoGoogle = ({ size = 16 }: { size?: number }) => { 200 | return ( 201 | 209 | 213 | 217 | 221 | 225 | 226 | ); 227 | }; 228 | 229 | export const LogoAnthropic = () => { 230 | return ( 231 | 241 | 245 | 246 | ); 247 | }; 248 | 249 | export const RouteIcon = ({ size = 16 }: { size?: number }) => { 250 | return ( 251 | 258 | 264 | 265 | ); 266 | }; 267 | 268 | export const FileIcon = ({ size = 16 }: { size?: number }) => { 269 | return ( 270 | 277 | 283 | 284 | ); 285 | }; 286 | 287 | export const LoaderIcon = ({ size = 16 }: { size?: number }) => { 288 | return ( 289 | 296 | 297 | 298 | 304 | 310 | 316 | 322 | 328 | 334 | 340 | 346 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | ); 360 | }; 361 | 362 | export const UploadIcon = ({ size = 16 }: { size?: number }) => { 363 | return ( 364 | 372 | 378 | 379 | ); 380 | }; 381 | 382 | export const MenuIcon = ({ size = 16 }: { size?: number }) => { 383 | return ( 384 | 391 | 397 | 398 | ); 399 | }; 400 | 401 | export const PencilEditIcon = ({ size = 16 }: { size?: number }) => { 402 | return ( 403 | 410 | 416 | 417 | ); 418 | }; 419 | 420 | export const CheckedSquare = ({ size = 16 }: { size?: number }) => { 421 | return ( 422 | 429 | 435 | 436 | ); 437 | }; 438 | 439 | export const UncheckedSquare = ({ size = 16 }: { size?: number }) => { 440 | return ( 441 | 448 | 457 | 458 | ); 459 | }; 460 | 461 | export const MoreIcon = ({ size = 16 }: { size?: number }) => { 462 | return ( 463 | 470 | 476 | 477 | ); 478 | }; 479 | 480 | export const TrashIcon = ({ size = 16 }: { size?: number }) => { 481 | return ( 482 | 489 | 495 | 496 | ); 497 | }; 498 | 499 | export const InfoIcon = ({ size = 16 }: { size?: number }) => { 500 | return ( 501 | 508 | 514 | 515 | ); 516 | }; 517 | 518 | export const ArrowUpIcon = ({ size = 16 }: { size?: number }) => { 519 | return ( 520 | 527 | 533 | 534 | ); 535 | }; 536 | 537 | export const StopIcon = ({ size = 16 }: { size?: number }) => { 538 | return ( 539 | 545 | 551 | 552 | ); 553 | }; 554 | 555 | export const PaperclipIcon = ({ size = 16 }: { size?: number }) => { 556 | return ( 557 | 565 | 571 | 572 | ); 573 | }; 574 | 575 | export const MoreHorizontalIcon = ({ size = 16 }: { size?: number }) => { 576 | return ( 577 | 584 | 590 | 591 | ); 592 | }; 593 | 594 | export const MessageIcon = ({ size = 16 }: { size?: number }) => { 595 | return ( 596 | 603 | 609 | 610 | ); 611 | }; 612 | 613 | export const CrossIcon = ({ size = 16 }: { size?: number }) => ( 614 | 621 | 627 | 628 | ); 629 | 630 | export const UndoIcon = ({ size = 16 }: { size?: number }) => ( 631 | 638 | 644 | 645 | ); 646 | 647 | export const RedoIcon = ({ size = 16 }: { size?: number }) => ( 648 | 655 | 661 | 662 | ); 663 | 664 | export const DeltaIcon = ({ size = 16 }: { size?: number }) => ( 665 | 672 | 678 | 679 | ); 680 | 681 | export const PenIcon = ({ size = 16 }: { size?: number }) => ( 682 | 689 | 695 | 696 | ); 697 | 698 | export const SummarizeIcon = ({ size = 16 }: { size?: number }) => ( 699 | 706 | 712 | 713 | ); 714 | 715 | export const SidebarLeftIcon = ({ size = 16 }: { size?: number }) => ( 716 | 723 | 729 | 730 | ); 731 | 732 | export const PlusIcon = ({ size = 16 }: { size?: number }) => ( 733 | 740 | 746 | 747 | ); 748 | 749 | export const CopyIcon = ({ size = 16 }: { size?: number }) => ( 750 | 757 | 763 | 764 | ); 765 | 766 | export const ThumbUpIcon = ({ size = 16 }: { size?: number }) => ( 767 | 774 | 780 | 781 | ); 782 | 783 | export const ThumbDownIcon = ({ size = 16 }: { size?: number }) => ( 784 | 791 | 797 | 798 | ); 799 | 800 | export const ChevronDownIcon = ({ size = 16 }: { size?: number }) => ( 801 | 808 | 814 | 815 | ); 816 | 817 | export const SparklesIcon = ({ size = 16 }: { size?: number }) => ( 818 | 825 | 829 | 833 | 837 | 838 | ); 839 | 840 | export const CheckCirclFillIcon = ({ size = 16 }: { size?: number }) => { 841 | return ( 842 | 849 | 855 | 856 | ); 857 | }; 858 | -------------------------------------------------------------------------------- /components/markdown.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import React, { memo } from "react"; 3 | import ReactMarkdown, { type Components } from "react-markdown"; 4 | import remarkGfm from "remark-gfm"; 5 | 6 | const NonMemoizedMarkdown = ({ children }: { children: string }) => { 7 | const components: Partial = { 8 | // @ts-expect-error 9 | code: ({ node, inline, className, children, ...props }) => { 10 | const match = /language-(\w+)/.exec(className || ""); 11 | return !inline && match ? ( 12 | // @ts-expect-error 13 |
 17 |           {children}
 18 |         
19 | ) : ( 20 | 24 | {children} 25 | 26 | ); 27 | }, 28 | ol: ({ node, children, ...props }) => { 29 | return ( 30 |
    31 | {children} 32 |
33 | ); 34 | }, 35 | li: ({ node, children, ...props }) => { 36 | return ( 37 |
  • 38 | {children} 39 |
  • 40 | ); 41 | }, 42 | ul: ({ node, children, ...props }) => { 43 | return ( 44 |
      45 | {children} 46 |
    47 | ); 48 | }, 49 | strong: ({ node, children, ...props }) => { 50 | return ( 51 | 52 | {children} 53 | 54 | ); 55 | }, 56 | a: ({ node, children, ...props }) => { 57 | return ( 58 | // @ts-expect-error 59 | 65 | {children} 66 | 67 | ); 68 | }, 69 | h1: ({ node, children, ...props }) => { 70 | return ( 71 |

    72 | {children} 73 |

    74 | ); 75 | }, 76 | h2: ({ node, children, ...props }) => { 77 | return ( 78 |

    79 | {children} 80 |

    81 | ); 82 | }, 83 | h3: ({ node, children, ...props }) => { 84 | return ( 85 |

    86 | {children} 87 |

    88 | ); 89 | }, 90 | h4: ({ node, children, ...props }) => { 91 | return ( 92 |

    93 | {children} 94 |

    95 | ); 96 | }, 97 | h5: ({ node, children, ...props }) => { 98 | return ( 99 |
    100 | {children} 101 |
    102 | ); 103 | }, 104 | h6: ({ node, children, ...props }) => { 105 | return ( 106 |
    107 | {children} 108 |
    109 | ); 110 | }, 111 | }; 112 | 113 | return ( 114 | 115 | {children} 116 | 117 | ); 118 | }; 119 | 120 | export const Markdown = memo( 121 | NonMemoizedMarkdown, 122 | (prevProps, nextProps) => prevProps.children === nextProps.children, 123 | ); 124 | -------------------------------------------------------------------------------- /components/message.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import type { Message } from "ai"; 4 | import { motion } from "framer-motion"; 5 | 6 | import { SparklesIcon } from "./icons"; 7 | import { Markdown } from "./markdown"; 8 | import { PreviewAttachment } from "./preview-attachment"; 9 | import { cn } from "@/lib/utils"; 10 | import { Weather } from "./weather"; 11 | 12 | export const PreviewMessage = ({ 13 | message, 14 | }: { 15 | chatId: string; 16 | message: Message; 17 | isLoading: boolean; 18 | }) => { 19 | return ( 20 | 26 |
    31 | {message.role === "assistant" && ( 32 |
    33 | 34 |
    35 | )} 36 | 37 |
    38 | {message.content && ( 39 |
    40 | {message.content as string} 41 |
    42 | )} 43 | 44 | {message.toolInvocations && message.toolInvocations.length > 0 && ( 45 |
    46 | {message.toolInvocations.map((toolInvocation) => { 47 | const { toolName, toolCallId, state } = toolInvocation; 48 | 49 | if (state === "result") { 50 | const { result } = toolInvocation; 51 | 52 | return ( 53 |
    54 | {toolName === "get_current_weather" ? ( 55 | 56 | ) : ( 57 |
    {JSON.stringify(result, null, 2)}
    58 | )} 59 |
    60 | ); 61 | } 62 | return ( 63 |
    69 | {toolName === "get_current_weather" ? : null} 70 |
    71 | ); 72 | })} 73 |
    74 | )} 75 | 76 | {message.experimental_attachments && ( 77 |
    78 | {message.experimental_attachments.map((attachment) => ( 79 | 83 | ))} 84 |
    85 | )} 86 |
    87 |
    88 |
    89 | ); 90 | }; 91 | 92 | export const ThinkingMessage = () => { 93 | const role = "assistant"; 94 | 95 | return ( 96 | 102 |
    110 |
    111 | 112 |
    113 | 114 |
    115 |
    116 | Thinking... 117 |
    118 |
    119 |
    120 |
    121 | ); 122 | }; 123 | -------------------------------------------------------------------------------- /components/multimodal-input.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import type { ChatRequestOptions, CreateMessage, Message } from "ai"; 4 | import { motion } from "framer-motion"; 5 | import type React from "react"; 6 | import { 7 | useRef, 8 | useEffect, 9 | useCallback, 10 | type Dispatch, 11 | type SetStateAction, 12 | } from "react"; 13 | import { toast } from "sonner"; 14 | import { useLocalStorage, useWindowSize } from "usehooks-ts"; 15 | 16 | import { cn, sanitizeUIMessages } from "@/lib/utils"; 17 | 18 | import { ArrowUpIcon, StopIcon } from "./icons"; 19 | import { Button } from "./ui/button"; 20 | import { Textarea } from "./ui/textarea"; 21 | 22 | const suggestedActions = [ 23 | { 24 | title: "What is the weather", 25 | label: "in San Francisco?", 26 | action: "What is the weather in San Francisco?", 27 | }, 28 | { 29 | title: "How is python useful", 30 | label: "for AI engineers?", 31 | action: "How is python useful for AI engineers?", 32 | }, 33 | ]; 34 | 35 | export function MultimodalInput({ 36 | chatId, 37 | input, 38 | setInput, 39 | isLoading, 40 | stop, 41 | messages, 42 | setMessages, 43 | append, 44 | handleSubmit, 45 | className, 46 | }: { 47 | chatId: string; 48 | input: string; 49 | setInput: (value: string) => void; 50 | isLoading: boolean; 51 | stop: () => void; 52 | messages: Array; 53 | setMessages: Dispatch>>; 54 | append: ( 55 | message: Message | CreateMessage, 56 | chatRequestOptions?: ChatRequestOptions, 57 | ) => Promise; 58 | handleSubmit: ( 59 | event?: { 60 | preventDefault?: () => void; 61 | }, 62 | chatRequestOptions?: ChatRequestOptions, 63 | ) => void; 64 | className?: string; 65 | }) { 66 | const textareaRef = useRef(null); 67 | const { width } = useWindowSize(); 68 | 69 | useEffect(() => { 70 | if (textareaRef.current) { 71 | adjustHeight(); 72 | } 73 | }, []); 74 | 75 | const adjustHeight = () => { 76 | if (textareaRef.current) { 77 | textareaRef.current.style.height = "auto"; 78 | textareaRef.current.style.height = `${textareaRef.current.scrollHeight + 2}px`; 79 | } 80 | }; 81 | 82 | const [localStorageInput, setLocalStorageInput] = useLocalStorage( 83 | "input", 84 | "", 85 | ); 86 | 87 | useEffect(() => { 88 | if (textareaRef.current) { 89 | const domValue = textareaRef.current.value; 90 | // Prefer DOM value over localStorage to handle hydration 91 | const finalValue = domValue || localStorageInput || ""; 92 | setInput(finalValue); 93 | adjustHeight(); 94 | } 95 | // Only run once after hydration 96 | // eslint-disable-next-line react-hooks/exhaustive-deps 97 | }, []); 98 | 99 | useEffect(() => { 100 | setLocalStorageInput(input); 101 | }, [input, setLocalStorageInput]); 102 | 103 | const handleInput = (event: React.ChangeEvent) => { 104 | setInput(event.target.value); 105 | adjustHeight(); 106 | }; 107 | 108 | const submitForm = useCallback(() => { 109 | handleSubmit(undefined, {}); 110 | setLocalStorageInput(""); 111 | 112 | if (width && width > 768) { 113 | textareaRef.current?.focus(); 114 | } 115 | }, [handleSubmit, setLocalStorageInput, width]); 116 | 117 | return ( 118 |
    119 | {messages.length === 0 && ( 120 |
    121 | {suggestedActions.map((suggestedAction, index) => ( 122 | 1 ? "hidden sm:block" : "block"} 129 | > 130 | 145 | 146 | ))} 147 |
    148 | )} 149 | 150 |