From 3d5d3cedca0b0f3dc0a20f0ff14c5dbd8ed0317f Mon Sep 17 00:00:00 2001 From: Zane <75694352+zanesq@users.noreply.github.com> Date: Thu, 29 May 2025 09:13:26 -0700 Subject: [PATCH 01/40] Cleanup Phase 1: Remove unused React imports from safe components (#2702) Co-authored-by: Michael Neale --- ui/desktop/src/App.tsx | 35 ++++++++++++++----- ui/desktop/src/components/AgentHeader.tsx | 1 - ui/desktop/src/components/ChatInput.tsx | 25 +++++++------ ui/desktop/src/components/ChatView.tsx | 2 +- ui/desktop/src/components/ConfigContext.tsx | 6 ++-- ui/desktop/src/components/ErrorBoundary.tsx | 2 +- ui/desktop/src/components/FlappyGoose.tsx | 2 +- ui/desktop/src/components/GooseLogo.tsx | 20 +++++++---- ui/desktop/src/components/GooseMessage.tsx | 2 +- ui/desktop/src/components/GoosehintsModal.tsx | 10 +++--- ui/desktop/src/components/ImagePreview.tsx | 2 +- ui/desktop/src/components/LayingEggLoader.tsx | 2 +- ui/desktop/src/components/LinkPreview.tsx | 9 ++--- ui/desktop/src/components/LoadingGoose.tsx | 1 - .../src/components/LoadingPlaceholder.tsx | 1 - ui/desktop/src/components/ProviderGrid.tsx | 13 +++---- ui/desktop/src/components/RecipeEditor.tsx | 7 ++-- ui/desktop/src/components/Splash.tsx | 1 - ui/desktop/src/components/SplashPills.tsx | 1 - .../src/components/ToolCallArguments.tsx | 2 +- ui/desktop/src/components/UserMessage.tsx | 2 +- .../src/components/WelcomeGooseLogo.tsx | 1 - ui/desktop/src/components/WelcomeView.tsx | 1 - ui/desktop/src/components/alerts/AlertBox.tsx | 18 +++++----- .../bottom_menu/BottomMenuModeSelection.tsx | 2 +- .../src/components/conversation/SearchBar.tsx | 2 +- .../components/conversation/SearchView.tsx | 10 ++---- ui/desktop/src/components/icons/ArrowDown.tsx | 1 - ui/desktop/src/components/icons/ArrowUp.tsx | 1 - ui/desktop/src/components/icons/Attach.tsx | 1 - ui/desktop/src/components/icons/Back.tsx | 1 - ui/desktop/src/components/icons/Bars.tsx | 1 - ui/desktop/src/components/icons/ChatSmart.tsx | 1 - ui/desktop/src/components/icons/Check.tsx | 1 - .../src/components/icons/ChevronDown.tsx | 3 +- .../src/components/icons/ChevronRight.tsx | 1 - ui/desktop/src/components/icons/ChevronUp.tsx | 1 - ui/desktop/src/components/icons/Close.tsx | 3 +- ui/desktop/src/components/icons/Copy.tsx | 1 - ui/desktop/src/components/icons/Document.tsx | 1 - ui/desktop/src/components/icons/Edit.tsx | 1 - ui/desktop/src/components/icons/Gear.tsx | 1 - ui/desktop/src/components/icons/Geese.tsx | 1 - ui/desktop/src/components/icons/Goose.tsx | 1 - ui/desktop/src/components/icons/Idea.tsx | 1 - ui/desktop/src/components/icons/More.tsx | 1 - ui/desktop/src/components/icons/Refresh.tsx | 1 - ui/desktop/src/components/icons/Send.tsx | 1 - .../src/components/icons/SensitiveHidden.tsx | 1 - .../src/components/icons/SensitiveVisible.tsx | 1 - ui/desktop/src/components/icons/Settings.tsx | 1 - ui/desktop/src/components/icons/Time.tsx | 1 - ui/desktop/src/components/icons/TrashIcon.tsx | 1 - .../src/components/sessions/SessionsView.tsx | 3 +- .../components/settings/OllamaBattleGame.tsx | 17 +++++---- .../settings/ProviderSetupModal.tsx | 4 +-- .../settings/api_keys/ActiveKeysContext.tsx | 2 +- .../components/settings/api_keys/utils.tsx | 10 +++--- .../settings/basic/ModeSelection.tsx | 2 +- .../settings/basic/ModeSelectionItem.tsx | 2 +- .../ConfigureBuiltInExtensionModal.tsx | 7 ++-- .../extensions/ConfigureExtensionModal.tsx | 7 ++-- .../extensions/ManualExtensionModal.tsx | 2 +- .../settings/models/AddModelInline.tsx | 6 ++-- .../settings/models/ModelContext.tsx | 2 +- .../settings/models/MoreModelsView.tsx | 1 - .../settings/models/ProviderButtons.tsx | 2 +- .../settings/models/RecentModels.tsx | 2 +- .../src/components/settings/models/utils.tsx | 2 +- .../settings/providers/BaseProviderGrid.tsx | 5 ++- .../providers/ConfigureProvidersGrid.tsx | 8 ++--- .../providers/ConfigureProvidersView.tsx | 1 - .../settings_v2/extensions/agent-api.ts | 4 +-- .../modal/ExtensionConfigFields.tsx | 1 - .../extensions/modal/ExtensionModal.tsx | 2 +- .../modal/ExtensionTimeoutField.tsx | 1 - .../subcomponents/ExtensionItem.tsx | 2 +- .../subcomponents/ExtensionList.tsx | 1 - .../settings_v2/extensions/utils.ts | 2 +- .../settings_v2/models/ModelsSection.tsx | 2 +- .../models/subcomponents/AddModelButton.tsx | 2 +- .../models/subcomponents/AddModelModal.tsx | 8 ++--- .../subcomponents/ModelSettingsButtons.tsx | 1 - .../providers/ProviderSettingsPage.tsx | 2 +- .../modal/ProviderConfiguationModal.tsx | 2 +- .../modal/subcomponents/ProviderLogo.tsx | 1 - .../subcomponents/ProviderSetupActions.tsx | 2 +- .../subcomponents/ProviderSetupHeader.tsx | 1 - .../subcomponents/SecureStorageNotice.tsx | 1 - .../modal/subcomponents/forms/OllamaForm.tsx | 1 - .../providers/subcomponents/CardHeader.tsx | 2 +- .../providers/subcomponents/ProviderCard.tsx | 2 +- .../buttons/DefaultCardButtons.tsx | 1 - .../subcomponents/utils/StringUtils.tsx | 1 - ui/desktop/src/components/ui/Box.tsx | 1 - .../src/components/ui/ConfirmationModal.tsx | 1 - ui/desktop/src/components/ui/CustomRadio.tsx | 1 - ui/desktop/src/components/ui/Select.tsx | 1 - ui/desktop/src/components/ui/Send.tsx | 1 - ui/desktop/src/components/ui/Stop.tsx | 1 - ui/desktop/src/components/ui/VertDots.tsx | 1 - ui/desktop/src/components/ui/X.tsx | 1 - ui/desktop/src/components/ui/icons.tsx | 1 - ui/desktop/src/json.d.ts | 35 +++++++++++++++++++ ui/desktop/src/main.ts | 12 +++---- ui/desktop/src/recipe/index.ts | 2 ++ ui/desktop/src/suspense-loader.tsx | 1 - ui/desktop/src/toasts.tsx | 1 - 108 files changed, 203 insertions(+), 194 deletions(-) diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 78cd5ed5..fa3121b6 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { IpcRendererEvent } from 'electron'; import { openSharedSessionFromDeepLink } from './sessionLinks'; import { initializeSystem } from './utils/providerUtils'; @@ -8,6 +8,7 @@ import { ToastContainer } from 'react-toastify'; import { toastService } from './toasts'; import { extractExtensionName } from './components/settings/extensions/utils'; import { GoosehintsModal } from './components/GoosehintsModal'; +import { type ExtensionConfig } from './extensions'; import ChatView from './components/ChatView'; import SuspenseLoader from './suspense-loader'; @@ -46,10 +47,28 @@ export type View = | 'recipeEditor' | 'permission'; -export type ViewOptions = - | SettingsViewOptions - | { resumedSession?: SessionDetails } - | Record; +export type ViewOptions = { + // Settings view options + extensionId?: string; + showEnvVars?: boolean; + deepLinkConfig?: ExtensionConfig; + + // Session view options + resumedSession?: SessionDetails; + sessionDetails?: SessionDetails; + error?: string; + shareToken?: string; + baseUrl?: string; + + // Recipe editor options + config?: unknown; + + // Permission view options + parentView?: View; + + // Generic options + [key: string]: unknown; +}; export type ViewConfig = { view: View; @@ -103,7 +122,7 @@ export default function App() { return `${cmd} ${args.join(' ')}`.trim(); } - function extractRemoteUrl(link: string): string { + function extractRemoteUrl(link: string): string | null { const url = new URL(link); return url.searchParams.get('url'); } @@ -164,7 +183,7 @@ export default function App() { if (provider && model) { setView('chat'); try { - await initializeSystem(provider, model, { + await initializeSystem(provider as string, model as string, { getExtensions, addExtension, }); @@ -289,7 +308,7 @@ export default function App() { }; setView(viewFromUrl, initialViewOptions); } else { - setView(viewFromUrl); + setView(viewFromUrl as View); } } window.electron.on('set-view', handleSetView); diff --git a/ui/desktop/src/components/AgentHeader.tsx b/ui/desktop/src/components/AgentHeader.tsx index 35f33a6b..d64b2bf8 100644 --- a/ui/desktop/src/components/AgentHeader.tsx +++ b/ui/desktop/src/components/AgentHeader.tsx @@ -1,4 +1,3 @@ -import React from 'react'; interface AgentHeaderProps { title: string; diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index 8767f9a5..77f4a2bb 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useState, useEffect, useCallback } from 'react'; +import React, { useRef, useState, useEffect, useMemo } from 'react'; import { Button } from './ui/button'; import type { View } from '../App'; import Stop from './ui/Stop'; @@ -148,21 +148,20 @@ export default function ChatInput({ }, [droppedFiles, processedFilePaths, displayValue]); // Debounced function to update actual value - const debouncedSetValue = useCallback((val: string) => { - debounce((value: string) => { + const debouncedSetValue = useMemo( + () => debounce((value: string) => { setValue(value); - }, 150)(val); - }, []); + }, 150), + [setValue] + ); // Debounced autosize function - const debouncedAutosize = useCallback( - (textArea: HTMLTextAreaElement) => { - debounce((element: HTMLTextAreaElement) => { - element.style.height = '0px'; // Reset height - const scrollHeight = element.scrollHeight; - element.style.height = Math.min(scrollHeight, maxHeight) + 'px'; - }, 150)(textArea); - }, + const debouncedAutosize = useMemo( + () => debounce((element: HTMLTextAreaElement) => { + element.style.height = '0px'; // Reset height + const scrollHeight = element.scrollHeight; + element.style.height = Math.min(scrollHeight, maxHeight) + 'px'; + }, 150), [maxHeight] ); diff --git a/ui/desktop/src/components/ChatView.tsx b/ui/desktop/src/components/ChatView.tsx index ca0fc0b3..0e54d0a1 100644 --- a/ui/desktop/src/components/ChatView.tsx +++ b/ui/desktop/src/components/ChatView.tsx @@ -272,7 +272,7 @@ function ChatContent({ // Update chat messages when they change and save to sessionStorage useEffect(() => { - setChat((prevChat) => { + setChat((prevChat: ChatType) => { const updatedChat = { ...prevChat, messages }; return updatedChat; }); diff --git a/ui/desktop/src/components/ConfigContext.tsx b/ui/desktop/src/components/ConfigContext.tsx index 76631ab6..82032dee 100644 --- a/ui/desktop/src/components/ConfigContext.tsx +++ b/ui/desktop/src/components/ConfigContext.tsx @@ -74,7 +74,7 @@ export const ConfigProvider: React.FC = ({ children }) => { const reloadConfig = useCallback(async () => { const response = await readAllConfig(); - setConfig(response.data.config || {}); + setConfig(response.data?.config || {}); }, []); const upsert = useCallback( @@ -186,7 +186,7 @@ export const ConfigProvider: React.FC = ({ children }) => { (async () => { // Load config const configResponse = await readAllConfig(); - setConfig(configResponse.data.config || {}); + setConfig(configResponse.data?.config || {}); // Load providers try { @@ -199,7 +199,7 @@ export const ConfigProvider: React.FC = ({ children }) => { // Load extensions try { const extensionsResponse = await apiGetExtensions(); - setExtensionsList(extensionsResponse.data.extensions); + setExtensionsList(extensionsResponse.data?.extensions || []); } catch (error) { console.error('Failed to load extensions:', error); } diff --git a/ui/desktop/src/components/ErrorBoundary.tsx b/ui/desktop/src/components/ErrorBoundary.tsx index eec01f6f..1c29d2a6 100644 --- a/ui/desktop/src/components/ErrorBoundary.tsx +++ b/ui/desktop/src/components/ErrorBoundary.tsx @@ -14,7 +14,7 @@ window.addEventListener('error', (event) => { ); }); -export function ErrorUI({ error }) { +export function ErrorUI({ error }: { error: Error }) { return (
diff --git a/ui/desktop/src/components/FlappyGoose.tsx b/ui/desktop/src/components/FlappyGoose.tsx index 3cd8b589..5cd9cb35 100644 --- a/ui/desktop/src/components/FlappyGoose.tsx +++ b/ui/desktop/src/components/FlappyGoose.tsx @@ -216,7 +216,7 @@ const FlappyGoose: React.FC = ({ onClose }) => { useEffect(() => { const frames = [svg1, svg7]; frames.forEach((src, index) => { - const img = new Image(); + const img = new Image() as HTMLImageElement; img.src = src; img.onload = () => { framesLoaded.current += 1; diff --git a/ui/desktop/src/components/GooseLogo.tsx b/ui/desktop/src/components/GooseLogo.tsx index 1df25c62..4d8ea402 100644 --- a/ui/desktop/src/components/GooseLogo.tsx +++ b/ui/desktop/src/components/GooseLogo.tsx @@ -1,7 +1,12 @@ -import React from 'react'; import { Goose, Rain } from './icons/Goose'; -export default function GooseLogo({ className = '', size = 'default', hover = true }) { +interface GooseLogoProps { + className?: string; + size?: 'default' | 'small'; + hover?: boolean; +} + +export default function GooseLogo({ className = '', size = 'default', hover = true }: GooseLogoProps) { const sizes = { default: { frame: 'w-16 h-16', @@ -13,15 +18,18 @@ export default function GooseLogo({ className = '', size = 'default', hover = tr rain: 'w-[150px] h-[150px]', goose: 'w-8 h-8', }, - }; + } as const; + + const currentSize = sizes[size]; + return (
- +
); } diff --git a/ui/desktop/src/components/GooseMessage.tsx b/ui/desktop/src/components/GooseMessage.tsx index 79f62b97..6066386b 100644 --- a/ui/desktop/src/components/GooseMessage.tsx +++ b/ui/desktop/src/components/GooseMessage.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useRef } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import LinkPreview from './LinkPreview'; import ImagePreview from './ImagePreview'; import GooseResponseForm from './GooseResponseForm'; diff --git a/ui/desktop/src/components/GoosehintsModal.tsx b/ui/desktop/src/components/GoosehintsModal.tsx index 8f78e886..19624efb 100644 --- a/ui/desktop/src/components/GoosehintsModal.tsx +++ b/ui/desktop/src/components/GoosehintsModal.tsx @@ -3,7 +3,7 @@ import { Card } from './ui/card'; import { Button } from './ui/button'; import { Check } from './icons'; -const Modal = ({ children }) => ( +const Modal = ({ children }: { children: React.ReactNode }) => (
@@ -48,13 +48,13 @@ const ModalHelpText = () => (
); -const ModalError = ({ error }) => ( +const ModalError = ({ error }: { error: Error }) => (
Error reading .goosehints file: {JSON.stringify(error)}
); -const ModalFileInfo = ({ filePath, found }) => ( +const ModalFileInfo = ({ filePath, found }: { filePath: string; found: boolean }) => (
{found ? (
@@ -66,7 +66,7 @@ const ModalFileInfo = ({ filePath, found }) => (
); -const ModalButtons = ({ onSubmit, onCancel }) => ( +const ModalButtons = ({ onSubmit, onCancel }: { onSubmit: () => void; onCancel: () => void }) => (
); -const getGoosehintsFile = async (filePath) => await window.electron.readFile(filePath); +const getGoosehintsFile = async (filePath: string) => await window.electron.readFile(filePath); type GoosehintsModalProps = { directory: string; diff --git a/ui/desktop/src/components/ImagePreview.tsx b/ui/desktop/src/components/ImagePreview.tsx index 7e6d66f5..29e0aff3 100644 --- a/ui/desktop/src/components/ImagePreview.tsx +++ b/ui/desktop/src/components/ImagePreview.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import { useState, useEffect } from 'react'; interface ImagePreviewProps { src: string; diff --git a/ui/desktop/src/components/LayingEggLoader.tsx b/ui/desktop/src/components/LayingEggLoader.tsx index 3d96947f..faa4f806 100644 --- a/ui/desktop/src/components/LayingEggLoader.tsx +++ b/ui/desktop/src/components/LayingEggLoader.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { Geese } from './icons/Geese'; export default function LayingEggLoader() { diff --git a/ui/desktop/src/components/LinkPreview.tsx b/ui/desktop/src/components/LinkPreview.tsx index 71b33417..f4fc835b 100644 --- a/ui/desktop/src/components/LinkPreview.tsx +++ b/ui/desktop/src/components/LinkPreview.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { Card } from './ui/card'; interface Metadata { @@ -85,10 +85,11 @@ export default function LinkPreview({ url }: LinkPreviewProps) { if (mounted) { setMetadata(data); } - } catch (error) { + } catch (err) { if (mounted) { - console.error('❌ Failed to fetch metadata:', error); - setError(error.message || 'Failed to fetch metadata'); + console.error('❌ Failed to fetch metadata:', err); + const errorMessage = err instanceof Error ? err.message : 'Failed to fetch metadata'; + setError(errorMessage); } } finally { if (mounted) { diff --git a/ui/desktop/src/components/LoadingGoose.tsx b/ui/desktop/src/components/LoadingGoose.tsx index 38677d18..e3c623f2 100644 --- a/ui/desktop/src/components/LoadingGoose.tsx +++ b/ui/desktop/src/components/LoadingGoose.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import GooseLogo from './GooseLogo'; const LoadingGoose = () => { diff --git a/ui/desktop/src/components/LoadingPlaceholder.tsx b/ui/desktop/src/components/LoadingPlaceholder.tsx index 2151fc25..4cae5174 100644 --- a/ui/desktop/src/components/LoadingPlaceholder.tsx +++ b/ui/desktop/src/components/LoadingPlaceholder.tsx @@ -1,4 +1,3 @@ -import React from 'react'; export function LoadingPlaceholder() { return ( diff --git a/ui/desktop/src/components/ProviderGrid.tsx b/ui/desktop/src/components/ProviderGrid.tsx index 9ad66b9d..1e3d6721 100644 --- a/ui/desktop/src/components/ProviderGrid.tsx +++ b/ui/desktop/src/components/ProviderGrid.tsx @@ -43,7 +43,7 @@ export function ProviderGrid({ onSubmit }: ProviderGridProps) { }); }, [activeKeys]); - const handleConfigure = async (provider) => { + const handleConfigure = async (provider: { id: string; name: string; isConfigured: boolean; description: string }) => { const providerId = provider.id.toLowerCase(); const modelName = getDefaultModel(providerId); @@ -63,7 +63,7 @@ export function ProviderGrid({ onSubmit }: ProviderGridProps) { onSubmit?.(); }; - const handleAddKeys = (provider) => { + const handleAddKeys = (provider: { id: string; name: string; isConfigured: boolean; description: string }) => { setSelectedId(provider.id); setShowSetupModal(true); }; @@ -74,7 +74,7 @@ export function ProviderGrid({ onSubmit }: ProviderGridProps) { const provider = providers.find((p) => p.id === selectedId)?.name; if (!provider) return; - const requiredKeys = required_keys[provider]; + const requiredKeys = required_keys[provider as keyof typeof required_keys]; if (!requiredKeys || requiredKeys.length === 0) { console.error(`No keys found for provider ${provider}`); return; @@ -145,12 +145,13 @@ export function ProviderGrid({ onSubmit }: ProviderGridProps) { setShowSetupModal(false); setSelectedId(null); - } catch (error) { - console.error('Error handling modal submit:', error); + } catch (err) { + console.error('Error handling modal submit:', err); + const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'; toastError({ title: provider, msg: `Failed to ${providers.find((p) => p.id === selectedId)?.isConfigured ? 'update' : 'add'} configuration`, - traceback: error.message, + traceback: errorMessage, }); } }; diff --git a/ui/desktop/src/components/RecipeEditor.tsx b/ui/desktop/src/components/RecipeEditor.tsx index efbb85d2..a621d32c 100644 --- a/ui/desktop/src/components/RecipeEditor.tsx +++ b/ui/desktop/src/components/RecipeEditor.tsx @@ -54,7 +54,7 @@ export default function RecipeEditor({ config }: RecipeEditorProps) { } } // Fall back to config if available, using extension names - const exts = []; + const exts: string[] = []; return exts; }); // Section visibility state @@ -125,7 +125,10 @@ export default function RecipeEditor({ config }: RecipeEditorProps) { delete cleanExtension.enabled; // Remove legacy envs which could potentially include secrets // env_keys will work but rely on the end user having setup those keys themselves - delete cleanExtension.envs; + if ('envs' in cleanExtension) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (cleanExtension as any).envs; + } return cleanExtension; }) .filter(Boolean) as FullExtensionConfig[], diff --git a/ui/desktop/src/components/Splash.tsx b/ui/desktop/src/components/Splash.tsx index 529dc343..ddad10d6 100644 --- a/ui/desktop/src/components/Splash.tsx +++ b/ui/desktop/src/components/Splash.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import SplashPills from './SplashPills'; import GooseLogo from './GooseLogo'; diff --git a/ui/desktop/src/components/SplashPills.tsx b/ui/desktop/src/components/SplashPills.tsx index 83bd9bf1..18506b75 100644 --- a/ui/desktop/src/components/SplashPills.tsx +++ b/ui/desktop/src/components/SplashPills.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import MarkdownContent from './MarkdownContent'; function truncateText(text: string, maxLength: number = 100): string { diff --git a/ui/desktop/src/components/ToolCallArguments.tsx b/ui/desktop/src/components/ToolCallArguments.tsx index 581f1580..b2e31a1f 100644 --- a/ui/desktop/src/components/ToolCallArguments.tsx +++ b/ui/desktop/src/components/ToolCallArguments.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import { useState } from 'react'; import MarkdownContent from './MarkdownContent'; import Expand from './ui/Expand'; diff --git a/ui/desktop/src/components/UserMessage.tsx b/ui/desktop/src/components/UserMessage.tsx index 4fad212f..a871b5b1 100644 --- a/ui/desktop/src/components/UserMessage.tsx +++ b/ui/desktop/src/components/UserMessage.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useMemo } from 'react'; +import { useRef, useMemo } from 'react'; import LinkPreview from './LinkPreview'; import ImagePreview from './ImagePreview'; import { extractUrls } from '../utils/urlUtils'; diff --git a/ui/desktop/src/components/WelcomeGooseLogo.tsx b/ui/desktop/src/components/WelcomeGooseLogo.tsx index 9fb17eb8..b47ab9fa 100644 --- a/ui/desktop/src/components/WelcomeGooseLogo.tsx +++ b/ui/desktop/src/components/WelcomeGooseLogo.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { Goose, Rain } from './icons/Goose'; export default function WelcomeGooseLogo({ className = '' }) { diff --git a/ui/desktop/src/components/WelcomeView.tsx b/ui/desktop/src/components/WelcomeView.tsx index 5003d740..2e5779e3 100644 --- a/ui/desktop/src/components/WelcomeView.tsx +++ b/ui/desktop/src/components/WelcomeView.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { ProviderGrid } from './ProviderGrid'; import { ScrollArea } from './ui/scroll-area'; import { Button } from './ui/button'; diff --git a/ui/desktop/src/components/alerts/AlertBox.tsx b/ui/desktop/src/components/alerts/AlertBox.tsx index fa89a5f0..c93f249f 100644 --- a/ui/desktop/src/components/alerts/AlertBox.tsx +++ b/ui/desktop/src/components/alerts/AlertBox.tsx @@ -33,10 +33,10 @@ export const AlertBox = ({ alert, className }: AlertBoxProps) => { className={cn( 'h-[2px] w-[2px] rounded-full', alert.type === AlertType.Info - ? i < Math.round((alert.progress.current / alert.progress.total) * 30) + ? i < Math.round((alert.progress!.current / alert.progress!.total) * 30) ? 'dark:bg-black bg-white' : 'dark:bg-black/20 bg-white/20' - : i < Math.round((alert.progress.current / alert.progress.total) * 30) + : i < Math.round((alert.progress!.current / alert.progress!.total) * 30) ? 'bg-white' : 'bg-white/20' )} @@ -46,18 +46,18 @@ export const AlertBox = ({ alert, className }: AlertBoxProps) => {
- {alert.progress.current >= 1000 - ? (alert.progress.current / 1000).toFixed(1) + 'k' - : alert.progress.current} + {alert.progress!.current >= 1000 + ? (alert.progress!.current / 1000).toFixed(1) + 'k' + : alert.progress!.current} - {Math.round((alert.progress.current / alert.progress.total) * 100)}% + {Math.round((alert.progress!.current / alert.progress!.total) * 100)}%
- {alert.progress.total >= 1000 - ? (alert.progress.total / 1000).toFixed(0) + 'k' - : alert.progress.total} + {alert.progress!.total >= 1000 + ? (alert.progress!.total / 1000).toFixed(0) + 'k' + : alert.progress!.total}
diff --git a/ui/desktop/src/components/bottom_menu/BottomMenuModeSelection.tsx b/ui/desktop/src/components/bottom_menu/BottomMenuModeSelection.tsx index 092a6c0b..eb9418dc 100644 --- a/ui/desktop/src/components/bottom_menu/BottomMenuModeSelection.tsx +++ b/ui/desktop/src/components/bottom_menu/BottomMenuModeSelection.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState, useCallback } from 'react'; +import { useEffect, useRef, useState, useCallback } from 'react'; import { all_goose_modes, ModeSelectionItem } from '../settings_v2/mode/ModeSelectionItem'; import { useConfig } from '../ConfigContext'; import { View, ViewOptions } from '../../App'; diff --git a/ui/desktop/src/components/conversation/SearchBar.tsx b/ui/desktop/src/components/conversation/SearchBar.tsx index e4ccf29f..6c3b91ee 100644 --- a/ui/desktop/src/components/conversation/SearchBar.tsx +++ b/ui/desktop/src/components/conversation/SearchBar.tsx @@ -168,7 +168,7 @@ export const SearchBar: React.FC = ({
{(() => { - return localSearchResults?.count > 0 && searchTerm + return localSearchResults?.count && localSearchResults.count > 0 && searchTerm ? `${localSearchResults.currentIndex}/${localSearchResults.count}` : null; })()} diff --git a/ui/desktop/src/components/conversation/SearchView.tsx b/ui/desktop/src/components/conversation/SearchView.tsx index 4f331dab..5024a332 100644 --- a/ui/desktop/src/components/conversation/SearchView.tsx +++ b/ui/desktop/src/components/conversation/SearchView.tsx @@ -231,23 +231,19 @@ export const SearchView: React.FC> = ({ highlighterRef.current = null; } - // Cancel any pending highlight operations - debouncedHighlight.cancel?.(); - // Clear search when closing onSearch?.('', false); - }, [debouncedHighlight, onSearch]); + }, [onSearch]); - // Clean up highlighter and debounced functions on unmount + // Clean up highlighter on unmount useEffect(() => { return () => { if (highlighterRef.current) { highlighterRef.current.destroy(); highlighterRef.current = null; } - debouncedHighlight.cancel?.(); }; - }, [debouncedHighlight]); + }, []); // Listen for keyboard events useEffect(() => { diff --git a/ui/desktop/src/components/icons/ArrowDown.tsx b/ui/desktop/src/components/icons/ArrowDown.tsx index 9a72d8c4..715a881a 100644 --- a/ui/desktop/src/components/icons/ArrowDown.tsx +++ b/ui/desktop/src/components/icons/ArrowDown.tsx @@ -1,4 +1,3 @@ -import React from 'react'; export default function ArrowDown({ className = '' }) { return ( diff --git a/ui/desktop/src/components/icons/ArrowUp.tsx b/ui/desktop/src/components/icons/ArrowUp.tsx index 0bef76a8..97a680d6 100644 --- a/ui/desktop/src/components/icons/ArrowUp.tsx +++ b/ui/desktop/src/components/icons/ArrowUp.tsx @@ -1,4 +1,3 @@ -import React from 'react'; export default function ArrowUp({ className = '' }) { return ( diff --git a/ui/desktop/src/components/icons/Attach.tsx b/ui/desktop/src/components/icons/Attach.tsx index 7e6f1e79..82cb1360 100644 --- a/ui/desktop/src/components/icons/Attach.tsx +++ b/ui/desktop/src/components/icons/Attach.tsx @@ -1,4 +1,3 @@ -import React from 'react'; export default function Attach({ className = '' }) { return ( diff --git a/ui/desktop/src/components/icons/Back.tsx b/ui/desktop/src/components/icons/Back.tsx index 1b7d0f16..ffce3cef 100644 --- a/ui/desktop/src/components/icons/Back.tsx +++ b/ui/desktop/src/components/icons/Back.tsx @@ -1,4 +1,3 @@ -import React from 'react'; export default function Back({ className = '' }) { return ( diff --git a/ui/desktop/src/components/icons/Bars.tsx b/ui/desktop/src/components/icons/Bars.tsx index 7951c278..60edc4b7 100644 --- a/ui/desktop/src/components/icons/Bars.tsx +++ b/ui/desktop/src/components/icons/Bars.tsx @@ -1,4 +1,3 @@ -import React from 'react'; export function Bars() { return ( diff --git a/ui/desktop/src/components/icons/ChatSmart.tsx b/ui/desktop/src/components/icons/ChatSmart.tsx index 9e901305..ef2563d0 100644 --- a/ui/desktop/src/components/icons/ChatSmart.tsx +++ b/ui/desktop/src/components/icons/ChatSmart.tsx @@ -1,4 +1,3 @@ -import React from 'react'; export default function ChatSmart({ className = '' }) { return ( diff --git a/ui/desktop/src/components/icons/Check.tsx b/ui/desktop/src/components/icons/Check.tsx index 589a4db8..8afa15c0 100644 --- a/ui/desktop/src/components/icons/Check.tsx +++ b/ui/desktop/src/components/icons/Check.tsx @@ -1,4 +1,3 @@ -import React from 'react'; export default function Check({ className = '' }) { return ( diff --git a/ui/desktop/src/components/icons/ChevronDown.tsx b/ui/desktop/src/components/icons/ChevronDown.tsx index 7af1e622..b730afa7 100644 --- a/ui/desktop/src/components/icons/ChevronDown.tsx +++ b/ui/desktop/src/components/icons/ChevronDown.tsx @@ -1,6 +1,5 @@ -import React from 'react'; -export default function ChevronDown({ className }) { +export default function ChevronDown({ className }: { className?: string }) { return ( {} diff --git a/ui/desktop/src/components/sessions/SessionsView.tsx b/ui/desktop/src/components/sessions/SessionsView.tsx index 0cd09550..739523d7 100644 --- a/ui/desktop/src/components/sessions/SessionsView.tsx +++ b/ui/desktop/src/components/sessions/SessionsView.tsx @@ -30,10 +30,11 @@ const SessionsView: React.FC = ({ setView }) => { // Keep the selected session null if there's an error setSelectedSession(null); + const errorMessage = err instanceof Error ? err.message : String(err); toastError({ title: 'Failed to load session. The file may be corrupted.', msg: 'Please try again later.', - traceback: err, + traceback: errorMessage, }); } finally { setIsLoadingSession(false); diff --git a/ui/desktop/src/components/settings/OllamaBattleGame.tsx b/ui/desktop/src/components/settings/OllamaBattleGame.tsx index c3b98f21..b282640d 100644 --- a/ui/desktop/src/components/settings/OllamaBattleGame.tsx +++ b/ui/desktop/src/components/settings/OllamaBattleGame.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef } from 'react'; // Import actual PNG images import llamaSprite from '../../assets/battle-game/llama.png'; @@ -22,11 +22,10 @@ interface OllamaBattleGameProps { requiredKeys: string[]; } -export function OllamaBattleGame({ onComplete, _requiredKeys }: OllamaBattleGameProps) { +export function OllamaBattleGame({ onComplete, requiredKeys: _ }: OllamaBattleGameProps) { // Use Audio element type for audioRef - const audioRef = useRef<{ play: () => Promise; pause: () => void; volume: number } | null>( - null - ); + // eslint-disable-next-line no-undef + const audioRef = useRef(null); const [isMuted, setIsMuted] = useState(false); const [battleState, setBattleState] = useState({ @@ -169,10 +168,10 @@ export function OllamaBattleGame({ onComplete, _requiredKeys }: OllamaBattleGame if (!currentStep) return; // Handle host input - if (currentStep.action === 'host_input' && value) { + if (currentStep.action === 'host_input' && value && currentStep.configKey) { setConfigValues((prev) => ({ ...prev, - [currentStep.configKey]: value, + [currentStep.configKey!]: value, })); return; } @@ -405,8 +404,8 @@ export function OllamaBattleGame({ onComplete, _requiredKeys }: OllamaBattleGame !battleState.processingAction && (
{(typeof battleSteps[battleState.currentStep].choices === 'function' - ? battleSteps[battleState.currentStep].choices(battleState.lastChoice || '') - : battleSteps[battleState.currentStep].choices + ? (battleSteps[battleState.currentStep].choices as (choice: string) => string[])(battleState.lastChoice || '') + : battleSteps[battleState.currentStep].choices as string[] )?.map((choice: string) => (
+ )} +
+
diff --git a/ui/desktop/src/components/ChatView.tsx b/ui/desktop/src/components/ChatView.tsx index a1aea580..fe523b77 100644 --- a/ui/desktop/src/components/ChatView.tsx +++ b/ui/desktop/src/components/ChatView.tsx @@ -34,6 +34,7 @@ import { ToolResponseMessageContent, ToolConfirmationRequestMessageContent, getTextContent, + TextContent, } from '../types/message'; export interface ChatType { @@ -245,12 +246,20 @@ function ChatContent({ // Create a new window for the recipe editor console.log('Opening recipe editor with config:', response.recipe); + const recipeConfig = { + id: response.recipe.title || 'untitled', + name: response.recipe.title || 'Untitled Recipe', + description: response.recipe.description || '', + instructions: response.recipe.instructions || '', + activities: response.recipe.activities || [], + prompt: response.recipe.prompt || '', + }; window.electron.createChatWindow( undefined, // query undefined, // dir undefined, // version undefined, // resumeSessionId - response.recipe, // recipe config + recipeConfig, // recipe config 'recipeEditor' // view type ); @@ -273,11 +282,8 @@ function ChatContent({ // Update chat messages when they change and save to sessionStorage useEffect(() => { - setChat((prevChat: ChatType) => { - const updatedChat = { ...prevChat, messages }; - return updatedChat; - }); - }, [messages, setChat]); + setChat({ ...chat, messages }); + }, [messages, setChat, chat]); useEffect(() => { if (messages.length > 0) { @@ -354,10 +360,11 @@ function ChatContent({ // check if the last message is a real user's message if (lastMessage && isUserMessage(lastMessage) && !isToolResponse) { // Get the text content from the last message before removing it - const textContent = lastMessage.content.find((c) => c.type === 'text')?.text || ''; + const textContent = lastMessage.content.find((c): c is TextContent => c.type === 'text'); + const textValue = textContent?.text || ''; // Set the text back to the input field - _setInput(textContent); + _setInput(textValue); // Remove the last user message if it's the most recent one if (messages.length > 1) { @@ -453,7 +460,8 @@ function ChatContent({ return filteredMessages .reduce((history, message) => { if (isUserMessage(message)) { - const text = message.content.find((c) => c.type === 'text')?.text?.trim(); + const textContent = message.content.find((c): c is TextContent => c.type === 'text'); + const text = textContent?.text?.trim(); if (text) { history.push(text); } @@ -468,7 +476,7 @@ function ChatContent({ const fetchSessionTokens = async () => { try { const sessionDetails = await fetchSessionDetails(chat.id); - setSessionTokenCount(sessionDetails.metadata.total_tokens); + setSessionTokenCount(sessionDetails.metadata.total_tokens || 0); } catch (err) { console.error('Error fetching session token count:', err); } @@ -535,7 +543,7 @@ function ChatContent({ {messages.length === 0 ? ( ) : ( diff --git a/ui/desktop/src/components/ConfigContext.tsx b/ui/desktop/src/components/ConfigContext.tsx index 82032dee..6e2a8032 100644 --- a/ui/desktop/src/components/ConfigContext.tsx +++ b/ui/desktop/src/components/ConfigContext.tsx @@ -148,7 +148,7 @@ export const ConfigProvider: React.FC = ({ children }) => { return extensionsList; } - const extensionResponse: ExtensionResponse = result.data; + const extensionResponse: ExtensionResponse = result.data!; setExtensionsList(extensionResponse.extensions); return extensionResponse.extensions; } @@ -173,8 +173,8 @@ export const ConfigProvider: React.FC = ({ children }) => { async (forceRefresh = false): Promise => { if (forceRefresh || providersList.length === 0) { const response = await providers(); - setProvidersList(response.data); - return response.data; + setProvidersList(response.data || []); + return response.data || []; } return providersList; }, @@ -191,7 +191,7 @@ export const ConfigProvider: React.FC = ({ children }) => { // Load providers try { const providersResponse = await providers(); - setProvidersList(providersResponse.data); + setProvidersList(providersResponse.data || []); } catch (error) { console.error('Failed to load providers:', error); } diff --git a/ui/desktop/src/components/ErrorBoundary.tsx b/ui/desktop/src/components/ErrorBoundary.tsx index 1c29d2a6..4b80cd32 100644 --- a/ui/desktop/src/components/ErrorBoundary.tsx +++ b/ui/desktop/src/components/ErrorBoundary.tsx @@ -51,7 +51,7 @@ export function ErrorUI({ error }: { error: Error }) { export class ErrorBoundary extends React.Component< { children: React.ReactNode }, - { error: Error; hasError: boolean } + { error: Error | null; hasError: boolean } > { constructor(props: { children: React.ReactNode }) { super(props); @@ -69,7 +69,7 @@ export class ErrorBoundary extends React.Component< render() { if (this.state.hasError) { - return ; + return ; } return this.props.children; } diff --git a/ui/desktop/src/components/FlappyGoose.tsx b/ui/desktop/src/components/FlappyGoose.tsx index 5cd9cb35..f9b8d54a 100644 --- a/ui/desktop/src/components/FlappyGoose.tsx +++ b/ui/desktop/src/components/FlappyGoose.tsx @@ -1,11 +1,5 @@ import React, { useEffect, useRef, useState, useCallback } from 'react'; -declare var requestAnimationFrame: (callback: FrameRequestCallback) => number; -declare class HTMLCanvasElement {} -declare class HTMLImageElement {} -declare class DOMHighResTimeStamp {} -declare class Image {} -declare type FrameRequestCallback = (time: DOMHighResTimeStamp) => void; import svg1 from '../images/loading-goose/1.svg'; import svg7 from '../images/loading-goose/7.svg'; @@ -20,9 +14,11 @@ interface FlappyGooseProps { } const FlappyGoose: React.FC = ({ onClose }) => { - const canvasRef = useRef(null); + // eslint-disable-next-line no-undef + const canvasRef = useRef(null); const [gameOver, setGameOver] = useState(false); const [displayScore, setDisplayScore] = useState(0); + // eslint-disable-next-line no-undef const gooseImages = useRef([]); const framesLoaded = useRef(0); const [imagesReady, setImagesReady] = useState(false); @@ -51,7 +47,7 @@ const FlappyGoose: React.FC = ({ onClose }) => { const OBSTACLE_WIDTH = 40; const FLAP_DURATION = 150; - const safeRequestAnimationFrame = useCallback((callback: FrameRequestCallback) => { + const safeRequestAnimationFrame = useCallback((callback: (time: number) => void) => { if (typeof window !== 'undefined' && typeof requestAnimationFrame !== 'undefined') { requestAnimationFrame(callback); } @@ -216,6 +212,7 @@ const FlappyGoose: React.FC = ({ onClose }) => { useEffect(() => { const frames = [svg1, svg7]; frames.forEach((src, index) => { + // eslint-disable-next-line no-undef const img = new Image() as HTMLImageElement; img.src = src; img.onload = () => { @@ -272,7 +269,9 @@ const FlappyGoose: React.FC = ({ onClose }) => { onClick={flap} > { + canvasRef.current = el; + }} style={{ border: '2px solid #333', borderRadius: '8px', diff --git a/ui/desktop/src/components/GooseLogo.tsx b/ui/desktop/src/components/GooseLogo.tsx index 4d8ea402..bdce688e 100644 --- a/ui/desktop/src/components/GooseLogo.tsx +++ b/ui/desktop/src/components/GooseLogo.tsx @@ -6,7 +6,11 @@ interface GooseLogoProps { hover?: boolean; } -export default function GooseLogo({ className = '', size = 'default', hover = true }: GooseLogoProps) { +export default function GooseLogo({ + className = '', + size = 'default', + hover = true, +}: GooseLogoProps) { const sizes = { default: { frame: 'w-16 h-16', @@ -19,9 +23,9 @@ export default function GooseLogo({ className = '', size = 'default', hover = tr goose: 'w-8 h-8', }, } as const; - + const currentSize = sizes[size]; - + return (
- +
)}
diff --git a/ui/desktop/src/components/GooseResponseForm.tsx b/ui/desktop/src/components/GooseResponseForm.tsx index 7c76d53e..621ae517 100644 --- a/ui/desktop/src/components/GooseResponseForm.tsx +++ b/ui/desktop/src/components/GooseResponseForm.tsx @@ -132,9 +132,14 @@ export default function GooseResponseForm({ return null; } - function isForm(f: DynamicForm) { + function isForm(f: DynamicForm | null): f is DynamicForm { return ( - f && f.title && f.description && f.fields && Array.isArray(f.fields) && f.fields.length > 0 + !!f && + !!f.title && + !!f.description && + !!f.fields && + Array.isArray(f.fields) && + f.fields.length > 0 ); } diff --git a/ui/desktop/src/components/GoosehintsModal.tsx b/ui/desktop/src/components/GoosehintsModal.tsx index 19624efb..f7cab002 100644 --- a/ui/desktop/src/components/GoosehintsModal.tsx +++ b/ui/desktop/src/components/GoosehintsModal.tsx @@ -96,9 +96,9 @@ type GoosehintsModalProps = { export const GoosehintsModal = ({ directory, setIsGoosehintsModalOpen }: GoosehintsModalProps) => { const goosehintsFilePath = `${directory}/.goosehints`; - const [goosehintsFile, setGoosehintsFile] = useState(null); + const [goosehintsFile, setGoosehintsFile] = useState(''); const [goosehintsFileFound, setGoosehintsFileFound] = useState(false); - const [goosehintsFileReadError, setGoosehintsFileReadError] = useState(null); + const [goosehintsFileReadError, setGoosehintsFileReadError] = useState(''); useEffect(() => { const fetchGoosehintsFile = async () => { @@ -106,7 +106,7 @@ export const GoosehintsModal = ({ directory, setIsGoosehintsModalOpen }: Goosehi const { file, error, found } = await getGoosehintsFile(goosehintsFilePath); setGoosehintsFile(file); setGoosehintsFileFound(found); - setGoosehintsFileReadError(error); + setGoosehintsFileReadError(error || ''); } catch (error) { console.error('Error fetching .goosehints file:', error); } @@ -125,7 +125,7 @@ export const GoosehintsModal = ({ directory, setIsGoosehintsModalOpen }: Goosehi
{goosehintsFileReadError ? ( - + ) : (
diff --git a/ui/desktop/src/components/LinkPreview.tsx b/ui/desktop/src/components/LinkPreview.tsx index f4fc835b..355d9154 100644 --- a/ui/desktop/src/components/LinkPreview.tsx +++ b/ui/desktop/src/components/LinkPreview.tsx @@ -54,9 +54,9 @@ async function fetchMetadata(url: string): Promise { return { title: title || url, - description, + description: description || undefined, favicon, - image, + image: image || undefined, url, }; } catch (error) { diff --git a/ui/desktop/src/components/LoadingPlaceholder.tsx b/ui/desktop/src/components/LoadingPlaceholder.tsx index 4cae5174..248688cd 100644 --- a/ui/desktop/src/components/LoadingPlaceholder.tsx +++ b/ui/desktop/src/components/LoadingPlaceholder.tsx @@ -1,4 +1,3 @@ - export function LoadingPlaceholder() { return (
diff --git a/ui/desktop/src/components/ProviderGrid.tsx b/ui/desktop/src/components/ProviderGrid.tsx index 1e3d6721..553bd0a5 100644 --- a/ui/desktop/src/components/ProviderGrid.tsx +++ b/ui/desktop/src/components/ProviderGrid.tsx @@ -43,10 +43,15 @@ export function ProviderGrid({ onSubmit }: ProviderGridProps) { }); }, [activeKeys]); - const handleConfigure = async (provider: { id: string; name: string; isConfigured: boolean; description: string }) => { + const handleConfigure = async (provider: { + id: string; + name: string; + isConfigured: boolean; + description: string; + }) => { const providerId = provider.id.toLowerCase(); - const modelName = getDefaultModel(providerId); + const modelName = getDefaultModel(providerId) || 'default-model'; const model = createSelectedModel(providerId, modelName); await initializeSystem(providerId, model.name); @@ -63,7 +68,12 @@ export function ProviderGrid({ onSubmit }: ProviderGridProps) { onSubmit?.(); }; - const handleAddKeys = (provider: { id: string; name: string; isConfigured: boolean; description: string }) => { + const handleAddKeys = (provider: { + id: string; + name: string; + isConfigured: boolean; + description: string; + }) => { setSelectedId(provider.id); setShowSetupModal(true); }; @@ -189,9 +199,9 @@ export function ProviderGrid({ onSubmit }: ProviderGridProps) { {showSetupModal && selectedId && (
p.id === selectedId)?.name} - model="Example Model" - endpoint="Example Endpoint" + provider={providers.find((p) => p.id === selectedId)?.name || 'Unknown Provider'} + _model="Example Model" + _endpoint="Example Endpoint" onSubmit={handleModalSubmit} onCancel={() => { setShowSetupModal(false); diff --git a/ui/desktop/src/components/RecipeEditor.tsx b/ui/desktop/src/components/RecipeEditor.tsx index 7f10d326..2c8ac36e 100644 --- a/ui/desktop/src/components/RecipeEditor.tsx +++ b/ui/desktop/src/components/RecipeEditor.tsx @@ -123,13 +123,13 @@ export default function RecipeEditor({ config }: RecipeEditorProps) { if (!extension) return null; // Create a clean copy of the extension configuration - const cleanExtension = { ...extension }; - delete cleanExtension.enabled; + const { enabled: _enabled, ...cleanExtension } = extension; // Remove legacy envs which could potentially include secrets // env_keys will work but rely on the end user having setup those keys themselves if ('envs' in cleanExtension) { // eslint-disable-next-line @typescript-eslint/no-explicit-any - delete (cleanExtension as any).envs; + const { envs: _envs, ...finalExtension } = cleanExtension as any; + return finalExtension; } return cleanExtension; }) diff --git a/ui/desktop/src/components/ToolCallWithResponse.tsx b/ui/desktop/src/components/ToolCallWithResponse.tsx index e33082ff..1bf602ef 100644 --- a/ui/desktop/src/components/ToolCallWithResponse.tsx +++ b/ui/desktop/src/components/ToolCallWithResponse.tsx @@ -129,7 +129,7 @@ function ToolCallView({ const toolResults: { result: Content; isExpandToolResults: boolean }[] = loadingStatus === 'success' && Array.isArray(toolResponse?.toolResult.value) - ? toolResponse.toolResult.value + ? toolResponse!.toolResult.value .filter((item) => { const audience = item.annotations?.audience as string[] | undefined; return !audience || audience.includes('user'); @@ -322,7 +322,7 @@ function ToolLogsView({ working: boolean; isStartExpanded?: boolean; }) { - const boxRef = useRef(null); + const boxRef = useRef(null); // Whenever logs update, jump to the newest entry useEffect(() => { diff --git a/ui/desktop/src/components/conversation/SearchBar.tsx b/ui/desktop/src/components/conversation/SearchBar.tsx index 6c3b91ee..2c47e555 100644 --- a/ui/desktop/src/components/conversation/SearchBar.tsx +++ b/ui/desktop/src/components/conversation/SearchBar.tsx @@ -69,13 +69,13 @@ export const SearchBar: React.FC = ({ } }, [initialSearchTerm, caseSensitive, debouncedSearchRef]); - const [localSearchResults, setLocalSearchResults] = useState(null); + const [localSearchResults, setLocalSearchResults] = useState(undefined); // Sync external search results with local state useEffect(() => { // Only set results if we have a search term if (!searchTerm) { - setLocalSearchResults(null); + setLocalSearchResults(undefined); } else { setLocalSearchResults(searchResults); } diff --git a/ui/desktop/src/components/conversation/SearchView.tsx b/ui/desktop/src/components/conversation/SearchView.tsx index 5024a332..2570fc5d 100644 --- a/ui/desktop/src/components/conversation/SearchView.tsx +++ b/ui/desktop/src/components/conversation/SearchView.tsx @@ -314,7 +314,7 @@ export const SearchView: React.FC> = ({
{ if (el) { - containerRef.current = el; + containerRef.current = el as SearchContainerElement; // Expose the highlighter instance containerRef.current._searchHighlighter = highlighterRef.current; } @@ -326,7 +326,7 @@ export const SearchView: React.FC> = ({ onSearch={handleSearch} onClose={handleCloseSearch} onNavigate={handleNavigate} - searchResults={searchResults || internalSearchResults} + searchResults={searchResults || internalSearchResults || undefined} inputRef={searchInputRef} initialSearchTerm={initialSearchTerm} /> diff --git a/ui/desktop/src/components/icons/ArrowDown.tsx b/ui/desktop/src/components/icons/ArrowDown.tsx index 715a881a..a3f1cd5e 100644 --- a/ui/desktop/src/components/icons/ArrowDown.tsx +++ b/ui/desktop/src/components/icons/ArrowDown.tsx @@ -1,4 +1,3 @@ - export default function ArrowDown({ className = '' }) { return ( diff --git a/ui/desktop/src/components/icons/ChatSmart.tsx b/ui/desktop/src/components/icons/ChatSmart.tsx index ef2563d0..acdd377a 100644 --- a/ui/desktop/src/components/icons/ChatSmart.tsx +++ b/ui/desktop/src/components/icons/ChatSmart.tsx @@ -1,4 +1,3 @@ - export default function ChatSmart({ className = '' }) { return ( void; children: React.ReactNode; @@ -187,7 +196,7 @@ export default function MoreMenu({ setOpen(false); window.electron.createChatWindow( undefined, - window.appConfig.get('GOOSE_WORKING_DIR') + window.appConfig.get('GOOSE_WORKING_DIR') as string | undefined ); }} subtitle="Start a new session in the current directory" @@ -244,7 +253,7 @@ export default function MoreMenu({ undefined, // dir undefined, // version undefined, // resumeSessionId - recipeConfig, // recipe config + recipeConfig as RecipeConfig, // recipe config 'recipeEditor' // view type ); }} diff --git a/ui/desktop/src/components/more_menu/MoreMenuLayout.tsx b/ui/desktop/src/components/more_menu/MoreMenuLayout.tsx index b030d65f..07479537 100644 --- a/ui/desktop/src/components/more_menu/MoreMenuLayout.tsx +++ b/ui/desktop/src/components/more_menu/MoreMenuLayout.tsx @@ -44,7 +44,7 @@ export default function MoreMenuLayout({ >
- {window.appConfig.get('GOOSE_WORKING_DIR')} + {String(window.appConfig.get('GOOSE_WORKING_DIR'))}
@@ -54,7 +54,10 @@ export default function MoreMenuLayout({ - + {})} + setIsGoosehintsModalOpen={setIsGoosehintsModalOpen || (() => {})} + />
)}
diff --git a/ui/desktop/src/components/schedule/CreateScheduleModal.tsx b/ui/desktop/src/components/schedule/CreateScheduleModal.tsx index c06d45db..9f6f80b8 100644 --- a/ui/desktop/src/components/schedule/CreateScheduleModal.tsx +++ b/ui/desktop/src/components/schedule/CreateScheduleModal.tsx @@ -96,12 +96,12 @@ function parseDeepLink(deepLink: string): Recipe | null { if (url.protocol !== 'goose:' || (url.hostname !== 'bot' && url.hostname !== 'recipe')) { return null; } - + const configParam = url.searchParams.get('config'); if (!configParam) { return null; } - + const configJson = Buffer.from(configParam, 'base64').toString('utf-8'); return JSON.parse(configJson) as Recipe; } catch (error) { @@ -128,89 +128,87 @@ function recipeToYaml(recipe: Recipe): string { } if (recipe.extensions && recipe.extensions.length > 0) { - cleanRecipe.extensions = recipe.extensions.map(ext => { + cleanRecipe.extensions = recipe.extensions.map((ext) => { const cleanExt: CleanExtension = { name: ext.name, type: 'builtin', // Default type, will be overridden below }; - - // Handle different extension types + + // Handle different extension types using type assertions if ('type' in ext && ext.type) { cleanExt.type = ext.type as CleanExtension['type']; - - // Add type-specific fields based on the ExtensionConfig union types - switch (ext.type) { - case 'sse': - if ('uri' in ext && ext.uri) { - cleanExt.uri = ext.uri as string; - } - break; - case 'stdio': - if ('cmd' in ext && ext.cmd) { - cleanExt.cmd = ext.cmd as string; - } - if ('args' in ext && ext.args) { - cleanExt.args = ext.args as string[]; - } - break; - case 'builtin': - if ('display_name' in ext && ext.display_name) { - cleanExt.display_name = ext.display_name as string; - } - break; - case 'frontend': - if ('tools' in ext && ext.tools) { - cleanExt.tools = ext.tools as unknown[]; - } - if ('instructions' in ext && ext.instructions) { - cleanExt.instructions = ext.instructions as string; - } - break; + + // Use type assertions to access properties safely + const extAny = ext as Record; + + if (ext.type === 'sse' && extAny.uri) { + cleanExt.uri = extAny.uri as string; + } else if (ext.type === 'stdio') { + if (extAny.cmd) { + cleanExt.cmd = extAny.cmd as string; + } + if (extAny.args) { + cleanExt.args = extAny.args as string[]; + } + } else if (ext.type === 'builtin' && extAny.display_name) { + cleanExt.display_name = extAny.display_name as string; + } + + // Handle frontend type separately to avoid TypeScript narrowing issues + if ((ext.type as string) === 'frontend') { + if (extAny.tools) { + cleanExt.tools = extAny.tools as unknown[]; + } + if (extAny.instructions) { + cleanExt.instructions = extAny.instructions as string; + } } } else { // Fallback: try to infer type from available fields - if ('cmd' in ext && ext.cmd) { + const extAny = ext as Record; + + if (extAny.cmd) { cleanExt.type = 'stdio'; - cleanExt.cmd = ext.cmd as string; - if ('args' in ext && ext.args) { - cleanExt.args = ext.args as string[]; + cleanExt.cmd = extAny.cmd as string; + if (extAny.args) { + cleanExt.args = extAny.args as string[]; } - } else if ('command' in ext && ext.command) { + } else if (extAny.command) { // Handle legacy 'command' field by converting to 'cmd' cleanExt.type = 'stdio'; - cleanExt.cmd = ext.command as string; - } else if ('uri' in ext && ext.uri) { + cleanExt.cmd = extAny.command as string; + } else if (extAny.uri) { cleanExt.type = 'sse'; - cleanExt.uri = ext.uri as string; - } else if ('tools' in ext && ext.tools) { + cleanExt.uri = extAny.uri as string; + } else if (extAny.tools) { cleanExt.type = 'frontend'; - cleanExt.tools = ext.tools as unknown[]; - if ('instructions' in ext && ext.instructions) { - cleanExt.instructions = ext.instructions as string; + cleanExt.tools = extAny.tools as unknown[]; + if (extAny.instructions) { + cleanExt.instructions = extAny.instructions as string; } } else { // Default to builtin if we can't determine type cleanExt.type = 'builtin'; } } - + // Add common optional fields if (ext.env_keys && ext.env_keys.length > 0) { cleanExt.env_keys = ext.env_keys; } - + if ('timeout' in ext && ext.timeout) { cleanExt.timeout = ext.timeout as number; } - + if ('description' in ext && ext.description) { cleanExt.description = ext.description as string; } - + if ('bundled' in ext && ext.bundled !== undefined) { cleanExt.bundled = ext.bundled as boolean; } - + return cleanExt; }); } @@ -258,27 +256,35 @@ export const CreateScheduleModal: React.FC = ({ const [readableCronExpression, setReadableCronExpression] = useState(''); const [internalValidationError, setInternalValidationError] = useState(null); - const handleDeepLinkChange = useCallback((value: string) => { - setDeepLinkInput(value); - setInternalValidationError(null); - - if (value.trim()) { - const recipe = parseDeepLink(value.trim()); - if (recipe) { - setParsedRecipe(recipe); - // Auto-populate schedule ID from recipe title if available - if (recipe.title && !scheduleId) { - const cleanId = recipe.title.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-'); - setScheduleId(cleanId); + const handleDeepLinkChange = useCallback( + (value: string) => { + setDeepLinkInput(value); + setInternalValidationError(null); + + if (value.trim()) { + const recipe = parseDeepLink(value.trim()); + if (recipe) { + setParsedRecipe(recipe); + // Auto-populate schedule ID from recipe title if available + if (recipe.title && !scheduleId) { + const cleanId = recipe.title + .toLowerCase() + .replace(/[^a-z0-9-]/g, '-') + .replace(/-+/g, '-'); + setScheduleId(cleanId); + } + } else { + setParsedRecipe(null); + setInternalValidationError( + 'Invalid deep link format. Please use a goose://bot or goose://recipe link.' + ); } } else { setParsedRecipe(null); - setInternalValidationError('Invalid deep link format. Please use a goose://bot or goose://recipe link.'); } - } else { - setParsedRecipe(null); - } - }, [scheduleId]); + }, + [scheduleId] + ); useEffect(() => { // Check for pending deep link when modal opens @@ -420,7 +426,7 @@ export const CreateScheduleModal: React.FC = ({ } let finalRecipeSource = ''; - + if (sourceType === 'file') { if (!recipeSourcePath) { setInternalValidationError('Recipe source file is required.'); @@ -436,7 +442,7 @@ export const CreateScheduleModal: React.FC = ({ setInternalValidationError('Invalid deep link. Please check the format.'); return; } - + try { // Convert recipe to YAML and save to a temporary file const yamlContent = recipeToYaml(parsedRecipe); @@ -444,14 +450,14 @@ export const CreateScheduleModal: React.FC = ({ const tempFileName = `schedule-${scheduleId}-${Date.now()}.yaml`; const tempDir = window.electron.getConfig().GOOSE_WORKING_DIR || '.'; const tempFilePath = `${tempDir}/${tempFileName}`; - + // Write the YAML file const writeSuccess = await window.electron.writeFile(tempFilePath, yamlContent); if (!writeSuccess) { setInternalValidationError('Failed to create temporary recipe file.'); return; } - + finalRecipeSource = tempFilePath; } catch (error) { console.error('Failed to convert recipe to YAML:', error); @@ -610,7 +616,8 @@ export const CreateScheduleModal: React.FC = ({ instanceId="frequency-select-modal" options={frequencies} value={frequencies.find((f) => f.value === frequency)} - onChange={(selectedOption: FrequencyOption | null) => { + onChange={(newValue: unknown) => { + const selectedOption = newValue as FrequencyOption | null; if (selectedOption) setFrequency(selectedOption.value); }} placeholder="Select frequency..." diff --git a/ui/desktop/src/components/schedule/EditScheduleModal.tsx b/ui/desktop/src/components/schedule/EditScheduleModal.tsx index 4859180b..5a130b6c 100644 --- a/ui/desktop/src/components/schedule/EditScheduleModal.tsx +++ b/ui/desktop/src/components/schedule/EditScheduleModal.tsx @@ -58,16 +58,40 @@ const parseCronExpression = (cron: string) => { if (dayOfMonth !== '*' && month !== '*' && dayOfWeek === '*') { return { frequency: 'once' as FrequencyValue, minutes, hours, dayOfMonth, month }; } - if (minutes !== '*' && hours === '*' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') { + if ( + minutes !== '*' && + hours === '*' && + dayOfMonth === '*' && + month === '*' && + dayOfWeek === '*' + ) { return { frequency: 'hourly' as FrequencyValue, minutes }; } - if (minutes !== '*' && hours !== '*' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') { + if ( + minutes !== '*' && + hours !== '*' && + dayOfMonth === '*' && + month === '*' && + dayOfWeek === '*' + ) { return { frequency: 'daily' as FrequencyValue, minutes, hours }; } - if (minutes !== '*' && hours !== '*' && dayOfMonth === '*' && month === '*' && dayOfWeek !== '*') { + if ( + minutes !== '*' && + hours !== '*' && + dayOfMonth === '*' && + month === '*' && + dayOfWeek !== '*' + ) { return { frequency: 'weekly' as FrequencyValue, minutes, hours, dayOfWeek }; } - if (minutes !== '*' && hours !== '*' && dayOfMonth !== '*' && month === '*' && dayOfWeek === '*') { + if ( + minutes !== '*' && + hours !== '*' && + dayOfMonth !== '*' && + month === '*' && + dayOfWeek === '*' + ) { return { frequency: 'monthly' as FrequencyValue, minutes, hours, dayOfMonth }; } @@ -98,32 +122,40 @@ export const EditScheduleModal: React.FC = ({ useEffect(() => { if (schedule && isOpen) { const parsed = parseCronExpression(schedule.cron); - + if (parsed) { setFrequency(parsed.frequency); - + switch (parsed.frequency) { case 'once': // For 'once', we'd need to reconstruct the date from cron parts // This is complex, so we'll default to current date/time for now setSelectedDate(new Date().toISOString().split('T')[0]); - setSelectedTime(`${parsed.hours?.padStart(2, '0')}:${parsed.minutes?.padStart(2, '0')}`); + setSelectedTime( + `${parsed.hours?.padStart(2, '0')}:${parsed.minutes?.padStart(2, '0')}` + ); break; case 'hourly': setSelectedMinute(parsed.minutes || '0'); break; case 'daily': - setSelectedTime(`${parsed.hours?.padStart(2, '0')}:${parsed.minutes?.padStart(2, '0')}`); + setSelectedTime( + `${parsed.hours?.padStart(2, '0')}:${parsed.minutes?.padStart(2, '0')}` + ); break; case 'weekly': - setSelectedTime(`${parsed.hours?.padStart(2, '0')}:${parsed.minutes?.padStart(2, '0')}`); + setSelectedTime( + `${parsed.hours?.padStart(2, '0')}:${parsed.minutes?.padStart(2, '0')}` + ); if (parsed.dayOfWeek) { - const days = parsed.dayOfWeek.split(',').map(d => d.trim()); + const days = parsed.dayOfWeek.split(',').map((d) => d.trim()); setSelectedDaysOfWeek(new Set(days)); } break; case 'monthly': - setSelectedTime(`${parsed.hours?.padStart(2, '0')}:${parsed.minutes?.padStart(2, '0')}`); + setSelectedTime( + `${parsed.hours?.padStart(2, '0')}:${parsed.minutes?.padStart(2, '0')}` + ); setSelectedDayOfMonth(parsed.dayOfMonth || '1'); break; } @@ -132,7 +164,7 @@ export const EditScheduleModal: React.FC = ({ setFrequency('daily'); setSelectedTime('09:00'); } - + setInternalValidationError(null); } }, [schedule, isOpen]); @@ -287,7 +319,8 @@ export const EditScheduleModal: React.FC = ({ instanceId="frequency-select-modal" options={frequencies} value={frequencies.find((f) => f.value === frequency)} - onChange={(selectedOption: FrequencyOption | null) => { + onChange={(newValue: unknown) => { + const selectedOption = newValue as FrequencyOption | null; if (selectedOption) setFrequency(selectedOption.value); }} placeholder="Select frequency..." @@ -431,4 +464,4 @@ export const EditScheduleModal: React.FC = ({
); -}; \ No newline at end of file +}; diff --git a/ui/desktop/src/components/sessions/SessionViewComponents.tsx b/ui/desktop/src/components/sessions/SessionViewComponents.tsx index 3c0f4dce..c7d76c5f 100644 --- a/ui/desktop/src/components/sessions/SessionViewComponents.tsx +++ b/ui/desktop/src/components/sessions/SessionViewComponents.tsx @@ -7,7 +7,11 @@ import { ScrollArea } from '../ui/scroll-area'; import MarkdownContent from '../MarkdownContent'; import ToolCallWithResponse from '../ToolCallWithResponse'; import ImagePreview from '../ImagePreview'; -import { ToolRequestMessageContent, ToolResponseMessageContent } from '../../types/message'; +import { + ToolRequestMessageContent, + ToolResponseMessageContent, + TextContent, +} from '../../types/message'; import { type Message } from '../../types/message'; import { formatMessageTimestamp } from '../../utils/timeUtils'; import { extractImagePaths, removeImagePathsFromText } from '../../utils/imageUtils'; @@ -109,7 +113,7 @@ export const SessionMessages: React.FC = ({ .map((message, index) => { // Extract text content from the message let textContent = message.content - .filter((c) => c.type === 'text') + .filter((c): c is TextContent => c.type === 'text') .map((c) => c.text) .join('\n'); diff --git a/ui/desktop/src/components/sessions/SharedSessionView.tsx b/ui/desktop/src/components/sessions/SharedSessionView.tsx index 7fa04bff..d13753ec 100644 --- a/ui/desktop/src/components/sessions/SharedSessionView.tsx +++ b/ui/desktop/src/components/sessions/SharedSessionView.tsx @@ -33,13 +33,13 @@ const SharedSessionView: React.FC = ({
- {formatMessageTimestamp(session.messages[0]?.created)} + {session ? formatMessageTimestamp(session.messages[0]?.created) : 'Unknown'} - {session.message_count} + {session ? session.message_count : 0} - {session.total_tokens !== null && ( + {session && session.total_tokens !== null && ( {session.total_tokens.toLocaleString()} @@ -49,7 +49,7 @@ const SharedSessionView: React.FC = ({
- {session.working_dir} + {session ? session.working_dir : 'Unknown'}
diff --git a/ui/desktop/src/components/settings/OllamaBattleGame.tsx b/ui/desktop/src/components/settings/OllamaBattleGame.tsx index b282640d..729329bd 100644 --- a/ui/desktop/src/components/settings/OllamaBattleGame.tsx +++ b/ui/desktop/src/components/settings/OllamaBattleGame.tsx @@ -404,8 +404,12 @@ export function OllamaBattleGame({ onComplete, requiredKeys: _ }: OllamaBattleGa !battleState.processingAction && (
{(typeof battleSteps[battleState.currentStep].choices === 'function' - ? (battleSteps[battleState.currentStep].choices as (choice: string) => string[])(battleState.lastChoice || '') - : battleSteps[battleState.currentStep].choices as string[] + ? ( + battleSteps[battleState.currentStep].choices as ( + choice: string + ) => string[] + )(battleState.lastChoice || '') + : (battleSteps[battleState.currentStep].choices as string[]) )?.map((choice: string) => ( -
-
- )} - - {/* Continue button for messages */} - {!battleSteps[battleState.currentStep].action && !battleState.processingAction && ( - - )} -
- )} -
- - {/* Black corners for that classic Pokemon feel */} -
-
-
-
-
- - ); -} diff --git a/ui/desktop/src/components/settings/ProviderSetupModal.tsx b/ui/desktop/src/components/settings/ProviderSetupModal.tsx deleted file mode 100644 index 28566e98..00000000 --- a/ui/desktop/src/components/settings/ProviderSetupModal.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import React from 'react'; -import { Card } from '../ui/card'; -import { Lock } from 'lucide-react'; -import { Input } from '../ui/input'; -import { Button } from '../ui/button'; -import { required_keys, default_key_value } from './models/hardcoded_stuff'; -import { isSecretKey } from './api_keys/utils'; -import { OllamaBattleGame } from './OllamaBattleGame'; - -interface ProviderSetupModalProps { - provider: string; - _model: string; - _endpoint: string; - title?: string; - onSubmit: (configValues: { [key: string]: string }) => void; - onCancel: () => void; - forceBattle?: boolean; -} - -export function ProviderSetupModal({ - provider, - _model: _, - _endpoint: __, - title, - onSubmit, - onCancel, - forceBattle = false, -}: ProviderSetupModalProps) { - const [configValues, setConfigValues] = React.useState<{ [key: string]: string }>( - default_key_value - ); - const requiredKeys = (required_keys as Record)[provider] || ['API Key']; - const headerText = title || `Setup ${provider}`; - - const shouldShowBattle = React.useMemo(() => { - if (forceBattle) return true; - if (provider.toLowerCase() !== 'ollama') return false; - - const now = new Date(); - return now.getMinutes() === 0; - }, [provider, forceBattle]); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - onSubmit(configValues); - }; - - return ( -
- -
- {/* Header */} -
-

{headerText}

-
- - {provider.toLowerCase() === 'ollama' && shouldShowBattle ? ( - - ) : ( - -
- {requiredKeys.map((keyName: string) => ( -
- - setConfigValues((prev) => ({ - ...prev, - [keyName]: e.target.value, - })) - } - placeholder={keyName} - className="w-full h-14 px-4 font-regular rounded-lg border shadow-none border-gray-300 bg-white text-lg placeholder:text-gray-400 font-regular text-gray-900" - required - /> -
- ))} -
{ - if (provider.toLowerCase() === 'ollama') { - onCancel(); - onSubmit({ forceBattle: 'true' }); - } - }} - > - - {`Your configuration values will be stored securely in the keychain and used only for making requests to ${provider}`} -
-
- - {/* Actions */} -
- - -
- - )} -
-
-
- ); -} diff --git a/ui/desktop/src/components/settings/SettingsView.tsx b/ui/desktop/src/components/settings/SettingsView.tsx index b0c0c225..54d95b92 100644 --- a/ui/desktop/src/components/settings/SettingsView.tsx +++ b/ui/desktop/src/components/settings/SettingsView.tsx @@ -1,57 +1,19 @@ -import React, { useState, useEffect } from 'react'; -import { IpcRendererEvent } from 'electron'; import { ScrollArea } from '../ui/scroll-area'; -import { Settings as SettingsType } from './types'; -import { - FullExtensionConfig, - addExtension, - removeExtension, - BUILT_IN_EXTENSIONS, -} from '../../extensions'; -import { ConfigureExtensionModal } from './extensions/ConfigureExtensionModal'; -import { ManualExtensionModal } from './extensions/ManualExtensionModal'; -import { ConfigureBuiltInExtensionModal } from './extensions/ConfigureBuiltInExtensionModal'; import BackButton from '../ui/BackButton'; -import { RecentModelsRadio } from './models/RecentModels'; -import { ExtensionItem } from './extensions/ExtensionItem'; -import { View, ViewOptions } from '../../App'; -import { ModeSelection } from './basic/ModeSelection'; -import SessionSharingSection from './session/SessionSharingSection'; -import { toastSuccess } from '../../toasts'; +import type { View, ViewOptions } from '../../App'; +import ExtensionsSection from './extensions/ExtensionsSection'; +import ModelsSection from './models/ModelsSection'; +import { ModeSection } from './mode/ModeSection'; +import { ToolSelectionStrategySection } from './tool_selection_strategy/ToolSelectionStrategySection'; +import SessionSharingSection from './sessions/SessionSharingSection'; +import { ResponseStylesSection } from './response_styles/ResponseStylesSection'; +import AppSettingsSection from './app/AppSettingsSection'; +import { ExtensionConfig } from '../../api'; import MoreMenuLayout from '../more_menu/MoreMenuLayout'; -const EXTENSIONS_DESCRIPTION = - 'The Model Context Protocol (MCP) is a system that allows AI models to securely connect with local or remote resources using standard server setups. It works like a client-server setup and expands AI capabilities using three main components: Prompts, Resources, and Tools.'; - -const EXTENSIONS_SITE_LINK = 'https://block.github.io/goose/v1/extensions/'; - -const DEFAULT_SETTINGS: SettingsType = { - models: [ - { - id: 'gpt4', - name: 'GPT 4.0', - description: 'Standard config', - enabled: false, - }, - { - id: 'gpt4lite', - name: 'GPT 4.0 lite', - description: 'Standard config', - enabled: false, - }, - { - id: 'claude', - name: 'Claude', - description: 'Standard config', - enabled: true, - }, - ], - extensions: BUILT_IN_EXTENSIONS, -}; - export type SettingsViewOptions = { - extensionId: string; - showEnvVars: boolean; + deepLinkConfig?: ExtensionConfig; + showEnvVars?: boolean; }; export default function SettingsView({ @@ -63,133 +25,8 @@ export default function SettingsView({ setView: (view: View, viewOptions?: ViewOptions) => void; viewOptions: SettingsViewOptions; }) { - const [settings, setSettings] = React.useState(() => { - const saved = localStorage.getItem('user_settings'); - window.electron.logInfo('Settings: ' + saved); - let currentSettings = saved ? JSON.parse(saved) : DEFAULT_SETTINGS; - - // Ensure built-in extensions are included if not already present - BUILT_IN_EXTENSIONS.forEach((builtIn) => { - const exists = currentSettings.extensions.some( - (ext: FullExtensionConfig) => ext.id === builtIn.id - ); - if (!exists) { - currentSettings.extensions.push(builtIn); - } - }); - - return currentSettings; - }); - - const [extensionBeingConfigured, setExtensionBeingConfigured] = - useState(null); - - const [isManualModalOpen, setIsManualModalOpen] = useState(false); - - // Persist settings changes - useEffect(() => { - localStorage.setItem('user_settings', JSON.stringify(settings)); - }, [settings]); - - // Listen for settings updates from extension storage - useEffect(() => { - const handleSettingsUpdate = (_event: IpcRendererEvent) => { - const saved = localStorage.getItem('user_settings'); - if (saved) { - let currentSettings = JSON.parse(saved); - setSettings(currentSettings); - } - }; - - window.electron.on('settings-updated', handleSettingsUpdate); - return () => { - window.electron.off('settings-updated', handleSettingsUpdate); - }; - }, []); - - // Handle URL parameters for auto-opening extension configuration - useEffect(() => { - const extensionId = viewOptions.extensionId; - const showEnvVars = viewOptions.showEnvVars; - - if (extensionId && showEnvVars === true) { - // Find the extension in settings - const extension = settings.extensions.find((ext) => ext.id === extensionId); - if (extension) { - // Auto-open the configuration modal - setExtensionBeingConfigured(extension); - // Scroll to extensions section - const element = document.getElementById('extensions'); - if (element) { - element.scrollIntoView({ behavior: 'smooth' }); - } - } - } - // We only run this once on load - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [settings.extensions]); - - const handleExtensionToggle = async (extensionId: string) => { - // Find the extension to get its current state - const extension = settings.extensions.find((ext) => ext.id === extensionId); - - if (!extension) return; - - const newEnabled = !extension.enabled; - - const originalSettings = settings; - - // Optimistically update local component state - setSettings((prev) => ({ - ...prev, - extensions: prev.extensions.map((ext) => - ext.id === extensionId ? { ...ext, enabled: newEnabled } : ext - ), - })); - - let response: Response; - - if (newEnabled) { - response = await addExtension(extension); - } else { - response = await removeExtension(extension.name); - } - - if (!response.ok) { - setSettings(originalSettings); - } - }; - - const handleExtensionRemove = async () => { - if (!extensionBeingConfigured) return; - - const response = await removeExtension(extensionBeingConfigured.name, true); - - if (response.ok) { - toastSuccess({ - title: extensionBeingConfigured.name, - msg: `Successfully removed extension`, - }); - - // Remove from localstorage - setSettings((prev) => ({ - ...prev, - extensions: prev.extensions.filter((ext) => ext.id !== extensionBeingConfigured.id), - })); - setExtensionBeingConfigured(null); - } - }; - - const handleExtensionConfigSubmit = () => { - setExtensionBeingConfigured(null); - }; - - const isBuiltIn = (extensionId: string) => { - return BUILT_IN_EXTENSIONS.some((builtIn) => builtIn.id === extensionId); - }; - return ( -
+
@@ -200,114 +37,29 @@ export default function SettingsView({
{/* Content Area */} -
+
- {/*Models Section*/} -
- -
-
-
-

Extensions

- - Browse - -
- -
-

{EXTENSIONS_DESCRIPTION}

- - {settings.extensions.length === 0 ? ( -

No Extensions Added

- ) : ( -
- {settings.extensions.map((ext) => ( - setExtensionBeingConfigured(extension)} - /> - ))} - -
- )} -
-
- -
-
-

Mode Selection

-
- -
-

- Configure how Goose interacts with tools and extensions -

- - -
-
-
- -
+ {/* Models Section */} + + {/* Extensions Section */} + + {/* Goose Modes */} + + {/*Session sharing*/} + + {/* Response Styles */} + + {/* Tool Selection Strategy */} + + {/* App Settings */} +
- - {extensionBeingConfigured && isBuiltIn(extensionBeingConfigured.id) ? ( - { - setExtensionBeingConfigured(null); - }} - extension={extensionBeingConfigured} - onSubmit={handleExtensionConfigSubmit} - /> - ) : ( - { - setExtensionBeingConfigured(null); - }} - extension={extensionBeingConfigured} - onSubmit={handleExtensionConfigSubmit} - onRemove={handleExtensionRemove} - /> - )} - - setIsManualModalOpen(false)} - onSubmit={async (extension) => { - const response = await addExtension(extension); - - if (response.ok) { - setSettings((prev) => ({ - ...prev, - extensions: [...prev.extensions, extension], - })); - setIsManualModalOpen(false); - } else { - // TODO - Anything for the UI state beyond validation? - } - }} - />
); } diff --git a/ui/desktop/src/components/settings/api_keys/ActiveKeysContext.tsx b/ui/desktop/src/components/settings/api_keys/ActiveKeysContext.tsx deleted file mode 100644 index 7533352d..00000000 --- a/ui/desktop/src/components/settings/api_keys/ActiveKeysContext.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { createContext, useContext, useState, ReactNode, useEffect } from 'react'; -import { getActiveProviders } from './utils'; -import SuspenseLoader from '../../../suspense-loader'; - -// Create a context for active keys -const ActiveKeysContext = createContext< - | { - activeKeys: string[]; - setActiveKeys: (keys: string[]) => void; - } - | undefined ->(undefined); - -export const ActiveKeysProvider = ({ children }: { children: ReactNode }) => { - const [activeKeys, setActiveKeys] = useState([]); // Start with an empty list - const [isLoading, setIsLoading] = useState(true); // Track loading state - - // Fetch active keys from the backend - useEffect(() => { - const fetchActiveProviders = async () => { - try { - const providers = await getActiveProviders(); // Fetch the active providers - setActiveKeys(providers); // Update state with fetched providers - } catch (error) { - console.error('Error fetching active providers:', error); - } finally { - setIsLoading(false); // Ensure loading is marked as complete - } - }; - - fetchActiveProviders(); // Call the async function - }, []); - - // Provide active keys and ability to update them - return ( - - {!isLoading ? children : } - - ); -}; - -// Custom hook to access active keys -export const useActiveKeys = () => { - const context = useContext(ActiveKeysContext); - if (!context) { - throw new Error('useActiveKeys must be used within an ActiveKeysProvider'); - } - return context; -}; diff --git a/ui/desktop/src/components/settings/api_keys/types.ts b/ui/desktop/src/components/settings/api_keys/types.ts deleted file mode 100644 index 9d8b2245..00000000 --- a/ui/desktop/src/components/settings/api_keys/types.ts +++ /dev/null @@ -1,13 +0,0 @@ -export interface ProviderResponse { - supported: boolean; - name?: string; - description?: string; - models?: string[]; - config_status: Record; -} - -export interface ConfigDetails { - key: string; - is_set: boolean; - location?: string; -} diff --git a/ui/desktop/src/components/settings/api_keys/utils.tsx b/ui/desktop/src/components/settings/api_keys/utils.tsx deleted file mode 100644 index 1f7d9d15..00000000 --- a/ui/desktop/src/components/settings/api_keys/utils.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { ProviderResponse, ConfigDetails } from './types'; -import { getApiUrl, getSecretKey } from '../../../config'; -import { default_key_value, required_keys } from '../models/hardcoded_stuff'; // e.g. { OPENAI_HOST: '', OLLAMA_HOST: '' } - -// Backend API response types -interface ProviderMetadata { - description: string; - models: string[]; -} - -interface ProviderDetails { - name: string; - metadata: ProviderMetadata; - is_configured: boolean; -} - -export function isSecretKey(keyName: string): boolean { - // Endpoints and hosts should not be stored as secrets - const nonSecretKeys = [ - 'ANTHROPIC_HOST', - 'DATABRICKS_HOST', - 'OLLAMA_HOST', - 'OPENAI_HOST', - 'OPENAI_BASE_PATH', - 'AZURE_OPENAI_ENDPOINT', - 'AZURE_OPENAI_DEPLOYMENT_NAME', - 'AZURE_OPENAI_API_VERSION', - 'GCP_PROJECT_ID', - 'GCP_LOCATION', - ]; - return !nonSecretKeys.includes(keyName); -} - -export async function getActiveProviders(): Promise { - try { - const configSettings = await getConfigSettings(); - const activeProviders = Object.values(configSettings) - .filter((provider) => { - const providerName = provider.name; - const configStatus = provider.config_status ?? {}; - - // Skip if provider isn't in required_keys - if (!required_keys[providerName as keyof typeof required_keys]) return false; - - // Get all required keys for this provider - const providerRequiredKeys = required_keys[providerName as keyof typeof required_keys]; - - // Special case: If a provider has exactly one required key and that key - // has a default value, check if it's explicitly set - if (providerRequiredKeys.length === 1 && providerRequiredKeys[0] in default_key_value) { - const key = providerRequiredKeys[0]; - // Only consider active if the key is explicitly set - return configStatus[key]?.is_set === true; - } - - // For providers with multiple keys or keys without defaults: - // Check if all required keys without defaults are set - const requiredNonDefaultKeys = providerRequiredKeys.filter( - (key: string) => !(key in default_key_value) - ); - - // If there are no non-default keys, this provider needs at least one key explicitly set - if (requiredNonDefaultKeys.length === 0) { - return providerRequiredKeys.some((key: string) => configStatus[key]?.is_set === true); - } - - // Otherwise, all non-default keys must be set - return requiredNonDefaultKeys.every((key: string) => configStatus[key]?.is_set === true); - }) - .map((provider) => provider.name || 'Unknown Provider'); - - console.log('[GET ACTIVE PROVIDERS]:', activeProviders); - return activeProviders; - } catch (error) { - console.error('Failed to get active providers:', error); - return []; - } -} - -export async function getConfigSettings(): Promise> { - // Fetch provider config status - const response = await fetch(getApiUrl('/config/providers'), { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - 'X-Secret-Key': getSecretKey(), - }, - }); - - if (!response.ok) { - throw new Error('Failed to fetch provider configuration status'); - } - - const providers: ProviderDetails[] = await response.json(); - - // Convert the response to the expected format - const data: Record = {}; - providers.forEach((provider) => { - const providerRequiredKeys = required_keys[provider.name as keyof typeof required_keys] || []; - - data[provider.name] = { - name: provider.name, - supported: true, - description: provider.metadata.description, - models: provider.metadata.models, - config_status: providerRequiredKeys.reduce>( - (acc: Record, key: string) => { - acc[key] = { - key, - is_set: provider.is_configured, - location: provider.is_configured ? 'config' : undefined, - }; - return acc; - }, - {} - ), - }; - }); - - return data; -} diff --git a/ui/desktop/src/components/settings_v2/app/AppSettingsSection.tsx b/ui/desktop/src/components/settings/app/AppSettingsSection.tsx similarity index 100% rename from ui/desktop/src/components/settings_v2/app/AppSettingsSection.tsx rename to ui/desktop/src/components/settings/app/AppSettingsSection.tsx diff --git a/ui/desktop/src/components/settings/basic/ConfigureApproveMode.tsx b/ui/desktop/src/components/settings/basic/ConfigureApproveMode.tsx deleted file mode 100644 index a7b61e2b..00000000 --- a/ui/desktop/src/components/settings/basic/ConfigureApproveMode.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { Card } from '../../ui/card'; -import { Button } from '../../ui/button'; -import { GooseMode, ModeSelectionItem } from './ModeSelectionItem'; - -interface ConfigureApproveModeProps { - onClose: () => void; - handleModeChange: (newMode: string) => void; - currentMode: string | null; -} - -export function ConfigureApproveMode({ - onClose, - handleModeChange, - currentMode, -}: ConfigureApproveModeProps) { - const approveModes: GooseMode[] = [ - { - key: 'approve', - label: 'Manual Approval', - description: 'All tools, extensions and file modifications will require human approval', - }, - { - key: 'smart_approve', - label: 'Smart Approval', - description: 'Intelligently determine which actions need approval based on risk level ', - }, - ]; - - const [isSubmitting, setIsSubmitting] = useState(false); - const [approveMode, setApproveMode] = useState(currentMode); - - useEffect(() => { - setApproveMode(currentMode); - }, [currentMode]); - - const handleModeSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - setIsSubmitting(true); - try { - handleModeChange(approveMode || ''); - onClose(); - } catch (error) { - console.error('Error configuring goose mode:', error); - } finally { - setIsSubmitting(false); - } - }; - - return ( -
- -
-
- {/* Header */} -
-

- Configure Approve Mode -

-
- -
-

- Approve requests can either be given to all tool requests or determine which actions - may need integration -

-
- {approveModes.map((mode) => ( - { - setApproveMode(newMode); - }} - /> - ))} -
-
-
-
- - {/* Actions */} -
- - -
-
-
- ); -} diff --git a/ui/desktop/src/components/settings/basic/ModeSelection.tsx b/ui/desktop/src/components/settings/basic/ModeSelection.tsx deleted file mode 100644 index b38aec12..00000000 --- a/ui/desktop/src/components/settings/basic/ModeSelection.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { useEffect, useState, useCallback } from 'react'; -import { all_goose_modes, filterGooseModes, ModeSelectionItem } from './ModeSelectionItem'; -import { useConfig } from '../../ConfigContext'; - -export const ModeSelection = () => { - const [currentMode, setCurrentMode] = useState('auto'); - const [previousApproveModel, setPreviousApproveModel] = useState(''); - const { read, upsert } = useConfig(); - - const handleModeChange = async (newMode: string) => { - try { - await upsert('GOOSE_MODE', newMode, false); - // Only track the previous approve if current mode is approve related but new mode is not. - if (currentMode.includes('approve') && !newMode.includes('approve')) { - setPreviousApproveModel(currentMode); - } - setCurrentMode(newMode); - } catch (error) { - console.error('Error updating goose mode:', error); - throw new Error(`Failed to store new goose mode: ${newMode}`); - } - }; - - const fetchCurrentMode = useCallback(async () => { - try { - const mode = (await read('GOOSE_MODE', false)) as string; - if (mode) { - setCurrentMode(mode); - } - } catch (error) { - console.error('Error fetching current mode:', error); - } - }, [read]); - - useEffect(() => { - fetchCurrentMode(); - }, [fetchCurrentMode]); - - return ( -
-
- {filterGooseModes(currentMode, all_goose_modes, previousApproveModel).map((mode) => ( - - ))} -
-
- ); -}; diff --git a/ui/desktop/src/components/settings/basic/ModeSelectionItem.tsx b/ui/desktop/src/components/settings/basic/ModeSelectionItem.tsx deleted file mode 100644 index d52ca600..00000000 --- a/ui/desktop/src/components/settings/basic/ModeSelectionItem.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import { useEffect, useState } from 'react'; -import { Gear } from '../../icons'; -import { ConfigureApproveMode } from './ConfigureApproveMode'; - -export interface GooseMode { - key: string; - label: string; - description: string; -} - -export const all_goose_modes: GooseMode[] = [ - { - key: 'auto', - label: 'Autonomous', - description: 'Full file modification capabilities, edit, create, and delete files freely.', - }, - { - key: 'approve', - label: 'Manual Approval', - description: 'All tools, extensions and file modifications will require human approval', - }, - { - key: 'smart_approve', - label: 'Smart Approval', - description: 'Intelligently determine which actions need approval based on risk level ', - }, - { - key: 'chat', - label: 'Chat Only', - description: 'Engage with the selected provider without using tools or extensions.', - }, -]; - -export function filterGooseModes( - currentMode: string, - modes: GooseMode[], - previousApproveMode: string -) { - return modes.filter((mode) => { - const approveList = ['approve', 'smart_approve']; - const nonApproveList = ['auto', 'chat']; - // Always keep 'auto' and 'chat' - if (nonApproveList.includes(mode.key)) { - return true; - } - // If current mode is non approve mode, we display write approve by default. - if (nonApproveList.includes(currentMode) && !previousApproveMode) { - return mode.key === 'smart_approve'; - } - - // Always include the current and previou approve mode - if (mode.key === currentMode) { - return true; - } - - // Current mode and previous approve mode cannot exist at the same time. - if (approveList.includes(currentMode) && approveList.includes(previousApproveMode)) { - return false; - } - - if (mode.key === previousApproveMode) { - return true; - } - - return false; - }); -} - -interface ModeSelectionItemProps { - currentMode: string; - mode: GooseMode; - showDescription: boolean; - isApproveModeConfigure: boolean; - handleModeChange: (newMode: string) => void; -} - -export function ModeSelectionItem({ - currentMode, - mode, - showDescription, - isApproveModeConfigure, - handleModeChange, -}: ModeSelectionItemProps) { - const [checked, setChecked] = useState(currentMode == mode.key); - const [isDislogOpen, setIsDislogOpen] = useState(false); - - useEffect(() => { - setChecked(currentMode === mode.key); - }, [currentMode, mode.key]); - - return ( -
-
handleModeChange(mode.key)}> -
-
-

{mode.label}

- {showDescription && ( -

{mode.description}

- )} -
-
-
- {!isApproveModeConfigure && (mode.key == 'approve' || mode.key == 'smart_approve') && ( - - )} - handleModeChange(mode.key)} - className="peer sr-only" - /> -
-
-
-
-
- {isDislogOpen ? ( - { - setIsDislogOpen(false); - }} - handleModeChange={handleModeChange} - currentMode={currentMode} - /> - ) : null} -
-
-
- ); -} diff --git a/ui/desktop/src/components/settings/extensions/ConfigureBuiltInExtensionModal.tsx b/ui/desktop/src/components/settings/extensions/ConfigureBuiltInExtensionModal.tsx deleted file mode 100644 index a8af5dcd..00000000 --- a/ui/desktop/src/components/settings/extensions/ConfigureBuiltInExtensionModal.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import React from 'react'; -import { Card } from '../../ui/card'; -import { Button } from '../../ui/button'; -import { Input } from '../../ui/input'; -import { FullExtensionConfig } from '../../../extensions'; -import { getApiUrl, getSecretKey } from '../../../config'; -import { addExtension } from '../../../extensions'; -import { toastError, toastSuccess } from '../../../toasts'; - -interface ConfigureExtensionModalProps { - isOpen: boolean; - onClose: () => void; - onSubmit: () => void; - extension: FullExtensionConfig | null; -} - -export function ConfigureBuiltInExtensionModal({ - isOpen, - onClose, - onSubmit, - extension, -}: ConfigureExtensionModalProps) { - const [envValues, setEnvValues] = React.useState>({}); - const [isSubmitting, setIsSubmitting] = React.useState(false); - - // Reset form when dialog closes or extension changes - React.useEffect(() => { - if (!isOpen || !extension) { - setEnvValues({}); - } - }, [isOpen, extension]); - - const handleExtensionConfigSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - if (!extension) return; - - setIsSubmitting(true); - try { - // First store all environment variables - if (extension.env_keys && extension.env_keys.length > 0) { - for (const envKey of extension.env_keys) { - const value = envValues[envKey]; - if (!value) continue; - - const storeResponse = await fetch(getApiUrl('/configs/store'), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Secret-Key': getSecretKey(), - }, - body: JSON.stringify({ - key: envKey, - value: value.trim(), - isSecret: true, - }), - }); - - if (!storeResponse.ok) { - throw new Error(`Failed to store environment variable: ${envKey}`); - } - } - } - - const response = await addExtension(extension); - - if (!response.ok) { - throw new Error('Failed to add system configuration'); - } - - toastSuccess({ - title: extension.name, - msg: `Successfully configured extension`, - }); - onSubmit(); - onClose(); - } catch (err) { - console.error('Error configuring extension:', err); - const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'; - toastError({ - title: extension.name, - msg: `Failed to configure the extension`, - traceback: errorMessage, - }); - } finally { - setIsSubmitting(false); - } - }; - - if (!extension || !isOpen) return null; - - return ( -
- -
- {/* Header */} -
-

- Configure {extension.name} -

-
- - {/* Form */} -
-
- {extension.env_keys && extension.env_keys.length > 0 ? ( - <> -

- Please provide the required environment variables for this extension: -

-
- {extension.env_keys.map((envVarName) => ( -
- - - setEnvValues((prev) => ({ - ...prev, - [envVarName]: e.target.value, - })) - } - className="w-full h-14 px-4 font-regular rounded-lg border shadow-none border-gray-300 bg-white text-lg placeholder:text-gray-400 font-regular text-gray-900 dark:bg-gray-800 dark:border-gray-600 dark:text-white dark:placeholder:text-gray-500" - required - /> -
- ))} -
- - ) : ( -

- This extension doesn't require any environment variables. -

- )} -
- - {/* Actions */} -
- - -
-
-
-
-
- ); -} diff --git a/ui/desktop/src/components/settings/extensions/ConfigureExtensionModal.tsx b/ui/desktop/src/components/settings/extensions/ConfigureExtensionModal.tsx deleted file mode 100644 index 56e929b9..00000000 --- a/ui/desktop/src/components/settings/extensions/ConfigureExtensionModal.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import React from 'react'; -import { Card } from '../../ui/card'; -import { Button } from '../../ui/button'; -import { Input } from '../../ui/input'; -import { FullExtensionConfig } from '../../../extensions'; -import { getApiUrl, getSecretKey } from '../../../config'; -import { addExtension } from '../../../extensions'; -import { toastError, toastSuccess } from '../../../toasts'; - -interface ConfigureExtensionModalProps { - isOpen: boolean; - onClose: () => void; - onSubmit: () => void; - onRemove: () => void; - extension: FullExtensionConfig | null; -} - -export function ConfigureExtensionModal({ - isOpen, - onClose, - onSubmit, - onRemove, - extension, -}: ConfigureExtensionModalProps) { - const [envValues, setEnvValues] = React.useState>({}); - const [isSubmitting, setIsSubmitting] = React.useState(false); - - // Reset form when dialog closes or extension changes - React.useEffect(() => { - if (!isOpen || !extension) { - setEnvValues({}); - } - }, [isOpen, extension]); - - const handleExtensionConfigSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - if (!extension) return; - - setIsSubmitting(true); - try { - // First store all environment variables - if (extension.env_keys && extension.env_keys.length > 0) { - for (const envKey of extension.env_keys) { - const value = envValues[envKey]; - if (!value) continue; - - const storeResponse = await fetch(getApiUrl('/configs/store'), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Secret-Key': getSecretKey(), - }, - body: JSON.stringify({ - key: envKey, - value: value.trim(), - isSecret: true, - }), - }); - - if (!storeResponse.ok) { - throw new Error(`Failed to store environment variable: ${envKey}`); - } - } - } - - const response = await addExtension(extension); - - if (!response.ok) { - throw new Error('Failed to add system configuration'); - } - - toastSuccess({ - title: extension.name, - msg: `Successfully configured extension`, - }); - onSubmit(); - onClose(); - } catch (err) { - console.error('Error configuring extension:', err); - const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'; - toastError({ - title: extension.name, - msg: `Failed to configure extension`, - traceback: errorMessage, - }); - } finally { - setIsSubmitting(false); - } - }; - - if (!extension || !isOpen) return null; - - return ( -
- -
- {/* Header */} -
-

- Configure {extension.name} -

-
- - {/* Form */} -
-
- {extension.env_keys && extension.env_keys.length > 0 ? ( - <> -

- Please provide the required environment variables for this extension: -

-
- {extension.env_keys.map((envVarName) => ( -
- - - setEnvValues((prev) => ({ - ...prev, - [envVarName]: e.target.value, - })) - } - className="w-full h-14 px-4 font-regular rounded-lg border shadow-none border-gray-300 bg-white text-lg placeholder:text-gray-400 font-regular text-gray-900 dark:bg-gray-800 dark:border-gray-600 dark:text-white dark:placeholder:text-gray-500" - required - /> -
- ))} -
- - ) : ( -

- This extension doesn't require any environment variables. -

- )} -
- - {/* Actions */} -
- - - -
-
-
-
-
- ); -} diff --git a/ui/desktop/src/components/settings/extensions/ExtensionItem.tsx b/ui/desktop/src/components/settings/extensions/ExtensionItem.tsx deleted file mode 100644 index 29b701f7..00000000 --- a/ui/desktop/src/components/settings/extensions/ExtensionItem.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import React from 'react'; -import { FullExtensionConfig } from '../../../extensions'; -import { Gear } from '../../icons'; - -type ExtensionItemProps = FullExtensionConfig & { - onToggle: (id: string) => void; - onConfigure: (extension: FullExtensionConfig) => void; - canConfigure?: boolean; // Added optional prop here -}; - -export const ExtensionItem: React.FC = (props) => { - const { id, name, description, enabled, onToggle, onConfigure, canConfigure } = props; - - return ( -
-
-
-
-

{name}

-
-

{description}

-
-
- {canConfigure && ( // Conditionally render the gear icon - - )} - -
-
-
- ); -}; diff --git a/ui/desktop/src/components/settings_v2/extensions/ExtensionsSection.tsx b/ui/desktop/src/components/settings/extensions/ExtensionsSection.tsx similarity index 100% rename from ui/desktop/src/components/settings_v2/extensions/ExtensionsSection.tsx rename to ui/desktop/src/components/settings/extensions/ExtensionsSection.tsx diff --git a/ui/desktop/src/components/settings/extensions/ManualExtensionModal.tsx b/ui/desktop/src/components/settings/extensions/ManualExtensionModal.tsx deleted file mode 100644 index ab5fcf99..00000000 --- a/ui/desktop/src/components/settings/extensions/ManualExtensionModal.tsx +++ /dev/null @@ -1,315 +0,0 @@ -import React, { useState } from 'react'; -import { Card } from '../../ui/card'; -import { Button } from '../../ui/button'; -import { Input } from '../../ui/input'; -import { FullExtensionConfig, DEFAULT_EXTENSION_TIMEOUT } from '../../../extensions'; -import { Select } from '../../ui/Select'; -import { getApiUrl, getSecretKey } from '../../../config'; -import { toastError } from '../../../toasts'; - -interface ManualExtensionModalProps { - isOpen: boolean; - onClose: () => void; - onSubmit: (extension: FullExtensionConfig) => void; -} - -export function ManualExtensionModal({ isOpen, onClose, onSubmit }: ManualExtensionModalProps) { - const [formData, setFormData] = useState< - Partial & { commandInput?: string } - >({ - type: 'stdio', - enabled: true, - args: [], - commandInput: '', - timeout: DEFAULT_EXTENSION_TIMEOUT, - }); - const [envKey, setEnvKey] = useState(''); - const [envValue, setEnvValue] = useState(''); - const [envVars, setEnvVars] = useState>([]); - - const typeOptions = [ - { value: 'stdio', label: 'Standard IO' }, - { value: 'sse', label: 'Server-Sent Events' }, - { value: 'builtin', label: 'Built-in' }, - ]; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - if (!formData.id || !formData.name || !formData.description) { - toastError({ title: 'Please fill in all required fields' }); - return; - } - - if (formData.type === 'stdio' && !formData.commandInput) { - toastError({ title: 'Command is required for stdio type' }); - return; - } - - if (formData.type === 'sse' && !formData.uri) { - toastError({ title: 'URI is required for SSE type' }); - return; - } - - if (formData.type === 'builtin' && !formData.name) { - toastError({ title: 'Name is required for builtin type' }); - return; - } - - try { - // Store environment variables as secrets - for (const envVar of envVars) { - const storeResponse = await fetch(getApiUrl('/configs/store'), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Secret-Key': getSecretKey(), - }, - body: JSON.stringify({ - key: envVar.key, - value: envVar.value.trim(), - isSecret: true, - }), - }); - - if (!storeResponse.ok) { - throw new Error(`Failed to store environment variable: ${envVar.key}`); - } - } - - // Parse command input into cmd and args - let cmd = ''; - let args: string[] = []; - if (formData.type === 'stdio' && formData.commandInput) { - const parts = formData.commandInput.trim().split(/\s+/); - [cmd, ...args] = parts; - } - - const extension: FullExtensionConfig = { - ...formData, - type: formData.type!, - enabled: true, - env_keys: envVars.map((v) => v.key), - ...(formData.type === 'stdio' && { cmd, args }), - } as FullExtensionConfig; - - onSubmit(extension); - resetForm(); - } catch (error) { - console.error('Error configuring extension:', error); - toastError({ - title: 'Failed to configure extension', - traceback: error instanceof Error ? error.message : String(error), - }); - } - }; - - const resetForm = () => { - setFormData({ - type: 'stdio', - enabled: true, - args: [], - commandInput: '', - }); - setEnvVars([]); - setEnvKey(''); - setEnvValue(''); - }; - - const handleAddEnvVar = () => { - if (envKey && !envVars.some((v) => v.key === envKey)) { - setEnvVars([...envVars, { key: envKey, value: envValue }]); - setEnvKey(''); - setEnvValue(''); - } - }; - - const handleRemoveEnvVar = (key: string) => { - setEnvVars(envVars.filter((v) => v.key !== key)); - }; - - if (!isOpen) return null; - - return ( -
- -
-
-

Add custom extension

-
- -
-
-
- - setFormData({ ...formData, id: e.target.value })} - className="w-full" - required - /> -
- -
- - setFormData({ ...formData, name: e.target.value })} - className="w-full" - required - /> -
- -
- - setFormData({ ...formData, description: e.target.value })} - className="w-full" - required - /> -
- - {formData.type === 'stdio' && ( -
- - setFormData({ ...formData, commandInput: e.target.value })} - placeholder="e.g. goosed mcp example" - className="w-full" - required - /> -
- )} - - {formData.type === 'sse' && ( -
- - setFormData({ ...formData, uri: e.target.value })} - className="w-full" - required - /> -
- )} - -
- -
- setEnvKey(e.target.value)} - placeholder="Environment variable name" - className="flex-1" - /> - setEnvValue(e.target.value)} - placeholder="Value" - className="flex-1" - /> - - -
- {envVars.length > 0 && ( -
- {envVars.map((envVar) => ( -
-
-
- {envVar.key} - - = {envVar.value} - -
-
-
- -
-
- ))} -
- )} -
- -
- - setFormData({ ...formData, timeout: parseInt(e.target.value) })} - className="w-full" - required - /> -
-
-
- - -
-
-
-
-
- ); -} diff --git a/ui/desktop/src/components/settings_v2/extensions/agent-api.ts b/ui/desktop/src/components/settings/extensions/agent-api.ts similarity index 100% rename from ui/desktop/src/components/settings_v2/extensions/agent-api.ts rename to ui/desktop/src/components/settings/extensions/agent-api.ts diff --git a/ui/desktop/src/components/settings_v2/extensions/bundled-extensions.json b/ui/desktop/src/components/settings/extensions/bundled-extensions.json similarity index 100% rename from ui/desktop/src/components/settings_v2/extensions/bundled-extensions.json rename to ui/desktop/src/components/settings/extensions/bundled-extensions.json diff --git a/ui/desktop/src/components/settings_v2/extensions/bundled-extensions.ts b/ui/desktop/src/components/settings/extensions/bundled-extensions.ts similarity index 100% rename from ui/desktop/src/components/settings_v2/extensions/bundled-extensions.ts rename to ui/desktop/src/components/settings/extensions/bundled-extensions.ts diff --git a/ui/desktop/src/components/settings_v2/extensions/deeplink.ts b/ui/desktop/src/components/settings/extensions/deeplink.ts similarity index 100% rename from ui/desktop/src/components/settings_v2/extensions/deeplink.ts rename to ui/desktop/src/components/settings/extensions/deeplink.ts diff --git a/ui/desktop/src/components/settings_v2/extensions/extension-manager.ts b/ui/desktop/src/components/settings/extensions/extension-manager.ts similarity index 100% rename from ui/desktop/src/components/settings_v2/extensions/extension-manager.ts rename to ui/desktop/src/components/settings/extensions/extension-manager.ts diff --git a/ui/desktop/src/components/settings_v2/extensions/index.ts b/ui/desktop/src/components/settings/extensions/index.ts similarity index 100% rename from ui/desktop/src/components/settings_v2/extensions/index.ts rename to ui/desktop/src/components/settings/extensions/index.ts diff --git a/ui/desktop/src/components/settings_v2/extensions/modal/EnvVarsSection.tsx b/ui/desktop/src/components/settings/extensions/modal/EnvVarsSection.tsx similarity index 100% rename from ui/desktop/src/components/settings_v2/extensions/modal/EnvVarsSection.tsx rename to ui/desktop/src/components/settings/extensions/modal/EnvVarsSection.tsx diff --git a/ui/desktop/src/components/settings_v2/extensions/modal/ExtensionConfigFields.tsx b/ui/desktop/src/components/settings/extensions/modal/ExtensionConfigFields.tsx similarity index 100% rename from ui/desktop/src/components/settings_v2/extensions/modal/ExtensionConfigFields.tsx rename to ui/desktop/src/components/settings/extensions/modal/ExtensionConfigFields.tsx diff --git a/ui/desktop/src/components/settings_v2/extensions/modal/ExtensionInfoFields.tsx b/ui/desktop/src/components/settings/extensions/modal/ExtensionInfoFields.tsx similarity index 100% rename from ui/desktop/src/components/settings_v2/extensions/modal/ExtensionInfoFields.tsx rename to ui/desktop/src/components/settings/extensions/modal/ExtensionInfoFields.tsx diff --git a/ui/desktop/src/components/settings_v2/extensions/modal/ExtensionModal.tsx b/ui/desktop/src/components/settings/extensions/modal/ExtensionModal.tsx similarity index 100% rename from ui/desktop/src/components/settings_v2/extensions/modal/ExtensionModal.tsx rename to ui/desktop/src/components/settings/extensions/modal/ExtensionModal.tsx diff --git a/ui/desktop/src/components/settings_v2/extensions/modal/ExtensionTimeoutField.tsx b/ui/desktop/src/components/settings/extensions/modal/ExtensionTimeoutField.tsx similarity index 100% rename from ui/desktop/src/components/settings_v2/extensions/modal/ExtensionTimeoutField.tsx rename to ui/desktop/src/components/settings/extensions/modal/ExtensionTimeoutField.tsx diff --git a/ui/desktop/src/components/settings_v2/extensions/subcomponents/ExtensionItem.tsx b/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionItem.tsx similarity index 100% rename from ui/desktop/src/components/settings_v2/extensions/subcomponents/ExtensionItem.tsx rename to ui/desktop/src/components/settings/extensions/subcomponents/ExtensionItem.tsx diff --git a/ui/desktop/src/components/settings_v2/extensions/subcomponents/ExtensionList.tsx b/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx similarity index 100% rename from ui/desktop/src/components/settings_v2/extensions/subcomponents/ExtensionList.tsx rename to ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx diff --git a/ui/desktop/src/components/settings_v2/extensions/utils.ts b/ui/desktop/src/components/settings/extensions/utils.ts similarity index 91% rename from ui/desktop/src/components/settings_v2/extensions/utils.ts rename to ui/desktop/src/components/settings/extensions/utils.ts index 87e3e4f2..b7205cd9 100644 --- a/ui/desktop/src/components/settings_v2/extensions/utils.ts +++ b/ui/desktop/src/components/settings/extensions/utils.ts @@ -184,3 +184,18 @@ export function removeShims(cmd: string) { // If it's not a shim, return the original command return cmd; } + +export function extractCommand(link: string): string { + const url = new URL(link); + const cmd = url.searchParams.get('cmd') || 'Unknown Command'; + const args = url.searchParams.getAll('arg').map(decodeURIComponent); + + // Combine the command and its arguments into a reviewable format + return `${cmd} ${args.join(' ')}`.trim(); +} + +export function extractExtensionName(link: string): string { + const url = new URL(link); + const name = url.searchParams.get('name'); + return name ? decodeURIComponent(name) : 'Unknown Extension'; +} diff --git a/ui/desktop/src/components/settings/extensions/utils.tsx b/ui/desktop/src/components/settings/extensions/utils.tsx deleted file mode 100644 index 39603d11..00000000 --- a/ui/desktop/src/components/settings/extensions/utils.tsx +++ /dev/null @@ -1,14 +0,0 @@ -export function extractCommand(link: string): string { - const url = new URL(link); - const cmd = url.searchParams.get('cmd') || 'Unknown Command'; - const args = url.searchParams.getAll('arg').map(decodeURIComponent); - - // Combine the command and its arguments into a reviewable format - return `${cmd} ${args.join(' ')}`.trim(); -} - -export function extractExtensionName(link: string): string { - const url = new URL(link); - const name = url.searchParams.get('name'); - return name ? decodeURIComponent(name) : 'Unknown Extension'; -} diff --git a/ui/desktop/src/components/settings_v2/mode/ConfigureApproveMode.tsx b/ui/desktop/src/components/settings/mode/ConfigureApproveMode.tsx similarity index 100% rename from ui/desktop/src/components/settings_v2/mode/ConfigureApproveMode.tsx rename to ui/desktop/src/components/settings/mode/ConfigureApproveMode.tsx diff --git a/ui/desktop/src/components/settings_v2/mode/ModeSection.tsx b/ui/desktop/src/components/settings/mode/ModeSection.tsx similarity index 100% rename from ui/desktop/src/components/settings_v2/mode/ModeSection.tsx rename to ui/desktop/src/components/settings/mode/ModeSection.tsx diff --git a/ui/desktop/src/components/settings_v2/mode/ModeSelectionItem.tsx b/ui/desktop/src/components/settings/mode/ModeSelectionItem.tsx similarity index 100% rename from ui/desktop/src/components/settings_v2/mode/ModeSelectionItem.tsx rename to ui/desktop/src/components/settings/mode/ModeSelectionItem.tsx diff --git a/ui/desktop/src/components/settings/models/AddModelInline.tsx b/ui/desktop/src/components/settings/models/AddModelInline.tsx deleted file mode 100644 index f9c47413..00000000 --- a/ui/desktop/src/components/settings/models/AddModelInline.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import { useState, useEffect } from 'react'; -import { Button } from '../../ui/button'; -import { Input } from '../../ui/input'; -import Select from 'react-select'; -import { Plus } from 'lucide-react'; -import { createSelectedModel, useHandleModelSelection } from './utils'; -import { useActiveKeys } from '../api_keys/ActiveKeysContext'; -import { gooseModels } from './GooseModels'; -import { createDarkSelectStyles, darkSelectTheme } from '../../ui/select-styles'; - -export function AddModelInline() { - const { activeKeys } = useActiveKeys(); // Access active keys from context - - // Convert active keys to dropdown options - const providerOptions = activeKeys.map((key) => ({ - value: key.toLowerCase(), - label: key, - })); - - const [selectedProvider, setSelectedProvider] = useState(null); - const [modelName, setModelName] = useState(''); - const [filteredModels, setFilteredModels] = useState< - { id: string; name: string; provider: string }[] - >([]); - const [showSuggestions, setShowSuggestions] = useState(false); - const handleModelSelection = useHandleModelSelection(); - - // Filter models by selected provider and input text - useEffect(() => { - if (!selectedProvider || !modelName) { - setFilteredModels([]); - setShowSuggestions(false); - return; - } - - const filtered = gooseModels - .filter( - (model) => - model.provider.toLowerCase() === selectedProvider && - model.name.toLowerCase().includes(modelName.toLowerCase()) - ) - .slice(0, 5) // Limit suggestions to top 5 - .map((model) => ({ - id: String(model.id || ''), - name: model.name, - provider: model.provider, - })); - setFilteredModels(filtered); - setShowSuggestions(filtered.length > 0); - }, [modelName, selectedProvider]); - - const handleSubmit = () => { - if (!selectedProvider || !modelName) { - console.error('Both provider and model name are required.'); - return; - } - - // Find the selected model from the filtered models - const selectedModel = createSelectedModel(selectedProvider, modelName); - - // Trigger the model selection logic - handleModelSelection(selectedModel, 'AddModelInline'); - - // Reset form state - setSelectedProvider(null); // Clear the provider selection - setModelName(''); // Clear the model name - setFilteredModels([]); - setShowSuggestions(false); - }; - - const handleSelectSuggestion = (suggestion: { provider: string; name: string }) => { - setModelName(suggestion.name); - setShowSuggestions(false); // Hide suggestions after selection - }; - - const handleBlur = () => { - setTimeout(() => setShowSuggestions(false), 150); // Delay to allow click to register - }; - - return ( -
-
- setModelName(e.target.value)} - onBlur={handleBlur} - /> - {showSuggestions && ( -
- {filteredModels.map((model) => ( -
handleSelectSuggestion(model)} - > - {model.name} -
- ))} -
- )} -
- - - - ); -} diff --git a/ui/desktop/src/components/settings/models/GooseModels.tsx b/ui/desktop/src/components/settings/models/GooseModels.tsx deleted file mode 100644 index 4badf402..00000000 --- a/ui/desktop/src/components/settings/models/GooseModels.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { Model } from './ModelContext'; - -// TODO: move into backends / fetch dynamically -// this is used by ModelContext -export const gooseModels: Model[] = [ - { id: 1, name: 'gpt-4o-mini', provider: 'OpenAI' }, - { id: 2, name: 'gpt-4o', provider: 'OpenAI' }, - { id: 3, name: 'gpt-4-turbo', provider: 'OpenAI' }, - { id: 5, name: 'o1', provider: 'OpenAI' }, - { id: 7, name: 'claude-3-5-sonnet-latest', provider: 'Anthropic' }, - { id: 8, name: 'claude-3-5-haiku-latest', provider: 'Anthropic' }, - { id: 9, name: 'claude-3-opus-latest', provider: 'Anthropic' }, - { id: 10, name: 'gemini-1.5-pro', provider: 'Google' }, - { id: 11, name: 'gemini-1.5-flash', provider: 'Google' }, - { id: 12, name: 'gemini-2.0-flash', provider: 'Google' }, - { id: 13, name: 'gemini-2.0-flash-lite-preview-02-05', provider: 'Google' }, - { id: 14, name: 'gemini-2.0-flash-thinking-exp-01-21', provider: 'Google' }, - { id: 15, name: 'gemini-2.0-pro-exp-02-05', provider: 'Google' }, - { id: 16, name: 'gemini-2.5-pro-exp-03-25', provider: 'Google' }, - { id: 17, name: 'llama-3.3-70b-versatile', provider: 'Groq' }, - { id: 18, name: 'qwen2.5', provider: 'Ollama' }, - { id: 19, name: 'anthropic/claude-3.5-sonnet', provider: 'OpenRouter' }, - { id: 20, name: 'gpt-4o', provider: 'Azure OpenAI' }, - { id: 21, name: 'claude-3-7-sonnet@20250219', provider: 'GCP Vertex AI' }, - { id: 22, name: 'claude-3-5-sonnet-v2@20241022', provider: 'GCP Vertex AI' }, - { id: 23, name: 'claude-3-5-sonnet@20240620', provider: 'GCP Vertex AI' }, - { id: 24, name: 'claude-3-5-haiku@20241022', provider: 'GCP Vertex AI' }, - { id: 25, name: 'claude-sonnet-4@20250514', provider: 'GCP Vertex AI' }, - { id: 26, name: 'gemini-2.0-pro-exp-02-05', provider: 'GCP Vertex AI' }, - { id: 27, name: 'gemini-2.0-flash-001', provider: 'GCP Vertex AI' }, - { id: 28, name: 'gemini-1.5-pro-002', provider: 'GCP Vertex AI' }, - { id: 29, name: 'gemini-2.5-pro-exp-03-25', provider: 'GCP Vertex AI' }, -]; diff --git a/ui/desktop/src/components/settings/models/ModelContext.tsx b/ui/desktop/src/components/settings/models/ModelContext.tsx deleted file mode 100644 index b179188a..00000000 --- a/ui/desktop/src/components/settings/models/ModelContext.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { createContext, useContext, useState, ReactNode } from 'react'; -import { GOOSE_MODEL, GOOSE_PROVIDER } from '../../../env_vars'; -import { gooseModels } from './GooseModels'; // Assuming hardcoded models are here - -// TODO: API keys -export interface Model { - id?: number; // Make `id` optional to allow user-defined models - name: string; - provider: string; - lastUsed?: string; - alias?: string; // optional model display name - subtext?: string; // goes below model name if not the provider -} - -interface ModelContextValue { - currentModel: Model | null; - setCurrentModel: (model: Model) => void; - switchModel: (model: Model) => void; // Add the reusable switch function -} - -const ModelContext = createContext(undefined); - -export const ModelProvider = ({ children }: { children: ReactNode }) => { - const [currentModel, setCurrentModel] = useState( - JSON.parse(localStorage.getItem(GOOSE_MODEL) || 'null') - ); - - const updateModel = (model: Model) => { - setCurrentModel(model); - localStorage.setItem(GOOSE_PROVIDER, model.provider.toLowerCase()); - localStorage.setItem(GOOSE_MODEL, JSON.stringify(model)); - }; - - const switchModel = (model: Model) => { - const newModel = model.id - ? gooseModels.find((m) => m.id === model.id) || model - : { id: Date.now(), ...model }; // Assign unique ID for user-defined models - updateModel(newModel); - }; - - return ( - - {children} - - ); -}; - -export const useModel = () => { - const context = useContext(ModelContext); - if (!context) throw new Error('useModel must be used within a ModelProvider'); - return context; -}; diff --git a/ui/desktop/src/components/settings/models/ModelRadioList.tsx b/ui/desktop/src/components/settings/models/ModelRadioList.tsx deleted file mode 100644 index d4bb7cb4..00000000 --- a/ui/desktop/src/components/settings/models/ModelRadioList.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { useRecentModels } from './RecentModels'; -import { useModel, Model } from './ModelContext'; -import { useHandleModelSelection } from './utils'; -import type { View } from '../../../App'; - -interface ModelRadioListProps { - renderItem: (props: { - model: Model; - isSelected: boolean; - onSelect: () => void; - }) => React.ReactNode; - className?: string; -} - -export function SeeMoreModelsButtons({ setView }: { setView: (view: View) => void }) { - return ( -
-

Models

- -
- ); -} - -export function ModelRadioList({ renderItem, className = '' }: ModelRadioListProps) { - const { recentModels } = useRecentModels(); - const { currentModel } = useModel(); - const handleModelSelection = useHandleModelSelection(); - const [selectedModel, setSelectedModel] = useState(null); - - useEffect(() => { - if (currentModel) { - setSelectedModel(currentModel.name); - } - }, [currentModel]); - - const handleRadioChange = async (model: Model) => { - if (selectedModel === model.name) { - console.log(`Model "${model.name}" is already active.`); - return; - } - - setSelectedModel(model.name); - await handleModelSelection(model, 'ModelList'); - }; - - return ( -
- {recentModels.map((model) => - renderItem({ - model, - isSelected: selectedModel === model.name, - onSelect: () => handleRadioChange(model), - }) - )} -
- ); -} diff --git a/ui/desktop/src/components/settings_v2/models/ModelsSection.tsx b/ui/desktop/src/components/settings/models/ModelsSection.tsx similarity index 100% rename from ui/desktop/src/components/settings_v2/models/ModelsSection.tsx rename to ui/desktop/src/components/settings/models/ModelsSection.tsx diff --git a/ui/desktop/src/components/settings/models/MoreModelsView.tsx b/ui/desktop/src/components/settings/models/MoreModelsView.tsx deleted file mode 100644 index 3a5b5110..00000000 --- a/ui/desktop/src/components/settings/models/MoreModelsView.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { RecentModels } from './RecentModels'; -import { ProviderButtons } from './ProviderButtons'; -import BackButton from '../../ui/BackButton'; -import { SearchBar } from './Search'; -import { AddModelInline } from './AddModelInline'; -import { ScrollArea } from '../../ui/scroll-area'; -import type { View } from '../../../App'; -import MoreMenuLayout from '../../more_menu/MoreMenuLayout'; - -export default function MoreModelsView({ - onClose, - setView, -}: { - onClose: () => void; - setView: (view: View) => void; -}) { - return ( -
- - - -
- -

Browse models

-
- - {/* Content Area */} -
-
-
-

Models

- -
- -
- {/* Search Section */} -
-

Search Models

- -
- - {/* Add Model Section */} -
-

Add Model

- -
- - {/* Provider Section */} -
-

Browse by Provider

-
- -
-
- - {/* Recent Models Section */} -
-
-

Recently used

-
-
- -
-
-
-
-
-
-
- ); -} diff --git a/ui/desktop/src/components/settings/models/ProviderButtons.tsx b/ui/desktop/src/components/settings/models/ProviderButtons.tsx deleted file mode 100644 index 54236651..00000000 --- a/ui/desktop/src/components/settings/models/ProviderButtons.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { useState, useEffect } from 'react'; -import { Button } from '../../ui/button'; -import { Switch } from '../../ui/switch'; -import { useActiveKeys } from '../api_keys/ActiveKeysContext'; -import { model_docs_link } from './hardcoded_stuff'; -import { gooseModels } from './GooseModels'; -import { useModel } from './ModelContext'; -import { useHandleModelSelection } from './utils'; - -// Create a mapping from provider name to href -const providerLinks: Record = model_docs_link.reduce( - (acc, { name, href }) => { - acc[name] = href; - return acc; - }, - {} as Record -); - -export function ProviderButtons() { - const { activeKeys } = useActiveKeys(); - const [selectedProvider, setSelectedProvider] = useState(null); - const { currentModel } = useModel(); - const handleModelSelection = useHandleModelSelection(); - - // Handle Escape key press - useEffect(() => { - const handleEsc = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - setSelectedProvider(null); - } - }; - window.addEventListener('keydown', handleEsc); - return () => window.removeEventListener('keydown', handleEsc); - }, []); - - // Filter models by provider - const providerModels = selectedProvider - ? gooseModels.filter((model) => model.provider === selectedProvider) - : []; - - return ( -
-
-
- {activeKeys.map((provider) => ( - - ))} -
-
- - {/* Models List */} - {selectedProvider && ( -
-
- {providerModels.map((model) => ( -
- {model.name} - handleModelSelection(model, 'ProviderButtons')} - /> -
- ))} -
- - - Browse more {selectedProvider} models - -
- )} -
- ); -} diff --git a/ui/desktop/src/components/settings/models/RecentModels.tsx b/ui/desktop/src/components/settings/models/RecentModels.tsx deleted file mode 100644 index 2091a007..00000000 --- a/ui/desktop/src/components/settings/models/RecentModels.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import { useState, useEffect } from 'react'; -import { Clock } from 'lucide-react'; -import { Model } from './ModelContext'; -import { ModelRadioList, SeeMoreModelsButtons } from './ModelRadioList'; -import { useModel } from './ModelContext'; -import { useHandleModelSelection } from './utils'; -import type { View } from '../../../App'; - -const MAX_RECENT_MODELS = 3; - -export function useRecentModels() { - const [recentModels, setRecentModels] = useState([]); - - useEffect(() => { - const storedModels = localStorage.getItem('recentModels'); - if (storedModels) { - setRecentModels(JSON.parse(storedModels)); - } - }, []); - - const addRecentModel = (model: Model) => { - const modelWithTimestamp = { ...model, lastUsed: new Date().toISOString() }; // Add lastUsed field - setRecentModels((prevModels) => { - const updatedModels = [ - modelWithTimestamp, - ...prevModels.filter((m) => m.name !== model.name), - ].slice(0, MAX_RECENT_MODELS); - - localStorage.setItem('recentModels', JSON.stringify(updatedModels)); - return updatedModels; - }); - }; - - return { recentModels, addRecentModel }; -} - -function getRelativeTimeString(date: string | Date): string { - const now = new Date(); - const then = new Date(date); - const diffInSeconds = Math.floor((now.getTime() - then.getTime()) / 1000); - - if (diffInSeconds < 60) { - return 'Just now'; - } - - const diffInMinutes = Math.floor(diffInSeconds / 60); - if (diffInMinutes < 60) { - return `${diffInMinutes}m ago`; - } - - const diffInHours = Math.floor(diffInMinutes / 60); - if (diffInHours < 24) { - return `${diffInHours}h ago`; - } - - const diffInDays = Math.floor(diffInHours / 24); - if (diffInDays < 7) { - return `${diffInDays}d ago`; - } - - if (diffInDays < 30) { - const weeks = Math.floor(diffInDays / 7); - return `${weeks}w ago`; - } - - const months = Math.floor(diffInDays / 30); - if (months < 12) { - return `${months}mo ago`; - } - - const years = Math.floor(months / 12); - return `${years}y ago`; -} - -export function RecentModels() { - const { recentModels } = useRecentModels(); - const { currentModel } = useModel(); - const handleModelSelection = useHandleModelSelection(); - const [selectedModel, setSelectedModel] = useState(null); - - useEffect(() => { - if (currentModel) { - setSelectedModel(currentModel.name); - } - }, [currentModel]); - - const handleRadioChange = async (model: Model) => { - if (selectedModel === model.name) { - console.log(`Model "${model.name}" is already active.`); - return; - } - - setSelectedModel(model.name); - await handleModelSelection(model, 'RecentModels'); - }; - - return ( -
- {recentModels.map((model) => ( - - ))} -
- ); -} - -export function RecentModelsRadio({ setView }: { setView: (view: View) => void }) { - return ( -
- -
-
- ( - - )} - /> -
-
-
- ); -} diff --git a/ui/desktop/src/components/settings/models/Search.tsx b/ui/desktop/src/components/settings/models/Search.tsx deleted file mode 100644 index 5f743937..00000000 --- a/ui/desktop/src/components/settings/models/Search.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import React, { useState, useEffect, useRef } from 'react'; -import { Search } from 'lucide-react'; -import { Switch } from '../../ui/switch'; -import { gooseModels } from './GooseModels'; -import { useModel } from './ModelContext'; -import { useHandleModelSelection } from './utils'; -import { useActiveKeys } from '../api_keys/ActiveKeysContext'; - -// TODO: dark mode (p1) -// FIXME: arrow keys do not work to select a model (p1) -export function SearchBar() { - const [search, setSearch] = useState(''); - const [focusedIndex, setFocusedIndex] = useState(-1); - const [showResults, setShowResults] = useState(false); - const resultsRef = useRef<(HTMLDivElement | null)[]>([]); - const searchBarRef = useRef(null); - - const { currentModel } = useModel(); // Access global state - const handleModelSelection = useHandleModelSelection(); - - // search results filtering - // results set will only include models that have a configured provider - const { activeKeys } = useActiveKeys(); // Access active keys from context - - const model_options = gooseModels.filter((model) => activeKeys.includes(model.provider)); - - const filteredModels = model_options - .filter((model) => model.name.toLowerCase().includes(search.toLowerCase())) - .slice(0, 5); - - useEffect(() => { - setFocusedIndex(-1); - }, [search]); - - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (searchBarRef.current && !searchBarRef.current.contains(event.target as Node)) { - setShowResults(false); - } - }; - - document.addEventListener('mousedown', handleClickOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, []); - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'ArrowDown') { - e.preventDefault(); - setFocusedIndex((prev) => (prev < filteredModels.length - 1 ? prev + 1 : prev)); - setShowResults(true); - } else if (e.key === 'ArrowUp') { - e.preventDefault(); - setFocusedIndex((prev) => (prev > 0 ? prev - 1 : prev)); - setShowResults(true); - } else if (e.key === 'Enter' && focusedIndex >= 0) { - e.preventDefault(); - const selectedModel = filteredModels[focusedIndex]; - handleModelSelection(selectedModel, 'SearchBar'); - } else if (e.key === 'Escape') { - e.preventDefault(); - setShowResults(false); - } - }; - - useEffect(() => { - if (focusedIndex >= 0 && focusedIndex < resultsRef.current.length) { - resultsRef.current[focusedIndex]?.scrollIntoView({ - block: 'nearest', - }); - } - }, [focusedIndex]); - - return ( -
- - { - setSearch(e.target.value); - setShowResults(true); - }} - onKeyDown={handleKeyDown} - onFocus={() => setShowResults(true)} - className="w-full pl-9 py-2 text-black dark:text-white bg-bgApp border border-borderSubtle rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" - /> - {showResults && search && ( -
- {filteredModels.length > 0 ? ( - filteredModels.map((model, index) => ( -
(resultsRef.current[index] = el)} - className={`p-2 flex justify-between items-center hover:bg-bgSubtle/50 dark:hover:bg-gray-700 cursor-pointer ${ - model.id === currentModel?.id ? 'bg-bgSubtle/50 dark:bg-gray-700' : '' - }`} - > -
- {model.name} - - {model.provider} - -
- handleModelSelection(model, 'SearchBar')} - /> -
- )) - ) : ( -
No models found
- )} -
- )} -
- ); -} diff --git a/ui/desktop/src/components/settings_v2/models/bottom_bar/ModelsBottomBar.tsx b/ui/desktop/src/components/settings/models/bottom_bar/ModelsBottomBar.tsx similarity index 84% rename from ui/desktop/src/components/settings_v2/models/bottom_bar/ModelsBottomBar.tsx rename to ui/desktop/src/components/settings/models/bottom_bar/ModelsBottomBar.tsx index 21bb47fa..2f37ce87 100644 --- a/ui/desktop/src/components/settings_v2/models/bottom_bar/ModelsBottomBar.tsx +++ b/ui/desktop/src/components/settings/models/bottom_bar/ModelsBottomBar.tsx @@ -1,7 +1,6 @@ import { Sliders } from 'lucide-react'; import React, { useEffect, useState, useRef } from 'react'; -import { useConfig } from '../../../ConfigContext'; -import { getCurrentModelAndProviderForDisplay } from '../index'; +import { useModelAndProvider } from '../../../ModelAndProviderContext'; import { AddModelModal } from '../subcomponents/AddModelModal'; import { View } from '../../../../App'; import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '../../../ui/Tooltip'; @@ -11,10 +10,10 @@ interface ModelsBottomBarProps { setView: (view: View) => void; } export default function ModelsBottomBar({ dropdownRef, setView }: ModelsBottomBarProps) { - const { read, getProviders } = useConfig(); + const { currentModel, currentProvider, getCurrentModelAndProviderForDisplay } = + useModelAndProvider(); const [isModelMenuOpen, setIsModelMenuOpen] = useState(false); - const [provider, setProvider] = useState(null); - const [model, setModel] = useState(''); + const [displayProvider, setDisplayProvider] = useState(null); const [isAddModelModalOpen, setIsAddModelModalOpen] = useState(false); const menuRef = useRef(null); const [isModelTruncated, setIsModelTruncated] = useState(false); @@ -22,16 +21,15 @@ export default function ModelsBottomBar({ dropdownRef, setView }: ModelsBottomBa const modelRef = useRef(null); const [isTooltipOpen, setIsTooltipOpen] = useState(false); + // Update display provider when current provider changes useEffect(() => { - (async () => { - const modelProvider = await getCurrentModelAndProviderForDisplay({ - readFromConfig: read, - getProviders, - }); - setProvider(modelProvider.provider as string | null); - setModel(modelProvider.model as string); - })(); - }); + if (currentProvider) { + (async () => { + const modelProvider = await getCurrentModelAndProviderForDisplay(); + setDisplayProvider(modelProvider.provider); + })(); + } + }, [currentProvider, getCurrentModelAndProviderForDisplay]); useEffect(() => { const checkTruncation = () => { @@ -42,7 +40,7 @@ export default function ModelsBottomBar({ dropdownRef, setView }: ModelsBottomBa checkTruncation(); window.addEventListener('resize', checkTruncation); return () => window.removeEventListener('resize', checkTruncation); - }, [model]); + }, [currentModel]); useEffect(() => { setIsTooltipOpen(false); @@ -81,12 +79,12 @@ export default function ModelsBottomBar({ dropdownRef, setView }: ModelsBottomBa ref={modelRef} className="truncate max-w-[130px] md:max-w-[200px] lg:max-w-[360px] min-w-0 block" > - {model || 'Select Model'} + {currentModel || 'Select Model'} {isModelTruncated && ( - {model || 'Select Model'} + {currentModel || 'Select Model'} )} @@ -99,7 +97,7 @@ export default function ModelsBottomBar({ dropdownRef, setView }: ModelsBottomBa
Current:
- {model} -- {provider} + {currentModel} -- {displayProvider}
- {/* Glowing ring */} -
-
isSelectable && isConfigured && onSelect?.()} - className={`relative bg-bgApp rounded-lg - p-3 transition-all duration-200 h-[160px] flex flex-col justify-between - ${isSelectable && isConfigured ? 'cursor-pointer' : ''} - ${!isSelectable ? 'hover:border-borderStandard' : ''} - ${isSelectable && isConfigured ? 'hover:border-borderStandard' : ''} - `} - > -
-
-

{name}

- - {/* Configured state: Green check */} - {isConfigured && ( - - - -
- -
-
- - -

- {hasRequiredKeys - ? `You have ${getArticle(name)} ${name} API Key set in your environment` - : `${name} is installed and running on your machine`} -

-
-
-
-
- )} -
-

- {description} -

-
- -
-
- {/* Default "Add Keys" Button for other providers */} - {!isConfigured && onAddKeys && hasRequiredKeys && ( - - - - - - - -

{tooltipText}

-
-
-
-
- )} - {isConfigured && showSettings && hasRequiredKeys && ( - - - - - - - -

Configure {name} settings

-
-
-
-
- )} - {showDelete && hasRequiredKeys && isConfigured && ( - - - - - - - -

Remove {name} API Key or Host

-
-
-
-
- )} -
- {isConfigured && onTakeoff && showTakeoff !== false && ( - - - - - - - -

Launch goose with {name}

-
-
-
-
- )} -
-
-
- ); -} - -interface BaseProviderGridProps { - providers: Provider[]; - isSelectable?: boolean; - showSettings?: boolean; - showDelete?: boolean; - selectedId?: string | null; - onSelect?: (providerId: string) => void; - onAddKeys?: (provider: Provider) => void; - onConfigure?: (provider: Provider) => void; - onDelete?: (provider: Provider) => void; - onTakeoff?: (provider: Provider) => void; - showTakeoff?: boolean; -} - -export function BaseProviderGrid({ - providers, - isSelectable = false, - showSettings = false, - showDelete = false, - selectedId = null, - onSelect, - onAddKeys, - onConfigure, - onDelete, - showTakeoff, - onTakeoff, -}: BaseProviderGridProps) { - return ( -
- {providers.map((provider) => { - const hasRequiredKeys = - (required_keys as Record)[provider.name]?.length > 0; - return ( - onSelect?.(provider.id)} - onAddKeys={() => onAddKeys?.(provider)} - onConfigure={() => onConfigure?.(provider)} - onDelete={() => onDelete?.(provider)} - onTakeoff={() => onTakeoff?.(provider)} - showSettings={showSettings} - showDelete={showDelete} - hasRequiredKeys={hasRequiredKeys} - showTakeoff={showTakeoff} - /> - ); - })} -
- ); -} diff --git a/ui/desktop/src/components/settings/providers/ConfigureProvidersGrid.tsx b/ui/desktop/src/components/settings/providers/ConfigureProvidersGrid.tsx deleted file mode 100644 index f9f3d860..00000000 --- a/ui/desktop/src/components/settings/providers/ConfigureProvidersGrid.tsx +++ /dev/null @@ -1,308 +0,0 @@ -import { useMemo, useState } from 'react'; -import { useActiveKeys } from '../api_keys/ActiveKeysContext'; -import { BaseProviderGrid, getProviderDescription } from './BaseProviderGrid'; -import { supported_providers, provider_aliases, required_keys } from '../models/hardcoded_stuff'; -import { ProviderSetupModal } from '../ProviderSetupModal'; -import { getApiUrl, getSecretKey } from '../../../config'; -import { getActiveProviders, isSecretKey } from '../api_keys/utils'; -import { useModel } from '../models/ModelContext'; -import { Button } from '../../ui/button'; -import { toastError, toastSuccess } from '../../../toasts'; - -function ConfirmationModal({ - message, - onConfirm, - onCancel, -}: { - message: string; - onConfirm: () => void; - onCancel: () => void; -}) { - return ( -
-
-
-

- Confirm Delete -

-

{message}

-
- - -
-
-
-
- ); -} - -// Settings version - non-selectable cards with settings gear -export function ConfigureProvidersGrid() { - const { activeKeys, setActiveKeys } = useActiveKeys(); - const [showSetupModal, setShowSetupModal] = useState(false); - const [selectedForSetup, setSelectedForSetup] = useState(null); - const [modalMode, setModalMode] = useState<'edit' | 'setup' | 'battle'>('setup'); - const [isConfirmationOpen, setIsConfirmationOpen] = useState(false); - const [providerToDelete, setProviderToDelete] = useState<{ - name: string; - id: string; - isConfigured: boolean; - description: string; - } | null>(null); - const { currentModel } = useModel(); - - const providers = useMemo(() => { - return supported_providers.map((providerName) => { - const alias = - provider_aliases.find((p) => p.provider === providerName)?.alias || - providerName.toLowerCase(); - const isConfigured = activeKeys.includes(providerName); - - return { - id: alias, - name: providerName, - isConfigured, - description: getProviderDescription(providerName), - }; - }); - }, [activeKeys]); - - const handleAddKeys = (provider: { - id: string; - name: string; - isConfigured: boolean; - description: string; - }) => { - setSelectedForSetup(provider.id); - setModalMode('setup'); - setShowSetupModal(true); - }; - - const handleConfigure = (provider: { - id: string; - name: string; - isConfigured: boolean; - description: string; - }) => { - setSelectedForSetup(provider.id); - setModalMode('edit'); - setShowSetupModal(true); - }; - - const handleModalSubmit = async (configValues: { [key: string]: string }) => { - if (!selectedForSetup) return; - - const provider = providers.find((p) => p.id === selectedForSetup)?.name; - if (!provider) return; - - const requiredKeys = (required_keys as Record)[provider]; - if (!requiredKeys || requiredKeys.length === 0) { - console.error(`No keys found for provider ${provider}`); - return; - } - - try { - // Delete existing keys if provider is already configured - const isUpdate = providers.find((p) => p.id === selectedForSetup)?.isConfigured; - if (isUpdate) { - for (const keyName of requiredKeys) { - const isSecret = isSecretKey(keyName); - const deleteResponse = await fetch(getApiUrl('/configs/delete'), { - method: 'DELETE', - headers: { - 'Content-Type': 'application/json', - 'X-Secret-Key': getSecretKey(), - }, - body: JSON.stringify({ - key: keyName, - isSecret, - }), - }); - - if (!deleteResponse.ok) { - const errorText = await deleteResponse.text(); - console.error('Delete response error:', errorText); - throw new Error(`Failed to delete old key: ${keyName}`); - } - } - } - - // Store new keys - for (const keyName of requiredKeys) { - const value = configValues[keyName]; - if (!value) { - console.error(`Missing value for required key: ${keyName}`); - continue; - } - - const isSecret = isSecretKey(keyName); - const storeResponse = await fetch(getApiUrl('/configs/store'), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Secret-Key': getSecretKey(), - }, - body: JSON.stringify({ - key: keyName, - value: value, - isSecret, - }), - }); - - if (!storeResponse.ok) { - const errorText = await storeResponse.text(); - console.error('Store response error:', errorText); - throw new Error(`Failed to store new key: ${keyName}`); - } - } - - toastSuccess({ - title: provider, - msg: isUpdate ? `Successfully updated configuration` : `Successfully added configuration`, - }); - - const updatedKeys = await getActiveProviders(); - setActiveKeys(updatedKeys); - - setShowSetupModal(false); - setSelectedForSetup(null); - setModalMode('setup'); - } catch (error) { - console.error('Error handling modal submit:', error); - toastError({ - title: provider, - msg: `Failed to ${providers.find((p) => p.id === selectedForSetup)?.isConfigured ? 'update' : 'add'} configuration`, - traceback: error instanceof Error ? error.message : String(error), - }); - } - }; - - const handleDelete = async (provider: { - id: string; - name: string; - isConfigured: boolean; - description: string; - }) => { - setProviderToDelete(provider); - setIsConfirmationOpen(true); - }; - - const confirmDelete = async () => { - if (!providerToDelete) return; - - const requiredKeys = required_keys[providerToDelete.name as keyof typeof required_keys]; - if (!requiredKeys || requiredKeys.length === 0) { - console.error(`No keys found for provider ${providerToDelete.name}`); - return; - } - - try { - // Check if the selected provider is currently active - if (currentModel?.provider === providerToDelete.name) { - const msg = `Cannot delete the configuration because it's the provider of the current model (${currentModel.name}). Please switch to a different model first.`; - toastError({ title: providerToDelete.name, msg, traceback: msg }); - setIsConfirmationOpen(false); - return; - } - - // Delete all keys for the provider - for (const keyName of requiredKeys) { - const isSecret = isSecretKey(keyName); - const deleteResponse = await fetch(getApiUrl('/configs/delete'), { - method: 'DELETE', - headers: { - 'Content-Type': 'application/json', - 'X-Secret-Key': getSecretKey(), - }, - body: JSON.stringify({ - key: keyName, - isSecret, - }), - }); - - if (!deleteResponse.ok) { - const errorText = await deleteResponse.text(); - console.error('Delete response error:', errorText); - throw new Error(`Failed to delete key: ${keyName}`); - } - } - - console.log('Configuration deleted successfully.'); - toastSuccess({ - title: providerToDelete.name, - msg: 'Successfully deleted configuration', - }); - - const updatedKeys = await getActiveProviders(); - setActiveKeys(updatedKeys); - } catch (error) { - console.error('Error deleting configuration:', error); - toastError({ - title: providerToDelete.name, - msg: 'Failed to delete configuration', - traceback: error instanceof Error ? error.message : String(error), - }); - } - setIsConfirmationOpen(false); - }; - - return ( -
- - - {showSetupModal && selectedForSetup && ( -
- p.id === selectedForSetup)?.name || ''} - _model="Example Model" - _endpoint="Example Endpoint" - title={ - modalMode === 'edit' - ? `Edit ${providers.find((p) => p.id === selectedForSetup)?.name} Configuration` - : undefined - } - onSubmit={(configValues) => { - if (configValues.forceBattle === 'true') { - setSelectedForSetup(selectedForSetup); - setModalMode('battle'); - setShowSetupModal(true); - return; - } - handleModalSubmit(configValues); - }} - onCancel={() => { - setShowSetupModal(false); - setSelectedForSetup(null); - setModalMode('setup'); - }} - forceBattle={modalMode === 'battle'} - /> -
- )} - - {isConfirmationOpen && providerToDelete && ( - setIsConfirmationOpen(false)} - /> - )} -
- ); -} diff --git a/ui/desktop/src/components/settings/providers/ConfigureProvidersView.tsx b/ui/desktop/src/components/settings/providers/ConfigureProvidersView.tsx deleted file mode 100644 index 8890fe71..00000000 --- a/ui/desktop/src/components/settings/providers/ConfigureProvidersView.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { ScrollArea } from '../../ui/scroll-area'; -import BackButton from '../../ui/BackButton'; -import { ConfigureProvidersGrid } from './ConfigureProvidersGrid'; -import MoreMenuLayout from '../../more_menu/MoreMenuLayout'; - -export default function ConfigureProvidersView({ onClose }: { onClose: () => void }) { - return ( -
- - - -
- -

Configure

-
- -
-
-

Providers

-
- - {/* Content Area */} -
-
- -
-
-
-
-
- ); -} diff --git a/ui/desktop/src/components/settings_v2/providers/ProviderGrid.tsx b/ui/desktop/src/components/settings/providers/ProviderGrid.tsx similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/ProviderGrid.tsx rename to ui/desktop/src/components/settings/providers/ProviderGrid.tsx diff --git a/ui/desktop/src/components/settings_v2/providers/ProviderRegistry.tsx b/ui/desktop/src/components/settings/providers/ProviderRegistry.tsx similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/ProviderRegistry.tsx rename to ui/desktop/src/components/settings/providers/ProviderRegistry.tsx diff --git a/ui/desktop/src/components/settings_v2/providers/ProviderSettingsPage.tsx b/ui/desktop/src/components/settings/providers/ProviderSettingsPage.tsx similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/ProviderSettingsPage.tsx rename to ui/desktop/src/components/settings/providers/ProviderSettingsPage.tsx diff --git a/ui/desktop/src/components/settings_v2/providers/interfaces/ButtonCallbacks.tsx b/ui/desktop/src/components/settings/providers/interfaces/ButtonCallbacks.tsx similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/interfaces/ButtonCallbacks.tsx rename to ui/desktop/src/components/settings/providers/interfaces/ButtonCallbacks.tsx diff --git a/ui/desktop/src/components/settings_v2/providers/interfaces/ConfigurationAction.tsx b/ui/desktop/src/components/settings/providers/interfaces/ConfigurationAction.tsx similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/interfaces/ConfigurationAction.tsx rename to ui/desktop/src/components/settings/providers/interfaces/ConfigurationAction.tsx diff --git a/ui/desktop/src/components/settings_v2/providers/interfaces/OllamaMetadata.tsx b/ui/desktop/src/components/settings/providers/interfaces/OllamaMetadata.tsx similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/interfaces/OllamaMetadata.tsx rename to ui/desktop/src/components/settings/providers/interfaces/OllamaMetadata.tsx diff --git a/ui/desktop/src/components/settings_v2/providers/interfaces/ParameterSchema.ts b/ui/desktop/src/components/settings/providers/interfaces/ParameterSchema.ts similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/interfaces/ParameterSchema.ts rename to ui/desktop/src/components/settings/providers/interfaces/ParameterSchema.ts diff --git a/ui/desktop/src/components/settings_v2/providers/interfaces/ProviderDetails.tsx b/ui/desktop/src/components/settings/providers/interfaces/ProviderDetails.tsx similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/interfaces/ProviderDetails.tsx rename to ui/desktop/src/components/settings/providers/interfaces/ProviderDetails.tsx diff --git a/ui/desktop/src/components/settings_v2/providers/interfaces/ProviderState.tsx b/ui/desktop/src/components/settings/providers/interfaces/ProviderState.tsx similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/interfaces/ProviderState.tsx rename to ui/desktop/src/components/settings/providers/interfaces/ProviderState.tsx diff --git a/ui/desktop/src/components/settings_v2/providers/modal/ProviderConfiguationModal.tsx b/ui/desktop/src/components/settings/providers/modal/ProviderConfiguationModal.tsx similarity index 96% rename from ui/desktop/src/components/settings_v2/providers/modal/ProviderConfiguationModal.tsx rename to ui/desktop/src/components/settings/providers/modal/ProviderConfiguationModal.tsx index 74deaca7..36e4245c 100644 --- a/ui/desktop/src/components/settings_v2/providers/modal/ProviderConfiguationModal.tsx +++ b/ui/desktop/src/components/settings/providers/modal/ProviderConfiguationModal.tsx @@ -10,8 +10,8 @@ import { DefaultSubmitHandler } from './subcomponents/handlers/DefaultSubmitHand import OllamaSubmitHandler from './subcomponents/handlers/OllamaSubmitHandler'; import OllamaForm from './subcomponents/forms/OllamaForm'; import { useConfig } from '../../../ConfigContext'; +import { useModelAndProvider } from '../../../ModelAndProviderContext'; import { AlertTriangle } from 'lucide-react'; -import { getCurrentModelAndProvider } from '../../models'; // Import the utility interface FormValues { [key: string]: string | number | boolean | null; @@ -27,7 +27,8 @@ const customFormsMap: Record = { export default function ProviderConfigurationModal() { const [validationErrors, setValidationErrors] = useState>({}); - const { upsert, remove, read } = useConfig(); // Add read to the destructured values + const { upsert, remove } = useConfig(); + const { getCurrentModelAndProvider } = useModelAndProvider(); const { isOpen, currentProvider, modalProps, closeModal } = useProviderModal(); const [configValues, setConfigValues] = useState>({}); const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false); @@ -126,10 +127,7 @@ export default function ProviderConfigurationModal() { const handleDelete = async () => { // Check if this is the currently active provider try { - const providerModel = await getCurrentModelAndProvider({ - readFromConfig: read, - writeToConfig: upsert, - }); + const providerModel = await getCurrentModelAndProvider(); if (currentProvider.name === providerModel.provider) { // It's the active provider - set state and show warning setIsActiveProvider(true); diff --git a/ui/desktop/src/components/settings_v2/providers/modal/ProviderModalProvider.tsx b/ui/desktop/src/components/settings/providers/modal/ProviderModalProvider.tsx similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/modal/ProviderModalProvider.tsx rename to ui/desktop/src/components/settings/providers/modal/ProviderModalProvider.tsx diff --git a/ui/desktop/src/components/settings_v2/providers/modal/constants.tsx b/ui/desktop/src/components/settings/providers/modal/constants.tsx similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/modal/constants.tsx rename to ui/desktop/src/components/settings/providers/modal/constants.tsx diff --git a/ui/desktop/src/components/settings_v2/providers/modal/interfaces/ProviderSetupFormProps.tsx b/ui/desktop/src/components/settings/providers/modal/interfaces/ProviderSetupFormProps.tsx similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/modal/interfaces/ProviderSetupFormProps.tsx rename to ui/desktop/src/components/settings/providers/modal/interfaces/ProviderSetupFormProps.tsx diff --git a/ui/desktop/src/components/settings_v2/providers/modal/subcomponents/ProviderLogo.tsx b/ui/desktop/src/components/settings/providers/modal/subcomponents/ProviderLogo.tsx similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/modal/subcomponents/ProviderLogo.tsx rename to ui/desktop/src/components/settings/providers/modal/subcomponents/ProviderLogo.tsx diff --git a/ui/desktop/src/components/settings_v2/providers/modal/subcomponents/ProviderSetupActions.tsx b/ui/desktop/src/components/settings/providers/modal/subcomponents/ProviderSetupActions.tsx similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/modal/subcomponents/ProviderSetupActions.tsx rename to ui/desktop/src/components/settings/providers/modal/subcomponents/ProviderSetupActions.tsx diff --git a/ui/desktop/src/components/settings_v2/providers/modal/subcomponents/ProviderSetupHeader.tsx b/ui/desktop/src/components/settings/providers/modal/subcomponents/ProviderSetupHeader.tsx similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/modal/subcomponents/ProviderSetupHeader.tsx rename to ui/desktop/src/components/settings/providers/modal/subcomponents/ProviderSetupHeader.tsx diff --git a/ui/desktop/src/components/settings_v2/providers/modal/subcomponents/SecureStorageNotice.tsx b/ui/desktop/src/components/settings/providers/modal/subcomponents/SecureStorageNotice.tsx similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/modal/subcomponents/SecureStorageNotice.tsx rename to ui/desktop/src/components/settings/providers/modal/subcomponents/SecureStorageNotice.tsx diff --git a/ui/desktop/src/components/settings_v2/providers/modal/subcomponents/forms/DefaultProviderSetupForm.tsx b/ui/desktop/src/components/settings/providers/modal/subcomponents/forms/DefaultProviderSetupForm.tsx similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/modal/subcomponents/forms/DefaultProviderSetupForm.tsx rename to ui/desktop/src/components/settings/providers/modal/subcomponents/forms/DefaultProviderSetupForm.tsx diff --git a/ui/desktop/src/components/settings_v2/providers/modal/subcomponents/forms/OllamaForm.tsx b/ui/desktop/src/components/settings/providers/modal/subcomponents/forms/OllamaForm.tsx similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/modal/subcomponents/forms/OllamaForm.tsx rename to ui/desktop/src/components/settings/providers/modal/subcomponents/forms/OllamaForm.tsx diff --git a/ui/desktop/src/components/settings_v2/providers/modal/subcomponents/handlers/DefaultSubmitHandler.tsx b/ui/desktop/src/components/settings/providers/modal/subcomponents/handlers/DefaultSubmitHandler.tsx similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/modal/subcomponents/handlers/DefaultSubmitHandler.tsx rename to ui/desktop/src/components/settings/providers/modal/subcomponents/handlers/DefaultSubmitHandler.tsx diff --git a/ui/desktop/src/components/settings_v2/providers/modal/subcomponents/handlers/OllamaSubmitHandler.tsx b/ui/desktop/src/components/settings/providers/modal/subcomponents/handlers/OllamaSubmitHandler.tsx similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/modal/subcomponents/handlers/OllamaSubmitHandler.tsx rename to ui/desktop/src/components/settings/providers/modal/subcomponents/handlers/OllamaSubmitHandler.tsx diff --git a/ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/anthropic.png b/ui/desktop/src/components/settings/providers/modal/subcomponents/icons/anthropic.png similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/anthropic.png rename to ui/desktop/src/components/settings/providers/modal/subcomponents/icons/anthropic.png diff --git a/ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/anthropic@2x.png b/ui/desktop/src/components/settings/providers/modal/subcomponents/icons/anthropic@2x.png similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/anthropic@2x.png rename to ui/desktop/src/components/settings/providers/modal/subcomponents/icons/anthropic@2x.png diff --git a/ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/anthropic@3x.png b/ui/desktop/src/components/settings/providers/modal/subcomponents/icons/anthropic@3x.png similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/anthropic@3x.png rename to ui/desktop/src/components/settings/providers/modal/subcomponents/icons/anthropic@3x.png diff --git a/ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/databricks.png b/ui/desktop/src/components/settings/providers/modal/subcomponents/icons/databricks.png similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/databricks.png rename to ui/desktop/src/components/settings/providers/modal/subcomponents/icons/databricks.png diff --git a/ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/databricks@2x.png b/ui/desktop/src/components/settings/providers/modal/subcomponents/icons/databricks@2x.png similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/databricks@2x.png rename to ui/desktop/src/components/settings/providers/modal/subcomponents/icons/databricks@2x.png diff --git a/ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/databricks@3x.png b/ui/desktop/src/components/settings/providers/modal/subcomponents/icons/databricks@3x.png similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/databricks@3x.png rename to ui/desktop/src/components/settings/providers/modal/subcomponents/icons/databricks@3x.png diff --git a/ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/default.png b/ui/desktop/src/components/settings/providers/modal/subcomponents/icons/default.png similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/default.png rename to ui/desktop/src/components/settings/providers/modal/subcomponents/icons/default.png diff --git a/ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/default@2x.png b/ui/desktop/src/components/settings/providers/modal/subcomponents/icons/default@2x.png similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/default@2x.png rename to ui/desktop/src/components/settings/providers/modal/subcomponents/icons/default@2x.png diff --git a/ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/default@3x.png b/ui/desktop/src/components/settings/providers/modal/subcomponents/icons/default@3x.png similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/default@3x.png rename to ui/desktop/src/components/settings/providers/modal/subcomponents/icons/default@3x.png diff --git a/ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/google.png b/ui/desktop/src/components/settings/providers/modal/subcomponents/icons/google.png similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/google.png rename to ui/desktop/src/components/settings/providers/modal/subcomponents/icons/google.png diff --git a/ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/google@2x.png b/ui/desktop/src/components/settings/providers/modal/subcomponents/icons/google@2x.png similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/google@2x.png rename to ui/desktop/src/components/settings/providers/modal/subcomponents/icons/google@2x.png diff --git a/ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/google@3x.png b/ui/desktop/src/components/settings/providers/modal/subcomponents/icons/google@3x.png similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/google@3x.png rename to ui/desktop/src/components/settings/providers/modal/subcomponents/icons/google@3x.png diff --git a/ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/groq.png b/ui/desktop/src/components/settings/providers/modal/subcomponents/icons/groq.png similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/groq.png rename to ui/desktop/src/components/settings/providers/modal/subcomponents/icons/groq.png diff --git a/ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/groq@2x.png b/ui/desktop/src/components/settings/providers/modal/subcomponents/icons/groq@2x.png similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/groq@2x.png rename to ui/desktop/src/components/settings/providers/modal/subcomponents/icons/groq@2x.png diff --git a/ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/groq@3x.png b/ui/desktop/src/components/settings/providers/modal/subcomponents/icons/groq@3x.png similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/groq@3x.png rename to ui/desktop/src/components/settings/providers/modal/subcomponents/icons/groq@3x.png diff --git a/ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/ollama.png b/ui/desktop/src/components/settings/providers/modal/subcomponents/icons/ollama.png similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/ollama.png rename to ui/desktop/src/components/settings/providers/modal/subcomponents/icons/ollama.png diff --git a/ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/ollama@2x.png b/ui/desktop/src/components/settings/providers/modal/subcomponents/icons/ollama@2x.png similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/ollama@2x.png rename to ui/desktop/src/components/settings/providers/modal/subcomponents/icons/ollama@2x.png diff --git a/ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/ollama@3x.png b/ui/desktop/src/components/settings/providers/modal/subcomponents/icons/ollama@3x.png similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/ollama@3x.png rename to ui/desktop/src/components/settings/providers/modal/subcomponents/icons/ollama@3x.png diff --git a/ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/openai.png b/ui/desktop/src/components/settings/providers/modal/subcomponents/icons/openai.png similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/openai.png rename to ui/desktop/src/components/settings/providers/modal/subcomponents/icons/openai.png diff --git a/ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/openai.svg b/ui/desktop/src/components/settings/providers/modal/subcomponents/icons/openai.svg similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/openai.svg rename to ui/desktop/src/components/settings/providers/modal/subcomponents/icons/openai.svg diff --git a/ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/openai@2x.png b/ui/desktop/src/components/settings/providers/modal/subcomponents/icons/openai@2x.png similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/openai@2x.png rename to ui/desktop/src/components/settings/providers/modal/subcomponents/icons/openai@2x.png diff --git a/ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/openai@3x.png b/ui/desktop/src/components/settings/providers/modal/subcomponents/icons/openai@3x.png similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/openai@3x.png rename to ui/desktop/src/components/settings/providers/modal/subcomponents/icons/openai@3x.png diff --git a/ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/openrouter.png b/ui/desktop/src/components/settings/providers/modal/subcomponents/icons/openrouter.png similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/openrouter.png rename to ui/desktop/src/components/settings/providers/modal/subcomponents/icons/openrouter.png diff --git a/ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/openrouter@2x.png b/ui/desktop/src/components/settings/providers/modal/subcomponents/icons/openrouter@2x.png similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/openrouter@2x.png rename to ui/desktop/src/components/settings/providers/modal/subcomponents/icons/openrouter@2x.png diff --git a/ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/openrouter@3x.png b/ui/desktop/src/components/settings/providers/modal/subcomponents/icons/openrouter@3x.png similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/openrouter@3x.png rename to ui/desktop/src/components/settings/providers/modal/subcomponents/icons/openrouter@3x.png diff --git a/ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/snowflake.png b/ui/desktop/src/components/settings/providers/modal/subcomponents/icons/snowflake.png similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/snowflake.png rename to ui/desktop/src/components/settings/providers/modal/subcomponents/icons/snowflake.png diff --git a/ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/snowflake@2x.png b/ui/desktop/src/components/settings/providers/modal/subcomponents/icons/snowflake@2x.png similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/snowflake@2x.png rename to ui/desktop/src/components/settings/providers/modal/subcomponents/icons/snowflake@2x.png diff --git a/ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/snowflake@3x.png b/ui/desktop/src/components/settings/providers/modal/subcomponents/icons/snowflake@3x.png similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/modal/subcomponents/icons/snowflake@3x.png rename to ui/desktop/src/components/settings/providers/modal/subcomponents/icons/snowflake@3x.png diff --git a/ui/desktop/src/components/settings_v2/providers/parameters/UpdateSecrets.tsx b/ui/desktop/src/components/settings/providers/parameters/UpdateSecrets.tsx similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/parameters/UpdateSecrets.tsx rename to ui/desktop/src/components/settings/providers/parameters/UpdateSecrets.tsx diff --git a/ui/desktop/src/components/settings_v2/providers/state/providerState.tsx b/ui/desktop/src/components/settings/providers/state/providerState.tsx similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/state/providerState.tsx rename to ui/desktop/src/components/settings/providers/state/providerState.tsx diff --git a/ui/desktop/src/components/settings_v2/providers/subcomponents/CardActions.tsx b/ui/desktop/src/components/settings/providers/subcomponents/CardActions.tsx similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/subcomponents/CardActions.tsx rename to ui/desktop/src/components/settings/providers/subcomponents/CardActions.tsx diff --git a/ui/desktop/src/components/settings_v2/providers/subcomponents/CardBody.tsx b/ui/desktop/src/components/settings/providers/subcomponents/CardBody.tsx similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/subcomponents/CardBody.tsx rename to ui/desktop/src/components/settings/providers/subcomponents/CardBody.tsx diff --git a/ui/desktop/src/components/settings_v2/providers/subcomponents/CardContainer.tsx b/ui/desktop/src/components/settings/providers/subcomponents/CardContainer.tsx similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/subcomponents/CardContainer.tsx rename to ui/desktop/src/components/settings/providers/subcomponents/CardContainer.tsx diff --git a/ui/desktop/src/components/settings_v2/providers/subcomponents/CardHeader.tsx b/ui/desktop/src/components/settings/providers/subcomponents/CardHeader.tsx similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/subcomponents/CardHeader.tsx rename to ui/desktop/src/components/settings/providers/subcomponents/CardHeader.tsx diff --git a/ui/desktop/src/components/settings_v2/providers/subcomponents/ProviderCard.tsx b/ui/desktop/src/components/settings/providers/subcomponents/ProviderCard.tsx similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/subcomponents/ProviderCard.tsx rename to ui/desktop/src/components/settings/providers/subcomponents/ProviderCard.tsx diff --git a/ui/desktop/src/components/settings_v2/providers/subcomponents/buttons/CardButtons.tsx b/ui/desktop/src/components/settings/providers/subcomponents/buttons/CardButtons.tsx similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/subcomponents/buttons/CardButtons.tsx rename to ui/desktop/src/components/settings/providers/subcomponents/buttons/CardButtons.tsx diff --git a/ui/desktop/src/components/settings_v2/providers/subcomponents/buttons/DefaultCardButtons.tsx b/ui/desktop/src/components/settings/providers/subcomponents/buttons/DefaultCardButtons.tsx similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/subcomponents/buttons/DefaultCardButtons.tsx rename to ui/desktop/src/components/settings/providers/subcomponents/buttons/DefaultCardButtons.tsx diff --git a/ui/desktop/src/components/settings_v2/providers/subcomponents/buttons/TooltipWrapper.tsx b/ui/desktop/src/components/settings/providers/subcomponents/buttons/TooltipWrapper.tsx similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/subcomponents/buttons/TooltipWrapper.tsx rename to ui/desktop/src/components/settings/providers/subcomponents/buttons/TooltipWrapper.tsx diff --git a/ui/desktop/src/components/settings_v2/providers/subcomponents/utils/StringUtils.tsx b/ui/desktop/src/components/settings/providers/subcomponents/utils/StringUtils.tsx similarity index 100% rename from ui/desktop/src/components/settings_v2/providers/subcomponents/utils/StringUtils.tsx rename to ui/desktop/src/components/settings/providers/subcomponents/utils/StringUtils.tsx diff --git a/ui/desktop/src/components/settings_v2/recipes/ViewRecipe.tsx b/ui/desktop/src/components/settings/recipes/ViewRecipe.tsx similarity index 100% rename from ui/desktop/src/components/settings_v2/recipes/ViewRecipe.tsx rename to ui/desktop/src/components/settings/recipes/ViewRecipe.tsx diff --git a/ui/desktop/src/components/settings_v2/response_styles/ResponseStyleSelectionItem.tsx b/ui/desktop/src/components/settings/response_styles/ResponseStyleSelectionItem.tsx similarity index 100% rename from ui/desktop/src/components/settings_v2/response_styles/ResponseStyleSelectionItem.tsx rename to ui/desktop/src/components/settings/response_styles/ResponseStyleSelectionItem.tsx diff --git a/ui/desktop/src/components/settings_v2/response_styles/ResponseStylesSection.tsx b/ui/desktop/src/components/settings/response_styles/ResponseStylesSection.tsx similarity index 100% rename from ui/desktop/src/components/settings_v2/response_styles/ResponseStylesSection.tsx rename to ui/desktop/src/components/settings/response_styles/ResponseStylesSection.tsx diff --git a/ui/desktop/src/components/settings/session/SessionSharingSection.tsx b/ui/desktop/src/components/settings/session/SessionSharingSection.tsx deleted file mode 100644 index de2a080d..00000000 --- a/ui/desktop/src/components/settings/session/SessionSharingSection.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { Input } from '../../ui/input'; -import { Check, Lock } from 'lucide-react'; - -export default function SessionSharingSection() { - const envBaseUrlShare = window.appConfig.get('GOOSE_BASE_URL_SHARE') as string | undefined; - console.log('envBaseUrlShare', envBaseUrlShare); - - // If env is set, force sharing enabled and set the baseUrl accordingly. - const [sessionSharingConfig, setSessionSharingConfig] = useState({ - enabled: envBaseUrlShare ? true : false, - baseUrl: envBaseUrlShare || '', - }); - const [urlError, setUrlError] = useState(''); - // isUrlConfigured is true if the user has configured a baseUrl and it is valid. - const isUrlConfigured = - !envBaseUrlShare && sessionSharingConfig.enabled && isValidUrl(sessionSharingConfig.baseUrl); - - // Only load saved config from localStorage if the env variable is not provided. - useEffect(() => { - if (envBaseUrlShare) { - // If env variable is set, save the forced configuration to localStorage - const forcedConfig = { - enabled: true, - baseUrl: envBaseUrlShare, - }; - localStorage.setItem('session_sharing_config', JSON.stringify(forcedConfig)); - } else { - const savedSessionConfig = localStorage.getItem('session_sharing_config'); - if (savedSessionConfig) { - try { - const config = JSON.parse(savedSessionConfig); - setSessionSharingConfig(config); - } catch (error) { - console.error('Error parsing session sharing config:', error); - } - } - } - }, [envBaseUrlShare]); - - // Helper to check if the user's input is a valid URL - function isValidUrl(value: string): boolean { - if (!value) return false; - try { - new URL(value); - return true; - } catch { - return false; - } - } - - // Toggle sharing (only allowed when env is not set). - const toggleSharing = () => { - if (envBaseUrlShare) { - return; // Do nothing if the environment variable forces sharing. - } - setSessionSharingConfig((prev) => { - const updated = { ...prev, enabled: !prev.enabled }; - localStorage.setItem('session_sharing_config', JSON.stringify(updated)); - return updated; - }); - }; - - // Handle changes to the base URL field - const handleBaseUrlChange = (e: React.ChangeEvent) => { - const newBaseUrl = e.target.value; - setSessionSharingConfig((prev) => ({ - ...prev, - baseUrl: newBaseUrl, - })); - - if (isValidUrl(newBaseUrl)) { - setUrlError(''); - const updated = { ...sessionSharingConfig, baseUrl: newBaseUrl }; - localStorage.setItem('session_sharing_config', JSON.stringify(updated)); - } else { - setUrlError('Invalid URL format. Please enter a valid URL (e.g. https://example.com/api).'); - } - }; - - return ( - <> -
-

Session Sharing

-
- -
- {envBaseUrlShare ? ( -

- Session sharing is configured but fully opt-in — your sessions are only shared when you - explicitly click the share button. -

- ) : ( -

- You can enable session sharing to share your sessions with others. You'll then need to - enter the base URL for the session sharing API endpoint. Anyone with access to the same - API and sharing session enabled will be able to see your sessions. -

- )} - -
- {/* Toggle for enabling session sharing */} -
- - {envBaseUrlShare ? ( - - ) : ( - - )} -
- - {/* Base URL field (only visible if enabled) */} - {sessionSharingConfig.enabled && ( -
-
- - {isUrlConfigured && } -
-
- {} : handleBaseUrlChange} - /> -
- {urlError &&

{urlError}

} -
- )} -
-
- - ); -} diff --git a/ui/desktop/src/components/settings_v2/sessions/SessionSharingSection.tsx b/ui/desktop/src/components/settings/sessions/SessionSharingSection.tsx similarity index 100% rename from ui/desktop/src/components/settings_v2/sessions/SessionSharingSection.tsx rename to ui/desktop/src/components/settings/sessions/SessionSharingSection.tsx diff --git a/ui/desktop/src/components/settings_v2/tool_selection_strategy/ToolSelectionStrategySection.tsx b/ui/desktop/src/components/settings/tool_selection_strategy/ToolSelectionStrategySection.tsx similarity index 100% rename from ui/desktop/src/components/settings_v2/tool_selection_strategy/ToolSelectionStrategySection.tsx rename to ui/desktop/src/components/settings/tool_selection_strategy/ToolSelectionStrategySection.tsx diff --git a/ui/desktop/src/components/settings/types.ts b/ui/desktop/src/components/settings/types.ts deleted file mode 100644 index 140c233e..00000000 --- a/ui/desktop/src/components/settings/types.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { FullExtensionConfig } from '../../extensions'; - -export interface Model { - id: string; - name: string; - description: string; - enabled: boolean; -} - -export interface Settings { - models: Model[]; - extensions: FullExtensionConfig[]; -} diff --git a/ui/desktop/src/components/settings_v2/SettingsView.tsx b/ui/desktop/src/components/settings_v2/SettingsView.tsx deleted file mode 100644 index 54d95b92..00000000 --- a/ui/desktop/src/components/settings_v2/SettingsView.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { ScrollArea } from '../ui/scroll-area'; -import BackButton from '../ui/BackButton'; -import type { View, ViewOptions } from '../../App'; -import ExtensionsSection from './extensions/ExtensionsSection'; -import ModelsSection from './models/ModelsSection'; -import { ModeSection } from './mode/ModeSection'; -import { ToolSelectionStrategySection } from './tool_selection_strategy/ToolSelectionStrategySection'; -import SessionSharingSection from './sessions/SessionSharingSection'; -import { ResponseStylesSection } from './response_styles/ResponseStylesSection'; -import AppSettingsSection from './app/AppSettingsSection'; -import { ExtensionConfig } from '../../api'; -import MoreMenuLayout from '../more_menu/MoreMenuLayout'; - -export type SettingsViewOptions = { - deepLinkConfig?: ExtensionConfig; - showEnvVars?: boolean; -}; - -export default function SettingsView({ - onClose, - setView, - viewOptions, -}: { - onClose: () => void; - setView: (view: View, viewOptions?: ViewOptions) => void; - viewOptions: SettingsViewOptions; -}) { - return ( -
- - - -
-
- onClose()} /> -

Settings

-
- - {/* Content Area */} -
-
- {/* Models Section */} - - {/* Extensions Section */} - - {/* Goose Modes */} - - {/*Session sharing*/} - - {/* Response Styles */} - - {/* Tool Selection Strategy */} - - {/* App Settings */} - -
-
-
-
-
- ); -} diff --git a/ui/desktop/src/json.d.ts b/ui/desktop/src/json.d.ts index 894b1fe7..5bb9ca45 100644 --- a/ui/desktop/src/json.d.ts +++ b/ui/desktop/src/json.d.ts @@ -37,3 +37,10 @@ declare module '*.mp4' { const value: string; export default value; } + +// Extend CSS properties to include Electron-specific properties +declare namespace React { + interface CSSProperties { + WebkitAppRegion?: 'drag' | 'no-drag'; + } +} diff --git a/ui/desktop/src/renderer.tsx b/ui/desktop/src/renderer.tsx index d08734f7..71a90179 100644 --- a/ui/desktop/src/renderer.tsx +++ b/ui/desktop/src/renderer.tsx @@ -1,9 +1,7 @@ import React, { Suspense, lazy } from 'react'; import ReactDOM from 'react-dom/client'; -import { ModelProvider } from './components/settings/models/ModelContext'; import { ConfigProvider } from './components/ConfigContext'; import { ErrorBoundary } from './components/ErrorBoundary'; -import { ActiveKeysProvider } from './components/settings/api_keys/ActiveKeysContext'; import { patchConsoleLogging } from './utils'; import SuspenseLoader from './suspense-loader'; @@ -15,13 +13,9 @@ ReactDOM.createRoot(document.getElementById('root')!).render( - - - - - - - + + + diff --git a/ui/desktop/src/utils/deleteAllKeys.tsx b/ui/desktop/src/utils/deleteAllKeys.tsx deleted file mode 100644 index 3972e9cc..00000000 --- a/ui/desktop/src/utils/deleteAllKeys.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { getApiUrl, getSecretKey } from '../config'; -import { required_keys } from '../components/settings/models/hardcoded_stuff'; - -export async function DeleteProviderKeysFromKeychain() { - for (const [_provider, keys] of Object.entries(required_keys)) { - for (const keyName of keys) { - try { - const deleteResponse = await fetch(getApiUrl('/configs/delete'), { - method: 'DELETE', - headers: { - 'Content-Type': 'application/json', - 'X-Secret-Key': getSecretKey(), - }, - body: JSON.stringify({ - key: keyName, - is_secret: true, // get rid of keychain keys only - }), - }); - - if (!deleteResponse.ok) { - const errorText = await deleteResponse.text(); - console.error('Delete response error:', errorText); - throw new Error('Failed to delete key: ' + keyName); - } else { - console.log('Successfully deleted key:', keyName); - } - } catch (error) { - console.error('Error deleting key:', keyName, error); - } - } - } -} diff --git a/ui/desktop/src/utils/providerUtils.ts b/ui/desktop/src/utils/providerUtils.ts index 0c0f5a94..a26f9f1a 100644 --- a/ui/desktop/src/utils/providerUtils.ts +++ b/ui/desktop/src/utils/providerUtils.ts @@ -5,8 +5,8 @@ import { initializeBundledExtensions, syncBundledExtensions, addToAgentOnStartup, -} from '../components/settings_v2/extensions'; -import { extractExtensionConfig } from '../components/settings_v2/extensions/utils'; +} from '../components/settings/extensions'; +import { extractExtensionConfig } from '../components/settings/extensions/utils'; import type { ExtensionConfig, FixedExtensionEntry } from '../components/ConfigContext'; // TODO: remove when removing migration logic import { toastService } from '../toasts'; diff --git a/ui/desktop/tests/e2e/app.spec.ts b/ui/desktop/tests/e2e/app.spec.ts index b33f265f..1c7ba1ce 100644 --- a/ui/desktop/tests/e2e/app.spec.ts +++ b/ui/desktop/tests/e2e/app.spec.ts @@ -179,7 +179,13 @@ test.describe('Goose App', () => { // Get the main window once for all tests mainWindow = await electronApp.firstWindow(); await mainWindow.waitForLoadState('domcontentloaded'); - await mainWindow.waitForLoadState('networkidle'); + + // Try to wait for networkidle, but don't fail if it times out due to MCP activity + try { + await mainWindow.waitForLoadState('networkidle', { timeout: 10000 }); + } catch (error) { + console.log('NetworkIdle timeout (likely due to MCP activity), continuing with test...'); + } // Wait for React app to be ready by checking for the root element to have content await mainWindow.waitForFunction(() => { @@ -417,7 +423,12 @@ test.describe('Goose App', () => { try { // Reload the page to ensure settings are fresh await mainWindow.reload(); - await mainWindow.waitForLoadState('networkidle'); + // Try to wait for networkidle, but don't fail if it times out due to MCP activity + try { + await mainWindow.waitForLoadState('networkidle', { timeout: 10000 }); + } catch (error) { + console.log('NetworkIdle timeout (likely due to MCP activity), continuing with test...'); + } await mainWindow.waitForLoadState('domcontentloaded'); // Wait for React app to be ready @@ -687,9 +698,18 @@ test.describe('Goose App', () => { }, initialMessages, { timeout: 30000 }); // Get the latest response - const response = await mainWindow.locator('[data-testid="message-container"]').last(); + const response = await mainWindow.waitForSelector('.goose-message-tool', { timeout: 5000 }); expect(await response.isVisible()).toBe(true); + // Click the Output dropdown to reveal the actual quote + await mainWindow.screenshot({ path: `test-results/${provider.name.toLowerCase()}-quote-response-debug.png` }); + const element = await mainWindow.$('.goose-message-tool'); + const html = await element.innerHTML(); + console.log('HTML content:', html); + // Click the Runningquote dropdown to reveal the actual quote + const runningQuoteButton = await mainWindow.waitForSelector('div.goose-message-tool svg.rotate-90', { timeout: 5000 }); + await runningQuoteButton.click(); + // Click the Output dropdown to reveal the actual quote const outputButton = await mainWindow.waitForSelector('button:has-text("Output")', { timeout: 5000 }); await outputButton.click(); From e56354603fd7b060025fabafead10e6f11cbb2c3 Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Tue, 3 Jun 2025 09:50:01 +1000 Subject: [PATCH 18/40] Revert "chore: use hermit in goose" (#2759) --- .github/workflows/build-cli.yml | 15 +++++++- .github/workflows/bundle-desktop-intel.yml | 23 ++++++++---- .github/workflows/bundle-desktop.yml | 23 +++++++++--- .github/workflows/ci.yml | 27 +++++++++++--- .gitignore | 3 +- Cross.toml | 42 +++++++++++++++++++++ bin/.node-22.9.0.pkg | 1 - bin/.protoc-31.1.pkg | 1 - bin/.rustup-1.25.2.pkg | 1 - bin/README.hermit.md | 7 ---- bin/activate-hermit | 21 ----------- bin/activate-hermit.fish | 24 ------------ bin/cargo | 1 - bin/cargo-clippy | 1 - bin/cargo-fmt | 1 - bin/cargo-miri | 1 - bin/clippy-driver | 1 - bin/corepack | 1 - bin/hermit | 43 ---------------------- bin/hermit.hcl | 4 -- bin/node | 1 - bin/npm | 1 - bin/npx | 1 - bin/protoc | 1 - bin/rls | 1 - bin/rust-analyzer | 1 - bin/rust-gdb | 1 - bin/rust-gdbgui | 1 - bin/rust-lldb | 1 - bin/rustc | 1 - bin/rustdoc | 1 - bin/rustfmt | 1 - bin/rustup | 1 - crates/goose/src/recipe/mod.rs | 8 ++-- 34 files changed, 114 insertions(+), 148 deletions(-) delete mode 120000 bin/.node-22.9.0.pkg delete mode 120000 bin/.protoc-31.1.pkg delete mode 120000 bin/.rustup-1.25.2.pkg delete mode 100644 bin/README.hermit.md delete mode 100755 bin/activate-hermit delete mode 100755 bin/activate-hermit.fish delete mode 120000 bin/cargo delete mode 120000 bin/cargo-clippy delete mode 120000 bin/cargo-fmt delete mode 120000 bin/cargo-miri delete mode 120000 bin/clippy-driver delete mode 120000 bin/corepack delete mode 100755 bin/hermit delete mode 100644 bin/hermit.hcl delete mode 120000 bin/node delete mode 120000 bin/npm delete mode 120000 bin/npx delete mode 120000 bin/protoc delete mode 120000 bin/rls delete mode 120000 bin/rust-analyzer delete mode 120000 bin/rust-gdb delete mode 120000 bin/rust-gdbgui delete mode 120000 bin/rust-lldb delete mode 120000 bin/rustc delete mode 120000 bin/rustdoc delete mode 120000 bin/rustfmt delete mode 120000 bin/rustup diff --git a/.github/workflows/build-cli.yml b/.github/workflows/build-cli.yml index 3012e238..d0956aff 100644 --- a/.github/workflows/build-cli.yml +++ b/.github/workflows/build-cli.yml @@ -48,8 +48,14 @@ jobs: sed -i.bak 's/^version = ".*"/version = "'${{ inputs.version }}'"/' Cargo.toml rm -f Cargo.toml.bak + - name: Setup Rust + uses: dtolnay/rust-toolchain@38b70195107dddab2c7bbd522bcf763bac00963b # pin@stable + with: + toolchain: stable + target: ${{ matrix.architecture }}-${{ matrix.target-suffix }} + - name: Install cross - run: source ./bin/activate-hermit && cargo install cross --git https://github.com/cross-rs/cross + run: cargo install cross --git https://github.com/cross-rs/cross - name: Build CLI env: @@ -58,7 +64,12 @@ jobs: RUST_BACKTRACE: 1 CROSS_VERBOSE: 1 run: | - source ./bin/activate-hermit + # Install protoc if on macOS + if [ "${{ matrix.os }}" = "macos-latest" ]; then + brew install protobuf + export PROTOC=$(which protoc) + fi + export TARGET="${{ matrix.architecture }}-${{ matrix.target-suffix }}" rustup target add "${TARGET}" echo "Building for target: ${TARGET}" diff --git a/.github/workflows/bundle-desktop-intel.yml b/.github/workflows/bundle-desktop-intel.yml index 8d614af2..e841ac31 100644 --- a/.github/workflows/bundle-desktop-intel.yml +++ b/.github/workflows/bundle-desktop-intel.yml @@ -85,16 +85,20 @@ jobs: # Update version in Cargo.toml sed -i.bak 's/^version = ".*"/version = "'${{ inputs.version }}'"/' Cargo.toml rm -f Cargo.toml.bak - - # Update version in package.json - source ./bin/activate-hermit + + # Update version in package.json cd ui/desktop npm version ${{ inputs.version }} --no-git-tag-version --allow-same-version + - name: Setup Rust + uses: dtolnay/rust-toolchain@38b70195107dddab2c7bbd522bcf763bac00963b # pin@stable + with: + toolchain: stable + targets: x86_64-apple-darwin + # Pre-build cleanup to ensure enough disk space - name: Pre-build cleanup run: | - source ./bin/activate-hermit echo "Performing pre-build cleanup..." # Clean npm cache npm cache clean --force || true @@ -133,7 +137,7 @@ jobs: # Build specifically for Intel architecture - name: Build goosed for Intel - run: source ./bin/activate-hermit && cargo build --release -p goose-server --target x86_64-apple-darwin + run: cargo build --release -p goose-server --target x86_64-apple-darwin # Post-build cleanup to free space - name: Post-build cleanup @@ -160,8 +164,13 @@ jobs: CERTIFICATE_OSX_APPLICATION: ${{ secrets.CERTIFICATE_OSX_APPLICATION }} CERTIFICATE_PASSWORD: ${{ secrets.CERTIFICATE_PASSWORD }} + - name: Set up Node.js + uses: actions/setup-node@7c12f8017d5436eb855f1ed4399f037a36fbd9e8 # pin@v2 + with: + node-version: 'lts/*' + - name: Install dependencies - run: source ../../bin/activate-hermit && npm ci + run: npm ci working-directory: ui/desktop # Configure Electron builder for Intel architecture @@ -178,7 +187,6 @@ jobs: - name: Make Unsigned App if: ${{ !inputs.signing }} run: | - source ../../bin/activate-hermit attempt=0 max_attempts=2 until [ $attempt -ge $max_attempts ]; do @@ -196,7 +204,6 @@ jobs: - name: Make Signed App if: ${{ inputs.signing }} run: | - source ../../bin/activate-hermit attempt=0 max_attempts=2 until [ $attempt -ge $max_attempts ]; do diff --git a/.github/workflows/bundle-desktop.yml b/.github/workflows/bundle-desktop.yml index a5875097..c4856158 100644 --- a/.github/workflows/bundle-desktop.yml +++ b/.github/workflows/bundle-desktop.yml @@ -135,7 +135,6 @@ jobs: sed -i.bak "s/^version = \".*\"/version = \"${VERSION}\"/" Cargo.toml rm -f Cargo.toml.bak - source ./bin/activate-hermit # Update version in package.json cd ui/desktop npm version "${VERSION}" --no-git-tag-version --allow-same-version @@ -143,7 +142,6 @@ jobs: # Pre-build cleanup to ensure enough disk space - name: Pre-build cleanup run: | - source ./bin/activate-hermit echo "Performing pre-build cleanup..." # Clean npm cache npm cache clean --force || true @@ -156,6 +154,16 @@ jobs: # Check disk space after cleanup df -h + - name: Install protobuf + run: | + brew install protobuf + echo "PROTOC=$(which protoc)" >> $GITHUB_ENV + + - name: Setup Rust + uses: dtolnay/rust-toolchain@38b70195107dddab2c7bbd522bcf763bac00963b # pin@stable + with: + toolchain: stable + - name: Cache Cargo registry uses: actions/cache@2f8e54208210a422b2efd51efaa6bd6d7ca8920f # pin@v3 with: @@ -182,7 +190,7 @@ jobs: # Build the project - name: Build goosed - run: source ./bin/activate-hermit && cargo build --release -p goose-server + run: cargo build --release -p goose-server # Post-build cleanup to free space - name: Post-build cleanup @@ -208,8 +216,13 @@ jobs: CERTIFICATE_OSX_APPLICATION: ${{ secrets.CERTIFICATE_OSX_APPLICATION }} CERTIFICATE_PASSWORD: ${{ secrets.CERTIFICATE_PASSWORD }} + - name: Set up Node.js + uses: actions/setup-node@7c12f8017d5436eb855f1ed4399f037a36fbd9e8 # pin@v2 + with: + node-version: 'lts/*' + - name: Install dependencies - run: source ../../bin/activate-hermit && npm ci + run: npm ci working-directory: ui/desktop # Check disk space before bundling @@ -219,7 +232,6 @@ jobs: - name: Make Unsigned App if: ${{ !inputs.signing }} run: | - source ../../bin/activate-hermit attempt=0 max_attempts=2 until [ $attempt -ge $max_attempts ]; do @@ -241,7 +253,6 @@ jobs: APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: | - attempt=0 max_attempts=2 until [ $attempt -ge $max_attempts ]; do diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a4d76255..37699a1f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,8 +17,13 @@ jobs: - name: Checkout Code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4 + - name: Setup Rust + uses: dtolnay/rust-toolchain@38b70195107dddab2c7bbd522bcf763bac00963b # pin@stable + with: + toolchain: stable + - name: Run cargo fmt - run: source ./bin/activate-hermit && cargo fmt --check + run: cargo fmt --check rust-build-and-test: name: Build and Test Rust Project @@ -52,7 +57,12 @@ jobs: - name: Install Dependencies run: | sudo apt update -y - sudo apt install -y libdbus-1-dev gnome-keyring libxcb1-dev + sudo apt install -y libdbus-1-dev gnome-keyring libxcb1-dev protobuf-compiler + + - name: Setup Rust + uses: dtolnay/rust-toolchain@38b70195107dddab2c7bbd522bcf763bac00963b # pin@stable + with: + toolchain: stable - name: Cache Cargo Registry uses: actions/cache@2f8e54208210a422b2efd51efaa6bd6d7ca8920f # pin@v3 @@ -81,7 +91,7 @@ jobs: - name: Build and Test run: | gnome-keyring-daemon --components=secrets --daemonize --unlock <<< 'foobar' - source ../bin/activate-hermit && cargo test + cargo test working-directory: crates # Add disk space cleanup before linting @@ -110,7 +120,7 @@ jobs: run: df -h - name: Lint - run: source ./bin/activate-hermit && cargo clippy -- -D warnings + run: cargo clippy -- -D warnings desktop-lint: name: Lint Electron Desktop App @@ -119,12 +129,17 @@ jobs: - name: Checkout Code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4 + - name: Set up Node.js + uses: actions/setup-node@7c12f8017d5436eb855f1ed4399f037a36fbd9e8 # pin@v2 + with: + node-version: "lts/*" + - name: Install Dependencies - run: source ../../bin/activate-hermit && npm ci + run: npm ci working-directory: ui/desktop - name: Run Lint - run: source ../../bin/activate-hermit && npm run lint:check + run: npm run lint:check working-directory: ui/desktop # Faster Desktop App build for PRs only diff --git a/.gitignore b/.gitignore index 3f5a8419..3d5ef353 100644 --- a/.gitignore +++ b/.gitignore @@ -24,7 +24,8 @@ target/ ./ui/desktop/out # Hermit -.hermit/ +/.hermit/ +/bin/ debug_*.txt diff --git a/Cross.toml b/Cross.toml index b52f98a2..4e7ab47d 100644 --- a/Cross.toml +++ b/Cross.toml @@ -1,4 +1,11 @@ # Configuration for cross-compiling using cross +[build.env] +passthrough = [ + "PROTOC", + "RUST_LOG", + "RUST_BACKTRACE" +] + [target.aarch64-unknown-linux-gnu] xargo = false pre-build = [ @@ -6,10 +13,20 @@ pre-build = [ "dpkg --add-architecture arm64", """\ apt-get update --fix-missing && apt-get install -y \ + curl \ + unzip \ pkg-config \ libssl-dev:arm64 \ libdbus-1-dev:arm64 \ libxcb1-dev:arm64 + """, + """\ + curl -LO https://github.com/protocolbuffers/protobuf/releases/download/v31.1/protoc-31.1-linux-x86_64.zip && \ + unzip -o protoc-31.1-linux-x86_64.zip -d /usr/local && \ + chmod +x /usr/local/bin/protoc && \ + ln -sf /usr/local/bin/protoc /usr/bin/protoc && \ + which protoc && \ + protoc --version """ ] @@ -18,15 +35,40 @@ xargo = false pre-build = [ """\ apt-get update && apt-get install -y \ + curl \ + unzip \ pkg-config \ libssl-dev \ libdbus-1-dev \ libxcb1-dev + """, + """\ + curl -LO https://github.com/protocolbuffers/protobuf/releases/download/v31.1/protoc-31.1-linux-x86_64.zip && \ + unzip -o protoc-31.1-linux-x86_64.zip -d /usr/local && \ + chmod +x /usr/local/bin/protoc && \ + ln -sf /usr/local/bin/protoc /usr/bin/protoc && \ + which protoc && \ + protoc --version """ ] +env = { "PROTOC" = "/usr/bin/protoc", "PATH" = "/usr/local/bin:${PATH}" } [target.x86_64-pc-windows-gnu] image = "dockcross/windows-static-x64:latest" # Enable verbose output for Windows builds build-std = true env = { "RUST_LOG" = "debug", "RUST_BACKTRACE" = "1", "CROSS_VERBOSE" = "1" } +pre-build = [ + """\ + apt-get update && apt-get install -y \ + curl \ + unzip + """, + """\ + curl -LO https://github.com/protocolbuffers/protobuf/releases/download/v31.1/protoc-31.1-linux-x86_64.zip && \ + unzip protoc-31.1-linux-x86_64.zip -d /usr/local && \ + chmod +x /usr/local/bin/protoc && \ + export PROTOC=/usr/local/bin/protoc && \ + protoc --version + """ +] diff --git a/bin/.node-22.9.0.pkg b/bin/.node-22.9.0.pkg deleted file mode 120000 index 383f4511..00000000 --- a/bin/.node-22.9.0.pkg +++ /dev/null @@ -1 +0,0 @@ -hermit \ No newline at end of file diff --git a/bin/.protoc-31.1.pkg b/bin/.protoc-31.1.pkg deleted file mode 120000 index 383f4511..00000000 --- a/bin/.protoc-31.1.pkg +++ /dev/null @@ -1 +0,0 @@ -hermit \ No newline at end of file diff --git a/bin/.rustup-1.25.2.pkg b/bin/.rustup-1.25.2.pkg deleted file mode 120000 index 383f4511..00000000 --- a/bin/.rustup-1.25.2.pkg +++ /dev/null @@ -1 +0,0 @@ -hermit \ No newline at end of file diff --git a/bin/README.hermit.md b/bin/README.hermit.md deleted file mode 100644 index e889550b..00000000 --- a/bin/README.hermit.md +++ /dev/null @@ -1,7 +0,0 @@ -# Hermit environment - -This is a [Hermit](https://github.com/cashapp/hermit) bin directory. - -The symlinks in this directory are managed by Hermit and will automatically -download and install Hermit itself as well as packages. These packages are -local to this environment. diff --git a/bin/activate-hermit b/bin/activate-hermit deleted file mode 100755 index fe28214d..00000000 --- a/bin/activate-hermit +++ /dev/null @@ -1,21 +0,0 @@ -#!/bin/bash -# This file must be used with "source bin/activate-hermit" from bash or zsh. -# You cannot run it directly -# -# THIS FILE IS GENERATED; DO NOT MODIFY - -if [ "${BASH_SOURCE-}" = "$0" ]; then - echo "You must source this script: \$ source $0" >&2 - exit 33 -fi - -BIN_DIR="$(dirname "${BASH_SOURCE[0]:-${(%):-%x}}")" -if "${BIN_DIR}/hermit" noop > /dev/null; then - eval "$("${BIN_DIR}/hermit" activate "${BIN_DIR}/..")" - - if [ -n "${BASH-}" ] || [ -n "${ZSH_VERSION-}" ]; then - hash -r 2>/dev/null - fi - - echo "Hermit environment $("${HERMIT_ENV}"/bin/hermit env HERMIT_ENV) activated" -fi diff --git a/bin/activate-hermit.fish b/bin/activate-hermit.fish deleted file mode 100755 index 0367d233..00000000 --- a/bin/activate-hermit.fish +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env fish - -# This file must be sourced with "source bin/activate-hermit.fish" from Fish shell. -# You cannot run it directly. -# -# THIS FILE IS GENERATED; DO NOT MODIFY - -if status is-interactive - set BIN_DIR (dirname (status --current-filename)) - - if "$BIN_DIR/hermit" noop > /dev/null - # Source the activation script generated by Hermit - "$BIN_DIR/hermit" activate "$BIN_DIR/.." | source - - # Clear the command cache if applicable - functions -c > /dev/null 2>&1 - - # Display activation message - echo "Hermit environment $($HERMIT_ENV/bin/hermit env HERMIT_ENV) activated" - end -else - echo "You must source this script: source $argv[0]" >&2 - exit 33 -end diff --git a/bin/cargo b/bin/cargo deleted file mode 120000 index 5046e66f..00000000 --- a/bin/cargo +++ /dev/null @@ -1 +0,0 @@ -.rustup-1.25.2.pkg \ No newline at end of file diff --git a/bin/cargo-clippy b/bin/cargo-clippy deleted file mode 120000 index 5046e66f..00000000 --- a/bin/cargo-clippy +++ /dev/null @@ -1 +0,0 @@ -.rustup-1.25.2.pkg \ No newline at end of file diff --git a/bin/cargo-fmt b/bin/cargo-fmt deleted file mode 120000 index 5046e66f..00000000 --- a/bin/cargo-fmt +++ /dev/null @@ -1 +0,0 @@ -.rustup-1.25.2.pkg \ No newline at end of file diff --git a/bin/cargo-miri b/bin/cargo-miri deleted file mode 120000 index 5046e66f..00000000 --- a/bin/cargo-miri +++ /dev/null @@ -1 +0,0 @@ -.rustup-1.25.2.pkg \ No newline at end of file diff --git a/bin/clippy-driver b/bin/clippy-driver deleted file mode 120000 index 5046e66f..00000000 --- a/bin/clippy-driver +++ /dev/null @@ -1 +0,0 @@ -.rustup-1.25.2.pkg \ No newline at end of file diff --git a/bin/corepack b/bin/corepack deleted file mode 120000 index 51cdc90c..00000000 --- a/bin/corepack +++ /dev/null @@ -1 +0,0 @@ -.node-22.9.0.pkg \ No newline at end of file diff --git a/bin/hermit b/bin/hermit deleted file mode 100755 index 31559b7d..00000000 --- a/bin/hermit +++ /dev/null @@ -1,43 +0,0 @@ -#!/bin/bash -# -# THIS FILE IS GENERATED; DO NOT MODIFY - -set -eo pipefail - -export HERMIT_USER_HOME=~ - -if [ -z "${HERMIT_STATE_DIR}" ]; then - case "$(uname -s)" in - Darwin) - export HERMIT_STATE_DIR="${HERMIT_USER_HOME}/Library/Caches/hermit" - ;; - Linux) - export HERMIT_STATE_DIR="${XDG_CACHE_HOME:-${HERMIT_USER_HOME}/.cache}/hermit" - ;; - esac -fi - -export HERMIT_DIST_URL="${HERMIT_DIST_URL:-https://github.com/cashapp/hermit/releases/download/stable}" -HERMIT_CHANNEL="$(basename "${HERMIT_DIST_URL}")" -export HERMIT_CHANNEL -export HERMIT_EXE=${HERMIT_EXE:-${HERMIT_STATE_DIR}/pkg/hermit@${HERMIT_CHANNEL}/hermit} - -if [ ! -x "${HERMIT_EXE}" ]; then - echo "Bootstrapping ${HERMIT_EXE} from ${HERMIT_DIST_URL}" 1>&2 - INSTALL_SCRIPT="$(mktemp)" - # This value must match that of the install script - INSTALL_SCRIPT_SHA256="09ed936378857886fd4a7a4878c0f0c7e3d839883f39ca8b4f2f242e3126e1c6" - if [ "${INSTALL_SCRIPT_SHA256}" = "BYPASS" ]; then - curl -fsSL "${HERMIT_DIST_URL}/install.sh" -o "${INSTALL_SCRIPT}" - else - # Install script is versioned by its sha256sum value - curl -fsSL "${HERMIT_DIST_URL}/install-${INSTALL_SCRIPT_SHA256}.sh" -o "${INSTALL_SCRIPT}" - # Verify install script's sha256sum - openssl dgst -sha256 "${INSTALL_SCRIPT}" | \ - awk -v EXPECTED="$INSTALL_SCRIPT_SHA256" \ - '$2!=EXPECTED {print "Install script sha256 " $2 " does not match " EXPECTED; exit 1}' - fi - /bin/bash "${INSTALL_SCRIPT}" 1>&2 -fi - -exec "${HERMIT_EXE}" --level=fatal exec "$0" -- "$@" diff --git a/bin/hermit.hcl b/bin/hermit.hcl deleted file mode 100644 index cc17d794..00000000 --- a/bin/hermit.hcl +++ /dev/null @@ -1,4 +0,0 @@ -manage-git = false - -github-token-auth { -} diff --git a/bin/node b/bin/node deleted file mode 120000 index 51cdc90c..00000000 --- a/bin/node +++ /dev/null @@ -1 +0,0 @@ -.node-22.9.0.pkg \ No newline at end of file diff --git a/bin/npm b/bin/npm deleted file mode 120000 index 51cdc90c..00000000 --- a/bin/npm +++ /dev/null @@ -1 +0,0 @@ -.node-22.9.0.pkg \ No newline at end of file diff --git a/bin/npx b/bin/npx deleted file mode 120000 index 51cdc90c..00000000 --- a/bin/npx +++ /dev/null @@ -1 +0,0 @@ -.node-22.9.0.pkg \ No newline at end of file diff --git a/bin/protoc b/bin/protoc deleted file mode 120000 index 6bb03478..00000000 --- a/bin/protoc +++ /dev/null @@ -1 +0,0 @@ -.protoc-31.1.pkg \ No newline at end of file diff --git a/bin/rls b/bin/rls deleted file mode 120000 index 5046e66f..00000000 --- a/bin/rls +++ /dev/null @@ -1 +0,0 @@ -.rustup-1.25.2.pkg \ No newline at end of file diff --git a/bin/rust-analyzer b/bin/rust-analyzer deleted file mode 120000 index 5046e66f..00000000 --- a/bin/rust-analyzer +++ /dev/null @@ -1 +0,0 @@ -.rustup-1.25.2.pkg \ No newline at end of file diff --git a/bin/rust-gdb b/bin/rust-gdb deleted file mode 120000 index 5046e66f..00000000 --- a/bin/rust-gdb +++ /dev/null @@ -1 +0,0 @@ -.rustup-1.25.2.pkg \ No newline at end of file diff --git a/bin/rust-gdbgui b/bin/rust-gdbgui deleted file mode 120000 index 5046e66f..00000000 --- a/bin/rust-gdbgui +++ /dev/null @@ -1 +0,0 @@ -.rustup-1.25.2.pkg \ No newline at end of file diff --git a/bin/rust-lldb b/bin/rust-lldb deleted file mode 120000 index 5046e66f..00000000 --- a/bin/rust-lldb +++ /dev/null @@ -1 +0,0 @@ -.rustup-1.25.2.pkg \ No newline at end of file diff --git a/bin/rustc b/bin/rustc deleted file mode 120000 index 5046e66f..00000000 --- a/bin/rustc +++ /dev/null @@ -1 +0,0 @@ -.rustup-1.25.2.pkg \ No newline at end of file diff --git a/bin/rustdoc b/bin/rustdoc deleted file mode 120000 index 5046e66f..00000000 --- a/bin/rustdoc +++ /dev/null @@ -1 +0,0 @@ -.rustup-1.25.2.pkg \ No newline at end of file diff --git a/bin/rustfmt b/bin/rustfmt deleted file mode 120000 index 5046e66f..00000000 --- a/bin/rustfmt +++ /dev/null @@ -1 +0,0 @@ -.rustup-1.25.2.pkg \ No newline at end of file diff --git a/bin/rustup b/bin/rustup deleted file mode 120000 index 5046e66f..00000000 --- a/bin/rustup +++ /dev/null @@ -1 +0,0 @@ -.rustup-1.25.2.pkg \ No newline at end of file diff --git a/crates/goose/src/recipe/mod.rs b/crates/goose/src/recipe/mod.rs index 510ba000..8891dbd4 100644 --- a/crates/goose/src/recipe/mod.rs +++ b/crates/goose/src/recipe/mod.rs @@ -28,7 +28,7 @@ fn default_version() -> String { /// /// # Example /// -/// +/// ``` /// use goose::recipe::Recipe; /// /// // Using the builder pattern @@ -52,7 +52,7 @@ fn default_version() -> String { /// author: None, /// parameters: None, /// }; -/// +/// ``` #[derive(Serialize, Deserialize, Debug)] pub struct Recipe { // Required fields @@ -166,7 +166,7 @@ impl Recipe { /// /// # Example /// - /// + /// ``` /// use goose::recipe::Recipe; /// /// let recipe = Recipe::builder() @@ -175,7 +175,7 @@ impl Recipe { /// .instructions("Act as a helpful assistant") /// .build() /// .expect("Failed to build Recipe: missing required fields"); - /// + /// ``` pub fn builder() -> RecipeBuilder { RecipeBuilder { version: default_version(), From 959ebcb43f249fced9c58e46fa0d2361e0202292 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Tue, 3 Jun 2025 13:16:05 +1000 Subject: [PATCH 19/40] fix: increase limit for direct to disk for performance (#2762) --- crates/goose/src/agents/large_response_handler.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/goose/src/agents/large_response_handler.rs b/crates/goose/src/agents/large_response_handler.rs index 29141bc8..e4c0ab10 100644 --- a/crates/goose/src/agents/large_response_handler.rs +++ b/crates/goose/src/agents/large_response_handler.rs @@ -3,8 +3,7 @@ use mcp_core::{Content, ToolError}; use std::fs::File; use std::io::Write; -// Constant for the size threshold (20K characters) -const LARGE_TEXT_THRESHOLD: usize = 20_000; +const LARGE_TEXT_THRESHOLD: usize = 200_000; /// Process tool response and handle large text content pub fn process_tool_response( From 601744518d282a7b4ed117b3fff86b1edd9065bb Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Tue, 3 Jun 2025 23:53:32 +1000 Subject: [PATCH 20/40] fix: new models have different messages for context length exceeded (#2763) --- crates/goose-llm/src/providers/databricks.rs | 2 ++ crates/goose/src/providers/databricks.rs | 2 ++ crates/goose/src/providers/snowflake.rs | 2 ++ 3 files changed, 6 insertions(+) diff --git a/crates/goose-llm/src/providers/databricks.rs b/crates/goose-llm/src/providers/databricks.rs index 13e0c1a2..7b91d6a2 100644 --- a/crates/goose-llm/src/providers/databricks.rs +++ b/crates/goose-llm/src/providers/databricks.rs @@ -138,6 +138,8 @@ impl DatabricksProvider { "reduce the length", "token count", "exceeds", + "exceed context limit", + "max_tokens", ]; if check_phrases.iter().any(|c| payload_str.contains(c)) { return Err(ProviderError::ContextLengthExceeded(payload_str)); diff --git a/crates/goose/src/providers/databricks.rs b/crates/goose/src/providers/databricks.rs index 7a04407a..bdca16bb 100644 --- a/crates/goose/src/providers/databricks.rs +++ b/crates/goose/src/providers/databricks.rs @@ -211,6 +211,8 @@ impl DatabricksProvider { "reduce the length", "token count", "exceeds", + "exceed context limit", + "max_tokens", ]; if check_phrases.iter().any(|c| payload_str.contains(c)) { return Err(ProviderError::ContextLengthExceeded(payload_str)); diff --git a/crates/goose/src/providers/snowflake.rs b/crates/goose/src/providers/snowflake.rs index dcd0ac44..f1c3ad10 100644 --- a/crates/goose/src/providers/snowflake.rs +++ b/crates/goose/src/providers/snowflake.rs @@ -333,6 +333,8 @@ impl SnowflakeProvider { "reduce the length", "token count", "exceeds", + "exceed context limit", + "max_tokens", ]; if check_phrases.iter().any(|c| payload_str.contains(c)) { return Err(ProviderError::ContextLengthExceeded("Request exceeds maximum context length. Please reduce the number of messages or content size.".to_string())); From 78234fb1030062e7f8343a0e1063ffe1ea03a448 Mon Sep 17 00:00:00 2001 From: Raduan Al-Shedivat <88370223+dbraduan@users.noreply.github.com> Date: Tue, 3 Jun 2025 19:32:44 +0200 Subject: [PATCH 21/40] cli(config): Add GOOSE_CONTEXT_STRATEGY setting (#2666) Co-authored-by: Michael Neale --- crates/goose-cli/src/session/mod.rs | 106 ++++++++++-------- .../docs/guides/environment-variables.md | 18 +++ .../docs/guides/smart-context-management.md | 45 +++++++- 3 files changed, 120 insertions(+), 49 deletions(-) diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 2695b366..9eab36b1 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -774,56 +774,68 @@ impl Session { } else if let Some(MessageContent::ContextLengthExceeded(_)) = message.content.first() { output::hide_thinking(); - if interactive { - // In interactive mode, ask the user what to do - let prompt = "The model's context length is maxed out. You will need to reduce the # msgs. Do you want to?".to_string(); - let selected_result = cliclack::select(prompt) - .item("clear", "Clear Session", "Removes all messages from Goose's memory") - .item("truncate", "Truncate Messages", "Removes old messages till context is within limits") - .item("summarize", "Summarize Session", "Summarize the session to reduce context length") - .item("cancel", "Cancel", "Cancel and return to chat") - .interact(); + // Check for user-configured default context strategy + let config = Config::global(); + let context_strategy = config.get_param::("GOOSE_CONTEXT_STRATEGY") + .unwrap_or_else(|_| if interactive { "prompt".to_string() } else { "summarize".to_string() }); - let selected = match selected_result { - Ok(s) => s, - Err(e) => { - if e.kind() == std::io::ErrorKind::Interrupted { - "cancel" // If interrupted, set selected to cancel - } else { - return Err(e.into()); - } - } - }; - - match selected { - "clear" => { - self.messages.clear(); - let msg = format!("Session cleared.\n{}", "-".repeat(50)); - output::render_text(&msg, Some(Color::Yellow), true); - break; // exit the loop to hand back control to the user - } - "truncate" => { - // Truncate messages to fit within context length - let (truncated_messages, _) = self.agent.truncate_context(&self.messages).await?; - let msg = format!("Context maxed out\n{}\nGoose tried its best to truncate messages for you.", "-".repeat(50)); - output::render_text("", Some(Color::Yellow), true); - output::render_text(&msg, Some(Color::Yellow), true); - self.messages = truncated_messages; - } - "summarize" => { - // Use the helper function to summarize context - Self::summarize_context_messages(&mut self.messages, &self.agent, "Goose summarized messages for you.").await?; - } - "cancel" => { - break; // Return to main prompt - } - _ => { - unreachable!() + let selected = match context_strategy.as_str() { + "clear" => "clear", + "truncate" => "truncate", + "summarize" => "summarize", + _ => { + if interactive { + // In interactive mode with no default, ask the user what to do + let prompt = "The model's context length is maxed out. You will need to reduce the # msgs. Do you want to?".to_string(); + cliclack::select(prompt) + .item("clear", "Clear Session", "Removes all messages from Goose's memory") + .item("truncate", "Truncate Messages", "Removes old messages till context is within limits") + .item("summarize", "Summarize Session", "Summarize the session to reduce context length") + .interact()? + } else { + // In headless mode, default to summarize + "summarize" } } - } else { - // In headless mode (goose run), automatically use summarize - Self::summarize_context_messages(&mut self.messages, &self.agent, "Goose automatically summarized messages to continue processing.").await?; + }; + + match selected { + "clear" => { + self.messages.clear(); + let msg = if context_strategy == "clear" { + format!("Context maxed out - automatically cleared session.\n{}", "-".repeat(50)) + } else { + format!("Session cleared.\n{}", "-".repeat(50)) + }; + output::render_text(&msg, Some(Color::Yellow), true); + break; // exit the loop to hand back control to the user + } + "truncate" => { + // Truncate messages to fit within context length + let (truncated_messages, _) = self.agent.truncate_context(&self.messages).await?; + let msg = if context_strategy == "truncate" { + format!("Context maxed out - automatically truncated messages.\n{}\nGoose tried its best to truncate messages for you.", "-".repeat(50)) + } else { + format!("Context maxed out\n{}\nGoose tried its best to truncate messages for you.", "-".repeat(50)) + }; + output::render_text("", Some(Color::Yellow), true); + output::render_text(&msg, Some(Color::Yellow), true); + self.messages = truncated_messages; + } + "summarize" => { + // Use the helper function to summarize context + let message_suffix = if context_strategy == "summarize" { + "Goose automatically summarized messages for you." + } else if interactive { + "Goose summarized messages for you." + } else { + "Goose automatically summarized messages to continue processing." + }; + Self::summarize_context_messages(&mut self.messages, &self.agent, message_suffix).await?; + } + _ => { + unreachable!() + } } // Restart the stream after handling ContextLengthExceeded diff --git a/documentation/docs/guides/environment-variables.md b/documentation/docs/guides/environment-variables.md index 7888b813..4f29ff0e 100644 --- a/documentation/docs/guides/environment-variables.md +++ b/documentation/docs/guides/environment-variables.md @@ -62,6 +62,24 @@ export GOOSE_PLANNER_PROVIDER="openai" export GOOSE_PLANNER_MODEL="gpt-4" ``` +## Session Management + +These variables control how Goose manages conversation sessions and context. + +| Variable | Purpose | Values | Default | +|----------|---------|---------|---------| +| `GOOSE_CONTEXT_STRATEGY` | Controls how Goose handles context limit exceeded situations | "summarize", "truncate", "clear", "prompt" | "prompt" (interactive), "summarize" (headless) | + +**Examples** + +```bash +# Automatically summarize when context limit is reached +export GOOSE_CONTEXT_STRATEGY=summarize + +# Always prompt user to choose (default for interactive mode) +export GOOSE_CONTEXT_STRATEGY=prompt +``` + ## Tool Configuration These variables control how Goose handles [tool permissions](/docs/guides/tool-permissions) and their execution. diff --git a/documentation/docs/guides/smart-context-management.md b/documentation/docs/guides/smart-context-management.md index 2421d929..81238bb1 100644 --- a/documentation/docs/guides/smart-context-management.md +++ b/documentation/docs/guides/smart-context-management.md @@ -58,10 +58,33 @@ You can proactively summarize your conversation before reaching context limits: The CLI offers three context management options: summarize, truncate, or clear your session. +### Default Context Strategy + +You can configure Goose to automatically handle context limits without prompting by setting the `GOOSE_CONTEXT_STRATEGY` environment variable: + +```bash +# Set default strategy (choose one) +export GOOSE_CONTEXT_STRATEGY=summarize # Automatically summarize (recommended) +export GOOSE_CONTEXT_STRATEGY=truncate # Automatically remove oldest messages +export GOOSE_CONTEXT_STRATEGY=clear # Automatically clear session +export GOOSE_CONTEXT_STRATEGY=prompt # Always prompt user (default) +``` + +Or configure it permanently: +```bash +goose configure set GOOSE_CONTEXT_STRATEGY summarize +``` + +**Default behavior:** +- **Interactive mode**: Prompts user to choose (equivalent to `prompt`) +- **Headless mode** (`goose run`): Automatically summarizes (equivalent to `summarize`) + -When you hit the context limit, you'll see this prompt to choose a management option, allowing you to continue your session: +When you hit the context limit, the behavior depends on your configuration: + +**With default settings (no `GOOSE_CONTEXT_STRATEGY` set)**, you'll see this prompt to choose a management option: ```sh ◇ The model's context length is maxed out. You will need to reduce the # msgs. Do you want to? @@ -76,6 +99,24 @@ final_summary: [A summary of your conversation will appear here] Context maxed out -------------------------------------------------- Goose summarized messages for you. +``` + +**With `GOOSE_CONTEXT_STRATEGY` configured**, Goose will automatically apply your chosen strategy: + +```sh +# Example with GOOSE_CONTEXT_STRATEGY=summarize +Context maxed out - automatically summarized messages. +-------------------------------------------------- +Goose automatically summarized messages for you. + +# Example with GOOSE_CONTEXT_STRATEGY=truncate +Context maxed out - automatically truncated messages. +-------------------------------------------------- +Goose tried its best to truncate messages for you. + +# Example with GOOSE_CONTEXT_STRATEGY=clear +Context maxed out - automatically cleared session. +-------------------------------------------------- ``` @@ -118,4 +159,4 @@ Key information has been preserved while reducing context length. This functionality is not available in the Goose CLI. - \ No newline at end of file + From 3a22d6b45248a43d932cd34e7e38ae26f1a74e98 Mon Sep 17 00:00:00 2001 From: Raduan Al-Shedivat <88370223+dbraduan@users.noreply.github.com> Date: Tue, 3 Jun 2025 21:48:37 +0200 Subject: [PATCH 22/40] cli(ux): Show active context length in CLI (#2315) Co-authored-by: Angie Jones --- crates/goose-cli/src/session/mod.rs | 35 +++++++++++++++++++++----- crates/goose-cli/src/session/output.rs | 32 +++++++++++++++++++++++ 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 9eab36b1..d980ba7c 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -16,8 +16,7 @@ pub use goose::session::Identifier; use anyhow::{Context, Result}; use completion::GooseCompleter; -use etcetera::choose_app_strategy; -use etcetera::AppStrategy; +use etcetera::{choose_app_strategy, AppStrategy}; use goose::agents::extension::{Envs, ExtensionConfig}; use goose::agents::{Agent, SessionConfig}; use goose::config::Config; @@ -26,9 +25,9 @@ use goose::session; use input::InputResult; use mcp_core::handler::ToolError; use mcp_core::prompt::PromptMessage; - use mcp_core::protocol::JsonRpcMessage; use mcp_core::protocol::JsonRpcNotification; + use rand::{distributions::Alphanumeric, Rng}; use serde_json::Value; use std::collections::HashMap; @@ -354,9 +353,10 @@ impl Session { // Create and use a global history file in ~/.config/goose directory // This allows command history to persist across different chat sessions // instead of being tied to each individual session's messages - let history_file = choose_app_strategy(crate::APP_STRATEGY.clone()) - .expect("goose requires a home dir") - .in_config_dir("history.txt"); + let strategy = + choose_app_strategy(crate::APP_STRATEGY.clone()).expect("goose requires a home dir"); + let config_dir = strategy.config_dir(); + let history_file = config_dir.join("history.txt"); // Ensure config directory exists if let Some(parent) = history_file.parent() { @@ -382,6 +382,9 @@ impl Session { output::display_greeting(); loop { + // Display context usage before each prompt + self.display_context_usage().await?; + match input::get_input(&mut editor)? { input::InputResult::Message(content) => { match self.run_mode { @@ -1118,6 +1121,26 @@ impl Session { Ok(metadata.total_tokens) } + /// Display enhanced context usage with session totals + pub async fn display_context_usage(&self) -> Result<()> { + let provider = self.agent.provider().await?; + let model_config = provider.get_model_config(); + let context_limit = model_config.context_limit.unwrap_or(32000); + + match self.get_metadata() { + Ok(metadata) => { + let total_tokens = metadata.total_tokens.unwrap_or(0) as usize; + + output::display_context_usage(total_tokens, context_limit); + } + Err(_) => { + output::display_context_usage(0, context_limit); + } + } + + Ok(()) + } + /// Handle prompt command execution async fn handle_prompt_command(&mut self, opts: input::PromptCommandOptions) -> Result<()> { // name is required diff --git a/crates/goose-cli/src/session/output.rs b/crates/goose-cli/src/session/output.rs index 78b4eb76..3bda532b 100644 --- a/crates/goose-cli/src/session/output.rs +++ b/crates/goose-cli/src/session/output.rs @@ -574,6 +574,38 @@ pub fn display_greeting() { println!("\nGoose is running! Enter your instructions, or try asking what goose can do.\n"); } +/// Display context window usage with both current and session totals +pub fn display_context_usage(total_tokens: usize, context_limit: usize) { + use console::style; + + // Calculate percentage used + let percentage = (total_tokens as f64 / context_limit as f64 * 100.0).round() as usize; + + // Create dot visualization + let dot_count = 10; + let filled_dots = ((percentage as f64 / 100.0) * dot_count as f64).round() as usize; + let empty_dots = dot_count - filled_dots; + + let filled = "●".repeat(filled_dots); + let empty = "○".repeat(empty_dots); + + // Combine dots and apply color + let dots = format!("{}{}", filled, empty); + let colored_dots = if percentage < 50 { + style(dots).green() + } else if percentage < 85 { + style(dots).yellow() + } else { + style(dots).red() + }; + + // Print the status line + println!( + "Context: {} {}% ({}/{} tokens)", + colored_dots, percentage, total_tokens, context_limit + ); +} + pub struct McpSpinners { bars: HashMap, log_spinner: Option, From d1dc6c3ff0882e53ad175ddd5bdd54f9e5aa49f0 Mon Sep 17 00:00:00 2001 From: Raduan Al-Shedivat <88370223+dbraduan@users.noreply.github.com> Date: Tue, 3 Jun 2025 22:12:40 +0200 Subject: [PATCH 23/40] mcp(developer): add fallback on .gitignore if no .gooseignore is present (#2661) --- crates/goose-mcp/src/developer/mod.rs | 207 +++++++++++++++++- .../docs/guides/using-gooseignore.md | 24 +- 2 files changed, 229 insertions(+), 2 deletions(-) diff --git a/crates/goose-mcp/src/developer/mod.rs b/crates/goose-mcp/src/developer/mod.rs index f5a12f1f..03dac338 100644 --- a/crates/goose-mcp/src/developer/mod.rs +++ b/crates/goose-mcp/src/developer/mod.rs @@ -408,10 +408,20 @@ impl DeveloperRouter { if local_ignore_path.is_file() { let _ = builder.add(local_ignore_path); has_ignore_file = true; + } else { + // If no .gooseignore exists, check for .gitignore as fallback + let gitignore_path = cwd.join(".gitignore"); + if gitignore_path.is_file() { + tracing::debug!( + "No .gooseignore found, using .gitignore as fallback for ignore patterns" + ); + let _ = builder.add(gitignore_path); + has_ignore_file = true; + } } // Only use default patterns if no .gooseignore files were found - // If the file is empty, we will not ignore any file + // AND no .gitignore was used as fallback if !has_ignore_file { // Add some sensible defaults let _ = builder.add_line(None, "**/.env"); @@ -1758,4 +1768,199 @@ mod tests { temp_dir.close().unwrap(); } + + #[tokio::test] + #[serial] + async fn test_gitignore_fallback_when_no_gooseignore() { + let temp_dir = tempfile::tempdir().unwrap(); + std::env::set_current_dir(&temp_dir).unwrap(); + + // Create a .gitignore file but no .gooseignore + std::fs::write(temp_dir.path().join(".gitignore"), "*.log\n*.tmp\n.env").unwrap(); + + let router = DeveloperRouter::new(); + + // Test that gitignore patterns are respected + assert!( + router.is_ignored(Path::new("test.log")), + "*.log pattern from .gitignore should be ignored" + ); + assert!( + router.is_ignored(Path::new("build.tmp")), + "*.tmp pattern from .gitignore should be ignored" + ); + assert!( + router.is_ignored(Path::new(".env")), + ".env pattern from .gitignore should be ignored" + ); + assert!( + !router.is_ignored(Path::new("test.txt")), + "test.txt should not be ignored" + ); + + temp_dir.close().unwrap(); + } + + #[tokio::test] + #[serial] + async fn test_gooseignore_takes_precedence_over_gitignore() { + let temp_dir = tempfile::tempdir().unwrap(); + std::env::set_current_dir(&temp_dir).unwrap(); + + // Create both .gooseignore and .gitignore files with different patterns + std::fs::write(temp_dir.path().join(".gooseignore"), "*.secret").unwrap(); + std::fs::write(temp_dir.path().join(".gitignore"), "*.log\ntarget/").unwrap(); + + let router = DeveloperRouter::new(); + + // .gooseignore patterns should be used + assert!( + router.is_ignored(Path::new("test.secret")), + "*.secret pattern from .gooseignore should be ignored" + ); + + // .gitignore patterns should NOT be used when .gooseignore exists + assert!( + !router.is_ignored(Path::new("test.log")), + "*.log pattern from .gitignore should NOT be ignored when .gooseignore exists" + ); + assert!( + !router.is_ignored(Path::new("build.tmp")), + "*.tmp pattern from .gitignore should NOT be ignored when .gooseignore exists" + ); + + temp_dir.close().unwrap(); + } + + #[tokio::test] + #[serial] + async fn test_default_patterns_when_no_ignore_files() { + let temp_dir = tempfile::tempdir().unwrap(); + std::env::set_current_dir(&temp_dir).unwrap(); + + // Don't create any ignore files + let router = DeveloperRouter::new(); + + // Default patterns should be used + assert!( + router.is_ignored(Path::new(".env")), + ".env should be ignored by default patterns" + ); + assert!( + router.is_ignored(Path::new(".env.local")), + ".env.local should be ignored by default patterns" + ); + assert!( + router.is_ignored(Path::new("secrets.txt")), + "secrets.txt should be ignored by default patterns" + ); + assert!( + !router.is_ignored(Path::new("normal.txt")), + "normal.txt should not be ignored" + ); + + temp_dir.close().unwrap(); + } + + #[tokio::test] + #[serial] + async fn test_text_editor_respects_gitignore_fallback() { + let temp_dir = tempfile::tempdir().unwrap(); + std::env::set_current_dir(&temp_dir).unwrap(); + + // Create a .gitignore file but no .gooseignore + std::fs::write(temp_dir.path().join(".gitignore"), "*.log").unwrap(); + + let router = DeveloperRouter::new(); + + // Try to write to a file ignored by .gitignore + let result = router + .call_tool( + "text_editor", + json!({ + "command": "write", + "path": temp_dir.path().join("test.log").to_str().unwrap(), + "file_text": "test content" + }), + dummy_sender(), + ) + .await; + + assert!( + result.is_err(), + "Should not be able to write to file ignored by .gitignore fallback" + ); + assert!(matches!(result.unwrap_err(), ToolError::ExecutionError(_))); + + // Try to write to a non-ignored file + let result = router + .call_tool( + "text_editor", + json!({ + "command": "write", + "path": temp_dir.path().join("allowed.txt").to_str().unwrap(), + "file_text": "test content" + }), + dummy_sender(), + ) + .await; + + assert!( + result.is_ok(), + "Should be able to write to non-ignored file" + ); + + temp_dir.close().unwrap(); + } + + #[tokio::test] + #[serial] + async fn test_bash_respects_gitignore_fallback() { + let temp_dir = tempfile::tempdir().unwrap(); + std::env::set_current_dir(&temp_dir).unwrap(); + + // Create a .gitignore file but no .gooseignore + std::fs::write(temp_dir.path().join(".gitignore"), "*.log").unwrap(); + + let router = DeveloperRouter::new(); + + // Create a file that would be ignored by .gitignore + let log_file_path = temp_dir.path().join("test.log"); + std::fs::write(&log_file_path, "log content").unwrap(); + + // Try to cat the ignored file + let result = router + .call_tool( + "shell", + json!({ + "command": format!("cat {}", log_file_path.to_str().unwrap()) + }), + dummy_sender(), + ) + .await; + + assert!( + result.is_err(), + "Should not be able to cat file ignored by .gitignore fallback" + ); + assert!(matches!(result.unwrap_err(), ToolError::ExecutionError(_))); + + // Try to cat a non-ignored file + let allowed_file_path = temp_dir.path().join("allowed.txt"); + std::fs::write(&allowed_file_path, "allowed content").unwrap(); + + let result = router + .call_tool( + "shell", + json!({ + "command": format!("cat {}", allowed_file_path.to_str().unwrap()) + }), + dummy_sender(), + ) + .await; + + assert!(result.is_ok(), "Should be able to cat non-ignored file"); + + temp_dir.close().unwrap(); + } } diff --git a/documentation/docs/guides/using-gooseignore.md b/documentation/docs/guides/using-gooseignore.md index 484adcfb..8c58de4a 100644 --- a/documentation/docs/guides/using-gooseignore.md +++ b/documentation/docs/guides/using-gooseignore.md @@ -23,6 +23,24 @@ Goose supports two types of `.gooseignore` files: You can use both global and local `.gooseignore` files simultaneously. When both exist, Goose will combine the restrictions from both files to determine which paths are restricted. ::: +## Automatic `.gitignore` fallback + +If no `.gooseignore` file is found in your current directory, Goose will automatically use your `.gitignore` file as a fallback. This means: + +1. **Priority Order**: Goose checks for ignore patterns in this order: + - Global `.gooseignore` (if exists) + - Local `.gooseignore` (if exists) + - Local `.gitignore` (if no local `.gooseignore` and `.gitignore` exists) + - Default patterns (if none of the above exist) + +2. **Seamless Integration**: Projects with existing `.gitignore` files get automatic protection without needing a separate `.gooseignore` file. + +3. **Override Capability**: Creating a local `.gooseignore` file will completely override `.gitignore` patterns for that directory. + +:::info Debug logging +When Goose uses `.gitignore` as a fallback, it will log a message to help you understand which ignore file is being used. +::: + ## Example `.gooseignore` file In your `.gooseignore` file, you can write patterns to match files you want Goose to ignore. Here are some common patterns: @@ -49,7 +67,7 @@ downloads/ # Ignore everything in the "downloads" directory ## Default patterns -By default, if you haven't created any `.gooseignore` files, Goose will not modify files matching these patterns: +By default, if you haven't created any `.gooseignore` files **and no `.gitignore` file exists**, Goose will not modify files matching these patterns: ```plaintext **/.env @@ -57,6 +75,8 @@ By default, if you haven't created any `.gooseignore` files, Goose will not modi **/secrets.* ``` +These default patterns only apply when neither `.gooseignore` nor `.gitignore` files are found in your project. + ## Common use cases Here are some typical scenarios where `.gooseignore` is helpful: @@ -65,4 +85,6 @@ Here are some typical scenarios where `.gooseignore` is helpful: - **Third-Party Code**: Keep Goose from changing external libraries or dependencies - **Important Configurations**: Protect critical configuration files from accidental modifications - **Version Control**: Prevent changes to version control files like `.git` directory +- **Existing Projects**: Most projects already have `.gitignore` files that work automatically as ignore patterns for Goose +- **Custom Restrictions**: Create `.gooseignore` when you need different patterns than your `.gitignore` (e.g., allowing Goose to read files that Git ignores) From 5574c20ff2ce9c6a35e64757d98d65890446adfd Mon Sep 17 00:00:00 2001 From: Gary Zhou Date: Tue, 3 Jun 2025 18:14:26 -0400 Subject: [PATCH 24/40] Feat: Refined the documentation for Goose (#2751) Co-authored-by: Angie Jones Co-authored-by: Rizel Scarlett --- .../docs/getting-started/installation.md | 12 +++++++++++- .../docs/getting-started/providers.md | 19 ++++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/documentation/docs/getting-started/installation.md b/documentation/docs/getting-started/installation.md index a4c5321f..25970aaa 100644 --- a/documentation/docs/getting-started/installation.md +++ b/documentation/docs/getting-started/installation.md @@ -96,7 +96,11 @@ import DesktopInstallButtons from '@site/src/components/DesktopInstallButtons'; wsl --install ``` - 2. Restart your computer if prompted. + 2. If prompted, restart your computer to complete the WSL installation. Once restarted, or if WSL is already installed, launch your Ubuntu shell by running: + + ```bash + wsl -d Ubuntu + ``` 3. Run the Goose installation script: ```bash @@ -165,6 +169,12 @@ Goose works with a set of [supported LLM providers][providers], and you'll need export OPENAI_API_KEY={your_api_key} ``` + Run `goose configure` again and proceed through the prompts. When you reach the step for entering the API key, Goose will detect that the key is already set as an environment variable and display a message like: + + ``` + ● OPENAI_API_KEY is set via environment variable + ``` + To make the changes persist in WSL across sessions, add the goose path and export commands to your `.bashrc` or `.bash_profile` file so you can load it later. ```bash diff --git a/documentation/docs/getting-started/providers.md b/documentation/docs/getting-started/providers.md index 3b133eb9..717201fa 100644 --- a/documentation/docs/getting-started/providers.md +++ b/documentation/docs/getting-started/providers.md @@ -92,7 +92,7 @@ To configure your chosen provider or see available options, run `goose configure │ ○ OpenRouter └ ``` - 4. Enter your API key (and any other configuration details) when prompted + 4. Enter your API key (and any other configuration details) when prompted. ``` ┌ goose-configure @@ -106,6 +106,23 @@ To configure your chosen provider or see available options, run `goose configure ◆ Provider Anthropic requires ANTHROPIC_API_KEY, please enter a value │ └ + ``` + 5. Enter your desired `ANTHROPIC_HOST` or you can use the default one by hitting the `Enter` key. + + ``` + ◇ Enter new value for ANTHROPIC_HOST + │ https://api.anthropic.com (default) + ``` + 6. Enter the model you want to use or you can use the default one by hitting the `Enter` key. + ``` + │ + ◇ Model fetch complete + │ + ◇ Enter a model from that provider: + │ claude-3-5-sonnet-latest (default) + │ + ◓ Checking your configuration... + └ Configuration saved successfully ``` From d1f9d4a31ec77b5e76c7f3ba4dbe927fd5ded1af Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Wed, 4 Jun 2025 09:45:43 +1000 Subject: [PATCH 25/40] chore: use hermit to install node, rust and protoc (#2766) --- .github/workflows/build-cli.yml | 22 +++++------ .github/workflows/bundle-desktop-intel.yml | 23 ++++-------- .github/workflows/bundle-desktop.yml | 23 +++--------- .github/workflows/ci.yml | 27 +++----------- .github/workflows/pr-comment-build-cli.yml | 13 +++++++ .gitignore | 3 +- Cross.toml | 8 ---- bin/.node-22.9.0.pkg | 1 + bin/.protoc-31.1.pkg | 1 + bin/.rustup-1.25.2.pkg | 1 + bin/README.hermit.md | 7 ++++ bin/activate-hermit | 21 +++++++++++ bin/activate-hermit.fish | 24 ++++++++++++ bin/cargo | 1 + bin/cargo-clippy | 1 + bin/cargo-fmt | 1 + bin/cargo-miri | 1 + bin/clippy-driver | 1 + bin/corepack | 1 + bin/hermit | 43 ++++++++++++++++++++++ bin/hermit.hcl | 4 ++ bin/node | 1 + bin/npm | 1 + bin/npx | 1 + bin/protoc | 1 + bin/rls | 1 + bin/rust-analyzer | 1 + bin/rust-gdb | 1 + bin/rust-gdbgui | 1 + bin/rust-lldb | 1 + bin/rustc | 1 + bin/rustdoc | 1 + bin/rustfmt | 1 + bin/rustup | 1 + crates/goose/src/recipe/mod.rs | 8 ++-- run_cross_local.md | 4 +- 36 files changed, 170 insertions(+), 82 deletions(-) create mode 120000 bin/.node-22.9.0.pkg create mode 120000 bin/.protoc-31.1.pkg create mode 120000 bin/.rustup-1.25.2.pkg create mode 100644 bin/README.hermit.md create mode 100755 bin/activate-hermit create mode 100755 bin/activate-hermit.fish create mode 120000 bin/cargo create mode 120000 bin/cargo-clippy create mode 120000 bin/cargo-fmt create mode 120000 bin/cargo-miri create mode 120000 bin/clippy-driver create mode 120000 bin/corepack create mode 100755 bin/hermit create mode 100644 bin/hermit.hcl create mode 120000 bin/node create mode 120000 bin/npm create mode 120000 bin/npx create mode 120000 bin/protoc create mode 120000 bin/rls create mode 120000 bin/rust-analyzer create mode 120000 bin/rust-gdb create mode 120000 bin/rust-gdbgui create mode 120000 bin/rust-lldb create mode 120000 bin/rustc create mode 120000 bin/rustdoc create mode 120000 bin/rustfmt create mode 120000 bin/rustup diff --git a/.github/workflows/build-cli.yml b/.github/workflows/build-cli.yml index d0956aff..6ae6efca 100644 --- a/.github/workflows/build-cli.yml +++ b/.github/workflows/build-cli.yml @@ -20,6 +20,10 @@ on: type: string required: false default: '["x86_64","aarch64"]' + ref: + type: string + required: false + default: 'refs/heads/main' name: "Reusable workflow to build CLI" @@ -41,6 +45,9 @@ jobs: steps: - name: Checkout code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4 + with: + ref: ${{ inputs.ref }} + fetch-depth: 0 - name: Update version in Cargo.toml if: ${{ inputs.version != '' }} @@ -48,14 +55,8 @@ jobs: sed -i.bak 's/^version = ".*"/version = "'${{ inputs.version }}'"/' Cargo.toml rm -f Cargo.toml.bak - - name: Setup Rust - uses: dtolnay/rust-toolchain@38b70195107dddab2c7bbd522bcf763bac00963b # pin@stable - with: - toolchain: stable - target: ${{ matrix.architecture }}-${{ matrix.target-suffix }} - - name: Install cross - run: cargo install cross --git https://github.com/cross-rs/cross + run: source ./bin/activate-hermit && cargo install cross --git https://github.com/cross-rs/cross - name: Build CLI env: @@ -64,12 +65,7 @@ jobs: RUST_BACKTRACE: 1 CROSS_VERBOSE: 1 run: | - # Install protoc if on macOS - if [ "${{ matrix.os }}" = "macos-latest" ]; then - brew install protobuf - export PROTOC=$(which protoc) - fi - + source ./bin/activate-hermit export TARGET="${{ matrix.architecture }}-${{ matrix.target-suffix }}" rustup target add "${TARGET}" echo "Building for target: ${TARGET}" diff --git a/.github/workflows/bundle-desktop-intel.yml b/.github/workflows/bundle-desktop-intel.yml index e841ac31..8d614af2 100644 --- a/.github/workflows/bundle-desktop-intel.yml +++ b/.github/workflows/bundle-desktop-intel.yml @@ -85,20 +85,16 @@ jobs: # Update version in Cargo.toml sed -i.bak 's/^version = ".*"/version = "'${{ inputs.version }}'"/' Cargo.toml rm -f Cargo.toml.bak - - # Update version in package.json + + # Update version in package.json + source ./bin/activate-hermit cd ui/desktop npm version ${{ inputs.version }} --no-git-tag-version --allow-same-version - - name: Setup Rust - uses: dtolnay/rust-toolchain@38b70195107dddab2c7bbd522bcf763bac00963b # pin@stable - with: - toolchain: stable - targets: x86_64-apple-darwin - # Pre-build cleanup to ensure enough disk space - name: Pre-build cleanup run: | + source ./bin/activate-hermit echo "Performing pre-build cleanup..." # Clean npm cache npm cache clean --force || true @@ -137,7 +133,7 @@ jobs: # Build specifically for Intel architecture - name: Build goosed for Intel - run: cargo build --release -p goose-server --target x86_64-apple-darwin + run: source ./bin/activate-hermit && cargo build --release -p goose-server --target x86_64-apple-darwin # Post-build cleanup to free space - name: Post-build cleanup @@ -164,13 +160,8 @@ jobs: CERTIFICATE_OSX_APPLICATION: ${{ secrets.CERTIFICATE_OSX_APPLICATION }} CERTIFICATE_PASSWORD: ${{ secrets.CERTIFICATE_PASSWORD }} - - name: Set up Node.js - uses: actions/setup-node@7c12f8017d5436eb855f1ed4399f037a36fbd9e8 # pin@v2 - with: - node-version: 'lts/*' - - name: Install dependencies - run: npm ci + run: source ../../bin/activate-hermit && npm ci working-directory: ui/desktop # Configure Electron builder for Intel architecture @@ -187,6 +178,7 @@ jobs: - name: Make Unsigned App if: ${{ !inputs.signing }} run: | + source ../../bin/activate-hermit attempt=0 max_attempts=2 until [ $attempt -ge $max_attempts ]; do @@ -204,6 +196,7 @@ jobs: - name: Make Signed App if: ${{ inputs.signing }} run: | + source ../../bin/activate-hermit attempt=0 max_attempts=2 until [ $attempt -ge $max_attempts ]; do diff --git a/.github/workflows/bundle-desktop.yml b/.github/workflows/bundle-desktop.yml index c4856158..a5875097 100644 --- a/.github/workflows/bundle-desktop.yml +++ b/.github/workflows/bundle-desktop.yml @@ -135,6 +135,7 @@ jobs: sed -i.bak "s/^version = \".*\"/version = \"${VERSION}\"/" Cargo.toml rm -f Cargo.toml.bak + source ./bin/activate-hermit # Update version in package.json cd ui/desktop npm version "${VERSION}" --no-git-tag-version --allow-same-version @@ -142,6 +143,7 @@ jobs: # Pre-build cleanup to ensure enough disk space - name: Pre-build cleanup run: | + source ./bin/activate-hermit echo "Performing pre-build cleanup..." # Clean npm cache npm cache clean --force || true @@ -154,16 +156,6 @@ jobs: # Check disk space after cleanup df -h - - name: Install protobuf - run: | - brew install protobuf - echo "PROTOC=$(which protoc)" >> $GITHUB_ENV - - - name: Setup Rust - uses: dtolnay/rust-toolchain@38b70195107dddab2c7bbd522bcf763bac00963b # pin@stable - with: - toolchain: stable - - name: Cache Cargo registry uses: actions/cache@2f8e54208210a422b2efd51efaa6bd6d7ca8920f # pin@v3 with: @@ -190,7 +182,7 @@ jobs: # Build the project - name: Build goosed - run: cargo build --release -p goose-server + run: source ./bin/activate-hermit && cargo build --release -p goose-server # Post-build cleanup to free space - name: Post-build cleanup @@ -216,13 +208,8 @@ jobs: CERTIFICATE_OSX_APPLICATION: ${{ secrets.CERTIFICATE_OSX_APPLICATION }} CERTIFICATE_PASSWORD: ${{ secrets.CERTIFICATE_PASSWORD }} - - name: Set up Node.js - uses: actions/setup-node@7c12f8017d5436eb855f1ed4399f037a36fbd9e8 # pin@v2 - with: - node-version: 'lts/*' - - name: Install dependencies - run: npm ci + run: source ../../bin/activate-hermit && npm ci working-directory: ui/desktop # Check disk space before bundling @@ -232,6 +219,7 @@ jobs: - name: Make Unsigned App if: ${{ !inputs.signing }} run: | + source ../../bin/activate-hermit attempt=0 max_attempts=2 until [ $attempt -ge $max_attempts ]; do @@ -253,6 +241,7 @@ jobs: APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: | + attempt=0 max_attempts=2 until [ $attempt -ge $max_attempts ]; do diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 37699a1f..a4d76255 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,13 +17,8 @@ jobs: - name: Checkout Code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4 - - name: Setup Rust - uses: dtolnay/rust-toolchain@38b70195107dddab2c7bbd522bcf763bac00963b # pin@stable - with: - toolchain: stable - - name: Run cargo fmt - run: cargo fmt --check + run: source ./bin/activate-hermit && cargo fmt --check rust-build-and-test: name: Build and Test Rust Project @@ -57,12 +52,7 @@ jobs: - name: Install Dependencies run: | sudo apt update -y - sudo apt install -y libdbus-1-dev gnome-keyring libxcb1-dev protobuf-compiler - - - name: Setup Rust - uses: dtolnay/rust-toolchain@38b70195107dddab2c7bbd522bcf763bac00963b # pin@stable - with: - toolchain: stable + sudo apt install -y libdbus-1-dev gnome-keyring libxcb1-dev - name: Cache Cargo Registry uses: actions/cache@2f8e54208210a422b2efd51efaa6bd6d7ca8920f # pin@v3 @@ -91,7 +81,7 @@ jobs: - name: Build and Test run: | gnome-keyring-daemon --components=secrets --daemonize --unlock <<< 'foobar' - cargo test + source ../bin/activate-hermit && cargo test working-directory: crates # Add disk space cleanup before linting @@ -120,7 +110,7 @@ jobs: run: df -h - name: Lint - run: cargo clippy -- -D warnings + run: source ./bin/activate-hermit && cargo clippy -- -D warnings desktop-lint: name: Lint Electron Desktop App @@ -129,17 +119,12 @@ jobs: - name: Checkout Code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4 - - name: Set up Node.js - uses: actions/setup-node@7c12f8017d5436eb855f1ed4399f037a36fbd9e8 # pin@v2 - with: - node-version: "lts/*" - - name: Install Dependencies - run: npm ci + run: source ../../bin/activate-hermit && npm ci working-directory: ui/desktop - name: Run Lint - run: npm run lint:check + run: source ../../bin/activate-hermit && npm run lint:check working-directory: ui/desktop # Faster Desktop App build for PRs only diff --git a/.github/workflows/pr-comment-build-cli.yml b/.github/workflows/pr-comment-build-cli.yml index e4042c06..24a865d2 100644 --- a/.github/workflows/pr-comment-build-cli.yml +++ b/.github/workflows/pr-comment-build-cli.yml @@ -27,6 +27,7 @@ jobs: outputs: continue: ${{ steps.command.outputs.continue || github.event_name == 'workflow_dispatch' }} pr_number: ${{ steps.command.outputs.issue_number || github.event.inputs.pr_number }} + head_sha: ${{ steps.set_head_sha.outputs.head_sha || github.sha }} steps: - if: ${{ github.event_name == 'issue_comment' }} uses: github/command@v1.3.0 @@ -36,11 +37,23 @@ jobs: skip_reviews: true reaction: "eyes" allowed_contexts: pull_request + - name: Get PR head SHA with gh + id: set_head_sha + run: | + echo "Get PR head SHA with gh" + HEAD_SHA=$(gh pr view "$ISSUE_NUMBER" --json headRefOid -q .headRefOid) + echo "head_sha=$HEAD_SHA" >> $GITHUB_OUTPUT + echo "head_sha=$HEAD_SHA" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ISSUE_NUMBER: ${{ steps.command.outputs.issue_number }} build-cli: needs: [trigger-on-command] if: ${{ needs.trigger-on-command.outputs.continue == 'true' }} uses: ./.github/workflows/build-cli.yml + with: + ref: ${{ needs.trigger-on-command.outputs.head_sha }} pr-comment-cli: name: PR Comment with CLI builds diff --git a/.gitignore b/.gitignore index 3d5ef353..3f5a8419 100644 --- a/.gitignore +++ b/.gitignore @@ -24,8 +24,7 @@ target/ ./ui/desktop/out # Hermit -/.hermit/ -/bin/ +.hermit/ debug_*.txt diff --git a/Cross.toml b/Cross.toml index 4e7ab47d..f2db2745 100644 --- a/Cross.toml +++ b/Cross.toml @@ -1,11 +1,4 @@ # Configuration for cross-compiling using cross -[build.env] -passthrough = [ - "PROTOC", - "RUST_LOG", - "RUST_BACKTRACE" -] - [target.aarch64-unknown-linux-gnu] xargo = false pre-build = [ @@ -51,7 +44,6 @@ pre-build = [ protoc --version """ ] -env = { "PROTOC" = "/usr/bin/protoc", "PATH" = "/usr/local/bin:${PATH}" } [target.x86_64-pc-windows-gnu] image = "dockcross/windows-static-x64:latest" diff --git a/bin/.node-22.9.0.pkg b/bin/.node-22.9.0.pkg new file mode 120000 index 00000000..383f4511 --- /dev/null +++ b/bin/.node-22.9.0.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.protoc-31.1.pkg b/bin/.protoc-31.1.pkg new file mode 120000 index 00000000..383f4511 --- /dev/null +++ b/bin/.protoc-31.1.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.rustup-1.25.2.pkg b/bin/.rustup-1.25.2.pkg new file mode 120000 index 00000000..383f4511 --- /dev/null +++ b/bin/.rustup-1.25.2.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/README.hermit.md b/bin/README.hermit.md new file mode 100644 index 00000000..e889550b --- /dev/null +++ b/bin/README.hermit.md @@ -0,0 +1,7 @@ +# Hermit environment + +This is a [Hermit](https://github.com/cashapp/hermit) bin directory. + +The symlinks in this directory are managed by Hermit and will automatically +download and install Hermit itself as well as packages. These packages are +local to this environment. diff --git a/bin/activate-hermit b/bin/activate-hermit new file mode 100755 index 00000000..fe28214d --- /dev/null +++ b/bin/activate-hermit @@ -0,0 +1,21 @@ +#!/bin/bash +# This file must be used with "source bin/activate-hermit" from bash or zsh. +# You cannot run it directly +# +# THIS FILE IS GENERATED; DO NOT MODIFY + +if [ "${BASH_SOURCE-}" = "$0" ]; then + echo "You must source this script: \$ source $0" >&2 + exit 33 +fi + +BIN_DIR="$(dirname "${BASH_SOURCE[0]:-${(%):-%x}}")" +if "${BIN_DIR}/hermit" noop > /dev/null; then + eval "$("${BIN_DIR}/hermit" activate "${BIN_DIR}/..")" + + if [ -n "${BASH-}" ] || [ -n "${ZSH_VERSION-}" ]; then + hash -r 2>/dev/null + fi + + echo "Hermit environment $("${HERMIT_ENV}"/bin/hermit env HERMIT_ENV) activated" +fi diff --git a/bin/activate-hermit.fish b/bin/activate-hermit.fish new file mode 100755 index 00000000..0367d233 --- /dev/null +++ b/bin/activate-hermit.fish @@ -0,0 +1,24 @@ +#!/usr/bin/env fish + +# This file must be sourced with "source bin/activate-hermit.fish" from Fish shell. +# You cannot run it directly. +# +# THIS FILE IS GENERATED; DO NOT MODIFY + +if status is-interactive + set BIN_DIR (dirname (status --current-filename)) + + if "$BIN_DIR/hermit" noop > /dev/null + # Source the activation script generated by Hermit + "$BIN_DIR/hermit" activate "$BIN_DIR/.." | source + + # Clear the command cache if applicable + functions -c > /dev/null 2>&1 + + # Display activation message + echo "Hermit environment $($HERMIT_ENV/bin/hermit env HERMIT_ENV) activated" + end +else + echo "You must source this script: source $argv[0]" >&2 + exit 33 +end diff --git a/bin/cargo b/bin/cargo new file mode 120000 index 00000000..5046e66f --- /dev/null +++ b/bin/cargo @@ -0,0 +1 @@ +.rustup-1.25.2.pkg \ No newline at end of file diff --git a/bin/cargo-clippy b/bin/cargo-clippy new file mode 120000 index 00000000..5046e66f --- /dev/null +++ b/bin/cargo-clippy @@ -0,0 +1 @@ +.rustup-1.25.2.pkg \ No newline at end of file diff --git a/bin/cargo-fmt b/bin/cargo-fmt new file mode 120000 index 00000000..5046e66f --- /dev/null +++ b/bin/cargo-fmt @@ -0,0 +1 @@ +.rustup-1.25.2.pkg \ No newline at end of file diff --git a/bin/cargo-miri b/bin/cargo-miri new file mode 120000 index 00000000..5046e66f --- /dev/null +++ b/bin/cargo-miri @@ -0,0 +1 @@ +.rustup-1.25.2.pkg \ No newline at end of file diff --git a/bin/clippy-driver b/bin/clippy-driver new file mode 120000 index 00000000..5046e66f --- /dev/null +++ b/bin/clippy-driver @@ -0,0 +1 @@ +.rustup-1.25.2.pkg \ No newline at end of file diff --git a/bin/corepack b/bin/corepack new file mode 120000 index 00000000..51cdc90c --- /dev/null +++ b/bin/corepack @@ -0,0 +1 @@ +.node-22.9.0.pkg \ No newline at end of file diff --git a/bin/hermit b/bin/hermit new file mode 100755 index 00000000..31559b7d --- /dev/null +++ b/bin/hermit @@ -0,0 +1,43 @@ +#!/bin/bash +# +# THIS FILE IS GENERATED; DO NOT MODIFY + +set -eo pipefail + +export HERMIT_USER_HOME=~ + +if [ -z "${HERMIT_STATE_DIR}" ]; then + case "$(uname -s)" in + Darwin) + export HERMIT_STATE_DIR="${HERMIT_USER_HOME}/Library/Caches/hermit" + ;; + Linux) + export HERMIT_STATE_DIR="${XDG_CACHE_HOME:-${HERMIT_USER_HOME}/.cache}/hermit" + ;; + esac +fi + +export HERMIT_DIST_URL="${HERMIT_DIST_URL:-https://github.com/cashapp/hermit/releases/download/stable}" +HERMIT_CHANNEL="$(basename "${HERMIT_DIST_URL}")" +export HERMIT_CHANNEL +export HERMIT_EXE=${HERMIT_EXE:-${HERMIT_STATE_DIR}/pkg/hermit@${HERMIT_CHANNEL}/hermit} + +if [ ! -x "${HERMIT_EXE}" ]; then + echo "Bootstrapping ${HERMIT_EXE} from ${HERMIT_DIST_URL}" 1>&2 + INSTALL_SCRIPT="$(mktemp)" + # This value must match that of the install script + INSTALL_SCRIPT_SHA256="09ed936378857886fd4a7a4878c0f0c7e3d839883f39ca8b4f2f242e3126e1c6" + if [ "${INSTALL_SCRIPT_SHA256}" = "BYPASS" ]; then + curl -fsSL "${HERMIT_DIST_URL}/install.sh" -o "${INSTALL_SCRIPT}" + else + # Install script is versioned by its sha256sum value + curl -fsSL "${HERMIT_DIST_URL}/install-${INSTALL_SCRIPT_SHA256}.sh" -o "${INSTALL_SCRIPT}" + # Verify install script's sha256sum + openssl dgst -sha256 "${INSTALL_SCRIPT}" | \ + awk -v EXPECTED="$INSTALL_SCRIPT_SHA256" \ + '$2!=EXPECTED {print "Install script sha256 " $2 " does not match " EXPECTED; exit 1}' + fi + /bin/bash "${INSTALL_SCRIPT}" 1>&2 +fi + +exec "${HERMIT_EXE}" --level=fatal exec "$0" -- "$@" diff --git a/bin/hermit.hcl b/bin/hermit.hcl new file mode 100644 index 00000000..cc17d794 --- /dev/null +++ b/bin/hermit.hcl @@ -0,0 +1,4 @@ +manage-git = false + +github-token-auth { +} diff --git a/bin/node b/bin/node new file mode 120000 index 00000000..51cdc90c --- /dev/null +++ b/bin/node @@ -0,0 +1 @@ +.node-22.9.0.pkg \ No newline at end of file diff --git a/bin/npm b/bin/npm new file mode 120000 index 00000000..51cdc90c --- /dev/null +++ b/bin/npm @@ -0,0 +1 @@ +.node-22.9.0.pkg \ No newline at end of file diff --git a/bin/npx b/bin/npx new file mode 120000 index 00000000..51cdc90c --- /dev/null +++ b/bin/npx @@ -0,0 +1 @@ +.node-22.9.0.pkg \ No newline at end of file diff --git a/bin/protoc b/bin/protoc new file mode 120000 index 00000000..6bb03478 --- /dev/null +++ b/bin/protoc @@ -0,0 +1 @@ +.protoc-31.1.pkg \ No newline at end of file diff --git a/bin/rls b/bin/rls new file mode 120000 index 00000000..5046e66f --- /dev/null +++ b/bin/rls @@ -0,0 +1 @@ +.rustup-1.25.2.pkg \ No newline at end of file diff --git a/bin/rust-analyzer b/bin/rust-analyzer new file mode 120000 index 00000000..5046e66f --- /dev/null +++ b/bin/rust-analyzer @@ -0,0 +1 @@ +.rustup-1.25.2.pkg \ No newline at end of file diff --git a/bin/rust-gdb b/bin/rust-gdb new file mode 120000 index 00000000..5046e66f --- /dev/null +++ b/bin/rust-gdb @@ -0,0 +1 @@ +.rustup-1.25.2.pkg \ No newline at end of file diff --git a/bin/rust-gdbgui b/bin/rust-gdbgui new file mode 120000 index 00000000..5046e66f --- /dev/null +++ b/bin/rust-gdbgui @@ -0,0 +1 @@ +.rustup-1.25.2.pkg \ No newline at end of file diff --git a/bin/rust-lldb b/bin/rust-lldb new file mode 120000 index 00000000..5046e66f --- /dev/null +++ b/bin/rust-lldb @@ -0,0 +1 @@ +.rustup-1.25.2.pkg \ No newline at end of file diff --git a/bin/rustc b/bin/rustc new file mode 120000 index 00000000..5046e66f --- /dev/null +++ b/bin/rustc @@ -0,0 +1 @@ +.rustup-1.25.2.pkg \ No newline at end of file diff --git a/bin/rustdoc b/bin/rustdoc new file mode 120000 index 00000000..5046e66f --- /dev/null +++ b/bin/rustdoc @@ -0,0 +1 @@ +.rustup-1.25.2.pkg \ No newline at end of file diff --git a/bin/rustfmt b/bin/rustfmt new file mode 120000 index 00000000..5046e66f --- /dev/null +++ b/bin/rustfmt @@ -0,0 +1 @@ +.rustup-1.25.2.pkg \ No newline at end of file diff --git a/bin/rustup b/bin/rustup new file mode 120000 index 00000000..5046e66f --- /dev/null +++ b/bin/rustup @@ -0,0 +1 @@ +.rustup-1.25.2.pkg \ No newline at end of file diff --git a/crates/goose/src/recipe/mod.rs b/crates/goose/src/recipe/mod.rs index 8891dbd4..510ba000 100644 --- a/crates/goose/src/recipe/mod.rs +++ b/crates/goose/src/recipe/mod.rs @@ -28,7 +28,7 @@ fn default_version() -> String { /// /// # Example /// -/// ``` +/// /// use goose::recipe::Recipe; /// /// // Using the builder pattern @@ -52,7 +52,7 @@ fn default_version() -> String { /// author: None, /// parameters: None, /// }; -/// ``` +/// #[derive(Serialize, Deserialize, Debug)] pub struct Recipe { // Required fields @@ -166,7 +166,7 @@ impl Recipe { /// /// # Example /// - /// ``` + /// /// use goose::recipe::Recipe; /// /// let recipe = Recipe::builder() @@ -175,7 +175,7 @@ impl Recipe { /// .instructions("Act as a helpful assistant") /// .build() /// .expect("Failed to build Recipe: missing required fields"); - /// ``` + /// pub fn builder() -> RecipeBuilder { RecipeBuilder { version: default_version(), diff --git a/run_cross_local.md b/run_cross_local.md index b27b7f74..cfcde742 100644 --- a/run_cross_local.md +++ b/run_cross_local.md @@ -29,7 +29,7 @@ docker pull arm64v8/ubuntu 2. Run the container pwd is the directory which contains the binary built in the previous step on your host machine ```sh -docker run -v $(pwd):/app -it arm64v8/ubuntu /bin/bash +docker run --rm -v "$(pwd)":/app -it --platform linux/arm64 arm64v8/ubuntu /bin/bash ``` 3. Install dependencies in the container and set up api testing environment @@ -63,7 +63,7 @@ docker pull --platform linux/amd64 debian:latest 2. Run the container pwd is the directory contains the binary built in the previous step on your host machine ```sh -docker run --platform linux/amd64 -it -v "$(pwd)":/app debian:latest /bin/bash +docker run --rm -v "$(pwd)":/app -it --platform linux/amd64 ubuntu:latest /bin/bash ``` 3. Install dependencies in the container and set up api testing environment From b29f930589a8942709945478a54b8e1aeb600575 Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Wed, 4 Jun 2025 16:02:29 +1000 Subject: [PATCH 26/40] Lifei/test workflow (#2772) --- .github/workflows/trigger-build-cli.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .github/workflows/trigger-build-cli.yml diff --git a/.github/workflows/trigger-build-cli.yml b/.github/workflows/trigger-build-cli.yml new file mode 100644 index 00000000..6647a746 --- /dev/null +++ b/.github/workflows/trigger-build-cli.yml @@ -0,0 +1,10 @@ +on: + workflow_dispatch: + +name: Trigger Build CLI + +jobs: + trigger-build-cli: + uses: ./.github/workflows/build-cli.yml + with: + ref: ${{ github.sha }} \ No newline at end of file From aa4758417dd214b845fb889b5e73de5681669e80 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Wed, 4 Jun 2025 16:23:16 +1000 Subject: [PATCH 27/40] hotfix: don't always run prompt (#2773) Co-authored-by: Lifei Zhou --- ui/desktop/src/components/ChatView.tsx | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/ui/desktop/src/components/ChatView.tsx b/ui/desktop/src/components/ChatView.tsx index fe523b77..82649d15 100644 --- a/ui/desktop/src/components/ChatView.tsx +++ b/ui/desktop/src/components/ChatView.tsx @@ -82,7 +82,6 @@ export default function ChatView({ } function ChatContent({ - readyForAutoUserPrompt, chat, setChat, setView, @@ -103,7 +102,6 @@ function ChatContent({ const [droppedFiles, setDroppedFiles] = useState([]); const scrollRef = useRef(null); - const hasSentPromptRef = useRef(false); const { summaryContent, @@ -291,13 +289,10 @@ function ChatContent({ } }, [messages]); - useEffect(() => { - const prompt = recipeConfig?.prompt; - if (prompt && !hasSentPromptRef.current && readyForAutoUserPrompt) { - append(prompt); - hasSentPromptRef.current = true; - } - }, [recipeConfig?.prompt, append, readyForAutoUserPrompt]); + // Pre-fill input with recipe prompt instead of auto-sending it + const initialPrompt = useMemo(() => { + return recipeConfig?.prompt || ''; + }, [recipeConfig?.prompt]); // Handle submit const handleSubmit = (e: React.FormEvent) => { @@ -632,7 +627,7 @@ function ChatContent({ isLoading={isLoading} onStop={onStopGoose} commandHistory={commandHistory} - initialValue={_input} + initialValue={_input || initialPrompt} setView={setView} hasMessages={hasMessages} numTokens={sessionTokenCount} From 772ce25884129c4e5a0e6d9394eda3f70c0e2977 Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Wed, 4 Jun 2025 20:50:08 +1000 Subject: [PATCH 28/40] fix: pr comment build cli workflow (#2774) --- .github/workflows/pr-comment-build-cli.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/pr-comment-build-cli.yml b/.github/workflows/pr-comment-build-cli.yml index 24a865d2..158981cf 100644 --- a/.github/workflows/pr-comment-build-cli.yml +++ b/.github/workflows/pr-comment-build-cli.yml @@ -37,6 +37,10 @@ jobs: skip_reviews: true reaction: "eyes" allowed_contexts: pull_request + + - name: Checkout code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4 + - name: Get PR head SHA with gh id: set_head_sha run: | From 176ab18880fabffdc636e25fa0ea424779e458af Mon Sep 17 00:00:00 2001 From: xinbenlv Date: Wed, 4 Jun 2025 10:58:30 -0700 Subject: [PATCH 29/40] feat(providers): Add support for Gemini 2.5 Flash Preview and Pro Preview models (#2780) --- .../goose-server/src/routes/providers_and_keys.json | 2 +- crates/goose/src/providers/formats/gcpvertexai.rs | 12 ++++++++++++ crates/goose/src/providers/gcpvertexai.rs | 3 +++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/goose-server/src/routes/providers_and_keys.json b/crates/goose-server/src/routes/providers_and_keys.json index 830cf665..fc61c5b9 100644 --- a/crates/goose-server/src/routes/providers_and_keys.json +++ b/crates/goose-server/src/routes/providers_and_keys.json @@ -20,7 +20,7 @@ "gcp_vertex_ai": { "name": "GCP Vertex AI", "description": "Use Vertex AI platform models", - "models": ["claude-3-5-haiku@20241022", "claude-3-5-sonnet@20240620", "claude-3-5-sonnet-v2@20241022", "claude-3-7-sonnet@20250219", "gemini-1.5-pro-002", "gemini-2.0-flash-001", "gemini-2.0-pro-exp-02-05", "gemini-2.5-pro-exp-03-25"], + "models": ["claude-3-5-haiku@20241022", "claude-3-5-sonnet@20240620", "claude-3-5-sonnet-v2@20241022", "claude-3-7-sonnet@20250219", "gemini-1.5-pro-002", "gemini-2.0-flash-001", "gemini-2.0-pro-exp-02-05", "gemini-2.5-pro-exp-03-25", "gemini-2.5-flash-preview-05-20", "gemini-2.5-pro-preview-05-06"], "required_keys": ["GCP_PROJECT_ID", "GCP_LOCATION"] }, "google": { diff --git a/crates/goose/src/providers/formats/gcpvertexai.rs b/crates/goose/src/providers/formats/gcpvertexai.rs index 5bc94841..d83d1939 100644 --- a/crates/goose/src/providers/formats/gcpvertexai.rs +++ b/crates/goose/src/providers/formats/gcpvertexai.rs @@ -98,6 +98,10 @@ pub enum GeminiVersion { Pro20Exp, /// Gemini 2.5 Pro Experimental version Pro25Exp, + /// Gemini 2.5 Flash Preview version + Flash25Preview, + /// Gemini 2.5 Pro Preview version + Pro25Preview, /// Generic Gemini model for custom or new versions Generic(String), } @@ -118,6 +122,8 @@ impl fmt::Display for GcpVertexAIModel { GeminiVersion::Flash20 => "gemini-2.0-flash-001", GeminiVersion::Pro20Exp => "gemini-2.0-pro-exp-02-05", GeminiVersion::Pro25Exp => "gemini-2.5-pro-exp-03-25", + GeminiVersion::Flash25Preview => "gemini-2.5-flash-preview-05-20", + GeminiVersion::Pro25Preview => "gemini-2.5-pro-preview-05-06", GeminiVersion::Generic(name) => name, }, }; @@ -154,6 +160,8 @@ impl TryFrom<&str> for GcpVertexAIModel { "gemini-2.0-flash-001" => Ok(Self::Gemini(GeminiVersion::Flash20)), "gemini-2.0-pro-exp-02-05" => Ok(Self::Gemini(GeminiVersion::Pro20Exp)), "gemini-2.5-pro-exp-03-25" => Ok(Self::Gemini(GeminiVersion::Pro25Exp)), + "gemini-2.5-flash-preview-05-20" => Ok(Self::Gemini(GeminiVersion::Flash25Preview)), + "gemini-2.5-pro-preview-05-06" => Ok(Self::Gemini(GeminiVersion::Pro25Preview)), // Generic models based on prefix matching _ if s.starts_with("claude-") => { Ok(Self::Claude(ClaudeVersion::Generic(s.to_string()))) @@ -349,6 +357,8 @@ mod tests { "gemini-2.0-flash-001", "gemini-2.0-pro-exp-02-05", "gemini-2.5-pro-exp-03-25", + "gemini-2.5-flash-preview-05-20", + "gemini-2.5-pro-preview-05-06", ]; for model_id in valid_models { @@ -372,6 +382,8 @@ mod tests { ("gemini-2.0-flash-001", GcpLocation::Iowa), ("gemini-2.0-pro-exp-02-05", GcpLocation::Iowa), ("gemini-2.5-pro-exp-03-25", GcpLocation::Iowa), + ("gemini-2.5-flash-preview-05-20", GcpLocation::Iowa), + ("gemini-2.5-pro-preview-05-06", GcpLocation::Iowa), ]; for (model_id, expected_location) in test_cases { diff --git a/crates/goose/src/providers/gcpvertexai.rs b/crates/goose/src/providers/gcpvertexai.rs index afec862d..6385ec29 100644 --- a/crates/goose/src/providers/gcpvertexai.rs +++ b/crates/goose/src/providers/gcpvertexai.rs @@ -434,6 +434,9 @@ impl Provider for GcpVertexAIProvider { GcpVertexAIModel::Gemini(GeminiVersion::Pro15), GcpVertexAIModel::Gemini(GeminiVersion::Flash20), GcpVertexAIModel::Gemini(GeminiVersion::Pro20Exp), + GcpVertexAIModel::Gemini(GeminiVersion::Pro25Exp), + GcpVertexAIModel::Gemini(GeminiVersion::Flash25Preview), + GcpVertexAIModel::Gemini(GeminiVersion::Pro25Preview), ] .iter() .map(|model| model.to_string()) From 465a43cf51cf46606da1badd7b5b22e9c7f4dfd1 Mon Sep 17 00:00:00 2001 From: GitMurf <64155612+GitMurf@users.noreply.github.com> Date: Wed, 4 Jun 2025 12:46:27 -0700 Subject: [PATCH 30/40] fix(copilot): gh copilot auth token conflicts w/ gh mcp env var (#2743) --- crates/goose/src/providers/githubcopilot.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/goose/src/providers/githubcopilot.rs b/crates/goose/src/providers/githubcopilot.rs index 1f29a898..63a582aa 100644 --- a/crates/goose/src/providers/githubcopilot.rs +++ b/crates/goose/src/providers/githubcopilot.rs @@ -230,7 +230,7 @@ impl GithubCopilotProvider { async fn refresh_api_info(&self) -> Result { let config = Config::global(); - let token = match config.get_secret::("GITHUB_TOKEN") { + let token = match config.get_secret::("GITHUB_COPILOT_TOKEN") { Ok(token) => token, Err(err) => match err { ConfigError::NotFound(_) => { @@ -238,7 +238,7 @@ impl GithubCopilotProvider { .get_access_token() .await .context("unable to login into github")?; - config.set_secret("GITHUB_TOKEN", Value::String(token.clone()))?; + config.set_secret("GITHUB_COPILOT_TOKEN", Value::String(token.clone()))?; token } _ => return Err(err.into()), From d3359a12a4f5d014a9f7d00d613f1b7c5170856f Mon Sep 17 00:00:00 2001 From: Raduan Al-Shedivat <88370223+dbraduan@users.noreply.github.com> Date: Wed, 4 Jun 2025 23:27:21 +0200 Subject: [PATCH 31/40] cli(command): Add `export` command to CLI for markdown export of sessions (#2533) --- crates/goose-cli/src/cli.rs | 32 +- crates/goose-cli/src/commands/session.rs | 190 +++- crates/goose-cli/src/session/export.rs | 1095 ++++++++++++++++++++++ crates/goose-cli/src/session/mod.rs | 2 + 4 files changed, 1315 insertions(+), 4 deletions(-) create mode 100644 crates/goose-cli/src/session/export.rs diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 4e6deb47..027d9656 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -34,7 +34,7 @@ struct Cli { command: Option, } -#[derive(Args)] +#[derive(Args, Debug)] #[group(required = false, multiple = false)] struct Identifier { #[arg( @@ -102,6 +102,19 @@ enum SessionCommand { #[arg(short, long, help = "Regex for removing matched sessions (optional)")] regex: Option, }, + #[command(about = "Export a session to Markdown format")] + Export { + #[command(flatten)] + identifier: Option, + + #[arg( + short, + long, + help = "Output file path (default: stdout)", + long_help = "Path to save the exported Markdown. If not provided, output will be sent to stdout" + )] + output: Option, + }, } #[derive(Subcommand, Debug)] @@ -550,6 +563,23 @@ pub async fn cli() -> Result<()> { handle_session_remove(id, regex)?; return Ok(()); } + Some(SessionCommand::Export { identifier, output }) => { + let session_identifier = if let Some(id) = identifier { + extract_identifier(id) + } else { + // If no identifier is provided, prompt for interactive selection + match crate::commands::session::prompt_interactive_session_selection() { + Ok(id) => id, + Err(e) => { + eprintln!("Error: {}", e); + return Ok(()); + } + } + }; + + crate::commands::session::handle_session_export(session_identifier, output)?; + Ok(()) + } None => { // Run session command by default let mut session: crate::Session = build_session(SessionBuilderConfig { diff --git a/crates/goose-cli/src/commands/session.rs b/crates/goose-cli/src/commands/session.rs index add1572d..f3fb97e7 100644 --- a/crates/goose-cli/src/commands/session.rs +++ b/crates/goose-cli/src/commands/session.rs @@ -1,8 +1,11 @@ +use crate::session::message_to_markdown; use anyhow::{Context, Result}; -use cliclack::{confirm, multiselect}; +use cliclack::{confirm, multiselect, select}; use goose::session::info::{get_session_info, SessionInfo, SortOrder}; +use goose::session::{self, Identifier}; use regex::Regex; use std::fs; +use std::path::{Path, PathBuf}; const TRUNCATED_DESC_LENGTH: usize = 60; @@ -29,7 +32,7 @@ pub fn remove_sessions(sessions: Vec) -> Result<()> { Ok(()) } -fn prompt_interactive_session_selection(sessions: &[SessionInfo]) -> Result> { +fn prompt_interactive_session_removal(sessions: &[SessionInfo]) -> Result> { if sessions.is_empty() { println!("No sessions to delete."); return Ok(vec![]); @@ -105,7 +108,7 @@ pub fn handle_session_remove(id: Option, regex_string: Option) - if all_sessions.is_empty() { return Err(anyhow::anyhow!("No sessions found.")); } - matched_sessions = prompt_interactive_session_selection(&all_sessions)?; + matched_sessions = prompt_interactive_session_removal(&all_sessions)?; } if matched_sessions.is_empty() { @@ -165,3 +168,184 @@ pub fn handle_session_list(verbose: bool, format: String, ascending: bool) -> Re } Ok(()) } + +/// Export a session to Markdown without creating a full Session object +/// +/// This function directly reads messages from the session file and converts them to Markdown +/// without creating an Agent or prompting about working directories. +pub fn handle_session_export(identifier: Identifier, output_path: Option) -> Result<()> { + // Get the session file path + let session_file_path = goose::session::get_path(identifier.clone()); + + if !session_file_path.exists() { + return Err(anyhow::anyhow!( + "Session file not found (expected path: {})", + session_file_path.display() + )); + } + + // Read messages directly without using Session + let messages = match goose::session::read_messages(&session_file_path) { + Ok(msgs) => msgs, + Err(e) => { + return Err(anyhow::anyhow!("Failed to read session messages: {}", e)); + } + }; + + // Generate the markdown content using the export functionality + let markdown = export_session_to_markdown(messages, &session_file_path, None); + + // Output the markdown + if let Some(output) = output_path { + fs::write(&output, markdown) + .with_context(|| format!("Failed to write to output file: {}", output.display()))?; + println!("Session exported to {}", output.display()); + } else { + println!("{}", markdown); + } + + Ok(()) +} + +/// Convert a list of messages to markdown format for session export +/// +/// This function handles the formatting of a complete session including headers, +/// message organization, and proper tool request/response pairing. +fn export_session_to_markdown( + messages: Vec, + session_file: &Path, + session_name_override: Option<&str>, +) -> String { + let mut markdown_output = String::new(); + + let session_name = session_name_override.unwrap_or_else(|| { + session_file + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("Unnamed Session") + }); + + markdown_output.push_str(&format!("# Session Export: {}\n\n", session_name)); + + if messages.is_empty() { + markdown_output.push_str("*(This session has no messages)*\n"); + return markdown_output; + } + + markdown_output.push_str(&format!("*Total messages: {}*\n\n---\n\n", messages.len())); + + // Track if the last message had tool requests to properly handle tool responses + let mut skip_next_if_tool_response = false; + + for message in &messages { + // Check if this is a User message containing only ToolResponses + let is_only_tool_response = message.role == mcp_core::role::Role::User + && message + .content + .iter() + .all(|content| matches!(content, goose::message::MessageContent::ToolResponse(_))); + + // If the previous message had tool requests and this one is just tool responses, + // don't create a new User section - we'll attach the responses to the tool calls + if skip_next_if_tool_response && is_only_tool_response { + // Export the tool responses without a User heading + markdown_output.push_str(&message_to_markdown(message, false)); + markdown_output.push_str("\n\n---\n\n"); + skip_next_if_tool_response = false; + continue; + } + + // Reset the skip flag - we'll update it below if needed + skip_next_if_tool_response = false; + + // Output the role prefix except for tool response-only messages + if !is_only_tool_response { + let role_prefix = match message.role { + mcp_core::role::Role::User => "### User:\n", + mcp_core::role::Role::Assistant => "### Assistant:\n", + }; + markdown_output.push_str(role_prefix); + } + + // Add the message content + markdown_output.push_str(&message_to_markdown(message, false)); + markdown_output.push_str("\n\n---\n\n"); + + // Check if this message has any tool requests, to handle the next message differently + if message + .content + .iter() + .any(|content| matches!(content, goose::message::MessageContent::ToolRequest(_))) + { + skip_next_if_tool_response = true; + } + } + + markdown_output +} + +/// Prompt the user to interactively select a session +/// +/// Shows a list of available sessions and lets the user select one +pub fn prompt_interactive_session_selection() -> Result { + // Get sessions sorted by modification date (newest first) + let sessions = match get_session_info(SortOrder::Descending) { + Ok(sessions) => sessions, + Err(e) => { + tracing::error!("Failed to list sessions: {:?}", e); + return Err(anyhow::anyhow!("Failed to list sessions")); + } + }; + + if sessions.is_empty() { + return Err(anyhow::anyhow!("No sessions found")); + } + + // Build the selection prompt + let mut selector = select("Select a session to export:"); + + // Map to display text + let display_map: std::collections::HashMap = sessions + .iter() + .map(|s| { + let desc = if s.metadata.description.is_empty() { + "(no description)" + } else { + &s.metadata.description + }; + + // Truncate description if too long + let truncated_desc = if desc.len() > 40 { + format!("{}...", &desc[..37]) + } else { + desc.to_string() + }; + + let display_text = format!("{} - {} ({})", s.modified, truncated_desc, s.id); + (display_text, s.clone()) + }) + .collect(); + + // Add each session as an option + for display_text in display_map.keys() { + selector = selector.item(display_text.clone(), display_text.clone(), ""); + } + + // Add a cancel option + let cancel_value = String::from("cancel"); + selector = selector.item(cancel_value, "Cancel", "Cancel export"); + + // Get user selection + let selected_display_text: String = selector.interact()?; + + if selected_display_text == "cancel" { + return Err(anyhow::anyhow!("Export canceled")); + } + + // Retrieve the selected session + if let Some(session) = display_map.get(&selected_display_text) { + Ok(goose::session::Identifier::Name(session.id.clone())) + } else { + Err(anyhow::anyhow!("Invalid selection")) + } +} diff --git a/crates/goose-cli/src/session/export.rs b/crates/goose-cli/src/session/export.rs new file mode 100644 index 00000000..57b83b1e --- /dev/null +++ b/crates/goose-cli/src/session/export.rs @@ -0,0 +1,1095 @@ +use goose::message::{Message, MessageContent, ToolRequest, ToolResponse}; +use mcp_core::content::Content as McpContent; +use mcp_core::resource::ResourceContents; +use mcp_core::role::Role; +use serde_json::Value; + +const MAX_STRING_LENGTH_MD_EXPORT: usize = 4096; // Generous limit for export +const REDACTED_PREFIX_LENGTH: usize = 100; // Show first 100 chars before trimming + +fn value_to_simple_markdown_string(value: &Value, export_full_strings: bool) -> String { + match value { + Value::String(s) => { + if !export_full_strings && s.len() > MAX_STRING_LENGTH_MD_EXPORT { + let prefix = &s[..REDACTED_PREFIX_LENGTH.min(s.len())]; + let trimmed_chars = s.len() - prefix.len(); + format!("`{}[ ... trimmed : {} chars ... ]`", prefix, trimmed_chars) + } else { + // Escape backticks and newlines for inline code. + let escaped = s.replace('`', "\\`").replace("\n", "\\\\n"); + format!("`{}`", escaped) + } + } + Value::Number(n) => n.to_string(), + Value::Bool(b) => format!("*{}*", b), + Value::Null => "_null_".to_string(), + _ => "`[Complex Value]`".to_string(), + } +} + +fn value_to_markdown(value: &Value, depth: usize, export_full_strings: bool) -> String { + let mut md_string = String::new(); + let base_indent_str = " ".repeat(depth); // Basic indentation for nesting + + match value { + Value::Object(map) => { + if map.is_empty() { + md_string.push_str(&format!("{}*empty object*\n", base_indent_str)); + } else { + for (key, val) in map { + md_string.push_str(&format!("{}* **{}**: ", base_indent_str, key)); + match val { + Value::String(s) => { + if s.contains('\n') || s.len() > 80 { + // Heuristic for block + md_string.push_str(&format!( + "\n{} ```\n{}{}\n{} ```\n", + base_indent_str, + base_indent_str, + s.trim(), + base_indent_str + )); + } else { + md_string.push_str(&format!("`{}`\n", s.replace('`', "\\`"))); + } + } + _ => { + // Use recursive call for all values including complex objects/arrays + md_string.push('\n'); + md_string.push_str(&value_to_markdown( + val, + depth + 2, + export_full_strings, + )); + } + } + } + } + } + Value::Array(arr) => { + if arr.is_empty() { + md_string.push_str(&format!("{}* *empty list*\n", base_indent_str)); + } else { + for item in arr { + md_string.push_str(&format!("{}* - ", base_indent_str)); + match item { + Value::String(s) => { + if s.contains('\n') || s.len() > 80 { + // Heuristic for block + md_string.push_str(&format!( + "\n{} ```\n{}{}\n{} ```\n", + base_indent_str, + base_indent_str, + s.trim(), + base_indent_str + )); + } else { + md_string.push_str(&format!("`{}`\n", s.replace('`', "\\`"))); + } + } + _ => { + // Use recursive call for all values including complex objects/arrays + md_string.push('\n'); + md_string.push_str(&value_to_markdown( + item, + depth + 2, + export_full_strings, + )); + } + } + } + } + } + _ => { + md_string.push_str(&format!( + "{}{}\n", + base_indent_str, + value_to_simple_markdown_string(value, export_full_strings) + )); + } + } + md_string +} + +pub fn tool_request_to_markdown(req: &ToolRequest, export_all_content: bool) -> String { + let mut md = String::new(); + match &req.tool_call { + Ok(call) => { + let parts: Vec<_> = call.name.rsplitn(2, "__").collect(); + let (namespace, tool_name_only) = if parts.len() == 2 { + (parts[1], parts[0]) + } else { + ("Tool", parts[0]) + }; + + md.push_str(&format!( + "#### Tool Call: `{}` (namespace: `{}`)\n", + tool_name_only, namespace + )); + md.push_str("**Arguments:**\n"); + + match call.name.as_str() { + "developer__shell" => { + if let Some(Value::String(command)) = call.arguments.get("command") { + md.push_str(&format!( + "* **command**:\n ```sh\n {}\n ```\n", + command.trim() + )); + } + let other_args: serde_json::Map = call + .arguments + .as_object() + .map(|obj| { + obj.iter() + .filter(|(k, _)| k.as_str() != "command") + .map(|(k, v)| (k.clone(), v.clone())) + .collect() + }) + .unwrap_or_default(); + if !other_args.is_empty() { + md.push_str(&value_to_markdown( + &Value::Object(other_args), + 0, + export_all_content, + )); + } + } + "developer__text_editor" => { + if let Some(Value::String(path)) = call.arguments.get("path") { + md.push_str(&format!("* **path**: `{}`\n", path)); + } + if let Some(Value::String(code_edit)) = call.arguments.get("code_edit") { + md.push_str(&format!( + "* **code_edit**:\n ```\n{}\n ```\n", + code_edit + )); + } + + let other_args: serde_json::Map = call + .arguments + .as_object() + .map(|obj| { + obj.iter() + .filter(|(k, _)| k.as_str() != "path" && k.as_str() != "code_edit") + .map(|(k, v)| (k.clone(), v.clone())) + .collect() + }) + .unwrap_or_default(); + if !other_args.is_empty() { + md.push_str(&value_to_markdown( + &Value::Object(other_args), + 0, + export_all_content, + )); + } + } + _ => { + md.push_str(&value_to_markdown(&call.arguments, 0, export_all_content)); + } + } + } + Err(e) => { + md.push_str(&format!( + "**Error in Tool Call:**\n```\n{} +```\n", + e + )); + } + } + md +} + +pub fn tool_response_to_markdown(resp: &ToolResponse, export_all_content: bool) -> String { + let mut md = String::new(); + md.push_str("#### Tool Response:\n"); + + match &resp.tool_result { + Ok(contents) => { + if contents.is_empty() { + md.push_str("*No textual output from tool.*\n"); + } + + for content in contents { + if !export_all_content { + if let Some(audience) = content.audience() { + if !audience.contains(&Role::Assistant) { + continue; + } + } + } + + match content { + McpContent::Text(text_content) => { + let trimmed_text = text_content.text.trim(); + if (trimmed_text.starts_with('{') && trimmed_text.ends_with('}')) + || (trimmed_text.starts_with('[') && trimmed_text.ends_with(']')) + { + md.push_str(&format!("```json\n{}\n```\n", trimmed_text)); + } else if trimmed_text.starts_with('<') + && trimmed_text.ends_with('>') + && trimmed_text.contains(" { + if image_content.mime_type.starts_with("image/") { + // For actual images, provide a placeholder that indicates it's an image + md.push_str(&format!( + "**Image:** `(type: {}, data: first 30 chars of base64...)`\n\n", + image_content.mime_type + )); + } else { + // For non-image mime types, just indicate it's binary data + md.push_str(&format!( + "**Binary Content:** `(type: {}, length: {} bytes)`\n\n", + image_content.mime_type, + image_content.data.len() + )); + } + } + McpContent::Resource(resource) => { + match &resource.resource { + ResourceContents::TextResourceContents { + uri, + mime_type, + text, + } => { + // Extract file extension from the URI for syntax highlighting + let file_extension = uri.split('.').next_back().unwrap_or(""); + let syntax_type = match file_extension { + "rs" => "rust", + "js" => "javascript", + "ts" => "typescript", + "py" => "python", + "json" => "json", + "yaml" | "yml" => "yaml", + "md" => "markdown", + "html" => "html", + "css" => "css", + "sh" => "bash", + _ => mime_type + .as_ref() + .map(|mime| if mime == "text" { "" } else { mime }) + .unwrap_or(""), + }; + + md.push_str(&format!("**File:** `{}`\n", uri)); + md.push_str(&format!( + "```{}\n{}\n```\n\n", + syntax_type, + text.trim() + )); + } + ResourceContents::BlobResourceContents { + uri, + mime_type, + blob, + } => { + md.push_str(&format!( + "**Binary File:** `{}` (type: {}, {} bytes)\n\n", + uri, + mime_type.as_ref().map(|s| s.as_str()).unwrap_or("unknown"), + blob.len() + )); + } + } + } + } + } + } + Err(e) => { + md.push_str(&format!( + "**Error in Tool Response:**\n```\n{} +```\n", + e + )); + } + } + md +} + +pub fn message_to_markdown(message: &Message, export_all_content: bool) -> String { + let mut md = String::new(); + for content in &message.content { + match content { + MessageContent::Text(text) => { + md.push_str(&text.text); + md.push_str("\n\n"); + } + MessageContent::ToolRequest(req) => { + md.push_str(&tool_request_to_markdown(req, export_all_content)); + md.push('\n'); + } + MessageContent::ToolResponse(resp) => { + md.push_str(&tool_response_to_markdown(resp, export_all_content)); + md.push('\n'); + } + MessageContent::Image(image) => { + md.push_str(&format!( + "**Image:** `(type: {}, data placeholder: {}...)`\n\n", + image.mime_type, + image.data.chars().take(30).collect::() + )); + } + MessageContent::Thinking(thinking) => { + md.push_str("**Thinking:**\n"); + md.push_str("> "); + md.push_str(&thinking.thinking.replace("\n", "\n> ")); + md.push_str("\n\n"); + } + MessageContent::RedactedThinking(_) => { + md.push_str("**Thinking:**\n"); + md.push_str("> *Thinking was redacted*\n\n"); + } + _ => { + md.push_str( + "`WARNING: Message content type could not be rendered to Markdown`\n\n", + ); + } + } + } + md.trim_end_matches("\n").to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use goose::message::{Message, ToolRequest, ToolResponse}; + use mcp_core::content::{Content as McpContent, TextContent}; + use mcp_core::tool::ToolCall; + use serde_json::json; + + #[test] + fn test_value_to_simple_markdown_string_normal() { + let value = json!("hello world"); + let result = value_to_simple_markdown_string(&value, true); + assert_eq!(result, "`hello world`"); + } + + #[test] + fn test_value_to_simple_markdown_string_with_backticks() { + let value = json!("hello `world`"); + let result = value_to_simple_markdown_string(&value, true); + assert_eq!(result, "`hello \\`world\\``"); + } + + #[test] + fn test_value_to_simple_markdown_string_long_string_full_export() { + let long_string = "a".repeat(5000); + let value = json!(long_string); + let result = value_to_simple_markdown_string(&value, true); + // When export_full_strings is true, should return full string + assert!(result.starts_with("`")); + assert!(result.ends_with("`")); + assert!(result.contains(&"a".repeat(5000))); + } + + #[test] + fn test_value_to_simple_markdown_string_long_string_trimmed() { + let long_string = "a".repeat(5000); + let value = json!(long_string); + let result = value_to_simple_markdown_string(&value, false); + // When export_full_strings is false, should trim long strings + assert!(result.starts_with("`")); + assert!(result.contains("[ ... trimmed : ")); + assert!(result.contains("4900 chars ... ]`")); + assert!(result.contains(&"a".repeat(100))); // Should contain the prefix + } + + #[test] + fn test_value_to_simple_markdown_string_numbers_and_bools() { + assert_eq!(value_to_simple_markdown_string(&json!(42), true), "42"); + assert_eq!( + value_to_simple_markdown_string(&json!(true), true), + "*true*" + ); + assert_eq!( + value_to_simple_markdown_string(&json!(false), true), + "*false*" + ); + assert_eq!( + value_to_simple_markdown_string(&json!(null), true), + "_null_" + ); + } + + #[test] + fn test_value_to_markdown_empty_object() { + let value = json!({}); + let result = value_to_markdown(&value, 0, true); + assert!(result.contains("*empty object*")); + } + + #[test] + fn test_value_to_markdown_empty_array() { + let value = json!([]); + let result = value_to_markdown(&value, 0, true); + assert!(result.contains("*empty list*")); + } + + #[test] + fn test_value_to_markdown_simple_object() { + let value = json!({ + "name": "test", + "count": 42, + "active": true + }); + let result = value_to_markdown(&value, 0, true); + assert!(result.contains("**name**")); + assert!(result.contains("`test`")); + assert!(result.contains("**count**")); + assert!(result.contains("42")); + assert!(result.contains("**active**")); + assert!(result.contains("*true*")); + } + + #[test] + fn test_value_to_markdown_nested_object() { + let value = json!({ + "user": { + "name": "Alice", + "age": 30 + } + }); + let result = value_to_markdown(&value, 0, true); + assert!(result.contains("**user**")); + assert!(result.contains("**name**")); + assert!(result.contains("`Alice`")); + assert!(result.contains("**age**")); + assert!(result.contains("30")); + } + + #[test] + fn test_value_to_markdown_array_with_items() { + let value = json!(["item1", "item2", 42]); + let result = value_to_markdown(&value, 0, true); + assert!(result.contains("- `item1`")); + assert!(result.contains("- `item2`")); + // Numbers are handled by recursive call, so they get formatted differently + assert!(result.contains("42")); + } + + #[test] + fn test_tool_request_to_markdown_shell() { + let tool_call = ToolCall { + name: "developer__shell".to_string(), + arguments: json!({ + "command": "ls -la", + "working_dir": "/home/user" + }), + }; + let tool_request = ToolRequest { + id: "test-id".to_string(), + tool_call: Ok(tool_call), + }; + + let result = tool_request_to_markdown(&tool_request, true); + assert!(result.contains("#### Tool Call: `shell`")); + assert!(result.contains("namespace: `developer`")); + assert!(result.contains("**command**:")); + assert!(result.contains("```sh")); + assert!(result.contains("ls -la")); + assert!(result.contains("**working_dir**")); + } + + #[test] + fn test_tool_request_to_markdown_text_editor() { + let tool_call = ToolCall { + name: "developer__text_editor".to_string(), + arguments: json!({ + "path": "/path/to/file.txt", + "code_edit": "print('Hello World')" + }), + }; + let tool_request = ToolRequest { + id: "test-id".to_string(), + tool_call: Ok(tool_call), + }; + + let result = tool_request_to_markdown(&tool_request, true); + assert!(result.contains("#### Tool Call: `text_editor`")); + assert!(result.contains("**path**: `/path/to/file.txt`")); + assert!(result.contains("**code_edit**:")); + assert!(result.contains("print('Hello World')")); + } + + #[test] + fn test_tool_response_to_markdown_text() { + let text_content = TextContent { + text: "Command executed successfully".to_string(), + annotations: None, + }; + let tool_response = ToolResponse { + id: "test-id".to_string(), + tool_result: Ok(vec![McpContent::Text(text_content)]), + }; + + let result = tool_response_to_markdown(&tool_response, true); + assert!(result.contains("#### Tool Response:")); + assert!(result.contains("Command executed successfully")); + } + + #[test] + fn test_tool_response_to_markdown_json() { + let json_text = r#"{"status": "success", "data": "test"}"#; + let text_content = TextContent { + text: json_text.to_string(), + annotations: None, + }; + let tool_response = ToolResponse { + id: "test-id".to_string(), + tool_result: Ok(vec![McpContent::Text(text_content)]), + }; + + let result = tool_response_to_markdown(&tool_response, true); + assert!(result.contains("#### Tool Response:")); + assert!(result.contains("```json")); + assert!(result.contains(json_text)); + } + + #[test] + fn test_message_to_markdown_text() { + let message = Message::user().with_text("Hello, this is a test message"); + + let result = message_to_markdown(&message, true); + assert_eq!(result, "Hello, this is a test message"); + } + + #[test] + fn test_message_to_markdown_with_tool_request() { + let tool_call = ToolCall { + name: "test_tool".to_string(), + arguments: json!({"param": "value"}), + }; + + let message = Message::assistant().with_tool_request("test-id", Ok(tool_call)); + + let result = message_to_markdown(&message, true); + assert!(result.contains("#### Tool Call: `test_tool`")); + assert!(result.contains("**param**")); + } + + #[test] + fn test_message_to_markdown_thinking() { + let message = Message::assistant() + .with_thinking("I need to analyze this problem...", "test-signature"); + + let result = message_to_markdown(&message, true); + assert!(result.contains("**Thinking:**")); + assert!(result.contains("> I need to analyze this problem...")); + } + + #[test] + fn test_message_to_markdown_redacted_thinking() { + let message = Message::assistant().with_redacted_thinking("redacted-data"); + + let result = message_to_markdown(&message, true); + assert!(result.contains("**Thinking:**")); + assert!(result.contains("> *Thinking was redacted*")); + } + + #[test] + fn test_recursive_value_to_markdown() { + // Test that complex nested structures are properly handled with recursion + let value = json!({ + "level1": { + "level2": { + "data": "nested value" + }, + "array": [ + {"item": "first"}, + {"item": "second"} + ] + } + }); + + let result = value_to_markdown(&value, 0, true); + assert!(result.contains("**level1**")); + assert!(result.contains("**level2**")); + assert!(result.contains("**data**")); + assert!(result.contains("`nested value`")); + assert!(result.contains("**array**")); + assert!(result.contains("**item**")); + assert!(result.contains("`first`")); + assert!(result.contains("`second`")); + } + + #[test] + fn test_shell_tool_with_code_output() { + let tool_call = ToolCall { + name: "developer__shell".to_string(), + arguments: json!({ + "command": "cat main.py" + }), + }; + let tool_request = ToolRequest { + id: "shell-cat".to_string(), + tool_call: Ok(tool_call), + }; + + let python_code = r#"#!/usr/bin/env python3 +def hello_world(): + print("Hello, World!") + +if __name__ == "__main__": + hello_world()"#; + + let text_content = TextContent { + text: python_code.to_string(), + annotations: None, + }; + let tool_response = ToolResponse { + id: "shell-cat".to_string(), + tool_result: Ok(vec![McpContent::Text(text_content)]), + }; + + let request_result = tool_request_to_markdown(&tool_request, true); + let response_result = tool_response_to_markdown(&tool_response, true); + + // Check request formatting + assert!(request_result.contains("#### Tool Call: `shell`")); + assert!(request_result.contains("```sh")); + assert!(request_result.contains("cat main.py")); + + // Check response formatting - text content is output as plain text + assert!(response_result.contains("#### Tool Response:")); + assert!(response_result.contains("def hello_world():")); + assert!(response_result.contains("print(\"Hello, World!\")")); + } + + #[test] + fn test_shell_tool_with_git_commands() { + let git_status_call = ToolCall { + name: "developer__shell".to_string(), + arguments: json!({ + "command": "git status --porcelain" + }), + }; + let tool_request = ToolRequest { + id: "git-status".to_string(), + tool_call: Ok(git_status_call), + }; + + let git_output = " M src/main.rs\n?? temp.txt\n A new_feature.rs"; + let text_content = TextContent { + text: git_output.to_string(), + annotations: None, + }; + let tool_response = ToolResponse { + id: "git-status".to_string(), + tool_result: Ok(vec![McpContent::Text(text_content)]), + }; + + let request_result = tool_request_to_markdown(&tool_request, true); + let response_result = tool_response_to_markdown(&tool_response, true); + + // Check request formatting + assert!(request_result.contains("git status --porcelain")); + assert!(request_result.contains("```sh")); + + // Check response formatting - git output as plain text + assert!(response_result.contains("M src/main.rs")); + assert!(response_result.contains("?? temp.txt")); + } + + #[test] + fn test_shell_tool_with_build_output() { + let cargo_build_call = ToolCall { + name: "developer__shell".to_string(), + arguments: json!({ + "command": "cargo build" + }), + }; + let _tool_request = ToolRequest { + id: "cargo-build".to_string(), + tool_call: Ok(cargo_build_call), + }; + + let build_output = r#" Compiling goose-cli v0.1.0 (/Users/user/goose) +warning: unused variable `x` + --> src/main.rs:10:9 + | +10 | let x = 5; + | ^ help: if this is intentional, prefix it with an underscore: `_x` + | + = note: `#[warn(unused_variables)]` on by default + + Finished dev [unoptimized + debuginfo] target(s) in 2.45s"#; + + let text_content = TextContent { + text: build_output.to_string(), + annotations: None, + }; + let tool_response = ToolResponse { + id: "cargo-build".to_string(), + tool_result: Ok(vec![McpContent::Text(text_content)]), + }; + + let response_result = tool_response_to_markdown(&tool_response, true); + + // Should format as plain text since it's build output, not code + assert!(response_result.contains("Compiling goose-cli")); + assert!(response_result.contains("warning: unused variable")); + assert!(response_result.contains("Finished dev")); + } + + #[test] + fn test_shell_tool_with_json_api_response() { + let curl_call = ToolCall { + name: "developer__shell".to_string(), + arguments: json!({ + "command": "curl -s https://api.github.com/repos/microsoft/vscode/releases/latest" + }), + }; + let _tool_request = ToolRequest { + id: "curl-api".to_string(), + tool_call: Ok(curl_call), + }; + + let api_response = r#"{ + "url": "https://api.github.com/repos/microsoft/vscode/releases/90543298", + "tag_name": "1.85.0", + "name": "1.85.0", + "published_at": "2023-12-07T16:54:32Z", + "assets": [ + { + "name": "VSCode-darwin-universal.zip", + "download_count": 123456 + } + ] +}"#; + + let text_content = TextContent { + text: api_response.to_string(), + annotations: None, + }; + let tool_response = ToolResponse { + id: "curl-api".to_string(), + tool_result: Ok(vec![McpContent::Text(text_content)]), + }; + + let response_result = tool_response_to_markdown(&tool_response, true); + + // Should detect and format as JSON + assert!(response_result.contains("```json")); + assert!(response_result.contains("\"tag_name\": \"1.85.0\"")); + assert!(response_result.contains("\"download_count\": 123456")); + } + + #[test] + fn test_text_editor_tool_with_code_creation() { + let editor_call = ToolCall { + name: "developer__text_editor".to_string(), + arguments: json!({ + "command": "write", + "path": "/tmp/fibonacci.js", + "file_text": "function fibonacci(n) {\n if (n <= 1) return n;\n return fibonacci(n - 1) + fibonacci(n - 2);\n}\n\nconsole.log(fibonacci(10));" + }), + }; + let tool_request = ToolRequest { + id: "editor-write".to_string(), + tool_call: Ok(editor_call), + }; + + let text_content = TextContent { + text: "File created successfully".to_string(), + annotations: None, + }; + let tool_response = ToolResponse { + id: "editor-write".to_string(), + tool_result: Ok(vec![McpContent::Text(text_content)]), + }; + + let request_result = tool_request_to_markdown(&tool_request, true); + let response_result = tool_response_to_markdown(&tool_response, true); + + // Check request formatting - should format code in file_text properly + assert!(request_result.contains("#### Tool Call: `text_editor`")); + assert!(request_result.contains("**path**: `/tmp/fibonacci.js`")); + assert!(request_result.contains("**file_text**:")); + assert!(request_result.contains("function fibonacci(n)")); + assert!(request_result.contains("return fibonacci(n - 1)")); + + // Check response formatting + assert!(response_result.contains("File created successfully")); + } + + #[test] + fn test_text_editor_tool_view_code() { + let editor_call = ToolCall { + name: "developer__text_editor".to_string(), + arguments: json!({ + "command": "view", + "path": "/src/utils.py" + }), + }; + let _tool_request = ToolRequest { + id: "editor-view".to_string(), + tool_call: Ok(editor_call), + }; + + let python_code = r#"import os +import json +from typing import Dict, List, Optional + +def load_config(config_path: str) -> Dict: + """Load configuration from JSON file.""" + if not os.path.exists(config_path): + raise FileNotFoundError(f"Config file not found: {config_path}") + + with open(config_path, 'r') as f: + return json.load(f) + +def process_data(data: List[Dict]) -> List[Dict]: + """Process a list of data dictionaries.""" + return [item for item in data if item.get('active', False)]"#; + + let text_content = TextContent { + text: python_code.to_string(), + annotations: None, + }; + let tool_response = ToolResponse { + id: "editor-view".to_string(), + tool_result: Ok(vec![McpContent::Text(text_content)]), + }; + + let response_result = tool_response_to_markdown(&tool_response, true); + + // Text content is output as plain text + assert!(response_result.contains("import os")); + assert!(response_result.contains("def load_config")); + assert!(response_result.contains("typing import Dict")); + } + + #[test] + fn test_shell_tool_with_error_output() { + let error_call = ToolCall { + name: "developer__shell".to_string(), + arguments: json!({ + "command": "python nonexistent_script.py" + }), + }; + let _tool_request = ToolRequest { + id: "shell-error".to_string(), + tool_call: Ok(error_call), + }; + + let error_output = r#"python: can't open file 'nonexistent_script.py': [Errno 2] No such file or directory +Command failed with exit code 2"#; + + let text_content = TextContent { + text: error_output.to_string(), + annotations: None, + }; + let tool_response = ToolResponse { + id: "shell-error".to_string(), + tool_result: Ok(vec![McpContent::Text(text_content)]), + }; + + let response_result = tool_response_to_markdown(&tool_response, true); + + // Error output should be formatted as plain text + assert!(response_result.contains("can't open file")); + assert!(response_result.contains("Command failed with exit code 2")); + } + + #[test] + fn test_shell_tool_complex_script_execution() { + let script_call = ToolCall { + name: "developer__shell".to_string(), + arguments: json!({ + "command": "python -c \"import sys; print(f'Python {sys.version}'); [print(f'{i}^2 = {i**2}') for i in range(1, 6)]\"" + }), + }; + let tool_request = ToolRequest { + id: "script-exec".to_string(), + tool_call: Ok(script_call), + }; + + let script_output = r#"Python 3.11.5 (main, Aug 24 2023, 15:18:16) [Clang 14.0.3 ] +1^2 = 1 +2^2 = 4 +3^2 = 9 +4^2 = 16 +5^2 = 25"#; + + let text_content = TextContent { + text: script_output.to_string(), + annotations: None, + }; + let tool_response = ToolResponse { + id: "script-exec".to_string(), + tool_result: Ok(vec![McpContent::Text(text_content)]), + }; + + let request_result = tool_request_to_markdown(&tool_request, true); + let response_result = tool_response_to_markdown(&tool_response, true); + + // Check request formatting for complex command + assert!(request_result.contains("```sh")); + assert!(request_result.contains("python -c")); + assert!(request_result.contains("sys.version")); + + // Check response formatting + assert!(response_result.contains("Python 3.11.5")); + assert!(response_result.contains("1^2 = 1")); + assert!(response_result.contains("5^2 = 25")); + } + + #[test] + fn test_shell_tool_with_multi_command() { + let multi_call = ToolCall { + name: "developer__shell".to_string(), + arguments: json!({ + "command": "cd /tmp && ls -la | head -5 && pwd" + }), + }; + let _tool_request = ToolRequest { + id: "multi-cmd".to_string(), + tool_call: Ok(multi_call), + }; + + let multi_output = r#"total 24 +drwxrwxrwt 15 root wheel 480 Dec 7 10:30 . +drwxr-xr-x 6 root wheel 192 Nov 15 09:15 .. +-rw-r--r-- 1 user staff 256 Dec 7 09:45 config.json +drwx------ 3 user staff 96 Dec 6 16:20 com.apple.launchd.abc +/tmp"#; + + let text_content = TextContent { + text: multi_output.to_string(), + annotations: None, + }; + let tool_response = ToolResponse { + id: "multi-cmd".to_string(), + tool_result: Ok(vec![McpContent::Text(text_content)]), + }; + + let request_result = tool_request_to_markdown(&_tool_request, true); + let response_result = tool_response_to_markdown(&tool_response, true); + + // Check request formatting for chained commands + assert!(request_result.contains("cd /tmp && ls -la | head -5 && pwd")); + + // Check response formatting + assert!(response_result.contains("drwxrwxrwt")); + assert!(response_result.contains("config.json")); + assert!(response_result.contains("/tmp")); + } + + #[test] + fn test_developer_tool_grep_code_search() { + let grep_call = ToolCall { + name: "developer__shell".to_string(), + arguments: json!({ + "command": "rg 'async fn' --type rust -n" + }), + }; + let tool_request = ToolRequest { + id: "grep-search".to_string(), + tool_call: Ok(grep_call), + }; + + let grep_output = r#"src/main.rs:15:async fn process_request(req: Request) -> Result { +src/handler.rs:8:async fn handle_connection(stream: TcpStream) { +src/database.rs:23:async fn query_users(pool: &Pool) -> Result> { +src/middleware.rs:12:async fn auth_middleware(req: Request, next: Next) -> Result {"#; + + let text_content = TextContent { + text: grep_output.to_string(), + annotations: None, + }; + let tool_response = ToolResponse { + id: "grep-search".to_string(), + tool_result: Ok(vec![McpContent::Text(text_content)]), + }; + + let request_result = tool_request_to_markdown(&tool_request, true); + let response_result = tool_response_to_markdown(&tool_response, true); + + // Check request formatting + assert!(request_result.contains("rg 'async fn' --type rust -n")); + + // Check response formatting - should be formatted as search results + assert!(response_result.contains("src/main.rs:15:")); + assert!(response_result.contains("async fn process_request")); + assert!(response_result.contains("src/database.rs:23:")); + } + + #[test] + fn test_shell_tool_json_detection_works() { + // This test shows that JSON detection in tool responses DOES work + let tool_call = ToolCall { + name: "developer__shell".to_string(), + arguments: json!({ + "command": "echo '{\"test\": \"json\"}'" + }), + }; + let _tool_request = ToolRequest { + id: "json-test".to_string(), + tool_call: Ok(tool_call), + }; + + let json_output = r#"{"status": "success", "data": {"count": 42}}"#; + let text_content = TextContent { + text: json_output.to_string(), + annotations: None, + }; + let tool_response = ToolResponse { + id: "json-test".to_string(), + tool_result: Ok(vec![McpContent::Text(text_content)]), + }; + + let response_result = tool_response_to_markdown(&tool_response, true); + + // JSON should be auto-detected and formatted + assert!(response_result.contains("```json")); + assert!(response_result.contains("\"status\": \"success\"")); + assert!(response_result.contains("\"count\": 42")); + } + + #[test] + fn test_shell_tool_with_package_management() { + let npm_call = ToolCall { + name: "developer__shell".to_string(), + arguments: json!({ + "command": "npm install express typescript @types/node --save-dev" + }), + }; + let tool_request = ToolRequest { + id: "npm-install".to_string(), + tool_call: Ok(npm_call), + }; + + let npm_output = r#"added 57 packages, and audited 58 packages in 3s + +8 packages are looking for funding + run `npm fund` for details + +found 0 vulnerabilities"#; + + let text_content = TextContent { + text: npm_output.to_string(), + annotations: None, + }; + let tool_response = ToolResponse { + id: "npm-install".to_string(), + tool_result: Ok(vec![McpContent::Text(text_content)]), + }; + + let request_result = tool_request_to_markdown(&tool_request, true); + let response_result = tool_response_to_markdown(&tool_response, true); + + // Check request formatting + assert!(request_result.contains("npm install express typescript")); + assert!(request_result.contains("--save-dev")); + + // Check response formatting + assert!(response_result.contains("added 57 packages")); + assert!(response_result.contains("found 0 vulnerabilities")); + } +} diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index d980ba7c..23e2735d 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -1,10 +1,12 @@ mod builder; mod completion; +mod export; mod input; mod output; mod prompt; mod thinking; +pub use self::export::message_to_markdown; pub use builder::{build_session, SessionBuilderConfig}; use console::Color; use goose::agents::AgentEvent; From db975fe4bc351caaa2a0c9e256e1f39ad616e2b9 Mon Sep 17 00:00:00 2001 From: Ebony Louis <55366651+EbonyLouis@users.noreply.github.com> Date: Wed, 4 Jun 2025 17:47:27 -0400 Subject: [PATCH 32/40] docs: Add Context7 YouTube Video (#2779) --- documentation/docs/tutorials/context7-mcp.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/docs/tutorials/context7-mcp.mdx b/documentation/docs/tutorials/context7-mcp.mdx index ec2276d0..06d6a62a 100644 --- a/documentation/docs/tutorials/context7-mcp.mdx +++ b/documentation/docs/tutorials/context7-mcp.mdx @@ -10,7 +10,7 @@ import YouTubeShortEmbed from '@site/src/components/YouTubeShortEmbed'; import CLIExtensionInstructions from '@site/src/components/CLIExtensionInstructions'; - + This tutorial covers how to add the [Context7 MCP Server](https://github.com/upstash/context7) as a Goose extension to pull up-to-date, version-specific code and docs so Goose can vibe code with real context, not hallucinated or outdated answers. From a24ee7e1701a15f05d18126b913c9c92fff0d323 Mon Sep 17 00:00:00 2001 From: Max Novich Date: Wed, 4 Jun 2025 15:12:44 -0700 Subject: [PATCH 33/40] testing windows build (#2770) --- .github/workflows/bundle-desktop-windows.yml | 155 ++++++++----------- .gitignore | 6 +- Cargo.lock | 3 +- Cargo.toml | 6 +- Cross.toml | 6 +- Justfile | 10 +- 6 files changed, 90 insertions(+), 96 deletions(-) diff --git a/.github/workflows/bundle-desktop-windows.yml b/.github/workflows/bundle-desktop-windows.yml index bdcbc795..b0757ac4 100644 --- a/.github/workflows/bundle-desktop-windows.yml +++ b/.github/workflows/bundle-desktop-windows.yml @@ -1,7 +1,7 @@ name: "Bundle Desktop (Windows)" on: -# push: +# push: # branches: [ "main" ] # pull_request: # branches: [ "main" ] @@ -21,28 +21,20 @@ on: jobs: build-desktop-windows: name: Build Desktop (Windows) - runs-on: windows-latest + runs-on: ubuntu-latest # Use Ubuntu for cross-compilation steps: # 1) Check out source - name: Checkout repository uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 - # 2) Set up Rust - - name: Set up Rust - uses: dtolnay/rust-toolchain@38b70195107dddab2c7bbd522bcf763bac00963b - # If you need a specific version, you could do: - # or uses: actions/setup-rust@v1 - # with: - # rust-version: 1.73.0 - - # 3) Set up Node.js + # 2) Set up Node.js - name: Set up Node.js uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # pin@v3 with: - node-version: 16 + node-version: 18 - # 4) Cache dependencies (optional, can add more paths if needed) + # 3) Cache dependencies - name: Cache node_modules uses: actions/cache@2f8e54208210a422b2efd51efaa6bd6d7ca8920f # pin@v3 with: @@ -53,103 +45,92 @@ jobs: restore-keys: | ${{ runner.os }}-build-desktop-windows- - # 5) Install top-level dependencies if a package.json is in root - - name: Install top-level deps + # 4) Build Rust for Windows using Docker (cross-compilation) + - name: Build Windows executable using Docker run: | - if (Test-Path package.json) { - npm install - } + echo "Building Windows executable using Docker cross-compilation..." + docker volume create goose-windows-cache || true + docker run --rm \ + -v "$(pwd)":/usr/src/myapp \ + -v goose-windows-cache:/usr/local/cargo/registry \ + -w /usr/src/myapp \ + rust:latest \ + sh -c "rustup target add x86_64-pc-windows-gnu && \ + apt-get update && \ + apt-get install -y mingw-w64 protobuf-compiler cmake && \ + export CC_x86_64_pc_windows_gnu=x86_64-w64-mingw32-gcc && \ + export CXX_x86_64_pc_windows_gnu=x86_64-w64-mingw32-g++ && \ + export AR_x86_64_pc_windows_gnu=x86_64-w64-mingw32-ar && \ + export CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=x86_64-w64-mingw32-gcc && \ + export PKG_CONFIG_ALLOW_CROSS=1 && \ + export PROTOC=/usr/bin/protoc && \ + export PATH=/usr/bin:\$PATH && \ + protoc --version && \ + cargo build --release --target x86_64-pc-windows-gnu && \ + GCC_DIR=\$(ls -d /usr/lib/gcc/x86_64-w64-mingw32/*/ | head -n 1) && \ + cp \$GCC_DIR/libstdc++-6.dll /usr/src/myapp/target/x86_64-pc-windows-gnu/release/ && \ + cp \$GCC_DIR/libgcc_s_seh-1.dll /usr/src/myapp/target/x86_64-pc-windows-gnu/release/ && \ + cp /usr/x86_64-w64-mingw32/lib/libwinpthread-1.dll /usr/src/myapp/target/x86_64-pc-windows-gnu/release/" - # 6) Build rust for x86_64-pc-windows-gnu - - name: Install MinGW dependencies - run: | - choco install mingw --version=8.1.0 - # Debug - check installation paths - Write-Host "Checking MinGW installation..." - Get-ChildItem -Path "C:\ProgramData\chocolatey\lib\mingw" -Recurse -Filter "*.dll" | ForEach-Object { - Write-Host $_.FullName - } - Get-ChildItem -Path "C:\tools" -Recurse -Filter "*.dll" | ForEach-Object { - Write-Host $_.FullName - } - - - name: Cargo build for Windows - run: | - cargo build --release --target x86_64-pc-windows-gnu - - # 7) Check that the compiled goosed.exe exists and copy exe/dll to ui/desktop/src/bin + # 5) Prepare Windows binary and DLLs - name: Prepare Windows binary and DLLs run: | - if (!(Test-Path .\target\x86_64-pc-windows-gnu\release\goosed.exe)) { - Write-Error "Windows binary not found."; exit 1; - } - Write-Host "Copying Windows binary and DLLs to ui/desktop/src/bin..." - if (!(Test-Path ui\desktop\src\bin)) { - New-Item -ItemType Directory -Path ui\desktop\src\bin | Out-Null - } - Copy-Item .\target\x86_64-pc-windows-gnu\release\goosed.exe ui\desktop\src\bin\ + if [ ! -f "./target/x86_64-pc-windows-gnu/release/goosed.exe" ]; then + echo "Windows binary not found." + exit 1 + fi - # Copy MinGW DLLs - try both possible locations - $mingwPaths = @( - "C:\ProgramData\chocolatey\lib\mingw\tools\install\mingw64\bin", - "C:\tools\mingw64\bin" - ) + echo "Cleaning destination directory..." + rm -rf ./ui/desktop/src/bin + mkdir -p ./ui/desktop/src/bin - foreach ($path in $mingwPaths) { - if (Test-Path "$path\libstdc++-6.dll") { - Write-Host "Found MinGW DLLs in $path" - Copy-Item "$path\libstdc++-6.dll" ui\desktop\src\bin\ - Copy-Item "$path\libgcc_s_seh-1.dll" ui\desktop\src\bin\ - Copy-Item "$path\libwinpthread-1.dll" ui\desktop\src\bin\ - break - } - } + echo "Copying Windows binary and DLLs..." + cp -f ./target/x86_64-pc-windows-gnu/release/goosed.exe ./ui/desktop/src/bin/ + cp -f ./target/x86_64-pc-windows-gnu/release/*.dll ./ui/desktop/src/bin/ - # Copy any other DLLs from the release directory - ls .\target\x86_64-pc-windows-gnu\release\*.dll | ForEach-Object { - Copy-Item $_ ui\desktop\src\bin\ - } + # Copy Windows platform files (tools, scripts, etc.) + if [ -d "./ui/desktop/src/platform/windows/bin" ]; then + echo "Copying Windows platform files..." + for file in ./ui/desktop/src/platform/windows/bin/*.{exe,dll,cmd}; do + if [ -f "$file" ] && [ "$(basename "$file")" != "goosed.exe" ]; then + cp -f "$file" ./ui/desktop/src/bin/ + fi + done + + if [ -d "./ui/desktop/src/platform/windows/bin/goose-npm" ]; then + echo "Setting up npm environment..." + rsync -a --delete ./ui/desktop/src/platform/windows/bin/goose-npm/ ./ui/desktop/src/bin/goose-npm/ + fi + echo "Windows-specific files copied successfully" + fi - # 8) Install & build UI desktop + # 6) Install & build UI desktop - name: Build desktop UI with npm run: | - cd ui\desktop + cd ui/desktop npm install npm run bundle:windows - # 9) Copy exe/dll to final out/Goose-win32-x64/resources/bin + # 7) Copy exe/dll to final out/Goose-win32-x64/resources/bin - name: Copy exe/dll to out folder run: | - cd ui\desktop - if (!(Test-Path .\out\Goose-win32-x64\resources\bin)) { - New-Item -ItemType Directory -Path .\out\Goose-win32-x64\resources\bin | Out-Null - } - Copy-Item .\src\bin\goosed.exe .\out\Goose-win32-x64\resources\bin\ - ls .\src\bin\*.dll | ForEach-Object { - Copy-Item $_ .\out\Goose-win32-x64\resources\bin\ - } + cd ui/desktop + mkdir -p ./out/Goose-win32-x64/resources/bin + rsync -av src/bin/ out/Goose-win32-x64/resources/bin/ - # 10) Code signing (if enabled) + # 8) Code signing (if enabled) - name: Sign Windows executable - # Skip this step by default - enable when we have a certificate if: inputs.signing && inputs.signing == true env: WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} run: | - # Create a temporary certificate file - $certBytes = [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE) - $certPath = Join-Path -Path $env:RUNNER_TEMP -ChildPath "certificate.pfx" - [IO.File]::WriteAllBytes($certPath, $certBytes) - - # Sign the main executable - $signtool = "C:\Program Files (x86)\Windows Kits\10\bin\10.0.17763.0\x64\signtool.exe" - & $signtool sign /f $certPath /p $env:WINDOWS_CERTIFICATE_PASSWORD /tr http://timestamp.digicert.com /td sha256 /fd sha256 "ui\desktop\out\Goose-win32-x64\Goose.exe" - - # Clean up the certificate - Remove-Item -Path $certPath + # Note: This would need to be adapted for Linux-based signing + # or moved to a Windows runner for the signing step only + echo "Code signing would be implemented here" + echo "Currently skipped as we're running on Ubuntu" - # 11) Upload the final Windows build + # 9) Upload the final Windows build - name: Upload Windows build artifacts uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # pin@v4 with: diff --git a/.gitignore b/.gitignore index 3f5a8419..7a9cea66 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,10 @@ target/ ./ui/desktop/node_modules ./ui/desktop/out +# Generated Goose DLLs (built at build time, not checked in) +ui/desktop/src/bin/goose_ffi.dll +ui/desktop/src/bin/goose_llm.dll + # Hermit .hermit/ @@ -43,4 +47,4 @@ debug_*.txt benchmark-* benchconf.json scripts/fake.sh -do_not_version/ \ No newline at end of file +do_not_version/ diff --git a/Cargo.lock b/Cargo.lock index 5c8f7acf..43c34589 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2072,8 +2072,7 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "crunchy" version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929" +source = "git+https://github.com/nmathewson/crunchy?branch=cross-compilation-fix#260ec5f08969480c342bb3fe47f88870ed5c6cce" [[package]] name = "crypto-common" diff --git a/Cargo.toml b/Cargo.toml index f44b43e2..89309b36 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,4 +8,8 @@ version = "1.0.24" authors = ["Block "] license = "Apache-2.0" repository = "https://github.com/block/goose" -description = "An AI agent" \ No newline at end of file +description = "An AI agent" + +# Patch for Windows cross-compilation issue with crunchy +[patch.crates-io] +crunchy = { git = "https://github.com/nmathewson/crunchy", branch = "cross-compilation-fix" } \ No newline at end of file diff --git a/Cross.toml b/Cross.toml index f2db2745..16cd1e3a 100644 --- a/Cross.toml +++ b/Cross.toml @@ -46,10 +46,8 @@ pre-build = [ ] [target.x86_64-pc-windows-gnu] -image = "dockcross/windows-static-x64:latest" -# Enable verbose output for Windows builds -build-std = true -env = { "RUST_LOG" = "debug", "RUST_BACKTRACE" = "1", "CROSS_VERBOSE" = "1" } +image = "ghcr.io/cross-rs/x86_64-pc-windows-gnu:latest" +env = { "RUST_LOG" = "debug", "RUST_BACKTRACE" = "1", "CROSS_VERBOSE" = "1", "PKG_CONFIG_ALLOW_CROSS" = "1" } pre-build = [ """\ apt-get update && apt-get install -y \ diff --git a/Justfile b/Justfile index 0049be73..fdc38bea 100644 --- a/Justfile +++ b/Justfile @@ -25,7 +25,15 @@ release-windows: rust:latest \ sh -c "rustup target add x86_64-pc-windows-gnu && \ apt-get update && \ - apt-get install -y mingw-w64 && \ + apt-get install -y mingw-w64 protobuf-compiler cmake && \ + export CC_x86_64_pc_windows_gnu=x86_64-w64-mingw32-gcc && \ + export CXX_x86_64_pc_windows_gnu=x86_64-w64-mingw32-g++ && \ + export AR_x86_64_pc_windows_gnu=x86_64-w64-mingw32-ar && \ + export CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=x86_64-w64-mingw32-gcc && \ + export PKG_CONFIG_ALLOW_CROSS=1 && \ + export PROTOC=/usr/bin/protoc && \ + export PATH=/usr/bin:\$PATH && \ + protoc --version && \ cargo build --release --target x86_64-pc-windows-gnu && \ GCC_DIR=\$(ls -d /usr/lib/gcc/x86_64-w64-mingw32/*/ | head -n 1) && \ cp \$GCC_DIR/libstdc++-6.dll /usr/src/myapp/target/x86_64-pc-windows-gnu/release/ && \ From eb0e2a71542226fab931c3ea3213ffdec4d19ba7 Mon Sep 17 00:00:00 2001 From: "Dhanji R. Prasanna" Date: Thu, 5 Jun 2025 09:09:44 +1000 Subject: [PATCH 34/40] Fix paths in google drive mcp documentation (#2775) --- .../docs/tutorials/google-drive-mcp.md | 36 +++++++++++++------ 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/documentation/docs/tutorials/google-drive-mcp.md b/documentation/docs/tutorials/google-drive-mcp.md index 45715329..6fa251fb 100644 --- a/documentation/docs/tutorials/google-drive-mcp.md +++ b/documentation/docs/tutorials/google-drive-mcp.md @@ -19,20 +19,24 @@ This tutorial covers how to add the [Google Drive MCP Server](https://github.com **Command** ```sh - GDRIVE_OAUTH_PATH=/Users//.config/gcp-oauth.keys.json \ - GDRIVE_CREDENTIALS_PATH=/Users//.config/.gdrive-server-credentials.json \ - npx -y @modelcontextprotocol/server-gdrive auth + GDRIVE_OAUTH_PATH=$USER_HOME/.config/gcp-oauth.keys.json \ + GDRIVE_CREDENTIALS_PATH=$USER_HOME/.config/.gdrive-server-credentials.json \ + npx -y @modelcontextprotocol/server-gdrive auth \ npx -y @modelcontextprotocol/server-gdrive ``` **Environment Variable** ``` - GDRIVE_CREDENTIALS_PATH: ~/.config/.gdrive-server-credentials.json - GDRIVE_OAUTH_PATH: ~/.config/gcp-oauth.keys.json + GDRIVE_CREDENTIALS_PATH: $USER_HOME/.config/.gdrive-server-credentials.json + GDRIVE_OAUTH_PATH: $USER_HOME/.config/gcp-oauth.keys.json ``` ::: +:::info +Note that you *must* use absolute paths in the environment variables. Make sure you replace `$USER_HOME` with your home directory. +::: + ## Configuration :::info @@ -72,10 +76,14 @@ To obtain your Google Drive server credentials and oauth keys, follow the steps To connect your Google account, run the following authentication command in your terminal: ```sh - GDRIVE_OAUTH_PATH=/Users//.config/gcp-oauth.keys.json \ - GDRIVE_CREDENTIALS_PATH=/Users//.config/.gdrive-server-credentials.json \ + GDRIVE_OAUTH_PATH=$USER_HOME/.config/gcp-oauth.keys.json \ + GDRIVE_CREDENTIALS_PATH=$USER_HOME/.config/.gdrive-server-credentials.json \ npx -y @modelcontextprotocol/server-gdrive auth ``` + :::info + Replace `$USER_HOME` with your home directory. + ::: + A browser window will open for authentication. Follow the prompts to connect your Google account and complete the OAuth process. At this stage, your environment variable `GDRIVE_CREDENTIALS_PATH` will be set with the saved credentials. :::tip @@ -87,13 +95,19 @@ You'll need to re-authenticate once a day when using the Google Drive extension. 1. [Launch the installer](goose://extension?cmd=npx&arg=-y&arg=%40modelcontextprotocol%2Fserver-gdrive&id=google-drive&name=Google%20Drive&description=Google%20Drive%20integration&env=GDRIVE_CREDENTIALS_PATH%3DPath%20to%20Google%20Drive%20credentials&env=GDRIVE_OAUTH_PATH%3DPath%20to%20OAuth%20token) 2. Press `Yes` to confirm the installation 3. For `GDRIVE_CREDENTIALS_PATH`, enter the following: + ```sh + $USER_HOME/.config/.gdrive-server-credentials.json ``` - ~/.config/.gdrive-server-credentials.json - ``` + :::info + Replace `$USER_HOME` with your home directory. You must specify an absolute path for this extension to work. + ::: 4. For `GDRIVE_OAUTH_PATH`, enter the following: + ```sh + $USER_HOME/.config/gcp-oauth.keys.json ``` - ~/.config/gcp-oauth.keys.json - ``` + :::info + Replace `$USER_HOME` with your home directory. You must specify an absolute path for this extension to work. + ::: 5. Click `Save Configuration` 6. Scroll to the top and click `Exit` from the upper left corner From a1ebd2f703eea5a6e872b9d2163dc2987e0e98ed Mon Sep 17 00:00:00 2001 From: Brandon Kvarda Date: Wed, 4 Jun 2025 16:19:50 -0700 Subject: [PATCH 35/40] Add retries w/ exponential backoff for databricks provider (#2764) --- crates/goose-llm/src/providers/databricks.rs | 3 + crates/goose/src/providers/databricks.rs | 305 +++++++++++++++---- crates/goose/src/providers/snowflake.rs | 3 + 3 files changed, 247 insertions(+), 64 deletions(-) diff --git a/crates/goose-llm/src/providers/databricks.rs b/crates/goose-llm/src/providers/databricks.rs index 7b91d6a2..3dd31493 100644 --- a/crates/goose-llm/src/providers/databricks.rs +++ b/crates/goose-llm/src/providers/databricks.rs @@ -139,7 +139,10 @@ impl DatabricksProvider { "token count", "exceeds", "exceed context limit", + "input length", "max_tokens", + "decrease input length", + "context limit", ]; if check_phrases.iter().any(|c| payload_str.contains(c)) { return Err(ProviderError::ContextLengthExceeded(payload_str)); diff --git a/crates/goose/src/providers/databricks.rs b/crates/goose/src/providers/databricks.rs index bdca16bb..bccae364 100644 --- a/crates/goose/src/providers/databricks.rs +++ b/crates/goose/src/providers/databricks.rs @@ -17,6 +17,7 @@ use reqwest::{Client, StatusCode}; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::time::Duration; +use tokio::time::sleep; const DEFAULT_CLIENT_ID: &str = "databricks-cli"; const DEFAULT_REDIRECT_URL: &str = "http://localhost:8020"; @@ -24,6 +25,17 @@ const DEFAULT_REDIRECT_URL: &str = "http://localhost:8020"; // https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess const DEFAULT_SCOPES: &[&str] = &["all-apis", "offline_access"]; +/// Default timeout for API requests in seconds +const DEFAULT_TIMEOUT_SECS: u64 = 600; +/// Default initial interval for retry (in milliseconds) +const DEFAULT_INITIAL_RETRY_INTERVAL_MS: u64 = 5000; +/// Default maximum number of retries +const DEFAULT_MAX_RETRIES: usize = 6; +/// Default retry backoff multiplier +const DEFAULT_BACKOFF_MULTIPLIER: f64 = 2.0; +/// Default maximum interval for retry (in milliseconds) +const DEFAULT_MAX_RETRY_INTERVAL_MS: u64 = 320_000; + pub const DATABRICKS_DEFAULT_MODEL: &str = "databricks-claude-3-7-sonnet"; // Databricks can passthrough to a wide range of models, we only provide the default pub const DATABRICKS_KNOWN_MODELS: &[&str] = &[ @@ -36,6 +48,53 @@ pub const DATABRICKS_KNOWN_MODELS: &[&str] = &[ pub const DATABRICKS_DOC_URL: &str = "https://docs.databricks.com/en/generative-ai/external-models/index.html"; +/// Retry configuration for handling rate limit errors +#[derive(Debug, Clone)] +struct RetryConfig { + /// Maximum number of retry attempts + max_retries: usize, + /// Initial interval between retries in milliseconds + initial_interval_ms: u64, + /// Multiplier for backoff (exponential) + backoff_multiplier: f64, + /// Maximum interval between retries in milliseconds + max_interval_ms: u64, +} + +impl Default for RetryConfig { + fn default() -> Self { + Self { + max_retries: DEFAULT_MAX_RETRIES, + initial_interval_ms: DEFAULT_INITIAL_RETRY_INTERVAL_MS, + backoff_multiplier: DEFAULT_BACKOFF_MULTIPLIER, + max_interval_ms: DEFAULT_MAX_RETRY_INTERVAL_MS, + } + } +} + +impl RetryConfig { + /// Calculate the delay for a specific retry attempt (with jitter) + fn delay_for_attempt(&self, attempt: usize) -> Duration { + if attempt == 0 { + return Duration::from_millis(0); + } + + // Calculate exponential backoff + let exponent = (attempt - 1) as u32; + let base_delay_ms = (self.initial_interval_ms as f64 + * self.backoff_multiplier.powi(exponent as i32)) as u64; + + // Apply max limit + let capped_delay_ms = std::cmp::min(base_delay_ms, self.max_interval_ms); + + // Add jitter (+/-20% randomness) to avoid thundering herd problem + let jitter_factor = 0.8 + (rand::random::() * 0.4); // Between 0.8 and 1.2 + let jittered_delay_ms = (capped_delay_ms as f64 * jitter_factor) as u64; + + Duration::from_millis(jittered_delay_ms) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub enum DatabricksAuth { Token(String), @@ -70,6 +129,8 @@ pub struct DatabricksProvider { auth: DatabricksAuth, model: ModelConfig, image_format: ImageFormat, + #[serde(skip)] + retry_config: RetryConfig, } impl Default for DatabricksProvider { @@ -100,9 +161,12 @@ impl DatabricksProvider { let host = host?; let client = Client::builder() - .timeout(Duration::from_secs(600)) + .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS)) .build()?; + // Load optional retry configuration from environment + let retry_config = Self::load_retry_config(config); + // If we find a databricks token we prefer that if let Ok(api_key) = config.get_secret("DATABRICKS_TOKEN") { return Ok(Self { @@ -111,6 +175,7 @@ impl DatabricksProvider { auth: DatabricksAuth::token(api_key), model, image_format: ImageFormat::OpenAi, + retry_config, }); } @@ -121,9 +186,44 @@ impl DatabricksProvider { host, model, image_format: ImageFormat::OpenAi, + retry_config, }) } + /// Loads retry configuration from environment variables or uses defaults. + fn load_retry_config(config: &crate::config::Config) -> RetryConfig { + let max_retries = config + .get_param("DATABRICKS_MAX_RETRIES") + .ok() + .and_then(|v: String| v.parse::().ok()) + .unwrap_or(DEFAULT_MAX_RETRIES); + + let initial_interval_ms = config + .get_param("DATABRICKS_INITIAL_RETRY_INTERVAL_MS") + .ok() + .and_then(|v: String| v.parse::().ok()) + .unwrap_or(DEFAULT_INITIAL_RETRY_INTERVAL_MS); + + let backoff_multiplier = config + .get_param("DATABRICKS_BACKOFF_MULTIPLIER") + .ok() + .and_then(|v: String| v.parse::().ok()) + .unwrap_or(DEFAULT_BACKOFF_MULTIPLIER); + + let max_interval_ms = config + .get_param("DATABRICKS_MAX_RETRY_INTERVAL_MS") + .ok() + .and_then(|v: String| v.parse::().ok()) + .unwrap_or(DEFAULT_MAX_RETRY_INTERVAL_MS); + + RetryConfig { + max_retries, + initial_interval_ms, + backoff_multiplier, + max_interval_ms, + } + } + /// Create a new DatabricksProvider with the specified host and token /// /// # Arguments @@ -145,6 +245,7 @@ impl DatabricksProvider { auth: DatabricksAuth::token(api_key), model, image_format: ImageFormat::OpenAi, + retry_config: RetryConfig::default(), }) } @@ -182,72 +283,148 @@ impl DatabricksProvider { ProviderError::RequestFailed(format!("Failed to construct endpoint URL: {e}")) })?; - let auth_header = self.ensure_auth_header().await?; - let response = self - .client - .post(url) - .header("Authorization", auth_header) - .json(&payload) - .send() - .await?; + // Initialize retry counter + let mut attempts = 0; + let mut last_error = None; - let status = response.status(); - let payload: Option = response.json().await.ok(); - - match status { - StatusCode::OK => payload.ok_or_else(|| ProviderError::RequestFailed("Response body is not valid JSON".to_string())), - StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => { - Err(ProviderError::Authentication(format!("Authentication failed. Please ensure your API keys are valid and have the required permissions. \ - Status: {}. Response: {:?}", status, payload))) - } - StatusCode::BAD_REQUEST => { - // Databricks provides a generic 'error' but also includes 'external_model_message' which is provider specific - // We try to extract the error message from the payload and check for phrases that indicate context length exceeded - let payload_str = serde_json::to_string(&payload).unwrap_or_default().to_lowercase(); - let check_phrases = [ - "too long", - "context length", - "context_length_exceeded", - "reduce the length", - "token count", - "exceeds", - "exceed context limit", - "max_tokens", - ]; - if check_phrases.iter().any(|c| payload_str.contains(c)) { - return Err(ProviderError::ContextLengthExceeded(payload_str)); - } - - let mut error_msg = "Unknown error".to_string(); - if let Some(payload) = &payload { - // try to convert message to string, if that fails use external_model_message - error_msg = payload - .get("message") - .and_then(|m| m.as_str()) - .or_else(|| { - payload.get("external_model_message") - .and_then(|ext| ext.get("message")) - .and_then(|m| m.as_str()) - }) - .unwrap_or("Unknown error").to_string(); - } - - tracing::debug!( - "{}", format!("Provider request failed with status: {}. Payload: {:?}", status, payload) + loop { + // Check if we've exceeded max retries + if attempts > 0 && attempts > self.retry_config.max_retries { + let error_msg = format!( + "Exceeded maximum retry attempts ({}) for rate limiting (429)", + self.retry_config.max_retries ); - Err(ProviderError::RequestFailed(format!("Request failed with status: {}. Message: {}", status, error_msg))) + tracing::error!("{}", error_msg); + return Err(last_error.unwrap_or(ProviderError::RateLimitExceeded(error_msg))); } - StatusCode::TOO_MANY_REQUESTS => { - Err(ProviderError::RateLimitExceeded(format!("{:?}", payload))) - } - StatusCode::INTERNAL_SERVER_ERROR | StatusCode::SERVICE_UNAVAILABLE => { - Err(ProviderError::ServerError(format!("{:?}", payload))) - } - _ => { - tracing::debug!( - "{}", format!("Provider request failed with status: {}. Payload: {:?}", status, payload) - ); - Err(ProviderError::RequestFailed(format!("Request failed with status: {}", status))) + + let auth_header = self.ensure_auth_header().await?; + let response = self + .client + .post(url.clone()) + .header("Authorization", auth_header) + .json(&payload) + .send() + .await?; + + let status = response.status(); + let payload: Option = response.json().await.ok(); + + match status { + StatusCode::OK => { + return payload.ok_or_else(|| { + ProviderError::RequestFailed("Response body is not valid JSON".to_string()) + }); + } + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => { + return Err(ProviderError::Authentication(format!( + "Authentication failed. Please ensure your API keys are valid and have the required permissions. \ + Status: {}. Response: {:?}", + status, payload + ))); + } + StatusCode::BAD_REQUEST => { + // Databricks provides a generic 'error' but also includes 'external_model_message' which is provider specific + // We try to extract the error message from the payload and check for phrases that indicate context length exceeded + let payload_str = serde_json::to_string(&payload) + .unwrap_or_default() + .to_lowercase(); + let check_phrases = [ + "too long", + "context length", + "context_length_exceeded", + "reduce the length", + "token count", + "exceeds", + "exceed context limit", + "input length", + "max_tokens", + "decrease input length", + "context limit", + ]; + if check_phrases.iter().any(|c| payload_str.contains(c)) { + return Err(ProviderError::ContextLengthExceeded(payload_str)); + } + + let mut error_msg = "Unknown error".to_string(); + if let Some(payload) = &payload { + // try to convert message to string, if that fails use external_model_message + error_msg = payload + .get("message") + .and_then(|m| m.as_str()) + .or_else(|| { + payload + .get("external_model_message") + .and_then(|ext| ext.get("message")) + .and_then(|m| m.as_str()) + }) + .unwrap_or("Unknown error") + .to_string(); + } + + tracing::debug!( + "{}", + format!( + "Provider request failed with status: {}. Payload: {:?}", + status, payload + ) + ); + return Err(ProviderError::RequestFailed(format!( + "Request failed with status: {}. Message: {}", + status, error_msg + ))); + } + StatusCode::TOO_MANY_REQUESTS => { + attempts += 1; + let error_msg = format!( + "Rate limit exceeded (attempt {}/{}): {:?}", + attempts, self.retry_config.max_retries, payload + ); + tracing::warn!("{}. Retrying after backoff...", error_msg); + + // Store the error in case we need to return it after max retries + last_error = Some(ProviderError::RateLimitExceeded(error_msg)); + + // Calculate and apply the backoff delay + let delay = self.retry_config.delay_for_attempt(attempts); + tracing::info!("Backing off for {:?} before retry", delay); + sleep(delay).await; + + // Continue to the next retry attempt + continue; + } + StatusCode::INTERNAL_SERVER_ERROR | StatusCode::SERVICE_UNAVAILABLE => { + attempts += 1; + let error_msg = format!( + "Server error (attempt {}/{}): {:?}", + attempts, self.retry_config.max_retries, payload + ); + tracing::warn!("{}. Retrying after backoff...", error_msg); + + // Store the error in case we need to return it after max retries + last_error = Some(ProviderError::ServerError(error_msg)); + + // Calculate and apply the backoff delay + let delay = self.retry_config.delay_for_attempt(attempts); + tracing::info!("Backing off for {:?} before retry", delay); + sleep(delay).await; + + // Continue to the next retry attempt + continue; + } + _ => { + tracing::debug!( + "{}", + format!( + "Provider request failed with status: {}. Payload: {:?}", + status, payload + ) + ); + return Err(ProviderError::RequestFailed(format!( + "Request failed with status: {}", + status + ))); + } } } } diff --git a/crates/goose/src/providers/snowflake.rs b/crates/goose/src/providers/snowflake.rs index f1c3ad10..32c1f2c6 100644 --- a/crates/goose/src/providers/snowflake.rs +++ b/crates/goose/src/providers/snowflake.rs @@ -334,7 +334,10 @@ impl SnowflakeProvider { "token count", "exceeds", "exceed context limit", + "input length", "max_tokens", + "decrease input length", + "context limit", ]; if check_phrases.iter().any(|c| payload_str.contains(c)) { return Err(ProviderError::ContextLengthExceeded("Request exceeds maximum context length. Please reduce the number of messages or content size.".to_string())); From 1d557161d0b3d9cd391820f2c4a5e29d231badb6 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Thu, 5 Jun 2025 11:20:11 +1000 Subject: [PATCH 36/40] fix: Don't break from consuming subprocess output in shell tool until both streams are done (#2771) Co-authored-by: Max Novich --- crates/goose-cli/src/session/mod.rs | 3 +- crates/goose-cli/src/session/output.rs | 6 ++ crates/goose-mcp/src/developer/mod.rs | 86 ++++++++++++++------------ 3 files changed, 55 insertions(+), 40 deletions(-) diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 23e2735d..cbabac3c 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -727,7 +727,6 @@ impl Session { loop { tokio::select! { result = stream.next() => { - let _ = progress_bars.hide(); match result { Some(Ok(AgentEvent::Message(message))) => { // If it's a confirmation request, get approval but otherwise do not render/persist @@ -865,6 +864,7 @@ impl Session { session::persist_messages(&self.session_file, &self.messages, None).await?; if interactive {output::hide_thinking()}; + let _ = progress_bars.hide(); output::render_message(&message, self.debug); if interactive {output::show_thinking()}; } @@ -891,7 +891,6 @@ impl Session { v.to_string() }, }; - // output::render_text_no_newlines(&message, None, true); progress_bars.log(&message); }, "notifications/progress" => { diff --git a/crates/goose-cli/src/session/output.rs b/crates/goose-cli/src/session/output.rs index 3bda532b..ea822c55 100644 --- a/crates/goose-cli/src/session/output.rs +++ b/crates/goose-cli/src/session/output.rs @@ -660,6 +660,12 @@ impl McpSpinners { } pub fn hide(&mut self) -> Result<(), Error> { + self.bars.iter_mut().for_each(|(_, bar)| { + bar.disable_steady_tick(); + }); + if let Some(spinner) = self.log_spinner.as_mut() { + spinner.disable_steady_tick(); + } self.multi_bar.clear() } } diff --git a/crates/goose-mcp/src/developer/mod.rs b/crates/goose-mcp/src/developer/mod.rs index 03dac338..df95f76d 100644 --- a/crates/goose-mcp/src/developer/mod.rs +++ b/crates/goose-mcp/src/developer/mod.rs @@ -531,52 +531,62 @@ impl DeveloperRouter { let mut stdout_buf = Vec::new(); let mut stderr_buf = Vec::new(); + let mut stdout_done = false; + let mut stderr_done = false; + loop { tokio::select! { - n = stdout_reader.read_until(b'\n', &mut stdout_buf) => { + n = stdout_reader.read_until(b'\n', &mut stdout_buf), if !stdout_done => { if n? == 0 { - break; + stdout_done = true; + } else { + let line = String::from_utf8_lossy(&stdout_buf); + + notifier.try_send(JsonRpcMessage::Notification(JsonRpcNotification { + jsonrpc: "2.0".to_string(), + method: "notifications/message".to_string(), + params: Some(json!({ + "data": { + "type": "shell", + "stream": "stdout", + "output": line.to_string(), + } + })), + })).ok(); + + combined_output.push_str(&line); + stdout_buf.clear(); } - let line = String::from_utf8_lossy(&stdout_buf); - - notifier.try_send(JsonRpcMessage::Notification(JsonRpcNotification { - jsonrpc: "2.0".to_string(), - method: "notifications/message".to_string(), - params: Some(json!({ - "data": { - "type": "shell", - "stream": "stdout", - "output": line.to_string(), - } - })), - })) - .ok(); - - combined_output.push_str(&line); - stdout_buf.clear(); } - n = stderr_reader.read_until(b'\n', &mut stderr_buf) => { + + n = stderr_reader.read_until(b'\n', &mut stderr_buf), if !stderr_done => { if n? == 0 { - break; + stderr_done = true; + } else { + let line = String::from_utf8_lossy(&stderr_buf); + + notifier.try_send(JsonRpcMessage::Notification(JsonRpcNotification { + jsonrpc: "2.0".to_string(), + method: "notifications/message".to_string(), + params: Some(json!({ + "data": { + "type": "shell", + "stream": "stderr", + "output": line.to_string(), + } + })), + })).ok(); + + combined_output.push_str(&line); + stderr_buf.clear(); } - let line = String::from_utf8_lossy(&stderr_buf); - - notifier.try_send(JsonRpcMessage::Notification(JsonRpcNotification { - jsonrpc: "2.0".to_string(), - method: "notifications/message".to_string(), - params: Some(json!({ - "data": { - "type": "shell", - "stream": "stderr", - "output": line.to_string(), - } - })), - })) - .ok(); - - combined_output.push_str(&line); - stderr_buf.clear(); } + + else => break, + } + + if stdout_done && stderr_done { + break; } } Ok::<_, std::io::Error>(combined_output) From fec5bfe9cd30dc41b17d213d96b225c726f1261d Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Wed, 4 Jun 2025 20:30:34 -0700 Subject: [PATCH 37/40] chore: run CI on merge_group (#2786) Co-authored-by: Michael Neale --- .github/workflows/ci.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a4d76255..982a0beb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,9 @@ on: pull_request: branches: - main + merge_group: + branches: + - main workflow_dispatch: name: CI @@ -24,7 +27,7 @@ jobs: name: Build and Test Rust Project runs-on: ubuntu-latest steps: - # Add disk space cleanup before linting + # Add disk space cleanup before linting - name: Check disk space before build run: df -h @@ -45,7 +48,7 @@ jobs: /usr/share/swift df -h - + - name: Checkout Code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4 @@ -130,6 +133,6 @@ jobs: # Faster Desktop App build for PRs only bundle-desktop-unsigned: uses: ./.github/workflows/bundle-desktop.yml - if: github.event_name == 'pull_request' + if: github.event_name == 'pull_request' || github.event_name == 'merge_group' with: signing: false From 49c4038d38b36ab5c4b536edafd87874c00d4094 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Thu, 5 Jun 2025 13:32:57 +1000 Subject: [PATCH 38/40] feat: goose web for local terminal alternative (#2718) --- Cargo.lock | 77 +++- crates/goose-cli/Cargo.toml | 8 + crates/goose-cli/WEB_INTERFACE.md | 78 ++++ crates/goose-cli/src/cli.rs | 29 ++ crates/goose-cli/src/commands/mod.rs | 1 + crates/goose-cli/src/commands/web.rs | 640 +++++++++++++++++++++++++++ crates/goose-cli/static/index.html | 46 ++ crates/goose-cli/static/script.js | 523 ++++++++++++++++++++++ crates/goose-cli/static/style.css | 480 ++++++++++++++++++++ test_web.sh | 26 ++ 10 files changed, 1906 insertions(+), 2 deletions(-) create mode 100644 crates/goose-cli/WEB_INTERFACE.md create mode 100644 crates/goose-cli/src/commands/web.rs create mode 100644 crates/goose-cli/static/index.html create mode 100644 crates/goose-cli/static/script.js create mode 100644 crates/goose-cli/static/style.css create mode 100644 test_web.sh diff --git a/Cargo.lock b/Cargo.lock index 43c34589..26b55915 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3440,7 +3440,7 @@ dependencies = [ "url", "utoipa", "uuid", - "webbrowser", + "webbrowser 0.8.15", "winapi", "wiremock", ] @@ -3475,8 +3475,10 @@ version = "1.0.24" dependencies = [ "anyhow", "async-trait", + "axum", "base64 0.22.1", "bat", + "bytes", "chrono", "clap 4.5.31", "cliclack", @@ -3486,6 +3488,7 @@ dependencies = [ "goose", "goose-bench", "goose-mcp", + "http 1.2.0", "indicatif", "mcp-client", "mcp-core", @@ -3506,9 +3509,12 @@ dependencies = [ "tempfile", "test-case", "tokio", + "tokio-stream", + "tower-http", "tracing", "tracing-appender", "tracing-subscriber", + "webbrowser 1.0.4", "winapi", ] @@ -3601,7 +3607,7 @@ dependencies = [ "url", "urlencoding", "utoipa", - "webbrowser", + "webbrowser 0.8.15", "xcap", ] @@ -3876,6 +3882,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.10.1" @@ -5419,6 +5431,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minijinja" version = "2.8.0" @@ -5813,6 +5835,31 @@ dependencies = [ "malloc_buf", ] +[[package]] +name = "objc2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88c6597e14493ab2e44ce58f2fdecf095a51f12ca57bec060a11c57332520551" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c" +dependencies = [ + "bitflags 2.9.0", + "objc2", +] + [[package]] name = "object" version = "0.36.7" @@ -8591,12 +8638,21 @@ checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" dependencies = [ "bitflags 2.9.0", "bytes", + "futures-util", "http 1.2.0", "http-body 1.0.1", "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", + "tokio", + "tokio-util", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -9249,6 +9305,23 @@ dependencies = [ "web-sys", ] +[[package]] +name = "webbrowser" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5df295f8451142f1856b1bd86a606dfe9587d439bc036e319c827700dbd555e" +dependencies = [ + "core-foundation 0.10.0", + "home", + "jni", + "log", + "ndk-context", + "objc2", + "objc2-foundation", + "url", + "web-sys", +] + [[package]] name = "webpki-roots" version = "0.26.8" diff --git a/crates/goose-cli/Cargo.toml b/crates/goose-cli/Cargo.toml index 3a73c44b..4f4294a6 100644 --- a/crates/goose-cli/Cargo.toml +++ b/crates/goose-cli/Cargo.toml @@ -55,6 +55,14 @@ regex = "1.11.1" minijinja = "2.8.0" nix = { version = "0.30.1", features = ["process", "signal"] } tar = "0.4" +# Web server dependencies +axum = { version = "0.8.1", features = ["ws", "macros"] } +tower-http = { version = "0.5", features = ["cors", "fs"] } +tokio-stream = "0.1" +bytes = "1.5" +http = "1.0" +webbrowser = "1.0" + indicatif = "0.17.11" [target.'cfg(target_os = "windows")'.dependencies] diff --git a/crates/goose-cli/WEB_INTERFACE.md b/crates/goose-cli/WEB_INTERFACE.md new file mode 100644 index 00000000..3665ef97 --- /dev/null +++ b/crates/goose-cli/WEB_INTERFACE.md @@ -0,0 +1,78 @@ +# Goose Web Interface + +The `goose web` command provides a (preview) web-based chat interface for interacting with Goose. +Do not expose this publicly - this is in a preview state as an option. + +## Usage + +```bash +# Start the web server on default port (3000) +goose web + +# Start on a specific port +goose web --port 8080 + +# Start and automatically open in browser +goose web --open + +# Bind to a specific host +goose web --host 0.0.0.0 --port 8080 +``` + +## Features + +- **Real-time chat interface**: Communicate with Goose through a clean web UI +- **WebSocket support**: Real-time message streaming +- **Session management**: Each browser tab maintains its own session +- **Responsive design**: Works on desktop and mobile devices + +## Architecture + +The web interface is built with: +- **Backend**: Rust with Axum web framework +- **Frontend**: Vanilla JavaScript with WebSocket communication +- **Styling**: CSS with dark/light mode support + +## Development Notes + +### Current Implementation + +The web interface provides: +1. A simple chat UI similar to the desktop Electron app +2. WebSocket-based real-time communication +3. Basic session management (messages are stored in memory) + +### Future Enhancements + +- [ ] Persistent session storage +- [ ] Tool call visualization +- [ ] File upload support +- [ ] Multiple session tabs +- [ ] Authentication/authorization +- [ ] Streaming responses with proper formatting +- [ ] Code syntax highlighting +- [ ] Export chat history + +### Integration with Goose Agent + +The web server creates an instance of the Goose Agent and processes messages through the same pipeline as the CLI. However, some features like: +- Extension management +- Tool confirmations +- File system interactions + +...may require additional UI components to be fully functional. + +## Security Considerations + +Currently, the web interface: +- Binds to localhost by default for security +- Does not include authentication (planned for future) +- Should not be exposed to the internet without proper security measures + +## Troubleshooting + +If you encounter issues: + +1. **Port already in use**: Try a different port with `--port` +2. **Cannot connect**: Ensure no firewall is blocking the port +3. **Agent not configured**: Run `goose configure` first to set up a provider \ No newline at end of file diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 027d9656..7afc4f03 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -504,6 +504,31 @@ enum Command { #[command(subcommand)] cmd: BenchCommand, }, + + /// Start a web server with a chat interface + #[command(about = "Start a web server with a chat interface", hide = true)] + Web { + /// Port to run the web server on + #[arg( + short, + long, + default_value = "3000", + help = "Port to run the web server on" + )] + port: u16, + + /// Host to bind the web server to + #[arg( + long, + default_value = "127.0.0.1", + help = "Host to bind the web server to" + )] + host: String, + + /// Open browser automatically + #[arg(long, help = "Open browser automatically when server starts")] + open: bool, + }, } #[derive(clap::ValueEnum, Clone, Debug)] @@ -785,6 +810,10 @@ pub async fn cli() -> Result<()> { } return Ok(()); } + Some(Command::Web { port, host, open }) => { + crate::commands::web::handle_web(port, host, open).await?; + return Ok(()); + } None => { return if !Config::global().exists() { let _ = handle_configure().await; diff --git a/crates/goose-cli/src/commands/mod.rs b/crates/goose-cli/src/commands/mod.rs index bda22fbd..72ce9be2 100644 --- a/crates/goose-cli/src/commands/mod.rs +++ b/crates/goose-cli/src/commands/mod.rs @@ -7,3 +7,4 @@ pub mod recipe; pub mod schedule; pub mod session; pub mod update; +pub mod web; diff --git a/crates/goose-cli/src/commands/web.rs b/crates/goose-cli/src/commands/web.rs new file mode 100644 index 00000000..0ef06290 --- /dev/null +++ b/crates/goose-cli/src/commands/web.rs @@ -0,0 +1,640 @@ +use anyhow::Result; +use axum::{ + extract::{ + ws::{Message, WebSocket, WebSocketUpgrade}, + State, + }, + response::{Html, IntoResponse, Response}, + routing::get, + Json, Router, +}; +use futures::{sink::SinkExt, stream::StreamExt}; +use goose::agents::{Agent, AgentEvent}; +use goose::message::Message as GooseMessage; +use goose::session; +use serde::{Deserialize, Serialize}; +use std::{net::SocketAddr, sync::Arc}; +use tokio::sync::{Mutex, RwLock}; +use tower_http::cors::{Any, CorsLayer}; +use tracing::error; + +type SessionStore = Arc>>>>>; +type CancellationStore = Arc>>; + +#[derive(Clone)] +struct AppState { + agent: Arc, + sessions: SessionStore, + cancellations: CancellationStore, +} + +#[derive(Serialize, Deserialize)] +#[serde(tag = "type")] +enum WebSocketMessage { + #[serde(rename = "message")] + Message { + content: String, + session_id: String, + timestamp: i64, + }, + #[serde(rename = "cancel")] + Cancel { session_id: String }, + #[serde(rename = "response")] + Response { + content: String, + role: String, + timestamp: i64, + }, + #[serde(rename = "tool_request")] + ToolRequest { + id: String, + tool_name: String, + arguments: serde_json::Value, + }, + #[serde(rename = "tool_response")] + ToolResponse { + id: String, + result: serde_json::Value, + is_error: bool, + }, + #[serde(rename = "tool_confirmation")] + ToolConfirmation { + id: String, + tool_name: String, + arguments: serde_json::Value, + needs_confirmation: bool, + }, + #[serde(rename = "error")] + Error { message: String }, + #[serde(rename = "thinking")] + Thinking { message: String }, + #[serde(rename = "context_exceeded")] + ContextExceeded { message: String }, + #[serde(rename = "cancelled")] + Cancelled { message: String }, + #[serde(rename = "complete")] + Complete { message: String }, +} + +pub async fn handle_web(port: u16, host: String, open: bool) -> Result<()> { + // Setup logging + crate::logging::setup_logging(Some("goose-web"), None)?; + + // Load config and create agent just like the CLI does + let config = goose::config::Config::global(); + + let provider_name: String = match config.get_param("GOOSE_PROVIDER") { + Ok(p) => p, + Err(_) => { + eprintln!("No provider configured. Run 'goose configure' first"); + std::process::exit(1); + } + }; + + let model: String = match config.get_param("GOOSE_MODEL") { + Ok(m) => m, + Err(_) => { + eprintln!("No model configured. Run 'goose configure' first"); + std::process::exit(1); + } + }; + + let model_config = goose::model::ModelConfig::new(model.clone()); + + // Create the agent + let agent = Agent::new(); + let provider = goose::providers::create(&provider_name, model_config)?; + agent.update_provider(provider).await?; + + // Load and enable extensions from config + let extensions = goose::config::ExtensionConfigManager::get_all()?; + for ext_config in extensions { + if ext_config.enabled { + if let Err(e) = agent.add_extension(ext_config.config.clone()).await { + eprintln!( + "Warning: Failed to load extension {}: {}", + ext_config.config.name(), + e + ); + } + } + } + + let state = AppState { + agent: Arc::new(agent), + sessions: Arc::new(RwLock::new(std::collections::HashMap::new())), + cancellations: Arc::new(RwLock::new(std::collections::HashMap::new())), + }; + + // Build router + let app = Router::new() + .route("/", get(serve_index)) + .route("/session/{session_name}", get(serve_session)) + .route("/ws", get(websocket_handler)) + .route("/api/health", get(health_check)) + .route("/api/sessions", get(list_sessions)) + .route("/api/sessions/{session_id}", get(get_session)) + .route("/static/{*path}", get(serve_static)) + .layer( + CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any), + ) + .with_state(state); + + let addr: SocketAddr = format!("{}:{}", host, port).parse()?; + + println!("\n🪿 Starting Goose web server"); + println!(" Provider: {} | Model: {}", provider_name, model); + println!( + " Working directory: {}", + std::env::current_dir()?.display() + ); + println!(" Server: http://{}", addr); + println!(" Press Ctrl+C to stop\n"); + + if open { + // Open browser + let url = format!("http://{}", addr); + if let Err(e) = webbrowser::open(&url) { + eprintln!("Failed to open browser: {}", e); + } + } + + let listener = tokio::net::TcpListener::bind(addr).await?; + axum::serve(listener, app).await?; + + Ok(()) +} + +async fn serve_index() -> Html<&'static str> { + Html(include_str!("../../static/index.html")) +} + +async fn serve_session( + axum::extract::Path(session_name): axum::extract::Path, +) -> Html { + let html = include_str!("../../static/index.html"); + // Inject the session name into the HTML so JavaScript can use it + let html_with_session = html.replace( + "", + &format!( + "\n ", + session_name + ) + ); + Html(html_with_session) +} + +async fn serve_static(axum::extract::Path(path): axum::extract::Path) -> Response { + match path.as_str() { + "style.css" => ( + [("content-type", "text/css")], + include_str!("../../static/style.css"), + ) + .into_response(), + "script.js" => ( + [("content-type", "application/javascript")], + include_str!("../../static/script.js"), + ) + .into_response(), + _ => (axum::http::StatusCode::NOT_FOUND, "Not found").into_response(), + } +} + +async fn health_check() -> Json { + Json(serde_json::json!({ + "status": "ok", + "service": "goose-web" + })) +} + +async fn list_sessions() -> Json { + match session::list_sessions() { + Ok(sessions) => { + let session_info: Vec = sessions + .into_iter() + .map(|(name, path)| { + let metadata = session::read_metadata(&path).unwrap_or_default(); + serde_json::json!({ + "name": name, + "path": path, + "description": metadata.description, + "message_count": metadata.message_count, + "working_dir": metadata.working_dir + }) + }) + .collect(); + + Json(serde_json::json!({ + "sessions": session_info + })) + } + Err(e) => Json(serde_json::json!({ + "error": e.to_string() + })), + } +} + +async fn get_session( + axum::extract::Path(session_id): axum::extract::Path, +) -> Json { + let session_file = session::get_path(session::Identifier::Name(session_id)); + + match session::read_messages(&session_file) { + Ok(messages) => { + let metadata = session::read_metadata(&session_file).unwrap_or_default(); + Json(serde_json::json!({ + "metadata": metadata, + "messages": messages + })) + } + Err(e) => Json(serde_json::json!({ + "error": e.to_string() + })), + } +} + +async fn websocket_handler( + ws: WebSocketUpgrade, + State(state): State, +) -> impl IntoResponse { + ws.on_upgrade(|socket| handle_socket(socket, state)) +} + +async fn handle_socket(socket: WebSocket, state: AppState) { + let (sender, mut receiver) = socket.split(); + let sender = Arc::new(Mutex::new(sender)); + + while let Some(msg) = receiver.next().await { + if let Ok(msg) = msg { + match msg { + Message::Text(text) => { + match serde_json::from_str::(&text.to_string()) { + Ok(WebSocketMessage::Message { + content, + session_id, + .. + }) => { + // Get session file path from session_id + let session_file = + session::get_path(session::Identifier::Name(session_id.clone())); + + // Get or create session in memory (for fast access during processing) + let session_messages = { + let sessions = state.sessions.read().await; + if let Some(session) = sessions.get(&session_id) { + session.clone() + } else { + drop(sessions); + let mut sessions = state.sessions.write().await; + + // Load existing messages from JSONL file if it exists + let existing_messages = session::read_messages(&session_file) + .unwrap_or_else(|_| Vec::new()); + + let new_session = Arc::new(Mutex::new(existing_messages)); + sessions.insert(session_id.clone(), new_session.clone()); + new_session + } + }; + + // Clone sender for async processing + let sender_clone = sender.clone(); + let agent = state.agent.clone(); + + // Process message in a separate task to allow streaming + let task_handle = tokio::spawn(async move { + let result = process_message_streaming( + &agent, + session_messages, + session_file, + content, + sender_clone, + ) + .await; + + if let Err(e) = result { + error!("Error processing message: {}", e); + } + }); + + // Store the abort handle + { + let mut cancellations = state.cancellations.write().await; + cancellations + .insert(session_id.clone(), task_handle.abort_handle()); + } + + // Wait for task completion and handle abort + let sender_for_abort = sender.clone(); + let session_id_for_cleanup = session_id.clone(); + let cancellations_for_cleanup = state.cancellations.clone(); + + tokio::spawn(async move { + match task_handle.await { + Ok(_) => { + // Task completed normally + } + Err(e) if e.is_cancelled() => { + // Task was aborted + let mut sender = sender_for_abort.lock().await; + let _ = sender + .send(Message::Text( + serde_json::to_string( + &WebSocketMessage::Cancelled { + message: "Operation cancelled by user" + .to_string(), + }, + ) + .unwrap() + .into(), + )) + .await; + } + Err(e) => { + error!("Task error: {}", e); + } + } + + // Clean up cancellation token + { + let mut cancellations = cancellations_for_cleanup.write().await; + cancellations.remove(&session_id_for_cleanup); + } + }); + } + Ok(WebSocketMessage::Cancel { session_id }) => { + // Cancel the active operation for this session + let abort_handle = { + let mut cancellations = state.cancellations.write().await; + cancellations.remove(&session_id) + }; + + if let Some(handle) = abort_handle { + handle.abort(); + + // Send cancellation confirmation + let mut sender = sender.lock().await; + let _ = sender + .send(Message::Text( + serde_json::to_string(&WebSocketMessage::Cancelled { + message: "Operation cancelled".to_string(), + }) + .unwrap() + .into(), + )) + .await; + } + } + Ok(_) => { + // Ignore other message types + } + Err(e) => { + error!("Failed to parse WebSocket message: {}", e); + } + } + } + Message::Close(_) => break, + _ => {} + } + } else { + break; + } + } +} + +async fn process_message_streaming( + agent: &Agent, + session_messages: Arc>>, + session_file: std::path::PathBuf, + content: String, + sender: Arc>>, +) -> Result<()> { + use futures::StreamExt; + use goose::agents::SessionConfig; + use goose::message::MessageContent; + use goose::session; + + // Create a user message + let user_message = GooseMessage::user().with_text(content.clone()); + + // Get existing messages from session and add the new user message + let mut messages = { + let mut session_msgs = session_messages.lock().await; + session_msgs.push(user_message.clone()); + session_msgs.clone() + }; + + // Persist messages to JSONL file with provider for automatic description generation + let provider = agent.provider().await; + if provider.is_err() { + let error_msg = "I'm not properly configured yet. Please configure a provider through the CLI first using `goose configure`.".to_string(); + let mut sender = sender.lock().await; + let _ = sender + .send(Message::Text( + serde_json::to_string(&WebSocketMessage::Response { + content: error_msg, + role: "assistant".to_string(), + timestamp: chrono::Utc::now().timestamp_millis(), + }) + .unwrap() + .into(), + )) + .await; + return Ok(()); + } + + let provider = provider.unwrap(); + session::persist_messages(&session_file, &messages, Some(provider.clone())).await?; + + // Create a session config + let session_config = SessionConfig { + id: session::Identifier::Path(session_file.clone()), + working_dir: std::env::current_dir()?, + schedule_id: None, + }; + + // Get response from agent + match agent.reply(&messages, Some(session_config)).await { + Ok(mut stream) => { + while let Some(result) = stream.next().await { + match result { + Ok(AgentEvent::Message(message)) => { + // Add message to our session + { + let mut session_msgs = session_messages.lock().await; + session_msgs.push(message.clone()); + } + + // Persist messages to JSONL file (no provider needed for assistant messages) + let current_messages = { + let session_msgs = session_messages.lock().await; + session_msgs.clone() + }; + session::persist_messages(&session_file, ¤t_messages, None).await?; + // Handle different message content types + for content in &message.content { + match content { + MessageContent::Text(text) => { + // Send the text response + let mut sender = sender.lock().await; + let _ = sender + .send(Message::Text( + serde_json::to_string(&WebSocketMessage::Response { + content: text.text.clone(), + role: "assistant".to_string(), + timestamp: chrono::Utc::now().timestamp_millis(), + }) + .unwrap() + .into(), + )) + .await; + } + MessageContent::ToolRequest(req) => { + // Send tool request notification + let mut sender = sender.lock().await; + if let Ok(tool_call) = &req.tool_call { + let _ = sender + .send(Message::Text( + serde_json::to_string( + &WebSocketMessage::ToolRequest { + id: req.id.clone(), + tool_name: tool_call.name.clone(), + arguments: tool_call.arguments.clone(), + }, + ) + .unwrap() + .into(), + )) + .await; + } + } + MessageContent::ToolResponse(_resp) => { + // Tool responses are already included in the complete message stream + // and will be persisted to session history. No need to send separate + // WebSocket messages as this would cause duplicates. + } + MessageContent::ToolConfirmationRequest(confirmation) => { + // Send tool confirmation request + let mut sender = sender.lock().await; + let _ = sender + .send(Message::Text( + serde_json::to_string( + &WebSocketMessage::ToolConfirmation { + id: confirmation.id.clone(), + tool_name: confirmation.tool_name.clone(), + arguments: confirmation.arguments.clone(), + needs_confirmation: true, + }, + ) + .unwrap() + .into(), + )) + .await; + + // For now, auto-approve in web mode + // TODO: Implement proper confirmation UI + agent.handle_confirmation( + confirmation.id.clone(), + goose::permission::PermissionConfirmation { + principal_type: goose::permission::permission_confirmation::PrincipalType::Tool, + permission: goose::permission::Permission::AllowOnce, + } + ).await; + } + MessageContent::Thinking(thinking) => { + // Send thinking indicator + let mut sender = sender.lock().await; + let _ = sender + .send(Message::Text( + serde_json::to_string(&WebSocketMessage::Thinking { + message: thinking.thinking.clone(), + }) + .unwrap() + .into(), + )) + .await; + } + MessageContent::ContextLengthExceeded(msg) => { + // Send context exceeded notification + let mut sender = sender.lock().await; + let _ = sender + .send(Message::Text( + serde_json::to_string( + &WebSocketMessage::ContextExceeded { + message: msg.msg.clone(), + }, + ) + .unwrap() + .into(), + )) + .await; + + // For now, auto-summarize in web mode + // TODO: Implement proper UI for context handling + let (summarized_messages, _) = + agent.summarize_context(&messages).await?; + messages = summarized_messages; + } + _ => { + // Handle other message types as needed + } + } + } + } + Ok(AgentEvent::McpNotification(_notification)) => { + // Handle MCP notifications if needed + // For now, we'll just log them + tracing::info!("Received MCP notification in web interface"); + } + Err(e) => { + error!("Error in message stream: {}", e); + let mut sender = sender.lock().await; + let _ = sender + .send(Message::Text( + serde_json::to_string(&WebSocketMessage::Error { + message: format!("Error: {}", e), + }) + .unwrap() + .into(), + )) + .await; + break; + } + } + } + } + Err(e) => { + error!("Error calling agent: {}", e); + let mut sender = sender.lock().await; + let _ = sender + .send(Message::Text( + serde_json::to_string(&WebSocketMessage::Error { + message: format!("Error: {}", e), + }) + .unwrap() + .into(), + )) + .await; + } + } + + // Send completion message + let mut sender = sender.lock().await; + let _ = sender + .send(Message::Text( + serde_json::to_string(&WebSocketMessage::Complete { + message: "Response complete".to_string(), + }) + .unwrap() + .into(), + )) + .await; + + Ok(()) +} + +// Add webbrowser dependency for opening browser +use webbrowser; diff --git a/crates/goose-cli/static/index.html b/crates/goose-cli/static/index.html new file mode 100644 index 00000000..f52b03bf --- /dev/null +++ b/crates/goose-cli/static/index.html @@ -0,0 +1,46 @@ + + + + + + Goose Chat + + + +
+
+

Goose Chat

+
Connecting...
+
+ +
+
+
+

Welcome to Goose!

+

I'm your AI assistant. How can I help you today?

+ +
+
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
+
+
+
+ +
+ + +
+
+
+ + + + \ No newline at end of file diff --git a/crates/goose-cli/static/script.js b/crates/goose-cli/static/script.js new file mode 100644 index 00000000..3cc9aa99 --- /dev/null +++ b/crates/goose-cli/static/script.js @@ -0,0 +1,523 @@ +// WebSocket connection and chat functionality +let socket = null; +let sessionId = getSessionId(); +let isConnected = false; + +// DOM elements +const messagesContainer = document.getElementById('messages'); +const messageInput = document.getElementById('message-input'); +const sendButton = document.getElementById('send-button'); +const connectionStatus = document.getElementById('connection-status'); + +// Track if we're currently processing +let isProcessing = false; + +// Get session ID - either from URL parameter, injected session name, or generate new one +function getSessionId() { + // Check if session name was injected by server (for /session/:name routes) + if (window.GOOSE_SESSION_NAME) { + return window.GOOSE_SESSION_NAME; + } + + // Check URL parameters + const urlParams = new URLSearchParams(window.location.search); + const sessionParam = urlParams.get('session') || urlParams.get('name'); + if (sessionParam) { + return sessionParam; + } + + // Generate new session ID using CLI format + return generateSessionId(); +} + +// Generate a session ID using timestamp format (yyyymmdd_hhmmss) like CLI +function generateSessionId() { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + const hour = String(now.getHours()).padStart(2, '0'); + const minute = String(now.getMinutes()).padStart(2, '0'); + const second = String(now.getSeconds()).padStart(2, '0'); + + return `${year}${month}${day}_${hour}${minute}${second}`; +} + +// Format timestamp +function formatTimestamp(date) { + return date.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit' + }); +} + +// Create message element +function createMessageElement(content, role, timestamp) { + const messageDiv = document.createElement('div'); + messageDiv.className = `message ${role}`; + + // Create content div + const contentDiv = document.createElement('div'); + contentDiv.className = 'message-content'; + contentDiv.innerHTML = formatMessageContent(content); + messageDiv.appendChild(contentDiv); + + // Add timestamp + const timestampDiv = document.createElement('div'); + timestampDiv.className = 'timestamp'; + timestampDiv.textContent = formatTimestamp(new Date(timestamp || Date.now())); + messageDiv.appendChild(timestampDiv); + + return messageDiv; +} + +// Format message content (handle markdown-like formatting) +function formatMessageContent(content) { + // Escape HTML + let formatted = content + .replace(/&/g, '&') + .replace(//g, '>'); + + // Handle code blocks + formatted = formatted.replace(/```(\w+)?\n([\s\S]*?)```/g, (match, lang, code) => { + return `
${code.trim()}
`; + }); + + // Handle inline code + formatted = formatted.replace(/`([^`]+)`/g, '$1'); + + // Handle line breaks + formatted = formatted.replace(/\n/g, '
'); + + return formatted; +} + +// Add message to chat +function addMessage(content, role, timestamp) { + // Remove welcome message if it exists + const welcomeMessage = messagesContainer.querySelector('.welcome-message'); + if (welcomeMessage) { + welcomeMessage.remove(); + } + + const messageElement = createMessageElement(content, role, timestamp); + messagesContainer.appendChild(messageElement); + + // Scroll to bottom + messagesContainer.scrollTop = messagesContainer.scrollHeight; +} + +// Add thinking indicator +function addThinkingIndicator() { + removeThinkingIndicator(); // Remove any existing one first + + const thinkingDiv = document.createElement('div'); + thinkingDiv.id = 'thinking-indicator'; + thinkingDiv.className = 'message thinking-message'; + thinkingDiv.innerHTML = ` +
+ + + +
+ Goose is thinking... + `; + messagesContainer.appendChild(thinkingDiv); + messagesContainer.scrollTop = messagesContainer.scrollHeight; +} + +// Remove thinking indicator +function removeThinkingIndicator() { + const thinking = document.getElementById('thinking-indicator'); + if (thinking) { + thinking.remove(); + } +} + +// Connect to WebSocket +function connectWebSocket() { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.host}/ws`; + + socket = new WebSocket(wsUrl); + + socket.onopen = () => { + console.log('WebSocket connected'); + isConnected = true; + connectionStatus.textContent = 'Connected'; + connectionStatus.className = 'status connected'; + sendButton.disabled = false; + + // Check if this session exists and load history if it does + loadSessionIfExists(); + }; + + socket.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + handleServerMessage(data); + } catch (e) { + console.error('Failed to parse message:', e); + } + }; + + socket.onclose = () => { + console.log('WebSocket disconnected'); + isConnected = false; + connectionStatus.textContent = 'Disconnected'; + connectionStatus.className = 'status disconnected'; + sendButton.disabled = true; + + // Attempt to reconnect after 3 seconds + setTimeout(connectWebSocket, 3000); + }; + + socket.onerror = (error) => { + console.error('WebSocket error:', error); + }; +} + +// Handle messages from server +function handleServerMessage(data) { + switch (data.type) { + case 'response': + // For streaming responses, we need to handle partial messages + handleStreamingResponse(data); + break; + case 'tool_request': + handleToolRequest(data); + break; + case 'tool_response': + handleToolResponse(data); + break; + case 'tool_confirmation': + handleToolConfirmation(data); + break; + case 'thinking': + handleThinking(data); + break; + case 'context_exceeded': + handleContextExceeded(data); + break; + case 'cancelled': + handleCancelled(data); + break; + case 'complete': + handleComplete(data); + break; + case 'error': + removeThinkingIndicator(); + resetSendButton(); + addMessage(`Error: ${data.message}`, 'assistant', Date.now()); + break; + default: + console.log('Unknown message type:', data.type); + } +} + +// Track current streaming message +let currentStreamingMessage = null; + +// Handle streaming responses +function handleStreamingResponse(data) { + removeThinkingIndicator(); + + // If this is the first chunk of a new message, or we don't have a current streaming message + if (!currentStreamingMessage) { + // Create a new message element + const messageElement = createMessageElement(data.content, data.role || 'assistant', data.timestamp); + messageElement.setAttribute('data-streaming', 'true'); + messagesContainer.appendChild(messageElement); + + currentStreamingMessage = { + element: messageElement, + content: data.content, + role: data.role || 'assistant', + timestamp: data.timestamp + }; + } else { + // Append to existing streaming message + currentStreamingMessage.content += data.content; + + // Update the message content using the proper content div + const contentDiv = currentStreamingMessage.element.querySelector('.message-content'); + if (contentDiv) { + contentDiv.innerHTML = formatMessageContent(currentStreamingMessage.content); + } + } + + // Scroll to bottom + messagesContainer.scrollTop = messagesContainer.scrollHeight; +} + +// Handle tool requests +function handleToolRequest(data) { + removeThinkingIndicator(); // Remove thinking when tool starts + + // Reset streaming message so tool doesn't interfere with message flow + currentStreamingMessage = null; + + const toolDiv = document.createElement('div'); + toolDiv.className = 'message assistant tool-message'; + + const headerDiv = document.createElement('div'); + headerDiv.className = 'tool-header'; + headerDiv.innerHTML = `🔧 ${data.tool_name}`; + + const contentDiv = document.createElement('div'); + contentDiv.className = 'tool-content'; + + // Format the arguments + if (data.tool_name === 'developer__shell' && data.arguments.command) { + contentDiv.innerHTML = `
${escapeHtml(data.arguments.command)}
`; + } else if (data.tool_name === 'developer__text_editor') { + const action = data.arguments.command || 'unknown'; + const path = data.arguments.path || 'unknown'; + contentDiv.innerHTML = `
action: ${action}
`; + contentDiv.innerHTML += `
path: ${escapeHtml(path)}
`; + if (data.arguments.file_text) { + contentDiv.innerHTML += `
content:
${escapeHtml(data.arguments.file_text.substring(0, 200))}${data.arguments.file_text.length > 200 ? '...' : ''}
`; + } + } else { + contentDiv.innerHTML = `
${JSON.stringify(data.arguments, null, 2)}
`; + } + + toolDiv.appendChild(headerDiv); + toolDiv.appendChild(contentDiv); + + // Add a "running" indicator + const runningDiv = document.createElement('div'); + runningDiv.className = 'tool-running'; + runningDiv.innerHTML = '⏳ Running...'; + toolDiv.appendChild(runningDiv); + + messagesContainer.appendChild(toolDiv); + messagesContainer.scrollTop = messagesContainer.scrollHeight; +} + +// Handle tool responses +function handleToolResponse(data) { + // Remove the "running" indicator from the last tool message + const toolMessages = messagesContainer.querySelectorAll('.tool-message'); + if (toolMessages.length > 0) { + const lastToolMessage = toolMessages[toolMessages.length - 1]; + const runningIndicator = lastToolMessage.querySelector('.tool-running'); + if (runningIndicator) { + runningIndicator.remove(); + } + } + + if (data.is_error) { + const errorDiv = document.createElement('div'); + errorDiv.className = 'message tool-error'; + errorDiv.innerHTML = `Tool Error: ${escapeHtml(data.result.error || 'Unknown error')}`; + messagesContainer.appendChild(errorDiv); + } else { + // Handle successful tool response + if (Array.isArray(data.result)) { + data.result.forEach(content => { + if (content.type === 'text' && content.text) { + const responseDiv = document.createElement('div'); + responseDiv.className = 'message tool-result'; + responseDiv.innerHTML = `
${escapeHtml(content.text)}
`; + messagesContainer.appendChild(responseDiv); + } + }); + } + } + messagesContainer.scrollTop = messagesContainer.scrollHeight; + + // Reset streaming message so next assistant response creates a new message + currentStreamingMessage = null; + + // Show thinking indicator because assistant will likely follow up with explanation + // Only show if we're still processing (cancel button is active) + if (isProcessing) { + addThinkingIndicator(); + } +} + +// Handle tool confirmations +function handleToolConfirmation(data) { + const confirmDiv = document.createElement('div'); + confirmDiv.className = 'message tool-confirmation'; + confirmDiv.innerHTML = ` +
⚠️ Tool Confirmation Required
+
+ ${data.tool_name} wants to execute with: +
${JSON.stringify(data.arguments, null, 2)}
+
+
Auto-approved in web mode (UI coming soon)
+ `; + messagesContainer.appendChild(confirmDiv); + messagesContainer.scrollTop = messagesContainer.scrollHeight; +} + +// Handle thinking messages +function handleThinking(data) { + // For now, just log thinking messages + console.log('Thinking:', data.message); +} + +// Handle context exceeded +function handleContextExceeded(data) { + const contextDiv = document.createElement('div'); + contextDiv.className = 'message context-warning'; + contextDiv.innerHTML = ` +
⚠️ Context Length Exceeded
+
${escapeHtml(data.message)}
+
Auto-summarizing conversation...
+ `; + messagesContainer.appendChild(contextDiv); + messagesContainer.scrollTop = messagesContainer.scrollHeight; +} + +// Handle cancelled operation +function handleCancelled(data) { + removeThinkingIndicator(); + resetSendButton(); + + const cancelDiv = document.createElement('div'); + cancelDiv.className = 'message system-message cancelled'; + cancelDiv.innerHTML = `${escapeHtml(data.message)}`; + messagesContainer.appendChild(cancelDiv); + messagesContainer.scrollTop = messagesContainer.scrollHeight; +} + +// Handle completion of response +function handleComplete(data) { + removeThinkingIndicator(); + resetSendButton(); + // Finalize any streaming message + if (currentStreamingMessage) { + currentStreamingMessage = null; + } +} + +// Reset send button to normal state +function resetSendButton() { + isProcessing = false; + sendButton.textContent = 'Send'; + sendButton.classList.remove('cancel-mode'); +} + +// Escape HTML to prevent XSS +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +// Send message or cancel +function sendMessage() { + if (isProcessing) { + // Cancel the current operation + socket.send(JSON.stringify({ + type: 'cancel', + session_id: sessionId + })); + return; + } + + const message = messageInput.value.trim(); + if (!message || !isConnected) return; + + // Add user message to chat + addMessage(message, 'user', Date.now()); + + // Clear input + messageInput.value = ''; + messageInput.style.height = 'auto'; + + // Add thinking indicator + addThinkingIndicator(); + + // Update button to show cancel + isProcessing = true; + sendButton.textContent = 'Cancel'; + sendButton.classList.add('cancel-mode'); + + // Send message through WebSocket + socket.send(JSON.stringify({ + type: 'message', + content: message, + session_id: sessionId, + timestamp: Date.now() + })); +} + +// Handle suggestion pill clicks +function sendSuggestion(text) { + if (!isConnected || isProcessing) return; + + messageInput.value = text; + sendMessage(); +} + +// Load session history if the session exists (like --resume in CLI) +async function loadSessionIfExists() { + try { + const response = await fetch(`/api/sessions/${sessionId}`); + if (response.ok) { + const sessionData = await response.json(); + if (sessionData.messages && sessionData.messages.length > 0) { + // Remove welcome message since we're resuming + const welcomeMessage = messagesContainer.querySelector('.welcome-message'); + if (welcomeMessage) { + welcomeMessage.remove(); + } + + // Display session resumed message + const resumeDiv = document.createElement('div'); + resumeDiv.className = 'message system-message'; + resumeDiv.innerHTML = `Session resumed: ${sessionData.messages.length} messages loaded`; + messagesContainer.appendChild(resumeDiv); + + + // Update page title with session description if available + if (sessionData.metadata && sessionData.metadata.description) { + document.title = `Goose Chat - ${sessionData.metadata.description}`; + } + + messagesContainer.scrollTop = messagesContainer.scrollHeight; + } + } + } catch (error) { + console.log('No existing session found or error loading:', error); + // This is fine - just means it's a new session + } +} + + +// Event listeners +sendButton.addEventListener('click', sendMessage); + +messageInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + sendMessage(); + } +}); + +// Auto-resize textarea +messageInput.addEventListener('input', () => { + messageInput.style.height = 'auto'; + messageInput.style.height = messageInput.scrollHeight + 'px'; +}); + +// Initialize WebSocket connection +connectWebSocket(); + +// Focus on input +messageInput.focus(); + +// Update session title +function updateSessionTitle() { + const titleElement = document.getElementById('session-title'); + // Just show "Goose Chat" - no need to show session ID + titleElement.textContent = 'Goose Chat'; +} + +// Update title on load +updateSessionTitle(); \ No newline at end of file diff --git a/crates/goose-cli/static/style.css b/crates/goose-cli/static/style.css new file mode 100644 index 00000000..f2f1eb3e --- /dev/null +++ b/crates/goose-cli/static/style.css @@ -0,0 +1,480 @@ +:root { + /* Dark theme colors (matching the dark.png) */ + --bg-primary: #000000; + --bg-secondary: #0a0a0a; + --bg-tertiary: #1a1a1a; + --text-primary: #ffffff; + --text-secondary: #a0a0a0; + --text-muted: #666666; + --border-color: #333333; + --border-subtle: #1a1a1a; + --accent-color: #ffffff; + --accent-hover: #f0f0f0; + --user-bg: #1a1a1a; + --assistant-bg: #0a0a0a; + --input-bg: #0a0a0a; + --input-border: #333333; + --button-bg: #ffffff; + --button-text: #000000; + --button-hover: #e0e0e0; + --pill-bg: transparent; + --pill-border: #333333; + --pill-hover: #1a1a1a; + --tool-bg: #0f0f0f; + --code-bg: #0f0f0f; +} + +/* Light theme */ +@media (prefers-color-scheme: light) { + :root { + --bg-primary: #ffffff; + --bg-secondary: #fafafa; + --bg-tertiary: #f5f5f5; + --text-primary: #000000; + --text-secondary: #666666; + --text-muted: #999999; + --border-color: #e1e5e9; + --border-subtle: #f0f0f0; + --accent-color: #000000; + --accent-hover: #333333; + --user-bg: #f0f0f0; + --assistant-bg: #fafafa; + --input-bg: #ffffff; + --input-border: #e1e5e9; + --button-bg: #000000; + --button-text: #ffffff; + --button-hover: #333333; + --pill-bg: #f5f5f5; + --pill-border: #e1e5e9; + --pill-hover: #e8eaed; + --tool-bg: #f8f9fa; + --code-bg: #f5f5f5; + } +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background-color: var(--bg-primary); + color: var(--text-primary); + line-height: 1.5; + height: 100vh; + overflow: hidden; + font-size: 14px; +} + +.container { + display: flex; + flex-direction: column; + height: 100vh; + max-width: 100%; + margin: 0 auto; +} + +header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1.5rem; + background-color: var(--bg-primary); + border-bottom: 1px solid var(--border-subtle); +} + +header h1 { + font-size: 1.25rem; + font-weight: 600; + display: flex; + align-items: center; + gap: 0.75rem; +} + +header h1::before { + content: "🪿"; + font-size: 1.5rem; +} + +.status { + font-size: 0.75rem; + color: var(--text-secondary); + padding: 0.25rem 0.75rem; + border-radius: 1rem; + background-color: var(--bg-secondary); + border: 1px solid var(--border-color); +} + +.status.connected { + color: #10b981; + border-color: #10b981; + background-color: rgba(16, 185, 129, 0.1); +} + +.status.disconnected { + color: #ef4444; + border-color: #ef4444; + background-color: rgba(239, 68, 68, 0.1); +} + +.chat-container { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.messages { + flex: 1; + overflow-y: auto; + padding: 2rem; + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.welcome-message { + text-align: center; + padding: 4rem 2rem; + color: var(--text-secondary); +} + +.welcome-message h2 { + font-size: 1.5rem; + margin-bottom: 1rem; + color: var(--text-primary); + font-weight: 600; +} + +.welcome-message p { + font-size: 1rem; + margin-bottom: 2rem; +} + +/* Suggestion pills like in the design */ +.suggestion-pills { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + justify-content: center; + margin-top: 2rem; +} + +.suggestion-pill { + padding: 0.75rem 1.25rem; + background-color: var(--pill-bg); + border: 1px solid var(--pill-border); + border-radius: 2rem; + color: var(--text-primary); + font-size: 0.875rem; + cursor: pointer; + transition: all 0.2s ease; + text-decoration: none; + display: inline-block; +} + +.suggestion-pill:hover { + background-color: var(--pill-hover); + border-color: var(--border-color); +} + +.message { + max-width: 80%; + padding: 1rem 1.25rem; + border-radius: 1rem; + word-wrap: break-word; + position: relative; +} + +.message.user { + align-self: flex-end; + background-color: var(--user-bg); + margin-left: auto; + border: 1px solid var(--border-subtle); +} + +.message.assistant { + align-self: flex-start; + background-color: var(--assistant-bg); + border: 1px solid var(--border-subtle); +} + +.message-content { + flex: 1; + margin-bottom: 0.5rem; +} + +.message .timestamp { + font-size: 0.6875rem; + color: var(--text-muted); + margin-top: 0.5rem; + opacity: 0.7; +} + +.message pre { + background-color: var(--code-bg); + padding: 0.75rem; + border-radius: 0.5rem; + overflow-x: auto; + margin: 0.75rem 0; + border: 1px solid var(--border-color); + font-size: 0.8125rem; +} + +.message code { + background-color: var(--code-bg); + padding: 0.125rem 0.375rem; + border-radius: 0.25rem; + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace; + font-size: 0.8125rem; + border: 1px solid var(--border-color); +} + +.input-container { + display: flex; + gap: 0.75rem; + padding: 1.5rem; + background-color: var(--bg-primary); + border-top: 1px solid var(--border-subtle); +} + +#message-input { + flex: 1; + padding: 0.875rem 1rem; + border: 1px solid var(--input-border); + border-radius: 0.75rem; + background-color: var(--input-bg); + color: var(--text-primary); + font-family: inherit; + font-size: 0.875rem; + resize: none; + min-height: 2.75rem; + max-height: 8rem; + outline: none; + transition: border-color 0.2s ease; +} + +#message-input:focus { + border-color: var(--accent-color); +} + +#message-input::placeholder { + color: var(--text-muted); +} + +#send-button { + padding: 0.875rem 1.5rem; + background-color: var(--button-bg); + color: var(--button-text); + border: none; + border-radius: 0.75rem; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + min-width: 4rem; +} + +#send-button:hover { + background-color: var(--button-hover); + transform: translateY(-1px); +} + +#send-button:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +#send-button.cancel-mode { + background-color: #ef4444; + color: #ffffff; +} + +#send-button.cancel-mode:hover { + background-color: #dc2626; +} + +/* Scrollbar styling */ +.messages::-webkit-scrollbar { + width: 6px; +} + +.messages::-webkit-scrollbar-track { + background: transparent; +} + +.messages::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 3px; +} + +.messages::-webkit-scrollbar-thumb:hover { + background: var(--text-secondary); +} + +/* Tool call styling */ +.tool-message, .tool-result, .tool-error, .tool-confirmation, .context-warning { + background-color: var(--tool-bg); + border: 1px solid var(--border-color); + border-radius: 0.75rem; + padding: 1rem; + margin: 0.75rem 0; + max-width: 90%; +} + +.tool-header, .tool-confirm-header, .context-header { + font-weight: 600; + color: var(--accent-color); + margin-bottom: 0.75rem; + font-size: 0.875rem; +} + +.tool-content { + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace; + font-size: 0.8125rem; + color: var(--text-secondary); +} + +.tool-param { + margin: 0.5rem 0; +} + +.tool-param strong { + color: var(--text-primary); +} + +.tool-running { + font-size: 0.8125rem; + color: var(--accent-color); + margin-top: 0.75rem; + font-style: italic; +} + +.tool-error { + border-color: #ef4444; + background-color: rgba(239, 68, 68, 0.05); +} + +.tool-error strong { + color: #ef4444; +} + +.tool-result { + background-color: var(--tool-bg); + border-left: 3px solid var(--accent-color); + margin-left: 1.5rem; + border-radius: 0.5rem; +} + +.tool-confirmation { + border-color: #f59e0b; + background-color: rgba(245, 158, 11, 0.05); +} + +.tool-confirm-note, .context-note { + font-size: 0.75rem; + color: var(--text-muted); + margin-top: 0.75rem; + font-style: italic; +} + +.context-warning { + border-color: #f59e0b; + background-color: rgba(245, 158, 11, 0.05); +} + +.context-header { + color: #f59e0b; +} + +.system-message { + text-align: center; + color: var(--text-secondary); + font-style: italic; + margin: 1rem 0; + font-size: 0.875rem; +} + +.cancelled { + color: #ef4444; +} + +/* Thinking indicator */ +.thinking-message { + display: flex; + align-items: center; + gap: 0.75rem; + color: var(--text-secondary); + font-style: italic; + padding: 1rem 1.25rem; + background-color: var(--bg-secondary); + border-radius: 1rem; + border: 1px solid var(--border-subtle); + max-width: 80%; + font-size: 0.875rem; +} + +.thinking-dots { + display: flex; + gap: 0.25rem; +} + +.thinking-dots span { + width: 6px; + height: 6px; + border-radius: 50%; + background-color: var(--text-secondary); + animation: thinking-bounce 1.4s infinite ease-in-out both; +} + +.thinking-dots span:nth-child(1) { + animation-delay: -0.32s; +} + +.thinking-dots span:nth-child(2) { + animation-delay: -0.16s; +} + +@keyframes thinking-bounce { + 0%, 80%, 100% { + transform: scale(0.6); + opacity: 0.5; + } + 40% { + transform: scale(1); + opacity: 1; + } +} + +/* Keep the old loading indicator for backwards compatibility */ +.loading-message { + display: none; +} + +/* Responsive design */ +@media (max-width: 768px) { + .messages { + padding: 1rem; + gap: 1rem; + } + + .message { + max-width: 90%; + padding: 0.875rem 1rem; + } + + .input-container { + padding: 1rem; + } + + header { + padding: 0.75rem 1rem; + } + + .welcome-message { + padding: 2rem 1rem; + } +} \ No newline at end of file diff --git a/test_web.sh b/test_web.sh new file mode 100644 index 00000000..adfd4d95 --- /dev/null +++ b/test_web.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# Test script for Goose Web Interface + +echo "Testing Goose Web Interface..." +echo "================================" + +# Start the web server in the background +echo "Starting web server on port 8080..." +./target/debug/goose web --port 8080 & +SERVER_PID=$! + +# Wait for server to start +sleep 2 + +# Test the health endpoint +echo -e "\nTesting health endpoint:" +curl -s http://localhost:8080/api/health | jq . + +# Open browser (optional) +# open http://localhost:8080 + +echo -e "\nWeb server is running at http://localhost:8080" +echo "Press Ctrl+C to stop the server" + +# Wait for user to stop +wait $SERVER_PID \ No newline at end of file From 6076c9b5dca922429de517693fe336acd744e4a7 Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Thu, 5 Jun 2025 13:34:31 +1000 Subject: [PATCH 39/40] fix: pass ref in pr comment workflow (#2777) Co-authored-by: Michael Neale --- .github/workflows/bundle-desktop-intel.yml | 7 +++++++ .github/workflows/bundle-desktop-windows.yml | 7 +++++++ .github/workflows/pr-comment-bundle-intel.yml | 16 ++++++++++++++++ .github/workflows/pr-comment-bundle-windows.yml | 16 ++++++++++++++++ .github/workflows/trigger-build-cli.yml | 10 ---------- 5 files changed, 46 insertions(+), 10 deletions(-) delete mode 100644 .github/workflows/trigger-build-cli.yml diff --git a/.github/workflows/bundle-desktop-intel.yml b/.github/workflows/bundle-desktop-intel.yml index 8d614af2..b4b9e77b 100644 --- a/.github/workflows/bundle-desktop-intel.yml +++ b/.github/workflows/bundle-desktop-intel.yml @@ -21,6 +21,10 @@ on: required: false default: true type: boolean + ref: + type: string + required: false + default: 'refs/heads/main' secrets: CERTIFICATE_OSX_APPLICATION: description: 'Certificate for macOS application signing' @@ -77,6 +81,9 @@ jobs: - name: Checkout code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + ref: ${{ inputs.ref }} + fetch-depth: 0 # Update versions before build - name: Update versions diff --git a/.github/workflows/bundle-desktop-windows.yml b/.github/workflows/bundle-desktop-windows.yml index b0757ac4..784079cf 100644 --- a/.github/workflows/bundle-desktop-windows.yml +++ b/.github/workflows/bundle-desktop-windows.yml @@ -17,6 +17,10 @@ on: required: false WINDOWS_CERTIFICATE_PASSWORD: required: false + ref: + type: string + required: false + default: 'refs/heads/main' jobs: build-desktop-windows: @@ -27,6 +31,9 @@ jobs: # 1) Check out source - name: Checkout repository uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 + with: + ref: ${{ inputs.ref }} + fetch-depth: 0 # 2) Set up Node.js - name: Set up Node.js diff --git a/.github/workflows/pr-comment-bundle-intel.yml b/.github/workflows/pr-comment-bundle-intel.yml index b7fa7ca8..24224b1d 100644 --- a/.github/workflows/pr-comment-bundle-intel.yml +++ b/.github/workflows/pr-comment-bundle-intel.yml @@ -30,6 +30,7 @@ jobs: continue: ${{ steps.command.outputs.continue || github.event_name == 'workflow_dispatch' }} # Cannot use github.event.pull_request.number since the trigger is 'issue_comment' pr_number: ${{ steps.command.outputs.issue_number || github.event.inputs.pr_number }} + head_sha: ${{ steps.set_head_sha.outputs.head_sha || github.sha }} steps: - if: ${{ github.event_name == 'issue_comment' }} uses: github/command@319d5236cc34ed2cb72a47c058a363db0b628ebe # pin@v1.3.0 @@ -40,6 +41,20 @@ jobs: reaction: "eyes" allowed_contexts: pull_request + - name: Checkout code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4 + + - name: Get PR head SHA with gh + id: set_head_sha + run: | + echo "Get PR head SHA with gh" + HEAD_SHA=$(gh pr view "$ISSUE_NUMBER" --json headRefOid -q .headRefOid) + echo "head_sha=$HEAD_SHA" >> $GITHUB_OUTPUT + echo "head_sha=$HEAD_SHA" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ISSUE_NUMBER: ${{ steps.command.outputs.issue_number }} + bundle-desktop-intel: # Only run this if ".bundle-intel" command is detected. needs: [trigger-on-command] @@ -47,6 +62,7 @@ jobs: uses: ./.github/workflows/bundle-desktop-intel.yml with: signing: true + ref: ${{ needs.trigger-on-command.outputs.head_sha }} secrets: CERTIFICATE_OSX_APPLICATION: ${{ secrets.CERTIFICATE_OSX_APPLICATION }} CERTIFICATE_PASSWORD: ${{ secrets.CERTIFICATE_PASSWORD }} diff --git a/.github/workflows/pr-comment-bundle-windows.yml b/.github/workflows/pr-comment-bundle-windows.yml index 6611863d..a394818e 100644 --- a/.github/workflows/pr-comment-bundle-windows.yml +++ b/.github/workflows/pr-comment-bundle-windows.yml @@ -30,6 +30,7 @@ jobs: continue: ${{ steps.command.outputs.continue || github.event_name == 'workflow_dispatch' }} # Cannot use github.event.pull_request.number since the trigger is 'issue_comment' pr_number: ${{ steps.command.outputs.issue_number || github.event.inputs.pr_number }} + head_sha: ${{ steps.set_head_sha.outputs.head_sha || github.sha }} steps: - if: ${{ github.event_name == 'issue_comment' }} uses: github/command@319d5236cc34ed2cb72a47c058a363db0b628ebe # pin@v1.3.0 @@ -40,6 +41,20 @@ jobs: reaction: "eyes" allowed_contexts: pull_request + - name: Checkout code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # pin@v4 + + - name: Get PR head SHA with gh + id: set_head_sha + run: | + echo "Get PR head SHA with gh" + HEAD_SHA=$(gh pr view "$ISSUE_NUMBER" --json headRefOid -q .headRefOid) + echo "head_sha=$HEAD_SHA" >> $GITHUB_OUTPUT + echo "head_sha=$HEAD_SHA" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ISSUE_NUMBER: ${{ steps.command.outputs.issue_number }} + bundle-desktop-windows: # Only run this if ".bundle-windows" command is detected. needs: [trigger-on-command] @@ -47,6 +62,7 @@ jobs: uses: ./.github/workflows/bundle-desktop-windows.yml with: signing: false # false for now as we don't have a cert yet + ref: ${{ needs.trigger-on-command.outputs.head_sha }} secrets: WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} diff --git a/.github/workflows/trigger-build-cli.yml b/.github/workflows/trigger-build-cli.yml deleted file mode 100644 index 6647a746..00000000 --- a/.github/workflows/trigger-build-cli.yml +++ /dev/null @@ -1,10 +0,0 @@ -on: - workflow_dispatch: - -name: Trigger Build CLI - -jobs: - trigger-build-cli: - uses: ./.github/workflows/build-cli.yml - with: - ref: ${{ github.sha }} \ No newline at end of file From 2f8f8e5767bb1fdc53dfaa4a492c9184f02c3721 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Thu, 5 Jun 2025 13:55:32 +1000 Subject: [PATCH 40/40] feat: lead/worker model (#2719) --- README.md | 25 + crates/goose-cli/src/session/builder.rs | 25 +- crates/goose-cli/src/session/output.rs | 53 +- crates/goose/src/providers/base.rs | 12 + crates/goose/src/providers/factory.rs | 284 ++++++++ crates/goose/src/providers/lead_worker.rs | 637 ++++++++++++++++++ crates/goose/src/providers/mod.rs | 1 + .../docs/guides/environment-variables.md | 31 +- test_lead_worker.sh | 31 + 9 files changed, 1088 insertions(+), 11 deletions(-) create mode 100644 crates/goose/src/providers/lead_worker.rs create mode 100755 test_lead_worker.sh diff --git a/README.md b/README.md index f2baddfe..ab0c9123 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,31 @@ Whether you're prototyping an idea, refining existing code, or managing intricat Designed for maximum flexibility, goose works with any LLM, seamlessly integrates with MCP servers, and is available as both a desktop app as well as CLI - making it the ultimate AI assistant for developers who want to move faster and focus on innovation. +## Multiple Model Configuration + +goose supports using different models for different purposes to optimize performance and cost, which can work across model providers as well as models. + +### Lead/Worker Model Pattern +Use a powerful model for initial planning and complex reasoning, then switch to a faster/cheaper model for execution, this happens automatically by goose: + +```bash +# Required: Enable lead model mode +export GOOSE_LEAD_MODEL=modelY +# Optional: configure a provider for the lead model if not the default provider +export GOOSE_LEAD_PROVIDER=providerX # Defaults to main provider +``` + +### Planning Model Configuration +Use a specialized model for the `/plan` command in CLI mode, this is explicitly invoked when you want to plan (vs execute) + +```bash +# Optional: Use different model for planning +export GOOSE_PLANNER_PROVIDER=openai +export GOOSE_PLANNER_MODEL=gpt-4 +``` + +Both patterns help you balance model capabilities with cost and speed for optimal results, and switch between models and vendors as required. + # Quick Links - [Quickstart](https://block.github.io/goose/docs/quickstart) diff --git a/crates/goose-cli/src/session/builder.rs b/crates/goose-cli/src/session/builder.rs index f7cfeba7..1190220b 100644 --- a/crates/goose-cli/src/session/builder.rs +++ b/crates/goose-cli/src/session/builder.rs @@ -7,6 +7,7 @@ use goose::session; use goose::session::Identifier; use mcp_client::transport::Error as McpClientError; use std::process; +use std::sync::Arc; use super::output; use super::Session; @@ -55,6 +56,22 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> Session { // Create the agent let agent: Agent = Agent::new(); let new_provider = create(&provider_name, model_config).unwrap(); + + // Keep a reference to the provider for display_session_info + let provider_for_display = Arc::clone(&new_provider); + + // Log model information at startup + if let Some(lead_worker) = new_provider.as_lead_worker() { + let (lead_model, worker_model) = lead_worker.get_model_info(); + tracing::info!( + "🤖 Lead/Worker Mode Enabled: Lead model (first 3 turns): {}, Worker model (turn 4+): {}, Auto-fallback on failures: Enabled", + lead_model, + worker_model + ); + } else { + tracing::info!("🤖 Using model: {}", model); + } + agent .update_provider(new_provider) .await @@ -217,6 +234,12 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> Session { session.agent.override_system_prompt(override_prompt).await; } - output::display_session_info(session_config.resume, &provider_name, &model, &session_file); + output::display_session_info( + session_config.resume, + &provider_name, + &model, + &session_file, + Some(&provider_for_display), + ); session } diff --git a/crates/goose-cli/src/session/output.rs b/crates/goose-cli/src/session/output.rs index ea822c55..525faa74 100644 --- a/crates/goose-cli/src/session/output.rs +++ b/crates/goose-cli/src/session/output.rs @@ -10,6 +10,7 @@ use std::cell::RefCell; use std::collections::HashMap; use std::io::Error; use std::path::Path; +use std::sync::Arc; use std::time::Duration; // Re-export theme for use in main @@ -536,7 +537,13 @@ fn shorten_path(path: &str, debug: bool) -> String { } // Session display functions -pub fn display_session_info(resume: bool, provider: &str, model: &str, session_file: &Path) { +pub fn display_session_info( + resume: bool, + provider: &str, + model: &str, + session_file: &Path, + provider_instance: Option<&Arc>, +) { let start_session_msg = if resume { "resuming session |" } else if session_file.to_str() == Some("/dev/null") || session_file.to_str() == Some("NUL") { @@ -544,14 +551,42 @@ pub fn display_session_info(resume: bool, provider: &str, model: &str, session_f } else { "starting session |" }; - println!( - "{} {} {} {} {}", - style(start_session_msg).dim(), - style("provider:").dim(), - style(provider).cyan().dim(), - style("model:").dim(), - style(model).cyan().dim(), - ); + + // Check if we have lead/worker mode + if let Some(provider_inst) = provider_instance { + if let Some(lead_worker) = provider_inst.as_lead_worker() { + let (lead_model, worker_model) = lead_worker.get_model_info(); + println!( + "{} {} {} {} {} {} {}", + style(start_session_msg).dim(), + style("provider:").dim(), + style(provider).cyan().dim(), + style("lead model:").dim(), + style(&lead_model).cyan().dim(), + style("worker model:").dim(), + style(&worker_model).cyan().dim(), + ); + } else { + println!( + "{} {} {} {} {}", + style(start_session_msg).dim(), + style("provider:").dim(), + style(provider).cyan().dim(), + style("model:").dim(), + style(model).cyan().dim(), + ); + } + } else { + // Fallback to original behavior if no provider instance + println!( + "{} {} {} {} {}", + style(start_session_msg).dim(), + style("provider:").dim(), + style(provider).cyan().dim(), + style("model:").dim(), + style(model).cyan().dim(), + ); + } if session_file.to_str() != Some("/dev/null") && session_file.to_str() != Some("NUL") { println!( diff --git a/crates/goose/src/providers/base.rs b/crates/goose/src/providers/base.rs index c7062642..2059ab00 100644 --- a/crates/goose/src/providers/base.rs +++ b/crates/goose/src/providers/base.rs @@ -148,6 +148,12 @@ impl Usage { use async_trait::async_trait; +/// Trait for LeadWorkerProvider-specific functionality +pub trait LeadWorkerProviderTrait { + /// Get information about the lead and worker models for logging + fn get_model_info(&self) -> (String, String); +} + /// Base trait for AI providers (OpenAI, Anthropic, etc) #[async_trait] pub trait Provider: Send + Sync { @@ -195,6 +201,12 @@ pub trait Provider: Send + Sync { "This provider does not support embeddings".to_string(), )) } + + /// Check if this provider is a LeadWorkerProvider + /// This is used for logging model information at startup + fn as_lead_worker(&self) -> Option<&dyn LeadWorkerProviderTrait> { + None + } } #[cfg(test)] diff --git a/crates/goose/src/providers/factory.rs b/crates/goose/src/providers/factory.rs index 42d7e69b..22bdaa95 100644 --- a/crates/goose/src/providers/factory.rs +++ b/crates/goose/src/providers/factory.rs @@ -10,6 +10,7 @@ use super::{ githubcopilot::GithubCopilotProvider, google::GoogleProvider, groq::GroqProvider, + lead_worker::LeadWorkerProvider, ollama::OllamaProvider, openai::OpenAiProvider, openrouter::OpenRouterProvider, @@ -19,6 +20,21 @@ use super::{ use crate::model::ModelConfig; use anyhow::Result; +#[cfg(test)] +use super::errors::ProviderError; +#[cfg(test)] +use mcp_core::tool::Tool; + +fn default_lead_turns() -> usize { + 3 +} +fn default_failure_threshold() -> usize { + 2 +} +fn default_fallback_turns() -> usize { + 2 +} + pub fn providers() -> Vec { vec![ AnthropicProvider::metadata(), @@ -38,6 +54,62 @@ pub fn providers() -> Vec { } pub fn create(name: &str, model: ModelConfig) -> Result> { + let config = crate::config::Config::global(); + + // Check for lead model environment variables + if let Ok(lead_model_name) = config.get_param::("GOOSE_LEAD_MODEL") { + tracing::info!("Creating lead/worker provider from environment variables"); + + return create_lead_worker_from_env(name, &model, &lead_model_name); + } + + // Default: create regular provider + create_provider(name, model) +} + +/// Create a lead/worker provider from environment variables +fn create_lead_worker_from_env( + default_provider_name: &str, + default_model: &ModelConfig, + lead_model_name: &str, +) -> Result> { + let config = crate::config::Config::global(); + + // Get lead provider (optional, defaults to main provider) + let lead_provider_name = config + .get_param::("GOOSE_LEAD_PROVIDER") + .unwrap_or_else(|_| default_provider_name.to_string()); + + // Get configuration parameters with defaults + let lead_turns = config + .get_param::("GOOSE_LEAD_TURNS") + .unwrap_or(default_lead_turns()); + let failure_threshold = config + .get_param::("GOOSE_LEAD_FAILURE_THRESHOLD") + .unwrap_or(default_failure_threshold()); + let fallback_turns = config + .get_param::("GOOSE_LEAD_FALLBACK_TURNS") + .unwrap_or(default_fallback_turns()); + + // Create model configs + let lead_model_config = ModelConfig::new(lead_model_name.to_string()); + let worker_model_config = default_model.clone(); + + // Create the providers + let lead_provider = create_provider(&lead_provider_name, lead_model_config)?; + let worker_provider = create_provider(default_provider_name, worker_model_config)?; + + // Create the lead/worker provider with configured settings + Ok(Arc::new(LeadWorkerProvider::new_with_settings( + lead_provider, + worker_provider, + lead_turns, + failure_threshold, + fallback_turns, + ))) +} + +fn create_provider(name: &str, model: ModelConfig) -> Result> { // We use Arc instead of Box to be able to clone for multiple async tasks match name { "openai" => Ok(Arc::new(OpenAiProvider::from_env(model)?)), @@ -56,3 +128,215 @@ pub fn create(name: &str, model: ModelConfig) -> Result> { _ => Err(anyhow::anyhow!("Unknown provider: {}", name)), } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::{Message, MessageContent}; + use crate::providers::base::{ProviderMetadata, ProviderUsage, Usage}; + use chrono::Utc; + use mcp_core::{content::TextContent, Role}; + use std::env; + + #[derive(Clone)] + struct MockTestProvider { + name: String, + model_config: ModelConfig, + } + + #[async_trait::async_trait] + impl Provider for MockTestProvider { + fn metadata() -> ProviderMetadata { + ProviderMetadata::new( + "mock_test", + "Mock Test Provider", + "A mock provider for testing", + "mock-model", + vec!["mock-model"], + "", + vec![], + ) + } + + fn get_model_config(&self) -> ModelConfig { + self.model_config.clone() + } + + async fn complete( + &self, + _system: &str, + _messages: &[Message], + _tools: &[Tool], + ) -> Result<(Message, ProviderUsage), ProviderError> { + Ok(( + Message { + role: Role::Assistant, + created: Utc::now().timestamp(), + content: vec![MessageContent::Text(TextContent { + text: format!( + "Response from {} with model {}", + self.name, self.model_config.model_name + ), + annotations: None, + })], + }, + ProviderUsage::new(self.model_config.model_name.clone(), Usage::default()), + )) + } + } + + #[test] + fn test_create_lead_worker_provider() { + // Save current env vars + let saved_lead = env::var("GOOSE_LEAD_MODEL").ok(); + let saved_provider = env::var("GOOSE_LEAD_PROVIDER").ok(); + let saved_turns = env::var("GOOSE_LEAD_TURNS").ok(); + + // Test with basic lead model configuration + env::set_var("GOOSE_LEAD_MODEL", "gpt-4o"); + + // This will try to create a lead/worker provider + let result = create("openai", ModelConfig::new("gpt-4o-mini".to_string())); + + // The creation might succeed or fail depending on API keys, but we can verify the logic path + match result { + Ok(_) => { + // If it succeeds, it means we created a lead/worker provider successfully + // This would happen if API keys are available in the test environment + } + Err(error) => { + // If it fails, it should be due to missing API keys, confirming we tried to create providers + let error_msg = error.to_string(); + assert!(error_msg.contains("OPENAI_API_KEY") || error_msg.contains("secret")); + } + } + + // Test with different lead provider + env::set_var("GOOSE_LEAD_PROVIDER", "anthropic"); + env::set_var("GOOSE_LEAD_TURNS", "5"); + + let _result = create("openai", ModelConfig::new("gpt-4o-mini".to_string())); + // Similar validation as above - will fail due to missing API keys but confirms the logic + + // Restore env vars + match saved_lead { + Some(val) => env::set_var("GOOSE_LEAD_MODEL", val), + None => env::remove_var("GOOSE_LEAD_MODEL"), + } + match saved_provider { + Some(val) => env::set_var("GOOSE_LEAD_PROVIDER", val), + None => env::remove_var("GOOSE_LEAD_PROVIDER"), + } + match saved_turns { + Some(val) => env::set_var("GOOSE_LEAD_TURNS", val), + None => env::remove_var("GOOSE_LEAD_TURNS"), + } + } + + #[test] + fn test_lead_model_env_vars_with_defaults() { + // Save current env vars + let saved_vars = [ + ("GOOSE_LEAD_MODEL", env::var("GOOSE_LEAD_MODEL").ok()), + ("GOOSE_LEAD_PROVIDER", env::var("GOOSE_LEAD_PROVIDER").ok()), + ("GOOSE_LEAD_TURNS", env::var("GOOSE_LEAD_TURNS").ok()), + ( + "GOOSE_LEAD_FAILURE_THRESHOLD", + env::var("GOOSE_LEAD_FAILURE_THRESHOLD").ok(), + ), + ( + "GOOSE_LEAD_FALLBACK_TURNS", + env::var("GOOSE_LEAD_FALLBACK_TURNS").ok(), + ), + ]; + + // Clear all lead env vars + for (key, _) in &saved_vars { + env::remove_var(key); + } + + // Set only the required lead model + env::set_var("GOOSE_LEAD_MODEL", "gpt-4o"); + + // This should use defaults for all other values + let result = create("openai", ModelConfig::new("gpt-4o-mini".to_string())); + + // Should attempt to create lead/worker provider (will fail due to missing API keys but confirms logic) + match result { + Ok(_) => { + // Success means we have API keys and created the provider + } + Err(error) => { + // Should fail due to missing API keys, confirming we tried to create providers + let error_msg = error.to_string(); + assert!(error_msg.contains("OPENAI_API_KEY") || error_msg.contains("secret")); + } + } + + // Test with custom values + env::set_var("GOOSE_LEAD_TURNS", "7"); + env::set_var("GOOSE_LEAD_FAILURE_THRESHOLD", "4"); + env::set_var("GOOSE_LEAD_FALLBACK_TURNS", "3"); + + let _result = create("openai", ModelConfig::new("gpt-4o-mini".to_string())); + // Should still attempt to create lead/worker provider with custom settings + + // Restore all env vars + for (key, value) in saved_vars { + match value { + Some(val) => env::set_var(key, val), + None => env::remove_var(key), + } + } + } + + #[test] + fn test_create_regular_provider_without_lead_config() { + // Save current env vars + let saved_lead = env::var("GOOSE_LEAD_MODEL").ok(); + let saved_provider = env::var("GOOSE_LEAD_PROVIDER").ok(); + let saved_turns = env::var("GOOSE_LEAD_TURNS").ok(); + let saved_threshold = env::var("GOOSE_LEAD_FAILURE_THRESHOLD").ok(); + let saved_fallback = env::var("GOOSE_LEAD_FALLBACK_TURNS").ok(); + + // Ensure all GOOSE_LEAD_* variables are not set + env::remove_var("GOOSE_LEAD_MODEL"); + env::remove_var("GOOSE_LEAD_PROVIDER"); + env::remove_var("GOOSE_LEAD_TURNS"); + env::remove_var("GOOSE_LEAD_FAILURE_THRESHOLD"); + env::remove_var("GOOSE_LEAD_FALLBACK_TURNS"); + + // This should try to create a regular provider + let result = create("openai", ModelConfig::new("gpt-4o-mini".to_string())); + + // The creation might succeed or fail depending on API keys + match result { + Ok(_) => { + // If it succeeds, it means we created a regular provider successfully + // This would happen if API keys are available in the test environment + } + Err(error) => { + // If it fails, it should be due to missing API keys + let error_msg = error.to_string(); + assert!(error_msg.contains("OPENAI_API_KEY") || error_msg.contains("secret")); + } + } + + // Restore env vars + if let Some(val) = saved_lead { + env::set_var("GOOSE_LEAD_MODEL", val); + } + if let Some(val) = saved_provider { + env::set_var("GOOSE_LEAD_PROVIDER", val); + } + if let Some(val) = saved_turns { + env::set_var("GOOSE_LEAD_TURNS", val); + } + if let Some(val) = saved_threshold { + env::set_var("GOOSE_LEAD_FAILURE_THRESHOLD", val); + } + if let Some(val) = saved_fallback { + env::set_var("GOOSE_LEAD_FALLBACK_TURNS", val); + } + } +} diff --git a/crates/goose/src/providers/lead_worker.rs b/crates/goose/src/providers/lead_worker.rs new file mode 100644 index 00000000..a242dcb9 --- /dev/null +++ b/crates/goose/src/providers/lead_worker.rs @@ -0,0 +1,637 @@ +use anyhow::Result; +use async_trait::async_trait; +use std::sync::Arc; +use tokio::sync::Mutex; + +use super::base::{LeadWorkerProviderTrait, Provider, ProviderMetadata, ProviderUsage}; +use super::errors::ProviderError; +use crate::message::{Message, MessageContent}; +use crate::model::ModelConfig; +use mcp_core::{tool::Tool, Content}; + +/// A provider that switches between a lead model and a worker model based on turn count +/// and can fallback to lead model on consecutive failures +pub struct LeadWorkerProvider { + lead_provider: Arc, + worker_provider: Arc, + lead_turns: usize, + turn_count: Arc>, + failure_count: Arc>, + max_failures_before_fallback: usize, + fallback_turns: usize, + in_fallback_mode: Arc>, + fallback_remaining: Arc>, +} + +impl LeadWorkerProvider { + /// Create a new LeadWorkerProvider + /// + /// # Arguments + /// * `lead_provider` - The provider to use for the initial turns + /// * `worker_provider` - The provider to use after lead_turns + /// * `lead_turns` - Number of turns to use the lead provider (default: 3) + pub fn new( + lead_provider: Arc, + worker_provider: Arc, + lead_turns: Option, + ) -> Self { + Self { + lead_provider, + worker_provider, + lead_turns: lead_turns.unwrap_or(3), + turn_count: Arc::new(Mutex::new(0)), + failure_count: Arc::new(Mutex::new(0)), + max_failures_before_fallback: 2, // Fallback after 2 consecutive failures + fallback_turns: 2, // Use lead model for 2 turns when in fallback mode + in_fallback_mode: Arc::new(Mutex::new(false)), + fallback_remaining: Arc::new(Mutex::new(0)), + } + } + + /// Create a new LeadWorkerProvider with custom settings + /// + /// # Arguments + /// * `lead_provider` - The provider to use for the initial turns + /// * `worker_provider` - The provider to use after lead_turns + /// * `lead_turns` - Number of turns to use the lead provider + /// * `failure_threshold` - Number of consecutive failures before fallback + /// * `fallback_turns` - Number of turns to use lead model in fallback mode + pub fn new_with_settings( + lead_provider: Arc, + worker_provider: Arc, + lead_turns: usize, + failure_threshold: usize, + fallback_turns: usize, + ) -> Self { + Self { + lead_provider, + worker_provider, + lead_turns, + turn_count: Arc::new(Mutex::new(0)), + failure_count: Arc::new(Mutex::new(0)), + max_failures_before_fallback: failure_threshold, + fallback_turns, + in_fallback_mode: Arc::new(Mutex::new(false)), + fallback_remaining: Arc::new(Mutex::new(0)), + } + } + + /// Reset the turn counter and failure tracking (useful for new conversations) + pub async fn reset_turn_count(&self) { + let mut count = self.turn_count.lock().await; + *count = 0; + let mut failures = self.failure_count.lock().await; + *failures = 0; + let mut fallback = self.in_fallback_mode.lock().await; + *fallback = false; + let mut remaining = self.fallback_remaining.lock().await; + *remaining = 0; + } + + /// Get the current turn count + pub async fn get_turn_count(&self) -> usize { + *self.turn_count.lock().await + } + + /// Get the current failure count + pub async fn get_failure_count(&self) -> usize { + *self.failure_count.lock().await + } + + /// Check if currently in fallback mode + pub async fn is_in_fallback_mode(&self) -> bool { + *self.in_fallback_mode.lock().await + } + + /// Get the currently active provider based on turn count and fallback state + async fn get_active_provider(&self) -> Arc { + let count = *self.turn_count.lock().await; + let in_fallback = *self.in_fallback_mode.lock().await; + + // Use lead provider if we're in initial turns OR in fallback mode + if count < self.lead_turns || in_fallback { + Arc::clone(&self.lead_provider) + } else { + Arc::clone(&self.worker_provider) + } + } + + /// Handle the result of a completion attempt and update failure tracking + async fn handle_completion_result( + &self, + result: &Result<(Message, ProviderUsage), ProviderError>, + ) { + match result { + Ok((message, _usage)) => { + // Check for task-level failures in the response + let has_task_failure = self.detect_task_failures(message).await; + + if has_task_failure { + // Task failure detected - increment failure count + let mut failures = self.failure_count.lock().await; + *failures += 1; + + let failure_count = *failures; + let turn_count = *self.turn_count.lock().await; + + tracing::warn!( + "Task failure detected in response (failure count: {})", + failure_count + ); + + // Check if we should trigger fallback + if turn_count >= self.lead_turns + && !*self.in_fallback_mode.lock().await + && failure_count >= self.max_failures_before_fallback + { + let mut in_fallback = self.in_fallback_mode.lock().await; + let mut fallback_remaining = self.fallback_remaining.lock().await; + + *in_fallback = true; + *fallback_remaining = self.fallback_turns; + *failures = 0; // Reset failure count when entering fallback + + tracing::warn!( + "🔄 SWITCHING TO LEAD MODEL: Entering fallback mode after {} consecutive task failures - using lead model for {} turns", + self.max_failures_before_fallback, + self.fallback_turns + ); + } + } else { + // Success - reset failure count and handle fallback mode + let mut failures = self.failure_count.lock().await; + *failures = 0; + + let mut in_fallback = self.in_fallback_mode.lock().await; + let mut fallback_remaining = self.fallback_remaining.lock().await; + + if *in_fallback { + *fallback_remaining -= 1; + if *fallback_remaining == 0 { + *in_fallback = false; + tracing::info!("✅ SWITCHING BACK TO WORKER MODEL: Exiting fallback mode - worker model resumed"); + } + } + } + + // Increment turn count on any completion (success or task failure) + let mut count = self.turn_count.lock().await; + *count += 1; + } + Err(_) => { + // Technical failure - just log and let it bubble up + // For technical failures (API/LLM issues), we don't want to second-guess + // the model choice - just let the default model handle it + tracing::warn!( + "Technical failure detected - API/LLM issue, will use default model" + ); + + // Don't increment turn count or failure tracking for technical failures + // as these are temporary infrastructure issues, not model capability issues + } + } + } + + /// Detect task-level failures in the model's response + async fn detect_task_failures(&self, message: &Message) -> bool { + let mut failure_indicators = 0; + + for content in &message.content { + match content { + MessageContent::ToolRequest(tool_request) => { + // Check if tool request itself failed (malformed, etc.) + if tool_request.tool_call.is_err() { + failure_indicators += 1; + tracing::debug!( + "Failed tool request detected: {:?}", + tool_request.tool_call + ); + } + } + MessageContent::ToolResponse(tool_response) => { + // Check if tool execution failed + if let Err(tool_error) = &tool_response.tool_result { + failure_indicators += 1; + tracing::debug!("Tool execution failure detected: {:?}", tool_error); + } else if let Ok(contents) = &tool_response.tool_result { + // Check tool output for error indicators + if self.contains_error_indicators(contents) { + failure_indicators += 1; + tracing::debug!("Tool output contains error indicators"); + } + } + } + MessageContent::Text(text_content) => { + // Check for user correction patterns or error acknowledgments + if self.contains_user_correction_patterns(&text_content.text) { + failure_indicators += 1; + tracing::debug!("User correction pattern detected in text"); + } + } + _ => {} + } + } + + // Consider it a failure if we have multiple failure indicators + failure_indicators >= 1 + } + + /// Check if tool output contains error indicators + fn contains_error_indicators(&self, contents: &[Content]) -> bool { + for content in contents { + if let Content::Text(text_content) = content { + let text_lower = text_content.text.to_lowercase(); + + // Common error patterns in tool outputs + if text_lower.contains("error:") + || text_lower.contains("failed:") + || text_lower.contains("exception:") + || text_lower.contains("traceback") + || text_lower.contains("syntax error") + || text_lower.contains("permission denied") + || text_lower.contains("file not found") + || text_lower.contains("command not found") + || text_lower.contains("compilation failed") + || text_lower.contains("test failed") + || text_lower.contains("assertion failed") + { + return true; + } + } + } + false + } + + /// Check for user correction patterns in text + fn contains_user_correction_patterns(&self, text: &str) -> bool { + let text_lower = text.to_lowercase(); + + // Patterns indicating user is correcting or expressing dissatisfaction + text_lower.contains("that's wrong") + || text_lower.contains("that's not right") + || text_lower.contains("that doesn't work") + || text_lower.contains("try again") + || text_lower.contains("let me correct") + || text_lower.contains("actually, ") + || text_lower.contains("no, that's") + || text_lower.contains("that's incorrect") + || text_lower.contains("fix this") + || text_lower.contains("this is broken") + || text_lower.contains("this doesn't") + || text_lower.starts_with("no,") + || text_lower.starts_with("wrong") + || text_lower.starts_with("incorrect") + } +} + +impl LeadWorkerProviderTrait for LeadWorkerProvider { + /// Get information about the lead and worker models for logging + fn get_model_info(&self) -> (String, String) { + let lead_model = self.lead_provider.get_model_config().model_name; + let worker_model = self.worker_provider.get_model_config().model_name; + (lead_model, worker_model) + } +} + +#[async_trait] +impl Provider for LeadWorkerProvider { + fn metadata() -> ProviderMetadata { + // This is a wrapper provider, so we return minimal metadata + ProviderMetadata::new( + "lead_worker", + "Lead/Worker Provider", + "A provider that switches between lead and worker models based on turn count", + "", // No default model as this is determined by the wrapped providers + vec![], // No known models as this depends on wrapped providers + "", // No doc link + vec![], // No config keys as configuration is done through wrapped providers + ) + } + + fn get_model_config(&self) -> ModelConfig { + // Return the lead provider's model config as the default + // In practice, this might need to be more sophisticated + self.lead_provider.get_model_config() + } + + async fn complete( + &self, + system: &str, + messages: &[Message], + tools: &[Tool], + ) -> Result<(Message, ProviderUsage), ProviderError> { + // Get the active provider + let provider = self.get_active_provider().await; + + // Log which provider is being used + let turn_count = *self.turn_count.lock().await; + let in_fallback = *self.in_fallback_mode.lock().await; + let fallback_remaining = *self.fallback_remaining.lock().await; + + let provider_type = if turn_count < self.lead_turns { + "lead (initial)" + } else if in_fallback { + "lead (fallback)" + } else { + "worker" + }; + + if in_fallback { + tracing::info!( + "🔄 Using {} provider for turn {} (FALLBACK MODE: {} turns remaining)", + provider_type, + turn_count + 1, + fallback_remaining + ); + } else { + tracing::info!( + "Using {} provider for turn {} (lead_turns: {})", + provider_type, + turn_count + 1, + self.lead_turns + ); + } + + // Make the completion request + let result = provider.complete(system, messages, tools).await; + + // For technical failures, try with default model (lead provider) instead + let final_result = match &result { + Err(_) => { + tracing::warn!("Technical failure with {} provider, retrying with default model (lead provider)", provider_type); + + // Try with lead provider as the default/fallback for technical failures + let default_result = self.lead_provider.complete(system, messages, tools).await; + + match &default_result { + Ok(_) => { + tracing::info!( + "✅ Default model (lead provider) succeeded after technical failure" + ); + default_result + } + Err(_) => { + tracing::error!("❌ Default model (lead provider) also failed - returning original error"); + result // Return the original error + } + } + } + Ok(_) => result, // Success with original provider + }; + + // Handle the result and update tracking (only for successful completions) + self.handle_completion_result(&final_result).await; + + final_result + } + + async fn fetch_supported_models_async(&self) -> Result>, ProviderError> { + // Combine models from both providers + let lead_models = self.lead_provider.fetch_supported_models_async().await?; + let worker_models = self.worker_provider.fetch_supported_models_async().await?; + + match (lead_models, worker_models) { + (Some(lead), Some(worker)) => { + let mut all_models = lead; + all_models.extend(worker); + all_models.sort(); + all_models.dedup(); + Ok(Some(all_models)) + } + (Some(models), None) | (None, Some(models)) => Ok(Some(models)), + (None, None) => Ok(None), + } + } + + fn supports_embeddings(&self) -> bool { + // Support embeddings if either provider supports them + self.lead_provider.supports_embeddings() || self.worker_provider.supports_embeddings() + } + + async fn create_embeddings(&self, texts: Vec) -> Result>, ProviderError> { + // Use the lead provider for embeddings if it supports them, otherwise use worker + if self.lead_provider.supports_embeddings() { + self.lead_provider.create_embeddings(texts).await + } else if self.worker_provider.supports_embeddings() { + self.worker_provider.create_embeddings(texts).await + } else { + Err(ProviderError::ExecutionError( + "Neither lead nor worker provider supports embeddings".to_string(), + )) + } + } + + /// Check if this provider is a LeadWorkerProvider + fn as_lead_worker(&self) -> Option<&dyn LeadWorkerProviderTrait> { + Some(self) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::MessageContent; + use crate::providers::base::{ProviderMetadata, ProviderUsage, Usage}; + use chrono::Utc; + use mcp_core::{content::TextContent, Role}; + + #[derive(Clone)] + struct MockProvider { + name: String, + model_config: ModelConfig, + } + + #[async_trait] + impl Provider for MockProvider { + fn metadata() -> ProviderMetadata { + ProviderMetadata::empty() + } + + fn get_model_config(&self) -> ModelConfig { + self.model_config.clone() + } + + async fn complete( + &self, + _system: &str, + _messages: &[Message], + _tools: &[Tool], + ) -> Result<(Message, ProviderUsage), ProviderError> { + Ok(( + Message { + role: Role::Assistant, + created: Utc::now().timestamp(), + content: vec![MessageContent::Text(TextContent { + text: format!("Response from {}", self.name), + annotations: None, + })], + }, + ProviderUsage::new(self.name.clone(), Usage::default()), + )) + } + } + + #[tokio::test] + async fn test_lead_worker_switching() { + let lead_provider = Arc::new(MockProvider { + name: "lead".to_string(), + model_config: ModelConfig::new("lead-model".to_string()), + }); + + let worker_provider = Arc::new(MockProvider { + name: "worker".to_string(), + model_config: ModelConfig::new("worker-model".to_string()), + }); + + let provider = LeadWorkerProvider::new(lead_provider, worker_provider, Some(3)); + + // First three turns should use lead provider + for i in 0..3 { + let (_message, usage) = provider.complete("system", &[], &[]).await.unwrap(); + assert_eq!(usage.model, "lead"); + assert_eq!(provider.get_turn_count().await, i + 1); + assert!(!provider.is_in_fallback_mode().await); + } + + // Subsequent turns should use worker provider + for i in 3..6 { + let (_message, usage) = provider.complete("system", &[], &[]).await.unwrap(); + assert_eq!(usage.model, "worker"); + assert_eq!(provider.get_turn_count().await, i + 1); + assert!(!provider.is_in_fallback_mode().await); + } + + // Reset and verify it goes back to lead + provider.reset_turn_count().await; + assert_eq!(provider.get_turn_count().await, 0); + assert_eq!(provider.get_failure_count().await, 0); + assert!(!provider.is_in_fallback_mode().await); + + let (_message, usage) = provider.complete("system", &[], &[]).await.unwrap(); + assert_eq!(usage.model, "lead"); + } + + #[tokio::test] + async fn test_technical_failure_retry() { + let lead_provider = Arc::new(MockFailureProvider { + name: "lead".to_string(), + model_config: ModelConfig::new("lead-model".to_string()), + should_fail: false, // Lead provider works + }); + + let worker_provider = Arc::new(MockFailureProvider { + name: "worker".to_string(), + model_config: ModelConfig::new("worker-model".to_string()), + should_fail: true, // Worker will fail + }); + + let provider = LeadWorkerProvider::new(lead_provider, worker_provider, Some(2)); + + // First two turns use lead (should succeed) + for _i in 0..2 { + let result = provider.complete("system", &[], &[]).await; + assert!(result.is_ok()); + assert_eq!(result.unwrap().1.model, "lead"); + assert!(!provider.is_in_fallback_mode().await); + } + + // Next turn uses worker (will fail, but should retry with lead and succeed) + let result = provider.complete("system", &[], &[]).await; + assert!(result.is_ok()); // Should succeed because lead provider is used as fallback + assert_eq!(result.unwrap().1.model, "lead"); // Should be lead provider + assert_eq!(provider.get_failure_count().await, 0); // No failure tracking for technical failures + assert!(!provider.is_in_fallback_mode().await); // Not in fallback mode + + // Another turn - should still try worker first, then retry with lead + let result = provider.complete("system", &[], &[]).await; + assert!(result.is_ok()); // Should succeed because lead provider is used as fallback + assert_eq!(result.unwrap().1.model, "lead"); // Should be lead provider + assert_eq!(provider.get_failure_count().await, 0); // Still no failure tracking + assert!(!provider.is_in_fallback_mode().await); // Still not in fallback mode + } + + #[tokio::test] + async fn test_fallback_on_task_failures() { + // Test that task failures (not technical failures) still trigger fallback mode + // This would need a different mock that simulates task failures in successful responses + // For now, we'll test the fallback mode functionality directly + let lead_provider = Arc::new(MockFailureProvider { + name: "lead".to_string(), + model_config: ModelConfig::new("lead-model".to_string()), + should_fail: false, + }); + + let worker_provider = Arc::new(MockFailureProvider { + name: "worker".to_string(), + model_config: ModelConfig::new("worker-model".to_string()), + should_fail: false, + }); + + let provider = LeadWorkerProvider::new(lead_provider, worker_provider, Some(2)); + + // Simulate being in fallback mode + { + let mut in_fallback = provider.in_fallback_mode.lock().await; + *in_fallback = true; + let mut fallback_remaining = provider.fallback_remaining.lock().await; + *fallback_remaining = 2; + let mut turn_count = provider.turn_count.lock().await; + *turn_count = 4; // Past initial lead turns + } + + // Should use lead provider in fallback mode + let result = provider.complete("system", &[], &[]).await; + assert!(result.is_ok()); + assert_eq!(result.unwrap().1.model, "lead"); + assert!(provider.is_in_fallback_mode().await); + + // One more fallback turn + let result = provider.complete("system", &[], &[]).await; + assert!(result.is_ok()); + assert_eq!(result.unwrap().1.model, "lead"); + assert!(!provider.is_in_fallback_mode().await); // Should exit fallback mode + } + + #[derive(Clone)] + struct MockFailureProvider { + name: String, + model_config: ModelConfig, + should_fail: bool, + } + + #[async_trait] + impl Provider for MockFailureProvider { + fn metadata() -> ProviderMetadata { + ProviderMetadata::empty() + } + + fn get_model_config(&self) -> ModelConfig { + self.model_config.clone() + } + + async fn complete( + &self, + _system: &str, + _messages: &[Message], + _tools: &[Tool], + ) -> Result<(Message, ProviderUsage), ProviderError> { + if self.should_fail { + Err(ProviderError::ExecutionError( + "Simulated failure".to_string(), + )) + } else { + Ok(( + Message { + role: Role::Assistant, + created: Utc::now().timestamp(), + content: vec![MessageContent::Text(TextContent { + text: format!("Response from {}", self.name), + annotations: None, + })], + }, + ProviderUsage::new(self.name.clone(), Usage::default()), + )) + } + } + } +} diff --git a/crates/goose/src/providers/mod.rs b/crates/goose/src/providers/mod.rs index 2c1a4571..2f6f1f87 100644 --- a/crates/goose/src/providers/mod.rs +++ b/crates/goose/src/providers/mod.rs @@ -13,6 +13,7 @@ pub mod gcpvertexai; pub mod githubcopilot; pub mod google; pub mod groq; +pub mod lead_worker; pub mod oauth; pub mod ollama; pub mod openai; diff --git a/documentation/docs/guides/environment-variables.md b/documentation/docs/guides/environment-variables.md index 4f29ff0e..487661a2 100644 --- a/documentation/docs/guides/environment-variables.md +++ b/documentation/docs/guides/environment-variables.md @@ -9,6 +9,7 @@ Goose supports various environment variables that allow you to customize its beh ## Model Configuration These variables control the [language models](/docs/getting-started/providers) and their behavior. + ### Basic Provider Configuration These are the minimum required variables to get started with Goose. @@ -27,6 +28,7 @@ export GOOSE_PROVIDER="anthropic" export GOOSE_MODEL="claude-3.5-sonnet" export GOOSE_TEMPERATURE=0.7 ``` + ### Advanced Provider Configuration These variables are needed when using custom endpoints, enterprise deployments, or specific provider implementations. @@ -45,7 +47,34 @@ export GOOSE_PROVIDER__TYPE="anthropic" export GOOSE_PROVIDER__HOST="https://api.anthropic.com" export GOOSE_PROVIDER__API_KEY="your-api-key-here" ``` -## Planning Mode Configuration + +### Lead/Worker Model Configuration + +Configure a lead/worker model pattern where a powerful model handles initial planning and complex reasoning, then switches to a faster/cheaper model for execution. + +| Variable | Purpose | Values | Default | +|----------|---------|---------|---------| +| `GOOSE_LEAD_MODEL` | **Required to enable lead mode.** Specifies the lead model name | Model name (e.g., "gpt-4o", "claude-3.5-sonnet") | None | +| `GOOSE_LEAD_PROVIDER` | Provider for the lead model | [See available providers](/docs/getting-started/providers#available-providers) | Falls back to GOOSE_PROVIDER | +| `GOOSE_LEAD_TURNS` | Number of initial turns using the lead model | Integer | 3 | +| `GOOSE_LEAD_FAILURE_THRESHOLD` | Consecutive failures before fallback to lead model | Integer | 2 | +| `GOOSE_LEAD_FALLBACK_TURNS` | Number of turns to use lead model in fallback mode | Integer | 2 | + +**Examples** + +```bash +# Basic lead/worker setup +export GOOSE_LEAD_MODEL="o4" + +# Advanced lead/worker configuration +export GOOSE_LEAD_MODEL="claude4-opus" +export GOOSE_LEAD_PROVIDER="anthropic" +export GOOSE_LEAD_TURNS=5 +export GOOSE_LEAD_FAILURE_THRESHOLD=3 +export GOOSE_LEAD_FALLBACK_TURNS=2 +``` + +### Planning Mode Configuration These variables control Goose's [planning functionality](/docs/guides/creating-plans). diff --git a/test_lead_worker.sh b/test_lead_worker.sh new file mode 100755 index 00000000..3d403b82 --- /dev/null +++ b/test_lead_worker.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# Test script for lead/worker provider functionality + +# Set up test environment variables +export GOOSE_PROVIDER="openai" +export GOOSE_MODEL="gpt-4o-mini" +export OPENAI_API_KEY="test-key" + +# Test 1: Default behavior (no lead/worker) +echo "Test 1: Default behavior (no lead/worker)" +unset GOOSE_LEAD_MODEL +unset GOOSE_WORKER_MODEL +unset GOOSE_LEAD_TURNS + +# Test 2: Lead/worker with same provider +echo -e "\nTest 2: Lead/worker with same provider" +export GOOSE_LEAD_MODEL="gpt-4o" +export GOOSE_WORKER_MODEL="gpt-4o-mini" +export GOOSE_LEAD_TURNS="3" + +# Test 3: Lead/worker with default worker (uses main model) +echo -e "\nTest 3: Lead/worker with default worker" +export GOOSE_LEAD_MODEL="gpt-4o" +unset GOOSE_WORKER_MODEL +export GOOSE_LEAD_TURNS="5" + +echo -e "\nConfiguration examples:" +echo "- Default: Uses GOOSE_MODEL for all turns" +echo "- Lead/Worker: Set GOOSE_LEAD_MODEL to use a different model for initial turns" +echo "- GOOSE_LEAD_TURNS: Number of turns to use lead model (default: 5)" +echo "- GOOSE_WORKER_MODEL: Model to use after lead turns (default: GOOSE_MODEL)" \ No newline at end of file