Initial commit: EGBE chatbot template
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
This commit is contained in:
commit
3e21c2334c
129 changed files with 21913 additions and 0 deletions
284
hooks/use-active-chat.tsx
Normal file
284
hooks/use-active-chat.tsx
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
"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;
|
||||
}
|
||||
55
hooks/use-chat-visibility.ts
Normal file
55
hooks/use-chat-visibility.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import useSWR, { useSWRConfig } from "swr";
|
||||
import { unstable_serialize } from "swr/infinite";
|
||||
import { updateChatVisibility } from "@/app/(chat)/actions";
|
||||
import {
|
||||
type ChatHistory,
|
||||
getChatHistoryPaginationKey,
|
||||
} from "@/components/chat/sidebar-history";
|
||||
import type { VisibilityType } from "@/components/chat/visibility-selector";
|
||||
|
||||
export function useChatVisibility({
|
||||
chatId,
|
||||
initialVisibilityType,
|
||||
}: {
|
||||
chatId: string;
|
||||
initialVisibilityType: VisibilityType;
|
||||
}) {
|
||||
const { mutate, cache } = useSWRConfig();
|
||||
const history: ChatHistory = cache.get(
|
||||
`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/history`
|
||||
)?.data;
|
||||
|
||||
const { data: localVisibility, mutate: setLocalVisibility } = useSWR(
|
||||
`${chatId}-visibility`,
|
||||
null,
|
||||
{
|
||||
fallbackData: initialVisibilityType,
|
||||
}
|
||||
);
|
||||
|
||||
const visibilityType = useMemo(() => {
|
||||
if (!history) {
|
||||
return localVisibility;
|
||||
}
|
||||
const chat = history.chats.find((currentChat) => currentChat.id === chatId);
|
||||
if (!chat) {
|
||||
return "private";
|
||||
}
|
||||
return chat.visibility;
|
||||
}, [history, chatId, localVisibility]);
|
||||
|
||||
const setVisibilityType = (updatedVisibilityType: VisibilityType) => {
|
||||
setLocalVisibility(updatedVisibilityType);
|
||||
mutate(unstable_serialize(getChatHistoryPaginationKey));
|
||||
|
||||
updateChatVisibility({
|
||||
chatId,
|
||||
visibility: updatedVisibilityType,
|
||||
});
|
||||
};
|
||||
|
||||
return { visibilityType, setVisibilityType };
|
||||
}
|
||||
39
hooks/use-messages.tsx
Normal file
39
hooks/use-messages.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import type { UseChatHelpers } from "@ai-sdk/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ChatMessage } from "@/lib/types";
|
||||
import { useScrollToBottom } from "./use-scroll-to-bottom";
|
||||
|
||||
export function useMessages({
|
||||
status,
|
||||
}: {
|
||||
status: UseChatHelpers<ChatMessage>["status"];
|
||||
}) {
|
||||
const {
|
||||
containerRef,
|
||||
endRef,
|
||||
isAtBottom,
|
||||
scrollToBottom,
|
||||
onViewportEnter,
|
||||
onViewportLeave,
|
||||
reset,
|
||||
} = useScrollToBottom();
|
||||
|
||||
const [hasSentMessage, setHasSentMessage] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "submitted") {
|
||||
setHasSentMessage(true);
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
endRef,
|
||||
isAtBottom,
|
||||
scrollToBottom,
|
||||
onViewportEnter,
|
||||
onViewportLeave,
|
||||
hasSentMessage,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
19
hooks/use-mobile.ts
Normal file
19
hooks/use-mobile.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import * as React from "react"
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [])
|
||||
|
||||
return !!isMobile
|
||||
}
|
||||
124
hooks/use-scroll-to-bottom.tsx
Normal file
124
hooks/use-scroll-to-bottom.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
export function useScrollToBottom() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const endRef = useRef<HTMLDivElement>(null);
|
||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||
const isAtBottomRef = useRef(true);
|
||||
const isUserScrollingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
isAtBottomRef.current = isAtBottom;
|
||||
}, [isAtBottom]);
|
||||
|
||||
const checkIfAtBottom = useCallback(() => {
|
||||
if (!containerRef.current) {
|
||||
return true;
|
||||
}
|
||||
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
|
||||
return scrollTop + clientHeight >= scrollHeight - 100;
|
||||
}, []);
|
||||
|
||||
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
|
||||
if (!containerRef.current) {
|
||||
return;
|
||||
}
|
||||
containerRef.current.scrollTo({
|
||||
top: containerRef.current.scrollHeight,
|
||||
behavior,
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
let scrollTimeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
const handleScroll = () => {
|
||||
isUserScrollingRef.current = true;
|
||||
clearTimeout(scrollTimeout);
|
||||
|
||||
const atBottom = checkIfAtBottom();
|
||||
setIsAtBottom(atBottom);
|
||||
isAtBottomRef.current = atBottom;
|
||||
|
||||
scrollTimeout = setTimeout(() => {
|
||||
isUserScrollingRef.current = false;
|
||||
}, 150);
|
||||
};
|
||||
|
||||
container.addEventListener("scroll", handleScroll, { passive: true });
|
||||
return () => {
|
||||
container.removeEventListener("scroll", handleScroll);
|
||||
clearTimeout(scrollTimeout);
|
||||
};
|
||||
}, [checkIfAtBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollIfNeeded = () => {
|
||||
if (isAtBottomRef.current && !isUserScrollingRef.current) {
|
||||
requestAnimationFrame(() => {
|
||||
container.scrollTo({
|
||||
top: container.scrollHeight,
|
||||
behavior: "instant",
|
||||
});
|
||||
setIsAtBottom(true);
|
||||
isAtBottomRef.current = true;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const mutationObserver = new MutationObserver(scrollIfNeeded);
|
||||
mutationObserver.observe(container, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
});
|
||||
|
||||
const resizeObserver = new ResizeObserver(scrollIfNeeded);
|
||||
resizeObserver.observe(container);
|
||||
|
||||
for (const child of container.children) {
|
||||
resizeObserver.observe(child);
|
||||
}
|
||||
|
||||
return () => {
|
||||
mutationObserver.disconnect();
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
function onViewportEnter() {
|
||||
setIsAtBottom(true);
|
||||
isAtBottomRef.current = true;
|
||||
}
|
||||
|
||||
function onViewportLeave() {
|
||||
setIsAtBottom(false);
|
||||
isAtBottomRef.current = false;
|
||||
}
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setIsAtBottom(true);
|
||||
isAtBottomRef.current = true;
|
||||
isUserScrollingRef.current = false;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
endRef,
|
||||
isAtBottom,
|
||||
scrollToBottom,
|
||||
onViewportEnter,
|
||||
onViewportLeave,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue