diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx new file mode 100644 index 0000000000..75bb8e5d9e --- /dev/null +++ b/ui/desktop/src/components/BaseChat.tsx @@ -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; + customMainLayoutProps?: Record; +} + +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 ( +
+ + {/* Loader when generating recipe */} + {isGeneratingRecipe && } + + {/* Custom header */} + {renderHeader && renderHeader()} + +
+ {/* Custom content before messages */} + {renderBeforeMessages && renderBeforeMessages()} + + {/* Recipe agent header */} + {recipeConfig?.title && messages.length > 0 && ( + { + console.log('Change profile clicked'); + }} + /> + )} + + {/* Messages */} + {messages.length > 0 && ( + <> + + {filteredMessages.map((message, index) => { + const isUser = isUserMessage(message); + + return ( +
+ {isUser ? ( + <> + {hasContextHandlerContent(message) ? ( + + ) : ( + + )} + + ) : ( + <> + {hasContextHandlerContent(message) ? ( + + ) : ( + { + const updatedMessages = [...messages, newMessage]; + setMessages(updatedMessages); + }} + toolCallNotifications={toolCallNotifications} + /> + )} + + )} +
+ ); + })} +
+ + {error && ( +
+
+ {error.message || 'Honk! Goose experienced an error while responding'} +
+
{ + // 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 +
+
+ )} +
+ + )} + + {/* Custom content after messages */} + {renderAfterMessages && renderAfterMessages()} +
+ +
+ +
+ + + { + updateSummary(editedContent); + closeSummaryModal(); + }} + summaryContent={summaryContent} + /> +
+ ); +} + +export default function BaseChat(props: BaseChatProps) { + return ( + + + + ); +} diff --git a/ui/desktop/src/components/hub.tsx b/ui/desktop/src/components/hub.tsx index 1dabf23883..40bce688b1 100644 --- a/ui/desktop/src/components/hub.tsx +++ b/ui/desktop/src/components/hub.tsx @@ -1,46 +1,18 @@ -import React, { useEffect, useRef, useState, useMemo, useContext, createContext } from 'react'; +import React, { useState, useEffect, useContext, createContext } from 'react'; import { useLocation } from 'react-router-dom'; -import { getApiUrl } from '../config'; import FlappyGoose from './FlappyGoose'; -import GooseMessage from './GooseMessage'; import { type View, ViewOptions } from '../App'; -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 { useModelAndProvider } from './ModelAndProviderContext'; -import { getCostForModel } from '../utils/costDatabase'; -import { Recipe } from '../recipe'; -import { - ChatContextManagerProvider, - useChatContextManager, -} from './context_management/ChatContextManager'; -import { ContextHandler } from './context_management/ContextHandler'; -import { useChatContext } from '../contexts/ChatContext'; -import { - Message, - createUserMessage, - ToolCall, - ToolCallResult, - ToolRequestMessageContent, - ToolResponseMessageContent, - ToolConfirmationRequestMessageContent, - TextContent, - getTextContent, -} from '../types/message'; +import { Message } from '../types/message'; import { SessionInsights } from './sessions/SessionsInsights'; import { Button } from './ui/button'; import { Idea } from './icons'; import { Tooltip, TooltipContent, TooltipTrigger } from './ui/Tooltip'; import { Bot, Save, Send } from 'lucide-react'; -import { MainPanelLayout } from './Layout/MainPanelLayout'; -import ChatInput from './ChatInput'; +import { useChatContext } from '../contexts/ChatContext'; +import BaseChat from './BaseChat'; +import { SearchView } from './conversation/SearchView'; +import { Recipe } from '../recipe'; +import 'react-toastify/dist/ReactToastify.css'; // Context for sharing current model info const CurrentModelContext = createContext<{ model: string; mode: string } | null>(null); @@ -53,19 +25,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 Hub({ - readyForAutoUserPrompt, chat, setChat, setView, @@ -76,80 +36,15 @@ export default function Hub({ setChat: (chat: ChatType) => void; setView: (view: View, viewOptions?: ViewOptions) => void; setIsGoosehintsModalOpen: (isOpen: boolean) => void; -}) { - return ( - - - - ); -} - -function HubContent({ - chat, - setChat, - setView, - setIsGoosehintsModalOpen, -}: { - readyForAutoUserPrompt: boolean; - chat: ChatType; - setChat: (chat: ChatType) => void; - setView: (view: View, viewOptions?: ViewOptions) => void; - setIsGoosehintsModalOpen: (isOpen: boolean) => void; -}) { - return ( - - ); -} - -function HubContentWithSidebar({ - chat, - setChat, - setView, - setIsGoosehintsModalOpen, -}: { - chat: ChatType; - setChat: (chat: ChatType) => void; - setView: (view: View, viewOptions?: ViewOptions) => void; - setIsGoosehintsModalOpen: (isOpen: boolean) => void; }) { const location = useLocation(); const { resetChat } = useChatContext(); - - const [lastInteractionTime, setLastInteractionTime] = useState(Date.now()); const [showGame, setShowGame] = useState(false); - const [isGeneratingRecipe, setIsGeneratingRecipe] = useState(false); - const [sessionTokenCount, setSessionTokenCount] = useState(0); - const [ancestorMessages, setAncestorMessages] = useState([]); - const [droppedFiles, setDroppedFiles] = useState([]); - const [isInPairMode, setIsInPairMode] = useState(false); - const [sessionInputTokens, setSessionInputTokens] = useState(0); - const [sessionOutputTokens, setSessionOutputTokens] = useState(0); - const [localInputTokens, setLocalInputTokens] = useState(0); - const [localOutputTokens, setLocalOutputTokens] = useState(0); - const [sessionCosts, setSessionCosts] = useState<{ - [key: string]: { - inputTokens: number; - outputTokens: number; - totalCost: number; - }; - }>({}); - const { currentModel, currentProvider } = useModelAndProvider(); - const prevModelRef = useRef(); - const prevProviderRef = useRef(); - - // New state to force showing insights when navigating to hub const [forceShowInsights, setForceShowInsights] = useState(true); + const [isInPairMode, setIsInPairMode] = useState(false); + + // Get recipeConfig directly from appConfig + const recipeConfig = window.appConfig.get('recipeConfig') as Recipe | null; // Effect to detect direct navigation to the hub page useEffect(() => { @@ -164,255 +59,6 @@ function HubContentWithSidebar({ } }, [location.pathname, chat.messages.length]); - // Get disableAnimation from location state - const disableAnimation = location.state?.disableAnimation || false; - - const scrollRef = useRef(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; - - const { - messages, - append, - 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(); - - 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( - 'Hub: Message stream finished for new session, emitting message-stream-finished event' - ); - // Emit event to trigger session refresh - window.dispatchEvent(new CustomEvent('message-stream-finished')); - } - }, - }); - - // 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]); - - // 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 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(); - } - }; - // Handle submit for the initial ChatInput (when no messages) const handleInitialSubmit = (e: React.FormEvent) => { const customEvent = e as unknown as CustomEvent; @@ -421,573 +67,183 @@ function HubContentWithSidebar({ if (combinedTextFromInput.trim()) { // Navigate to pair page with animation disabled setView('pair', { disableAnimation: true }); - - // Create and submit the user message - const userMessage = createUserMessage(combinedTextFromInput.trim()); - append(userMessage); - - // Scroll to bottom after a short delay to ensure the message is rendered - setTimeout(() => { - if (scrollRef.current?.scrollToBottom) { - scrollRef.current.scrollToBottom(); - } - }, 100); } }; - if (error) { - console.log('Error:', error); - } + // Custom header for Hub + const renderHeader = () => ( +
+
+ {chat.messages.length > 0 && ( + <> + {setIsGoosehintsModalOpen && ( + + + + + +

Customize instructions

+
+
+ )} - const onStopGoose = () => { - stop(); - setLastInteractionTime(Date.now()); - window.electron.stopPowerSaveBlocker(); + {recipeConfig ? ( + <> + + + + + +

View the recipe you're using

+
+
- // Handle stopping the message stream - const lastMessage = messages[messages.length - 1]; + + + + + +

Save this recipe for reuse

+
+
+ + ) : ( + + + + + +

Make a custom agent you can share or reuse

+
+
+ )} + + )} +
+
+ ); - // Check if there are any messages before proceeding - if (!lastMessage) { - return; - } + // Custom content before messages + const renderBeforeMessages = () => ( + <> + {/* Session Insights - always show on hub page regardless of message count */} + {(chat.messages.length === 0 || forceShowInsights) && !isInPairMode && } - // check if the last user message has any tool response(s) - const isToolResponse = lastMessage.content.some( - (content): content is ToolResponseMessageContent => content.type == 'toolResponse' + {/* Empty search view when no messages or insights forced */} + {(chat.messages.length === 0 || forceShowInsights) && !isInPairMode && ( + {/* Empty search view when no messages or insights forced */} + )} + + ); + + // Custom chat input props for Hub-specific behavior + const customChatInputProps = { + handleSubmit: + forceShowInsights || (chat.messages.length === 0 && !isInPairMode) + ? handleInitialSubmit + : undefined, + messages: forceShowInsights ? [] : undefined, + }; + + // Show session continuation UI when there's an active session but insights are shown + if (forceShowInsights && chat.messages.length > 0) { + return ( +
+
+
+
+
+

