draft: use rust messages in typescript (#1393)
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Message, useChat } from '../ai-sdk-fork/useChat';
|
||||
import { getApiUrl } from '../config';
|
||||
import BottomMenu from './BottomMenu';
|
||||
import FlappyGoose from './FlappyGoose';
|
||||
@@ -14,15 +13,13 @@ import UserMessage from './UserMessage';
|
||||
import { askAi } from '../utils/askAI';
|
||||
import Splash from './Splash';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import { useMessageStream } from '../hooks/useMessageStream';
|
||||
import { Message, createUserMessage, getTextContent } from '../types/message';
|
||||
|
||||
export interface ChatType {
|
||||
id: number;
|
||||
title: string;
|
||||
messages: Array<{
|
||||
id: string;
|
||||
role: 'function' | 'system' | 'user' | 'assistant' | 'data' | 'tool';
|
||||
content: string;
|
||||
}>;
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
export default function ChatView({ setView }: { setView: (view: View) => void }) {
|
||||
@@ -39,14 +36,27 @@ export default function ChatView({ setView }: { setView: (view: View) => void })
|
||||
const [showGame, setShowGame] = useState(false);
|
||||
const scrollRef = useRef<ScrollAreaHandle>(null);
|
||||
|
||||
const { messages, append, stop, isLoading, error, setMessages } = useChat({
|
||||
const {
|
||||
messages,
|
||||
append,
|
||||
stop,
|
||||
isLoading,
|
||||
error,
|
||||
setMessages,
|
||||
input: _input,
|
||||
setInput: _setInput,
|
||||
handleInputChange: _handleInputChange,
|
||||
handleSubmit: _submitMessage,
|
||||
} = useMessageStream({
|
||||
api: getApiUrl('/reply'),
|
||||
initialMessages: chat?.messages || [],
|
||||
onFinish: async (message, _) => {
|
||||
onFinish: async (message, _reason) => {
|
||||
window.electron.stopPowerSaveBlocker();
|
||||
|
||||
const fetchResponses = await askAi(message.content);
|
||||
setMessageMetadata((prev) => ({ ...prev, [message.id]: fetchResponses }));
|
||||
// Extract text content from the message to pass to askAi
|
||||
const messageText = getTextContent(message);
|
||||
const fetchResponses = await askAi(messageText);
|
||||
setMessageMetadata((prev) => ({ ...prev, [message.id || '']: fetchResponses }));
|
||||
|
||||
const timeSinceLastInteraction = Date.now() - lastInteractionTime;
|
||||
window.electron.logInfo('last interaction:' + lastInteractionTime);
|
||||
@@ -58,11 +68,16 @@ export default function ChatView({ setView }: { setView: (view: View) => void })
|
||||
});
|
||||
}
|
||||
},
|
||||
onToolCall: (toolCall) => {
|
||||
// Handle tool calls if needed
|
||||
console.log('Tool call received:', toolCall);
|
||||
// Implement tool call handling logic here
|
||||
},
|
||||
});
|
||||
|
||||
// Update chat messages when they change
|
||||
useEffect(() => {
|
||||
setChat({ ...chat, messages });
|
||||
setChat((prevChat) => ({ ...prevChat, messages }));
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -78,10 +93,7 @@ export default function ChatView({ setView }: { setView: (view: View) => void })
|
||||
const content = customEvent.detail?.value || '';
|
||||
if (content.trim()) {
|
||||
setLastInteractionTime(Date.now());
|
||||
append({
|
||||
role: 'user',
|
||||
content,
|
||||
});
|
||||
append(createUserMessage(content));
|
||||
if (scrollRef.current?.scrollToBottom) {
|
||||
scrollRef.current.scrollToBottom();
|
||||
}
|
||||
@@ -97,47 +109,38 @@ export default function ChatView({ setView }: { setView: (view: View) => void })
|
||||
setLastInteractionTime(Date.now());
|
||||
window.electron.stopPowerSaveBlocker();
|
||||
|
||||
const lastMessage: Message = messages[messages.length - 1];
|
||||
if (lastMessage.role === 'user' && lastMessage.toolInvocations === undefined) {
|
||||
// Remove the last user message.
|
||||
// Handle stopping the message stream
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
if (lastMessage && lastMessage.role === 'user') {
|
||||
// Remove the last user message if it's the most recent one
|
||||
if (messages.length > 1) {
|
||||
setMessages(messages.slice(0, -1));
|
||||
} else {
|
||||
setMessages([]);
|
||||
}
|
||||
} else if (lastMessage.role === 'assistant' && lastMessage.toolInvocations !== undefined) {
|
||||
// Add messaging about interrupted ongoing tool invocations
|
||||
const newLastMessage: Message = {
|
||||
...lastMessage,
|
||||
toolInvocations: lastMessage.toolInvocations.map((invocation) => {
|
||||
if (invocation.state !== 'result') {
|
||||
return {
|
||||
...invocation,
|
||||
result: [
|
||||
{
|
||||
audience: ['user'],
|
||||
text: 'Interrupted.\n',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
audience: ['assistant'],
|
||||
text: 'Interrupted by the user to make a correction.\n',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
state: 'result',
|
||||
};
|
||||
} else {
|
||||
return invocation;
|
||||
}
|
||||
}),
|
||||
};
|
||||
|
||||
const updatedMessages = [...messages.slice(0, -1), newLastMessage];
|
||||
setMessages(updatedMessages);
|
||||
}
|
||||
// Note: Tool call interruption handling would need to be implemented
|
||||
// differently with the new message format
|
||||
};
|
||||
|
||||
// Filter out standalone tool response messages for rendering
|
||||
// They will be shown as part of the tool invocation in the assistant message
|
||||
const filteredMessages = messages.filter((message) => {
|
||||
// Keep all assistant messages and user messages that aren't just tool responses
|
||||
if (message.role === 'assistant') return true;
|
||||
|
||||
// For user messages, check if they're only tool responses
|
||||
if (message.role === 'user') {
|
||||
const hasOnlyToolResponses = message.content.every((c) => c.type === 'toolResponse');
|
||||
const hasTextContent = message.content.some((c) => c.type === 'text');
|
||||
|
||||
// Keep the message if it has text content or is not just tool responses
|
||||
return hasTextContent || !hasOnlyToolResponses;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-screen items-center justify-center">
|
||||
<div className="relative flex items-center h-[36px] w-full bg-bgSubtle border-b border-borderSubtle">
|
||||
@@ -145,19 +148,19 @@ export default function ChatView({ setView }: { setView: (view: View) => void })
|
||||
</div>
|
||||
<Card className="flex flex-col flex-1 rounded-none h-[calc(100vh-95px)] w-full bg-bgApp mt-0 border-none relative">
|
||||
{messages.length === 0 ? (
|
||||
<Splash append={append} />
|
||||
<Splash append={(text) => append(createUserMessage(text))} />
|
||||
) : (
|
||||
<ScrollArea ref={scrollRef} className="flex-1 px-4" autoScroll>
|
||||
{messages.map((message) => (
|
||||
<div key={message.id} className="mt-[16px]">
|
||||
{filteredMessages.map((message, index) => (
|
||||
<div key={message.id || index} className="mt-[16px]">
|
||||
{message.role === 'user' ? (
|
||||
<UserMessage message={message} />
|
||||
) : (
|
||||
<GooseMessage
|
||||
message={message}
|
||||
messages={messages}
|
||||
metadata={messageMetadata[message.id]}
|
||||
append={append}
|
||||
metadata={messageMetadata[message.id || '']}
|
||||
append={(text) => append(createUserMessage(text))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -166,20 +169,17 @@ export default function ChatView({ setView }: { setView: (view: View) => void })
|
||||
<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'}
|
||||
{error.status && <span className="ml-2">(Status: {error.status})</span>}
|
||||
</div>
|
||||
<div
|
||||
className="px-3 py-2 mt-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
|
||||
null as Message | null
|
||||
);
|
||||
if (lastUserMessage) {
|
||||
append({
|
||||
role: 'user',
|
||||
content: lastUserMessage.content,
|
||||
});
|
||||
append(lastUserMessage);
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,41 +1,77 @@
|
||||
import React from 'react';
|
||||
import ToolInvocations from './ToolInvocations';
|
||||
import React, { useMemo } from 'react';
|
||||
import LinkPreview from './LinkPreview';
|
||||
import GooseResponseForm from './GooseResponseForm';
|
||||
import { extractUrls } from '../utils/urlUtils';
|
||||
import MarkdownContent from './MarkdownContent';
|
||||
import ToolCallWithResponse from './ToolCallWithResponse';
|
||||
import { Message, getTextContent, getToolRequests, getToolResponses } from '../types/message';
|
||||
|
||||
interface GooseMessageProps {
|
||||
message: any;
|
||||
messages: any[];
|
||||
metadata?: any;
|
||||
append: (value: any) => void;
|
||||
message: Message;
|
||||
messages: Message[];
|
||||
metadata?: string[];
|
||||
append: (value: string) => void;
|
||||
}
|
||||
|
||||
export default function GooseMessage({ message, metadata, messages, append }: GooseMessageProps) {
|
||||
// Extract text content from the message
|
||||
const textContent = getTextContent(message);
|
||||
|
||||
// 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(previousMessage.content) : [];
|
||||
const urls = !message.toolInvocations ? extractUrls(message.content, previousUrls) : [];
|
||||
const previousUrls = previousMessage ? extractUrls(getTextContent(previousMessage)) : [];
|
||||
const urls = toolRequests.length === 0 ? extractUrls(textContent, previousUrls) : [];
|
||||
|
||||
// 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]);
|
||||
|
||||
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">
|
||||
{message.content && (
|
||||
{/* Always show the top content area if there are tool calls, even if textContent is empty */}
|
||||
{(textContent || toolRequests.length > 0) && (
|
||||
<div
|
||||
className={`goose-message-content bg-bgSubtle rounded-2xl px-4 py-2 ${message.toolInvocations ? 'rounded-b-none' : ''}`}
|
||||
className={`goose-message-content bg-bgSubtle rounded-2xl px-4 py-2 ${toolRequests.length > 0 ? 'rounded-b-none' : ''}`}
|
||||
>
|
||||
<MarkdownContent content={message.content} />
|
||||
{textContent ? <MarkdownContent content={textContent} /> : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message.toolInvocations && (
|
||||
{toolRequests.length > 0 && (
|
||||
<div className="goose-message-tool bg-bgApp border border-borderSubtle dark:border-gray-700 rounded-b-2xl px-4 pt-4 pb-2 mt-1">
|
||||
<ToolInvocations toolInvocations={message.toolInvocations} />
|
||||
{toolRequests.map((toolRequest) => (
|
||||
<ToolCallWithResponse
|
||||
key={toolRequest.id}
|
||||
toolRequest={toolRequest}
|
||||
toolResponse={toolResponsesMap.get(toolRequest.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -53,7 +89,7 @@ export default function GooseMessage({ message, metadata, messages, append }: Go
|
||||
{/* 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={message.content} metadata={metadata} append={append} />
|
||||
<GooseResponseForm message={textContent} metadata={metadata} append={append} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,8 @@ import MarkdownContent from './MarkdownContent';
|
||||
import { Button } from './ui/button';
|
||||
import { cn } from '../utils';
|
||||
import { Send } from './icons';
|
||||
// Prefixing unused imports with underscore
|
||||
import { createUserMessage as _createUserMessage } from '../types/message';
|
||||
|
||||
interface FormField {
|
||||
label: string;
|
||||
@@ -20,8 +22,8 @@ interface DynamicForm {
|
||||
|
||||
interface GooseResponseFormProps {
|
||||
message: string;
|
||||
metadata: any;
|
||||
append: (value: any) => void;
|
||||
metadata: string[] | null;
|
||||
append: (value: string) => void;
|
||||
}
|
||||
|
||||
export default function GooseResponseForm({
|
||||
@@ -103,31 +105,19 @@ export default function GooseResponseForm({
|
||||
};
|
||||
|
||||
const handleAccept = () => {
|
||||
const message = {
|
||||
content: 'Yes - go ahead.',
|
||||
role: 'user',
|
||||
};
|
||||
append(message);
|
||||
append('Yes - go ahead.');
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (selectedOption !== null && options[selectedOption]) {
|
||||
const message = {
|
||||
content: `Yes - continue with: ${options[selectedOption].optionTitle}`,
|
||||
role: 'user',
|
||||
};
|
||||
append(message);
|
||||
append(`Yes - continue with: ${options[selectedOption].optionTitle}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (dynamicForm) {
|
||||
const message = {
|
||||
content: JSON.stringify(formValues),
|
||||
role: 'user',
|
||||
};
|
||||
append(message);
|
||||
append(JSON.stringify(formValues));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import SplashPills from './SplashPills';
|
||||
import { Goose, Rain } from './icons/Goose';
|
||||
import GooseLogo from './GooseLogo';
|
||||
|
||||
export default function Splash({ append }) {
|
||||
|
||||
@@ -5,11 +5,8 @@ function SplashPill({ content, append, className = '', longForm = '' }) {
|
||||
<div
|
||||
className={`px-4 py-2 text-sm text-center text-textSubtle dark:text-textStandard cursor-pointer border border-borderSubtle hover:bg-bgSubtle rounded-full transition-all duration-150 ${className}`}
|
||||
onClick={async () => {
|
||||
const message = {
|
||||
content: longForm || content,
|
||||
role: 'user',
|
||||
};
|
||||
await append(message);
|
||||
// Use the longForm text if provided, otherwise use the content
|
||||
await append(longForm || content);
|
||||
}}
|
||||
>
|
||||
<div className="line-clamp-2">{content}</div>
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import React from 'react';
|
||||
import { Card } from './ui/card';
|
||||
import Box from './ui/Box';
|
||||
import { ToolCallArguments } from './ToolCallArguments';
|
||||
import MarkdownContent from './MarkdownContent';
|
||||
import { LoadingPlaceholder } from './LoadingPlaceholder';
|
||||
import { ChevronUp } from 'lucide-react';
|
||||
import { Content, ToolRequestMessageContent, ToolResponseMessageContent } from '../types/message';
|
||||
import { snakeToTitleCase } from '../utils';
|
||||
|
||||
interface ToolCallWithResponseProps {
|
||||
toolRequest: ToolRequestMessageContent;
|
||||
toolResponse?: ToolResponseMessageContent;
|
||||
}
|
||||
|
||||
export default function ToolCallWithResponse({
|
||||
toolRequest,
|
||||
toolResponse,
|
||||
}: ToolCallWithResponseProps) {
|
||||
const toolCall = toolRequest.toolCall.status === 'success' ? toolRequest.toolCall.value : null;
|
||||
|
||||
if (!toolCall) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="">
|
||||
<ToolCallView toolCall={toolCall} />
|
||||
{toolResponse ? (
|
||||
<ToolResultView
|
||||
result={
|
||||
toolResponse.toolResult.status === 'success'
|
||||
? toolResponse.toolResult.value
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<LoadingPlaceholder />
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToolCallViewProps {
|
||||
toolCall: {
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
function ToolCallView({ toolCall }: ToolCallViewProps) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center mb-4">
|
||||
<Box size={16} />
|
||||
<span className="ml-[8px] text-textStandard">
|
||||
{snakeToTitleCase(toolCall.name.substring(toolCall.name.lastIndexOf('__') + 2))}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{toolCall.arguments && <ToolCallArguments args={toolCall.arguments} />}
|
||||
|
||||
<div className="self-stretch h-px my-[10px] -mx-4 bg-borderSubtle dark:bg-gray-700" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToolResultViewProps {
|
||||
result?: Content[];
|
||||
}
|
||||
|
||||
function ToolResultView({ result }: ToolResultViewProps) {
|
||||
// State to track expanded items
|
||||
const [expandedItems, setExpandedItems] = React.useState<number[]>([]);
|
||||
|
||||
// If no result info, don't show anything
|
||||
if (!result) return null;
|
||||
|
||||
// Find results where either audience is not set, or it's set to a list that includes user
|
||||
const filteredResults = result.filter((item) => {
|
||||
// Check audience (which may not be in the type)
|
||||
const audience = item.annotations?.audience;
|
||||
|
||||
return !audience || audience.includes('user');
|
||||
});
|
||||
|
||||
if (filteredResults.length === 0) return null;
|
||||
|
||||
const toggleExpand = (index: number) => {
|
||||
setExpandedItems((prev) =>
|
||||
prev.includes(index) ? prev.filter((i) => i !== index) : [...prev, index]
|
||||
);
|
||||
};
|
||||
|
||||
const shouldShowExpanded = (item: Content, index: number) => {
|
||||
return (
|
||||
(item.annotations.priority !== undefined && item.annotations.priority >= 0.5) ||
|
||||
expandedItems.includes(index)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
{filteredResults.map((item, index) => {
|
||||
const isExpanded = shouldShowExpanded(item, index);
|
||||
const shouldMinimize =
|
||||
item.annotations.priority === undefined || item.annotations.priority < 0.5;
|
||||
return (
|
||||
<div key={index} className="relative">
|
||||
{shouldMinimize && (
|
||||
<button
|
||||
onClick={() => toggleExpand(index)}
|
||||
className="mb-1 flex items-center text-textStandard"
|
||||
>
|
||||
<span className="mr-2 text-sm">Output</span>
|
||||
<ChevronUp
|
||||
className={`h-5 w-5 transition-all origin-center ${!isExpanded ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
{(isExpanded || !shouldMinimize) && (
|
||||
<>
|
||||
{item.text && (
|
||||
<MarkdownContent
|
||||
content={item.text}
|
||||
className="whitespace-pre-wrap p-2 max-w-full overflow-x-auto"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Card } from './ui/card';
|
||||
import Box from './ui/Box';
|
||||
import { ToolCallArguments } from './ToolCallArguments';
|
||||
import MarkdownContent from './MarkdownContent';
|
||||
import { snakeToTitleCase } from '../utils';
|
||||
import { LoadingPlaceholder } from './LoadingPlaceholder';
|
||||
import { ChevronUp } from 'lucide-react';
|
||||
|
||||
export default function ToolInvocations({ toolInvocations }) {
|
||||
return (
|
||||
<>
|
||||
{toolInvocations.map((toolInvocation) => (
|
||||
<ToolInvocation key={toolInvocation.toolCallId} toolInvocation={toolInvocation} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolInvocation({ toolInvocation }) {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="">
|
||||
<ToolCall call={toolInvocation} />
|
||||
{toolInvocation.state === 'result' ? (
|
||||
<ToolResult result={toolInvocation} />
|
||||
) : (
|
||||
<LoadingPlaceholder />
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToolCallProps {
|
||||
call: {
|
||||
state: 'call' | 'result';
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
args: Record<string, any>;
|
||||
};
|
||||
}
|
||||
|
||||
function ToolCall({ call }: ToolCallProps) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center mb-4">
|
||||
<Box size={16} />
|
||||
<span className="ml-[8px] text-textStandard">
|
||||
{snakeToTitleCase(call.toolName.substring(call.toolName.lastIndexOf('__') + 2))}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{call.args && <ToolCallArguments args={call.args} />}
|
||||
|
||||
<div className="self-stretch h-px my-[10px] -mx-4 bg-borderSubtle dark:bg-gray-700" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface Annotations {
|
||||
audience?: string[]; // Array of audience types
|
||||
priority?: number; // Priority value between 0 and 1
|
||||
}
|
||||
|
||||
interface ResultItem {
|
||||
text?: string;
|
||||
type: 'text' | 'image';
|
||||
mimeType?: string;
|
||||
data?: string; // Base64 encoded image data
|
||||
annotations?: Annotations;
|
||||
}
|
||||
|
||||
interface ToolResultProps {
|
||||
result: {
|
||||
message?: string;
|
||||
result?: ResultItem[];
|
||||
state?: string;
|
||||
toolCallId?: string;
|
||||
toolName?: string;
|
||||
args?: any;
|
||||
input_todo?: any;
|
||||
};
|
||||
}
|
||||
|
||||
function ToolResult({ result }: ToolResultProps) {
|
||||
// State to track expanded items
|
||||
const [expandedItems, setExpandedItems] = React.useState<number[]>([]);
|
||||
|
||||
// If no result info, don't show anything
|
||||
if (!result || !result.result) return null;
|
||||
|
||||
// Normalize to an array
|
||||
const results = Array.isArray(result.result) ? result.result : [result.result];
|
||||
|
||||
// Find results where either audience is not set, or it's set to a list that contains user
|
||||
const filteredResults = results.filter(
|
||||
(item: ResultItem) =>
|
||||
!item.annotations?.audience || item.annotations?.audience?.includes('user')
|
||||
);
|
||||
|
||||
if (filteredResults.length === 0) return null;
|
||||
|
||||
const toggleExpand = (index: number) => {
|
||||
setExpandedItems((prev) =>
|
||||
prev.includes(index) ? prev.filter((i) => i !== index) : [...prev, index]
|
||||
);
|
||||
};
|
||||
|
||||
const shouldShowExpanded = (item: ResultItem, index: number) => {
|
||||
// (priority is defined and > 0.5) OR already in the expandedItems
|
||||
return (
|
||||
(item.annotations?.priority !== undefined && item.annotations?.priority >= 0.5) ||
|
||||
expandedItems.includes(index)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
{filteredResults.map((item: ResultItem, index: number) => {
|
||||
const isExpanded = shouldShowExpanded(item, index);
|
||||
// minimize if priority is not set or < 0.5
|
||||
const shouldMinimize =
|
||||
item.annotations?.priority === undefined || item.annotations?.priority < 0.5;
|
||||
return (
|
||||
<div key={index} className="relative">
|
||||
{shouldMinimize && (
|
||||
<button
|
||||
onClick={() => toggleExpand(index)}
|
||||
className="mb-1 flex items-center text-textStandard"
|
||||
>
|
||||
<span className="mr-2 text-sm">Output</span>
|
||||
<ChevronUp
|
||||
className={`h-5 w-5 transition-all origin-center ${!isExpanded ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
{(isExpanded || !shouldMinimize) && (
|
||||
<>
|
||||
{item.type === 'text' && item.text && (
|
||||
<MarkdownContent
|
||||
content={item.text}
|
||||
className="whitespace-pre-wrap p-2 max-w-full overflow-x-auto"
|
||||
/>
|
||||
)}
|
||||
{item.type === 'image' && item.data && item.mimeType && (
|
||||
<img
|
||||
src={`data:${item.mimeType};base64,${item.data}`}
|
||||
alt="Tool result"
|
||||
className="max-w-full h-auto rounded-md"
|
||||
onError={(e) => {
|
||||
console.error('Failed to load image: Invalid MIME-type encoded image data');
|
||||
e.currentTarget.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,16 +2,24 @@ import React from 'react';
|
||||
import LinkPreview from './LinkPreview';
|
||||
import { extractUrls } from '../utils/urlUtils';
|
||||
import MarkdownContent from './MarkdownContent';
|
||||
import { Message, getTextContent } from '../types/message';
|
||||
|
||||
export default function UserMessage({ message }) {
|
||||
interface UserMessageProps {
|
||||
message: Message;
|
||||
}
|
||||
|
||||
export default function UserMessage({ message }: UserMessageProps) {
|
||||
// Extract text content from the message
|
||||
const textContent = getTextContent(message);
|
||||
|
||||
// Extract URLs which explicitly contain the http:// or https:// protocol
|
||||
const urls = extractUrls(message.content, []);
|
||||
const urls = extractUrls(textContent, []);
|
||||
|
||||
return (
|
||||
<div className="flex justify-end mt-[16px] w-full opacity-0 animate-[appear_150ms_ease-in_forwards]">
|
||||
<div className="flex-col max-w-[85%]">
|
||||
<div className="flex bg-slate text-white rounded-xl rounded-br-none py-2 px-3">
|
||||
<MarkdownContent content={message.content} className="text-white" />
|
||||
<MarkdownContent content={textContent} className="text-white" />
|
||||
</div>
|
||||
|
||||
{/* TODO(alexhancock): Re-enable link previews once styled well again */}
|
||||
@@ -25,4 +33,4 @@ export default function UserMessage({ message }) {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user