diff --git a/app/(chat)/actions.ts b/app/(chat)/actions.ts index 5bc9b4c216..dde22902f4 100644 --- a/app/(chat)/actions.ts +++ b/app/(chat)/actions.ts @@ -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, diff --git a/app/(chat)/api/chat/webllm-save/route.ts b/app/(chat)/api/chat/webllm-save/route.ts new file mode 100644 index 0000000000..e51c9bc078 --- /dev/null +++ b/app/(chat)/api/chat/webllm-save/route.ts @@ -0,0 +1,71 @@ +import { z } from "zod"; +import { auth } from "@/app/(auth)/auth"; +import { getChatById, saveChat, saveMessages } from "@/lib/db/queries"; +import { ChatSDKError } from "@/lib/errors"; +import { generateTitleFromUserMessage } from "../../../actions"; + +const webllmSaveSchema = z.object({ + chatId: z.string().uuid(), + messages: z.array( + z.object({ + id: z.string().uuid(), + role: z.enum(["user", "assistant"]), + parts: z.array(z.any()), + createdAt: z.string().or(z.date()).optional(), + }) + ), + visibility: z.enum(["public", "private"]).optional().default("private"), +}); + +export async function POST(request: Request) { + try { + const json = await request.json(); + const { chatId, messages, visibility } = webllmSaveSchema.parse(json); + + const session = await auth(); + + if (!session?.user) { + return new ChatSDKError("unauthorized:chat").toResponse(); + } + + const chat = await getChatById({ id: chatId }); + + if (!chat) { + const userMessage = messages.find((m) => m.role === "user"); + if (userMessage) { + const title = await generateTitleFromUserMessage({ + message: userMessage as any, + }); + + await saveChat({ + id: chatId, + userId: session.user.id, + title, + visibility, + }); + } + } else if (chat.userId !== session.user.id) { + return new ChatSDKError("forbidden:chat").toResponse(); + } + + await saveMessages({ + messages: messages.map((msg) => ({ + id: msg.id, + chatId, + role: msg.role, + parts: msg.parts, + attachments: [], + createdAt: msg.createdAt ? new Date(msg.createdAt) : new Date(), + })), + }); + + return Response.json({ success: true }); + } catch (error) { + if (error instanceof ChatSDKError) { + return error.toResponse(); + } + + console.error("Error saving WebLLM messages:", error); + return new ChatSDKError("bad_request:api").toResponse(); + } +} diff --git a/app/(chat)/api/history/route.ts b/app/(chat)/api/history/route.ts index 2525a9a1f0..23615e305a 100644 --- a/app/(chat)/api/history/route.ts +++ b/app/(chat)/api/history/route.ts @@ -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) { diff --git a/app/(chat)/chat/[id]/page.tsx b/app/(chat)/chat/[id]/page.tsx index f208fac603..475bcdea8b 100644 --- a/app/(chat)/chat/[id]/page.tsx +++ b/app/(chat)/chat/[id]/page.tsx @@ -2,7 +2,7 @@ import { cookies } from "next/headers"; import { notFound, redirect } from "next/navigation"; import { auth } from "@/app/(auth)/auth"; -import { Chat } from "@/components/chat"; +import { ChatWrapper } from "@/components/chat-wrapper"; import { DataStreamHandler } from "@/components/data-stream-handler"; import { DEFAULT_CHAT_MODEL } from "@/lib/ai/models"; import { getChatById, getMessagesByChatId } from "@/lib/db/queries"; @@ -45,7 +45,7 @@ export default async function Page(props: { params: Promise<{ id: string }> }) { if (!chatModelFromCookie) { return ( <> - }) { return ( <> - - - {user && } - + Delete all chats? - 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. diff --git a/components/chat-wrapper.tsx b/components/chat-wrapper.tsx new file mode 100644 index 0000000000..261dbca9c1 --- /dev/null +++ b/components/chat-wrapper.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { useState } from "react"; +import { isWebLLMModel } from "@/lib/ai/models"; +import type { ChatMessage } from "@/lib/types"; +import type { AppUsage } from "@/lib/usage"; +import { Chat } from "./chat"; +import type { VisibilityType } from "./visibility-selector"; +import { WebLLMChat } from "./webllm-chat"; + +export function ChatWrapper({ + id, + initialMessages, + initialChatModel, + initialVisibilityType, + isReadonly, + autoResume, + initialLastContext, +}: { + id: string; + initialMessages: ChatMessage[]; + initialChatModel: string; + initialVisibilityType: VisibilityType; + isReadonly: boolean; + autoResume: boolean; + initialLastContext?: AppUsage; +}) { + const [currentModelId, setCurrentModelId] = useState(initialChatModel); + const [messagesForSwitch] = useState(initialMessages); + + const isWebLLM = isWebLLMModel(currentModelId); + + const handleModelChange = (modelId: string) => { + setCurrentModelId(modelId); + }; + + if (isWebLLM) { + return ( + + ); + } + + return ( + + ); +} diff --git a/components/chat.tsx b/components/chat.tsx index 4380db16a5..fecc641768 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -41,6 +41,7 @@ export function Chat({ isReadonly, autoResume, initialLastContext, + onModelChange, }: { id: string; initialMessages: ChatMessage[]; @@ -49,6 +50,7 @@ export function Chat({ isReadonly: boolean; autoResume: boolean; initialLastContext?: AppUsage; + onModelChange?: (modelId: string) => void; }) { const { visibilityType } = useChatVisibility({ chatId: id, @@ -68,6 +70,11 @@ export function Chat({ currentModelIdRef.current = currentModelId; }, [currentModelId]); + const handleModelChange = (modelId: string) => { + setCurrentModelId(modelId); + onModelChange?.(modelId); + }; + const { messages, setMessages, @@ -182,7 +189,7 @@ export function Chat({ chatId={id} input={input} messages={messages} - onModelChange={setCurrentModelId} + onModelChange={handleModelChange} selectedModelId={currentModelId} selectedVisibilityType={visibilityType} sendMessage={sendMessage} diff --git a/components/data-stream-handler.tsx b/components/data-stream-handler.tsx index 65aa0c414f..270b8d2a85 100644 --- a/components/data-stream-handler.tsx +++ b/components/data-stream-handler.tsx @@ -6,7 +6,7 @@ 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(); diff --git a/components/elements/context.tsx b/components/elements/context.tsx index 96af118367..8d5d6db32f 100644 --- a/components/elements/context.tsx +++ b/components/elements/context.tsx @@ -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" diff --git a/components/message.tsx b/components/message.tsx index 41fd613b98..53e86d1a6c 100644 --- a/components/message.tsx +++ b/components/message.tsx @@ -328,12 +328,9 @@ export const ThinkingMessage = () => {
-
- Thinking... -
+
Thinking...
); }; - diff --git a/components/multimodal-input.tsx b/components/multimodal-input.tsx index 299f140bea..3e78502b60 100644 --- a/components/multimodal-input.tsx +++ b/components/multimodal-input.tsx @@ -231,14 +231,14 @@ function PureMultimodalInput({ }, [setAttachments, uploadFile] ); - + const handlePaste = useCallback( async (event: ClipboardEvent) => { const items = event.clipboardData?.items; if (!items) return; const imageItems = Array.from(items).filter((item) => - item.type.startsWith('image/'), + item.type.startsWith("image/") ); if (imageItems.length === 0) return; @@ -246,7 +246,7 @@ function PureMultimodalInput({ // 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) => { @@ -260,7 +260,7 @@ function PureMultimodalInput({ (attachment) => attachment !== undefined && attachment.url !== undefined && - attachment.contentType !== undefined, + attachment.contentType !== undefined ); setAttachments((curr) => [ @@ -268,13 +268,13 @@ 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] ); // Add paste event listener to textarea @@ -282,8 +282,8 @@ function PureMultimodalInput({ const textarea = textareaRef.current; 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({ ) : ( 0} status={status} - data-testid="send-button" > @@ -482,7 +482,7 @@ function PureModelSelectorCompact({ value={selectedModel?.name} > -