mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
basecamp2 - Resume chat session from server with a cache (#5088)
This commit is contained in:
@@ -99,10 +99,6 @@ const PairRouteWrapper = ({
|
||||
chat={chat}
|
||||
setChat={setChat}
|
||||
setView={setView}
|
||||
agentState={agentState}
|
||||
loadCurrentChat={loadCurrentChat}
|
||||
setFatalError={setFatalError}
|
||||
setAgentWaitingMessage={setAgentWaitingMessage}
|
||||
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
|
||||
resumeSessionId={resumeSessionId}
|
||||
initialMessage={initialMessage}
|
||||
@@ -346,7 +342,6 @@ export function AppInner() {
|
||||
setAgentWaitingMessage,
|
||||
setIsExtensionsLoading,
|
||||
});
|
||||
// Update the chat state with the loaded session to ensure sessionId is available globally
|
||||
setChat(loadedChat);
|
||||
} catch (e) {
|
||||
if (e instanceof NoProviderOrModelError) {
|
||||
|
||||
@@ -10,16 +10,17 @@ import { MainPanelLayout } from './Layout/MainPanelLayout';
|
||||
import ChatInput from './ChatInput';
|
||||
import { ScrollArea, ScrollAreaHandle } from './ui/scroll-area';
|
||||
import { useFileDrop } from '../hooks/useFileDrop';
|
||||
import { Message } from '../api';
|
||||
import { Message, Session } from '../api';
|
||||
import { ChatState } from '../types/chatState';
|
||||
import { ChatType } from '../types/chat';
|
||||
import { useIsMobile } from '../hooks/use-mobile';
|
||||
import { useSidebar } from './ui/sidebar';
|
||||
import { cn } from '../utils';
|
||||
import { useChatStream } from '../hooks/useChatStream';
|
||||
import { loadSession } from '../utils/sessionCache';
|
||||
|
||||
interface BaseChatProps {
|
||||
chat: ChatType | null;
|
||||
chat: ChatType;
|
||||
setChat: (chat: ChatType) => void;
|
||||
setView: (view: View, viewOptions?: ViewOptions) => void;
|
||||
setIsGoosehintsModalOpen?: (isOpen: boolean) => void;
|
||||
@@ -35,10 +36,12 @@ interface BaseChatProps {
|
||||
showPopularTopics?: boolean;
|
||||
suppressEmptyState?: boolean;
|
||||
autoSubmit?: boolean;
|
||||
resumeSessionId?: string; // Optional session ID to resume on mount
|
||||
}
|
||||
|
||||
function BaseChatContent({
|
||||
chat,
|
||||
setChat,
|
||||
setView,
|
||||
setIsGoosehintsModalOpen,
|
||||
renderHeader,
|
||||
@@ -47,6 +50,7 @@ function BaseChatContent({
|
||||
customChatInputProps = {},
|
||||
customMainLayoutProps = {},
|
||||
disableSearch = false,
|
||||
resumeSessionId,
|
||||
}: BaseChatProps) {
|
||||
const location = useLocation();
|
||||
const scrollRef = useRef<ScrollAreaHandle>(null);
|
||||
@@ -72,17 +76,62 @@ function BaseChatContent({
|
||||
// session: sessionMetadata,
|
||||
// });
|
||||
|
||||
const [messages, setMessages] = useState(chat?.messages || []);
|
||||
// Session loading state
|
||||
const [sessionLoadError, setSessionLoadError] = useState<string | null>(null);
|
||||
const hasLoadedSessionRef = useRef(false);
|
||||
|
||||
const [messages, setMessages] = useState(chat.messages || []);
|
||||
|
||||
// Load session on mount if resumeSessionId is provided
|
||||
useEffect(() => {
|
||||
const needsLoad = resumeSessionId && !hasLoadedSessionRef.current;
|
||||
|
||||
if (needsLoad) {
|
||||
hasLoadedSessionRef.current = true;
|
||||
setSessionLoadError(null);
|
||||
|
||||
// Set chat to empty session to indicate loading state
|
||||
// todo: set to null instead and handle that in other places
|
||||
const emptyChat: ChatType = {
|
||||
sessionId: resumeSessionId,
|
||||
title: 'Loading...',
|
||||
messageHistoryIndex: 0,
|
||||
messages: [],
|
||||
recipe: null,
|
||||
recipeParameters: null,
|
||||
};
|
||||
setChat(emptyChat);
|
||||
|
||||
loadSession(resumeSessionId)
|
||||
.then((session: Session) => {
|
||||
const conversation = session.conversation || [];
|
||||
const loadedChat: ChatType = {
|
||||
sessionId: session.id,
|
||||
title: session.description || 'Untitled Chat',
|
||||
messageHistoryIndex: 0,
|
||||
messages: conversation,
|
||||
recipe: null,
|
||||
recipeParameters: null,
|
||||
};
|
||||
|
||||
setChat(loadedChat);
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
const errorMessage = error.message || 'Failed to load session';
|
||||
setSessionLoadError(errorMessage);
|
||||
});
|
||||
}
|
||||
}, [resumeSessionId, setChat]);
|
||||
|
||||
// Update messages when chat changes (e.g., when resuming a session)
|
||||
useEffect(() => {
|
||||
if (chat?.messages) {
|
||||
if (chat.messages) {
|
||||
setMessages(chat.messages);
|
||||
}
|
||||
}, [chat?.messages, chat?.sessionId]);
|
||||
}, [chat.messages, chat.sessionId]);
|
||||
|
||||
const { chatState, handleSubmit, stopStreaming } = useChatStream({
|
||||
sessionId: chat?.sessionId || '',
|
||||
sessionId: chat.sessionId || '',
|
||||
messages,
|
||||
setMessages,
|
||||
onStreamFinish: () => {},
|
||||
@@ -236,9 +285,27 @@ function BaseChatContent({
|
||||
{/* </div>*/}
|
||||
{/*)}*/}
|
||||
|
||||
{sessionLoadError && (
|
||||
<div className="flex flex-col items-center justify-center p-8">
|
||||
<div className="text-red-700 dark:text-red-300 bg-red-400/50 p-4 rounded-lg mb-4 max-w-md">
|
||||
<h3 className="font-semibold mb-2">Failed to Load Session</h3>
|
||||
<p className="text-sm">{sessionLoadError}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSessionLoadError(null);
|
||||
hasLoadedSessionRef.current = false;
|
||||
}}
|
||||
className="px-4 py-2 text-center cursor-pointer text-textStandard border border-borderSubtle hover:bg-bgSubtle rounded-lg transition-all duration-150"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Messages or Popular Topics */}
|
||||
{
|
||||
!chat ? null : messages.length > 0 || recipe ? (
|
||||
messages.length > 0 || recipe ? (
|
||||
<>
|
||||
{disableSearch ? (
|
||||
renderProgressiveMessageList(chat)
|
||||
@@ -304,11 +371,11 @@ function BaseChatContent({
|
||||
</ScrollArea>
|
||||
|
||||
{/* Fixed loading indicator at bottom left of chat container */}
|
||||
{(!chat || isCompacting) && (
|
||||
{(messages.length === 0 || isCompacting) && !sessionLoadError && (
|
||||
<div className="absolute bottom-1 left-4 z-20 pointer-events-none">
|
||||
<LoadingGoose
|
||||
message={
|
||||
!chat
|
||||
messages.length === 0
|
||||
? 'loading conversation...'
|
||||
: isCompacting
|
||||
? 'goose is compacting the conversation...'
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { View, ViewOptions } from '../utils/navigationUtils';
|
||||
import { AgentState, InitializationContext } from '../hooks/useAgent';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
|
||||
import { ChatType } from '../types/chat';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import BaseChat2 from './BaseChat2';
|
||||
|
||||
export interface PairRouteState {
|
||||
@@ -17,57 +14,22 @@ interface PairProps {
|
||||
setChat: (chat: ChatType) => void;
|
||||
setView: (view: View, viewOptions?: ViewOptions) => void;
|
||||
setIsGoosehintsModalOpen: (isOpen: boolean) => void;
|
||||
setFatalError: (value: ((prevState: string | null) => string | null) | string | null) => void;
|
||||
setAgentWaitingMessage: (msg: string | null) => void;
|
||||
agentState: AgentState;
|
||||
loadCurrentChat: (context: InitializationContext) => Promise<ChatType>;
|
||||
}
|
||||
|
||||
export default function Pair({
|
||||
chat,
|
||||
setChat,
|
||||
setView,
|
||||
setIsGoosehintsModalOpen,
|
||||
setFatalError,
|
||||
setAgentWaitingMessage,
|
||||
agentState,
|
||||
loadCurrentChat,
|
||||
resumeSessionId,
|
||||
}: PairProps & PairRouteState) {
|
||||
const [_searchParams, setSearchParams] = useSearchParams();
|
||||
const [chat, setChat] = useState<ChatType | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const initializeFromState = async () => {
|
||||
try {
|
||||
const chat = await loadCurrentChat({
|
||||
resumeSessionId,
|
||||
setAgentWaitingMessage,
|
||||
});
|
||||
setChat(chat);
|
||||
setSearchParams((prev) => {
|
||||
prev.set('resumeSessionId', chat.sessionId);
|
||||
return prev;
|
||||
});
|
||||
} catch (error) {
|
||||
setFatalError(`Agent init failure: ${error instanceof Error ? error.message : '' + error}`);
|
||||
}
|
||||
};
|
||||
initializeFromState();
|
||||
}, [
|
||||
agentState,
|
||||
setChat,
|
||||
setFatalError,
|
||||
setAgentWaitingMessage,
|
||||
loadCurrentChat,
|
||||
resumeSessionId,
|
||||
setSearchParams,
|
||||
]);
|
||||
|
||||
return (
|
||||
<BaseChat2
|
||||
chat={chat}
|
||||
setChat={setChat}
|
||||
setView={setView}
|
||||
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
|
||||
resumeSessionId={resumeSessionId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Session } from '../api';
|
||||
import { getApiUrl } from '../config';
|
||||
|
||||
/**
|
||||
* In-memory cache for session data
|
||||
* Maps session ID to Session object
|
||||
*/
|
||||
const sessionCache = new Map<string, Session>();
|
||||
|
||||
/**
|
||||
* In-flight request tracking to prevent duplicate fetches
|
||||
* Maps session ID to Promise of Session
|
||||
*/
|
||||
const inFlightRequests = new Map<string, Promise<Session>>();
|
||||
|
||||
/**
|
||||
* Load a session from the server using the /agent/resume endpoint
|
||||
* Implements caching to avoid redundant fetches
|
||||
*
|
||||
* @param sessionId - The unique identifier for the session
|
||||
* @param forceRefresh - If true, bypass cache and fetch fresh data
|
||||
* @returns Promise resolving to the Session object
|
||||
* @throws Error if the request fails or session not found
|
||||
*/
|
||||
export async function loadSession(sessionId: string, forceRefresh = false): Promise<Session> {
|
||||
if (!forceRefresh && sessionCache.has(sessionId)) {
|
||||
return sessionCache.get(sessionId)!;
|
||||
}
|
||||
|
||||
if (inFlightRequests.has(sessionId)) {
|
||||
return inFlightRequests.get(sessionId)!;
|
||||
}
|
||||
|
||||
const fetchPromise = (async () => {
|
||||
try {
|
||||
const url = getApiUrl('/agent/resume');
|
||||
const secretKey = await window.electron.getSecretKey();
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Secret-Key': secretKey,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
session_id: sessionId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
throw new Error(`Failed to load session: HTTP ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
const session: Session = await response.json();
|
||||
sessionCache.set(sessionId, session);
|
||||
|
||||
return session;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw new Error(`Error loading session ${sessionId}: ${error.message}`);
|
||||
}
|
||||
throw new Error(`Error loading session ${sessionId}: Unknown error`);
|
||||
} finally {
|
||||
inFlightRequests.delete(sessionId);
|
||||
}
|
||||
})();
|
||||
|
||||
inFlightRequests.set(sessionId, fetchPromise);
|
||||
return fetchPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear a specific session from the cache
|
||||
* Useful when a session has been updated and needs to be refetched
|
||||
*
|
||||
* @param sessionId - The unique identifier for the session to clear
|
||||
*/
|
||||
export function clearSessionCache(sessionId: string): void {
|
||||
sessionCache.delete(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all sessions from the cache
|
||||
* Useful for logout or when switching contexts
|
||||
*/
|
||||
export function clearAllSessionCache(): void {
|
||||
sessionCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a session is currently cached
|
||||
*
|
||||
* @param sessionId - The unique identifier for the session
|
||||
* @returns true if the session is in cache, false otherwise
|
||||
*/
|
||||
export function isSessionCached(sessionId: string): boolean {
|
||||
return sessionCache.has(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a session from cache without fetching
|
||||
* Returns undefined if not cached
|
||||
*
|
||||
* @param sessionId - The unique identifier for the session
|
||||
* @returns The cached Session object or undefined
|
||||
*/
|
||||
export function getCachedSession(sessionId: string): Session | undefined {
|
||||
return sessionCache.get(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload a session into cache
|
||||
* Useful when you already have session data from another source
|
||||
*
|
||||
* @param session - The Session object to cache
|
||||
*/
|
||||
export function preloadSession(session: Session): void {
|
||||
sessionCache.set(session.id, session);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current cache size
|
||||
* Useful for debugging and monitoring
|
||||
*
|
||||
* @returns The number of sessions currently cached
|
||||
*/
|
||||
export function getCacheSize(): number {
|
||||
return sessionCache.size;
|
||||
}
|
||||
Reference in New Issue
Block a user