Skip to content

Commit

Permalink
draft: use rust messages in typescript
Browse files Browse the repository at this point in the history
  • Loading branch information
baxen committed Feb 26, 2025
1 parent 6f915c1 commit 11f9edd
Show file tree
Hide file tree
Showing 11 changed files with 935 additions and 441 deletions.
385 changes: 78 additions & 307 deletions crates/goose-server/src/routes/reply.rs

Large diffs are not rendered by default.

118 changes: 59 additions & 59 deletions ui/desktop/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 }) {
Expand All @@ -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,
setInput,
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);
Expand All @@ -58,6 +68,11 @@ 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
Expand All @@ -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();
}
Expand All @@ -97,67 +109,58 @@ 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 => 'ToolResponse' in c);
const hasTextContent = message.content.some(c => 'Text' in c);

// 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">
<MoreMenu setView={setView} />
</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>
Expand All @@ -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);
}
}}
>
Expand All @@ -206,4 +206,4 @@ export default function ChatView({ setView }: { setView: (view: View) => void })
{showGame && <FlappyGoose onClose={() => setShowGame(false)} />}
</div>
);
}
}
79 changes: 67 additions & 12 deletions ui/desktop/src/components/GooseMessage.tsx
Original file line number Diff line number Diff line change
@@ -1,41 +1,96 @@
import React from 'react';
import React, { useMemo } from 'react';
import ToolInvocations from './ToolInvocations';
import LinkPreview from './LinkPreview';
import GooseResponseForm from './GooseResponseForm';
import { extractUrls } from '../utils/urlUtils';
import MarkdownContent from './MarkdownContent';
import { Message, getTextContent, getToolRequests, getToolResponses } from '../types/message';

interface GooseMessageProps {
message: any;
messages: any[];
message: Message;
messages: Message[];
metadata?: any;
append: (value: any) => void;
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]);

// Convert tool requests to the format expected by ToolInvocations
const toolInvocations = useMemo(() => {
const invocations = toolRequests
.map((toolRequest) => {
const toolCall = toolRequest.tool_call.Ok;

if (!toolCall) {
return null;
}

const toolResponse = toolResponsesMap.get(toolRequest.id);

return {
toolCallId: toolRequest.id,
toolName: toolCall.name,
args: toolCall.arguments,
state: toolResponse ? 'result' : 'running',
result: toolResponse?.tool_result?.Ok || undefined,
};
})
.filter(Boolean);

return invocations;
}, [toolRequests, toolResponsesMap]);

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 || toolInvocations.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 ${toolInvocations.length > 0 ? 'rounded-b-none' : ''}`}
>
<MarkdownContent content={message.content} />
{textContent ? <MarkdownContent content={textContent} /> : null}
</div>
)}

{message.toolInvocations && (
{toolInvocations.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} />
<ToolInvocations toolInvocations={toolInvocations} />
</div>
)}
</div>
Expand All @@ -53,7 +108,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>
Expand Down
21 changes: 5 additions & 16 deletions ui/desktop/src/components/GooseResponseForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import MarkdownContent from './MarkdownContent';
import { Button } from './ui/button';
import { cn } from '../utils';
import { Send } from './icons';
import { createUserMessage } from '../types/message';

interface FormField {
label: string;
Expand All @@ -21,7 +22,7 @@ interface DynamicForm {
interface GooseResponseFormProps {
message: string;
metadata: any;
append: (value: any) => void;
append: (value: string) => void;
}

export default function GooseResponseForm({
Expand Down Expand Up @@ -103,31 +104,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));
}
};

Expand Down
1 change: 0 additions & 1 deletion ui/desktop/src/components/Splash.tsx
Original file line number Diff line number Diff line change
@@ -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 }) {
Expand Down
7 changes: 2 additions & 5 deletions ui/desktop/src/components/SplashPills.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
Loading

0 comments on commit 11f9edd

Please sign in to comment.