fix: resolve scroll flickering on message send and improve thinking state animation (#1333)
This commit is contained in:
parent
d0b6f37aee
commit
a3802348fa
14 changed files with 225 additions and 174 deletions
|
|
@ -3,8 +3,8 @@
|
|||
import { generateText, type UIMessage } from "ai";
|
||||
import { cookies } from "next/headers";
|
||||
import type { VisibilityType } from "@/components/visibility-selector";
|
||||
import { myProvider } from "@/lib/ai/providers";
|
||||
import { titlePrompt } from "@/lib/ai/prompts";
|
||||
import { myProvider } from "@/lib/ai/providers";
|
||||
import {
|
||||
deleteMessagesByChatIdAfterTimestamp,
|
||||
getMessageById,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { NextRequest } from "next/server";
|
||||
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";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
@import "tailwindcss";
|
||||
@import "katex/dist/katex.min.css";
|
||||
|
||||
/* include utility classes in streamdown */
|
||||
@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 dark (&:is(.dark, .dark *));
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@ import { toast } from "sonner";
|
|||
import { useSWRConfig } from "swr";
|
||||
import { unstable_serialize } from "swr/infinite";
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import {
|
||||
|
|
@ -19,7 +22,6 @@ import {
|
|||
SidebarMenu,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
|
|
@ -30,6 +32,7 @@ import {
|
|||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "./ui/alert-dialog";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
export function AppSidebar({ user }: { user: User | undefined }) {
|
||||
const router = useRouter();
|
||||
|
|
@ -118,13 +121,16 @@ export function AppSidebar({ user }: { user: User | undefined }) {
|
|||
<SidebarFooter>{user && <SidebarUserNav user={user} />}</SidebarFooter>
|
||||
</Sidebar>
|
||||
|
||||
<AlertDialog onOpenChange={setShowDeleteAllDialog} open={showDeleteAllDialog}>
|
||||
<AlertDialog
|
||||
onOpenChange={setShowDeleteAllDialog}
|
||||
open={showDeleteAllDialog}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete all chats?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete all your
|
||||
chats and remove them from our servers.
|
||||
This action cannot be undone. This will permanently delete all
|
||||
your chats and remove them from our servers.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { initialArtifactData, useArtifact } from "@/hooks/use-artifact";
|
||||
import { artifactDefinitions } from "./artifact";
|
||||
import { useDataStream } from "./data-stream-provider";
|
||||
|
||||
export function DataStreamHandler() {
|
||||
const { dataStream,setDataStream } = useDataStream();
|
||||
const { dataStream, setDataStream } = useDataStream();
|
||||
|
||||
const { artifact, setArtifact, setMetadata } = useArtifact();
|
||||
|
||||
|
|
@ -77,7 +77,7 @@ export function DataStreamHandler() {
|
|||
}
|
||||
});
|
||||
}
|
||||
}, [dataStream, setArtifact, setMetadata, artifact]);
|
||||
}, [dataStream, setArtifact, setMetadata, artifact, setDataStream]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ export const Context = ({ className, usage, ...props }: ContextProps) => {
|
|||
className={cn(
|
||||
"inline-flex select-none items-center gap-1 rounded-md text-sm",
|
||||
"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
|
||||
)}
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
"use client";
|
||||
import type { UseChatHelpers } from "@ai-sdk/react";
|
||||
import equal from "fast-deep-equal";
|
||||
import { motion } from "framer-motion";
|
||||
import { memo, useState } from "react";
|
||||
import type { Vote } from "@/lib/db/schema";
|
||||
import type { ChatMessage } from "@/lib/types";
|
||||
|
|
@ -33,7 +32,7 @@ const PurePreviewMessage = ({
|
|||
setMessages,
|
||||
regenerate,
|
||||
isReadonly,
|
||||
requiresScrollPadding,
|
||||
requiresScrollPadding: _requiresScrollPadding,
|
||||
}: {
|
||||
chatId: string;
|
||||
message: ChatMessage;
|
||||
|
|
@ -53,12 +52,10 @@ const PurePreviewMessage = ({
|
|||
useDataStream();
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
animate={{ opacity: 1 }}
|
||||
className="group/message w-full"
|
||||
<div
|
||||
className="group/message fade-in w-full animate-in duration-200"
|
||||
data-role={message.role}
|
||||
data-testid={`message-${message.role}`}
|
||||
initial={{ opacity: 0 }}
|
||||
>
|
||||
<div
|
||||
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(
|
||||
(p) => p.type === "text" && p.text?.trim()
|
||||
),
|
||||
"min-h-96": message.role === "assistant" && requiresScrollPadding,
|
||||
"w-full":
|
||||
(message.role === "assistant" &&
|
||||
message.parts?.some(
|
||||
|
|
@ -282,7 +278,7 @@ const PurePreviewMessage = ({
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -310,30 +306,30 @@ export const PreviewMessage = memo(
|
|||
);
|
||||
|
||||
export const ThinkingMessage = () => {
|
||||
const role = "assistant";
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
animate={{ opacity: 1 }}
|
||||
className="group/message w-full"
|
||||
data-role={role}
|
||||
<div
|
||||
className="group/message fade-in w-full animate-in duration-300"
|
||||
data-role="assistant"
|
||||
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="-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 className="flex w-full flex-col gap-2 md:gap-4">
|
||||
<div className="p-0 text-muted-foreground text-sm">
|
||||
Thinking...
|
||||
<div className="flex items-center gap-1 p-0 text-muted-foreground text-sm">
|
||||
<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>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,11 @@
|
|||
import type { UseChatHelpers } from "@ai-sdk/react";
|
||||
import equal from "fast-deep-equal";
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import { ArrowDownIcon } from "lucide-react";
|
||||
import { memo, useEffect } from "react";
|
||||
import { memo } from "react";
|
||||
import { useMessages } from "@/hooks/use-messages";
|
||||
import type { Vote } from "@/lib/db/schema";
|
||||
import type { ChatMessage } from "@/lib/types";
|
||||
import { useDataStream } from "./data-stream-provider";
|
||||
import { Conversation, ConversationContent } from "./elements/conversation";
|
||||
import { Greeting } from "./greeting";
|
||||
import { PreviewMessage, ThinkingMessage } from "./message";
|
||||
|
||||
|
|
@ -31,7 +29,7 @@ function PureMessages({
|
|||
setMessages,
|
||||
regenerate,
|
||||
isReadonly,
|
||||
selectedModelId,
|
||||
selectedModelId: _selectedModelId,
|
||||
}: MessagesProps) {
|
||||
const {
|
||||
containerRef: messagesContainerRef,
|
||||
|
|
@ -45,62 +43,41 @@ function PureMessages({
|
|||
|
||||
useDataStream();
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "submitted") {
|
||||
requestAnimationFrame(() => {
|
||||
const container = messagesContainerRef.current;
|
||||
if (container) {
|
||||
container.scrollTo({
|
||||
top: container.scrollHeight,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [status, messagesContainerRef]);
|
||||
|
||||
return (
|
||||
<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}
|
||||
style={{ overflowAnchor: "none" }}
|
||||
>
|
||||
<Conversation className="mx-auto flex min-w-0 max-w-4xl flex-col gap-4 md:gap-6">
|
||||
<ConversationContent className="flex flex-col gap-4 px-2 py-4 md:gap-6 md:px-4">
|
||||
{messages.length === 0 && <Greeting />}
|
||||
<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">
|
||||
{messages.length === 0 && <Greeting />}
|
||||
|
||||
{messages.map((message, index) => (
|
||||
<PreviewMessage
|
||||
chatId={chatId}
|
||||
isLoading={
|
||||
status === "streaming" && messages.length - 1 === index
|
||||
}
|
||||
isReadonly={isReadonly}
|
||||
key={message.id}
|
||||
message={message}
|
||||
regenerate={regenerate}
|
||||
requiresScrollPadding={
|
||||
hasSentMessage && index === messages.length - 1
|
||||
}
|
||||
setMessages={setMessages}
|
||||
vote={
|
||||
votes
|
||||
? 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}
|
||||
{messages.map((message, index) => (
|
||||
<PreviewMessage
|
||||
chatId={chatId}
|
||||
isLoading={status === "streaming" && messages.length - 1 === index}
|
||||
isReadonly={isReadonly}
|
||||
key={message.id}
|
||||
message={message}
|
||||
regenerate={regenerate}
|
||||
requiresScrollPadding={
|
||||
hasSentMessage && index === messages.length - 1
|
||||
}
|
||||
setMessages={setMessages}
|
||||
vote={
|
||||
votes
|
||||
? votes.find((vote) => vote.messageId === message.id)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</ConversationContent>
|
||||
</Conversation>
|
||||
))}
|
||||
|
||||
{status === "submitted" && <ThinkingMessage />}
|
||||
|
||||
<div
|
||||
className="min-h-[24px] min-w-[24px] shrink-0"
|
||||
ref={messagesEndRef}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isAtBottom && (
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ import { useLocalStorage, useWindowSize } from "usehooks-ts";
|
|||
import { saveChatModelAsCookie } from "@/app/(chat)/actions";
|
||||
import { SelectItem } from "@/components/ui/select";
|
||||
import { chatModels } from "@/lib/ai/models";
|
||||
import { myProvider } from "@/lib/ai/providers";
|
||||
import type { Attachment, ChatMessage } from "@/lib/types";
|
||||
import type { AppUsage } from "@/lib/usage";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -195,10 +194,6 @@ function PureMultimodalInput({
|
|||
}
|
||||
}, []);
|
||||
|
||||
const _modelResolver = useMemo(() => {
|
||||
return myProvider.languageModel(selectedModelId);
|
||||
}, [selectedModelId]);
|
||||
|
||||
const contextProps = useMemo(
|
||||
() => ({
|
||||
usage,
|
||||
|
|
@ -231,36 +226,39 @@ function PureMultimodalInput({
|
|||
},
|
||||
[setAttachments, uploadFile]
|
||||
);
|
||||
|
||||
|
||||
const handlePaste = useCallback(
|
||||
async (event: ClipboardEvent) => {
|
||||
const items = event.clipboardData?.items;
|
||||
if (!items) return;
|
||||
if (!items) {
|
||||
return;
|
||||
}
|
||||
|
||||
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
|
||||
event.preventDefault();
|
||||
|
||||
setUploadQueue((prev) => [...prev, 'Pasted image']);
|
||||
setUploadQueue((prev) => [...prev, "Pasted image"]);
|
||||
|
||||
try {
|
||||
const uploadPromises = imageItems.map(async (item) => {
|
||||
const file = item.getAsFile();
|
||||
if (!file) return;
|
||||
return uploadFile(file);
|
||||
});
|
||||
const uploadPromises = imageItems
|
||||
.map((item) => item.getAsFile())
|
||||
.filter((file): file is File => file !== null)
|
||||
.map((file) => uploadFile(file));
|
||||
|
||||
const uploadedAttachments = await Promise.all(uploadPromises);
|
||||
const successfullyUploadedAttachments = uploadedAttachments.filter(
|
||||
(attachment) =>
|
||||
attachment !== undefined &&
|
||||
attachment.url !== undefined &&
|
||||
attachment.contentType !== undefined,
|
||||
attachment.contentType !== undefined
|
||||
);
|
||||
|
||||
setAttachments((curr) => [
|
||||
|
|
@ -268,22 +266,24 @@ function PureMultimodalInput({
|
|||
...(successfullyUploadedAttachments as Attachment[]),
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('Error uploading pasted images:', error);
|
||||
toast.error('Failed to upload pasted image(s)');
|
||||
console.error("Error uploading pasted images:", error);
|
||||
toast.error("Failed to upload pasted image(s)");
|
||||
} finally {
|
||||
setUploadQueue([]);
|
||||
}
|
||||
},
|
||||
[setAttachments],
|
||||
[setAttachments, uploadFile]
|
||||
);
|
||||
|
||||
// Add paste event listener to textarea
|
||||
useEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
if (!textarea) {
|
||||
return;
|
||||
}
|
||||
|
||||
textarea.addEventListener('paste', handlePaste);
|
||||
return () => textarea.removeEventListener('paste', handlePaste);
|
||||
textarea.addEventListener("paste", handlePaste);
|
||||
return () => textarea.removeEventListener("paste", handlePaste);
|
||||
}, [handlePaste]);
|
||||
|
||||
return (
|
||||
|
|
@ -385,9 +385,9 @@ function PureMultimodalInput({
|
|||
) : (
|
||||
<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"
|
||||
data-testid="send-button"
|
||||
disabled={!input.trim() || uploadQueue.length > 0}
|
||||
status={status}
|
||||
data-testid="send-button"
|
||||
>
|
||||
<ArrowUpIcon size={14} />
|
||||
</PromptInputSubmit>
|
||||
|
|
@ -482,7 +482,7 @@ function PureModelSelectorCompact({
|
|||
value={selectedModel?.name}
|
||||
>
|
||||
<Trigger asChild>
|
||||
<Button variant="ghost" className="h-8 px-2">
|
||||
<Button className="h-8 px-2" variant="ghost">
|
||||
<CpuIcon size={16} />
|
||||
<span className="hidden font-medium text-xs sm:block">
|
||||
{selectedModel?.name}
|
||||
|
|
|
|||
|
|
@ -36,9 +36,9 @@ export const PreviewAttachment = ({
|
|||
|
||||
{isUploading && (
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center bg-black/50"
|
||||
data-testid="input-attachment-loader"
|
||||
>
|
||||
className="absolute inset-0 flex items-center justify-center bg-black/50"
|
||||
data-testid="input-attachment-loader"
|
||||
>
|
||||
<Loader size={16} />
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -5,28 +5,78 @@ import { format, isWithinInterval } from "date-fns";
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
const SunIcon = ({ size = 40 }: { size?: number }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="5" fill="currentColor" />
|
||||
<line x1="12" y1="1" x2="12" y2="3" stroke="currentColor" strokeWidth="2" />
|
||||
<line x1="12" y1="21" x2="12" y2="23" stroke="currentColor" strokeWidth="2" />
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" stroke="currentColor" strokeWidth="2" />
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" stroke="currentColor" strokeWidth="2" />
|
||||
<line x1="1" y1="12" x2="3" y2="12" stroke="currentColor" strokeWidth="2" />
|
||||
<line x1="21" y1="12" x2="23" y2="12" stroke="currentColor" strokeWidth="2" />
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" stroke="currentColor" strokeWidth="2" />
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" stroke="currentColor" strokeWidth="2" />
|
||||
<svg fill="none" height={size} viewBox="0 0 24 24" width={size}>
|
||||
<circle cx="12" cy="12" fill="currentColor" r="5" />
|
||||
<line stroke="currentColor" strokeWidth="2" x1="12" x2="12" y1="1" y2="3" />
|
||||
<line
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
x1="12"
|
||||
x2="12"
|
||||
y1="21"
|
||||
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>
|
||||
);
|
||||
|
||||
const MoonIcon = ({ size = 40 }: { size?: number }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3A7 7 0 0 0 21 12.79z" fill="currentColor" />
|
||||
<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"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CloudIcon = ({ size = 24 }: { size?: number }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z" stroke="currentColor" strokeWidth="2" 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"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
|
|
@ -273,39 +323,44 @@ export function Weather({
|
|||
currentTimeIndex + hoursToShow
|
||||
);
|
||||
|
||||
const location = weatherAtLocation.cityName ||
|
||||
const location =
|
||||
weatherAtLocation.cityName ||
|
||||
`${weatherAtLocation.latitude?.toFixed(1)}°, ${weatherAtLocation.longitude?.toFixed(1)}°`;
|
||||
|
||||
return (
|
||||
<div
|
||||
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-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="relative z-10">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="text-white/80 text-sm font-medium">
|
||||
{location}
|
||||
</div>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="font-medium text-sm text-white/80">{location}</div>
|
||||
<div className="text-white/60 text-xs">
|
||||
{format(new Date(weatherAtLocation.current.time), "MMM d, h:mm a")}
|
||||
</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={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} />}
|
||||
</div>
|
||||
<div className="text-white text-5xl font-light">
|
||||
<div className="font-light text-5xl text-white">
|
||||
{n(weatherAtLocation.current.temperature_2m)}
|
||||
<span className="text-2xl text-white/80">
|
||||
{weatherAtLocation.current_units.temperature_2m}
|
||||
|
|
@ -314,43 +369,47 @@ export function Weather({
|
|||
</div>
|
||||
|
||||
<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)}°
|
||||
</div>
|
||||
<div className="text-white/70 text-sm">
|
||||
L: {n(currentLow)}°
|
||||
</div>
|
||||
<div className="text-sm text-white/70">L: {n(currentLow)}°</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/10 rounded-2xl p-4 backdrop-blur-sm">
|
||||
<div className="text-white/80 text-sm mb-3 font-medium">
|
||||
<div className="rounded-2xl bg-white/10 p-4 backdrop-blur-sm">
|
||||
<div className="mb-3 font-medium text-sm text-white/80">
|
||||
Hourly Forecast
|
||||
</div>
|
||||
<div className="flex justify-between gap-2">
|
||||
{displayTimes.map((time, index) => {
|
||||
const hourTime = new Date(time);
|
||||
const isCurrentHour = hourTime.getHours() === new Date().getHours();
|
||||
|
||||
const isCurrentHour =
|
||||
hourTime.getHours() === new Date().getHours();
|
||||
|
||||
return (
|
||||
<div
|
||||
<div
|
||||
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,
|
||||
}
|
||||
)}
|
||||
)}
|
||||
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")}
|
||||
</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} />
|
||||
</div>
|
||||
|
||||
<div className="text-white text-sm font-medium">
|
||||
|
||||
<div className="font-medium text-sm text-white">
|
||||
{n(displayTemperatures[index])}°
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -359,9 +418,15 @@ export function Weather({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between text-white/60 text-xs mt-4">
|
||||
<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 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>
|
||||
Sunset:{" "}
|
||||
{format(new Date(weatherAtLocation.daily.sunset[0]), "h:mm a")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
- ensure it is not more than 80 characters long
|
||||
- the title should be a summary of the user's message
|
||||
- do not use quotes or colons`
|
||||
- do not use quotes or colons`;
|
||||
|
|
|
|||
|
|
@ -1,20 +1,24 @@
|
|||
import { tool } from "ai";
|
||||
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 {
|
||||
const response = await fetch(
|
||||
`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();
|
||||
|
||||
|
||||
if (!data.results || data.results.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
const result = data.results[0];
|
||||
return {
|
||||
latitude: result.latitude,
|
||||
|
|
@ -26,11 +30,15 @@ async function geocodeCity(city: string): Promise<{ latitude: number; longitude:
|
|||
}
|
||||
|
||||
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({
|
||||
latitude: 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) => {
|
||||
let latitude: number;
|
||||
|
|
@ -50,7 +58,8 @@ export const getWeather = tool({
|
|||
longitude = input.longitude;
|
||||
} else {
|
||||
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.",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -59,11 +68,11 @@ export const getWeather = tool({
|
|||
);
|
||||
|
||||
const weatherData = await response.json();
|
||||
|
||||
|
||||
if ("city" in input) {
|
||||
weatherData.cityName = input.city;
|
||||
}
|
||||
|
||||
|
||||
return weatherData;
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ export async function deleteAllChatsByUserId({ userId }: { userId: string }) {
|
|||
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(message).where(inArray(message.chatId, chatIds));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue