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:
dmitry.galkin 2026-05-25 14:54:04 +04:00
commit 3e21c2334c
129 changed files with 21913 additions and 0 deletions

284
hooks/use-active-chat.tsx Normal file
View 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;
}

View 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
View 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
View 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
}

View 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,
};
}