fix: resolve scroll flickering on message send and improve thinking state animation (#1333)

This commit is contained in:
josh 2025-11-29 13:02:24 +00:00 committed by GitHub
parent d0b6f37aee
commit a3802348fa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 225 additions and 174 deletions

View file

@ -3,8 +3,8 @@
import { generateText, type UIMessage } from "ai"; import { generateText, type UIMessage } from "ai";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import type { VisibilityType } from "@/components/visibility-selector"; import type { VisibilityType } from "@/components/visibility-selector";
import { myProvider } from "@/lib/ai/providers";
import { titlePrompt } from "@/lib/ai/prompts"; import { titlePrompt } from "@/lib/ai/prompts";
import { myProvider } from "@/lib/ai/providers";
import { import {
deleteMessagesByChatIdAfterTimestamp, deleteMessagesByChatIdAfterTimestamp,
getMessageById, getMessageById,

View file

@ -1,6 +1,6 @@
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { getChatsByUserId, deleteAllChatsByUserId } from "@/lib/db/queries"; import { deleteAllChatsByUserId, getChatsByUserId } from "@/lib/db/queries";
import { ChatSDKError } from "@/lib/errors"; import { ChatSDKError } from "@/lib/errors";
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {

View file

@ -1,11 +1,9 @@
@import "tailwindcss"; @import "tailwindcss";
@import "katex/dist/katex.min.css";
/* include utility classes in streamdown */ /* include utility classes in streamdown */
@source '../node_modules/streamdown/dist/index.js'; @source '../node_modules/streamdown/dist/index.js';
/* Add KaTeX CSS for math rendering */
@import 'katex/dist/katex.min.css';
/* custom variant for setting dark mode programmatically */ /* custom variant for setting dark mode programmatically */
@custom-variant dark (&:is(.dark, .dark *)); @custom-variant dark (&:is(.dark, .dark *));

View file

@ -8,7 +8,10 @@ import { toast } from "sonner";
import { useSWRConfig } from "swr"; import { useSWRConfig } from "swr";
import { unstable_serialize } from "swr/infinite"; import { unstable_serialize } from "swr/infinite";
import { PlusIcon, TrashIcon } from "@/components/icons"; import { PlusIcon, TrashIcon } from "@/components/icons";
import { SidebarHistory, getChatHistoryPaginationKey } from "@/components/sidebar-history"; import {
getChatHistoryPaginationKey,
SidebarHistory,
} from "@/components/sidebar-history";
import { SidebarUserNav } from "@/components/sidebar-user-nav"; import { SidebarUserNav } from "@/components/sidebar-user-nav";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@ -19,7 +22,6 @@ import {
SidebarMenu, SidebarMenu,
useSidebar, useSidebar,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@ -30,6 +32,7 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "./ui/alert-dialog"; } from "./ui/alert-dialog";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
export function AppSidebar({ user }: { user: User | undefined }) { export function AppSidebar({ user }: { user: User | undefined }) {
const router = useRouter(); const router = useRouter();
@ -118,13 +121,16 @@ export function AppSidebar({ user }: { user: User | undefined }) {
<SidebarFooter>{user && <SidebarUserNav user={user} />}</SidebarFooter> <SidebarFooter>{user && <SidebarUserNav user={user} />}</SidebarFooter>
</Sidebar> </Sidebar>
<AlertDialog onOpenChange={setShowDeleteAllDialog} open={showDeleteAllDialog}> <AlertDialog
onOpenChange={setShowDeleteAllDialog}
open={showDeleteAllDialog}
>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Delete all chats?</AlertDialogTitle> <AlertDialogTitle>Delete all chats?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
This action cannot be undone. This will permanently delete all your This action cannot be undone. This will permanently delete all
chats and remove them from our servers. your chats and remove them from our servers.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>

View file

@ -1,12 +1,12 @@
"use client"; "use client";
import { useEffect, useRef } from "react"; import { useEffect } from "react";
import { initialArtifactData, useArtifact } from "@/hooks/use-artifact"; import { initialArtifactData, useArtifact } from "@/hooks/use-artifact";
import { artifactDefinitions } from "./artifact"; import { artifactDefinitions } from "./artifact";
import { useDataStream } from "./data-stream-provider"; import { useDataStream } from "./data-stream-provider";
export function DataStreamHandler() { export function DataStreamHandler() {
const { dataStream,setDataStream } = useDataStream(); const { dataStream, setDataStream } = useDataStream();
const { artifact, setArtifact, setMetadata } = useArtifact(); const { artifact, setArtifact, setMetadata } = useArtifact();
@ -77,7 +77,7 @@ export function DataStreamHandler() {
} }
}); });
} }
}, [dataStream, setArtifact, setMetadata, artifact]); }, [dataStream, setArtifact, setMetadata, artifact, setDataStream]);
return null; return null;
} }

