import React, { useEffect, useRef, useState, useMemo, useCallback } from 'react'; import { getApiUrl } from '../config'; import FlappyGoose from './FlappyGoose'; import GooseMessage from './GooseMessage'; import ChatInput from './ChatInput'; import { type View, ViewOptions } from '../App'; import LoadingGoose from './LoadingGoose'; import MoreMenuLayout from './more_menu/MoreMenuLayout'; import { Card } from './ui/card'; import { ScrollArea, ScrollAreaHandle } from './ui/scroll-area'; import UserMessage from './UserMessage'; import Splash from './Splash'; 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, } from '../types/message'; export interface ChatType { id: string; title: string; messageHistoryIndex: number; 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 ChatView({ readyForAutoUserPrompt, chat, setChat, setView, setIsGoosehintsModalOpen, }: { readyForAutoUserPrompt: boolean; chat: ChatType; setChat: (chat: ChatType) => void; setView: (view: View, viewOptions?: ViewOptions) => void; setIsGoosehintsModalOpen: (isOpen: boolean) => void; }) { return ( ); } function ChatContent({ readyForAutoUserPrompt, chat, setChat, setView, setIsGoosehintsModalOpen, }: { readyForAutoUserPrompt: boolean; chat: ChatType; setChat: (chat: ChatType) => void; setView: (view: View, viewOptions?: ViewOptions) => void; setIsGoosehintsModalOpen: (isOpen: boolean) => void; }) { const [hasMessages, setHasMessages] = useState(false); 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 scrollRef = useRef(null); const hasSentPromptRef = useRef(false); 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, } = useMessageStream({ api: getApiUrl('/reply'), 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.', }); } }, }); // Wrap append to store messages in global history const append = useCallback( (messageOrString: Message | string) => { const message = typeof messageOrString === 'string' ? createUserMessage(messageOrString) : messageOrString; storeMessageInHistory(message); return originalAppend(message); }, [originalAppend, storeMessageInHistory] ); // 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); window.electron.createChatWindow( undefined, // query undefined, // dir undefined, // version undefined, // resumeSessionId response.recipe, // 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(() => { setChat((prevChat) => { const updatedChat = { ...prevChat, messages }; return updatedChat; }); }, [messages, setChat]); useEffect(() => { if (messages.length > 0) { setHasMessages(true); } }, [messages]); useEffect(() => { const prompt = recipeConfig?.prompt; if (prompt && !hasSentPromptRef.current && readyForAutoUserPrompt) { append(prompt); hasSentPromptRef.current = true; } }, [recipeConfig?.prompt, append, readyForAutoUserPrompt]); // Handle submit const handleSubmit = (e: React.FormEvent) => { window.electron.startPowerSaveBlocker(); const customEvent = e as unknown as CustomEvent; const content = customEvent.detail?.value || ''; if (content.trim()) { setLastInteractionTime(Date.now()); if (summarizedThread.length > 0) { // move current `messages` to `ancestorMessages` and `messages` to `summarizedThread` resetMessagesWithSummary( messages, setMessages, ancestorMessages, setAncestorMessages, summaryContent ); // update the chat with new sessionId // now call the llm setTimeout(() => { append(createUserMessage(content)); if (scrollRef.current?.scrollToBottom) { scrollRef.current.scrollToBottom(); } }, 150); } else { // Normal flow (existing code) append(createUserMessage(content)); if (scrollRef.current?.scrollToBottom) { scrollRef.current.scrollToBottom(); } } } }; 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 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.type === 'text')?.text || ''; // Set the text back to the input field _setInput(textContent); // 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 text = message.content.find((c) => c.type === 'text')?.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); } 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(); }; 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) => (
{isUserMessage(message) ? ( <> {hasContextHandlerContent(message) ? ( ) : ( )} ) : ( <> {/* Only render GooseMessage if it's not a message invoking some context management */} {hasContextHandlerContent(message) ? ( ) : ( { const updatedMessages = [...messages, newMessage]; setMessages(updatedMessages); }} /> )} )}
))}
{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 && }
{showGame && setShowGame(false)} />} { updateSummary(editedContent); closeSummaryModal(); }} summaryContent={summaryContent} />
); }