You have an active session

+

+ Would you like to continue or start fresh? +

+
+
+ + +
+
+
+
+ {showGame && setShowGame(false)} />} +
); - - // 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][] = 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 = { - 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((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); - 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 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, - }, - })); - } - - // Restore token counters from session metadata instead of resetting to 0 - // This preserves the accumulated session tokens when switching models - // and ensures cost tracking remains accurate across model changes - if (sessionMetadata) { - // Use Math.max to ensure non-negative values and handle potential data issues - setSessionTokenCount(Math.max(0, sessionMetadata.totalTokens || 0)); - setSessionInputTokens(Math.max(0, sessionMetadata.accumulatedInputTokens || 0)); - setSessionOutputTokens(Math.max(0, sessionMetadata.accumulatedOutputTokens || 0)); - } else { - // Fallback: if no session metadata, preserve current session tokens instead of resetting - // This handles edge cases where metadata might not be available yet - console.warn( - 'No session metadata available during model change, preserving current tokens' - ); - } - // Only reset local token estimation counters since they're model-specific - setLocalInputTokens(0); - setLocalOutputTokens(0); - - 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, - ]); - - const handleDrop = (e: React.DragEvent) => { - 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) => { - 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()); + } return (
- - {/* Loader when generating recipe */} - {isGeneratingRecipe && } - -
-
- {messages.length > 0 && ( - <> - {setIsGoosehintsModalOpen && ( - - - - - -

Customize instructions

-
-
- )} - - {recipeConfig ? ( - <> - - - - - -

View the recipe you're using

-
-
- - - - - - -

Save this recipe for reuse

-
-
- - ) : ( - - - - - -

Make a custom agent you can share or reuse

-
-
- )} - - )} -
-
- -
- {/* Session Insights - always show on hub page regardless of message count */} - {(messages.length === 0 || forceShowInsights) && !isInPairMode && } - - {/* Only show agent header and messages if insights are not forced */} - {!forceShowInsights && recipeConfig?.title && messages.length > 0 && ( - { - // Handle profile change - console.log('Change profile clicked'); - }} - /> - )} - {(messages.length === 0 || forceShowInsights) && !isInPairMode ? ( - <> - {/* Empty search view when no messages or insights forced */} - - ) : ( - <> - - {filteredMessages.map((message, index) => { - const isUser = isUserMessage(message); - // const nextMessage = filteredMessages[index + 1]; - - return ( -
- {isUser ? ( - <> - {hasContextHandlerContent(message) ? ( - - ) : ( - - )} - - ) : ( - <> - {/* Only render GooseMessage if it's not a message invoking some context management */} - {hasContextHandlerContent(message) ? ( - - ) : ( - { - const updatedMessages = [...messages, newMessage]; - setMessages(updatedMessages); - }} - toolCallNotifications={toolCallNotifications} - /> - )} - - )} -
- ); - })} -
- - {error && ( -
-
- {error.message || 'Honk! Goose experienced an error while responding'} -
-
{ - // 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 -
-
- )} -
- - )} -
-
- {/* Show session continuation UI when there's an active session but insights are shown */} - {forceShowInsights && messages.length > 0 ? ( -
-
-
-

You have an active session

-

- Would you like to continue or start fresh? -

-
-
- - -
-
-
- ) : ( - /* ChatInput for all conversations */ - - )} -
- - - {showGame && setShowGame(false)} />} - - { - updateSummary(editedContent); - closeSummaryModal(); - }} - summaryContent={summaryContent} + + {showGame && setShowGame(false)} />}
); } diff --git a/ui/desktop/src/components/pair.tsx b/ui/desktop/src/components/pair.tsx index 362cd67418..e224023bf3 100644 --- a/ui/desktop/src/components/pair.tsx +++ b/ui/desktop/src/components/pair.tsx @@ -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 ( - - - - ); -} - -function PairContent({ - chat, - setChat, - setView, -}: { - readyForAutoUserPrompt: boolean; - chat: ChatType; - setChat: (chat: ChatType) => void; - setView: (view: View, viewOptions?: ViewOptions) => void; - setIsGoosehintsModalOpen: (isOpen: boolean) => void; -}) { - return ; -} - -function PairContentWithSidebar({ - chat, - setChat, - setView, -}: { - chat: ChatType; - setChat: (chat: ChatType) => void; - setView: (view: View, viewOptions?: ViewOptions) => void; }) { const location = useLocation(); - const [lastInteractionTime, setLastInteractionTime] = useState(Date.now()); - const [isGeneratingRecipe, setIsGeneratingRecipe] = useState(false); - const [sessionTokenCount, setSessionTokenCount] = useState(0); - const [ancestorMessages, setAncestorMessages] = useState([]); - const [droppedFiles, setDroppedFiles] = useState([]); const [hasProcessedInitialInput, setHasProcessedInitialInput] = useState(false); - // Get disableAnimation from location state - const disableAnimation = location.state?.disableAnimation || false; - - const scrollRef = useRef(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][] = 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 = { - 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((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) => { - 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) => { - 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 ( -
- - {/* Loader when generating recipe */} - {isGeneratingRecipe && } - -
- {recipeConfig?.title && messages.length > 0 && ( - { - // Handle profile change - console.log('Change profile clicked'); - }} - /> - )} - - {messages.length > 0 && ( - <> - - {filteredMessages.map((message, index) => { - const isUser = isUserMessage(message); - // const nextMessage = filteredMessages[index + 1]; - - return ( -
- {isUser ? ( - <> - {hasContextHandlerContent(message) ? ( - - ) : ( - - )} - - ) : ( - <> - {/* Only render GooseMessage if it's not a message invoking some context management */} - {hasContextHandlerContent(message) ? ( - - ) : ( - { - const updatedMessages = [...messages, newMessage]; - setMessages(updatedMessages); - }} - toolCallNotifications={toolCallNotifications} - /> - )} - - )} -
- ); - })} -
- - {error && ( -
-
- {error.message || 'Honk! Goose experienced an error while responding'} -
-
{ - // 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 -
-
- )} -
- - )} -
-
-
{isLoading && }
- -
- - - { - updateSummary(editedContent); - closeSummaryModal(); - }} - summaryContent={summaryContent} - /> + // Custom content before messages for loading state + const renderBeforeMessages = () => ( +
+ {/* Loading indicator will be handled by BaseChat's isLoading state */}
); + + // 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 ( + + ); } diff --git a/ui/desktop/src/hooks/useChatEngine.ts b/ui/desktop/src/hooks/useChatEngine.ts new file mode 100644 index 0000000000..1bb86c57a6 --- /dev/null +++ b/ui/desktop/src/hooks/useChatEngine.ts @@ -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(Date.now()); + const [sessionTokenCount, setSessionTokenCount] = useState(0); + const [ancestorMessages, setAncestorMessages] = useState([]); + const [sessionInputTokens, setSessionInputTokens] = useState(0); + const [sessionOutputTokens, setSessionOutputTokens] = useState(0); + const [localInputTokens, setLocalInputTokens] = useState(0); + const [localOutputTokens, setLocalOutputTokens] = useState(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][] = 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 = { + 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((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, + }; +}; diff --git a/ui/desktop/src/hooks/useCostTracking.ts b/ui/desktop/src/hooks/useCostTracking.ts new file mode 100644 index 0000000000..e72fc991ff --- /dev/null +++ b/ui/desktop/src/hooks/useCostTracking.ts @@ -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(); + const prevProviderRef = useRef(); + + // 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, + }; +}; diff --git a/ui/desktop/src/hooks/useFileDrop.ts b/ui/desktop/src/hooks/useFileDrop.ts new file mode 100644 index 0000000000..6431f3d0f9 --- /dev/null +++ b/ui/desktop/src/hooks/useFileDrop.ts @@ -0,0 +1,28 @@ +import { useCallback, useState } from 'react'; + +export const useFileDrop = () => { + const [droppedFiles, setDroppedFiles] = useState([]); + + const handleDrop = useCallback((e: React.DragEvent) => { + 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) => { + e.preventDefault(); + }, []); + + return { + droppedFiles, + setDroppedFiles, + handleDrop, + handleDragOver, + }; +}; diff --git a/ui/desktop/src/hooks/useRecipeManager.ts b/ui/desktop/src/hooks/useRecipeManager.ts new file mode 100644 index 0000000000..cfcc8b1fd0 --- /dev/null +++ b/ui/desktop/src/hooks/useRecipeManager.ts @@ -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, + }; +}; diff --git a/ui/desktop/src/hooks/useSessionContinuation.ts b/ui/desktop/src/hooks/useSessionContinuation.ts new file mode 100644 index 0000000000..01242948ec --- /dev/null +++ b/ui/desktop/src/hooks/useSessionContinuation.ts @@ -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) => 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, + ]); +};