View file

@ -114,7 +114,7 @@ export const Context = ({ className, usage, ...props }: ContextProps) => {
className={cn( className={cn(
"inline-flex select-none items-center gap-1 rounded-md text-sm", "inline-flex select-none items-center gap-1 rounded-md text-sm",
"cursor-pointer bg-background text-foreground", "cursor-pointer bg-background text-foreground",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 outline-none ring-offset-background", "outline-none ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className className
)} )}
type="button" type="button"

View file

@ -1,7 +1,6 @@
"use client"; "use client";
import type { UseChatHelpers } from "@ai-sdk/react"; import type { UseChatHelpers } from "@ai-sdk/react";
import equal from "fast-deep-equal"; import equal from "fast-deep-equal";
import { motion } from "framer-motion";
import { memo, useState } from "react"; import { memo, useState } from "react";
import type { Vote } from "@/lib/db/schema"; import type { Vote } from "@/lib/db/schema";
import type { ChatMessage } from "@/lib/types"; import type { ChatMessage } from "@/lib/types";
@ -33,7 +32,7 @@ const PurePreviewMessage = ({
setMessages, setMessages,
regenerate, regenerate,
isReadonly, isReadonly,
requiresScrollPadding, requiresScrollPadding: _requiresScrollPadding,
}: { }: {
chatId: string; chatId: string;
message: ChatMessage; message: ChatMessage;
@ -53,12 +52,10 @@ const PurePreviewMessage = ({
useDataStream(); useDataStream();
return ( return (
<motion.div <div
animate={{ opacity: 1 }} className="group/message fade-in w-full animate-in duration-200"
className="group/message w-full"
data-role={message.role} data-role={message.role}
data-testid={`message-${message.role}`} data-testid={`message-${message.role}`}
initial={{ opacity: 0 }}
> >
<div <div
className={cn("flex w-full items-start gap-2 md:gap-3", { className={cn("flex w-full items-start gap-2 md:gap-3", {
@ -77,7 +74,6 @@ const PurePreviewMessage = ({
"gap-2 md:gap-4": message.parts?.some( "gap-2 md:gap-4": message.parts?.some(
(p) => p.type === "text" && p.text?.trim() (p) => p.type === "text" && p.text?.trim()
), ),
"min-h-96": message.role === "assistant" && requiresScrollPadding,
"w-full": "w-full":
(message.role === "assistant" && (message.role === "assistant" &&
message.parts?.some( message.parts?.some(
@ -282,7 +278,7 @@ const PurePreviewMessage = ({
)} )}
</div> </div>
</div> </div>
</motion.div> </div>
); );
}; };
@ -310,30 +306,30 @@ export const PreviewMessage = memo(
); );
export const ThinkingMessage = () => { export const ThinkingMessage = () => {
const role = "assistant";
return ( return (
<motion.div <div
animate={{ opacity: 1 }} className="group/message fade-in w-full animate-in duration-300"
className="group/message w-full" data-role="assistant"
data-role={role}
data-testid="message-assistant-loading" data-testid="message-assistant-loading"
exit={{ opacity: 0, transition: { duration: 0.5 } }}
initial={{ opacity: 0 }}
transition={{ duration: 0.2 }}
> >
<div className="flex items-start justify-start gap-3"> <div className="flex items-start justify-start gap-3">
<div className="-mt-1 flex size-8 shrink-0 items-center justify-center rounded-full bg-background ring-1 ring-border"> <div className="-mt-1 flex size-8 shrink-0 items-center justify-center rounded-full bg-background ring-1 ring-border">
<SparklesIcon size={14} /> <div className="animate-pulse">
<SparklesIcon size={14} />
</div>
</div> </div>
<div className="flex w-full flex-col gap-2 md:gap-4"> <div className="flex w-full flex-col gap-2 md:gap-4">
<div className="p-0 text-muted-foreground text-sm"> <div className="flex items-center gap-1 p-0 text-muted-foreground text-sm">
Thinking... <span className="animate-pulse">Thinking</span>
<span className="inline-flex">
<span className="animate-bounce [animation-delay:0ms]">.</span>
<span className="animate-bounce [animation-delay:150ms]">.</span>
<span className="animate-bounce [animation-delay:300ms]">.</span>
</span>
</div> </div>
</div> </div>
</div> </div>
</motion.div> </div>
); );
}; };

View file

@ -1,13 +1,11 @@
import type { UseChatHelpers } from "@ai-sdk/react"; import type { UseChatHelpers } from "@ai-sdk/react";
import equal from "fast-deep-equal"; import equal from "fast-deep-equal";
import { AnimatePresence } from "framer-motion";
import { ArrowDownIcon } from "lucide-react"; import { ArrowDownIcon } from "lucide-react";
import { memo, useEffect } from "react"; import { memo } from "react";
import { useMessages } from "@/hooks/use-messages"; import { useMessages } from "@/hooks/use-messages";
import type { Vote } from "@/lib/db/schema"; import type { Vote } from "@/lib/db/schema";
import type { ChatMessage } from "@/lib/types"; import type { ChatMessage } from "@/lib/types";
import { useDataStream } from "./data-stream-provider"; import { useDataStream } from "./data-stream-provider";
import { Conversation, ConversationContent } from "./elements/conversation";
import { Greeting } from "./greeting"; import { Greeting } from "./greeting";
import { PreviewMessage, ThinkingMessage } from "./message"; import { PreviewMessage, ThinkingMessage } from "./message";
@ -31,7 +29,7 @@ function PureMessages({
setMessages, setMessages,
regenerate, regenerate,
isReadonly, isReadonly,
selectedModelId, selectedModelId: _selectedModelId,
}: MessagesProps) { }: MessagesProps) {
const { const {
containerRef: messagesContainerRef, containerRef: messagesContainerRef,
@ -45,62 +43,41 @@ function PureMessages({
useDataStream(); useDataStream();
useEffect(() => {
if (status === "submitted") {
requestAnimationFrame(() => {
const container = messagesContainerRef.current;
if (container) {
container.scrollTo({
top: container.scrollHeight,
behavior: "smooth",
});
}
});
}
}, [status, messagesContainerRef]);
return ( return (
<div <div
className="overscroll-behavior-contain -webkit-overflow-scrolling-touch flex-1 touch-pan-y overflow-y-scroll" className="relative flex-1 touch-pan-y overflow-y-auto"
ref={messagesContainerRef} ref={messagesContainerRef}
style={{ overflowAnchor: "none" }}
> >
<Conversation className="mx-auto flex min-w-0 max-w-4xl flex-col gap-4 md:gap-6"> <div className="mx-auto flex min-w-0 max-w-4xl flex-col gap-4 px-2 py-4 md:gap-6 md:px-4">
<ConversationContent className="flex flex-col gap-4 px-2 py-4 md:gap-6 md:px-4"> {messages.length === 0 && <Greeting />}
{messages.length === 0 && <Greeting />}
{messages.map((message, index) => ( {messages.map((message, index) => (
<PreviewMessage <PreviewMessage
chatId={chatId} chatId={chatId}
isLoading={ isLoading={status === "streaming" && messages.length - 1 === index}
status === "streaming" && messages.length - 1 === index isReadonly={isReadonly}
} key={message.id}
isReadonly={isReadonly} message={message}
key={message.id} regenerate={regenerate}
message={message} requiresScrollPadding={
regenerate={regenerate} hasSentMessage && index === messages.length - 1
requiresScrollPadding={ }
hasSentMessage && index === messages.length - 1 setMessages={setMessages}
} vote={
setMessages={setMessages} votes
vote={ ? votes.find((vote) => vote.messageId === message.id)
votes : undefined
? votes.find((vote) => vote.messageId === message.id) }
: undefined
}
/>
))}
<AnimatePresence mode="wait">
{status === "submitted" && <ThinkingMessage key="thinking" />}
</AnimatePresence>
<div
className="min-h-[24px] min-w-[24px] shrink-0"
ref={messagesEndRef}
/> />
</ConversationContent> ))}
</Conversation>
{status === "submitted" && <ThinkingMessage />}
<div
className="min-h-[24px] min-w-[24px] shrink-0"
ref={messagesEndRef}
/>
</div>
{!isAtBottom && ( {!isAtBottom && (
<button <button

View file

@ -21,7 +21,6 @@ import { useLocalStorage, useWindowSize } from "usehooks-ts";
import { saveChatModelAsCookie } from "@/app/(chat)/actions"; import { saveChatModelAsCookie } from "@/app/(chat)/actions";
import { SelectItem } from "@/components/ui/select"; import { SelectItem } from "@/components/ui/select";
import { chatModels } from "@/lib/ai/models"; import { chatModels } from "@/lib/ai/models";
import { myProvider } from "@/lib/ai/providers";
import type { Attachment, ChatMessage } from "@/lib/types"; import type { Attachment, ChatMessage } from "@/lib/types";
import type { AppUsage } from "@/lib/usage"; import type { AppUsage } from "@/lib/usage";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -195,10 +194,6 @@ function PureMultimodalInput({
} }
}, []); }, []);
const _modelResolver = useMemo(() => {
return myProvider.languageModel(selectedModelId);
}, [selectedModelId]);
const contextProps = useMemo( const contextProps = useMemo(
() => ({ () => ({
usage, usage,
@ -235,32 +230,35 @@ function PureMultimodalInput({
const handlePaste = useCallback( const handlePaste = useCallback(
async (event: ClipboardEvent) => { async (event: ClipboardEvent) => {
const items = event.clipboardData?.items; const items = event.clipboardData?.items;
if (!items) return; if (!items) {
return;
}
const imageItems = Array.from(items).filter((item) => const imageItems = Array.from(items).filter((item) =>
item.type.startsWith('image/'), item.type.startsWith("image/")
); );
if (imageItems.length === 0) return; if (imageItems.length === 0) {
return;
}
// Prevent default paste behavior for images // Prevent default paste behavior for images
event.preventDefault(); event.preventDefault();
setUploadQueue((prev) => [...prev, 'Pasted image']); setUploadQueue((prev) => [...prev, "Pasted image"]);
try { try {
const uploadPromises = imageItems.map(async (item) => { const uploadPromises = imageItems
const file = item.getAsFile(); .map((item) => item.getAsFile())
if (!file) return; .filter((file): file is File => file !== null)
return uploadFile(file); .map((file) => uploadFile(file));
});
const uploadedAttachments = await Promise.all(uploadPromises); const uploadedAttachments = await Promise.all(uploadPromises);
const successfullyUploadedAttachments = uploadedAttachments.filter( const successfullyUploadedAttachments = uploadedAttachments.filter(
(attachment) => (attachment) =>
attachment !== undefined && attachment !== undefined &&
attachment.url !== undefined && attachment.url !== undefined &&
attachment.contentType !== undefined, attachment.contentType !== undefined
); );
setAttachments((curr) => [ setAttachments((curr) => [
@ -268,22 +266,24 @@ function PureMultimodalInput({
...(successfullyUploadedAttachments as Attachment[]), ...(successfullyUploadedAttachments as Attachment[]),
]); ]);
} catch (error) { } catch (error) {
console.error('Error uploading pasted images:', error); console.error("Error uploading pasted images:", error);
toast.error('Failed to upload pasted image(s)'); toast.error("Failed to upload pasted image(s)");
} finally { } finally {
setUploadQueue([]); setUploadQueue([]);
} }
}, },
[setAttachments], [setAttachments, uploadFile]
); );
// Add paste event listener to textarea // Add paste event listener to textarea
useEffect(() => { useEffect(() => {
const textarea = textareaRef.current; const textarea = textareaRef.current;
if (!textarea) return; if (!textarea) {
return;
}
textarea.addEventListener('paste', handlePaste); textarea.addEventListener("paste", handlePaste);
return () => textarea.removeEventListener('paste', handlePaste); return () => textarea.removeEventListener("paste", handlePaste);
}, [handlePaste]); }, [handlePaste]);
return ( return (
@ -385,9 +385,9 @@ function PureMultimodalInput({
) : ( ) : (
<PromptInputSubmit <PromptInputSubmit
className="size-8 rounded-full bg-primary text-primary-foreground transition-colors duration-200 hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground" className="size-8 rounded-full bg-primary text-primary-foreground transition-colors duration-200 hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground"
data-testid="send-button"
disabled={!input.trim() || uploadQueue.length > 0} disabled={!input.trim() || uploadQueue.length > 0}
status={status} status={status}
data-testid="send-button"
> >
<ArrowUpIcon size={14} /> <ArrowUpIcon size={14} />
</PromptInputSubmit> </PromptInputSubmit>
@ -482,7 +482,7 @@ function PureModelSelectorCompact({
value={selectedModel?.name} value={selectedModel?.name}
> >
<Trigger asChild> <Trigger asChild>
<Button variant="ghost" className="h-8 px-2"> <Button className="h-8 px-2" variant="ghost">
<CpuIcon size={16} /> <CpuIcon size={16} />
<span className="hidden font-medium text-xs sm:block"> <span className="hidden font-medium text-xs sm:block">
{selectedModel?.name} {selectedModel?.name}

View file

@ -36,9 +36,9 @@ export const PreviewAttachment = ({
{isUploading && ( {isUploading && (
<div <div
className="absolute inset-0 flex items-center justify-center bg-black/50" className="absolute inset-0 flex items-center justify-center bg-black/50"
data-testid="input-attachment-loader" data-testid="input-attachment-loader"
> >
<Loader size={16} /> <Loader size={16} />
</div> </div>
)} )}

View file

@ -5,28 +5,78 @@ import { format, isWithinInterval } from "date-fns";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
const SunIcon = ({ size = 40 }: { size?: number }) => ( const SunIcon = ({ size = 40 }: { size?: number }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none"> <svg fill="none" height={size} viewBox="0 0 24 24" width={size}>
<circle cx="12" cy="12" r="5" fill="currentColor" /> <circle cx="12" cy="12" fill="currentColor" r="5" />
<line x1="12" y1="1" x2="12" y2="3" stroke="currentColor" strokeWidth="2" /> <line stroke="currentColor" strokeWidth="2" x1="12" x2="12" y1="1" y2="3" />
<line x1="12" y1="21" x2="12" y2="23" stroke="currentColor" strokeWidth="2" /> <line
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" stroke="currentColor" strokeWidth="2" /> stroke="currentColor"
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" stroke="currentColor" strokeWidth="2" /> strokeWidth="2"
<line x1="1" y1="12" x2="3" y2="12" stroke="currentColor" strokeWidth="2" /> x1="12"
<line x1="21" y1="12" x2="23" y2="12" stroke="currentColor" strokeWidth="2" /> x2="12"
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" stroke="currentColor" strokeWidth="2" /> y1="21"
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" stroke="currentColor" strokeWidth="2" /> y2="23"
/>
<line
stroke="currentColor"
strokeWidth="2"
x1="4.22"
x2="5.64"
y1="4.22"
y2="5.64"
/>
<line
stroke="currentColor"
strokeWidth="2"
x1="18.36"
x2="19.78"
y1="18.36"
y2="19.78"
/>
<line stroke="currentColor" strokeWidth="2" x1="1" x2="3" y1="12" y2="12" />
<line
stroke="currentColor"
strokeWidth="2"
x1="21"
x2="23"
y1="12"
y2="12"
/>
<line
stroke="currentColor"
strokeWidth="2"
x1="4.22"
x2="5.64"
y1="19.78"
y2="18.36"
/>
<line
stroke="currentColor"
strokeWidth="2"
x1="18.36"
x2="19.78"
y1="5.64"
y2="4.22"
/>
</svg> </svg>
); );
const MoonIcon = ({ size = 40 }: { size?: number }) => ( const MoonIcon = ({ size = 40 }: { size?: number }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none"> <svg fill="none" height={size} viewBox="0 0 24 24" width={size}>
<path d="M21 12.79A9 9 0 1 1 11.21 3A7 7 0 0 0 21 12.79z" fill="currentColor" /> <path
d="M21 12.79A9 9 0 1 1 11.21 3A7 7 0 0 0 21 12.79z"
fill="currentColor"
/>
</svg> </svg>
); );
const CloudIcon = ({ size = 24 }: { size?: number }) => ( const CloudIcon = ({ size = 24 }: { size?: number }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none"> <svg fill="none" height={size} viewBox="0 0 24 24" width={size}>
<path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z" stroke="currentColor" strokeWidth="2" fill="none" /> <path
d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"
fill="none"
stroke="currentColor"
strokeWidth="2"
/>
</svg> </svg>
); );
@ -273,39 +323,44 @@ export function Weather({
currentTimeIndex + hoursToShow currentTimeIndex + hoursToShow
); );
const location = weatherAtLocation.cityName || const location =
weatherAtLocation.cityName ||
`${weatherAtLocation.latitude?.toFixed(1)}°, ${weatherAtLocation.longitude?.toFixed(1)}°`; `${weatherAtLocation.latitude?.toFixed(1)}°, ${weatherAtLocation.longitude?.toFixed(1)}°`;
return ( return (
<div <div
className={cx( className={cx(
"relative flex w-full flex-col gap-6 rounded-3xl p-6 shadow-lg overflow-hidden backdrop-blur-sm", "relative flex w-full flex-col gap-6 overflow-hidden rounded-3xl p-6 shadow-lg backdrop-blur-sm",
{ {
"bg-gradient-to-br from-sky-400 via-blue-500 to-blue-600": isDay, "bg-gradient-to-br from-sky-400 via-blue-500 to-blue-600": isDay,
}, },
{ {
"bg-gradient-to-br from-indigo-900 via-purple-900 to-slate-900": !isDay, "bg-gradient-to-br from-indigo-900 via-purple-900 to-slate-900":
!isDay,
} }
)} )}
> >
<div className="absolute inset-0 bg-white/10 backdrop-blur-sm" /> <div className="absolute inset-0 bg-white/10 backdrop-blur-sm" />
<div className="relative z-10"> <div className="relative z-10">
<div className="flex items-center justify-between mb-4"> <div className="mb-4 flex items-center justify-between">
<div className="text-white/80 text-sm font-medium"> <div className="font-medium text-sm text-white/80">{location}</div>
{location}
</div>
<div className="text-white/60 text-xs"> <div className="text-white/60 text-xs">
{format(new Date(weatherAtLocation.current.time), "MMM d, h:mm a")} {format(new Date(weatherAtLocation.current.time), "MMM d, h:mm a")}
</div> </div>
</div> </div>
<div className="flex items-center justify-between mb-6"> <div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className={cx("text-white/90", { "text-yellow-200": isDay, "text-blue-200": !isDay })}> <div
className={cx("text-white/90", {
"text-yellow-200": isDay,
"text-blue-200": !isDay,
})}
>
{isDay ? <SunIcon size={48} /> : <MoonIcon size={48} />} {isDay ? <SunIcon size={48} /> : <MoonIcon size={48} />}
</div> </div>
<div className="text-white text-5xl font-light"> <div className="font-light text-5xl text-white">
{n(weatherAtLocation.current.temperature_2m)} {n(weatherAtLocation.current.temperature_2m)}
<span className="text-2xl text-white/80"> <span className="text-2xl text-white/80">
{weatherAtLocation.current_units.temperature_2m} {weatherAtLocation.current_units.temperature_2m}
@ -314,43 +369,47 @@ export function Weather({
</div> </div>
<div className="text-right"> <div className="text-right">
<div className="text-white/90 text-sm font-medium"> <div className="font-medium text-sm text-white/90">
H: {n(currentHigh)}° H: {n(currentHigh)}°
</div> </div>
<div className="text-white/70 text-sm"> <div className="text-sm text-white/70">L: {n(currentLow)}°</div>
L: {n(currentLow)}°
</div>
</div> </div>
</div> </div>
<div className="bg-white/10 rounded-2xl p-4 backdrop-blur-sm"> <div className="rounded-2xl bg-white/10 p-4 backdrop-blur-sm">
<div className="text-white/80 text-sm mb-3 font-medium"> <div className="mb-3 font-medium text-sm text-white/80">
Hourly Forecast Hourly Forecast
</div> </div>
<div className="flex justify-between gap-2"> <div className="flex justify-between gap-2">
{displayTimes.map((time, index) => { {displayTimes.map((time, index) => {
const hourTime = new Date(time); const hourTime = new Date(time);
const isCurrentHour = hourTime.getHours() === new Date().getHours(); const isCurrentHour =
hourTime.getHours() === new Date().getHours();
return ( return (
<div <div
className={cx( className={cx(
"flex flex-col items-center gap-2 py-2 px-1 rounded-lg min-w-0 flex-1", "flex min-w-0 flex-1 flex-col items-center gap-2 rounded-lg px-1 py-2",
{ {
"bg-white/20": isCurrentHour, "bg-white/20": isCurrentHour,
} }
)} )}
key={time} key={time}
> >
<div className="text-white/70 text-xs font-medium"> <div className="font-medium text-white/70 text-xs">
{index === 0 ? "Now" : format(hourTime, "ha")} {index === 0 ? "Now" : format(hourTime, "ha")}
</div> </div>
<div className={cx("text-white/60", { "text-yellow-200": isDay, "text-blue-200": !isDay })}> <div
className={cx("text-white/60", {
"text-yellow-200": isDay,
"text-blue-200": !isDay,
})}
>
<CloudIcon size={20} /> <CloudIcon size={20} />
</div> </div>
<div className="text-white text-sm font-medium"> <div className="font-medium text-sm text-white">
{n(displayTemperatures[index])}° {n(displayTemperatures[index])}°
</div> </div>
</div> </div>
@ -359,9 +418,15 @@ export function Weather({
</div> </div>
</div> </div>
<div className="flex justify-between text-white/60 text-xs mt-4"> <div className="mt-4 flex justify-between text-white/60 text-xs">
<div>Sunrise: {format(new Date(weatherAtLocation.daily.sunrise[0]), "h:mm a")}</div> <div>
<div>Sunset: {format(new Date(weatherAtLocation.daily.sunset[0]), "h:mm a")}</div> Sunrise:{" "}
{format(new Date(weatherAtLocation.daily.sunrise[0]), "h:mm a")}
</div>
<div>
Sunset:{" "}
{format(new Date(weatherAtLocation.daily.sunset[0]), "h:mm a")}
</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -117,4 +117,4 @@ export const titlePrompt = `\n
- you will generate a short title based on the first message a user begins a conversation with - you will generate a short title based on the first message a user begins a conversation with
- ensure it is not more than 80 characters long - ensure it is not more than 80 characters long
- the title should be a summary of the user's message - the title should be a summary of the user's message
- do not use quotes or colons` - do not use quotes or colons`;

View file

@ -1,13 +1,17 @@
import { tool } from "ai"; import { tool } from "ai";
import { z } from "zod"; import { z } from "zod";
async function geocodeCity(city: string): Promise<{ latitude: number; longitude: number } | null> { async function geocodeCity(
city: string
): Promise<{ latitude: number; longitude: number } | null> {
try { try {
const response = await fetch( const response = await fetch(
`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1&language=en&format=json` `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1&language=en&format=json`
); );
if (!response.ok) return null; if (!response.ok) {
return null;
}
const data = await response.json(); const data = await response.json();
@ -26,11 +30,15 @@ async function geocodeCity(city: string): Promise<{ latitude: number; longitude:
} }
export const getWeather = tool({ export const getWeather = tool({
description: "Get the current weather at a location. You can provide either coordinates or a city name.", description:
"Get the current weather at a location. You can provide either coordinates or a city name.",
inputSchema: z.object({ inputSchema: z.object({
latitude: z.number().optional(), latitude: z.number().optional(),
longitude: z.number().optional(), longitude: z.number().optional(),
city: z.string().describe("City name (e.g., 'San Francisco', 'New York', 'London')").optional(), city: z
.string()
.describe("City name (e.g., 'San Francisco', 'New York', 'London')")
.optional(),
}), }),
execute: async (input) => { execute: async (input) => {
let latitude: number; let latitude: number;
@ -50,7 +58,8 @@ export const getWeather = tool({
longitude = input.longitude; longitude = input.longitude;
} else { } else {
return { return {
error: "Please provide either a city name or both latitude and longitude coordinates.", error:
"Please provide either a city name or both latitude and longitude coordinates.",
}; };
} }

View file

@ -134,7 +134,7 @@ export async function deleteAllChatsByUserId({ userId }: { userId: string }) {
return { deletedCount: 0 }; return { deletedCount: 0 };
} }
const chatIds = userChats.map(c => c.id); const chatIds = userChats.map((c) => c.id);
await db.delete(vote).where(inArray(vote.chatId, chatIds)); await db.delete(vote).where(inArray(vote.chatId, chatIds));
await db.delete(message).where(inArray(message.chatId, chatIds)); await db.delete(message).where(inArray(message.chatId, chatIds));