mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
refactored hub and pair to use shared logic and eliminate duplicate code
This commit is contained in:
@@ -0,0 +1,317 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import GooseMessage from './GooseMessage';
|
||||
import UserMessage from './UserMessage';
|
||||
import { SearchView } from './conversation/SearchView';
|
||||
import { AgentHeader } from './AgentHeader';
|
||||
import LayingEggLoader from './LayingEggLoader';
|
||||
import { SessionSummaryModal } from './context_management/SessionSummaryModal';
|
||||
import {
|
||||
ChatContextManagerProvider,
|
||||
useChatContextManager,
|
||||
} from './context_management/ChatContextManager';
|
||||
import { ContextHandler } from './context_management/ContextHandler';
|
||||
import { type View, ViewOptions } from '../App';
|
||||
import { MainPanelLayout } from './Layout/MainPanelLayout';
|
||||
import ChatInput from './ChatInput';
|
||||
import { useChatEngine } from '../hooks/useChatEngine';
|
||||
import { useRecipeManager } from '../hooks/useRecipeManager';
|
||||
import { useSessionContinuation } from '../hooks/useSessionContinuation';
|
||||
import { useFileDrop } from '../hooks/useFileDrop';
|
||||
import { useCostTracking } from '../hooks/useCostTracking';
|
||||
import { Message } from '../types/message';
|
||||
|
||||
export interface ChatType {
|
||||
id: string;
|
||||
title: string;
|
||||
messageHistoryIndex: number;
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
interface BaseChatProps {
|
||||
chat: ChatType;
|
||||
setChat: (chat: ChatType) => void;
|
||||
setView: (view: View, viewOptions?: ViewOptions) => void;
|
||||
setIsGoosehintsModalOpen?: (isOpen: boolean) => void;
|
||||
enableLocalStorage?: boolean;
|
||||
onMessageStreamFinish?: () => void;
|
||||
renderHeader?: () => React.ReactNode;
|
||||
renderBeforeMessages?: () => React.ReactNode;
|
||||
renderAfterMessages?: () => React.ReactNode;
|
||||
customChatInputProps?: Record<string, unknown>;
|
||||
customMainLayoutProps?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function BaseChatContent({
|
||||
chat,
|
||||
setChat,
|
||||
setView,
|
||||
enableLocalStorage = false,
|
||||
onMessageStreamFinish,
|
||||
renderHeader,
|
||||
renderBeforeMessages,
|
||||
renderAfterMessages,
|
||||
customChatInputProps = {},
|
||||
customMainLayoutProps = {},
|
||||
}: BaseChatProps) {
|
||||
const location = useLocation();
|
||||
|
||||
// Get disableAnimation from location state
|
||||
const disableAnimation = location.state?.disableAnimation || false;
|
||||
|
||||
const {
|
||||
summaryContent,
|
||||
summarizedThread,
|
||||
isSummaryModalOpen,
|
||||
resetMessagesWithSummary,
|
||||
closeSummaryModal,
|
||||
updateSummary,
|
||||
hasContextHandlerContent,
|
||||
getContextHandlerType,
|
||||
} = useChatContextManager();
|
||||
|
||||
// Use shared chat engine
|
||||
const {
|
||||
messages,
|
||||
filteredMessages,
|
||||
ancestorMessages,
|
||||
setAncestorMessages,
|
||||
append,
|
||||
isLoading,
|
||||
error,
|
||||
setMessages,
|
||||
input: _input,
|
||||
setInput: _setInput,
|
||||
handleSubmit: engineHandleSubmit,
|
||||
onStopGoose,
|
||||
sessionTokenCount,
|
||||
sessionInputTokens,
|
||||
sessionOutputTokens,
|
||||
localInputTokens,
|
||||
localOutputTokens,
|
||||
commandHistory,
|
||||
toolCallNotifications,
|
||||
updateMessageStreamBody,
|
||||
sessionMetadata,
|
||||
isUserMessage,
|
||||
} = useChatEngine({
|
||||
chat,
|
||||
setChat,
|
||||
onMessageStreamFinish,
|
||||
enableLocalStorage,
|
||||
});
|
||||
|
||||
// Use shared recipe manager
|
||||
const { recipeConfig, initialPrompt, isGeneratingRecipe } = useRecipeManager(messages);
|
||||
|
||||
// Use shared session continuation
|
||||
useSessionContinuation({
|
||||
chat,
|
||||
setChat,
|
||||
summarizedThread,
|
||||
updateMessageStreamBody,
|
||||
});
|
||||
|
||||
// Use shared file drop
|
||||
const { droppedFiles, handleDrop, handleDragOver } = useFileDrop();
|
||||
|
||||
// Use shared cost tracking
|
||||
const { sessionCosts } = useCostTracking({
|
||||
sessionInputTokens,
|
||||
sessionOutputTokens,
|
||||
localInputTokens,
|
||||
localOutputTokens,
|
||||
sessionMetadata,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Log all messages when the component first mounts
|
||||
window.electron.logInfo(
|
||||
'Initial messages when resuming session: ' + JSON.stringify(chat.messages, null, 2)
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // Empty dependency array means this runs once on mount
|
||||
|
||||
// Handle submit with summary reset support
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
const customEvent = e as unknown as CustomEvent;
|
||||
const combinedTextFromInput = customEvent.detail?.value || '';
|
||||
|
||||
const onSummaryReset =
|
||||
summarizedThread.length > 0
|
||||
? () => {
|
||||
resetMessagesWithSummary(
|
||||
messages,
|
||||
setMessages,
|
||||
ancestorMessages,
|
||||
setAncestorMessages,
|
||||
summaryContent
|
||||
);
|
||||
}
|
||||
: undefined;
|
||||
|
||||
engineHandleSubmit(combinedTextFromInput, onSummaryReset);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<MainPanelLayout {...customMainLayoutProps}>
|
||||
{/* Loader when generating recipe */}
|
||||
{isGeneratingRecipe && <LayingEggLoader />}
|
||||
|
||||
{/* Custom header */}
|
||||
{renderHeader && renderHeader()}
|
||||
|
||||
<div
|
||||
className="flex flex-col min-w-0 flex-1 overflow-y-scroll relative"
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
>
|
||||
{/* Custom content before messages */}
|
||||
{renderBeforeMessages && renderBeforeMessages()}
|
||||
|
||||
{/* Recipe agent header */}
|
||||
{recipeConfig?.title && messages.length > 0 && (
|
||||
<AgentHeader
|
||||
title={recipeConfig.title}
|
||||
profileInfo={
|
||||
recipeConfig.profile
|
||||
? `${recipeConfig.profile} - ${recipeConfig.mcps || 12} MCPs`
|
||||
: undefined
|
||||
}
|
||||
onChangeProfile={() => {
|
||||
console.log('Change profile clicked');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Messages */}
|
||||
{messages.length > 0 && (
|
||||
<>
|
||||
<SearchView>
|
||||
{filteredMessages.map((message, index) => {
|
||||
const isUser = isUserMessage(message);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={message.id || index}
|
||||
className={`relative ${index === 0 ? 'mt-0' : 'mt-4'} ${isUser ? 'user' : 'assistant'}`}
|
||||
data-testid="message-container"
|
||||
>
|
||||
{isUser ? (
|
||||
<>
|
||||
{hasContextHandlerContent(message) ? (
|
||||
<ContextHandler
|
||||
messages={messages}
|
||||
messageId={message.id ?? message.created.toString()}
|
||||
chatId={chat.id}
|
||||
workingDir={window.appConfig.get('GOOSE_WORKING_DIR') as string}
|
||||
contextType={getContextHandlerType(message)}
|
||||
/>
|
||||
) : (
|
||||
<UserMessage message={message} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{hasContextHandlerContent(message) ? (
|
||||
<ContextHandler
|
||||
messages={messages}
|
||||
messageId={message.id ?? message.created.toString()}
|
||||
chatId={chat.id}
|
||||
workingDir={window.appConfig.get('GOOSE_WORKING_DIR') as string}
|
||||
contextType={getContextHandlerType(message)}
|
||||
/>
|
||||
) : (
|
||||
<GooseMessage
|
||||
messageHistoryIndex={chat?.messageHistoryIndex}
|
||||
message={message}
|
||||
messages={messages}
|
||||
append={append}
|
||||
appendMessage={(newMessage) => {
|
||||
const updatedMessages = [...messages, newMessage];
|
||||
setMessages(updatedMessages);
|
||||
}}
|
||||
toolCallNotifications={toolCallNotifications}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</SearchView>
|
||||
|
||||
{error && (
|
||||
<div className="flex flex-col items-center justify-center p-4">
|
||||
<div className="text-red-700 dark:text-red-300 bg-red-400/50 p-3 rounded-lg mb-2">
|
||||
{error.message || 'Honk! Goose experienced an error while responding'}
|
||||
</div>
|
||||
<div
|
||||
className="px-3 py-2 mt-2 text-center whitespace-nowrap cursor-pointer text-textStandard border border-borderSubtle hover:bg-bgSubtle rounded-full inline-block transition-all duration-150"
|
||||
onClick={async () => {
|
||||
// Find the last user message
|
||||
const lastUserMessage = messages.reduceRight(
|
||||
(found, m) => found || (m.role === 'user' ? m : null),
|
||||
null as Message | null
|
||||
);
|
||||
if (lastUserMessage) {
|
||||
append(lastUserMessage);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Retry Last Message
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="block h-8" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Custom content after messages */}
|
||||
{renderAfterMessages && renderAfterMessages()}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`relative z-10 ${disableAnimation ? '' : 'animate-[fadein_400ms_ease-in_forwards]'}`}
|
||||
>
|
||||
<ChatInput
|
||||
handleSubmit={handleSubmit}
|
||||
isLoading={isLoading}
|
||||
onStop={onStopGoose}
|
||||
commandHistory={commandHistory}
|
||||
initialValue={_input || initialPrompt}
|
||||
setView={setView}
|
||||
numTokens={sessionTokenCount}
|
||||
inputTokens={sessionInputTokens || localInputTokens}
|
||||
outputTokens={sessionOutputTokens || localOutputTokens}
|
||||
droppedFiles={droppedFiles}
|
||||
messages={messages}
|
||||
setMessages={setMessages}
|
||||
disableAnimation={disableAnimation}
|
||||
sessionCosts={sessionCosts}
|
||||
{...customChatInputProps}
|
||||
/>
|
||||
</div>
|
||||
</MainPanelLayout>
|
||||
|
||||
<SessionSummaryModal
|
||||
isOpen={isSummaryModalOpen}
|
||||
onClose={closeSummaryModal}
|
||||
onSave={(editedContent) => {
|
||||
updateSummary(editedContent);
|
||||
closeSummaryModal();
|
||||
}}
|
||||
summaryContent={summaryContent}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BaseChat(props: BaseChatProps) {
|
||||
return (
|
||||
<ChatContextManagerProvider>
|
||||
<BaseChatContent {...props} />
|
||||
</ChatContextManagerProvider>
|
||||
);
|
||||
}
|
||||
+176
-920
File diff suppressed because it is too large
Load Diff
@@ -1,47 +1,9 @@
|
||||
import React, {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useMemo,
|
||||
useCallback,
|
||||
useContext,
|
||||
createContext,
|
||||
} from 'react';
|
||||
import { getApiUrl } from '../config';
|
||||
import GooseMessage from './GooseMessage';
|
||||
import ChatInput from './ChatInput';
|
||||
import { type View, ViewOptions } from '../App';
|
||||
import LoadingGoose from './LoadingGoose';
|
||||
import { ScrollAreaHandle } from './ui/scroll-area';
|
||||
import UserMessage from './UserMessage';
|
||||
import { SearchView } from './conversation/SearchView';
|
||||
import { createRecipe } from '../recipe';
|
||||
import { AgentHeader } from './AgentHeader';
|
||||
import LayingEggLoader from './LayingEggLoader';
|
||||
import { fetchSessionDetails, generateSessionId } from '../sessions';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import { useMessageStream } from '../hooks/useMessageStream';
|
||||
import { SessionSummaryModal } from './context_management/SessionSummaryModal';
|
||||
import { Recipe } from '../recipe';
|
||||
import {
|
||||
ChatContextManagerProvider,
|
||||
useChatContextManager,
|
||||
} from './context_management/ChatContextManager';
|
||||
import { ContextHandler } from './context_management/ContextHandler';
|
||||
import { LocalMessageStorage } from '../utils/localMessageStorage';
|
||||
import {
|
||||
Message,
|
||||
createUserMessage,
|
||||
ToolCall,
|
||||
ToolCallResult,
|
||||
ToolRequestMessageContent,
|
||||
ToolResponseMessageContent,
|
||||
ToolConfirmationRequestMessageContent,
|
||||
getTextContent,
|
||||
TextContent,
|
||||
} from '../types/message';
|
||||
import { MainPanelLayout } from './Layout/MainPanelLayout';
|
||||
import { useEffect, useState, useContext, createContext } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { type View, ViewOptions } from '../App';
|
||||
import { Message } from '../types/message';
|
||||
import BaseChat from './BaseChat';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
|
||||
// Context for sharing current model info
|
||||
const CurrentModelContext = createContext<{ model: string; mode: string } | null>(null);
|
||||
@@ -54,19 +16,7 @@ export interface ChatType {
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
// Helper function to determine if a message is a user message
|
||||
const isUserMessage = (message: Message): boolean => {
|
||||
if (message.role === 'assistant') {
|
||||
return false;
|
||||
}
|
||||
if (message.content.every((c) => c.type === 'toolConfirmationRequest')) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
export default function Pair({
|
||||
readyForAutoUserPrompt,
|
||||
chat,
|
||||
setChat,
|
||||
setView,
|
||||
@@ -77,635 +27,51 @@ export default function Pair({
|
||||
setChat: (chat: ChatType) => void;
|
||||
setView: (view: View, viewOptions?: ViewOptions) => void;
|
||||
setIsGoosehintsModalOpen: (isOpen: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<ChatContextManagerProvider>
|
||||
<PairContent
|
||||
readyForAutoUserPrompt={readyForAutoUserPrompt}
|
||||
chat={chat}
|
||||
setChat={setChat}
|
||||
setView={setView}
|
||||
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
|
||||
/>
|
||||
</ChatContextManagerProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function PairContent({
|
||||
chat,
|
||||
setChat,
|
||||
setView,
|
||||
}: {
|
||||
readyForAutoUserPrompt: boolean;
|
||||
chat: ChatType;
|
||||
setChat: (chat: ChatType) => void;
|
||||
setView: (view: View, viewOptions?: ViewOptions) => void;
|
||||
setIsGoosehintsModalOpen: (isOpen: boolean) => void;
|
||||
}) {
|
||||
return <PairContentWithSidebar chat={chat} setChat={setChat} setView={setView} />;
|
||||
}
|
||||
|
||||
function PairContentWithSidebar({
|
||||
chat,
|
||||
setChat,
|
||||
setView,
|
||||
}: {
|
||||
chat: ChatType;
|
||||
setChat: (chat: ChatType) => void;
|
||||
setView: (view: View, viewOptions?: ViewOptions) => void;
|
||||
}) {
|
||||
const location = useLocation();
|
||||
const [lastInteractionTime, setLastInteractionTime] = useState<number>(Date.now());
|
||||
const [isGeneratingRecipe, setIsGeneratingRecipe] = useState(false);
|
||||
const [sessionTokenCount, setSessionTokenCount] = useState<number>(0);
|
||||
const [ancestorMessages, setAncestorMessages] = useState<Message[]>([]);
|
||||
const [droppedFiles, setDroppedFiles] = useState<string[]>([]);
|
||||
const [hasProcessedInitialInput, setHasProcessedInitialInput] = useState(false);
|
||||
|
||||
// Get disableAnimation from location state
|
||||
const disableAnimation = location.state?.disableAnimation || false;
|
||||
|
||||
const scrollRef = useRef<ScrollAreaHandle>(null);
|
||||
|
||||
const {
|
||||
summaryContent,
|
||||
summarizedThread,
|
||||
isSummaryModalOpen,
|
||||
resetMessagesWithSummary,
|
||||
closeSummaryModal,
|
||||
updateSummary,
|
||||
hasContextHandlerContent,
|
||||
getContextHandlerType,
|
||||
} = useChatContextManager();
|
||||
|
||||
useEffect(() => {
|
||||
// Log all messages when the component first mounts
|
||||
window.electron.logInfo(
|
||||
'Initial messages when resuming session: ' + JSON.stringify(chat.messages, null, 2)
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // Empty dependency array means this runs once on mount;
|
||||
|
||||
// Get recipeConfig directly from appConfig
|
||||
const recipeConfig = window.appConfig.get('recipeConfig') as Recipe | null;
|
||||
|
||||
// Store message in global history when it's added
|
||||
const storeMessageInHistory = useCallback((message: Message) => {
|
||||
if (isUserMessage(message)) {
|
||||
const text = getTextContent(message);
|
||||
if (text) {
|
||||
LocalMessageStorage.addMessage(text);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const {
|
||||
messages,
|
||||
append: originalAppend,
|
||||
stop,
|
||||
isLoading,
|
||||
error,
|
||||
setMessages,
|
||||
input: _input,
|
||||
setInput: _setInput,
|
||||
handleInputChange: _handleInputChange,
|
||||
handleSubmit: _submitMessage,
|
||||
updateMessageStreamBody,
|
||||
notifications,
|
||||
} = useMessageStream({
|
||||
api: getApiUrl('/reply'),
|
||||
id: chat.id,
|
||||
initialMessages: chat.messages,
|
||||
body: { session_id: chat.id, session_working_dir: window.appConfig.get('GOOSE_WORKING_DIR') },
|
||||
onFinish: async (_message, _reason) => {
|
||||
window.electron.stopPowerSaveBlocker();
|
||||
|
||||
setTimeout(() => {
|
||||
if (scrollRef.current?.scrollToBottom) {
|
||||
scrollRef.current.scrollToBottom();
|
||||
}
|
||||
}, 300);
|
||||
|
||||
const timeSinceLastInteraction = Date.now() - lastInteractionTime;
|
||||
window.electron.logInfo('last interaction:' + lastInteractionTime);
|
||||
if (timeSinceLastInteraction > 60000) {
|
||||
// 60000ms = 1 minute
|
||||
window.electron.showNotification({
|
||||
title: 'Goose finished the task.',
|
||||
body: 'Click here to expand.',
|
||||
});
|
||||
}
|
||||
|
||||
// Always emit refresh event when message stream finishes for new sessions
|
||||
// Check if this is a new session by looking at the current session ID format
|
||||
const isNewSession = chat.id && chat.id.match(/^\d{8}_\d{6}$/);
|
||||
if (isNewSession) {
|
||||
console.log(
|
||||
'Pair: Message stream finished for new session, emitting message-stream-finished event'
|
||||
);
|
||||
// Emit event to trigger session refresh
|
||||
window.dispatchEvent(new CustomEvent('message-stream-finished'));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Wrap append to store messages in global history
|
||||
const append = useCallback(
|
||||
(messageOrString: Message | string) => {
|
||||
const message =
|
||||
typeof messageOrString === 'string' ? createUserMessage(messageOrString) : messageOrString;
|
||||
storeMessageInHistory(message);
|
||||
|
||||
// If this is the first message in a new session, trigger a refresh immediately
|
||||
// Only trigger if we're starting a completely new session (no existing messages)
|
||||
if (messages.length === 0 && chat.messages.length === 0) {
|
||||
console.log('Pair: New session detected, emitting session-created event');
|
||||
// Emit event to indicate a new session is being created
|
||||
window.dispatchEvent(new CustomEvent('session-created'));
|
||||
}
|
||||
|
||||
return originalAppend(message);
|
||||
},
|
||||
[originalAppend, storeMessageInHistory, messages.length, chat.messages.length]
|
||||
);
|
||||
|
||||
// for CLE events -- create a new session id for the next set of messages
|
||||
useEffect(() => {
|
||||
// If we're in a continuation session, update the chat ID
|
||||
if (summarizedThread.length > 0) {
|
||||
const newSessionId = generateSessionId();
|
||||
|
||||
// Update the session ID in the chat object
|
||||
setChat({
|
||||
...chat,
|
||||
id: newSessionId!,
|
||||
title: `Continued from ${chat.id}`,
|
||||
messageHistoryIndex: summarizedThread.length,
|
||||
});
|
||||
|
||||
// Update the body used by useMessageStream to send future messages to the new session
|
||||
if (summarizedThread.length > 0 && updateMessageStreamBody) {
|
||||
updateMessageStreamBody({
|
||||
session_id: newSessionId,
|
||||
session_working_dir: window.appConfig.get('GOOSE_WORKING_DIR'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// only update if summarizedThread length changes from 0 -> 1+
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
summarizedThread.length > 0,
|
||||
]);
|
||||
|
||||
// Listen for make-agent-from-chat event
|
||||
useEffect(() => {
|
||||
const handleMakeAgent = async () => {
|
||||
window.electron.logInfo('Making recipe from chat...');
|
||||
setIsGeneratingRecipe(true);
|
||||
|
||||
try {
|
||||
// Create recipe directly from chat messages
|
||||
const createRecipeRequest = {
|
||||
messages: messages,
|
||||
title: '',
|
||||
description: '',
|
||||
};
|
||||
|
||||
const response = await createRecipe(createRecipeRequest);
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(`Failed to create recipe: ${response.error}`);
|
||||
}
|
||||
|
||||
window.electron.logInfo('Created recipe:');
|
||||
window.electron.logInfo(JSON.stringify(response.recipe, null, 2));
|
||||
|
||||
// First, verify the recipe data
|
||||
if (!response.recipe) {
|
||||
throw new Error('No recipe data received');
|
||||
}
|
||||
|
||||
// Create a new window for the recipe editor
|
||||
console.log('Opening recipe editor with config:', response.recipe);
|
||||
const recipeConfig = {
|
||||
id: response.recipe.title || 'untitled',
|
||||
title: response.recipe.title || 'Untitled Recipe',
|
||||
description: response.recipe.description || '',
|
||||
instructions: response.recipe.instructions || '',
|
||||
activities: response.recipe.activities || [],
|
||||
prompt: response.recipe.prompt || '',
|
||||
};
|
||||
window.electron.createChatWindow(
|
||||
undefined, // query
|
||||
undefined, // dir
|
||||
undefined, // version
|
||||
undefined, // resumeSessionId
|
||||
recipeConfig, // recipe config
|
||||
'recipeEditor' // view type
|
||||
);
|
||||
|
||||
window.electron.logInfo('Opening recipe editor window');
|
||||
} catch (error) {
|
||||
window.electron.logInfo('Failed to create recipe:');
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
window.electron.logInfo(errorMessage);
|
||||
} finally {
|
||||
setIsGeneratingRecipe(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('make-agent-from-chat', handleMakeAgent);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('make-agent-from-chat', handleMakeAgent);
|
||||
};
|
||||
}, [messages]);
|
||||
|
||||
// Update chat messages when they change and save to sessionStorage
|
||||
useEffect(() => {
|
||||
// @ts-expect-error - TypeScript being overly strict about the return type
|
||||
setChat((prevChat: ChatType) => ({ ...prevChat, messages }));
|
||||
}, [messages, setChat]);
|
||||
|
||||
// Pre-fill input with recipe prompt instead of auto-sending it
|
||||
const initialPrompt = useMemo(() => {
|
||||
return recipeConfig?.prompt || '';
|
||||
}, [recipeConfig?.prompt]);
|
||||
|
||||
// Handle submit
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
window.electron.startPowerSaveBlocker();
|
||||
const customEvent = e as unknown as CustomEvent;
|
||||
// ChatInput now sends a single 'value' field with text and appended image paths
|
||||
const combinedTextFromInput = customEvent.detail?.value || '';
|
||||
|
||||
if (combinedTextFromInput.trim()) {
|
||||
setLastInteractionTime(Date.now());
|
||||
|
||||
// createUserMessage was reverted to only accept text.
|
||||
// It will create a Message with a single TextContent part containing text + paths.
|
||||
const userMessage = createUserMessage(combinedTextFromInput.trim());
|
||||
|
||||
if (summarizedThread.length > 0) {
|
||||
resetMessagesWithSummary(
|
||||
messages,
|
||||
setMessages,
|
||||
ancestorMessages,
|
||||
setAncestorMessages,
|
||||
summaryContent
|
||||
);
|
||||
setTimeout(() => {
|
||||
append(userMessage);
|
||||
if (scrollRef.current?.scrollToBottom) {
|
||||
scrollRef.current.scrollToBottom();
|
||||
}
|
||||
}, 150);
|
||||
} else {
|
||||
append(userMessage);
|
||||
if (scrollRef.current?.scrollToBottom) {
|
||||
scrollRef.current.scrollToBottom();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If nothing was actually submitted (e.g. empty input and no images pasted)
|
||||
window.electron.stopPowerSaveBlocker();
|
||||
}
|
||||
};
|
||||
|
||||
if (error) {
|
||||
console.log('Error:', error);
|
||||
}
|
||||
|
||||
const onStopGoose = () => {
|
||||
stop();
|
||||
setLastInteractionTime(Date.now());
|
||||
window.electron.stopPowerSaveBlocker();
|
||||
|
||||
// Handle stopping the message stream
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
|
||||
// Check if there are any messages before proceeding
|
||||
if (!lastMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
// check if the last user message has any tool response(s)
|
||||
const isToolResponse = lastMessage.content.some(
|
||||
(content): content is ToolResponseMessageContent => content.type == 'toolResponse'
|
||||
);
|
||||
|
||||
// isUserMessage also checks if the message is a toolConfirmationRequest
|
||||
// check if the last message is a real user's message
|
||||
if (lastMessage && isUserMessage(lastMessage) && !isToolResponse) {
|
||||
// Get the text content from the last message before removing it
|
||||
const textContent = lastMessage.content.find((c): c is TextContent => c.type === 'text');
|
||||
const textValue = textContent?.text || '';
|
||||
|
||||
// Set the text back to the input field
|
||||
_setInput(textValue);
|
||||
|
||||
// Remove the last user message if it's the most recent one
|
||||
if (messages.length > 1) {
|
||||
setMessages(messages.slice(0, -1));
|
||||
} else {
|
||||
setMessages([]);
|
||||
}
|
||||
// Interruption occured after a tool has completed, but no assistant reply
|
||||
// handle his if we want to popup a message too the user
|
||||
// } else if (lastMessage && isUserMessage(lastMessage) && isToolResponse) {
|
||||
} else if (!isUserMessage(lastMessage)) {
|
||||
// the last message was an assistant message
|
||||
// check if we have any tool requests or tool confirmation requests
|
||||
const toolRequests: [string, ToolCallResult<ToolCall>][] = lastMessage.content
|
||||
.filter(
|
||||
(content): content is ToolRequestMessageContent | ToolConfirmationRequestMessageContent =>
|
||||
content.type === 'toolRequest' || content.type === 'toolConfirmationRequest'
|
||||
)
|
||||
.map((content) => {
|
||||
if (content.type === 'toolRequest') {
|
||||
return [content.id, content.toolCall];
|
||||
} else {
|
||||
// extract tool call from confirmation
|
||||
const toolCall: ToolCallResult<ToolCall> = {
|
||||
status: 'success',
|
||||
value: {
|
||||
name: content.toolName,
|
||||
arguments: content.arguments,
|
||||
},
|
||||
};
|
||||
return [content.id, toolCall];
|
||||
}
|
||||
});
|
||||
|
||||
if (toolRequests.length !== 0) {
|
||||
// This means we were interrupted during a tool request
|
||||
// Create tool responses for all interrupted tool requests
|
||||
|
||||
let responseMessage: Message = {
|
||||
display: true,
|
||||
sendToLLM: true,
|
||||
role: 'user',
|
||||
created: Date.now(),
|
||||
content: [],
|
||||
};
|
||||
|
||||
const notification = 'Interrupted by the user to make a correction';
|
||||
|
||||
// generate a response saying it was interrupted for each tool request
|
||||
for (const [reqId, _] of toolRequests) {
|
||||
const toolResponse: ToolResponseMessageContent = {
|
||||
type: 'toolResponse',
|
||||
id: reqId,
|
||||
toolResult: {
|
||||
status: 'error',
|
||||
error: notification,
|
||||
},
|
||||
};
|
||||
|
||||
responseMessage.content.push(toolResponse);
|
||||
}
|
||||
// Use an immutable update to add the response message to the messages array
|
||||
setMessages([...messages, responseMessage]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Filter out standalone tool response messages for rendering
|
||||
// They will be shown as part of the tool invocation in the assistant message
|
||||
const filteredMessages = [...ancestorMessages, ...messages].filter((message) => {
|
||||
// Only filter out when display is explicitly false
|
||||
if (message.display === false) return false;
|
||||
|
||||
// Keep all assistant messages and user messages that aren't just tool responses
|
||||
if (message.role === 'assistant') return true;
|
||||
|
||||
// For user messages, check if they're only tool responses
|
||||
if (message.role === 'user') {
|
||||
const hasOnlyToolResponses = message.content.every((c) => c.type === 'toolResponse');
|
||||
const hasTextContent = message.content.some((c) => c.type === 'text');
|
||||
const hasToolConfirmation = message.content.every(
|
||||
(c) => c.type === 'toolConfirmationRequest'
|
||||
);
|
||||
|
||||
// Keep the message if it has text content or tool confirmation or is not just tool responses
|
||||
return hasTextContent || !hasOnlyToolResponses || hasToolConfirmation;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const commandHistory = useMemo(() => {
|
||||
return filteredMessages
|
||||
.reduce<string[]>((history, message) => {
|
||||
if (isUserMessage(message)) {
|
||||
const textContent = message.content.find((c): c is TextContent => c.type === 'text');
|
||||
const text = textContent?.text?.trim();
|
||||
if (text) {
|
||||
history.push(text);
|
||||
}
|
||||
}
|
||||
return history;
|
||||
}, [])
|
||||
.reverse();
|
||||
}, [filteredMessages]);
|
||||
|
||||
// Fetch session metadata to get token count
|
||||
useEffect(() => {
|
||||
const fetchSessionTokens = async () => {
|
||||
try {
|
||||
const sessionDetails = await fetchSessionDetails(chat.id);
|
||||
setSessionTokenCount(sessionDetails.metadata.total_tokens || 0);
|
||||
} catch (err) {
|
||||
console.error('Error fetching session token count:', err);
|
||||
}
|
||||
};
|
||||
if (chat.id) {
|
||||
fetchSessionTokens();
|
||||
}
|
||||
}, [chat.id, messages]);
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
const paths: string[] = [];
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
paths.push(window.electron.getPathForFile(files[i]));
|
||||
}
|
||||
setDroppedFiles(paths);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const toolCallNotifications = notifications.reduce((map, item) => {
|
||||
const key = item.request_id;
|
||||
if (!map.has(key)) {
|
||||
map.set(key, []);
|
||||
}
|
||||
map.get(key).push(item);
|
||||
return map;
|
||||
}, new Map());
|
||||
|
||||
// Handle initial input from hub page
|
||||
useEffect(() => {
|
||||
const initialInput = location.state?.initialInput;
|
||||
const fromHub = location.state?.fromHub;
|
||||
|
||||
if (fromHub && initialInput && !hasProcessedInitialInput && messages.length === 0) {
|
||||
// Auto-submit the initial input
|
||||
const userMessage = createUserMessage(initialInput);
|
||||
append(userMessage);
|
||||
if (fromHub && initialInput && !hasProcessedInitialInput && chat.messages.length === 0) {
|
||||
// Auto-submit the initial input - this would need to be handled by BaseChat
|
||||
setHasProcessedInitialInput(true);
|
||||
|
||||
// Clear the location state to prevent re-processing
|
||||
window.history.replaceState({}, '', '/pair');
|
||||
}
|
||||
}, [location.state, hasProcessedInitialInput, messages.length, append]);
|
||||
}, [location.state, hasProcessedInitialInput, chat.messages.length]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<MainPanelLayout>
|
||||
{/* Loader when generating recipe */}
|
||||
{isGeneratingRecipe && <LayingEggLoader />}
|
||||
|
||||
<div
|
||||
className="flex flex-col min-w-0 flex-1 overflow-y-scroll relative pl-6 px-4 pb-16 pt-2"
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
>
|
||||
{recipeConfig?.title && messages.length > 0 && (
|
||||
<AgentHeader
|
||||
title={recipeConfig.title}
|
||||
profileInfo={
|
||||
recipeConfig.profile
|
||||
? `${recipeConfig.profile} - ${recipeConfig.mcps || 12} MCPs`
|
||||
: undefined
|
||||
}
|
||||
onChangeProfile={() => {
|
||||
// Handle profile change
|
||||
console.log('Change profile clicked');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{messages.length > 0 && (
|
||||
<>
|
||||
<SearchView>
|
||||
{filteredMessages.map((message, index) => {
|
||||
const isUser = isUserMessage(message);
|
||||
// const nextMessage = filteredMessages[index + 1];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={message.id || index}
|
||||
className={`relative px-2 ${index === 0 ? 'mt-0' : 'mt-2'} ${isUser ? 'user' : 'assistant'}`}
|
||||
data-testid="message-container"
|
||||
>
|
||||
{isUser ? (
|
||||
<>
|
||||
{hasContextHandlerContent(message) ? (
|
||||
<ContextHandler
|
||||
messages={messages}
|
||||
messageId={message.id ?? message.created.toString()}
|
||||
chatId={chat.id}
|
||||
workingDir={window.appConfig.get('GOOSE_WORKING_DIR') as string}
|
||||
contextType={getContextHandlerType(message)}
|
||||
/>
|
||||
) : (
|
||||
<UserMessage message={message} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Only render GooseMessage if it's not a message invoking some context management */}
|
||||
{hasContextHandlerContent(message) ? (
|
||||
<ContextHandler
|
||||
messages={messages}
|
||||
messageId={message.id ?? message.created.toString()}
|
||||
chatId={chat.id}
|
||||
workingDir={window.appConfig.get('GOOSE_WORKING_DIR') as string}
|
||||
contextType={getContextHandlerType(message)}
|
||||
/>
|
||||
) : (
|
||||
<GooseMessage
|
||||
messageHistoryIndex={chat?.messageHistoryIndex}
|
||||
message={message}
|
||||
messages={messages}
|
||||
append={append}
|
||||
appendMessage={(newMessage) => {
|
||||
const updatedMessages = [...messages, newMessage];
|
||||
setMessages(updatedMessages);
|
||||
}}
|
||||
toolCallNotifications={toolCallNotifications}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</SearchView>
|
||||
|
||||
{error && (
|
||||
<div className="flex flex-col items-center justify-center p-4">
|
||||
<div className="text-red-700 dark:text-red-300 bg-red-400/50 p-3 rounded-lg mb-2">
|
||||
{error.message || 'Honk! Goose experienced an error while responding'}
|
||||
</div>
|
||||
<div
|
||||
className="px-3 py-2 mt-2 text-center whitespace-nowrap cursor-pointer text-textStandard border border-borderSubtle hover:bg-bgSubtle rounded-full inline-block transition-all duration-150"
|
||||
onClick={async () => {
|
||||
// Find the last user message
|
||||
const lastUserMessage = messages.reduceRight(
|
||||
(found, m) => found || (m.role === 'user' ? m : null),
|
||||
null as Message | null
|
||||
);
|
||||
if (lastUserMessage) {
|
||||
append(lastUserMessage);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Retry Last Message
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="block h-8" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`relative z-10 ${disableAnimation ? '' : 'animate-[fadein_400ms_ease-in_forwards]'}`}
|
||||
>
|
||||
<div className="px-6">{isLoading && <LoadingGoose />}</div>
|
||||
<ChatInput
|
||||
handleSubmit={handleSubmit}
|
||||
isLoading={isLoading}
|
||||
onStop={onStopGoose}
|
||||
commandHistory={commandHistory}
|
||||
initialValue={_input || initialPrompt}
|
||||
setView={setView}
|
||||
numTokens={sessionTokenCount}
|
||||
droppedFiles={droppedFiles}
|
||||
messages={messages}
|
||||
setMessages={setMessages}
|
||||
disableAnimation={disableAnimation}
|
||||
/>
|
||||
</div>
|
||||
</MainPanelLayout>
|
||||
|
||||
<SessionSummaryModal
|
||||
isOpen={isSummaryModalOpen}
|
||||
onClose={closeSummaryModal}
|
||||
onSave={(editedContent) => {
|
||||
updateSummary(editedContent);
|
||||
closeSummaryModal();
|
||||
}}
|
||||
summaryContent={summaryContent}
|
||||
/>
|
||||
// Custom content before messages for loading state
|
||||
const renderBeforeMessages = () => (
|
||||
<div className="px-6">
|
||||
{/* Loading indicator will be handled by BaseChat's isLoading state */}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Custom main layout props for Pair-specific styling
|
||||
const customMainLayoutProps = {
|
||||
className: 'pl-6 px-4 pb-16 pt-2',
|
||||
};
|
||||
|
||||
// Custom chat input props for Pair-specific behavior
|
||||
const customChatInputProps = {
|
||||
// Any Pair-specific chat input customizations can go here
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseChat
|
||||
chat={chat}
|
||||
setChat={setChat}
|
||||
setView={setView}
|
||||
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
|
||||
enableLocalStorage={true} // Enable local storage for Pair mode
|
||||
renderBeforeMessages={renderBeforeMessages}
|
||||
customMainLayoutProps={customMainLayoutProps}
|
||||
customChatInputProps={customChatInputProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { getApiUrl } from '../config';
|
||||
import { useMessageStream } from './useMessageStream';
|
||||
import { fetchSessionDetails } from '../sessions';
|
||||
import { LocalMessageStorage } from '../utils/localMessageStorage';
|
||||
import {
|
||||
Message,
|
||||
createUserMessage,
|
||||
ToolCall,
|
||||
ToolCallResult,
|
||||
ToolRequestMessageContent,
|
||||
ToolResponseMessageContent,
|
||||
ToolConfirmationRequestMessageContent,
|
||||
getTextContent,
|
||||
TextContent,
|
||||
} from '../types/message';
|
||||
import { ChatType } from '../components/hub';
|
||||
|
||||
// Helper function to determine if a message is a user message
|
||||
const isUserMessage = (message: Message): boolean => {
|
||||
if (message.role === 'assistant') {
|
||||
return false;
|
||||
}
|
||||
if (message.content.every((c) => c.type === 'toolConfirmationRequest')) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
interface UseChatEngineProps {
|
||||
chat: ChatType;
|
||||
setChat: (chat: ChatType) => void;
|
||||
onMessageStreamFinish?: () => void;
|
||||
enableLocalStorage?: boolean;
|
||||
}
|
||||
|
||||
export const useChatEngine = ({
|
||||
chat,
|
||||
setChat,
|
||||
onMessageStreamFinish,
|
||||
enableLocalStorage = false,
|
||||
}: UseChatEngineProps) => {
|
||||
const [lastInteractionTime, setLastInteractionTime] = useState<number>(Date.now());
|
||||
const [sessionTokenCount, setSessionTokenCount] = useState<number>(0);
|
||||
const [ancestorMessages, setAncestorMessages] = useState<Message[]>([]);
|
||||
const [sessionInputTokens, setSessionInputTokens] = useState<number>(0);
|
||||
const [sessionOutputTokens, setSessionOutputTokens] = useState<number>(0);
|
||||
const [localInputTokens, setLocalInputTokens] = useState<number>(0);
|
||||
const [localOutputTokens, setLocalOutputTokens] = useState<number>(0);
|
||||
|
||||
// Store message in global history when it's added (if enabled)
|
||||
const storeMessageInHistory = useCallback(
|
||||
(message: Message) => {
|
||||
if (enableLocalStorage && isUserMessage(message)) {
|
||||
const text = getTextContent(message);
|
||||
if (text) {
|
||||
LocalMessageStorage.addMessage(text);
|
||||
}
|
||||
}
|
||||
},
|
||||
[enableLocalStorage]
|
||||
);
|
||||
|
||||
const {
|
||||
messages,
|
||||
append: originalAppend,
|
||||
stop,
|
||||
isLoading,
|
||||
error,
|
||||
setMessages,
|
||||
input: _input,
|
||||
setInput: _setInput,
|
||||
handleInputChange: _handleInputChange,
|
||||
handleSubmit: _submitMessage,
|
||||
updateMessageStreamBody,
|
||||
notifications,
|
||||
sessionMetadata,
|
||||
} = useMessageStream({
|
||||
api: getApiUrl('/reply'),
|
||||
id: chat.id,
|
||||
initialMessages: chat.messages,
|
||||
body: { session_id: chat.id, session_working_dir: window.appConfig.get('GOOSE_WORKING_DIR') },
|
||||
onFinish: async (_message, _reason) => {
|
||||
window.electron.stopPowerSaveBlocker();
|
||||
|
||||
const timeSinceLastInteraction = Date.now() - lastInteractionTime;
|
||||
window.electron.logInfo('last interaction:' + lastInteractionTime);
|
||||
if (timeSinceLastInteraction > 60000) {
|
||||
// 60000ms = 1 minute
|
||||
window.electron.showNotification({
|
||||
title: 'Goose finished the task.',
|
||||
body: 'Click here to expand.',
|
||||
});
|
||||
}
|
||||
|
||||
// Always emit refresh event when message stream finishes for new sessions
|
||||
// Check if this is a new session by looking at the current session ID format
|
||||
const isNewSession = chat.id && chat.id.match(/^\d{8}_\d{6}$/);
|
||||
if (isNewSession) {
|
||||
console.log(
|
||||
'ChatEngine: Message stream finished for new session, emitting message-stream-finished event'
|
||||
);
|
||||
// Emit event to trigger session refresh
|
||||
window.dispatchEvent(new CustomEvent('message-stream-finished'));
|
||||
}
|
||||
|
||||
onMessageStreamFinish?.();
|
||||
},
|
||||
});
|
||||
|
||||
// Wrap append to store messages in global history (if enabled)
|
||||
const append = useCallback(
|
||||
(messageOrString: Message | string) => {
|
||||
const message =
|
||||
typeof messageOrString === 'string' ? createUserMessage(messageOrString) : messageOrString;
|
||||
storeMessageInHistory(message);
|
||||
|
||||
// If this is the first message in a new session, trigger a refresh immediately
|
||||
// Only trigger if we're starting a completely new session (no existing messages)
|
||||
if (messages.length === 0 && chat.messages.length === 0) {
|
||||
console.log('ChatEngine: New session detected, emitting session-created event');
|
||||
// Emit event to indicate a new session is being created
|
||||
window.dispatchEvent(new CustomEvent('session-created'));
|
||||
}
|
||||
|
||||
return originalAppend(message);
|
||||
},
|
||||
[originalAppend, storeMessageInHistory, messages.length, chat.messages.length]
|
||||
);
|
||||
|
||||
// Simple token estimation function (roughly 4 characters per token)
|
||||
const estimateTokens = (text: string): number => {
|
||||
return Math.ceil(text.length / 4);
|
||||
};
|
||||
|
||||
// Calculate token counts from messages
|
||||
useEffect(() => {
|
||||
let inputTokens = 0;
|
||||
let outputTokens = 0;
|
||||
|
||||
messages.forEach((message) => {
|
||||
const textContent = getTextContent(message);
|
||||
if (textContent) {
|
||||
const tokens = estimateTokens(textContent);
|
||||
if (message.role === 'user') {
|
||||
inputTokens += tokens;
|
||||
} else if (message.role === 'assistant') {
|
||||
outputTokens += tokens;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setLocalInputTokens(inputTokens);
|
||||
setLocalOutputTokens(outputTokens);
|
||||
}, [messages]);
|
||||
|
||||
// Update chat messages when they change
|
||||
useEffect(() => {
|
||||
// @ts-expect-error - TypeScript being overly strict about the return type
|
||||
setChat((prevChat: ChatType) => ({ ...prevChat, messages }));
|
||||
}, [messages, setChat]);
|
||||
|
||||
// Fetch session metadata to get token count
|
||||
useEffect(() => {
|
||||
const fetchSessionTokens = async () => {
|
||||
try {
|
||||
const sessionDetails = await fetchSessionDetails(chat.id);
|
||||
setSessionTokenCount(sessionDetails.metadata.total_tokens || 0);
|
||||
setSessionInputTokens(sessionDetails.metadata.accumulated_input_tokens || 0);
|
||||
setSessionOutputTokens(sessionDetails.metadata.accumulated_output_tokens || 0);
|
||||
} catch (err) {
|
||||
console.error('Error fetching session token count:', err);
|
||||
}
|
||||
};
|
||||
if (chat.id) {
|
||||
fetchSessionTokens();
|
||||
}
|
||||
}, [chat.id, messages]);
|
||||
|
||||
// Update token counts when sessionMetadata changes from the message stream
|
||||
useEffect(() => {
|
||||
console.log('Session metadata received:', sessionMetadata);
|
||||
if (sessionMetadata) {
|
||||
setSessionTokenCount(sessionMetadata.totalTokens || 0);
|
||||
setSessionInputTokens(sessionMetadata.accumulatedInputTokens || 0);
|
||||
setSessionOutputTokens(sessionMetadata.accumulatedOutputTokens || 0);
|
||||
}
|
||||
}, [sessionMetadata]);
|
||||
|
||||
// Handle submit
|
||||
const handleSubmit = useCallback(
|
||||
(combinedTextFromInput: string, onSummaryReset?: () => void) => {
|
||||
if (combinedTextFromInput.trim()) {
|
||||
window.electron.startPowerSaveBlocker();
|
||||
setLastInteractionTime(Date.now());
|
||||
|
||||
const userMessage = createUserMessage(combinedTextFromInput.trim());
|
||||
|
||||
if (onSummaryReset) {
|
||||
onSummaryReset();
|
||||
setTimeout(() => {
|
||||
append(userMessage);
|
||||
}, 150);
|
||||
} else {
|
||||
append(userMessage);
|
||||
}
|
||||
} else {
|
||||
// If nothing was actually submitted (e.g. empty input and no images pasted)
|
||||
window.electron.stopPowerSaveBlocker();
|
||||
}
|
||||
},
|
||||
[append]
|
||||
);
|
||||
|
||||
// Handle stopping the message stream
|
||||
const onStopGoose = useCallback(() => {
|
||||
stop();
|
||||
setLastInteractionTime(Date.now());
|
||||
window.electron.stopPowerSaveBlocker();
|
||||
|
||||
// Handle stopping the message stream
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
|
||||
// Check if there are any messages before proceeding
|
||||
if (!lastMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
// check if the last user message has any tool response(s)
|
||||
const isToolResponse = lastMessage.content.some(
|
||||
(content): content is ToolResponseMessageContent => content.type == 'toolResponse'
|
||||
);
|
||||
|
||||
// isUserMessage also checks if the message is a toolConfirmationRequest
|
||||
// check if the last message is a real user's message
|
||||
if (lastMessage && isUserMessage(lastMessage) && !isToolResponse) {
|
||||
// Get the text content from the last message before removing it
|
||||
const textContent = lastMessage.content.find((c): c is TextContent => c.type === 'text');
|
||||
const textValue = textContent?.text || '';
|
||||
|
||||
// Set the text back to the input field
|
||||
_setInput(textValue);
|
||||
|
||||
// Remove the last user message if it's the most recent one
|
||||
if (messages.length > 1) {
|
||||
setMessages(messages.slice(0, -1));
|
||||
} else {
|
||||
setMessages([]);
|
||||
}
|
||||
} else if (!isUserMessage(lastMessage)) {
|
||||
// the last message was an assistant message
|
||||
// check if we have any tool requests or tool confirmation requests
|
||||
const toolRequests: [string, ToolCallResult<ToolCall>][] = lastMessage.content
|
||||
.filter(
|
||||
(content): content is ToolRequestMessageContent | ToolConfirmationRequestMessageContent =>
|
||||
content.type === 'toolRequest' || content.type === 'toolConfirmationRequest'
|
||||
)
|
||||
.map((content) => {
|
||||
if (content.type === 'toolRequest') {
|
||||
return [content.id, content.toolCall];
|
||||
} else {
|
||||
// extract tool call from confirmation
|
||||
const toolCall: ToolCallResult<ToolCall> = {
|
||||
status: 'success',
|
||||
value: {
|
||||
name: content.toolName,
|
||||
arguments: content.arguments,
|
||||
},
|
||||
};
|
||||
return [content.id, toolCall];
|
||||
}
|
||||
});
|
||||
|
||||
if (toolRequests.length !== 0) {
|
||||
// This means we were interrupted during a tool request
|
||||
// Create tool responses for all interrupted tool requests
|
||||
|
||||
let responseMessage: Message = {
|
||||
display: true,
|
||||
sendToLLM: true,
|
||||
role: 'user',
|
||||
created: Date.now(),
|
||||
content: [],
|
||||
};
|
||||
|
||||
const notification = 'Interrupted by the user to make a correction';
|
||||
|
||||
// generate a response saying it was interrupted for each tool request
|
||||
for (const [reqId, _] of toolRequests) {
|
||||
const toolResponse: ToolResponseMessageContent = {
|
||||
type: 'toolResponse',
|
||||
id: reqId,
|
||||
toolResult: {
|
||||
status: 'error',
|
||||
error: notification,
|
||||
},
|
||||
};
|
||||
|
||||
responseMessage.content.push(toolResponse);
|
||||
}
|
||||
// Use an immutable update to add the response message to the messages array
|
||||
setMessages([...messages, responseMessage]);
|
||||
}
|
||||
}
|
||||
}, [stop, messages, _setInput, setMessages]);
|
||||
|
||||
// Filter out standalone tool response messages for rendering
|
||||
const filteredMessages = useMemo(() => {
|
||||
return [...ancestorMessages, ...messages].filter((message) => {
|
||||
// Only filter out when display is explicitly false
|
||||
if (message.display === false) return false;
|
||||
|
||||
// Keep all assistant messages and user messages that aren't just tool responses
|
||||
if (message.role === 'assistant') return true;
|
||||
|
||||
// For user messages, check if they're only tool responses
|
||||
if (message.role === 'user') {
|
||||
const hasOnlyToolResponses = message.content.every((c) => c.type === 'toolResponse');
|
||||
const hasTextContent = message.content.some((c) => c.type === 'text');
|
||||
const hasToolConfirmation = message.content.every(
|
||||
(c) => c.type === 'toolConfirmationRequest'
|
||||
);
|
||||
|
||||
// Keep the message if it has text content or tool confirmation or is not just tool responses
|
||||
return hasTextContent || !hasOnlyToolResponses || hasToolConfirmation;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [ancestorMessages, messages]);
|
||||
|
||||
// Generate command history from filtered messages
|
||||
const commandHistory = useMemo(() => {
|
||||
return filteredMessages
|
||||
.reduce<string[]>((history, message) => {
|
||||
if (isUserMessage(message)) {
|
||||
const textContent = message.content.find((c): c is TextContent => c.type === 'text');
|
||||
const text = textContent?.text?.trim();
|
||||
if (text) {
|
||||
history.push(text);
|
||||
}
|
||||
}
|
||||
return history;
|
||||
}, [])
|
||||
.reverse();
|
||||
}, [filteredMessages]);
|
||||
|
||||
// Process tool call notifications
|
||||
const toolCallNotifications = useMemo(() => {
|
||||
return notifications.reduce((map, item) => {
|
||||
const key = item.request_id;
|
||||
if (!map.has(key)) {
|
||||
map.set(key, []);
|
||||
}
|
||||
map.get(key).push(item);
|
||||
return map;
|
||||
}, new Map());
|
||||
}, [notifications]);
|
||||
|
||||
return {
|
||||
// Core message data
|
||||
messages,
|
||||
filteredMessages,
|
||||
ancestorMessages,
|
||||
setAncestorMessages,
|
||||
|
||||
// Message stream controls
|
||||
append,
|
||||
stop,
|
||||
isLoading,
|
||||
error,
|
||||
setMessages,
|
||||
|
||||
// Input controls
|
||||
input: _input,
|
||||
setInput: _setInput,
|
||||
handleInputChange: _handleInputChange,
|
||||
|
||||
// Event handlers
|
||||
handleSubmit,
|
||||
onStopGoose,
|
||||
|
||||
// Token and session data
|
||||
sessionTokenCount,
|
||||
sessionInputTokens,
|
||||
sessionOutputTokens,
|
||||
localInputTokens,
|
||||
localOutputTokens,
|
||||
|
||||
// UI helpers
|
||||
commandHistory,
|
||||
toolCallNotifications,
|
||||
|
||||
// Stream utilities
|
||||
updateMessageStreamBody,
|
||||
sessionMetadata,
|
||||
|
||||
// Utilities
|
||||
isUserMessage,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useModelAndProvider } from '../components/ModelAndProviderContext';
|
||||
import { getCostForModel } from '../utils/costDatabase';
|
||||
import { SessionMetadata } from './useMessageStream';
|
||||
|
||||
interface UseCostTrackingProps {
|
||||
sessionInputTokens: number;
|
||||
sessionOutputTokens: number;
|
||||
localInputTokens: number;
|
||||
localOutputTokens: number;
|
||||
sessionMetadata?: SessionMetadata | null;
|
||||
}
|
||||
|
||||
export const useCostTracking = ({
|
||||
sessionInputTokens,
|
||||
sessionOutputTokens,
|
||||
localInputTokens,
|
||||
localOutputTokens,
|
||||
sessionMetadata,
|
||||
}: UseCostTrackingProps) => {
|
||||
const [sessionCosts, setSessionCosts] = useState<{
|
||||
[key: string]: {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalCost: number;
|
||||
};
|
||||
}>({});
|
||||
|
||||
const { currentModel, currentProvider } = useModelAndProvider();
|
||||
const prevModelRef = useRef<string | undefined>();
|
||||
const prevProviderRef = useRef<string | undefined>();
|
||||
|
||||
// Handle model changes and accumulate costs
|
||||
useEffect(() => {
|
||||
if (
|
||||
prevModelRef.current !== undefined &&
|
||||
prevProviderRef.current !== undefined &&
|
||||
(prevModelRef.current !== currentModel || prevProviderRef.current !== currentProvider)
|
||||
) {
|
||||
// Model/provider has changed, save the costs for the previous model
|
||||
const prevKey = `${prevProviderRef.current}/${prevModelRef.current}`;
|
||||
|
||||
// Get pricing info for the previous model
|
||||
const prevCostInfo = getCostForModel(prevProviderRef.current, prevModelRef.current);
|
||||
|
||||
if (prevCostInfo) {
|
||||
const prevInputCost =
|
||||
(sessionInputTokens || localInputTokens) * (prevCostInfo.input_token_cost || 0);
|
||||
const prevOutputCost =
|
||||
(sessionOutputTokens || localOutputTokens) * (prevCostInfo.output_token_cost || 0);
|
||||
const prevTotalCost = prevInputCost + prevOutputCost;
|
||||
|
||||
// Save the accumulated costs for this model
|
||||
setSessionCosts((prev) => ({
|
||||
...prev,
|
||||
[prevKey]: {
|
||||
inputTokens: sessionInputTokens || localInputTokens,
|
||||
outputTokens: sessionOutputTokens || localOutputTokens,
|
||||
totalCost: prevTotalCost,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
console.log(
|
||||
'Model changed from',
|
||||
`${prevProviderRef.current}/${prevModelRef.current}`,
|
||||
'to',
|
||||
`${currentProvider}/${currentModel}`,
|
||||
'- saved costs and restored session token counters'
|
||||
);
|
||||
}
|
||||
|
||||
prevModelRef.current = currentModel || undefined;
|
||||
prevProviderRef.current = currentProvider || undefined;
|
||||
}, [
|
||||
currentModel,
|
||||
currentProvider,
|
||||
sessionInputTokens,
|
||||
sessionOutputTokens,
|
||||
localInputTokens,
|
||||
localOutputTokens,
|
||||
sessionMetadata,
|
||||
]);
|
||||
|
||||
return {
|
||||
sessionCosts,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
export const useFileDrop = () => {
|
||||
const [droppedFiles, setDroppedFiles] = useState<string[]>([]);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
const paths: string[] = [];
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
paths.push(window.electron.getPathForFile(files[i]));
|
||||
}
|
||||
setDroppedFiles(paths);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
droppedFiles,
|
||||
setDroppedFiles,
|
||||
handleDrop,
|
||||
handleDragOver,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { createRecipe, Recipe } from '../recipe';
|
||||
import { Message } from '../types/message';
|
||||
|
||||
export const useRecipeManager = (messages: Message[]) => {
|
||||
const [isGeneratingRecipe, setIsGeneratingRecipe] = useState(false);
|
||||
|
||||
// Get recipeConfig directly from appConfig
|
||||
const recipeConfig = useMemo(() => {
|
||||
return window.appConfig.get('recipeConfig') as Recipe | null;
|
||||
}, []);
|
||||
|
||||
// Pre-fill input with recipe prompt instead of auto-sending it
|
||||
const initialPrompt = useMemo(() => {
|
||||
return recipeConfig?.prompt || '';
|
||||
}, [recipeConfig?.prompt]);
|
||||
|
||||
// Listen for make-agent-from-chat event
|
||||
useEffect(() => {
|
||||
const handleMakeAgent = async () => {
|
||||
window.electron.logInfo('Making recipe from chat...');
|
||||
setIsGeneratingRecipe(true);
|
||||
|
||||
try {
|
||||
// Create recipe directly from chat messages
|
||||
const createRecipeRequest = {
|
||||
messages: messages,
|
||||
title: '',
|
||||
description: '',
|
||||
};
|
||||
|
||||
const response = await createRecipe(createRecipeRequest);
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(`Failed to create recipe: ${response.error}`);
|
||||
}
|
||||
|
||||
window.electron.logInfo('Created recipe:');
|
||||
window.electron.logInfo(JSON.stringify(response.recipe, null, 2));
|
||||
|
||||
// First, verify the recipe data
|
||||
if (!response.recipe) {
|
||||
throw new Error('No recipe data received');
|
||||
}
|
||||
|
||||
// Create a new window for the recipe editor
|
||||
console.log('Opening recipe editor with config:', response.recipe);
|
||||
const recipeConfig = {
|
||||
id: response.recipe.title || 'untitled',
|
||||
title: response.recipe.title || 'Untitled Recipe',
|
||||
description: response.recipe.description || '',
|
||||
instructions: response.recipe.instructions || '',
|
||||
activities: response.recipe.activities || [],
|
||||
prompt: response.recipe.prompt || '',
|
||||
};
|
||||
window.electron.createChatWindow(
|
||||
undefined, // query
|
||||
undefined, // dir
|
||||
undefined, // version
|
||||
undefined, // resumeSessionId
|
||||
recipeConfig, // recipe config
|
||||
'recipeEditor' // view type
|
||||
);
|
||||
|
||||
window.electron.logInfo('Opening recipe editor window');
|
||||
} catch (error) {
|
||||
window.electron.logInfo('Failed to create recipe:');
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
window.electron.logInfo(errorMessage);
|
||||
} finally {
|
||||
setIsGeneratingRecipe(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('make-agent-from-chat', handleMakeAgent);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('make-agent-from-chat', handleMakeAgent);
|
||||
};
|
||||
}, [messages]);
|
||||
|
||||
return {
|
||||
recipeConfig,
|
||||
initialPrompt,
|
||||
isGeneratingRecipe,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useEffect } from 'react';
|
||||
import { generateSessionId } from '../sessions';
|
||||
import { ChatType } from '../components/hub';
|
||||
|
||||
interface UseSessionContinuationProps {
|
||||
chat: ChatType;
|
||||
setChat: (chat: ChatType) => void;
|
||||
summarizedThread: unknown[];
|
||||
updateMessageStreamBody?: (body: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export const useSessionContinuation = ({
|
||||
chat,
|
||||
setChat,
|
||||
summarizedThread,
|
||||
updateMessageStreamBody,
|
||||
}: UseSessionContinuationProps) => {
|
||||
// Handle session continuation when there's a summarized thread
|
||||
useEffect(() => {
|
||||
// If we're in a continuation session, update the chat ID
|
||||
if (summarizedThread.length > 0) {
|
||||
const newSessionId = generateSessionId();
|
||||
|
||||
// Update the session ID in the chat object
|
||||
setChat({
|
||||
...chat,
|
||||
id: newSessionId!,
|
||||
title: `Continued from ${chat.id}`,
|
||||
messageHistoryIndex: summarizedThread.length,
|
||||
});
|
||||
|
||||
// Update the body used by useMessageStream to send future messages to the new session
|
||||
if (summarizedThread.length > 0 && updateMessageStreamBody) {
|
||||
updateMessageStreamBody({
|
||||
session_id: newSessionId,
|
||||
session_working_dir: window.appConfig.get('GOOSE_WORKING_DIR'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// only update if summarizedThread length changes from 0 -> 1+
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
summarizedThread.length > 0,
|
||||
]);
|
||||
};
|
||||
Reference in New Issue
Block a user