├── .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 | [](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 |
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 |
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 |
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 | {
133 | append({
134 | role: "user",
135 | content: suggestedAction.action,
136 | });
137 | }}
138 | className="text-left border rounded-xl px-4 py-3.5 text-sm flex-1 gap-1 sm:flex-col w-full h-auto justify-start items-start"
139 | >
140 | {suggestedAction.title}
141 |
142 | {suggestedAction.label}
143 |
144 |
145 |
146 | ))}
147 |
148 | )}
149 |
150 |
198 | );
199 | }
200 |
--------------------------------------------------------------------------------
/components/navbar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "./ui/button";
4 | import { GitIcon, VercelIcon } from "./icons";
5 | import Link from "next/link";
6 |
7 | export const Navbar = () => {
8 | return (
9 |
10 |
11 |
12 | View Source Code
13 |
14 |
15 |
16 |
17 |
18 |
19 | Deploy with Vercel
20 |
21 |
22 |
23 | );
24 | };
25 |
--------------------------------------------------------------------------------
/components/overview.tsx:
--------------------------------------------------------------------------------
1 | import { motion } from "framer-motion";
2 | import Link from "next/link";
3 |
4 | import { MessageIcon } from "./icons";
5 | import { LogoPython } from "@/app/icons";
6 |
7 | export const Overview = () => {
8 | return (
9 |
17 |
18 |
19 |
20 | +
21 |
22 |
23 |
24 | This is an{" "}
25 |
30 | open source
31 | {" "}
32 | template that demonstrates the usage of{" "}
33 |
38 | Data Stream Protocol
39 | {" "}
40 | to stream chat completions from a Python function (
41 |
46 | FastAPI
47 |
48 | ) along with the
49 | useChat
hook
50 | on the client to create a seamless chat experience.
51 |
52 |
53 | You can learn more about the AI SDK by visiting the{" "}
54 |
59 | docs
60 |
61 | .
62 |
63 |
64 |
65 | );
66 | };
67 |
--------------------------------------------------------------------------------
/components/preview-attachment.tsx:
--------------------------------------------------------------------------------
1 | import type { Attachment } from "ai";
2 |
3 | import { LoaderIcon } from "./icons";
4 |
5 | export const PreviewAttachment = ({
6 | attachment,
7 | isUploading = false,
8 | }: {
9 | attachment: Attachment;
10 | isUploading?: boolean;
11 | }) => {
12 | const { name, url, contentType } = attachment;
13 |
14 | return (
15 |
16 |
17 | {contentType ? (
18 | contentType.startsWith("image") ? (
19 | // NOTE: it is recommended to use next/image for images
20 | // eslint-disable-next-line @next/next/no-img-element
21 |
27 | ) : (
28 |
29 | )
30 | ) : (
31 |
32 | )}
33 |
34 | {isUploading && (
35 |
36 |
37 |
38 | )}
39 |
40 |
{name}
41 |
42 | );
43 | };
44 |
--------------------------------------------------------------------------------
/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 gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90",
14 | destructive:
15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
16 | outline:
17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
18 | secondary:
19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
20 | ghost: "hover:bg-accent hover:text-accent-foreground",
21 | link: "text-primary underline-offset-4 hover:underline",
22 | },
23 | size: {
24 | default: "h-9 px-4 py-2",
25 | sm: "h-8 rounded-md px-3 text-xs",
26 | lg: "h-10 rounded-md px-8",
27 | icon: "h-9 w-9",
28 | },
29 | },
30 | defaultVariants: {
31 | variant: "default",
32 | size: "default",
33 | },
34 | }
35 | )
36 |
37 | export interface ButtonProps
38 | extends React.ButtonHTMLAttributes,
39 | VariantProps {
40 | asChild?: boolean
41 | }
42 |
43 | const Button = React.forwardRef(
44 | ({ className, variant, size, asChild = false, ...props }, ref) => {
45 | const Comp = asChild ? Slot : "button"
46 | return (
47 |
52 | )
53 | }
54 | )
55 | Button.displayName = "Button"
56 |
57 | export { Button, buttonVariants }
58 |
--------------------------------------------------------------------------------
/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Textarea = React.forwardRef<
6 | HTMLTextAreaElement,
7 | React.ComponentProps<"textarea">
8 | >(({ className, ...props }, ref) => {
9 | return (
10 |
18 | )
19 | })
20 | Textarea.displayName = "Textarea"
21 |
22 | export { Textarea }
23 |
--------------------------------------------------------------------------------
/components/weather.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { cn } from "@/lib/utils";
4 | import { format, isWithinInterval } from "date-fns";
5 | import { useEffect, useState } from "react";
6 |
7 | interface WeatherAtLocation {
8 | latitude: number;
9 | longitude: number;
10 | generationtime_ms: number;
11 | utc_offset_seconds: number;
12 | timezone: string;
13 | timezone_abbreviation: string;
14 | elevation: number;
15 | current_units: {
16 | time: string;
17 | interval: string;
18 | temperature_2m: string;
19 | };
20 | current: {
21 | time: string;
22 | interval: number;
23 | temperature_2m: number;
24 | };
25 | hourly_units: {
26 | time: string;
27 | temperature_2m: string;
28 | };
29 | hourly: {
30 | time: string[];
31 | temperature_2m: number[];
32 | };
33 | daily_units: {
34 | time: string;
35 | sunrise: string;
36 | sunset: string;
37 | };
38 | daily: {
39 | time: string[];
40 | sunrise: string[];
41 | sunset: string[];
42 | };
43 | }
44 |
45 | const SAMPLE = {
46 | latitude: 37.763283,
47 | longitude: -122.41286,
48 | generationtime_ms: 0.027894973754882812,
49 | utc_offset_seconds: 0,
50 | timezone: "GMT",
51 | timezone_abbreviation: "GMT",
52 | elevation: 18,
53 | current_units: { time: "iso8601", interval: "seconds", temperature_2m: "°C" },
54 | current: { time: "2024-10-07T19:30", interval: 900, temperature_2m: 29.3 },
55 | hourly_units: { time: "iso8601", temperature_2m: "°C" },
56 | hourly: {
57 | time: [
58 | "2024-10-07T00:00",
59 | "2024-10-07T01:00",
60 | "2024-10-07T02:00",
61 | "2024-10-07T03:00",
62 | "2024-10-07T04:00",
63 | "2024-10-07T05:00",
64 | "2024-10-07T06:00",
65 | "2024-10-07T07:00",
66 | "2024-10-07T08:00",
67 | "2024-10-07T09:00",
68 | "2024-10-07T10:00",
69 | "2024-10-07T11:00",
70 | "2024-10-07T12:00",
71 | "2024-10-07T13:00",
72 | "2024-10-07T14:00",
73 | "2024-10-07T15:00",
74 | "2024-10-07T16:00",
75 | "2024-10-07T17:00",
76 | "2024-10-07T18:00",
77 | "2024-10-07T19:00",
78 | "2024-10-07T20:00",
79 | "2024-10-07T21:00",
80 | "2024-10-07T22:00",
81 | "2024-10-07T23:00",
82 | "2024-10-08T00:00",
83 | "2024-10-08T01:00",
84 | "2024-10-08T02:00",
85 | "2024-10-08T03:00",
86 | "2024-10-08T04:00",
87 | "2024-10-08T05:00",
88 | "2024-10-08T06:00",
89 | "2024-10-08T07:00",
90 | "2024-10-08T08:00",
91 | "2024-10-08T09:00",
92 | "2024-10-08T10:00",
93 | "2024-10-08T11:00",
94 | "2024-10-08T12:00",
95 | "2024-10-08T13:00",
96 | "2024-10-08T14:00",
97 | "2024-10-08T15:00",
98 | "2024-10-08T16:00",
99 | "2024-10-08T17:00",
100 | "2024-10-08T18:00",
101 | "2024-10-08T19:00",
102 | "2024-10-08T20:00",
103 | "2024-10-08T21:00",
104 | "2024-10-08T22:00",
105 | "2024-10-08T23:00",
106 | "2024-10-09T00:00",
107 | "2024-10-09T01:00",
108 | "2024-10-09T02:00",
109 | "2024-10-09T03:00",
110 | "2024-10-09T04:00",
111 | "2024-10-09T05:00",
112 | "2024-10-09T06:00",
113 | "2024-10-09T07:00",
114 | "2024-10-09T08:00",
115 | "2024-10-09T09:00",
116 | "2024-10-09T10:00",
117 | "2024-10-09T11:00",
118 | "2024-10-09T12:00",
119 | "2024-10-09T13:00",
120 | "2024-10-09T14:00",
121 | "2024-10-09T15:00",
122 | "2024-10-09T16:00",
123 | "2024-10-09T17:00",
124 | "2024-10-09T18:00",
125 | "2024-10-09T19:00",
126 | "2024-10-09T20:00",
127 | "2024-10-09T21:00",
128 | "2024-10-09T22:00",
129 | "2024-10-09T23:00",
130 | "2024-10-10T00:00",
131 | "2024-10-10T01:00",
132 | "2024-10-10T02:00",
133 | "2024-10-10T03:00",
134 | "2024-10-10T04:00",
135 | "2024-10-10T05:00",
136 | "2024-10-10T06:00",
137 | "2024-10-10T07:00",
138 | "2024-10-10T08:00",
139 | "2024-10-10T09:00",
140 | "2024-10-10T10:00",
141 | "2024-10-10T11:00",
142 | "2024-10-10T12:00",
143 | "2024-10-10T13:00",
144 | "2024-10-10T14:00",
145 | "2024-10-10T15:00",
146 | "2024-10-10T16:00",
147 | "2024-10-10T17:00",
148 | "2024-10-10T18:00",
149 | "2024-10-10T19:00",
150 | "2024-10-10T20:00",
151 | "2024-10-10T21:00",
152 | "2024-10-10T22:00",
153 | "2024-10-10T23:00",
154 | "2024-10-11T00:00",
155 | "2024-10-11T01:00",
156 | "2024-10-11T02:00",
157 | "2024-10-11T03:00",
158 | ],
159 | temperature_2m: [
160 | 36.6, 32.8, 29.5, 28.6, 29.2, 28.2, 27.5, 26.6, 26.5, 26, 25, 23.5, 23.9,
161 | 24.2, 22.9, 21, 24, 28.1, 31.4, 33.9, 32.1, 28.9, 26.9, 25.2, 23, 21.1,
162 | 19.6, 18.6, 17.7, 16.8, 16.2, 15.5, 14.9, 14.4, 14.2, 13.7, 13.3, 12.9,
163 | 12.5, 13.5, 15.8, 17.7, 19.6, 21, 21.9, 22.3, 22, 20.7, 18.9, 17.9, 17.3,
164 | 17, 16.7, 16.2, 15.6, 15.2, 15, 15, 15.1, 14.8, 14.8, 14.9, 14.7, 14.8,
165 | 15.3, 16.2, 17.9, 19.6, 20.5, 21.6, 21, 20.7, 19.3, 18.7, 18.4, 17.9,
166 | 17.3, 17, 17, 16.8, 16.4, 16.2, 16, 15.8, 15.7, 15.4, 15.4, 16.1, 16.7,
167 | 17, 18.6, 19, 19.5, 19.4, 18.5, 17.9, 17.5, 16.7, 16.3, 16.1,
168 | ],
169 | },
170 | daily_units: {
171 | time: "iso8601",
172 | sunrise: "iso8601",
173 | sunset: "iso8601",
174 | },
175 | daily: {
176 | time: [
177 | "2024-10-07",
178 | "2024-10-08",
179 | "2024-10-09",
180 | "2024-10-10",
181 | "2024-10-11",
182 | ],
183 | sunrise: [
184 | "2024-10-07T07:15",
185 | "2024-10-08T07:16",
186 | "2024-10-09T07:17",
187 | "2024-10-10T07:18",
188 | "2024-10-11T07:19",
189 | ],
190 | sunset: [
191 | "2024-10-07T19:00",
192 | "2024-10-08T18:58",
193 | "2024-10-09T18:57",
194 | "2024-10-10T18:55",
195 | "2024-10-11T18:54",
196 | ],
197 | },
198 | };
199 |
200 | function n(num: number): number {
201 | return Math.ceil(num);
202 | }
203 |
204 | export function Weather({
205 | weatherAtLocation = SAMPLE,
206 | }: {
207 | weatherAtLocation?: WeatherAtLocation;
208 | }) {
209 | const currentHigh = Math.max(
210 | ...weatherAtLocation.hourly.temperature_2m.slice(0, 24),
211 | );
212 | const currentLow = Math.min(
213 | ...weatherAtLocation.hourly.temperature_2m.slice(0, 24),
214 | );
215 |
216 | const isDay = isWithinInterval(new Date(weatherAtLocation.current.time), {
217 | start: new Date(weatherAtLocation.daily.sunrise[0]),
218 | end: new Date(weatherAtLocation.daily.sunset[0]),
219 | });
220 |
221 | const [isMobile, setIsMobile] = useState(false);
222 |
223 | useEffect(() => {
224 | const handleResize = () => {
225 | setIsMobile(window.innerWidth < 768);
226 | };
227 |
228 | handleResize();
229 | window.addEventListener("resize", handleResize);
230 |
231 | return () => window.removeEventListener("resize", handleResize);
232 | }, []);
233 |
234 | const hoursToShow = isMobile ? 5 : 6;
235 |
236 | // Find the index of the current time or the next closest time
237 | const currentTimeIndex = weatherAtLocation.hourly.time.findIndex(
238 | (time) => new Date(time) >= new Date(weatherAtLocation.current.time),
239 | );
240 |
241 | // Slice the arrays to get the desired number of items
242 | const displayTimes = weatherAtLocation.hourly.time.slice(
243 | currentTimeIndex,
244 | currentTimeIndex + hoursToShow,
245 | );
246 | const displayTemperatures = weatherAtLocation.hourly.temperature_2m.slice(
247 | currentTimeIndex,
248 | currentTimeIndex + hoursToShow,
249 | );
250 |
251 | return (
252 |
263 |
264 |
265 |
276 |
277 | {n(weatherAtLocation.current.temperature_2m)}
278 | {weatherAtLocation.current_units.temperature_2m}
279 |
280 |
281 |
282 |
{`H:${n(currentHigh)}° L:${n(currentLow)}°`}
283 |
284 |
285 |
286 | {displayTimes.map((time, index) => (
287 |
288 |
289 | {format(new Date(time), "ha")}
290 |
291 |
302 |
303 | {n(displayTemperatures[index])}
304 | {weatherAtLocation.hourly_units.temperature_2m}
305 |
306 |
307 | ))}
308 |
309 |
310 | );
311 | }
312 |
--------------------------------------------------------------------------------
/hooks/use-scroll-to-bottom.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, type RefObject } from "react";
2 |
3 | export function useScrollToBottom(): [
4 | RefObject,
5 | RefObject,
6 | ] {
7 | const containerRef = useRef(null);
8 | const endRef = useRef(null);
9 |
10 | useEffect(() => {
11 | const container = containerRef.current;
12 | const end = endRef.current;
13 |
14 | if (container && end) {
15 | const observer = new MutationObserver(() => {
16 | end.scrollIntoView({ behavior: "auto", block: "end" });
17 | });
18 |
19 | observer.observe(container, {
20 | childList: true,
21 | subtree: true,
22 | attributes: true,
23 | characterData: true,
24 | });
25 |
26 | return () => observer.disconnect();
27 | }
28 | }, []);
29 |
30 | return [containerRef, endRef];
31 | }
32 |
--------------------------------------------------------------------------------
/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { Message } from "ai";
2 | import { clsx, type ClassValue } from "clsx";
3 | import { twMerge } from "tailwind-merge";
4 |
5 | export function cn(...inputs: ClassValue[]) {
6 | return twMerge(clsx(inputs));
7 | }
8 |
9 | export function sanitizeUIMessages(messages: Array): Array {
10 | const messagesBySanitizedToolInvocations = messages.map((message) => {
11 | if (message.role !== "assistant") return message;
12 |
13 | if (!message.toolInvocations) return message;
14 |
15 | const toolResultIds: Array = [];
16 |
17 | for (const toolInvocation of message.toolInvocations) {
18 | if (toolInvocation.state === "result") {
19 | toolResultIds.push(toolInvocation.toolCallId);
20 | }
21 | }
22 |
23 | const sanitizedToolInvocations = message.toolInvocations.filter(
24 | (toolInvocation) =>
25 | toolInvocation.state === "result" ||
26 | toolResultIds.includes(toolInvocation.toolCallId),
27 | );
28 |
29 | return {
30 | ...message,
31 | toolInvocations: sanitizedToolInvocations,
32 | };
33 | });
34 |
35 | return messagesBySanitizedToolInvocations.filter(
36 | (message) =>
37 | message.content.length > 0 ||
38 | (message.toolInvocations && message.toolInvocations.length > 0),
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | rewrites: async () => {
4 | return [
5 | {
6 | source: "/api/:path*",
7 | destination:
8 | process.env.NODE_ENV === "development"
9 | ? "http://127.0.0.1:8000/api/:path*"
10 | : "/api/",
11 | },
12 | {
13 | source: "/docs",
14 | destination:
15 | process.env.NODE_ENV === "development"
16 | ? "http://127.0.0.1:8000/docs"
17 | : "/api/docs",
18 | },
19 | {
20 | source: "/openapi.json",
21 | destination:
22 | process.env.NODE_ENV === "development"
23 | ? "http://127.0.0.1:8000/openapi.json"
24 | : "/api/openapi.json",
25 | },
26 | ];
27 | },
28 | };
29 |
30 | module.exports = nextConfig;
31 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nextjs-fastapi",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "fastapi-dev": "pip3 install -r requirements.txt && python3 -m uvicorn api.index:app --reload",
7 | "next-dev": "next dev",
8 | "dev": "concurrently \"npm run next-dev\" \"npm run fastapi-dev\"",
9 | "build": "next build",
10 | "start": "next start",
11 | "lint": "next lint"
12 | },
13 | "dependencies": {
14 | "@ai-sdk/ui-utils": "^0.0.20",
15 | "@radix-ui/react-slot": "^1.1.0",
16 | "@types/node": "20.2.4",
17 | "@types/react": "18.2.7",
18 | "@types/react-dom": "18.2.4",
19 | "@vercel/analytics": "^1.3.1",
20 | "@vercel/kv": "^2.0.0",
21 | "ai": "^4.0.2",
22 | "autoprefixer": "10.4.14",
23 | "class-variance-authority": "^0.7.0",
24 | "clsx": "^2.1.1",
25 | "concurrently": "^8.0.1",
26 | "date-fns": "^4.1.0",
27 | "eslint": "8.41.0",
28 | "eslint-config-next": "13.4.4",
29 | "framer-motion": "^11.11.17",
30 | "geist": "^1.3.1",
31 | "lucide-react": "^0.460.0",
32 | "next": "13.4.4",
33 | "postcss": "8.4.23",
34 | "react": "18.2.0",
35 | "react-dom": "18.2.0",
36 | "react-markdown": "^9.0.1",
37 | "remark-gfm": "^4.0.0",
38 | "sonner": "^1.5.0",
39 | "tailwind-merge": "^2.5.4",
40 | "tailwindcss": "3.4.15",
41 | "tailwindcss-animate": "^1.0.7",
42 | "types": "link:next-themes/dist/types",
43 | "typescript": "5.0.4",
44 | "usehooks-ts": "^3.1.0"
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | annotated-types==0.7.0
2 | anyio==4.4.0
3 | certifi==2024.7.4
4 | charset-normalizer==3.4.0
5 | click==8.1.7
6 | distro==1.9.0
7 | dnspython==2.6.1
8 | email_validator==2.2.0
9 | fastapi==0.111.1
10 | fastapi-cli==0.0.4
11 | h11==0.14.0
12 | httpcore==1.0.5
13 | httptools==0.6.1
14 | httpx==0.27.0
15 | idna==3.7
16 | Jinja2==3.1.4
17 | markdown-it-py==3.0.0
18 | MarkupSafe==2.1.5
19 | mdurl==0.1.2
20 | openai==1.37.1
21 | pydantic==2.8.2
22 | pydantic_core==2.20.1
23 | Pygments==2.18.0
24 | python-dotenv==1.0.1
25 | python-multipart==0.0.9
26 | PyYAML==6.0.1
27 | requests==2.32.3
28 | rich==13.7.1
29 | shellingham==1.5.4
30 | sniffio==1.3.1
31 | starlette==0.37.2
32 | tqdm==4.66.4
33 | typer==0.12.3
34 | typing_extensions==4.12.2
35 | urllib3==2.2.3
36 | uvicorn==0.30.3
37 | uvloop==0.19.0
38 | watchfiles==0.22.0
39 | websockets==12.0
40 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | darkMode: ['class'],
4 | content: [
5 | './pages/**/*.{js,ts,jsx,tsx,mdx}',
6 | './components/**/*.{js,ts,jsx,tsx,mdx}',
7 | './app/**/*.{js,ts,jsx,tsx,mdx}',
8 | ],
9 | theme: {
10 | extend: {
11 | backgroundImage: {
12 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
13 | 'gradient-conic': 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))'
14 | },
15 | borderRadius: {
16 | lg: 'var(--radius)',
17 | md: 'calc(var(--radius) - 2px)',
18 | sm: 'calc(var(--radius) - 4px)'
19 | },
20 | colors: {
21 | background: 'hsl(var(--background))',
22 | foreground: 'hsl(var(--foreground))',
23 | card: {
24 | DEFAULT: 'hsl(var(--card))',
25 | foreground: 'hsl(var(--card-foreground))'
26 | },
27 | popover: {
28 | DEFAULT: 'hsl(var(--popover))',
29 | foreground: 'hsl(var(--popover-foreground))'
30 | },
31 | primary: {
32 | DEFAULT: 'hsl(var(--primary))',
33 | foreground: 'hsl(var(--primary-foreground))'
34 | },
35 | secondary: {
36 | DEFAULT: 'hsl(var(--secondary))',
37 | foreground: 'hsl(var(--secondary-foreground))'
38 | },
39 | muted: {
40 | DEFAULT: 'hsl(var(--muted))',
41 | foreground: 'hsl(var(--muted-foreground))'
42 | },
43 | accent: {
44 | DEFAULT: 'hsl(var(--accent))',
45 | foreground: 'hsl(var(--accent-foreground))'
46 | },
47 | destructive: {
48 | DEFAULT: 'hsl(var(--destructive))',
49 | foreground: 'hsl(var(--destructive-foreground))'
50 | },
51 | border: 'hsl(var(--border))',
52 | input: 'hsl(var(--input))',
53 | ring: 'hsl(var(--ring))',
54 | chart: {
55 | '1': 'hsl(var(--chart-1))',
56 | '2': 'hsl(var(--chart-2))',
57 | '3': 'hsl(var(--chart-3))',
58 | '4': 'hsl(var(--chart-4))',
59 | '5': 'hsl(var(--chart-5))'
60 | }
61 | }
62 | }
63 | },
64 | plugins: [require("tailwindcss-animate")],
65 | }
66 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true,
17 | "plugins": [
18 | {
19 | "name": "next"
20 | }
21 | ],
22 | "paths": {
23 | "@/*": ["./*"]
24 | }
25 | },
26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
27 | "exclude": ["node_modules"]
28 | }
29 |
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "functions": {
3 | "api/**": {
4 | "excludeFiles": "{.next,.git,node_modules}/**"
5 | }
6 | }
7 | }
8 |
--------------------------------------------------------------------------------