ctx_management: summarize on command button (#2479)
This commit is contained in:
+52
-35
@@ -1,6 +1,10 @@
|
||||
import React, { createContext, useContext, useState } from 'react';
|
||||
import { Message } from '../../types/message';
|
||||
import { manageContextFromBackend, convertApiMessageToFrontendMessage } from './index';
|
||||
import {
|
||||
manageContextFromBackend,
|
||||
convertApiMessageToFrontendMessage,
|
||||
createSummarizationRequestMessage,
|
||||
} from './index';
|
||||
|
||||
// Define the context management interface
|
||||
interface ChatContextManagerState {
|
||||
@@ -9,10 +13,10 @@ interface ChatContextManagerState {
|
||||
isSummaryModalOpen: boolean;
|
||||
isLoadingSummary: boolean;
|
||||
errorLoadingSummary: boolean;
|
||||
preparingManualSummary: boolean;
|
||||
}
|
||||
|
||||
interface ChatContextManagerActions {
|
||||
fetchSummary: (messages: Message[]) => Promise<void>;
|
||||
updateSummary: (newSummaryContent: string) => void;
|
||||
resetMessagesWithSummary: (
|
||||
messages: Message[],
|
||||
@@ -23,12 +27,15 @@ interface ChatContextManagerActions {
|
||||
) => void;
|
||||
openSummaryModal: () => void;
|
||||
closeSummaryModal: () => void;
|
||||
hasContextHandlerContent: (message: Message) => boolean;
|
||||
hasContextLengthExceededContent: (message: Message) => boolean;
|
||||
handleContextLengthExceeded: (
|
||||
hasSummarizationRequestedContent: (message: Message) => boolean;
|
||||
getContextHandlerType: (message: Message) => 'contextLengthExceeded' | 'summarizationRequested';
|
||||
handleContextLengthExceeded: (messages: Message[]) => Promise<void>;
|
||||
handleManualSummarization: (
|
||||
messages: Message[],
|
||||
chatId: string,
|
||||
workingDir: string
|
||||
) => Promise<void>;
|
||||
setMessages: (messages: Message[]) => void
|
||||
) => void;
|
||||
}
|
||||
|
||||
// Create the context
|
||||
@@ -45,10 +52,12 @@ export const ChatContextManagerProvider: React.FC<{ children: React.ReactNode }>
|
||||
const [isSummaryModalOpen, setIsSummaryModalOpen] = useState<boolean>(false);
|
||||
const [isLoadingSummary, setIsLoadingSummary] = useState<boolean>(false);
|
||||
const [errorLoadingSummary, setErrorLoadingSummary] = useState<boolean>(false);
|
||||
const [preparingManualSummary, setPreparingManualSummary] = useState<boolean>(false);
|
||||
|
||||
const handleContextLengthExceeded = async (messages: Message[]): Promise<void> => {
|
||||
setIsLoadingSummary(true);
|
||||
setErrorLoadingSummary(false);
|
||||
setPreparingManualSummary(true);
|
||||
|
||||
try {
|
||||
// 2. Now get the summary from the backend
|
||||
@@ -75,38 +84,25 @@ export const ChatContextManagerProvider: React.FC<{ children: React.ReactNode }>
|
||||
console.error('Error handling context length exceeded:', err);
|
||||
setErrorLoadingSummary(true);
|
||||
setIsLoadingSummary(false);
|
||||
} finally {
|
||||
setPreparingManualSummary(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSummary = async (messages: Message[]) => {
|
||||
setIsLoadingSummary(true);
|
||||
setErrorLoadingSummary(false);
|
||||
const handleManualSummarization = (
|
||||
messages: Message[],
|
||||
setMessages: (messages: Message[]) => void
|
||||
): void => {
|
||||
// add some messages to the message thread
|
||||
// these messages will be filtered out in chat view
|
||||
// but they will also be what allows us to render some text in the chatview itself, similar to CLE events
|
||||
const summarizationRequest = createSummarizationRequestMessage(
|
||||
messages,
|
||||
'Summarize the session and begin a new one'
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await manageContextFromBackend({
|
||||
messages: messages,
|
||||
manageAction: 'summarize',
|
||||
});
|
||||
|
||||
// Convert API messages to frontend messages
|
||||
const convertedMessages = response.messages.map(
|
||||
(apiMessage) => convertApiMessageToFrontendMessage(apiMessage, false, true) // do not show to user but send to llm
|
||||
);
|
||||
|
||||
// Extract the summary text from the first message
|
||||
const summaryMessage = convertedMessages[0].content[0];
|
||||
if (summaryMessage.type === 'text') {
|
||||
const summary = summaryMessage.text;
|
||||
setSummaryContent(summary);
|
||||
setSummarizedThread(convertedMessages);
|
||||
}
|
||||
|
||||
setIsLoadingSummary(false);
|
||||
} catch (err) {
|
||||
console.error('Error fetching summary:', err);
|
||||
setErrorLoadingSummary(true);
|
||||
setIsLoadingSummary(false);
|
||||
}
|
||||
// add the message to the message thread
|
||||
setMessages([...messages, summarizationRequest]);
|
||||
};
|
||||
|
||||
const updateSummary = (newSummaryContent: string) => {
|
||||
@@ -212,10 +208,27 @@ export const ChatContextManagerProvider: React.FC<{ children: React.ReactNode }>
|
||||
setSummaryContent('');
|
||||
};
|
||||
|
||||
const hasContextHandlerContent = (message: Message): boolean => {
|
||||
return hasContextLengthExceededContent(message) || hasSummarizationRequestedContent(message);
|
||||
};
|
||||
|
||||
const hasContextLengthExceededContent = (message: Message): boolean => {
|
||||
return message.content.some((content) => content.type === 'contextLengthExceeded');
|
||||
};
|
||||
|
||||
const hasSummarizationRequestedContent = (message: Message): boolean => {
|
||||
return message.content.some((content) => content.type === 'summarizationRequested');
|
||||
};
|
||||
|
||||
const getContextHandlerType = (
|
||||
message: Message
|
||||
): 'contextLengthExceeded' | 'summarizationRequested' => {
|
||||
if (hasContextLengthExceededContent(message)) {
|
||||
return 'contextLengthExceeded';
|
||||
}
|
||||
return 'summarizationRequested';
|
||||
};
|
||||
|
||||
const openSummaryModal = () => {
|
||||
setIsSummaryModalOpen(true);
|
||||
};
|
||||
@@ -231,15 +244,19 @@ export const ChatContextManagerProvider: React.FC<{ children: React.ReactNode }>
|
||||
isSummaryModalOpen,
|
||||
isLoadingSummary,
|
||||
errorLoadingSummary,
|
||||
preparingManualSummary,
|
||||
|
||||
// Actions
|
||||
fetchSummary,
|
||||
updateSummary,
|
||||
resetMessagesWithSummary,
|
||||
openSummaryModal,
|
||||
closeSummaryModal,
|
||||
hasContextHandlerContent,
|
||||
hasContextLengthExceededContent,
|
||||
hasSummarizationRequestedContent,
|
||||
getContextHandlerType,
|
||||
handleContextLengthExceeded,
|
||||
handleManualSummarization,
|
||||
};
|
||||
|
||||
return (
|
||||
+66
-21
@@ -1,19 +1,21 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Message } from '../../types/message';
|
||||
import { useChatContextManager } from './ContextManager';
|
||||
import { useChatContextManager } from './ChatContextManager';
|
||||
|
||||
interface ContextLengthExceededHandlerProps {
|
||||
interface ContextHandlerProps {
|
||||
messages: Message[];
|
||||
messageId: string;
|
||||
chatId: string;
|
||||
workingDir: string;
|
||||
contextType: 'contextLengthExceeded' | 'summarizationRequested';
|
||||
}
|
||||
|
||||
export const ContextLengthExceededHandler: React.FC<ContextLengthExceededHandlerProps> = ({
|
||||
export const ContextHandler: React.FC<ContextHandlerProps> = ({
|
||||
messages,
|
||||
messageId,
|
||||
chatId,
|
||||
workingDir,
|
||||
contextType,
|
||||
}) => {
|
||||
const {
|
||||
summaryContent,
|
||||
@@ -22,10 +24,11 @@ export const ContextLengthExceededHandler: React.FC<ContextLengthExceededHandler
|
||||
openSummaryModal,
|
||||
handleContextLengthExceeded,
|
||||
} = useChatContextManager();
|
||||
|
||||
const [hasFetchStarted, setHasFetchStarted] = useState(false);
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
|
||||
const isContextLengthExceeded = contextType === 'contextLengthExceeded';
|
||||
|
||||
// Find the relevant message to check if it's the latest
|
||||
const isCurrentMessageLatest =
|
||||
messageId === messages[messages.length - 1]?.id ||
|
||||
@@ -43,7 +46,7 @@ export const ContextLengthExceededHandler: React.FC<ContextLengthExceededHandler
|
||||
fetchStartedRef.current = true;
|
||||
|
||||
// Call the async function without awaiting it in useEffect
|
||||
handleContextLengthExceeded(messages, chatId, workingDir).catch((err) => {
|
||||
handleContextLengthExceeded(messages).catch((err) => {
|
||||
console.error('Error handling context length exceeded:', err);
|
||||
});
|
||||
};
|
||||
@@ -109,8 +112,16 @@ export const ContextLengthExceededHandler: React.FC<ContextLengthExceededHandler
|
||||
|
||||
const renderFailedState = () => (
|
||||
<>
|
||||
<span className="text-xs text-gray-400">{`Your conversation has exceeded the model's context capacity`}</span>
|
||||
<span className="text-xs text-gray-400">{`This conversation has too much information to continue. Extension data often takes up significant space.`}</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{isContextLengthExceeded
|
||||
? `Your conversation has exceeded the model's context capacity`
|
||||
: `Summarization requested`}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{isContextLengthExceeded
|
||||
? `This conversation has too much information to continue. Extension data often takes up significant space.`
|
||||
: `Summarization failed. Continue chatting or start a new session.`}
|
||||
</span>
|
||||
<button
|
||||
onClick={openNewSession}
|
||||
className="text-xs text-textStandard hover:text-textSubtle transition-colors mt-1 flex items-center"
|
||||
@@ -122,7 +133,11 @@ export const ContextLengthExceededHandler: React.FC<ContextLengthExceededHandler
|
||||
|
||||
const renderRetryState = () => (
|
||||
<>
|
||||
<span className="text-xs text-gray-400">{`Your conversation has exceeded the model's context capacity`}</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{isContextLengthExceeded
|
||||
? `Your conversation has exceeded the model's context capacity`
|
||||
: `Summarization requested`}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
className="text-xs text-textStandard hover:text-textSubtle transition-colors mt-1 flex items-center"
|
||||
@@ -134,27 +149,57 @@ export const ContextLengthExceededHandler: React.FC<ContextLengthExceededHandler
|
||||
|
||||
const renderSuccessState = () => (
|
||||
<>
|
||||
<span className="text-xs text-gray-400">{`Your conversation has exceeded the model's context capacity and a summary was prepared.`}</span>
|
||||
<span className="text-xs text-gray-400">{`Messages above this line remain viewable but specific details are not included in active context.`}</span>
|
||||
<button
|
||||
onClick={openSummaryModal}
|
||||
className="text-xs text-textStandard hover:text-textSubtle transition-colors mt-1 flex items-center"
|
||||
>
|
||||
View or edit summary (you may continue your conversation based on the summary)
|
||||
</button>
|
||||
<span className="text-xs text-gray-400">
|
||||
{isContextLengthExceeded
|
||||
? `Your conversation has exceeded the model's context capacity and a summary was prepared.`
|
||||
: `A summary of your conversation was prepared as requested.`}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{isContextLengthExceeded
|
||||
? `Messages above this line remain viewable but specific details are not included in active context.`
|
||||
: `This summary includes key points from your conversation.`}
|
||||
</span>
|
||||
{shouldAllowSummaryInteraction && (
|
||||
<button
|
||||
onClick={openSummaryModal}
|
||||
className="text-xs text-textStandard hover:text-textSubtle transition-colors mt-1 flex items-center"
|
||||
>
|
||||
View or edit summary{' '}
|
||||
{isContextLengthExceeded
|
||||
? '(you may continue your conversation based on the summary)'
|
||||
: ''}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
// Render persistent summarized notification when we shouldn't show interaction options
|
||||
const renderPersistentMarker = () => (
|
||||
<span className="text-xs text-gray-400">
|
||||
Session summarized — messages above this line are not included in the conversation
|
||||
</span>
|
||||
);
|
||||
|
||||
const renderContentState = () => {
|
||||
if (!shouldAllowSummaryInteraction) {
|
||||
return null;
|
||||
// If this is not the latest context event message but we have a valid summary,
|
||||
// show the persistent marker
|
||||
if (!shouldAllowSummaryInteraction && summaryContent) {
|
||||
return renderPersistentMarker();
|
||||
}
|
||||
|
||||
if (errorLoadingSummary) {
|
||||
return retryCount >= 2 ? renderFailedState() : renderRetryState();
|
||||
// For the latest message with the context event
|
||||
if (shouldAllowSummaryInteraction) {
|
||||
if (errorLoadingSummary) {
|
||||
return retryCount >= 2 ? renderFailedState() : renderRetryState();
|
||||
}
|
||||
|
||||
if (summaryContent) {
|
||||
return renderSuccessState();
|
||||
}
|
||||
}
|
||||
|
||||
return renderSuccessState();
|
||||
// Fallback to showing at least the persistent marker
|
||||
return renderPersistentMarker();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -0,0 +1,97 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ScrollText } from 'lucide-react';
|
||||
import Modal from '../Modal';
|
||||
import { Button } from '../ui/button';
|
||||
import { useChatContextManager } from './ChatContextManager';
|
||||
import { Message } from '../../types/message';
|
||||
|
||||
interface ManualSummarizeButtonProps {
|
||||
messages: Message[];
|
||||
isLoading?: boolean; // need this prop to know if Goose is responding
|
||||
setMessages: (messages: Message[]) => void; // context management is triggered via special message content types
|
||||
}
|
||||
|
||||
export const ManualSummarizeButton: React.FC<ManualSummarizeButtonProps> = ({
|
||||
messages,
|
||||
isLoading = false,
|
||||
setMessages,
|
||||
}) => {
|
||||
const { handleManualSummarization, isLoadingSummary } = useChatContextManager();
|
||||
|
||||
const [isConfirmationOpen, setIsConfirmationOpen] = useState(false);
|
||||
|
||||
const handleClick = () => {
|
||||
setIsConfirmationOpen(true);
|
||||
};
|
||||
|
||||
const handleSummarize = async () => {
|
||||
setIsConfirmationOpen(false);
|
||||
|
||||
try {
|
||||
handleManualSummarization(messages, setMessages);
|
||||
} catch (error) {
|
||||
console.error('Error in handleSummarize:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Footer content for the confirmation modal
|
||||
const footerContent = (
|
||||
<>
|
||||
<Button
|
||||
onClick={handleSummarize}
|
||||
className="w-full h-[60px] rounded-none border-b border-borderSubtle bg-transparent hover:bg-bgSubtle text-textProminent font-medium text-large"
|
||||
>
|
||||
Summarize
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setIsConfirmationOpen(false)}
|
||||
variant="ghost"
|
||||
className="w-full h-[60px] rounded-none hover:bg-bgSubtle text-textSubtle hover:text-textStandard text-large font-regular"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative flex items-center">
|
||||
<button
|
||||
className={`flex items-center justify-center text-textSubtle hover:text-textStandard h-6 [&_svg]:size-4 ${
|
||||
isLoadingSummary || isLoading ? 'opacity-50 cursor-not-allowed' : ''
|
||||
}`}
|
||||
onClick={handleClick}
|
||||
disabled={isLoadingSummary || isLoading}
|
||||
title="Summarize conversation context"
|
||||
>
|
||||
<ScrollText size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Confirmation Modal */}
|
||||
{isConfirmationOpen && (
|
||||
<Modal footer={footerContent} onClose={() => setIsConfirmationOpen(false)}>
|
||||
<div className="flex flex-col mb-6">
|
||||
<div>
|
||||
<ScrollText className="text-iconStandard" size={24} />
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<h2 className="text-2xl font-regular text-textStandard">Summarize Conversation</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<p className="text-textStandard mb-4">
|
||||
This will summarize your conversation history to save context space.
|
||||
</p>
|
||||
<p className="text-textStandard">
|
||||
Previous messages will remain visible but only the summary will be included in the
|
||||
active context for Goose. This is useful for long conversations that are approaching
|
||||
the context limit.
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
MessageContent as FrontendMessageContent,
|
||||
ToolCallResult,
|
||||
ToolCall,
|
||||
Role,
|
||||
} from '../../types/message';
|
||||
import {
|
||||
ContextManageRequest,
|
||||
@@ -115,9 +116,40 @@ function mapApiContentToFrontendMessageContent(
|
||||
type: 'contextLengthExceeded',
|
||||
msg: apiContent.msg,
|
||||
};
|
||||
} else if (apiContent.type === 'summarizationRequested') {
|
||||
return {
|
||||
type: 'summarizationRequested',
|
||||
msg: apiContent.msg,
|
||||
};
|
||||
}
|
||||
|
||||
// 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,
|
||||
},
|
||||
],
|
||||
sendToLLM: false,
|
||||
display: true,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user