233 lines
8.9 KiB
TypeScript
233 lines
8.9 KiB
TypeScript
import { useEffect, useMemo, useRef } from 'react';
|
|
import LinkPreview from './LinkPreview';
|
|
import ImagePreview from './ImagePreview';
|
|
import GooseResponseForm from './GooseResponseForm';
|
|
import { extractUrls } from '../utils/urlUtils';
|
|
import { extractImagePaths, removeImagePathsFromText } from '../utils/imageUtils';
|
|
import { formatMessageTimestamp } from '../utils/timeUtils';
|
|
import MarkdownContent from './MarkdownContent';
|
|
import ToolCallWithResponse from './ToolCallWithResponse';
|
|
import {
|
|
Message,
|
|
getTextContent,
|
|
getToolRequests,
|
|
getToolResponses,
|
|
getToolConfirmationContent,
|
|
createToolErrorResponseMessage,
|
|
} from '../types/message';
|
|
import ToolCallConfirmation from './ToolCallConfirmation';
|
|
import MessageCopyLink from './MessageCopyLink';
|
|
import { NotificationEvent } from '../hooks/useMessageStream';
|
|
|
|
interface GooseMessageProps {
|
|
// messages up to this index are presumed to be "history" from a resumed session, this is used to track older tool confirmation requests
|
|
// anything before this index should not render any buttons, but anything after should
|
|
messageHistoryIndex: number;
|
|
message: Message;
|
|
messages: Message[];
|
|
metadata?: string[];
|
|
toolCallNotifications: Map<string, NotificationEvent[]>;
|
|
append: (value: string) => void;
|
|
appendMessage: (message: Message) => void;
|
|
}
|
|
|
|
export default function GooseMessage({
|
|
messageHistoryIndex,
|
|
message,
|
|
metadata,
|
|
messages,
|
|
toolCallNotifications,
|
|
append,
|
|
appendMessage,
|
|
}: GooseMessageProps) {
|
|
const contentRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Extract text content from the message
|
|
let textContent = getTextContent(message);
|
|
|
|
// Utility to split Chain-of-Thought (CoT) from the visible assistant response.
|
|
// If the text contains a <think>...</think> block, everything inside is treated as the
|
|
// CoT and removed from the user-visible text.
|
|
const splitChainOfThought = (text: string): { visibleText: string; cotText: string | null } => {
|
|
const regex = /<think>([\s\S]*?)<\/think>/i;
|
|
const match = text.match(regex);
|
|
if (!match) {
|
|
return { visibleText: text, cotText: null };
|
|
}
|
|
|
|
const cotRaw = match[1].trim();
|
|
const visible = text.replace(match[0], '').trim();
|
|
return { visibleText: visible, cotText: cotRaw.length > 0 ? cotRaw : null };
|
|
};
|
|
|
|
const { visibleText: textWithoutCot, cotText } = splitChainOfThought(textContent);
|
|
|
|
// Extract image paths from the visible part of the message (exclude CoT)
|
|
const imagePaths = extractImagePaths(textWithoutCot);
|
|
|
|
// Remove image paths from text for display
|
|
const displayText =
|
|
imagePaths.length > 0 ? removeImagePathsFromText(textWithoutCot, imagePaths) : textWithoutCot;
|
|
|
|
// Memoize the timestamp
|
|
const timestamp = useMemo(() => formatMessageTimestamp(message.created), [message.created]);
|
|
|
|
// Get tool requests from the message
|
|
const toolRequests = getToolRequests(message);
|
|
|
|
// Extract URLs under a few conditions
|
|
// 1. The message is purely text
|
|
// 2. The link wasn't also present in the previous message
|
|
// 3. The message contains the explicit http:// or https:// protocol at the beginning
|
|
const messageIndex = messages?.findIndex((msg) => msg.id === message.id);
|
|
const previousMessage = messageIndex > 0 ? messages[messageIndex - 1] : null;
|
|
const previousUrls = previousMessage ? extractUrls(getTextContent(previousMessage)) : [];
|
|
const urls = toolRequests.length === 0 ? extractUrls(displayText, previousUrls) : [];
|
|
|
|
const toolConfirmationContent = getToolConfirmationContent(message);
|
|
const hasToolConfirmation = toolConfirmationContent !== undefined;
|
|
|
|
// Find tool responses that correspond to the tool requests in this message
|
|
const toolResponsesMap = useMemo(() => {
|
|
const responseMap = new Map();
|
|
|
|
// Look for tool responses in subsequent messages
|
|
if (messageIndex !== undefined && messageIndex >= 0) {
|
|
for (let i = messageIndex + 1; i < messages.length; i++) {
|
|
const responses = getToolResponses(messages[i]);
|
|
|
|
for (const response of responses) {
|
|
// Check if this response matches any of our tool requests
|
|
const matchingRequest = toolRequests.find((req) => req.id === response.id);
|
|
if (matchingRequest) {
|
|
responseMap.set(response.id, response);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return responseMap;
|
|
}, [messages, messageIndex, toolRequests]);
|
|
|
|
useEffect(() => {
|
|
// If the message is the last message in the resumed session and has tool confirmation, it means the tool confirmation
|
|
// is broken or cancelled, to contonue use the session, we need to append a tool response to avoid mismatch tool result error.
|
|
if (
|
|
messageIndex === messageHistoryIndex - 1 &&
|
|
hasToolConfirmation &&
|
|
toolConfirmationContent
|
|
) {
|
|
appendMessage(
|
|
createToolErrorResponseMessage(toolConfirmationContent.id, 'The tool call is cancelled.')
|
|
);
|
|
}
|
|
}, [
|
|
messageIndex,
|
|
messageHistoryIndex,
|
|
hasToolConfirmation,
|
|
toolConfirmationContent,
|
|
appendMessage,
|
|
]);
|
|
|
|
return (
|
|
<div className="goose-message flex w-[90%] justify-start opacity-0 animate-[appear_150ms_ease-in_forwards]">
|
|
<div className="flex flex-col w-full">
|
|
{/* Chain-of-Thought (hidden by default) */}
|
|
{cotText && (
|
|
<details className="bg-bgSubtle border border-borderSubtle rounded p-2 mb-2">
|
|
<summary className="cursor-pointer text-sm text-textSubtle select-none">
|
|
Show thinking
|
|
</summary>
|
|
<div className="mt-2">
|
|
<MarkdownContent content={cotText} />
|
|
</div>
|
|
</details>
|
|
)}
|
|
|
|
{/* Visible assistant response */}
|
|
{displayText && (
|
|
<div className="flex flex-col group">
|
|
<div className={`goose-message-content pt-2`}>
|
|
<div ref={contentRef}>{<MarkdownContent content={displayText} />}</div>
|
|
</div>
|
|
|
|
{/* Render images if any */}
|
|
{imagePaths.length > 0 && (
|
|
<div className="flex flex-wrap gap-2 mt-2 mb-2">
|
|
{imagePaths.map((imagePath, index) => (
|
|
<ImagePreview key={index} src={imagePath} alt={`Image ${index + 1}`} />
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Only show MessageCopyLink if there's text content and no tool requests/responses */}
|
|
<div className="relative flex justify-start">
|
|
{toolRequests.length === 0 && (
|
|
<div className="text-xs text-textSubtle pt-1 transition-all duration-200 group-hover:-translate-y-4 group-hover:opacity-0">
|
|
{timestamp}
|
|
</div>
|
|
)}
|
|
{displayText && message.content.every((content) => content.type === 'text') && (
|
|
<div className="absolute left-0 pt-1">
|
|
<MessageCopyLink text={displayText} contentRef={contentRef} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{toolRequests.length > 0 && (
|
|
<div className="relative flex flex-col w-full">
|
|
{toolRequests.map((toolRequest) => (
|
|
<div
|
|
className={`goose-message-tool bg-bgSubtle rounded px-2 py-2 mb-2`}
|
|
key={toolRequest.id}
|
|
>
|
|
<ToolCallWithResponse
|
|
// If the message is resumed and not matched tool response, it means the tool is broken or cancelled.
|
|
isCancelledMessage={
|
|
messageIndex < messageHistoryIndex &&
|
|
toolResponsesMap.get(toolRequest.id) == undefined
|
|
}
|
|
toolRequest={toolRequest}
|
|
toolResponse={toolResponsesMap.get(toolRequest.id)}
|
|
notifications={toolCallNotifications.get(toolRequest.id)}
|
|
/>
|
|
</div>
|
|
))}
|
|
<div className="text-xs text-textSubtle pt-1 transition-all duration-200 group-hover:-translate-y-4 group-hover:opacity-0">
|
|
{timestamp}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{hasToolConfirmation && (
|
|
<ToolCallConfirmation
|
|
isCancelledMessage={messageIndex == messageHistoryIndex - 1}
|
|
isClicked={messageIndex < messageHistoryIndex - 1}
|
|
toolConfirmationId={toolConfirmationContent.id}
|
|
toolName={toolConfirmationContent.toolName}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* TODO(alexhancock): Re-enable link previews once styled well again */}
|
|
{false && urls.length > 0 && (
|
|
<div className="flex flex-wrap mt-[16px]">
|
|
{urls.map((url, index) => (
|
|
<LinkPreview key={index} url={url} />
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* enable or disable prompts here */}
|
|
{/* NOTE from alexhancock on 1/14/2025 - disabling again temporarily due to non-determinism in when the forms show up */}
|
|
{false && metadata && (
|
|
<div className="flex mt-[16px]">
|
|
<GooseResponseForm message={displayText} metadata={metadata || null} append={append} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|