mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
Clean room implementation of the chat process (#5079)
Co-authored-by: Douwe Osinga <douwe@squareup.com> Co-authored-by: Zane <75694352+zanesq@users.noreply.github.com>
This commit is contained in:
@@ -13,6 +13,12 @@ use utoipa::ToSchema;
|
||||
use crate::conversation::tool_result_serde;
|
||||
use crate::utils::sanitize_unicode_tags;
|
||||
|
||||
#[derive(ToSchema)]
|
||||
pub enum ToolCallResult<T> {
|
||||
Success { value: T },
|
||||
Error { error: String },
|
||||
}
|
||||
|
||||
/// Custom deserializer for MessageContent that sanitizes Unicode Tags in text content
|
||||
fn deserialize_sanitized_content<'de, D>(deserializer: D) -> Result<Vec<MessageContent>, D::Error>
|
||||
where
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
pub use rmcp::model::ErrorData;
|
||||
|
||||
/// Type alias for tool results
|
||||
pub type ToolResult<T> = std::result::Result<T, ErrorData>;
|
||||
pub type ToolResult<T> = Result<T, ErrorData>;
|
||||
|
||||
Generated
-6
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "goose",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
+11
-2
@@ -44,6 +44,7 @@ import {
|
||||
useAgent,
|
||||
} from './hooks/useAgent';
|
||||
import { useNavigation } from './hooks/useNavigation';
|
||||
import Pair2 from './components/Pair2';
|
||||
|
||||
// Route Components
|
||||
const HubRouteWrapper = ({
|
||||
@@ -93,7 +94,16 @@ const PairRouteWrapper = ({
|
||||
|
||||
const resumeSessionId = searchParams.get('resumeSessionId') ?? undefined;
|
||||
|
||||
return (
|
||||
return process.env.ALPHA ? (
|
||||
<Pair2
|
||||
chat={chat}
|
||||
setChat={setChat}
|
||||
setView={setView}
|
||||
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
|
||||
resumeSessionId={resumeSessionId}
|
||||
initialMessage={initialMessage}
|
||||
/>
|
||||
) : (
|
||||
<Pair
|
||||
chat={chat}
|
||||
setChat={setChat}
|
||||
@@ -332,7 +342,6 @@ export function AppInner() {
|
||||
setAgentWaitingMessage,
|
||||
setIsExtensionsLoading,
|
||||
});
|
||||
// Update the chat state with the loaded session to ensure sessionId is available globally
|
||||
setChat(loadedChat);
|
||||
} catch (e) {
|
||||
if (e instanceof NoProviderOrModelError) {
|
||||
|
||||
@@ -61,10 +61,10 @@ import { useChatEngine } from '../hooks/useChatEngine';
|
||||
import { useRecipeManager } from '../hooks/useRecipeManager';
|
||||
import { useFileDrop } from '../hooks/useFileDrop';
|
||||
import { useCostTracking } from '../hooks/useCostTracking';
|
||||
import { Message } from '../types/message';
|
||||
import { ChatState } from '../types/chatState';
|
||||
import { ChatType } from '../types/chat';
|
||||
import { useToolCount } from './alerts/useToolCount';
|
||||
import { Message } from '../api';
|
||||
|
||||
// Context for sharing current model info
|
||||
const CurrentModelContext = createContext<{ model: string; mode: string } | null>(null);
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { SearchView } from './conversation/SearchView';
|
||||
import LoadingGoose from './LoadingGoose';
|
||||
import PopularChatTopics from './PopularChatTopics';
|
||||
import ProgressiveMessageList from './ProgressiveMessageList';
|
||||
import { View, ViewOptions } from '../utils/navigationUtils';
|
||||
import { ContextManagerProvider } from './context_management/ContextManager';
|
||||
import { MainPanelLayout } from './Layout/MainPanelLayout';
|
||||
import ChatInput from './ChatInput';
|
||||
import { ScrollArea, ScrollAreaHandle } from './ui/scroll-area';
|
||||
import { useFileDrop } from '../hooks/useFileDrop';
|
||||
import { Message, Session } from '../api';
|
||||
import { ChatState } from '../types/chatState';
|
||||
import { ChatType } from '../types/chat';
|
||||
import { useIsMobile } from '../hooks/use-mobile';
|
||||
import { useSidebar } from './ui/sidebar';
|
||||
import { cn } from '../utils';
|
||||
import { useChatStream } from '../hooks/useChatStream';
|
||||
import { loadSession } from '../utils/sessionCache';
|
||||
|
||||
interface BaseChatProps {
|
||||
chat: ChatType;
|
||||
setChat: (chat: ChatType) => void;
|
||||
setView: (view: View, viewOptions?: ViewOptions) => void;
|
||||
setIsGoosehintsModalOpen?: (isOpen: boolean) => void;
|
||||
onMessageStreamFinish?: () => void;
|
||||
onMessageSubmit?: (message: string) => void;
|
||||
renderHeader?: () => React.ReactNode;
|
||||
renderBeforeMessages?: () => React.ReactNode;
|
||||
renderAfterMessages?: () => React.ReactNode;
|
||||
customChatInputProps?: Record<string, unknown>;
|
||||
customMainLayoutProps?: Record<string, unknown>;
|
||||
contentClassName?: string;
|
||||
disableSearch?: boolean;
|
||||
showPopularTopics?: boolean;
|
||||
suppressEmptyState?: boolean;
|
||||
autoSubmit?: boolean;
|
||||
resumeSessionId?: string; // Optional session ID to resume on mount
|
||||
}
|
||||
|
||||
function BaseChatContent({
|
||||
chat,
|
||||
setChat,
|
||||
setView,
|
||||
setIsGoosehintsModalOpen,
|
||||
renderHeader,
|
||||
renderBeforeMessages,
|
||||
renderAfterMessages,
|
||||
customChatInputProps = {},
|
||||
customMainLayoutProps = {},
|
||||
disableSearch = false,
|
||||
resumeSessionId,
|
||||
}: BaseChatProps) {
|
||||
const location = useLocation();
|
||||
const scrollRef = useRef<ScrollAreaHandle>(null);
|
||||
|
||||
const disableAnimation = location.state?.disableAnimation || false;
|
||||
// const [hasStartedUsingRecipe, setHasStartedUsingRecipe] = React.useState(false);
|
||||
// const [currentRecipeTitle, setCurrentRecipeTitle] = React.useState<string | null>(null);
|
||||
// const { isCompacting, handleManualCompaction } = useContextManager();
|
||||
const isMobile = useIsMobile();
|
||||
const { state: sidebarState } = useSidebar();
|
||||
|
||||
const contentClassName = cn('pr-1 pb-10', (isMobile || sidebarState === 'collapsed') && 'pt-11');
|
||||
|
||||
// Use shared file drop
|
||||
const { droppedFiles, setDroppedFiles, handleDrop, handleDragOver } = useFileDrop();
|
||||
|
||||
// Use shared cost tracking
|
||||
// const { sessionCosts } = useCostTracking({
|
||||
// sessionInputTokens,
|
||||
// sessionOutputTokens,
|
||||
// localInputTokens,
|
||||
// localOutputTokens,
|
||||
// session: sessionMetadata,
|
||||
// });
|
||||
|
||||
// Session loading state
|
||||
const [sessionLoadError, setSessionLoadError] = useState<string | null>(null);
|
||||
const hasLoadedSessionRef = useRef(false);
|
||||
|
||||
const [messages, setMessages] = useState(chat.messages || []);
|
||||
|
||||
// Load session on mount if resumeSessionId is provided
|
||||
useEffect(() => {
|
||||
const needsLoad = resumeSessionId && !hasLoadedSessionRef.current;
|
||||
|
||||
if (needsLoad) {
|
||||
hasLoadedSessionRef.current = true;
|
||||
setSessionLoadError(null);
|
||||
|
||||
// Set chat to empty session to indicate loading state
|
||||
// todo: set to null instead and handle that in other places
|
||||
const emptyChat: ChatType = {
|
||||
sessionId: resumeSessionId,
|
||||
title: 'Loading...',
|
||||
messageHistoryIndex: 0,
|
||||
messages: [],
|
||||
recipe: null,
|
||||
recipeParameters: null,
|
||||
};
|
||||
setChat(emptyChat);
|
||||
|
||||
loadSession(resumeSessionId)
|
||||
.then((session: Session) => {
|
||||
const conversation = session.conversation || [];
|
||||
const loadedChat: ChatType = {
|
||||
sessionId: session.id,
|
||||
title: session.description || 'Untitled Chat',
|
||||
messageHistoryIndex: 0,
|
||||
messages: conversation,
|
||||
recipe: null,
|
||||
recipeParameters: null,
|
||||
};
|
||||
|
||||
setChat(loadedChat);
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
const errorMessage = error.message || 'Failed to load session';
|
||||
setSessionLoadError(errorMessage);
|
||||
});
|
||||
}
|
||||
}, [resumeSessionId, setChat]);
|
||||
|
||||
// Update messages when chat changes (e.g., when resuming a session)
|
||||
useEffect(() => {
|
||||
if (chat.messages) {
|
||||
setMessages(chat.messages);
|
||||
}
|
||||
}, [chat.messages, chat.sessionId]);
|
||||
|
||||
const { chatState, handleSubmit, stopStreaming } = useChatStream({
|
||||
sessionId: chat.sessionId || '',
|
||||
messages,
|
||||
setMessages,
|
||||
onStreamFinish: () => {},
|
||||
});
|
||||
|
||||
const handleFormSubmit = (e: React.FormEvent) => {
|
||||
const customEvent = e as unknown as CustomEvent;
|
||||
const textValue = customEvent.detail?.value || '';
|
||||
|
||||
// if (recipe && textValue.trim()) {
|
||||
// setHasStartedUsingRecipe(true);
|
||||
// }
|
||||
//
|
||||
// if (onMessageSubmit && textValue.trim()) {
|
||||
// onMessageSubmit(textValue);
|
||||
// }
|
||||
|
||||
handleSubmit(textValue);
|
||||
};
|
||||
|
||||
// TODO(Douwe): send this to the chatbox instead, possibly autosubmit? or backend
|
||||
const append = (_txt: string) => {};
|
||||
|
||||
useEffect(() => {
|
||||
window.electron.logInfo(
|
||||
'Initial messages when resuming session: ' + JSON.stringify(messages, null, 2)
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Track if this is the initial render for session resuming
|
||||
const initialRenderRef = useRef(true);
|
||||
|
||||
const recipe = chat?.recipe;
|
||||
|
||||
// Auto-scroll when messages are loaded (for session resuming)
|
||||
const handleRenderingComplete = React.useCallback(() => {
|
||||
// Only force scroll on the very first render
|
||||
if (initialRenderRef.current && messages.length > 0) {
|
||||
initialRenderRef.current = false;
|
||||
if (scrollRef.current?.scrollToBottom) {
|
||||
scrollRef.current.scrollToBottom();
|
||||
}
|
||||
} else if (scrollRef.current?.isFollowing) {
|
||||
if (scrollRef.current?.scrollToBottom) {
|
||||
scrollRef.current.scrollToBottom();
|
||||
}
|
||||
}
|
||||
}, [messages.length]);
|
||||
|
||||
//const toolCount = useToolCount(chat.sessionId);
|
||||
|
||||
// Wrapper for append that tracks recipe usage
|
||||
// const appendWithTracking = (text: string | Message) => {
|
||||
// // Mark that user has started using the recipe when they use append
|
||||
// if (recipe) {
|
||||
// setHasStartedUsingRecipe(true);
|
||||
// }
|
||||
// append(text);
|
||||
// };
|
||||
|
||||
// Listen for global scroll-to-bottom requests (e.g., from MCP UI prompt actions)
|
||||
useEffect(() => {
|
||||
const handleGlobalScrollRequest = () => {
|
||||
// Add a small delay to ensure content has been rendered
|
||||
setTimeout(() => {
|
||||
if (scrollRef.current?.scrollToBottom) {
|
||||
scrollRef.current.scrollToBottom();
|
||||
}
|
||||
}, 200);
|
||||
};
|
||||
|
||||
window.addEventListener('scroll-chat-to-bottom', handleGlobalScrollRequest);
|
||||
return () => window.removeEventListener('scroll-chat-to-bottom', handleGlobalScrollRequest);
|
||||
}, []);
|
||||
|
||||
const renderProgressiveMessageList = (chat: ChatType) => (
|
||||
<>
|
||||
<ProgressiveMessageList
|
||||
messages={messages}
|
||||
chat={chat}
|
||||
// toolCallNotifications={toolCallNotifications}
|
||||
// appendMessage={(newMessage) => {
|
||||
// const updatedMessages = [...messages, newMessage];
|
||||
// setMessages(updatedMessages);
|
||||
// }}
|
||||
isUserMessage={(m: Message) => m.role === 'user'}
|
||||
isStreamingMessage={chatState !== ChatState.Idle}
|
||||
// onMessageUpdate={onMessageUpdate}
|
||||
onRenderingComplete={handleRenderingComplete}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
const showPopularTopics = messages.length === 0;
|
||||
// TODO(Douwe): get this from the backend
|
||||
const isCompacting = false;
|
||||
|
||||
const initialPrompt = messages.length == 0 && recipe?.prompt ? recipe.prompt : '';
|
||||
return (
|
||||
<div className="h-full flex flex-col min-h-0">
|
||||
<h2>Warning: BaseChat2!</h2>
|
||||
<MainPanelLayout
|
||||
backgroundColor={'bg-background-muted'}
|
||||
removeTopPadding={true}
|
||||
{...customMainLayoutProps}
|
||||
>
|
||||
{/* Custom header */}
|
||||
{renderHeader && renderHeader()}
|
||||
|
||||
{/* Chat container with sticky recipe header */}
|
||||
<div className="flex flex-col flex-1 mb-0.5 min-h-0 relative">
|
||||
<ScrollArea
|
||||
ref={scrollRef}
|
||||
className={`flex-1 bg-background-default rounded-b-2xl min-h-0 relative ${contentClassName}`}
|
||||
autoScroll
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
data-drop-zone="true"
|
||||
paddingX={6}
|
||||
paddingY={0}
|
||||
>
|
||||
{/*/!* Recipe agent header - sticky at top of chat container *!/*/}
|
||||
{/*{recipe?.title && (*/}
|
||||
{/* <div className="sticky top-0 z-10 bg-background-default px-0 -mx-6 mb-6 pt-6">*/}
|
||||
{/* <AgentHeader*/}
|
||||
{/* title={recipe.title}*/}
|
||||
{/* profileInfo={*/}
|
||||
{/* recipe.profile ? `${recipe.profile} - ${recipe.mcps || 12} MCPs` : undefined*/}
|
||||
{/* }*/}
|
||||
{/* onChangeProfile={() => {*/}
|
||||
{/* console.log('Change profile clicked');*/}
|
||||
{/* }}*/}
|
||||
{/* showBorder={true}*/}
|
||||
{/* />*/}
|
||||
{/* </div>*/}
|
||||
{/*)}*/}
|
||||
|
||||
{/* Custom content before messages */}
|
||||
{renderBeforeMessages && renderBeforeMessages()}
|
||||
|
||||
{/*/!* Recipe Activities - always show when recipe is active and accepted *!/*/}
|
||||
{/*{recipe && recipeAccepted && !suppressEmptyState && (*/}
|
||||
{/* <div className={hasStartedUsingRecipe ? 'mb-6' : ''}>*/}
|
||||
{/* <RecipeActivities*/}
|
||||
{/* append={(text: string) => appendWithTracking(text)}*/}
|
||||
{/* activities={Array.isArray(recipe.activities) ? recipe.activities : null}*/}
|
||||
{/* title={recipe.title}*/}
|
||||
{/* parameterValues={recipeParameters || {}}*/}
|
||||
{/* />*/}
|
||||
{/* </div>*/}
|
||||
{/*)}*/}
|
||||
|
||||
{sessionLoadError && (
|
||||
<div className="flex flex-col items-center justify-center p-8">
|
||||
<div className="text-red-700 dark:text-red-300 bg-red-400/50 p-4 rounded-lg mb-4 max-w-md">
|
||||
<h3 className="font-semibold mb-2">Failed to Load Session</h3>
|
||||
<p className="text-sm">{sessionLoadError}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSessionLoadError(null);
|
||||
hasLoadedSessionRef.current = false;
|
||||
}}
|
||||
className="px-4 py-2 text-center cursor-pointer text-textStandard border border-borderSubtle hover:bg-bgSubtle rounded-lg transition-all duration-150"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Messages or Popular Topics */}
|
||||
{
|
||||
messages.length > 0 || recipe ? (
|
||||
<>
|
||||
{disableSearch ? (
|
||||
renderProgressiveMessageList(chat)
|
||||
) : (
|
||||
// Render messages with SearchView wrapper when search is enabled
|
||||
<SearchView>{renderProgressiveMessageList(chat)}</SearchView>
|
||||
)}
|
||||
|
||||
{/*{error && (*/}
|
||||
{/* <>*/}
|
||||
{/* <div className="flex flex-col items-center justify-center p-4">*/}
|
||||
{/* <div className="text-red-700 dark:text-red-300 bg-red-400/50 p-3 rounded-lg mb-2">*/}
|
||||
{/* {error.message || 'Honk! Goose experienced an error while responding'}*/}
|
||||
{/* </div>*/}
|
||||
|
||||
{/* /!* Action buttons for all errors including token limit errors *!/*/}
|
||||
{/* <div className="flex gap-2 mt-2">*/}
|
||||
{/* <div*/}
|
||||
{/* className="px-3 py-2 text-center whitespace-nowrap cursor-pointer text-textStandard border border-borderSubtle hover:bg-bgSubtle rounded-full inline-block transition-all duration-150"*/}
|
||||
{/* onClick={async () => {*/}
|
||||
{/* clearError();*/}
|
||||
|
||||
{/* await handleManualCompaction(*/}
|
||||
{/* messages,*/}
|
||||
{/* setMessages,*/}
|
||||
{/* append,*/}
|
||||
{/* chat.sessionId*/}
|
||||
{/* );*/}
|
||||
{/* }}*/}
|
||||
{/* >*/}
|
||||
{/* Summarize Conversation*/}
|
||||
{/* </div>*/}
|
||||
{/* <div*/}
|
||||
{/* className="px-3 py-2 text-center whitespace-nowrap cursor-pointer text-textStandard border border-borderSubtle hover:bg-bgSubtle rounded-full inline-block transition-all duration-150"*/}
|
||||
{/* onClick={async () => {*/}
|
||||
{/* // Find the last user message*/}
|
||||
{/* const lastUserMessage = messages.reduceRight(*/}
|
||||
{/* (found, m) => found || (m.role === 'user' ? m : null),*/}
|
||||
{/* null as Message | null*/}
|
||||
{/* );*/}
|
||||
{/* if (lastUserMessage) {*/}
|
||||
{/* await append(lastUserMessage);*/}
|
||||
{/* }*/}
|
||||
{/* }}*/}
|
||||
{/* >*/}
|
||||
{/* Retry Last Message*/}
|
||||
{/* </div>*/}
|
||||
{/* </div>*/}
|
||||
{/* </div>*/}
|
||||
{/* </>*/}
|
||||
{/*)}*/}
|
||||
|
||||
<div className="block h-8" />
|
||||
</>
|
||||
) : !recipe && showPopularTopics ? (
|
||||
/* Show PopularChatTopics when no messages, no recipe, and showPopularTopics is true (Pair view) */
|
||||
<PopularChatTopics append={(text: string) => append(text)} />
|
||||
) : null /* Show nothing when messages.length === 0 && suppressEmptyState === true */
|
||||
}
|
||||
|
||||
{/* Custom content after messages */}
|
||||
{renderAfterMessages && renderAfterMessages()}
|
||||
</ScrollArea>
|
||||
|
||||
{/* Fixed loading indicator at bottom left of chat container */}
|
||||
{(messages.length === 0 || isCompacting) && !sessionLoadError && (
|
||||
<div className="absolute bottom-1 left-4 z-20 pointer-events-none">
|
||||
<LoadingGoose
|
||||
message={
|
||||
messages.length === 0
|
||||
? 'loading conversation...'
|
||||
: isCompacting
|
||||
? 'goose is compacting the conversation...'
|
||||
: undefined
|
||||
}
|
||||
chatState={chatState}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`relative z-10 ${disableAnimation ? '' : 'animate-[fadein_400ms_ease-in_forwards]'}`}
|
||||
>
|
||||
<ChatInput
|
||||
sessionId={chat?.sessionId || ''}
|
||||
handleSubmit={handleFormSubmit}
|
||||
chatState={chatState}
|
||||
onStop={stopStreaming}
|
||||
//commandHistory={commandHistory}
|
||||
initialValue={initialPrompt}
|
||||
setView={setView}
|
||||
// numTokens={sessionTokenCount}
|
||||
// inputTokens={sessionInputTokens || localInputTokens}
|
||||
// outputTokens={sessionOutputTokens || localOutputTokens}
|
||||
droppedFiles={droppedFiles}
|
||||
onFilesProcessed={() => setDroppedFiles([])} // Clear dropped files after processing
|
||||
messages={messages}
|
||||
setMessages={(_m) => {}}
|
||||
disableAnimation={disableAnimation}
|
||||
//sessionCosts={sessionCosts}
|
||||
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
|
||||
recipe={recipe}
|
||||
//recipeAccepted={recipeAccepted}
|
||||
initialPrompt={initialPrompt}
|
||||
//toolCount={toolCount || 0}
|
||||
toolCount={0}
|
||||
//autoSubmit={autoSubmit}
|
||||
autoSubmit={false}
|
||||
//append={append}
|
||||
{...customChatInputProps}
|
||||
/>
|
||||
</div>
|
||||
</MainPanelLayout>
|
||||
|
||||
{/*/!* Recipe Warning Modal *!/*/}
|
||||
{/*<RecipeWarningModal*/}
|
||||
{/* isOpen={isRecipeWarningModalOpen}*/}
|
||||
{/* onConfirm={handleRecipeAccept}*/}
|
||||
{/* onCancel={handleRecipeCancel}*/}
|
||||
{/* recipeDetails={{*/}
|
||||
{/* title: recipe?.title,*/}
|
||||
{/* description: recipe?.description,*/}
|
||||
{/* instructions: recipe?.instructions || undefined,*/}
|
||||
{/* }}*/}
|
||||
{/* hasSecurityWarnings={hasSecurityWarnings}*/}
|
||||
{/*/>*/}
|
||||
|
||||
{/*/!* Recipe Parameter Modal *!/*/}
|
||||
{/*{isParameterModalOpen && filteredParameters.length > 0 && (*/}
|
||||
{/* <ParameterInputModal*/}
|
||||
{/* parameters={filteredParameters}*/}
|
||||
{/* onSubmit={handleParameterSubmit}*/}
|
||||
{/* onClose={() => setIsParameterModalOpen(false)}*/}
|
||||
{/* />*/}
|
||||
{/*)}*/}
|
||||
|
||||
{/*/!* Create Recipe from Session Modal *!/*/}
|
||||
{/*<CreateRecipeFromSessionModal*/}
|
||||
{/* isOpen={isCreateRecipeModalOpen}*/}
|
||||
{/* onClose={() => setIsCreateRecipeModalOpen(false)}*/}
|
||||
{/* sessionId={chat.sessionId}*/}
|
||||
{/* onRecipeCreated={handleRecipeCreated}*/}
|
||||
{/*/>*/}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BaseChat(props: BaseChatProps) {
|
||||
return (
|
||||
<ContextManagerProvider>
|
||||
<BaseChatContent {...props} />
|
||||
</ContextManagerProvider>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { Attach, Send, Close, Microphone } from './icons';
|
||||
import { ChatState } from '../types/chatState';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { LocalMessageStorage } from '../utils/localMessageStorage';
|
||||
import { Message } from '../types/message';
|
||||
import { Message } from '../api';
|
||||
import { DirSwitcher } from './bottom_menu/DirSwitcher';
|
||||
import ModelsBottomBar from './settings/models/bottom_bar/ModelsBottomBar';
|
||||
import { BottomMenuModeSelection } from './bottom_menu/BottomMenuModeSelection';
|
||||
|
||||
@@ -11,13 +11,13 @@ import {
|
||||
getChainForMessage,
|
||||
} from '../utils/toolCallChaining';
|
||||
import {
|
||||
Message,
|
||||
getTextContent,
|
||||
getToolRequests,
|
||||
getToolResponses,
|
||||
getToolConfirmationContent,
|
||||
createToolErrorResponseMessage,
|
||||
} from '../types/message';
|
||||
import { Message } from '../api';
|
||||
import ToolCallConfirmation from './ToolCallConfirmation';
|
||||
import MessageCopyLink from './MessageCopyLink';
|
||||
import { NotificationEvent } from '../hooks/useMessageStream';
|
||||
|
||||
@@ -7,13 +7,14 @@ import {
|
||||
UIActionResultToolCall,
|
||||
} from '@mcp-ui/client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ResourceContent } from '../types/message';
|
||||
import { toast } from 'react-toastify';
|
||||
import { EmbeddedResource } from '../api';
|
||||
|
||||
interface MCPUIResourceRendererProps {
|
||||
content: ResourceContent;
|
||||
content: EmbeddedResource & { type: 'resource' };
|
||||
appendPromptToChat?: (value: string) => void;
|
||||
}
|
||||
|
||||
type UISizeChange = {
|
||||
type: 'ui-size-change';
|
||||
payload: {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { View, ViewOptions } from '../utils/navigationUtils';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
|
||||
import { ChatType } from '../types/chat';
|
||||
import BaseChat2 from './BaseChat2';
|
||||
|
||||
export interface PairRouteState {
|
||||
resumeSessionId?: string;
|
||||
initialMessage?: string;
|
||||
}
|
||||
|
||||
interface PairProps {
|
||||
chat: ChatType;
|
||||
setChat: (chat: ChatType) => void;
|
||||
setView: (view: View, viewOptions?: ViewOptions) => void;
|
||||
setIsGoosehintsModalOpen: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
export default function Pair({
|
||||
chat,
|
||||
setChat,
|
||||
setView,
|
||||
setIsGoosehintsModalOpen,
|
||||
resumeSessionId,
|
||||
}: PairProps & PairRouteState) {
|
||||
return (
|
||||
<BaseChat2
|
||||
chat={chat}
|
||||
setChat={setChat}
|
||||
setView={setView}
|
||||
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
|
||||
resumeSessionId={resumeSessionId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Message } from '../types/message';
|
||||
import { Message } from '../api';
|
||||
import GooseMessage from './GooseMessage';
|
||||
import UserMessage from './UserMessage';
|
||||
import { CompactionMarker } from './context_management/CompactionMarker';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { formatMessageTimestamp } from '../utils/timeUtils';
|
||||
import { Message, getToolRequests } from '../types/message';
|
||||
import { Message } from '../api';
|
||||
import { getToolRequests } from '../types/message';
|
||||
import { NotificationEvent } from '../hooks/useMessageStream';
|
||||
import ToolCallWithResponse from './ToolCallWithResponse';
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
|
||||
import { snakeToTitleCase } from '../utils';
|
||||
import PermissionModal from './settings/permission/PermissionModal';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { confirmPermission } from '../api';
|
||||
import { confirmPermission, ToolConfirmationRequest } from '../api';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
const ALLOW_ONCE = 'allow_once';
|
||||
@@ -20,13 +20,11 @@ const toolConfirmationState = new Map<
|
||||
}
|
||||
>();
|
||||
|
||||
import { ToolConfirmationRequestMessageContent } from '../types/message';
|
||||
|
||||
interface ToolConfirmationProps {
|
||||
sessionId: string;
|
||||
isCancelledMessage: boolean;
|
||||
isClicked: boolean;
|
||||
toolConfirmationContent: ToolConfirmationRequestMessageContent;
|
||||
toolConfirmationContent: ToolConfirmationRequest & { type: 'toolConfirmationRequest' };
|
||||
}
|
||||
|
||||
export default function ToolConfirmation({
|
||||
|
||||
@@ -4,7 +4,7 @@ import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { ToolCallArguments, ToolCallArgumentValue } from './ToolCallArguments';
|
||||
import MarkdownContent from './MarkdownContent';
|
||||
import { Content, ToolRequestMessageContent, ToolResponseMessageContent } from '../types/message';
|
||||
import { ToolRequestMessageContent, ToolResponseMessageContent } from '../types/message';
|
||||
import { cn, snakeToTitleCase } from '../utils';
|
||||
import { LoadingStatus } from './ui/Dot';
|
||||
import { NotificationEvent } from '../hooks/useMessageStream';
|
||||
@@ -12,6 +12,7 @@ import { ChevronRight, FlaskConical } from 'lucide-react';
|
||||
import { TooltipWrapper } from './settings/providers/subcomponents/buttons/TooltipWrapper';
|
||||
import MCPUIResourceRenderer from './MCPUIResourceRenderer';
|
||||
import { isUIResource } from '@mcp-ui/client';
|
||||
import { Content, EmbeddedResource } from '../api';
|
||||
|
||||
interface ToolCallWithResponseProps {
|
||||
isCancelledMessage: boolean;
|
||||
@@ -22,16 +23,34 @@ interface ToolCallWithResponseProps {
|
||||
append?: (value: string) => void; // Function to append messages to the chat
|
||||
}
|
||||
|
||||
function getToolResultValue(toolResult: Record<string, unknown>): Content[] | null {
|
||||
if ('value' in toolResult && Array.isArray(toolResult.value)) {
|
||||
return toolResult.value as Content[];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isEmbeddedResource(content: Content): content is EmbeddedResource {
|
||||
return 'resource' in content && typeof (content as Record<string, unknown>).resource === 'object';
|
||||
}
|
||||
|
||||
export default function ToolCallWithResponse({
|
||||
isCancelledMessage,
|
||||
toolRequest,
|
||||
toolResponse,
|
||||
notifications,
|
||||
isStreamingMessage = false,
|
||||
isStreamingMessage,
|
||||
append,
|
||||
}: ToolCallWithResponseProps) {
|
||||
const toolCall = toolRequest.toolCall.status === 'success' ? toolRequest.toolCall.value : null;
|
||||
if (!toolCall) {
|
||||
// Handle both the wrapped ToolResult format and the unwrapped format
|
||||
// The server serializes ToolResult<T> as { status: "success", value: T } or { status: "error", error: string }
|
||||
const toolCallData = toolRequest.toolCall as Record<string, unknown>;
|
||||
const toolCall =
|
||||
toolCallData?.status === 'success'
|
||||
? (toolCallData.value as { name: string; arguments: Record<string, unknown> })
|
||||
: (toolCallData as { name: string; arguments: Record<string, unknown> });
|
||||
|
||||
if (!toolCall || !toolCall.name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -53,12 +72,15 @@ export default function ToolCallWithResponse({
|
||||
/>
|
||||
</div>
|
||||
{/* MCP UI — Inline */}
|
||||
{toolResponse?.toolResult?.value &&
|
||||
toolResponse.toolResult.value.map((content, index) => {
|
||||
if (isUIResource(content)) {
|
||||
{toolResponse?.toolResult &&
|
||||
getToolResultValue(toolResponse.toolResult)?.map((content, index) => {
|
||||
const resourceContent = isEmbeddedResource(content)
|
||||
? { ...content, type: 'resource' as const }
|
||||
: null;
|
||||
if (resourceContent && isUIResource(resourceContent)) {
|
||||
return (
|
||||
<div key={`${content.type}-${index}`} className="mt-3">
|
||||
<MCPUIResourceRenderer content={content} appendPromptToChat={append} />
|
||||
<div key={index} className="mt-3">
|
||||
<MCPUIResourceRenderer content={resourceContent} appendPromptToChat={append} />
|
||||
<div className="mt-3 p-4 py-3 border border-borderSubtle rounded-lg bg-background-muted flex items-center">
|
||||
<FlaskConical className="mr-2" size={20} />
|
||||
<div className="text-sm font-sans">
|
||||
@@ -200,7 +222,7 @@ function ToolCallView({
|
||||
}
|
||||
})();
|
||||
|
||||
const isToolDetails = Object.entries(toolCall?.arguments).length > 0;
|
||||
const isToolDetails = toolCall?.arguments && Object.entries(toolCall.arguments).length > 0;
|
||||
|
||||
// Check if streaming has finished but no tool response was received
|
||||
// This is a workaround for cases where the backend doesn't send tool responses
|
||||
@@ -211,7 +233,9 @@ function ToolCallView({
|
||||
? shouldShowAsComplete
|
||||
? 'success'
|
||||
: 'loading'
|
||||
: toolResponse.toolResult.status;
|
||||
: (toolResponse.toolResult as Record<string, unknown>).status === 'error'
|
||||
? 'error'
|
||||
: 'success';
|
||||
|
||||
// Tool call timing tracking
|
||||
const [startTime, setStartTime] = useState<number | null>(null);
|
||||
@@ -547,19 +571,30 @@ interface ToolResultViewProps {
|
||||
}
|
||||
|
||||
function ToolResultView({ result, isStartExpanded }: ToolResultViewProps) {
|
||||
const hasText = (c: Content): c is Content & { text: string } =>
|
||||
'text' in c && typeof (c as Record<string, unknown>).text === 'string';
|
||||
|
||||
const hasImage = (c: Content): c is Content & { data: string; mimeType: string } => {
|
||||
if (!('data' in c && 'mimeType' in c)) return false;
|
||||
const mimeType = (c as Record<string, unknown>).mimeType;
|
||||
return typeof mimeType === 'string' && mimeType.startsWith('image');
|
||||
};
|
||||
|
||||
const hasResource = (c: Content): c is Content & { resource: unknown } => 'resource' in c;
|
||||
|
||||
return (
|
||||
<ToolCallExpandable
|
||||
label={<span className="pl-4 py-1 font-sans text-sm">Output</span>}
|
||||
isStartExpanded={isStartExpanded}
|
||||
>
|
||||
<div className="pl-4 pr-4 py-4">
|
||||
{result.type === 'text' && result.text && (
|
||||
{hasText(result) && (
|
||||
<MarkdownContent
|
||||
content={result.text}
|
||||
className="whitespace-pre-wrap max-w-full overflow-x-auto"
|
||||
/>
|
||||
)}
|
||||
{result.type === 'image' && (
|
||||
{hasImage(result) && (
|
||||
<img
|
||||
src={`data:${result.mimeType};base64,${result.data}`}
|
||||
alt="Tool result"
|
||||
@@ -570,7 +605,7 @@ function ToolResultView({ result, isStartExpanded }: ToolResultViewProps) {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{result.type === 'resource' && (
|
||||
{hasResource(result) && (
|
||||
<pre className="font-sans text-sm">{JSON.stringify(result, null, 2)}</pre>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useRef, useMemo, useState, useEffect, useCallback } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import ImagePreview from './ImagePreview';
|
||||
import { extractImagePaths, removeImagePathsFromText } from '../utils/imageUtils';
|
||||
import MarkdownContent from './MarkdownContent';
|
||||
import { Message, getTextContent } from '../types/message';
|
||||
import { getTextContent } from '../types/message';
|
||||
import { Message } from '../api';
|
||||
import MessageCopyLink from './MessageCopyLink';
|
||||
import { formatMessageTimestamp } from '../utils/timeUtils';
|
||||
import Edit from './icons/Edit';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Message, SummarizationRequestedContent } from '../../types/message';
|
||||
import { Message, SummarizationRequested } from '../../api';
|
||||
|
||||
interface CompactionMarkerProps {
|
||||
message: Message;
|
||||
@@ -7,8 +7,9 @@ interface CompactionMarkerProps {
|
||||
|
||||
export const CompactionMarker: React.FC<CompactionMarkerProps> = ({ message }) => {
|
||||
const compactionContent = message.content.find(
|
||||
(content) => content.type === 'summarizationRequested'
|
||||
) as SummarizationRequestedContent | undefined;
|
||||
(content): content is SummarizationRequested & { type: 'summarizationRequested' } =>
|
||||
content.type === 'summarizationRequested'
|
||||
);
|
||||
|
||||
const markerText = compactionContent?.msg || 'Conversation compacted';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { createContext, useContext, useState, useCallback } from 'react';
|
||||
import { Message } from '../../types/message';
|
||||
import { manageContextFromBackend, convertApiMessageToFrontendMessage } from './index';
|
||||
import { manageContextFromBackend } from './index';
|
||||
import { Message } from '../../api';
|
||||
|
||||
// Define the context management interface
|
||||
interface ContextManagerState {
|
||||
@@ -53,21 +53,14 @@ export const ContextManagerProvider: React.FC<{ children: React.ReactNode }> = (
|
||||
sessionId: sessionId,
|
||||
});
|
||||
|
||||
// Convert API messages to frontend messages
|
||||
// The server now handles all visibility - we just display what we receive
|
||||
const convertedMessages = summaryResponse.messages.map((apiMessage) =>
|
||||
convertApiMessageToFrontendMessage(apiMessage)
|
||||
);
|
||||
|
||||
// Replace messages with the server-provided messages
|
||||
setMessages(convertedMessages);
|
||||
setMessages(summaryResponse.messages);
|
||||
|
||||
// Only automatically submit the continuation message for auto-compaction (context limit reached)
|
||||
// Manual compaction should just compact without continuing the conversation
|
||||
if (!isManual) {
|
||||
// Automatically submit the continuation message to continue the conversation
|
||||
// This should be the third message (index 2) which contains the "I ran into a context length exceeded error..." text
|
||||
const continuationMessage = convertedMessages[2];
|
||||
const continuationMessage = summaryResponse.messages[2];
|
||||
if (continuationMessage) {
|
||||
setTimeout(() => {
|
||||
append(continuationMessage);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { CompactionMarker } from '../CompactionMarker';
|
||||
import { Message } from '../../../types/message';
|
||||
import { Message } from '../../../api';
|
||||
|
||||
describe('CompactionMarker', () => {
|
||||
it('should render default message when no summarizationRequested content found', () => {
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { ContextManagerProvider, useContextManager } from '../ContextManager';
|
||||
import { Message } from '../../../types/message';
|
||||
import * as contextManagement from '../index';
|
||||
import { ContextManageResponse } from '../../../api';
|
||||
import { ContextManageResponse, Message } from '../../../api';
|
||||
|
||||
// Mock the context management functions
|
||||
vi.mock('../index', () => ({
|
||||
manageContextFromBackend: vi.fn(),
|
||||
convertApiMessageToFrontendMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockManageContextFromBackend = vi.mocked(contextManagement.manageContextFromBackend);
|
||||
const mockConvertApiMessageToFrontendMessage = vi.mocked(
|
||||
contextManagement.convertApiMessageToFrontendMessage
|
||||
);
|
||||
|
||||
describe('ContextManager', () => {
|
||||
const mockMessages: Message[] = [
|
||||
@@ -32,13 +27,6 @@ describe('ContextManager', () => {
|
||||
},
|
||||
];
|
||||
|
||||
const mockSummaryMessage: Message = {
|
||||
id: 'summary-1',
|
||||
role: 'assistant',
|
||||
created: 3000,
|
||||
content: [{ type: 'text', text: 'This is a summary of the conversation.' }],
|
||||
};
|
||||
|
||||
const mockSetMessages = vi.fn();
|
||||
const mockAppend = vi.fn();
|
||||
|
||||
@@ -113,6 +101,7 @@ describe('ContextManager', () => {
|
||||
describe('handleAutoCompaction', () => {
|
||||
it('should successfully perform auto compaction with server-provided messages', async () => {
|
||||
// Mock the backend response with 3 messages: marker, summary, continuation
|
||||
// Note: Server messages may not have id/created, which will be added by the code
|
||||
mockManageContextFromBackend.mockResolvedValue({
|
||||
messages: [
|
||||
{
|
||||
@@ -120,11 +109,11 @@ describe('ContextManager', () => {
|
||||
content: [
|
||||
{ type: 'summarizationRequested', msg: 'Conversation compacted and summarized' },
|
||||
],
|
||||
},
|
||||
} as Message,
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'Summary content' }],
|
||||
},
|
||||
} as Message,
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
@@ -133,36 +122,11 @@ describe('ContextManager', () => {
|
||||
text: 'The previous message contains a summary that was prepared because a context limit was reached. Do not mention that you read a summary or that conversation summarization occurred Just continue the conversation naturally based on the summarized context',
|
||||
},
|
||||
],
|
||||
},
|
||||
} as Message,
|
||||
],
|
||||
tokenCounts: [8, 100, 50],
|
||||
});
|
||||
|
||||
const mockCompactionMarker: Message = {
|
||||
id: 'marker-1',
|
||||
role: 'assistant',
|
||||
created: 3000,
|
||||
content: [{ type: 'summarizationRequested', msg: 'Conversation compacted and summarized' }],
|
||||
};
|
||||
|
||||
const mockContinuationMessage: Message = {
|
||||
id: 'continuation-1',
|
||||
role: 'assistant',
|
||||
created: 3000,
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'The previous message contains a summary that was prepared because a context limit was reached. Do not mention that you read a summary or that conversation summarization occurred Just continue the conversation naturally based on the summarized context',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Mock the conversion function to return different messages based on call order
|
||||
mockConvertApiMessageToFrontendMessage
|
||||
.mockReturnValueOnce(mockCompactionMarker) // First call - compaction marker
|
||||
.mockReturnValueOnce(mockSummaryMessage) // Second call - summary
|
||||
.mockReturnValueOnce(mockContinuationMessage); // Third call - continuation
|
||||
|
||||
const { result } = renderContextManager();
|
||||
|
||||
await act(async () => {
|
||||
@@ -180,39 +144,28 @@ describe('ContextManager', () => {
|
||||
sessionId: 'test-session-id',
|
||||
});
|
||||
|
||||
// Verify conversion calls with correct parameters
|
||||
expect(mockConvertApiMessageToFrontendMessage).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
content: [
|
||||
{ type: 'summarizationRequested', msg: 'Conversation compacted and summarized' },
|
||||
],
|
||||
})
|
||||
);
|
||||
expect(mockConvertApiMessageToFrontendMessage).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
content: [{ type: 'text', text: 'Summary content' }],
|
||||
})
|
||||
);
|
||||
expect(mockConvertApiMessageToFrontendMessage).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.objectContaining({
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: expect.stringContaining('The previous message contains a summary'),
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
// Expect setMessages to be called with all 3 converted messages
|
||||
expect(mockSetMessages).toHaveBeenCalledWith([
|
||||
mockCompactionMarker,
|
||||
mockSummaryMessage,
|
||||
mockContinuationMessage,
|
||||
]);
|
||||
// Expect setMessages to be called with all 3 messages from server
|
||||
// Note: Server doesn't provide id/created fields, so we don't check for them
|
||||
expect(mockSetMessages).toHaveBeenCalledTimes(1);
|
||||
const setMessagesCall = mockSetMessages.mock.calls[0][0];
|
||||
expect(setMessagesCall).toHaveLength(3);
|
||||
expect(setMessagesCall[0]).toMatchObject({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'summarizationRequested', msg: 'Conversation compacted and summarized' }],
|
||||
});
|
||||
expect(setMessagesCall[1]).toMatchObject({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'Summary content' }],
|
||||
});
|
||||
expect(setMessagesCall[2]).toMatchObject({
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'The previous message contains a summary that was prepared because a context limit was reached. Do not mention that you read a summary or that conversation summarization occurred Just continue the conversation naturally based on the summarized context',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Fast-forward timers to trigger the append call
|
||||
act(() => {
|
||||
@@ -221,7 +174,16 @@ describe('ContextManager', () => {
|
||||
|
||||
// Should append the continuation message (index 2) for auto-compaction
|
||||
expect(mockAppend).toHaveBeenCalledTimes(1);
|
||||
expect(mockAppend).toHaveBeenCalledWith(mockContinuationMessage);
|
||||
const appendedMessage = mockAppend.mock.calls[0][0];
|
||||
expect(appendedMessage).toMatchObject({
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'The previous message contains a summary that was prepared because a context limit was reached. Do not mention that you read a summary or that conversation summarization occurred Just continue the conversation naturally based on the summarized context',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle compaction errors gracefully', async () => {
|
||||
@@ -290,8 +252,6 @@ describe('ContextManager', () => {
|
||||
tokenCounts: [100, 50],
|
||||
});
|
||||
|
||||
mockConvertApiMessageToFrontendMessage.mockReturnValue(mockSummaryMessage);
|
||||
|
||||
await act(async () => {
|
||||
await promise;
|
||||
});
|
||||
@@ -363,30 +323,6 @@ describe('ContextManager', () => {
|
||||
tokenCounts: [8, 100, 50],
|
||||
});
|
||||
|
||||
const mockCompactionMarker: Message = {
|
||||
id: 'marker-1',
|
||||
role: 'assistant',
|
||||
created: 3000,
|
||||
content: [{ type: 'summarizationRequested', msg: 'Conversation compacted and summarized' }],
|
||||
};
|
||||
|
||||
const mockContinuationMessage: Message = {
|
||||
id: 'continuation-1',
|
||||
role: 'assistant',
|
||||
created: 3000,
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'The previous message contains a summary that was prepared because a context limit was reached. Do not mention that you read a summary or that conversation summarization occurred Just continue the conversation naturally based on the summarized context',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockConvertApiMessageToFrontendMessage
|
||||
.mockReturnValueOnce(mockCompactionMarker)
|
||||
.mockReturnValueOnce(mockSummaryMessage)
|
||||
.mockReturnValueOnce(mockContinuationMessage);
|
||||
|
||||
const { result } = renderContextManager();
|
||||
|
||||
await act(async () => {
|
||||
@@ -405,11 +341,26 @@ describe('ContextManager', () => {
|
||||
});
|
||||
|
||||
// Verify all three messages are set
|
||||
expect(mockSetMessages).toHaveBeenCalledWith([
|
||||
mockCompactionMarker,
|
||||
mockSummaryMessage,
|
||||
mockContinuationMessage,
|
||||
]);
|
||||
expect(mockSetMessages).toHaveBeenCalledTimes(1);
|
||||
const setMessagesCall = mockSetMessages.mock.calls[0][0];
|
||||
expect(setMessagesCall).toHaveLength(3);
|
||||
expect(setMessagesCall[0]).toMatchObject({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'summarizationRequested', msg: 'Conversation compacted and summarized' }],
|
||||
});
|
||||
expect(setMessagesCall[1]).toMatchObject({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'Manual summary content' }],
|
||||
});
|
||||
expect(setMessagesCall[2]).toMatchObject({
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'The previous message contains a summary that was prepared because a context limit was reached. Do not mention that you read a summary or that conversation summarization occurred Just continue the conversation naturally based on the summarized context',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Fast-forward timers to check if append would be called
|
||||
act(() => {
|
||||
@@ -431,8 +382,6 @@ describe('ContextManager', () => {
|
||||
tokenCounts: [100, 50],
|
||||
});
|
||||
|
||||
mockConvertApiMessageToFrontendMessage.mockReturnValue(mockSummaryMessage);
|
||||
|
||||
const { result } = renderContextManager();
|
||||
|
||||
await act(async () => {
|
||||
@@ -481,30 +430,6 @@ describe('ContextManager', () => {
|
||||
tokenCounts: [8, 100, 50],
|
||||
});
|
||||
|
||||
const mockCompactionMarker: Message = {
|
||||
id: 'marker-1',
|
||||
role: 'assistant',
|
||||
created: 3000,
|
||||
content: [{ type: 'summarizationRequested', msg: 'Conversation compacted and summarized' }],
|
||||
};
|
||||
|
||||
const mockContinuationMessage: Message = {
|
||||
id: 'continuation-1',
|
||||
role: 'assistant',
|
||||
created: 3000,
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'The previous message contains a summary that was prepared because a context limit was reached. Do not mention that you read a summary or that conversation summarization occurred Just continue the conversation naturally based on the summarized context',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockConvertApiMessageToFrontendMessage
|
||||
.mockReturnValueOnce(mockCompactionMarker)
|
||||
.mockReturnValueOnce(mockSummaryMessage)
|
||||
.mockReturnValueOnce(mockContinuationMessage);
|
||||
|
||||
const { result } = renderContextManager();
|
||||
|
||||
await act(async () => {
|
||||
@@ -517,11 +442,26 @@ describe('ContextManager', () => {
|
||||
});
|
||||
|
||||
// Verify all three messages are set
|
||||
expect(mockSetMessages).toHaveBeenCalledWith([
|
||||
mockCompactionMarker,
|
||||
mockSummaryMessage,
|
||||
mockContinuationMessage,
|
||||
]);
|
||||
expect(mockSetMessages).toHaveBeenCalledTimes(1);
|
||||
const setMessagesCall = mockSetMessages.mock.calls[0][0];
|
||||
expect(setMessagesCall).toHaveLength(3);
|
||||
expect(setMessagesCall[0]).toMatchObject({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'summarizationRequested', msg: 'Conversation compacted and summarized' }],
|
||||
});
|
||||
expect(setMessagesCall[1]).toMatchObject({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'Manual summary content' }],
|
||||
});
|
||||
expect(setMessagesCall[2]).toMatchObject({
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'The previous message contains a summary that was prepared because a context limit was reached. Do not mention that you read a summary or that conversation summarization occurred Just continue the conversation naturally based on the summarized context',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Fast-forward timers to check if append would be called
|
||||
act(() => {
|
||||
@@ -559,20 +499,11 @@ describe('ContextManager', () => {
|
||||
content: [
|
||||
{ type: 'toolResponse', id: 'test', toolResult: { content: 'Not text content' } },
|
||||
],
|
||||
},
|
||||
} as Message,
|
||||
],
|
||||
tokenCounts: [100, 50],
|
||||
});
|
||||
|
||||
const mockMessageWithoutText: Message = {
|
||||
id: 'summary-1',
|
||||
role: 'assistant',
|
||||
created: 3000,
|
||||
content: [{ type: 'toolResponse', id: 'test', toolResult: { status: 'success' } }],
|
||||
};
|
||||
|
||||
mockConvertApiMessageToFrontendMessage.mockReturnValue(mockMessageWithoutText);
|
||||
|
||||
const { result } = renderContextManager();
|
||||
|
||||
await act(async () => {
|
||||
@@ -588,8 +519,16 @@ describe('ContextManager', () => {
|
||||
expect(result.current.isCompacting).toBe(false);
|
||||
expect(result.current.compactionError).toBe(null);
|
||||
|
||||
// Should still set messages with the converted message
|
||||
expect(mockSetMessages).toHaveBeenCalledWith([mockMessageWithoutText]);
|
||||
// Should still set messages from server
|
||||
expect(mockSetMessages).toHaveBeenCalledTimes(1);
|
||||
const setMessagesCall = mockSetMessages.mock.calls[0][0];
|
||||
expect(setMessagesCall).toHaveLength(1);
|
||||
expect(setMessagesCall[0]).toMatchObject({
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'toolResponse', id: 'test', toolResult: { content: 'Not text content' } },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,149 +1,29 @@
|
||||
import {
|
||||
Message as FrontendMessage,
|
||||
Content as FrontendContent,
|
||||
MessageContent as FrontendMessageContent,
|
||||
ToolCallResult,
|
||||
ToolCall,
|
||||
Role,
|
||||
} from '../../types/message';
|
||||
import {
|
||||
ContextManageRequest,
|
||||
ContextManageResponse,
|
||||
manageContext,
|
||||
Message as ApiMessage,
|
||||
MessageContent as ApiMessageContent,
|
||||
} from '../../api';
|
||||
import { generateId } from 'ai';
|
||||
import { ContextManageRequest, ContextManageResponse, manageContext, Message } from '../../api';
|
||||
|
||||
export async function manageContextFromBackend({
|
||||
messages,
|
||||
manageAction,
|
||||
sessionId,
|
||||
}: {
|
||||
messages: FrontendMessage[];
|
||||
messages: Message[];
|
||||
manageAction: 'truncation' | 'summarize';
|
||||
sessionId: string;
|
||||
}): Promise<ContextManageResponse> {
|
||||
try {
|
||||
const contextManagementRequest = { manageAction, messages, sessionId };
|
||||
const contextManagementRequest = { manageAction, messages, sessionId };
|
||||
|
||||
// Cast to the API-expected type
|
||||
const result = await manageContext({
|
||||
body: contextManagementRequest as unknown as ContextManageRequest,
|
||||
});
|
||||
// Cast to the API-expected type
|
||||
const result = await manageContext({
|
||||
body: contextManagementRequest as unknown as ContextManageRequest,
|
||||
});
|
||||
|
||||
// Check for errors in the result
|
||||
if (result.error) {
|
||||
throw new Error(`Context management failed: ${result.error}`);
|
||||
}
|
||||
|
||||
// Extract the actual data from the result
|
||||
if (!result.data) {
|
||||
throw new Error('Context management returned no data');
|
||||
}
|
||||
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
console.error(`Context management failed: ${error || 'Unknown error'}`);
|
||||
throw new Error(
|
||||
`Context management failed: ${error || 'Unknown error'}\n\nStart a new session.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Function to convert API Message to frontend Message
|
||||
export function convertApiMessageToFrontendMessage(apiMessage: ApiMessage): FrontendMessage {
|
||||
return {
|
||||
id: generateId(),
|
||||
role: apiMessage.role as Role,
|
||||
created: apiMessage.created ?? Math.floor(Date.now() / 1000),
|
||||
content: apiMessage.content
|
||||
.map((apiContent) => mapApiContentToFrontendMessageContent(apiContent))
|
||||
.filter((content): content is FrontendMessageContent => content !== null),
|
||||
};
|
||||
}
|
||||
|
||||
// Function to convert API MessageContent to frontend MessageContent
|
||||
function mapApiContentToFrontendMessageContent(
|
||||
apiContent: ApiMessageContent
|
||||
): FrontendMessageContent | null {
|
||||
// Handle each content type specifically based on its "type" property
|
||||
if (apiContent.type === 'text') {
|
||||
return {
|
||||
type: 'text',
|
||||
text: apiContent.text,
|
||||
annotations: apiContent.annotations as Record<string, unknown> | undefined,
|
||||
};
|
||||
} else if (apiContent.type === 'image') {
|
||||
return {
|
||||
type: 'image',
|
||||
data: apiContent.data,
|
||||
mimeType: apiContent.mimeType,
|
||||
annotations: apiContent.annotations as Record<string, unknown> | undefined,
|
||||
};
|
||||
} else if (apiContent.type === 'toolRequest') {
|
||||
// Ensure the toolCall has the correct type structure
|
||||
const toolCall = apiContent.toolCall as unknown as ToolCallResult<ToolCall>;
|
||||
|
||||
return {
|
||||
type: 'toolRequest',
|
||||
id: apiContent.id,
|
||||
toolCall: toolCall,
|
||||
};
|
||||
} else if (apiContent.type === 'toolResponse') {
|
||||
// Ensure the toolResult has the correct type structure
|
||||
const toolResult = apiContent.toolResult as unknown as ToolCallResult<FrontendContent[]>;
|
||||
|
||||
return {
|
||||
type: 'toolResponse',
|
||||
id: apiContent.id,
|
||||
toolResult: toolResult,
|
||||
};
|
||||
} else if (apiContent.type === 'toolConfirmationRequest') {
|
||||
return {
|
||||
type: 'toolConfirmationRequest',
|
||||
id: apiContent.id,
|
||||
toolName: apiContent.toolName,
|
||||
arguments: apiContent.arguments as Record<string, unknown>,
|
||||
prompt: apiContent.prompt === null ? undefined : apiContent.prompt,
|
||||
};
|
||||
} else if (apiContent.type === 'contextLengthExceeded') {
|
||||
return {
|
||||
type: 'contextLengthExceeded',
|
||||
msg: apiContent.msg,
|
||||
};
|
||||
} else if (apiContent.type === 'summarizationRequested') {
|
||||
return {
|
||||
type: 'summarizationRequested',
|
||||
msg: apiContent.msg,
|
||||
};
|
||||
// Check for errors in the result
|
||||
if (result.error) {
|
||||
throw new Error(`Context management failed: ${result.error}`);
|
||||
}
|
||||
|
||||
// For types that exist in API but not in frontend, either skip or convert
|
||||
console.warn(`Skipping unsupported content type: ${apiContent.type}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function createSummarizationRequestMessage(
|
||||
messages: FrontendMessage[],
|
||||
requestMessage: string
|
||||
): FrontendMessage {
|
||||
// Get the last message
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
|
||||
// Determine the next role (opposite of the last message)
|
||||
const nextRole: Role = lastMessage.role === 'user' ? 'assistant' : 'user';
|
||||
|
||||
// Create the new message with SummarizationRequestedContent
|
||||
return {
|
||||
id: generateId(),
|
||||
role: nextRole,
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
content: [
|
||||
{
|
||||
type: 'summarizationRequested',
|
||||
msg: requestMessage,
|
||||
},
|
||||
],
|
||||
};
|
||||
if (!result.data) {
|
||||
throw new Error('Context management returned no data');
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
|
||||
@@ -29,11 +29,9 @@ import {
|
||||
import ProgressiveMessageList from '../ProgressiveMessageList';
|
||||
import { SearchView } from '../conversation/SearchView';
|
||||
import { ContextManagerProvider } from '../context_management/ContextManager';
|
||||
import { Message } from '../../types/message';
|
||||
import BackButton from '../ui/BackButton';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/Tooltip';
|
||||
import { Session } from '../../api';
|
||||
import { convertApiMessageToFrontendMessage } from '../context_management';
|
||||
import { Message, Session } from '../../api';
|
||||
|
||||
// Helper function to determine if a message is a user message (same as useChatEngine)
|
||||
const isUserMessage = (message: Message): boolean => {
|
||||
@@ -153,7 +151,7 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const [canShare, setCanShare] = useState(false);
|
||||
|
||||
const messages = (session.conversation || []).map(convertApiMessageToFrontendMessage);
|
||||
const messages = session.conversation || [];
|
||||
|
||||
useEffect(() => {
|
||||
const savedSessionConfig = localStorage.getItem('session_sharing_config');
|
||||
|
||||
@@ -8,13 +8,13 @@ import MarkdownContent from '../MarkdownContent';
|
||||
import ToolCallWithResponse from '../ToolCallWithResponse';
|
||||
import ImagePreview from '../ImagePreview';
|
||||
import {
|
||||
getTextContent,
|
||||
ToolRequestMessageContent,
|
||||
ToolResponseMessageContent,
|
||||
TextContent,
|
||||
} from '../../types/message';
|
||||
import { type Message } from '../../types/message';
|
||||
import { formatMessageTimestamp } from '../../utils/timeUtils';
|
||||
import { extractImagePaths, removeImagePathsFromText } from '../../utils/imageUtils';
|
||||
import { Message } from '../../api';
|
||||
|
||||
/**
|
||||
* Get tool responses map from messages
|
||||
@@ -111,12 +111,7 @@ export const SessionMessages: React.FC<SessionMessagesProps> = ({
|
||||
) : messages?.length > 0 ? (
|
||||
messages
|
||||
.map((message, index) => {
|
||||
// Extract text content from the message
|
||||
let textContent = message.content
|
||||
.filter((c): c is TextContent => c.type === 'text')
|
||||
.map((c) => c.text)
|
||||
.join('\n');
|
||||
|
||||
const textContent = getTextContent(message);
|
||||
// Extract image paths from the message
|
||||
const imagePaths = extractImagePaths(textContent);
|
||||
|
||||
|
||||
@@ -86,7 +86,9 @@ export function SessionInsights() {
|
||||
|
||||
const handleSessionClick = async (session: Session) => {
|
||||
try {
|
||||
resumeSession(session);
|
||||
resumeSession(session, (sessionId: string) => {
|
||||
navigate(`/pair?resumeSessionId=${sessionId}`);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to start session:', error);
|
||||
navigate('/sessions', {
|
||||
|
||||
@@ -6,7 +6,6 @@ import { initializeCostDatabase } from '../utils/costDatabase';
|
||||
import {
|
||||
backupConfig,
|
||||
initConfig,
|
||||
Message as ApiMessage,
|
||||
readAllConfig,
|
||||
Recipe,
|
||||
recoverConfig,
|
||||
@@ -15,7 +14,6 @@ import {
|
||||
validateConfig,
|
||||
} from '../api';
|
||||
import { COST_TRACKING_ENABLED } from '../updates';
|
||||
import { convertApiMessageToFrontendMessage } from '../components/context_management';
|
||||
|
||||
export enum AgentState {
|
||||
UNINITIALIZED = 'uninitialized',
|
||||
@@ -77,9 +75,7 @@ export function useAgent(): UseAgentReturn {
|
||||
sessionId: agentSession.id,
|
||||
title: agentSession.recipe?.title || agentSession.description,
|
||||
messageHistoryIndex: 0,
|
||||
messages: messages?.map((message: ApiMessage) =>
|
||||
convertApiMessageToFrontendMessage(message)
|
||||
),
|
||||
messages,
|
||||
recipe: agentSession.recipe,
|
||||
recipeParameters: agentSession.user_recipe_values || null,
|
||||
};
|
||||
@@ -159,13 +155,7 @@ export function useAgent(): UseAgentReturn {
|
||||
const conversation = agentSession.conversation || [];
|
||||
// If we're loading a recipe from initContext (new recipe load), start with empty messages
|
||||
// Otherwise, use the messages from the session
|
||||
const messages =
|
||||
initContext.recipe && !initContext.resumeSessionId
|
||||
? []
|
||||
: conversation.map((message: ApiMessage) =>
|
||||
convertApiMessageToFrontendMessage(message)
|
||||
);
|
||||
|
||||
const messages = initContext.recipe && !initContext.resumeSessionId ? [] : conversation;
|
||||
let initChat: ChatType = {
|
||||
sessionId: agentSession.id,
|
||||
title: agentSession.recipe?.title || agentSession.description,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { useChatEngine } from './useChatEngine';
|
||||
import { Message, getTextContent } from '../types/message';
|
||||
import { ChatType } from '../types/chat';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { Mock } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useChatEngine } from './useChatEngine';
|
||||
import { getTextContent } from '../types/message';
|
||||
import { Message } from '../api';
|
||||
import { ChatType } from '../types/chat';
|
||||
|
||||
// Mock the useMessageStream hook which is a dependency of useChatEngine
|
||||
vi.mock('./useMessageStream', () => ({
|
||||
|
||||
@@ -2,20 +2,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { getApiUrl } from '../config';
|
||||
import { useMessageStream } from './useMessageStream';
|
||||
import { LocalMessageStorage } from '../utils/localMessageStorage';
|
||||
import {
|
||||
Message,
|
||||
createUserMessage,
|
||||
ToolCall,
|
||||
ToolCallResult,
|
||||
ToolRequestMessageContent,
|
||||
ToolResponseMessageContent,
|
||||
ToolConfirmationRequestMessageContent,
|
||||
getTextContent,
|
||||
TextContent,
|
||||
} from '../types/message';
|
||||
import { createUserMessage, getTextContent, ToolResponseMessageContent } from '../types/message';
|
||||
import { getSession, Message } from '../api';
|
||||
import { ChatType } from '../types/chat';
|
||||
import { ChatState } from '../types/chatState';
|
||||
import { getSession } from '../api';
|
||||
|
||||
// Helper function to determine if a message is a user message
|
||||
const isUserMessage = (message: Message): boolean => {
|
||||
@@ -308,11 +298,7 @@ export const useChatEngine = ({
|
||||
// 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
|
||||
const textValue = getTextContent(lastMessage);
|
||||
_setInput(textValue);
|
||||
|
||||
// Also add to local storage history as a backup so cmd+up can retrieve it
|
||||
@@ -327,19 +313,15 @@ export const useChatEngine = ({
|
||||
setMessages([]);
|
||||
}
|
||||
} else if (!isUserMessage(lastMessage)) {
|
||||
// the last message was an assistant message
|
||||
// check if we have any tool requests or tool confirmation requests
|
||||
const toolRequests: [string, ToolCallResult<ToolCall>][] = lastMessage.content
|
||||
const toolRequests: [string, Record<string, unknown>][] = lastMessage.content
|
||||
.filter(
|
||||
(content): content is ToolRequestMessageContent | ToolConfirmationRequestMessageContent =>
|
||||
content.type === 'toolRequest' || content.type === 'toolConfirmationRequest'
|
||||
(content) => content.type === 'toolRequest' || content.type === 'toolConfirmationRequest'
|
||||
)
|
||||
.map((content) => {
|
||||
if (content.type === 'toolRequest') {
|
||||
return [content.id, content.toolCall];
|
||||
} else {
|
||||
// extract tool call from confirmation
|
||||
const toolCall: ToolCallResult<ToolCall> = {
|
||||
const toolCall = {
|
||||
status: 'success',
|
||||
value: {
|
||||
name: content.toolName,
|
||||
@@ -391,8 +373,7 @@ export const useChatEngine = ({
|
||||
return filteredMessages
|
||||
.reduce<string[]>((history, message) => {
|
||||
if (isUserMessage(message)) {
|
||||
const textContent = message.content.find((c): c is TextContent => c.type === 'text');
|
||||
const text = textContent?.text?.trim();
|
||||
const text = getTextContent(message).trim();
|
||||
if (text) {
|
||||
history.push(text);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { ChatState } from '../types/chatState';
|
||||
import { Message } from '../api';
|
||||
import { getApiUrl } from '../config';
|
||||
|
||||
const TextDecoder = globalThis.TextDecoder;
|
||||
|
||||
interface UseChatStreamProps {
|
||||
sessionId: string;
|
||||
messages: Message[];
|
||||
setMessages: (messages: Message[]) => void;
|
||||
onStreamFinish?: () => void;
|
||||
}
|
||||
|
||||
function pushMessage(currentMessages: Message[], incomingMsg: Message): Message[] {
|
||||
const lastMsg = currentMessages[currentMessages.length - 1];
|
||||
|
||||
if (lastMsg?.id && lastMsg.id === incomingMsg.id) {
|
||||
const lastContent = lastMsg.content[lastMsg.content.length - 1];
|
||||
const newContent = incomingMsg.content[incomingMsg.content.length - 1];
|
||||
|
||||
if (
|
||||
lastContent?.type === 'text' &&
|
||||
newContent?.type === 'text' &&
|
||||
incomingMsg.content.length === 1
|
||||
) {
|
||||
lastContent.text += newContent.text;
|
||||
} else {
|
||||
lastMsg.content.push(...incomingMsg.content);
|
||||
}
|
||||
return [...currentMessages];
|
||||
} else {
|
||||
return [...currentMessages, incomingMsg];
|
||||
}
|
||||
}
|
||||
|
||||
export function useChatStream({
|
||||
sessionId,
|
||||
messages,
|
||||
setMessages,
|
||||
onStreamFinish,
|
||||
}: UseChatStreamProps) {
|
||||
const [chatState, setChatState] = useState<ChatState>(ChatState.Idle);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (userMessage: string) => {
|
||||
const newMessage: Message = {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: userMessage }],
|
||||
created: Date.now(),
|
||||
};
|
||||
|
||||
let currentMessages = [...messages, newMessage];
|
||||
setMessages(currentMessages);
|
||||
setChatState(ChatState.Streaming);
|
||||
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
try {
|
||||
// TODO(Douwe): this side steps our API. heyapi does support streaming though which should make
|
||||
// this all nice & typed
|
||||
const response = await fetch(getApiUrl('/reply'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Secret-Key': await window.electron.getSecretKey(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
session_id: sessionId,
|
||||
messages: currentMessages,
|
||||
}),
|
||||
signal: abortControllerRef.current.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
if (!response.body) throw new Error('No response body');
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const chunk = decoder.decode(value);
|
||||
const lines = chunk.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') continue;
|
||||
|
||||
try {
|
||||
const event = JSON.parse(data);
|
||||
|
||||
if (event.message) {
|
||||
const msg = event.message as Message;
|
||||
currentMessages = pushMessage(currentMessages, msg);
|
||||
setMessages(currentMessages);
|
||||
}
|
||||
|
||||
if (event.error) {
|
||||
console.error('Stream error:', event.error);
|
||||
setChatState(ChatState.Idle);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.finish) {
|
||||
setChatState(ChatState.Idle);
|
||||
onStreamFinish?.();
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse SSE:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name !== 'AbortError') {
|
||||
console.error('Stream error:', error);
|
||||
}
|
||||
setChatState(ChatState.Idle);
|
||||
}
|
||||
},
|
||||
[sessionId, messages, setMessages, onStreamFinish]
|
||||
);
|
||||
|
||||
const stopStreaming = useCallback(() => {
|
||||
abortControllerRef.current?.abort();
|
||||
setChatState(ChatState.Idle);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
chatState,
|
||||
handleSubmit,
|
||||
stopStreaming,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useCallback, useEffect, useId, useReducer, useRef, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { createUserMessage, hasCompletedToolCalls, Message, Role } from '../types/message';
|
||||
import { createUserMessage, hasCompletedToolCalls } from '../types/message';
|
||||
import { Message, Role } from '../api';
|
||||
|
||||
import { getSession, Session } from '../api';
|
||||
import { ChatState } from '../types/chatState';
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useMemo, useState, useRef } from 'react';
|
||||
import { Recipe, scanRecipe } from '../recipe';
|
||||
import { Message, createUserMessage } from '../types/message';
|
||||
import { createUserMessage } from '../types/message';
|
||||
import { Message } from '../api';
|
||||
|
||||
import {
|
||||
updateSystemPromptWithParameters,
|
||||
substituteParameters,
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
import { Session } from './api';
|
||||
|
||||
export function resumeSession(session: Session) {
|
||||
console.log('Launching session in new window:', session.description || session.id);
|
||||
export function resumeSession(
|
||||
session: Session,
|
||||
navigateInSameWindow?: (sessionId: string) => void
|
||||
) {
|
||||
const workingDir = session.working_dir;
|
||||
if (!workingDir) {
|
||||
throw new Error('Cannot resume session: working directory is missing in session');
|
||||
}
|
||||
|
||||
window.electron.createChatWindow(
|
||||
undefined, // query
|
||||
workingDir,
|
||||
undefined, // version
|
||||
session.id
|
||||
);
|
||||
// When ALPHA is true and we have a navigation callback, resume in the same window
|
||||
// Otherwise, open in a new window (old behavior)
|
||||
if (process.env.ALPHA && navigateInSameWindow) {
|
||||
navigateInSameWindow(session.id);
|
||||
} else {
|
||||
window.electron.createChatWindow(
|
||||
undefined, // query
|
||||
workingDir,
|
||||
undefined, // version
|
||||
session.id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Message } from './types/message';
|
||||
import { safeJsonParse } from './utils/jsonUtils';
|
||||
import { Message } from './api';
|
||||
|
||||
export interface SharedSessionDetails {
|
||||
share_token: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Message } from './message';
|
||||
import { Recipe } from '../recipe';
|
||||
import { Message } from '../api';
|
||||
|
||||
export interface ChatType {
|
||||
sessionId: string;
|
||||
|
||||
+12
-134
@@ -1,116 +1,8 @@
|
||||
/**
|
||||
* Message types that match the Rust message structures
|
||||
* for direct serialization between client and server
|
||||
*/
|
||||
import { Content, Message, ToolConfirmationRequest, ToolRequest, ToolResponse } from '../api';
|
||||
|
||||
export type Role = 'user' | 'assistant';
|
||||
export type ToolRequestMessageContent = ToolRequest & { type: 'toolRequest' };
|
||||
export type ToolResponseMessageContent = ToolResponse & { type: 'toolResponse' };
|
||||
|
||||
export interface TextContent {
|
||||
type: 'text';
|
||||
text: string;
|
||||
annotations?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ImageContent {
|
||||
type: 'image';
|
||||
data: string;
|
||||
mimeType: string;
|
||||
annotations?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ResourceContent {
|
||||
type: 'resource';
|
||||
resource: {
|
||||
uri: string;
|
||||
mimeType: string;
|
||||
text?: string;
|
||||
blob?: string;
|
||||
};
|
||||
annotations?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type Content = TextContent | ImageContent | ResourceContent;
|
||||
|
||||
export interface ToolCall {
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ToolCallResult<T> {
|
||||
status: 'success' | 'error';
|
||||
value?: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ToolRequest {
|
||||
id: string;
|
||||
toolCall: ToolCallResult<ToolCall>;
|
||||
}
|
||||
|
||||
export interface ToolResponse {
|
||||
id: string;
|
||||
toolResult: ToolCallResult<Content[]>;
|
||||
}
|
||||
|
||||
export interface ToolRequestMessageContent {
|
||||
type: 'toolRequest';
|
||||
id: string;
|
||||
toolCall: ToolCallResult<ToolCall>;
|
||||
}
|
||||
|
||||
export interface ToolResponseMessageContent {
|
||||
type: 'toolResponse';
|
||||
id: string;
|
||||
toolResult: ToolCallResult<Content[]>;
|
||||
}
|
||||
|
||||
export interface ToolConfirmationRequestMessageContent {
|
||||
type: 'toolConfirmationRequest';
|
||||
id: string;
|
||||
toolName: string;
|
||||
arguments: Record<string, unknown>;
|
||||
prompt?: string;
|
||||
}
|
||||
|
||||
export interface ExtensionCall {
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
extensionName: string;
|
||||
}
|
||||
|
||||
export interface ExtensionCallResult<T> {
|
||||
status: 'success' | 'error';
|
||||
value?: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ContextLengthExceededContent {
|
||||
type: 'contextLengthExceeded';
|
||||
msg: string;
|
||||
}
|
||||
|
||||
export interface SummarizationRequestedContent {
|
||||
type: 'summarizationRequested';
|
||||
msg: string;
|
||||
}
|
||||
|
||||
export type MessageContent =
|
||||
| TextContent
|
||||
| ImageContent
|
||||
| ToolRequestMessageContent
|
||||
| ToolResponseMessageContent
|
||||
| ToolConfirmationRequestMessageContent
|
||||
| ContextLengthExceededContent
|
||||
| SummarizationRequestedContent;
|
||||
|
||||
export interface Message {
|
||||
id?: string;
|
||||
role: Role;
|
||||
created: number;
|
||||
content: MessageContent[];
|
||||
}
|
||||
|
||||
// Helper functions to create messages
|
||||
export function createUserMessage(text: string): Message {
|
||||
return {
|
||||
id: generateId(),
|
||||
@@ -190,56 +82,42 @@ export function createToolErrorResponseMessage(id: string, error: string): Messa
|
||||
};
|
||||
}
|
||||
|
||||
// Generate a unique ID for messages
|
||||
function generateId(): string {
|
||||
return Math.random().toString(36).substring(2, 10);
|
||||
}
|
||||
|
||||
// Helper functions to extract content from messages
|
||||
export function getTextContent(message: Message): string {
|
||||
return message.content
|
||||
.filter(
|
||||
(content): content is TextContent | ContextLengthExceededContent =>
|
||||
content.type === 'text' || content.type === 'contextLengthExceeded'
|
||||
)
|
||||
.map((content) => {
|
||||
if (content.type === 'text') {
|
||||
return content.text;
|
||||
} else if (content.type === 'contextLengthExceeded') {
|
||||
return content.msg;
|
||||
}
|
||||
if (content.type === 'text') return content.text;
|
||||
if (content.type === 'contextLengthExceeded') return content.msg;
|
||||
return '';
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
export function getToolRequests(message: Message): ToolRequestMessageContent[] {
|
||||
export function getToolRequests(message: Message): (ToolRequest & { type: 'toolRequest' })[] {
|
||||
return message.content.filter(
|
||||
(content): content is ToolRequestMessageContent => content.type === 'toolRequest'
|
||||
(content): content is ToolRequest & { type: 'toolRequest' } => content.type === 'toolRequest'
|
||||
);
|
||||
}
|
||||
|
||||
export function getToolResponses(message: Message): ToolResponseMessageContent[] {
|
||||
export function getToolResponses(message: Message): (ToolResponse & { type: 'toolResponse' })[] {
|
||||
return message.content.filter(
|
||||
(content): content is ToolResponseMessageContent => content.type === 'toolResponse'
|
||||
(content): content is ToolResponse & { type: 'toolResponse' } => content.type === 'toolResponse'
|
||||
);
|
||||
}
|
||||
|
||||
export function getToolConfirmationContent(
|
||||
message: Message
|
||||
): ToolConfirmationRequestMessageContent | undefined {
|
||||
): (ToolConfirmationRequest & { type: 'toolConfirmationRequest' }) | undefined {
|
||||
return message.content.find(
|
||||
(content): content is ToolConfirmationRequestMessageContent =>
|
||||
(content): content is ToolConfirmationRequest & { type: 'toolConfirmationRequest' } =>
|
||||
content.type === 'toolConfirmationRequest'
|
||||
);
|
||||
}
|
||||
|
||||
export function hasCompletedToolCalls(message: Message): boolean {
|
||||
const toolRequests = getToolRequests(message);
|
||||
if (toolRequests.length === 0) return false;
|
||||
|
||||
// For now, we'll assume all tool calls are completed when this is checked
|
||||
// In a real implementation, you'd need to check if all tool requests have responses
|
||||
// by looking through subsequent messages
|
||||
return true;
|
||||
return toolRequests.length > 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Session } from '../api';
|
||||
import { getApiUrl } from '../config';
|
||||
|
||||
/**
|
||||
* In-memory cache for session data
|
||||
* Maps session ID to Session object
|
||||
*/
|
||||
const sessionCache = new Map<string, Session>();
|
||||
|
||||
/**
|
||||
* In-flight request tracking to prevent duplicate fetches
|
||||
* Maps session ID to Promise of Session
|
||||
*/
|
||||
const inFlightRequests = new Map<string, Promise<Session>>();
|
||||
|
||||
/**
|
||||
* Load a session from the server using the /agent/resume endpoint
|
||||
* Implements caching to avoid redundant fetches
|
||||
*
|
||||
* @param sessionId - The unique identifier for the session
|
||||
* @param forceRefresh - If true, bypass cache and fetch fresh data
|
||||
* @returns Promise resolving to the Session object
|
||||
* @throws Error if the request fails or session not found
|
||||
*/
|
||||
export async function loadSession(sessionId: string, forceRefresh = false): Promise<Session> {
|
||||
if (!forceRefresh && sessionCache.has(sessionId)) {
|
||||
return sessionCache.get(sessionId)!;
|
||||
}
|
||||
|
||||
if (inFlightRequests.has(sessionId)) {
|
||||
return inFlightRequests.get(sessionId)!;
|
||||
}
|
||||
|
||||
const fetchPromise = (async () => {
|
||||
try {
|
||||
const url = getApiUrl('/agent/resume');
|
||||
const secretKey = await window.electron.getSecretKey();
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Secret-Key': secretKey,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
session_id: sessionId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
throw new Error(`Failed to load session: HTTP ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
const session: Session = await response.json();
|
||||
sessionCache.set(sessionId, session);
|
||||
|
||||
return session;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw new Error(`Error loading session ${sessionId}: ${error.message}`);
|
||||
}
|
||||
throw new Error(`Error loading session ${sessionId}: Unknown error`);
|
||||
} finally {
|
||||
inFlightRequests.delete(sessionId);
|
||||
}
|
||||
})();
|
||||
|
||||
inFlightRequests.set(sessionId, fetchPromise);
|
||||
return fetchPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear a specific session from the cache
|
||||
* Useful when a session has been updated and needs to be refetched
|
||||
*
|
||||
* @param sessionId - The unique identifier for the session to clear
|
||||
*/
|
||||
export function clearSessionCache(sessionId: string): void {
|
||||
sessionCache.delete(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all sessions from the cache
|
||||
* Useful for logout or when switching contexts
|
||||
*/
|
||||
export function clearAllSessionCache(): void {
|
||||
sessionCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a session is currently cached
|
||||
*
|
||||
* @param sessionId - The unique identifier for the session
|
||||
* @returns true if the session is in cache, false otherwise
|
||||
*/
|
||||
export function isSessionCached(sessionId: string): boolean {
|
||||
return sessionCache.has(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a session from cache without fetching
|
||||
* Returns undefined if not cached
|
||||
*
|
||||
* @param sessionId - The unique identifier for the session
|
||||
* @returns The cached Session object or undefined
|
||||
*/
|
||||
export function getCachedSession(sessionId: string): Session | undefined {
|
||||
return sessionCache.get(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload a session into cache
|
||||
* Useful when you already have session data from another source
|
||||
*
|
||||
* @param session - The Session object to cache
|
||||
*/
|
||||
export function preloadSession(session: Session): void {
|
||||
sessionCache.set(session.id, session);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current cache size
|
||||
* Useful for debugging and monitoring
|
||||
*
|
||||
* @returns The number of sessions currently cached
|
||||
*/
|
||||
export function getCacheSize(): number {
|
||||
return sessionCache.size;
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
export function formatMessageTimestamp(timestamp: number): string {
|
||||
// Convert from Unix timestamp (seconds) to milliseconds
|
||||
const date = new Date(timestamp * 1000);
|
||||
export function formatMessageTimestamp(timestamp?: number): string {
|
||||
const date = timestamp ? new Date(timestamp * 1000) : new Date();
|
||||
const now = new Date();
|
||||
|
||||
// Format time as HH:MM AM/PM
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Message, getToolRequests, getTextContent, getToolResponses } from '../types/message';
|
||||
import { getToolRequests, getTextContent, getToolResponses } from '../types/message';
|
||||
import { Message } from '../api';
|
||||
|
||||
export function identifyConsecutiveToolCalls(messages: Message[]): number[][] {
|
||||
const chains: number[][] = [];
|
||||
|
||||
Reference in New Issue
Block a user