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 { 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,
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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 *));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
<div className="animate-pulse">
|
||||||
<SparklesIcon size={14} />
|
<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>
|
</div>
|
||||||
</motion.div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,36 +43,18 @@ 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}
|
isReadonly={isReadonly}
|
||||||
key={message.id}
|
key={message.id}
|
||||||
message={message}
|
message={message}
|
||||||
|
|
@ -91,16 +71,13 @@ function PureMessages({
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<AnimatePresence mode="wait">
|
{status === "submitted" && <ThinkingMessage />}
|
||||||
{status === "submitted" && <ThinkingMessage key="thinking" />}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="min-h-[24px] min-w-[24px] shrink-0"
|
className="min-h-[24px] min-w-[24px] shrink-0"
|
||||||
ref={messagesEndRef}
|
ref={messagesEndRef}
|
||||||
/>
|
/>
|
||||||
</ConversationContent>
|
</div>
|
||||||
</Conversation>
|
|
||||||
|
|
||||||
{!isAtBottom && (
|
{!isAtBottom && (
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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`;
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue