mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-02-02 19:05:10 -05:00
fix(frontend): more copilot refinements (#11858)
## Changes 🏗️ On the **Copilot** page: - prevent unnecessary sidebar repaints - show a disclaimer when switching chats on the sidebar to terminate a current stream - handle loading better - save streams better when disconnecting ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] Run the app locally and test the above
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
import { ChatLoader } from "@/components/contextual/Chat/components/ChatLoader/ChatLoader";
|
||||
import { NAVBAR_HEIGHT_PX } from "@/lib/constants";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useCopilotStore } from "../../copilot-page-store";
|
||||
import { DesktopSidebar } from "./components/DesktopSidebar/DesktopSidebar";
|
||||
import { LoadingState } from "./components/LoadingState/LoadingState";
|
||||
@@ -25,10 +25,12 @@ export function CopilotShell({ children }: Props) {
|
||||
sessions,
|
||||
currentSessionId,
|
||||
handleSelectSession,
|
||||
performSelectSession,
|
||||
handleOpenDrawer,
|
||||
handleCloseDrawer,
|
||||
handleDrawerOpenChange,
|
||||
handleNewChat,
|
||||
performNewChat,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
@@ -36,22 +38,71 @@ export function CopilotShell({ children }: Props) {
|
||||
} = useCopilotShell();
|
||||
|
||||
const setNewChatHandler = useCopilotStore((s) => s.setNewChatHandler);
|
||||
const setNewChatWithInterruptHandler = useCopilotStore(
|
||||
(s) => s.setNewChatWithInterruptHandler,
|
||||
);
|
||||
const setSelectSessionHandler = useCopilotStore(
|
||||
(s) => s.setSelectSessionHandler,
|
||||
);
|
||||
const setSelectSessionWithInterruptHandler = useCopilotStore(
|
||||
(s) => s.setSelectSessionWithInterruptHandler,
|
||||
);
|
||||
const requestNewChat = useCopilotStore((s) => s.requestNewChat);
|
||||
const requestSelectSession = useCopilotStore((s) => s.requestSelectSession);
|
||||
|
||||
const stableHandleNewChat = useCallback(handleNewChat, [handleNewChat]);
|
||||
const stablePerformNewChat = useCallback(performNewChat, [performNewChat]);
|
||||
|
||||
useEffect(
|
||||
function registerNewChatHandler() {
|
||||
setNewChatHandler(handleNewChat);
|
||||
function registerNewChatHandlers() {
|
||||
setNewChatHandler(stableHandleNewChat);
|
||||
setNewChatWithInterruptHandler(stablePerformNewChat);
|
||||
return function cleanup() {
|
||||
setNewChatHandler(null);
|
||||
setNewChatWithInterruptHandler(null);
|
||||
};
|
||||
},
|
||||
[handleNewChat],
|
||||
[
|
||||
stableHandleNewChat,
|
||||
stablePerformNewChat,
|
||||
setNewChatHandler,
|
||||
setNewChatWithInterruptHandler,
|
||||
],
|
||||
);
|
||||
|
||||
const stableHandleSelectSession = useCallback(handleSelectSession, [
|
||||
handleSelectSession,
|
||||
]);
|
||||
|
||||
const stablePerformSelectSession = useCallback(performSelectSession, [
|
||||
performSelectSession,
|
||||
]);
|
||||
|
||||
useEffect(
|
||||
function registerSelectSessionHandlers() {
|
||||
setSelectSessionHandler(stableHandleSelectSession);
|
||||
setSelectSessionWithInterruptHandler(stablePerformSelectSession);
|
||||
return function cleanup() {
|
||||
setSelectSessionHandler(null);
|
||||
setSelectSessionWithInterruptHandler(null);
|
||||
};
|
||||
},
|
||||
[
|
||||
stableHandleSelectSession,
|
||||
stablePerformSelectSession,
|
||||
setSelectSessionHandler,
|
||||
setSelectSessionWithInterruptHandler,
|
||||
],
|
||||
);
|
||||
|
||||
function handleNewChatClick() {
|
||||
requestNewChat();
|
||||
}
|
||||
|
||||
function handleSessionClick(sessionId: string) {
|
||||
requestSelectSession(sessionId);
|
||||
}
|
||||
|
||||
if (!isLoggedIn) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
@@ -72,7 +123,7 @@ export function CopilotShell({ children }: Props) {
|
||||
isLoading={isLoading}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
onSelectSession={handleSelectSession}
|
||||
onSelectSession={handleSessionClick}
|
||||
onFetchNextPage={fetchNextPage}
|
||||
onNewChat={handleNewChatClick}
|
||||
hasActiveSession={Boolean(hasActiveSession)}
|
||||
@@ -94,7 +145,7 @@ export function CopilotShell({ children }: Props) {
|
||||
isLoading={isLoading}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
onSelectSession={handleSelectSession}
|
||||
onSelectSession={handleSessionClick}
|
||||
onFetchNextPage={fetchNextPage}
|
||||
onNewChat={handleNewChatClick}
|
||||
onClose={handleCloseDrawer}
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
getGetV2GetSessionQueryKey,
|
||||
getGetV2ListSessionsQueryKey,
|
||||
useGetV2GetSession,
|
||||
} from "@/app/api/__generated__/endpoints/chat/chat";
|
||||
import type { SessionSummaryResponse } from "@/app/api/__generated__/models/sessionSummaryResponse";
|
||||
import { okData } from "@/app/api/helpers";
|
||||
import { useChatStore } from "@/components/contextual/Chat/chat-store";
|
||||
import { useBreakpoint } from "@/lib/hooks/useBreakpoint";
|
||||
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { parseAsString, useQueryState } from "nuqs";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCopilotStore } from "../../copilot-page-store";
|
||||
import { useMobileDrawer } from "./components/MobileDrawer/useMobileDrawer";
|
||||
import { useSessionsPagination } from "./components/SessionsList/useSessionsPagination";
|
||||
import {
|
||||
@@ -73,6 +76,19 @@ export function useCopilotShell() {
|
||||
Map<string, SessionSummaryResponse>
|
||||
>(new Map());
|
||||
|
||||
const [optimisticSessionId, setOptimisticSessionId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
useEffect(
|
||||
function clearOptimisticWhenUrlMatches() {
|
||||
if (optimisticSessionId && currentSessionId === optimisticSessionId) {
|
||||
setOptimisticSessionId(null);
|
||||
}
|
||||
},
|
||||
[currentSessionId, optimisticSessionId],
|
||||
);
|
||||
|
||||
// Mark as auto-selected when sessionId is in URL
|
||||
useEffect(() => {
|
||||
if (paramSessionId && !hasAutoSelectedRef.current) {
|
||||
@@ -142,7 +158,9 @@ export function useCopilotShell() {
|
||||
const visibleSessions = filterVisibleSessions(sessions);
|
||||
|
||||
const sidebarSelectedSessionId =
|
||||
isOnHomepage && !paramSessionId ? null : currentSessionId;
|
||||
isOnHomepage && !paramSessionId && !optimisticSessionId
|
||||
? null
|
||||
: optimisticSessionId || currentSessionId;
|
||||
|
||||
const isReadyToShowContent = isOnHomepage
|
||||
? true
|
||||
@@ -155,8 +173,89 @@ export function useCopilotShell() {
|
||||
hasAutoSelectedSession,
|
||||
);
|
||||
|
||||
function handleSelectSession(sessionId: string) {
|
||||
const stopStream = useChatStore((s) => s.stopStream);
|
||||
const onStreamComplete = useChatStore((s) => s.onStreamComplete);
|
||||
const setIsSwitchingSession = useCopilotStore((s) => s.setIsSwitchingSession);
|
||||
|
||||
async function performSelectSession(sessionId: string) {
|
||||
if (sessionId === currentSessionId) return;
|
||||
|
||||
const sourceSessionId = currentSessionId;
|
||||
|
||||
if (sourceSessionId) {
|
||||
setIsSwitchingSession(true);
|
||||
|
||||
await new Promise<void>(function waitForStreamComplete(resolve) {
|
||||
const unsubscribe = onStreamComplete(
|
||||
function handleComplete(completedId) {
|
||||
if (completedId === sourceSessionId) {
|
||||
clearTimeout(timeout);
|
||||
unsubscribe();
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
);
|
||||
const timeout = setTimeout(function handleTimeout() {
|
||||
unsubscribe();
|
||||
resolve();
|
||||
}, 3000);
|
||||
stopStream(sourceSessionId);
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getGetV2GetSessionQueryKey(sourceSessionId),
|
||||
});
|
||||
}
|
||||
|
||||
setOptimisticSessionId(sessionId);
|
||||
setUrlSessionId(sessionId, { shallow: false });
|
||||
setIsSwitchingSession(false);
|
||||
if (isMobile) handleCloseDrawer();
|
||||
}
|
||||
|
||||
function handleSelectSession(sessionId: string) {
|
||||
if (sessionId === currentSessionId) return;
|
||||
setOptimisticSessionId(sessionId);
|
||||
setUrlSessionId(sessionId, { shallow: false });
|
||||
if (isMobile) handleCloseDrawer();
|
||||
}
|
||||
|
||||
async function performNewChat() {
|
||||
const sourceSessionId = currentSessionId;
|
||||
|
||||
if (sourceSessionId) {
|
||||
setIsSwitchingSession(true);
|
||||
|
||||
await new Promise<void>(function waitForStreamComplete(resolve) {
|
||||
const unsubscribe = onStreamComplete(
|
||||
function handleComplete(completedId) {
|
||||
if (completedId === sourceSessionId) {
|
||||
clearTimeout(timeout);
|
||||
unsubscribe();
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
);
|
||||
const timeout = setTimeout(function handleTimeout() {
|
||||
unsubscribe();
|
||||
resolve();
|
||||
}, 3000);
|
||||
stopStream(sourceSessionId);
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getGetV2GetSessionQueryKey(sourceSessionId),
|
||||
});
|
||||
setIsSwitchingSession(false);
|
||||
}
|
||||
|
||||
resetAutoSelect();
|
||||
resetPagination();
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getGetV2ListSessionsQueryKey(),
|
||||
});
|
||||
setUrlSessionId(null, { shallow: false });
|
||||
setOptimisticSessionId(null);
|
||||
if (isMobile) handleCloseDrawer();
|
||||
}
|
||||
|
||||
@@ -167,6 +266,7 @@ export function useCopilotShell() {
|
||||
queryKey: getGetV2ListSessionsQueryKey(),
|
||||
});
|
||||
setUrlSessionId(null, { shallow: false });
|
||||
setOptimisticSessionId(null);
|
||||
if (isMobile) handleCloseDrawer();
|
||||
}
|
||||
|
||||
@@ -187,10 +287,12 @@ export function useCopilotShell() {
|
||||
sessions: visibleSessions,
|
||||
currentSessionId: sidebarSelectedSessionId,
|
||||
handleSelectSession,
|
||||
performSelectSession,
|
||||
handleOpenDrawer,
|
||||
handleCloseDrawer,
|
||||
handleDrawerOpenChange,
|
||||
handleNewChat,
|
||||
performNewChat,
|
||||
hasNextPage,
|
||||
isFetchingNextPage: isSessionsFetching,
|
||||
fetchNextPage,
|
||||
|
||||
@@ -4,51 +4,106 @@ import { create } from "zustand";
|
||||
|
||||
interface CopilotStoreState {
|
||||
isStreaming: boolean;
|
||||
isNewChatModalOpen: boolean;
|
||||
isSwitchingSession: boolean;
|
||||
isInterruptModalOpen: boolean;
|
||||
pendingAction: (() => void) | null;
|
||||
newChatHandler: (() => void) | null;
|
||||
newChatWithInterruptHandler: (() => void) | null;
|
||||
selectSessionHandler: ((sessionId: string) => void) | null;
|
||||
selectSessionWithInterruptHandler: ((sessionId: string) => void) | null;
|
||||
}
|
||||
|
||||
interface CopilotStoreActions {
|
||||
setIsStreaming: (isStreaming: boolean) => void;
|
||||
setIsSwitchingSession: (isSwitchingSession: boolean) => void;
|
||||
setNewChatHandler: (handler: (() => void) | null) => void;
|
||||
setNewChatWithInterruptHandler: (handler: (() => void) | null) => void;
|
||||
setSelectSessionHandler: (
|
||||
handler: ((sessionId: string) => void) | null,
|
||||
) => void;
|
||||
setSelectSessionWithInterruptHandler: (
|
||||
handler: ((sessionId: string) => void) | null,
|
||||
) => void;
|
||||
requestNewChat: () => void;
|
||||
confirmNewChat: () => void;
|
||||
cancelNewChat: () => void;
|
||||
requestSelectSession: (sessionId: string) => void;
|
||||
confirmInterrupt: () => void;
|
||||
cancelInterrupt: () => void;
|
||||
}
|
||||
|
||||
type CopilotStore = CopilotStoreState & CopilotStoreActions;
|
||||
|
||||
export const useCopilotStore = create<CopilotStore>((set, get) => ({
|
||||
isStreaming: false,
|
||||
isNewChatModalOpen: false,
|
||||
isSwitchingSession: false,
|
||||
isInterruptModalOpen: false,
|
||||
pendingAction: null,
|
||||
newChatHandler: null,
|
||||
newChatWithInterruptHandler: null,
|
||||
selectSessionHandler: null,
|
||||
selectSessionWithInterruptHandler: null,
|
||||
|
||||
setIsStreaming(isStreaming) {
|
||||
set({ isStreaming });
|
||||
},
|
||||
|
||||
setIsSwitchingSession(isSwitchingSession) {
|
||||
set({ isSwitchingSession });
|
||||
},
|
||||
|
||||
setNewChatHandler(handler) {
|
||||
set({ newChatHandler: handler });
|
||||
},
|
||||
|
||||
setNewChatWithInterruptHandler(handler) {
|
||||
set({ newChatWithInterruptHandler: handler });
|
||||
},
|
||||
|
||||
setSelectSessionHandler(handler) {
|
||||
set({ selectSessionHandler: handler });
|
||||
},
|
||||
|
||||
setSelectSessionWithInterruptHandler(handler) {
|
||||
set({ selectSessionWithInterruptHandler: handler });
|
||||
},
|
||||
|
||||
requestNewChat() {
|
||||
const { isStreaming, newChatHandler } = get();
|
||||
const { isStreaming, newChatHandler, newChatWithInterruptHandler } = get();
|
||||
if (isStreaming) {
|
||||
set({ isNewChatModalOpen: true });
|
||||
if (!newChatWithInterruptHandler) return;
|
||||
set({
|
||||
isInterruptModalOpen: true,
|
||||
pendingAction: newChatWithInterruptHandler,
|
||||
});
|
||||
} else if (newChatHandler) {
|
||||
newChatHandler();
|
||||
}
|
||||
},
|
||||
|
||||
confirmNewChat() {
|
||||
const { newChatHandler } = get();
|
||||
set({ isNewChatModalOpen: false });
|
||||
if (newChatHandler) {
|
||||
newChatHandler();
|
||||
requestSelectSession(sessionId) {
|
||||
const {
|
||||
isStreaming,
|
||||
selectSessionHandler,
|
||||
selectSessionWithInterruptHandler,
|
||||
} = get();
|
||||
if (isStreaming) {
|
||||
if (!selectSessionWithInterruptHandler) return;
|
||||
set({
|
||||
isInterruptModalOpen: true,
|
||||
pendingAction: () => selectSessionWithInterruptHandler(sessionId),
|
||||
});
|
||||
} else {
|
||||
if (!selectSessionHandler) return;
|
||||
selectSessionHandler(sessionId);
|
||||
}
|
||||
},
|
||||
|
||||
cancelNewChat() {
|
||||
set({ isNewChatModalOpen: false });
|
||||
confirmInterrupt() {
|
||||
const { pendingAction } = get();
|
||||
set({ isInterruptModalOpen: false, pendingAction: null });
|
||||
if (pendingAction) pendingAction();
|
||||
},
|
||||
|
||||
cancelInterrupt() {
|
||||
set({ isInterruptModalOpen: false, pendingAction: null });
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -13,22 +13,15 @@ import { useCopilotPage } from "./useCopilotPage";
|
||||
|
||||
export default function CopilotPage() {
|
||||
const { state, handlers } = useCopilotPage();
|
||||
const confirmNewChat = useCopilotStore((s) => s.confirmNewChat);
|
||||
const {
|
||||
greetingName,
|
||||
quickActions,
|
||||
isLoading,
|
||||
pageState,
|
||||
isNewChatModalOpen,
|
||||
isReady,
|
||||
} = state;
|
||||
const isInterruptModalOpen = useCopilotStore((s) => s.isInterruptModalOpen);
|
||||
const confirmInterrupt = useCopilotStore((s) => s.confirmInterrupt);
|
||||
const cancelInterrupt = useCopilotStore((s) => s.cancelInterrupt);
|
||||
const { greetingName, quickActions, isLoading, pageState, isReady } = state;
|
||||
const {
|
||||
handleQuickAction,
|
||||
startChatWithPrompt,
|
||||
handleSessionNotFound,
|
||||
handleStreamingChange,
|
||||
handleCancelNewChat,
|
||||
handleNewChatModalOpen,
|
||||
} = handlers;
|
||||
|
||||
if (!isReady) return null;
|
||||
@@ -48,31 +41,33 @@ export default function CopilotPage() {
|
||||
title="Interrupt current chat?"
|
||||
styling={{ maxWidth: 300, width: "100%" }}
|
||||
controlled={{
|
||||
isOpen: isNewChatModalOpen,
|
||||
set: handleNewChatModalOpen,
|
||||
isOpen: isInterruptModalOpen,
|
||||
set: (open) => {
|
||||
if (!open) cancelInterrupt();
|
||||
},
|
||||
}}
|
||||
onClose={handleCancelNewChat}
|
||||
onClose={cancelInterrupt}
|
||||
>
|
||||
<Dialog.Content>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Text variant="body">
|
||||
The current chat response will be interrupted. Are you sure you
|
||||
want to start a new chat?
|
||||
want to continue?
|
||||
</Text>
|
||||
<Dialog.Footer>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCancelNewChat}
|
||||
onClick={cancelInterrupt}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={confirmNewChat}
|
||||
onClick={confirmInterrupt}
|
||||
>
|
||||
Start new chat
|
||||
Continue
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</div>
|
||||
|
||||
@@ -75,9 +75,7 @@ export function useCopilotPage() {
|
||||
const { user, isLoggedIn, isUserLoading } = useSupabase();
|
||||
const { toast } = useToast();
|
||||
|
||||
const isNewChatModalOpen = useCopilotStore((s) => s.isNewChatModalOpen);
|
||||
const setIsStreaming = useCopilotStore((s) => s.setIsStreaming);
|
||||
const cancelNewChat = useCopilotStore((s) => s.cancelNewChat);
|
||||
|
||||
const isChatEnabled = useGetFlag(Flag.CHAT);
|
||||
const flags = useFlags<FlagValues>();
|
||||
@@ -201,21 +199,12 @@ export function useCopilotPage() {
|
||||
setIsStreaming(isStreamingValue);
|
||||
}
|
||||
|
||||
function handleCancelNewChat() {
|
||||
cancelNewChat();
|
||||
}
|
||||
|
||||
function handleNewChatModalOpen(isOpen: boolean) {
|
||||
if (!isOpen) cancelNewChat();
|
||||
}
|
||||
|
||||
return {
|
||||
state: {
|
||||
greetingName,
|
||||
quickActions,
|
||||
isLoading: isUserLoading,
|
||||
pageState: state.pageState,
|
||||
isNewChatModalOpen,
|
||||
isReady: isFlagReady && isChatEnabled !== false && isLoggedIn,
|
||||
},
|
||||
handlers: {
|
||||
@@ -223,8 +212,6 @@ export function useCopilotPage() {
|
||||
startChatWithPrompt,
|
||||
handleSessionNotFound,
|
||||
handleStreamingChange,
|
||||
handleCancelNewChat,
|
||||
handleNewChatModalOpen,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useCopilotStore } from "@/app/(platform)/copilot/copilot-page-store";
|
||||
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { ChatContainer } from "./components/ChatContainer/ChatContainer";
|
||||
import { ChatErrorState } from "./components/ChatErrorState/ChatErrorState";
|
||||
import { ChatLoader } from "./components/ChatLoader/ChatLoader";
|
||||
import { useChat } from "./useChat";
|
||||
|
||||
export interface ChatProps {
|
||||
@@ -24,6 +25,7 @@ export function Chat({
|
||||
onStreamingChange,
|
||||
}: ChatProps) {
|
||||
const hasHandledNotFoundRef = useRef(false);
|
||||
const isSwitchingSession = useCopilotStore((s) => s.isSwitchingSession);
|
||||
const {
|
||||
messages,
|
||||
isLoading,
|
||||
@@ -47,29 +49,34 @@ export function Chat({
|
||||
[onSessionNotFound, urlSessionId, isSessionNotFound, isLoading, isCreating],
|
||||
);
|
||||
|
||||
const shouldShowLoader =
|
||||
(showLoader && (isLoading || isCreating)) || isSwitchingSession;
|
||||
|
||||
return (
|
||||
<div className={cn("flex h-full flex-col", className)}>
|
||||
{/* Main Content */}
|
||||
<main className="flex min-h-0 w-full flex-1 flex-col overflow-hidden bg-[#f8f8f9]">
|
||||
{/* Loading State */}
|
||||
{showLoader && (isLoading || isCreating) && (
|
||||
{shouldShowLoader && (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<ChatLoader />
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<LoadingSpinner size="large" className="text-neutral-400" />
|
||||
<Text variant="body" className="text-zinc-500">
|
||||
Loading your chats...
|
||||
{isSwitchingSession
|
||||
? "Switching chat..."
|
||||
: "Loading your chat..."}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{error && !isLoading && (
|
||||
{error && !isLoading && !isSwitchingSession && (
|
||||
<ChatErrorState error={error} onRetry={createSession} />
|
||||
)}
|
||||
|
||||
{/* Session Content */}
|
||||
{sessionId && !isLoading && !error && (
|
||||
{sessionId && !isLoading && !error && !isSwitchingSession && (
|
||||
<ChatContainer
|
||||
sessionId={sessionId}
|
||||
initialMessages={messages}
|
||||
|
||||
@@ -58,39 +58,17 @@ function notifyStreamComplete(
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupCompletedStreams(completedStreams: Map<string, StreamResult>) {
|
||||
function cleanupExpiredStreams(
|
||||
completedStreams: Map<string, StreamResult>,
|
||||
): Map<string, StreamResult> {
|
||||
const now = Date.now();
|
||||
for (const [sessionId, result] of completedStreams) {
|
||||
const cleaned = new Map(completedStreams);
|
||||
for (const [sessionId, result] of cleaned) {
|
||||
if (now - result.completedAt > COMPLETED_STREAM_TTL) {
|
||||
completedStreams.delete(sessionId);
|
||||
cleaned.delete(sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function moveToCompleted(
|
||||
activeStreams: Map<string, ActiveStream>,
|
||||
completedStreams: Map<string, StreamResult>,
|
||||
streamCompleteCallbacks: Set<StreamCompleteCallback>,
|
||||
sessionId: string,
|
||||
) {
|
||||
const stream = activeStreams.get(sessionId);
|
||||
if (!stream) return;
|
||||
|
||||
const result: StreamResult = {
|
||||
sessionId,
|
||||
status: stream.status,
|
||||
chunks: stream.chunks,
|
||||
completedAt: Date.now(),
|
||||
error: stream.error,
|
||||
};
|
||||
|
||||
completedStreams.set(sessionId, result);
|
||||
activeStreams.delete(sessionId);
|
||||
cleanupCompletedStreams(completedStreams);
|
||||
|
||||
if (stream.status === "completed" || stream.status === "error") {
|
||||
notifyStreamComplete(streamCompleteCallbacks, sessionId);
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
@@ -106,17 +84,31 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
context,
|
||||
onChunk,
|
||||
) {
|
||||
const { activeStreams, completedStreams, streamCompleteCallbacks } = get();
|
||||
const state = get();
|
||||
const newActiveStreams = new Map(state.activeStreams);
|
||||
let newCompletedStreams = new Map(state.completedStreams);
|
||||
const callbacks = state.streamCompleteCallbacks;
|
||||
|
||||
const existingStream = activeStreams.get(sessionId);
|
||||
const existingStream = newActiveStreams.get(sessionId);
|
||||
if (existingStream) {
|
||||
existingStream.abortController.abort();
|
||||
moveToCompleted(
|
||||
activeStreams,
|
||||
completedStreams,
|
||||
streamCompleteCallbacks,
|
||||
const normalizedStatus =
|
||||
existingStream.status === "streaming"
|
||||
? "completed"
|
||||
: existingStream.status;
|
||||
const result: StreamResult = {
|
||||
sessionId,
|
||||
);
|
||||
status: normalizedStatus,
|
||||
chunks: existingStream.chunks,
|
||||
completedAt: Date.now(),
|
||||
error: existingStream.error,
|
||||
};
|
||||
newCompletedStreams.set(sessionId, result);
|
||||
newActiveStreams.delete(sessionId);
|
||||
newCompletedStreams = cleanupExpiredStreams(newCompletedStreams);
|
||||
if (normalizedStatus === "completed" || normalizedStatus === "error") {
|
||||
notifyStreamComplete(callbacks, sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
@@ -132,36 +124,76 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
onChunkCallbacks: initialCallbacks,
|
||||
};
|
||||
|
||||
activeStreams.set(sessionId, stream);
|
||||
newActiveStreams.set(sessionId, stream);
|
||||
set({
|
||||
activeStreams: newActiveStreams,
|
||||
completedStreams: newCompletedStreams,
|
||||
});
|
||||
|
||||
try {
|
||||
await executeStream(stream, message, isUserMessage, context);
|
||||
} finally {
|
||||
if (onChunk) stream.onChunkCallbacks.delete(onChunk);
|
||||
if (stream.status !== "streaming") {
|
||||
moveToCompleted(
|
||||
activeStreams,
|
||||
completedStreams,
|
||||
streamCompleteCallbacks,
|
||||
sessionId,
|
||||
);
|
||||
const currentState = get();
|
||||
const finalActiveStreams = new Map(currentState.activeStreams);
|
||||
let finalCompletedStreams = new Map(currentState.completedStreams);
|
||||
|
||||
const storedStream = finalActiveStreams.get(sessionId);
|
||||
if (storedStream === stream) {
|
||||
const result: StreamResult = {
|
||||
sessionId,
|
||||
status: stream.status,
|
||||
chunks: stream.chunks,
|
||||
completedAt: Date.now(),
|
||||
error: stream.error,
|
||||
};
|
||||
finalCompletedStreams.set(sessionId, result);
|
||||
finalActiveStreams.delete(sessionId);
|
||||
finalCompletedStreams = cleanupExpiredStreams(finalCompletedStreams);
|
||||
set({
|
||||
activeStreams: finalActiveStreams,
|
||||
completedStreams: finalCompletedStreams,
|
||||
});
|
||||
if (stream.status === "completed" || stream.status === "error") {
|
||||
notifyStreamComplete(
|
||||
currentState.streamCompleteCallbacks,
|
||||
sessionId,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
stopStream: function stopStream(sessionId) {
|
||||
const { activeStreams, completedStreams, streamCompleteCallbacks } = get();
|
||||
const stream = activeStreams.get(sessionId);
|
||||
if (stream) {
|
||||
stream.abortController.abort();
|
||||
stream.status = "completed";
|
||||
moveToCompleted(
|
||||
activeStreams,
|
||||
completedStreams,
|
||||
streamCompleteCallbacks,
|
||||
sessionId,
|
||||
);
|
||||
}
|
||||
const state = get();
|
||||
const stream = state.activeStreams.get(sessionId);
|
||||
if (!stream) return;
|
||||
|
||||
stream.abortController.abort();
|
||||
stream.status = "completed";
|
||||
|
||||
const newActiveStreams = new Map(state.activeStreams);
|
||||
let newCompletedStreams = new Map(state.completedStreams);
|
||||
|
||||
const result: StreamResult = {
|
||||
sessionId,
|
||||
status: stream.status,
|
||||
chunks: stream.chunks,
|
||||
completedAt: Date.now(),
|
||||
error: stream.error,
|
||||
};
|
||||
newCompletedStreams.set(sessionId, result);
|
||||
newActiveStreams.delete(sessionId);
|
||||
newCompletedStreams = cleanupExpiredStreams(newCompletedStreams);
|
||||
|
||||
set({
|
||||
activeStreams: newActiveStreams,
|
||||
completedStreams: newCompletedStreams,
|
||||
});
|
||||
|
||||
notifyStreamComplete(state.streamCompleteCallbacks, sessionId);
|
||||
},
|
||||
|
||||
subscribeToStream: function subscribeToStream(
|
||||
@@ -169,16 +201,18 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
onChunk,
|
||||
skipReplay = false,
|
||||
) {
|
||||
const { activeStreams } = get();
|
||||
const state = get();
|
||||
const stream = state.activeStreams.get(sessionId);
|
||||
|
||||
const stream = activeStreams.get(sessionId);
|
||||
if (stream) {
|
||||
if (!skipReplay) {
|
||||
for (const chunk of stream.chunks) {
|
||||
onChunk(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
stream.onChunkCallbacks.add(onChunk);
|
||||
|
||||
return function unsubscribe() {
|
||||
stream.onChunkCallbacks.delete(onChunk);
|
||||
};
|
||||
@@ -204,7 +238,12 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
},
|
||||
|
||||
clearCompletedStream: function clearCompletedStream(sessionId) {
|
||||
get().completedStreams.delete(sessionId);
|
||||
const state = get();
|
||||
if (!state.completedStreams.has(sessionId)) return;
|
||||
|
||||
const newCompletedStreams = new Map(state.completedStreams);
|
||||
newCompletedStreams.delete(sessionId);
|
||||
set({ completedStreams: newCompletedStreams });
|
||||
},
|
||||
|
||||
isStreaming: function isStreaming(sessionId) {
|
||||
@@ -213,11 +252,21 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
},
|
||||
|
||||
registerActiveSession: function registerActiveSession(sessionId) {
|
||||
get().activeSessions.add(sessionId);
|
||||
const state = get();
|
||||
if (state.activeSessions.has(sessionId)) return;
|
||||
|
||||
const newActiveSessions = new Set(state.activeSessions);
|
||||
newActiveSessions.add(sessionId);
|
||||
set({ activeSessions: newActiveSessions });
|
||||
},
|
||||
|
||||
unregisterActiveSession: function unregisterActiveSession(sessionId) {
|
||||
get().activeSessions.delete(sessionId);
|
||||
const state = get();
|
||||
if (!state.activeSessions.has(sessionId)) return;
|
||||
|
||||
const newActiveSessions = new Set(state.activeSessions);
|
||||
newActiveSessions.delete(sessionId);
|
||||
set({ activeSessions: newActiveSessions });
|
||||
},
|
||||
|
||||
isSessionActive: function isSessionActive(sessionId) {
|
||||
@@ -225,10 +274,16 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
},
|
||||
|
||||
onStreamComplete: function onStreamComplete(callback) {
|
||||
const { streamCompleteCallbacks } = get();
|
||||
streamCompleteCallbacks.add(callback);
|
||||
const state = get();
|
||||
const newCallbacks = new Set(state.streamCompleteCallbacks);
|
||||
newCallbacks.add(callback);
|
||||
set({ streamCompleteCallbacks: newCallbacks });
|
||||
|
||||
return function unsubscribe() {
|
||||
streamCompleteCallbacks.delete(callback);
|
||||
const currentState = get();
|
||||
const cleanedCallbacks = new Set(currentState.streamCompleteCallbacks);
|
||||
cleanedCallbacks.delete(callback);
|
||||
set({ streamCompleteCallbacks: cleanedCallbacks });
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -126,10 +126,6 @@ export function ChatMessage({
|
||||
[displayContent, message],
|
||||
);
|
||||
|
||||
function isLongResponse(content: string): boolean {
|
||||
return content.split("\n").length > 5;
|
||||
}
|
||||
|
||||
const handleTryAgain = useCallback(() => {
|
||||
if (message.type !== "message" || !onSendMessage) return;
|
||||
onSendMessage(message.content, message.role === "user");
|
||||
@@ -358,7 +354,7 @@ export function ChatMessage({
|
||||
<ArrowsClockwiseIcon className="size-4 text-zinc-600" />
|
||||
</Button>
|
||||
)}
|
||||
{!isUser && isFinalMessage && isLongResponse(displayContent) && (
|
||||
{!isUser && isFinalMessage && !isStreaming && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
||||
@@ -10,7 +10,7 @@ export function UserChatBubble({ children, className }: UserChatBubbleProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative min-w-20 overflow-hidden rounded-xl bg-purple-100 px-3 text-right text-[1rem] leading-relaxed transition-all duration-500 ease-in-out",
|
||||
"group relative min-w-20 overflow-hidden rounded-xl bg-purple-100 px-3 text-left text-[1rem] leading-relaxed transition-all duration-500 ease-in-out",
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
getGetV2GetSessionQueryKey,
|
||||
getGetV2GetSessionQueryOptions,
|
||||
getGetV2ListSessionsQueryKey,
|
||||
postV2CreateSession,
|
||||
useGetV2GetSession,
|
||||
usePatchV2SessionAssignUser,
|
||||
@@ -102,17 +101,6 @@ export function useChatSession({
|
||||
}
|
||||
}, [createError, loadError]);
|
||||
|
||||
useEffect(
|
||||
function refreshSessionsListOnLoad() {
|
||||
if (sessionId && sessionData && !isLoadingSession) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getGetV2ListSessionsQueryKey(),
|
||||
});
|
||||
}
|
||||
},
|
||||
[sessionId, sessionData, isLoadingSession, queryClient],
|
||||
);
|
||||
|
||||
async function createSession() {
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
Reference in New Issue
Block a user