From 20a35c7badb80473d6f8de72b30e1ee5e8a28926 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Sat, 22 Mar 2025 22:35:58 +1100 Subject: [PATCH] feat: shareable goose bots (#1721) --- ui/desktop/src/botConfig.ts | 7 + ui/desktop/src/components/ChatView.tsx | 129 ++++++++++- ui/desktop/src/components/MoreMenu.tsx | 16 +- ui/desktop/src/components/Splash.tsx | 4 +- ui/desktop/src/components/SplashPills.tsx | 19 +- .../src/components/ui/DeepLinkModal.tsx | 216 ++++++++++++++++++ ui/desktop/src/main.ts | 100 ++++++-- ui/desktop/src/preload.ts | 12 +- ui/desktop/src/utils/providerUtils.ts | 28 ++- 9 files changed, 498 insertions(+), 33 deletions(-) create mode 100644 ui/desktop/src/botConfig.ts create mode 100644 ui/desktop/src/components/ui/DeepLinkModal.tsx diff --git a/ui/desktop/src/botConfig.ts b/ui/desktop/src/botConfig.ts new file mode 100644 index 00000000..cb64c9fb --- /dev/null +++ b/ui/desktop/src/botConfig.ts @@ -0,0 +1,7 @@ +/** + * Bot configuration interface + */ +export interface BotConfig { + instructions: string; + activities: string[] | null; +} diff --git a/ui/desktop/src/components/ChatView.tsx b/ui/desktop/src/components/ChatView.tsx index f2c8d4ab..3474a4ac 100644 --- a/ui/desktop/src/components/ChatView.tsx +++ b/ui/desktop/src/components/ChatView.tsx @@ -1,6 +1,5 @@ import React, { useEffect, useRef, useState, useMemo } from 'react'; import { getApiUrl } from '../config'; -import { generateSessionId } from '../sessions'; import BottomMenu from './BottomMenu'; import FlappyGoose from './FlappyGoose'; import GooseMessage from './GooseMessage'; @@ -11,10 +10,11 @@ import MoreMenu from './MoreMenu'; import { Card } from './ui/card'; import { ScrollArea, ScrollAreaHandle } from './ui/scroll-area'; import UserMessage from './UserMessage'; -import { askAi } from '../utils/askAI'; import Splash from './Splash'; +import { DeepLinkModal } from './ui/DeepLinkModal'; import 'react-toastify/dist/ReactToastify.css'; import { useMessageStream } from '../hooks/useMessageStream'; +import { BotConfig } from '../botConfig'; import { Message, createUserMessage, @@ -52,8 +52,14 @@ export default function ChatView({ const [hasMessages, setHasMessages] = useState(false); const [lastInteractionTime, setLastInteractionTime] = useState(Date.now()); const [showGame, setShowGame] = useState(false); + const [waitingForAgentResponse, setWaitingForAgentResponse] = useState(false); + const [showShareableBotModal, setshowShareableBotModal] = useState(false); + const [generatedBotConfig, setGeneratedBotConfig] = useState(null); const scrollRef = useRef(null); + // Get botConfig directly from appConfig + const botConfig = window.appConfig.get('botConfig') as BotConfig | null; + const { messages, append, @@ -94,6 +100,104 @@ export default function ChatView({ }, }); + // Listen for make-agent-from-chat event + useEffect(() => { + const handleMakeAgent = async () => { + window.electron.logInfo('Making agent from chat...'); + + // Log all messages for now + window.electron.logInfo('Current messages:'); + chat.messages.forEach((message, index) => { + const role = isUserMessage(message) ? 'user' : 'assistant'; + const content = isUserMessage(message) ? message.text : getTextContent(message); + window.electron.logInfo(`Message ${index} (${role}): ${content}`); + }); + + // Inject a question into the chat to generate instructions + const instructionsPrompt = + 'Based on our conversation so far, could you create:\n' + + "1. A concise set of instructions (1-2 paragraphs) that describe what you've been helping with. Pay special attention if any output styles or formats are requested (and make it clear), and note any non standard tools used or required.\n" + + '2. A list of 3-5 example activities (as a few words each at most) that would be relevant to this topic\n\n' + + "Format your response with clear headings for 'Instructions:' and 'Activities:' sections." + + 'For example, perhaps we have been discussing fruit and you might write:\n\n' + + 'Instructions:\nUsing web searches we find pictures of fruit, and always check what language to reply in.' + + 'Activities:\nShow pics of apples, say a random fruit, share a fruit fact'; + + // Set waiting state to true before adding the prompt + setWaitingForAgentResponse(true); + + // Add the prompt as a user message + append(createUserMessage(instructionsPrompt)); + + window.electron.logInfo('Injected instructions prompt into chat'); + }; + + window.addEventListener('make-agent-from-chat', handleMakeAgent); + + return () => { + window.removeEventListener('make-agent-from-chat', handleMakeAgent); + }; + }, [append, chat.messages, setWaitingForAgentResponse]); + + // Listen for new messages and process agent response + useEffect(() => { + // Only process if we're waiting for an agent response + if (!waitingForAgentResponse || messages.length === 0) { + return; + } + + // Get the last message + const lastMessage = messages[messages.length - 1]; + + // Check if it's an assistant message (response to our prompt) + if (lastMessage.role === 'assistant') { + // Extract the content + const content = getTextContent(lastMessage); + + // Process the agent's response + if (content) { + window.electron.logInfo('Received agent response:'); + window.electron.logInfo(content); + + // Parse the response to extract instructions and activities + const instructionsMatch = content.match(/Instructions:(.*?)(?=Activities:|$)/s); + const activitiesMatch = content.match(/Activities:(.*?)$/s); + + const instructions = instructionsMatch ? instructionsMatch[1].trim() : ''; + const activitiesText = activitiesMatch ? activitiesMatch[1].trim() : ''; + + // Parse activities into an array + const activities = activitiesText + .split(/\n+/) + .map((line) => line.replace(/^[•\-*\d]+\.?\s*/, '').trim()) + .filter((activity) => activity.length > 0); + + // Create a bot config object + const generatedConfig = { + id: `bot-${Date.now()}`, + name: 'Custom Bot', + description: 'Bot created from chat', + instructions: instructions, + activities: activities, + }; + + window.electron.logInfo('Extracted bot config:'); + window.electron.logInfo(JSON.stringify(generatedConfig, null, 2)); + + // Store the generated bot config + setGeneratedBotConfig(generatedConfig); + + // Show the modal with the generated bot config + setshowShareableBotModal(true); + + window.electron.logInfo('Generated bot config for agent creation'); + + // Reset waiting state + setWaitingForAgentResponse(false); + } + } + }, [messages, waitingForAgentResponse, setshowShareableBotModal, setGeneratedBotConfig]); + // Leaving these in for easy debugging of different message states // One message with a tool call and no text content @@ -102,6 +206,7 @@ export default function ChatView({ // One message with text content and tool calls // const messages = [{"role":"assistant","created":1742484388,"content":[{"type":"text","text":"Sure, let's break this down into two steps:\n\n1. **Write content to a `.txt` file.**\n2. **Read the content from the `.txt` file.**\n\nLet's start by writing some example content to a `.txt` file. I'll create a file named `example.txt` and write a sample sentence into it. Then I'll read the content back. \n\n### Sample Content\nWe'll write the following content into the `example.txt` file:\n\n```\nHello World! This is an example text file.\n```\n\nLet's proceed with this task."},{"type":"toolRequest","id":"call_CmvAsxMxiWVKZvONZvnz4QCE","toolCall":{"status":"success","value":{"name":"developer__text_editor","arguments":{"command":"write","file_text":"Hello World! This is an example text file.","path":"/Users/alexhancock/Development/example.txt"}}}}]}]; + // Update chat messages when they change and save to sessionStorage useEffect(() => { setChat((prevChat) => { @@ -270,7 +375,10 @@ export default function ChatView({ {messages.length === 0 ? ( - append(createUserMessage(text))} /> + append(createUserMessage(text))} + activities={botConfig?.activities || null} + /> ) : ( {filteredMessages.map((message, index) => ( @@ -331,6 +439,21 @@ export default function ChatView({ {showGame && setShowGame(false)} />} + + {/* Deep Link Modal */} + {showShareableBotModal && generatedBotConfig && ( + { + setshowShareableBotModal(false); + setGeneratedBotConfig(null); + }} + onOpen={() => { + setshowShareableBotModal(false); + setGeneratedBotConfig(null); + }} + /> + )} ); } diff --git a/ui/desktop/src/components/MoreMenu.tsx b/ui/desktop/src/components/MoreMenu.tsx index 8a8b209e..b378e2a1 100644 --- a/ui/desktop/src/components/MoreMenu.tsx +++ b/ui/desktop/src/components/MoreMenu.tsx @@ -1,6 +1,6 @@ import { Popover, PopoverContent, PopoverPortal, PopoverTrigger } from '../components/ui/popover'; import React, { useEffect, useState } from 'react'; -import { ChatSmart, Idea, More, Refresh, Time } from './icons'; +import { ChatSmart, Idea, More, Refresh, Time, Send } from './icons'; import { FolderOpen, Moon, Sliders, Sun } from 'lucide-react'; import { View } from '../App'; @@ -229,6 +229,20 @@ export default function MoreMenu({ + {/* Make Agent from Chat */} + { + setOpen(false); + // Signal to ChatView that we want to make an agent from the current chat + window.electron.logInfo('Make Agent button clicked'); + window.dispatchEvent(new CustomEvent('make-agent-from-chat')); + }} + subtitle="Make a custom agent you can share or reuse with a link" + icon={} + > + Make Agent from this session + + { setOpen(false); diff --git a/ui/desktop/src/components/Splash.tsx b/ui/desktop/src/components/Splash.tsx index 4058fae2..7d6880ba 100644 --- a/ui/desktop/src/components/Splash.tsx +++ b/ui/desktop/src/components/Splash.tsx @@ -2,7 +2,7 @@ import React from 'react'; import SplashPills from './SplashPills'; import GooseLogo from './GooseLogo'; -export default function Splash({ append }) { +export default function Splash({ append, activities = null }) { return (
@@ -13,7 +13,7 @@ export default function Splash({ append }) {
- +
diff --git a/ui/desktop/src/components/SplashPills.tsx b/ui/desktop/src/components/SplashPills.tsx index d738e411..feff2ca2 100644 --- a/ui/desktop/src/components/SplashPills.tsx +++ b/ui/desktop/src/components/SplashPills.tsx @@ -14,14 +14,21 @@ function SplashPill({ content, append, className = '', longForm = '' }) { ); } -export default function SplashPills({ append }) { +export default function SplashPills({ append, activities = null }) { + // If custom activities are provided, use those instead of the default ones + const pills = activities || [ + 'What can you do?', + 'Demo writing and reading files', + 'Make a snake game in a new folder', + 'List files in my current directory', + 'Take a screenshot and summarize', + ]; + return (
- - - - - + {pills.map((content, index) => ( + + ))}
); } diff --git a/ui/desktop/src/components/ui/DeepLinkModal.tsx b/ui/desktop/src/components/ui/DeepLinkModal.tsx new file mode 100644 index 00000000..3d18b5f7 --- /dev/null +++ b/ui/desktop/src/components/ui/DeepLinkModal.tsx @@ -0,0 +1,216 @@ +import React, { useMemo, useState, useEffect, useRef } from 'react'; +import { Buffer } from 'buffer'; +import Copy from '../icons/Copy'; +import Modal from '../Modal'; +import { Card } from '../ui/card'; + +interface DeepLinkModalProps { + botConfig: any; + onClose: () => void; + onOpen: () => void; +} + +// Function to generate a deep link from a bot config +export function generateDeepLink(botConfig: any): string { + const configBase64 = Buffer.from(JSON.stringify(botConfig)).toString('base64'); + return `goose://bot?config=${configBase64}`; +} + +export function DeepLinkModal({ + botConfig: initialBotConfig, + onClose, + onOpen, +}: DeepLinkModalProps) { + // Create editable state for the bot config + const [botConfig, setBotConfig] = useState(initialBotConfig); + const [instructions, setInstructions] = useState(initialBotConfig.instructions || ''); + const [activities, setActivities] = useState(initialBotConfig.activities || []); + const [activityInput, setActivityInput] = useState(''); + + // Generate the deep link using the current bot config + const deepLink = useMemo(() => { + const currentConfig = { + ...botConfig, + instructions, + activities, + }; + return generateDeepLink(currentConfig); + }, [botConfig, instructions, activities]); + + // Handle Esc key press + useEffect(() => { + const handleEscKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose(); + } + }; + + // Add event listener + document.addEventListener('keydown', handleEscKey); + + // Clean up + return () => { + document.removeEventListener('keydown', handleEscKey); + }; + }, [onClose]); + + // Update the bot config when instructions or activities change + useEffect(() => { + setBotConfig({ + ...botConfig, + instructions, + activities, + }); + }, [instructions, activities]); + + // Handle adding a new activity + const handleAddActivity = () => { + if (activityInput.trim()) { + setActivities([...activities, activityInput.trim()]); + setActivityInput(''); + } + }; + + // Handle removing an activity + const handleRemoveActivity = (index: number) => { + const newActivities = [...activities]; + newActivities.splice(index, 1); + setActivities(newActivities); + }; + + // Reference for the modal content + const modalRef = useRef(null); + + // Handle click outside the modal + const handleBackdropClick = (e: React.MouseEvent) => { + if (modalRef.current && !modalRef.current.contains(e.target as Node)) { + onClose(); + } + }; + + return ( +
+ +
+
+

Agent Created!

+

+ Your agent has been created successfully. You can share or open it below: +

+ + {/* Sharable Goose Bot Section - Moved to top */} +
+ +
+ + +
+
+ + {/* Action Buttons - Moved to top */} +
+ + +
+ +

Edit Instructions:

+
+
+