import React, { useEffect, useRef, useState, useMemo } from 'react'; import { getApiUrl } from '../config'; import { generateSessionId } from '../sessions'; import BottomMenu from './BottomMenu'; import FlappyGoose from './FlappyGoose'; import GooseMessage from './GooseMessage'; import Input from './Input'; import { type View } from '../App'; import LoadingGoose from './LoadingGoose'; import MoreMenu from './MoreMenu'; import { Card } from './ui/card'; import { ScrollArea, ScrollAreaHandle } from './ui/scroll-area'; import UserMessage from './UserMessage'; import { askAi } from '../utils/askAI'; import Splash from './Splash'; import 'react-toastify/dist/ReactToastify.css'; import { useMessageStream } from '../hooks/useMessageStream'; import { Message, createUserMessage, getTextContent } from '../types/message'; export interface ChatType { id: number; title: string; messages: Message[]; } export default function ChatView({ setView, viewOptions, setIsGoosehintsModalOpen, }: { setView: (view: View, viewOptions?: Record) => void; viewOptions?: Record; setIsGoosehintsModalOpen: (isOpen: boolean) => void; }) { // Check if we're resuming a session const resumedSession = viewOptions?.resumedSession; // Generate or retrieve session ID const [sessionId] = useState(() => { // If resuming a session, use that session ID if (resumedSession?.session_id) { // Store the resumed session ID in sessionStorage window.sessionStorage.setItem('goose-session-id', resumedSession.session_id); return resumedSession.session_id; } // For a new chat, generate a new session ID const newId = generateSessionId(); window.sessionStorage.setItem('goose-session-id', newId); return newId; }); const [chat, setChat] = useState(() => { // If resuming a session, convert the session messages to our format if (resumedSession) { try { // Convert the resumed session messages to the expected format const convertedMessages = resumedSession.messages.map((msg): Message => { return { id: `${msg.role}-${msg.created}`, role: msg.role, created: msg.created, content: msg.content, }; }); return { id: Date.now(), title: resumedSession.metadata?.description || `ID: ${resumedSession.session_id}`, messages: convertedMessages, }; } catch (e) { console.error('Failed to parse resumed session:', e); } } // Try to load saved chat from sessionStorage const savedChat = window.sessionStorage.getItem(`goose-chat-${sessionId}`); if (savedChat) { try { return JSON.parse(savedChat); } catch (e) { console.error('Failed to parse saved chat:', e); } } // Return default chat if no saved chat exists return { id: Date.now(), title: 'Chat 1', messages: [], }; }); const [messageMetadata, setMessageMetadata] = useState>({}); const [hasMessages, setHasMessages] = useState(false); const [lastInteractionTime, setLastInteractionTime] = useState(Date.now()); const [showGame, setShowGame] = useState(false); const scrollRef = useRef(null); const { messages, append, stop, isLoading, error, setMessages, input: _input, setInput: _setInput, handleInputChange: _handleInputChange, handleSubmit: _submitMessage, } = useMessageStream({ api: getApiUrl('/reply'), initialMessages: chat?.messages || [], body: { session_id: sessionId }, onFinish: async (message, _reason) => { window.electron.stopPowerSaveBlocker(); // Disabled askAi calls to save costs // const messageText = getTextContent(message); // const fetchResponses = await askAi(messageText); // setMessageMetadata((prev) => ({ ...prev, [message.id || '']: fetchResponses })); 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.', }); } }, onToolCall: (toolCall) => { // Handle tool calls if needed console.log('Tool call received:', toolCall); // Implement tool call handling logic here }, }); // Update chat messages when they change and save to sessionStorage useEffect(() => { setChat((prevChat) => { const updatedChat = { ...prevChat, messages }; // Save to sessionStorage try { window.sessionStorage.setItem(`goose-chat-${sessionId}`, JSON.stringify(updatedChat)); } catch (e) { console.error('Failed to save chat to sessionStorage:', e); } return updatedChat; }); }, [messages, sessionId]); useEffect(() => { if (messages.length > 0) { setHasMessages(true); } }, [messages]); // Handle submit const handleSubmit = (e: React.FormEvent) => { window.electron.startPowerSaveBlocker(); const customEvent = e as CustomEvent; const content = customEvent.detail?.value || ''; if (content.trim()) { setLastInteractionTime(Date.now()); 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]; if (lastMessage && lastMessage.role === 'user') { // Remove the last user message if it's the most recent one if (messages.length > 1) { setMessages(messages.slice(0, -1)); } else { setMessages([]); } } // Note: Tool call interruption handling would need to be implemented // differently with the new message format }; // Filter out standalone tool response messages for rendering // They will be shown as part of the tool invocation in the assistant message const filteredMessages = messages.filter((message) => { // 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 isUserMessage = (message: Message) => { if (message.role === 'assistant') { return false; } if (message.content.every((c) => c.type === 'toolConfirmationRequest')) { return false; } 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, isUserMessage]); return (
{messages.length === 0 ? ( append(createUserMessage(text))} /> ) : ( {filteredMessages.map((message, index) => (
{isUserMessage(message) ? ( ) : ( append(createUserMessage(text))} /> )}
))} {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)} />}
); }