Stripped from vercel/chatbot (Apache 2.0): - Dropped @vercel/* packages and AI Gateway - Removed artifacts feature (code/text/sheet/image side panel) - Switched AI provider to @ai-sdk/openai-compatible -> EGBE LiteLLM - Replaced Vercel Blob upload with data URLs - Dropped Redis resumable streams and rate limiter (in-memory now) - Added Dockerfile (Next.js standalone) + entrypoint that runs migrations - Wired DATABASE_URL, EGBE_AI_API_URL/KEY, NEXT_PUBLIC_BASE_URL for app-deploy.sh
284 lines
7.9 KiB
TypeScript
284 lines
7.9 KiB
TypeScript
"use client";
|
|
|
|
import type { UseChatHelpers } from "@ai-sdk/react";
|
|
import { useChat } from "@ai-sdk/react";
|
|
import { DefaultChatTransport } from "ai";
|
|
import { usePathname } from "next/navigation";
|
|
import {
|
|
createContext,
|
|
type Dispatch,
|
|
type ReactNode,
|
|
type SetStateAction,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import useSWR, { useSWRConfig } from "swr";
|
|
import { unstable_serialize } from "swr/infinite";
|
|
import { useDataStream } from "@/components/chat/data-stream-provider";
|
|
import { getChatHistoryPaginationKey } from "@/components/chat/sidebar-history";
|
|
import { toast } from "@/components/chat/toast";
|
|
import type { VisibilityType } from "@/components/chat/visibility-selector";
|
|
import { DEFAULT_CHAT_MODEL } from "@/lib/ai/models";
|
|
import type { Vote } from "@/lib/db/schema";
|
|
import { ChatbotError } from "@/lib/errors";
|
|
import type { ChatMessage } from "@/lib/types";
|
|
import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils";
|
|
|
|
type ActiveChatContextValue = {
|
|
chatId: string;
|
|
messages: ChatMessage[];
|
|
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
|
|
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
|
|
status: UseChatHelpers<ChatMessage>["status"];
|
|
stop: UseChatHelpers<ChatMessage>["stop"];
|
|
regenerate: UseChatHelpers<ChatMessage>["regenerate"];
|
|
addToolApprovalResponse: UseChatHelpers<ChatMessage>["addToolApprovalResponse"];
|
|
input: string;
|
|
setInput: Dispatch<SetStateAction<string>>;
|
|
visibilityType: VisibilityType;
|
|
isReadonly: boolean;
|
|
isLoading: boolean;
|
|
votes: Vote[] | undefined;
|
|
currentModelId: string;
|
|
setCurrentModelId: (id: string) => void;
|
|
};
|
|
|
|
const ActiveChatContext = createContext<ActiveChatContextValue | null>(null);
|
|
|
|
function extractChatId(pathname: string): string | null {
|
|
const match = pathname.match(/\/chat\/([^/]+)/);
|
|
return match ? match[1] : null;
|
|
}
|
|
|
|
export function ActiveChatProvider({ children }: { children: ReactNode }) {
|
|
const pathname = usePathname();
|
|
const { setDataStream } = useDataStream();
|
|
const { mutate } = useSWRConfig();
|
|
|
|
const chatIdFromUrl = extractChatId(pathname);
|
|
const isNewChat = !chatIdFromUrl;
|
|
const newChatIdRef = useRef(generateUUID());
|
|
const prevPathnameRef = useRef(pathname);
|
|
|
|
if (isNewChat && prevPathnameRef.current !== pathname) {
|
|
newChatIdRef.current = generateUUID();
|
|
}
|
|
prevPathnameRef.current = pathname;
|
|
|
|
const chatId = chatIdFromUrl ?? newChatIdRef.current;
|
|
|
|
const [currentModelId, setCurrentModelId] = useState(DEFAULT_CHAT_MODEL);
|
|
const currentModelIdRef = useRef(currentModelId);
|
|
useEffect(() => {
|
|
currentModelIdRef.current = currentModelId;
|
|
}, [currentModelId]);
|
|
|
|
const [input, setInput] = useState("");
|
|
|
|
const { data: chatData, isLoading } = useSWR(
|
|
isNewChat
|
|
? null
|
|
: `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/messages?chatId=${chatId}`,
|
|
fetcher,
|
|
{ revalidateOnFocus: false }
|
|
);
|
|
|
|
const initialMessages: ChatMessage[] = isNewChat
|
|
? []
|
|
: (chatData?.messages ?? []);
|
|
const visibility: VisibilityType = isNewChat
|
|
? "private"
|
|
: (chatData?.visibility ?? "private");
|
|
|
|
const {
|
|
messages,
|
|
setMessages,
|
|
sendMessage,
|
|
status,
|
|
stop,
|
|
regenerate,
|
|
addToolApprovalResponse,
|
|
} = useChat<ChatMessage>({
|
|
id: chatId,
|
|
messages: initialMessages,
|
|
generateId: generateUUID,
|
|
sendAutomaticallyWhen: ({ messages: currentMessages }) => {
|
|
const lastMessage = currentMessages.at(-1);
|
|
return (
|
|
lastMessage?.parts?.some(
|
|
(part) =>
|
|
"state" in part &&
|
|
part.state === "approval-responded" &&
|
|
"approval" in part &&
|
|
(part.approval as { approved?: boolean })?.approved === true
|
|
) ?? false
|
|
);
|
|
},
|
|
transport: new DefaultChatTransport({
|
|
api: `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/chat`,
|
|
fetch: fetchWithErrorHandlers,
|
|
prepareSendMessagesRequest(request) {
|
|
const lastMessage = request.messages.at(-1);
|
|
const isToolApprovalContinuation =
|
|
lastMessage?.role !== "user" ||
|
|
request.messages.some((msg) =>
|
|
msg.parts?.some((part) => {
|
|
const state = (part as { state?: string }).state;
|
|
return (
|
|
state === "approval-responded" || state === "output-denied"
|
|
);
|
|
})
|
|
);
|
|
|
|
return {
|
|
body: {
|
|
id: request.id,
|
|
...(isToolApprovalContinuation
|
|
? { messages: request.messages }
|
|
: { message: lastMessage }),
|
|
selectedChatModel: currentModelIdRef.current,
|
|
selectedVisibilityType: visibility,
|
|
...request.body,
|
|
},
|
|
};
|
|
},
|
|
}),
|
|
onData: (dataPart) => {
|
|
setDataStream((ds) => (ds ? [...ds, dataPart] : []));
|
|
},
|
|
onFinish: () => {
|
|
mutate(unstable_serialize(getChatHistoryPaginationKey));
|
|
},
|
|
onError: (error) => {
|
|
if (error instanceof ChatbotError) {
|
|
toast({ type: "error", description: error.message });
|
|
} else {
|
|
toast({
|
|
type: "error",
|
|
description: error.message || "Oops, an error occurred!",
|
|
});
|
|
}
|
|
},
|
|
});
|
|
|
|
const loadedChatIds = useRef(new Set<string>());
|
|
|
|
if (isNewChat && !loadedChatIds.current.has(newChatIdRef.current)) {
|
|
loadedChatIds.current.add(newChatIdRef.current);
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (loadedChatIds.current.has(chatId)) {
|
|
return;
|
|
}
|
|
if (chatData?.messages) {
|
|
loadedChatIds.current.add(chatId);
|
|
setMessages(chatData.messages);
|
|
}
|
|
}, [chatId, chatData?.messages, setMessages]);
|
|
|
|
const prevChatIdRef = useRef(chatId);
|
|
useEffect(() => {
|
|
if (prevChatIdRef.current !== chatId) {
|
|
prevChatIdRef.current = chatId;
|
|
if (isNewChat) {
|
|
setMessages([]);
|
|
}
|
|
}
|
|
}, [chatId, isNewChat, setMessages]);
|
|
|
|
useEffect(() => {
|
|
if (chatData && !isNewChat) {
|
|
const cookieModel = document.cookie
|
|
.split("; ")
|
|
.find((row) => row.startsWith("chat-model="))
|
|
?.split("=")[1];
|
|
if (cookieModel) {
|
|
setCurrentModelId(decodeURIComponent(cookieModel));
|
|
}
|
|
}
|
|
}, [chatData, isNewChat]);
|
|
|
|
const hasAppendedQueryRef = useRef(false);
|
|
useEffect(() => {
|
|
const params = new URLSearchParams(window.location.search);
|
|
const query = params.get("query");
|
|
if (query && !hasAppendedQueryRef.current) {
|
|
hasAppendedQueryRef.current = true;
|
|
window.history.replaceState(
|
|
{},
|
|
"",
|
|
`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/chat/${chatId}`
|
|
);
|
|
sendMessage({
|
|
role: "user" as const,
|
|
parts: [{ type: "text", text: query }],
|
|
});
|
|
}
|
|
}, [sendMessage, chatId]);
|
|
|
|
const isReadonly = isNewChat ? false : (chatData?.isReadonly ?? false);
|
|
|
|
const { data: votes } = useSWR<Vote[]>(
|
|
!isReadonly && messages.length >= 2
|
|
? `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/vote?chatId=${chatId}`
|
|
: null,
|
|
fetcher,
|
|
{ revalidateOnFocus: false }
|
|
);
|
|
|
|
const value = useMemo<ActiveChatContextValue>(
|
|
() => ({
|
|
chatId,
|
|
messages,
|
|
setMessages,
|
|
sendMessage,
|
|
status,
|
|
stop,
|
|
regenerate,
|
|
addToolApprovalResponse,
|
|
input,
|
|
setInput,
|
|
visibilityType: visibility,
|
|
isReadonly,
|
|
isLoading: !isNewChat && isLoading,
|
|
votes,
|
|
currentModelId,
|
|
setCurrentModelId,
|
|
}),
|
|
[
|
|
chatId,
|
|
messages,
|
|
setMessages,
|
|
sendMessage,
|
|
status,
|
|
stop,
|
|
regenerate,
|
|
addToolApprovalResponse,
|
|
input,
|
|
visibility,
|
|
isReadonly,
|
|
isNewChat,
|
|
isLoading,
|
|
votes,
|
|
currentModelId,
|
|
]
|
|
);
|
|
|
|
return (
|
|
<ActiveChatContext.Provider value={value}>
|
|
{children}
|
|
</ActiveChatContext.Provider>
|
|
);
|
|
}
|
|
|
|
export function useActiveChat() {
|
|
const context = useContext(ActiveChatContext);
|
|
if (!context) {
|
|
throw new Error("useActiveChat must be used within ActiveChatProvider");
|
|
}
|
|
return context;
|
|
}
|