alexhancock/remove-settings-v1 (#2744)

Co-authored-by: Zane Staggs <zane@squareup.com>
This commit is contained in:
Alex Hancock
2025-06-02 14:04:57 -04:00
committed by GitHub
parent abee0000c2
commit a89fa18502
139 changed files with 324 additions and 4300 deletions

View File

@@ -14,22 +14,20 @@ import { type Recipe } from './recipe';
import ChatView from './components/ChatView'; import ChatView from './components/ChatView';
import SuspenseLoader from './suspense-loader'; import SuspenseLoader from './suspense-loader';
import { type SettingsViewOptions } from './components/settings/SettingsView'; import SettingsView, { SettingsViewOptions } from './components/settings/SettingsView';
import SettingsViewV2 from './components/settings_v2/SettingsView';
import MoreModelsView from './components/settings/models/MoreModelsView';
import ConfigureProvidersView from './components/settings/providers/ConfigureProvidersView';
import SessionsView from './components/sessions/SessionsView'; import SessionsView from './components/sessions/SessionsView';
import SharedSessionView from './components/sessions/SharedSessionView'; import SharedSessionView from './components/sessions/SharedSessionView';
import SchedulesView from './components/schedule/SchedulesView'; import SchedulesView from './components/schedule/SchedulesView';
import ProviderSettings from './components/settings_v2/providers/ProviderSettingsPage'; import ProviderSettings from './components/settings/providers/ProviderSettingsPage';
import RecipeEditor from './components/RecipeEditor'; import RecipeEditor from './components/RecipeEditor';
import { useChat } from './hooks/useChat'; import { useChat } from './hooks/useChat';
import 'react-toastify/dist/ReactToastify.css'; import 'react-toastify/dist/ReactToastify.css';
import { useConfig, MalformedConfigError } from './components/ConfigContext'; import { useConfig, MalformedConfigError } from './components/ConfigContext';
import { addExtensionFromDeepLink as addExtensionFromDeepLinkV2 } from './components/settings_v2/extensions'; import { ModelAndProviderProvider } from './components/ModelAndProviderContext';
import { addExtensionFromDeepLink as addExtensionFromDeepLinkV2 } from './components/settings/extensions';
import { backupConfig, initConfig, readAllConfig } from './api/sdk.gen'; import { backupConfig, initConfig, readAllConfig } from './api/sdk.gen';
import PermissionSettingsView from './components/settings_v2/permission/PermissionSetting'; import PermissionSettingsView from './components/settings/permission/PermissionSetting';
import { type SessionDetails } from './sessions'; import { type SessionDetails } from './sessions';
@@ -462,7 +460,7 @@ export default function App() {
); );
return ( return (
<> <ModelAndProviderProvider>
<ToastContainer <ToastContainer
aria-label="Toast notifications" aria-label="Toast notifications"
toastClassName={() => toastClassName={() =>
@@ -496,7 +494,7 @@ export default function App() {
<ProviderSettings onClose={() => setView('chat')} isOnboarding={true} /> <ProviderSettings onClose={() => setView('chat')} isOnboarding={true} />
)} )}
{view === 'settings' && ( {view === 'settings' && (
<SettingsViewV2 <SettingsView
onClose={() => { onClose={() => {
setView('chat'); setView('chat');
}} }}
@@ -504,21 +502,6 @@ export default function App() {
viewOptions={viewOptions as SettingsViewOptions} viewOptions={viewOptions as SettingsViewOptions}
/> />
)} )}
{view === 'moreModels' && (
<MoreModelsView
onClose={() => {
setView('settings');
}}
setView={setView}
/>
)}
{view === 'configureProviders' && (
<ConfigureProvidersView
onClose={() => {
setView('settings');
}}
/>
)}
{view === 'ConfigureProviders' && ( {view === 'ConfigureProviders' && (
<ProviderSettings onClose={() => setView('chat')} isOnboarding={false} /> <ProviderSettings onClose={() => setView('chat')} isOnboarding={false} />
)} )}
@@ -579,6 +562,6 @@ export default function App() {
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen} setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
/> />
)} )}
</> </ModelAndProviderProvider>
); );
} }

View File

@@ -19,7 +19,7 @@ import type {
ExtensionQuery, ExtensionQuery,
ExtensionConfig, ExtensionConfig,
} from '../api/types.gen'; } from '../api/types.gen';
import { removeShims } from './settings_v2/extensions/utils'; import { removeShims } from './settings/extensions/utils';
export type { ExtensionConfig } from '../api/types.gen'; export type { ExtensionConfig } from '../api/types.gen';

View File

@@ -0,0 +1,190 @@
import React, { createContext, useContext, useState, useEffect, useMemo, useCallback } from 'react';
import { initializeAgent } from '../agent';
import { toastError, toastSuccess } from '../toasts';
import Model, { getProviderMetadata } from './settings/models/modelInterface';
import { ProviderMetadata } from '../api';
import { useConfig } from './ConfigContext';
// titles
export const UNKNOWN_PROVIDER_TITLE = 'Provider name lookup';
// errors
const CHANGE_MODEL_ERROR_TITLE = 'Change failed';
const SWITCH_MODEL_AGENT_ERROR_MSG =
'Failed to start agent with selected model -- please try again';
const CONFIG_UPDATE_ERROR_MSG = 'Failed to update configuration settings -- please try again';
export const UNKNOWN_PROVIDER_MSG = 'Unknown provider in config -- please inspect your config.yaml';
// success
const CHANGE_MODEL_TOAST_TITLE = 'Model changed';
const SWITCH_MODEL_SUCCESS_MSG = 'Successfully switched models';
interface ModelAndProviderContextType {
currentModel: string | null;
currentProvider: string | null;
changeModel: (model: Model) => Promise<void>;
getCurrentModelAndProvider: () => Promise<{ model: string; provider: string }>;
getFallbackModelAndProvider: () => Promise<{ model: string; provider: string }>;
getCurrentModelAndProviderForDisplay: () => Promise<{ model: string; provider: string }>;
refreshCurrentModelAndProvider: () => Promise<void>;
}
interface ModelAndProviderProviderProps {
children: React.ReactNode;
}
const ModelAndProviderContext = createContext<ModelAndProviderContextType | undefined>(undefined);
export const ModelAndProviderProvider: React.FC<ModelAndProviderProviderProps> = ({ children }) => {
const [currentModel, setCurrentModel] = useState<string | null>(null);
const [currentProvider, setCurrentProvider] = useState<string | null>(null);
const { read, upsert, getProviders } = useConfig();
const changeModel = useCallback(
async (model: Model) => {
const modelName = model.name;
const providerName = model.provider;
try {
await initializeAgent({
model: model.name,
provider: model.provider,
});
} catch (error) {
console.error(`Failed to change model at agent step -- ${modelName} ${providerName}`);
toastError({
title: CHANGE_MODEL_ERROR_TITLE,
msg: SWITCH_MODEL_AGENT_ERROR_MSG,
traceback: error instanceof Error ? error.message : String(error),
});
// don't write to config
return;
}
try {
await upsert('GOOSE_PROVIDER', providerName, false);
await upsert('GOOSE_MODEL', modelName, false);
// Update local state
setCurrentProvider(providerName);
setCurrentModel(modelName);
} catch (error) {
console.error(`Failed to change model at config step -- ${modelName} ${providerName}}`);
toastError({
title: CHANGE_MODEL_ERROR_TITLE,
msg: CONFIG_UPDATE_ERROR_MSG,
traceback: error instanceof Error ? error.message : String(error),
});
// agent and config will be out of sync at this point
// TODO: reset agent to use current config settings
} finally {
// show toast
toastSuccess({
title: CHANGE_MODEL_TOAST_TITLE,
msg: `${SWITCH_MODEL_SUCCESS_MSG} -- using ${model.alias ?? modelName} from ${model.subtext ?? providerName}`,
});
}
},
[upsert]
);
const getFallbackModelAndProvider = useCallback(async () => {
const provider = window.appConfig.get('GOOSE_DEFAULT_PROVIDER') as string;
const model = window.appConfig.get('GOOSE_DEFAULT_MODEL') as string;
if (provider && model) {
try {
await upsert('GOOSE_MODEL', model, false);
await upsert('GOOSE_PROVIDER', provider, false);
} catch (error) {
console.error('[getFallbackModelAndProvider] Failed to write to config', error);
}
}
return { model: model, provider: provider };
}, [upsert]);
const getCurrentModelAndProvider = useCallback(async () => {
let model: string;
let provider: string;
// read from config
try {
model = (await read('GOOSE_MODEL', false)) as string;
provider = (await read('GOOSE_PROVIDER', false)) as string;
} catch (error) {
console.error(`Failed to read GOOSE_MODEL or GOOSE_PROVIDER from config`);
throw error;
}
if (!model || !provider) {
console.log('[getCurrentModelAndProvider] Checking app environment as fallback');
return getFallbackModelAndProvider();
}
return { model: model, provider: provider };
}, [read, getFallbackModelAndProvider]);
const getCurrentModelAndProviderForDisplay = useCallback(async () => {
const modelProvider = await getCurrentModelAndProvider();
const gooseModel = modelProvider.model;
const gooseProvider = modelProvider.provider;
// lookup display name
let metadata: ProviderMetadata;
try {
metadata = await getProviderMetadata(String(gooseProvider), getProviders);
} catch (error) {
return { model: gooseModel, provider: gooseProvider };
}
const providerDisplayName = metadata.display_name;
return { model: gooseModel, provider: providerDisplayName };
}, [getCurrentModelAndProvider, getProviders]);
const refreshCurrentModelAndProvider = useCallback(async () => {
try {
const { model, provider } = await getCurrentModelAndProvider();
setCurrentModel(model);
setCurrentProvider(provider);
} catch (error) {
console.error('Failed to refresh current model and provider:', error);
}
}, [getCurrentModelAndProvider]);
// Load initial model and provider on mount
useEffect(() => {
refreshCurrentModelAndProvider();
}, [refreshCurrentModelAndProvider]);
const contextValue = useMemo(
() => ({
currentModel,
currentProvider,
changeModel,
getCurrentModelAndProvider,
getFallbackModelAndProvider,
getCurrentModelAndProviderForDisplay,
refreshCurrentModelAndProvider,
}),
[
currentModel,
currentProvider,
changeModel,
getCurrentModelAndProvider,
getFallbackModelAndProvider,
getCurrentModelAndProviderForDisplay,
refreshCurrentModelAndProvider,
]
);
return (
<ModelAndProviderContext.Provider value={contextValue}>
{children}
</ModelAndProviderContext.Provider>
);
};
export const useModelAndProvider = () => {
const context = useContext(ModelAndProviderContext);
if (context === undefined) {
throw new Error('useModelAndProvider must be used within a ModelAndProviderProvider');
}
return context;
};

View File

@@ -1,215 +0,0 @@
import React from 'react';
import {
supported_providers,
required_keys,
provider_aliases,
} from './settings/models/hardcoded_stuff';
import { useActiveKeys } from './settings/api_keys/ActiveKeysContext';
import { ProviderSetupModal } from './settings/ProviderSetupModal';
import { useModel } from './settings/models/ModelContext';
import { useRecentModels } from './settings/models/RecentModels';
import { createSelectedModel } from './settings/models/utils';
import { getDefaultModel } from './settings/models/hardcoded_stuff';
import { initializeSystem } from '../utils/providerUtils';
import { getApiUrl, getSecretKey } from '../config';
import { getActiveProviders, isSecretKey } from './settings/api_keys/utils';
import { BaseProviderGrid, getProviderDescription } from './settings/providers/BaseProviderGrid';
import { toastError, toastSuccess } from '../toasts';
interface ProviderGridProps {
onSubmit?: () => void;
}
export function ProviderGrid({ onSubmit }: ProviderGridProps) {
const { activeKeys, setActiveKeys } = useActiveKeys();
const [selectedId, setSelectedId] = React.useState<string | null>(null);
const [showSetupModal, setShowSetupModal] = React.useState(false);
const { switchModel } = useModel();
const { addRecentModel } = useRecentModels();
const providers = React.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 handleConfigure = async (provider: {
id: string;
name: string;
isConfigured: boolean;
description: string;
}) => {
const providerId = provider.id.toLowerCase();
const modelName = getDefaultModel(providerId) || 'default-model';
const model = createSelectedModel(providerId, modelName);
await initializeSystem(providerId, model.name);
switchModel(model);
addRecentModel(model);
localStorage.setItem('GOOSE_PROVIDER', providerId);
toastSuccess({
title: provider.name,
msg: `Starting Goose with default model: ${getDefaultModel(provider.name.toLowerCase().replace(/ /g, '_'))}.`,
});
onSubmit?.();
};
const handleAddKeys = (provider: {
id: string;
name: string;
isConfigured: boolean;
description: string;
}) => {
setSelectedId(provider.id);
setShowSetupModal(true);
};
const handleModalSubmit = async (configValues: { [key: string]: string }) => {
if (!selectedId) return;
const provider = providers.find((p) => p.id === selectedId)?.name;
if (!provider) return;
const requiredKeys = required_keys[provider as keyof typeof required_keys];
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 === selectedId)?.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);
setSelectedId(null);
} 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: errorMessage,
});
}
};
const handleSelect = (providerId: string) => {
setSelectedId(selectedId === providerId ? null : providerId);
};
// Add useEffect for Esc key handling
React.useEffect(() => {
const handleEsc = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setSelectedId(null);
}
};
window.addEventListener('keydown', handleEsc);
return () => {
window.removeEventListener('keydown', handleEsc);
};
}, []);
return (
<div className="space-y-4 max-w-[1400px] mx-auto">
<BaseProviderGrid
providers={providers}
isSelectable={true}
selectedId={selectedId}
onSelect={handleSelect}
onAddKeys={handleAddKeys}
onTakeoff={(provider) => {
handleConfigure(provider);
}}
/>
{showSetupModal && selectedId && (
<div className="relative z-[9999]">
<ProviderSetupModal
provider={providers.find((p) => p.id === selectedId)?.name || 'Unknown Provider'}
_model="Example Model"
_endpoint="Example Endpoint"
onSubmit={handleModalSubmit}
onCancel={() => {
setShowSetupModal(false);
setSelectedId(null);
}}
/>
</div>
)}
</div>
);
}

View File

@@ -11,7 +11,6 @@ import RecipeActivityEditor from './RecipeActivityEditor';
import RecipeInfoModal from './RecipeInfoModal'; import RecipeInfoModal from './RecipeInfoModal';
import RecipeExpandableInfo from './RecipeExpandableInfo'; import RecipeExpandableInfo from './RecipeExpandableInfo';
import { ScheduleFromRecipeModal } from './schedule/ScheduleFromRecipeModal'; import { ScheduleFromRecipeModal } from './schedule/ScheduleFromRecipeModal';
// import ExtensionList from './settings_v2/extensions/subcomponents/ExtensionList';
interface RecipeEditorProps { interface RecipeEditorProps {
config?: Recipe; config?: Recipe;
@@ -365,7 +364,14 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
recipe={getCurrentConfig()} recipe={getCurrentConfig()}
onCreateSchedule={(deepLink) => { onCreateSchedule={(deepLink) => {
// Open the schedules view with the deep link pre-filled // Open the schedules view with the deep link pre-filled
window.electron.createChatWindow(undefined, undefined, undefined, undefined, undefined, 'schedules'); window.electron.createChatWindow(
undefined,
undefined,
undefined,
undefined,
undefined,
'schedules'
);
// Store the deep link in localStorage for the schedules view to pick up // Store the deep link in localStorage for the schedules view to pick up
localStorage.setItem('pendingScheduleDeepLink', deepLink); localStorage.setItem('pendingScheduleDeepLink', deepLink);
}} }}

View File

@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { snakeToTitleCase } from '../utils'; import { snakeToTitleCase } from '../utils';
import PermissionModal from './settings_v2/permission/PermissionModal'; import PermissionModal from './settings/permission/PermissionModal';
import { ChevronRight } from 'lucide-react'; import { ChevronRight } from 'lucide-react';
import { confirmPermission } from '../api'; import { confirmPermission } from '../api';

View File

@@ -1,86 +0,0 @@
import { ProviderGrid } from './ProviderGrid';
import { ScrollArea } from './ui/scroll-area';
import { Button } from './ui/button';
import WelcomeGooseLogo from './WelcomeGooseLogo';
import MoreMenuLayout from './more_menu/MoreMenuLayout';
// Extending React CSSProperties to include custom webkit property
declare module 'react' {
interface CSSProperties {
WebkitAppRegion?: string;
}
}
interface WelcomeScreenProps {
onSubmit?: () => void;
}
export default function WelcomeScreen({ onSubmit }: WelcomeScreenProps) {
return (
<div className="h-screen w-full select-none bg-white dark:bg-black">
<MoreMenuLayout showMenu={false} />
{/* Content area - explicitly set as non-draggable */}
<div
className="h-[calc(100vh-36px)] w-full overflow-hidden"
style={{ WebkitAppRegion: 'no-drag' }}
>
<ScrollArea className="h-full w-full">
<div className="flex min-h-full flex-col justify-center px-8 py-8 md:px-16 max-w-4xl mx-auto">
{/* Header Section */}
<div className="mb-8 mt-4">
<div className="flex items-center gap-4 mb-4">
<div className="group/logo">
<WelcomeGooseLogo className="h-16 w-16 md:h-20 md:w-20 text-black dark:text-white" />
</div>
<div>
<h1 className="text-4xl font-medium text-textStandard tracking-tight md:text-5xl">
Welcome to goose
</h1>
<p className="text-lg text-textSubtle max-w-2xl">
Your intelligent AI assistant for seamless productivity and creativity.
</p>
</div>
</div>
</div>
{/* ProviderGrid */}
<div className="w-full">
<h2
className="text-3xl font-medium text-textStandard tracking-tight mb-2"
data-testid="provider-selection-heading"
>
Choose a Provider
</h2>
<p className="text-xl text-textStandard mb-4">
Select an AI model provider to get started with goose.
</p>
<p className="text-sm text-textSubtle mb-8">
Click on a provider to configure its API keys and start using goose. Your keys are
stored securely and encrypted locally. You can change your provider and select
specific models in the settings.
</p>
<ProviderGrid onSubmit={onSubmit} />
</div>
{/* Get started (now less prominent) */}
<div className="mt-12">
<p className="text-sm text-textSubtle">
Not sure where to start?{' '}
<Button
variant="link"
className="text-blue-500 hover:text-blue-600 p-0 h-auto"
onClick={() =>
window.open('https://block.github.io/goose/docs/quickstart', '_blank')
}
>
Quick Start Guide
</Button>
</p>
</div>
</div>
</ScrollArea>
</div>
</div>
);
}

View File

@@ -1,13 +1,12 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { useModel } from '../settings/models/ModelContext';
import { AlertType, useAlerts } from '../alerts'; import { AlertType, useAlerts } from '../alerts';
import { useToolCount } from '../alerts/useToolCount'; import { useToolCount } from '../alerts/useToolCount';
import BottomMenuAlertPopover from './BottomMenuAlertPopover'; import BottomMenuAlertPopover from './BottomMenuAlertPopover';
import type { View, ViewOptions } from '../../App'; import type { View, ViewOptions } from '../../App';
import { BottomMenuModeSelection } from './BottomMenuModeSelection'; import { BottomMenuModeSelection } from './BottomMenuModeSelection';
import ModelsBottomBar from '../settings_v2/models/bottom_bar/ModelsBottomBar'; import ModelsBottomBar from '../settings/models/bottom_bar/ModelsBottomBar';
import { useConfig } from '../ConfigContext'; import { useConfig } from '../ConfigContext';
import { getCurrentModelAndProvider } from '../settings_v2/models'; import { useModelAndProvider } from '../ModelAndProviderContext';
import { Message } from '../../types/message'; import { Message } from '../../types/message';
import { ManualSummarizeButton } from '../context_management/ManualSummaryButton'; import { ManualSummarizeButton } from '../context_management/ManualSummaryButton';
@@ -34,11 +33,11 @@ export default function BottomMenu({
setMessages: (messages: Message[]) => void; setMessages: (messages: Message[]) => void;
}) { }) {
const [isModelMenuOpen, setIsModelMenuOpen] = useState(false); const [isModelMenuOpen, setIsModelMenuOpen] = useState(false);
const { currentModel } = useModel();
const { alerts, addAlert, clearAlerts } = useAlerts(); const { alerts, addAlert, clearAlerts } = useAlerts();
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
const toolCount = useToolCount(); const toolCount = useToolCount();
const { getProviders, read } = useConfig(); const { getProviders, read } = useConfig();
const { getCurrentModelAndProvider, currentModel, currentProvider } = useModelAndProvider();
const [tokenLimit, setTokenLimit] = useState<number>(TOKEN_LIMIT_DEFAULT); const [tokenLimit, setTokenLimit] = useState<number>(TOKEN_LIMIT_DEFAULT);
const [isTokenLimitLoaded, setIsTokenLimitLoaded] = useState(false); const [isTokenLimitLoaded, setIsTokenLimitLoaded] = useState(false);
@@ -72,7 +71,7 @@ export default function BottomMenu({
setIsTokenLimitLoaded(false); setIsTokenLimitLoaded(false);
// Get current model and provider first to avoid unnecessary provider fetches // Get current model and provider first to avoid unnecessary provider fetches
const { model, provider } = await getCurrentModelAndProvider({ readFromConfig: read }); const { model, provider } = await getCurrentModelAndProvider();
if (!model || !provider) { if (!model || !provider) {
console.log('No model or provider found'); console.log('No model or provider found');
setIsTokenLimitLoaded(true); setIsTokenLimitLoaded(true);
@@ -117,7 +116,7 @@ export default function BottomMenu({
useEffect(() => { useEffect(() => {
loadProviderDetails(); loadProviderDetails();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentModel]); }, [currentModel, currentProvider]);
// Handle tool count alerts and token usage // Handle tool count alerts and token usage
useEffect(() => { useEffect(() => {

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState, useCallback } from 'react'; import { useEffect, useRef, useState, useCallback } from 'react';
import { all_goose_modes, ModeSelectionItem } from '../settings_v2/mode/ModeSelectionItem'; import { all_goose_modes, ModeSelectionItem } from '../settings/mode/ModeSelectionItem';
import { useConfig } from '../ConfigContext'; import { useConfig } from '../ConfigContext';
import { View, ViewOptions } from '../../App'; import { View, ViewOptions } from '../../App';
import { Orbit } from 'lucide-react'; import { Orbit } from 'lucide-react';

View File

@@ -1,472 +0,0 @@
import { useState, useEffect, useRef } from 'react';
// Import actual PNG images
import llamaSprite from '../../assets/battle-game/llama.png';
import gooseSprite from '../../assets/battle-game/goose.png';
import battleBackground from '../../assets/battle-game/background.png';
import battleMusic from '../../assets/battle-game/battle.mp3';
interface BattleState {
currentStep: number;
gooseHp: number;
llamaHp: number;
message: string;
animation: string | null;
lastChoice?: string;
showHostInput?: boolean;
processingAction?: boolean;
}
interface OllamaBattleGameProps {
onComplete: (configValues: { [key: string]: string }) => void;
requiredKeys: string[];
}
export function OllamaBattleGame({ onComplete, requiredKeys: _ }: OllamaBattleGameProps) {
// Use Audio element type for audioRef
// eslint-disable-next-line no-undef
const audioRef = useRef<HTMLAudioElement | null>(null);
const [isMuted, setIsMuted] = useState(false);
const [battleState, setBattleState] = useState<BattleState>({
currentStep: 0,
gooseHp: 100,
llamaHp: 100,
message: 'A wild Ollama appeared!',
animation: null,
processingAction: false,
});
const [configValues, setConfigValues] = useState<{ [key: string]: string }>({});
// Initialize audio when component mounts
useEffect(() => {
if (typeof window !== 'undefined') {
audioRef.current = new window.Audio(battleMusic);
audioRef.current.loop = true;
audioRef.current.volume = 0.2;
audioRef.current.play().catch((e) => console.log('Audio autoplay prevented:', e));
}
return () => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
};
}, []);
const toggleMute = () => {
if (audioRef.current) {
if (isMuted) {
audioRef.current.volume = 0.2;
} else {
audioRef.current.volume = 0;
}
setIsMuted(!isMuted);
}
};
const battleSteps = [
{
message: 'A wild Ollama appeared!',
action: null,
animation: 'appear',
},
{
message: 'What will GOOSE do?',
action: 'choice',
choices: ['Pacify', 'HONK!'],
animation: 'attack',
followUpMessages: ["It's not very effective...", 'But OLLAMA is confused!'],
},
{
message: 'OLLAMA used YAML Confusion!',
action: null,
animation: 'counter',
followUpMessages: ['OLLAMA hurt itself in confusion!', 'GOOSE maintained composure!'],
},
{
message: 'What will GOOSE do?',
action: 'final_choice',
choices: (previousChoice: string) => [
previousChoice === 'Pacify' ? 'HONK!' : 'Pacify',
'Configure Host',
],
animation: 'attack',
},
{
message: 'OLLAMA used Docker Dependency!',
action: null,
animation: 'counter',
followUpMessages: ["It's not very effective...", 'GOOSE knows containerization!'],
},
{
message: 'What will GOOSE do?',
action: 'host_choice',
choices: ['Configure Host'],
animation: 'finish',
},
{
message: '', // Will be set dynamically based on choice
action: 'host_input',
prompt: 'Enter your Ollama host address:',
configKey: 'OLLAMA_HOST',
animation: 'finish',
followUpMessages: [
"It's super effective!",
'OLLAMA has been configured!',
'OLLAMA joined your team!',
],
},
{
message: 'Configuration complete!\nOLLAMA will remember this friendship!',
action: 'complete',
},
];
const animateHit = (isLlama: boolean) => {
const element = document.querySelector(isLlama ? '.llama-sprite' : '.goose-sprite');
if (element) {
element.classList.add('hit-flash');
setTimeout(() => {
element.classList.remove('hit-flash');
}, 500);
}
};
useEffect(() => {
// Add CSS for the hit animation and defeat animation
const style = document.createElement('style');
style.textContent = `
@keyframes hitFlash {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
.hit-flash {
animation: hitFlash 0.5s;
}
@keyframes defeat {
0% { transform: translateY(0); opacity: 1; }
20% { transform: translateY(-30px); opacity: 1; }
100% { transform: translateY(500px); opacity: 0; }
}
.defeated {
animation: defeat 1.3s cubic-bezier(.36,.07,.19,.97) both;
}
`;
document.head.appendChild(style);
return () => {
document.head.removeChild(style);
};
}, []);
const handleAction = async (value: string) => {
const currentStep =
battleState.currentStep < battleSteps.length ? battleSteps[battleState.currentStep] : null;
if (!currentStep) return;
// Handle host input
if (currentStep.action === 'host_input' && value && currentStep.configKey) {
setConfigValues((prev) => ({
...prev,
[currentStep.configKey!]: value,
}));
return;
}
// Handle host submit
if (currentStep.action === 'host_input' && !value) {
setBattleState((prev) => ({
...prev,
processingAction: true,
llamaHp: 0,
message: "It's super effective!",
}));
animateHit(true);
// Add defeat class to llama sprite and health bar
const llamaContainer = document.querySelector('.llama-container');
if (llamaContainer) {
await new Promise((resolve) => setTimeout(resolve, 500));
llamaContainer.classList.add('defeated');
}
// Show victory messages with delays
if (currentStep.followUpMessages) {
for (const msg of currentStep.followUpMessages) {
await new Promise((resolve) => setTimeout(resolve, 1000));
setBattleState((prev) => ({ ...prev, message: msg }));
}
}
await new Promise((resolve) => setTimeout(resolve, 1000));
onComplete(configValues);
return;
}
// Handle continue button for messages
if (!currentStep.action) {
setBattleState((prev) => ({
...prev,
currentStep: prev.currentStep + 1,
message: battleSteps[prev.currentStep + 1]?.message || prev.message,
processingAction: false,
}));
return;
}
// Handle choices (Pacify/HONK/Configure Host)
if (
(currentStep.action === 'choice' ||
currentStep.action === 'final_choice' ||
currentStep.action === 'host_choice') &&
value
) {
// Set processing flag to hide buttons
setBattleState((prev) => ({
...prev,
processingAction: true,
}));
if (value === 'Configure Host') {
setBattleState((prev) => ({
...prev,
message: 'GOOSE used Configure Host!',
showHostInput: true,
currentStep: battleSteps.findIndex((step) => step.action === 'host_input'),
processingAction: false,
}));
return;
}
// Handle Pacify or HONK attacks
setBattleState((prev) => ({
...prev,
lastChoice: value,
llamaHp: Math.max(0, prev.llamaHp - 25),
message: `GOOSE used ${value}!`,
}));
animateHit(true);
// Show follow-up messages
if (currentStep.followUpMessages) {
for (const msg of currentStep.followUpMessages) {
await new Promise((resolve) => setTimeout(resolve, 1000));
setBattleState((prev) => ({ ...prev, message: msg }));
}
}
// Proceed to counter-attack
await new Promise((resolve) => setTimeout(resolve, 1000));
const isFirstCycle = currentStep.action === 'choice';
const nextStep = battleSteps[battleState.currentStep + 1];
setBattleState((prev) => ({
...prev,
gooseHp: Math.max(0, prev.gooseHp - 25),
message: isFirstCycle ? 'OLLAMA used YAML Confusion!' : 'OLLAMA used Docker Dependency!',
currentStep: prev.currentStep + 1,
processingAction: false,
}));
animateHit(false);
// Show counter-attack messages
if (nextStep?.followUpMessages) {
await new Promise((resolve) => setTimeout(resolve, 1000));
for (const msg of nextStep.followUpMessages) {
await new Promise((resolve) => setTimeout(resolve, 1000));
setBattleState((prev) => ({ ...prev, message: msg }));
}
}
return;
}
// Check for battle completion
if (battleState.currentStep === battleSteps.length - 2) {
onComplete(configValues);
}
};
return (
<div className="w-full h-full px-4 py-6">
{/* Battle Scene */}
<div
className="relative w-full h-[300px] rounded-lg mb-4 bg-cover bg-center border-4 border-[#2C3E50] overflow-hidden"
style={{
backgroundImage: `url(${battleBackground})`,
backgroundSize: 'cover',
backgroundPosition: 'center bottom',
}}
>
{/* Llama sprite */}
<div className="absolute right-24 top-8 llama-container">
<div className="mb-2">
<div className="bg-[#1F2937] rounded-lg px-3 py-1 text-white font-pokemon mb-1">
OLLAMA
<span className="text-xs ml-2">Lv.1</span>
</div>
<div className="flex items-center gap-2">
<div className="h-2 bg-[#374151] rounded-full flex-grow">
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${battleState.llamaHp}%`,
backgroundColor:
battleState.llamaHp > 50
? '#10B981'
: battleState.llamaHp > 20
? '#F59E0B'
: '#EF4444',
}}
/>
</div>
<span className="text-sm font-pokemon text-[#1F2937]">
{Math.floor(battleState.llamaHp)}/100
</span>
</div>
</div>
<img
src={llamaSprite}
alt="Llama"
className="w-40 h-40 object-contain llama-sprite pixelated"
style={{
transform: `translateY(${battleState.currentStep % 2 === 1 ? '-4px' : '0'})`,
transition: 'transform 0.3s ease-in-out',
imageRendering: 'pixelated',
}}
/>
</div>
{/* Goose sprite */}
<div className="absolute left-24 bottom-4">
<img
src={gooseSprite}
alt="Goose"
className="w-40 h-40 object-contain mb-2 goose-sprite pixelated"
style={{
transform: `translateY(${battleState.currentStep % 2 === 0 ? '-4px' : '0'})`,
transition: 'transform 0.3s ease-in-out',
imageRendering: 'pixelated',
}}
/>
<div>
<div className="bg-[#1F2937] rounded-lg px-3 py-1 text-white font-pokemon mb-1">
GOOSE
<span className="text-xs ml-2">Lv.99</span>
</div>
<div className="flex items-center gap-2">
<div className="h-2 bg-[#374151] rounded-full flex-grow">
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${battleState.gooseHp}%`,
backgroundColor:
battleState.gooseHp > 50
? '#10B981'
: battleState.gooseHp > 20
? '#F59E0B'
: '#EF4444',
}}
/>
</div>
<span className="text-sm font-pokemon text-[#1F2937]">
{Math.floor(battleState.gooseHp)}/100
</span>
</div>
</div>
</div>
</div>
{/* Dialog Box */}
<div className="relative w-full">
<div className="w-full bg-[#1F2937] rounded-lg p-6 border-4 border-[#4B5563] shadow-lg">
<div className="absolute top-4 right-4">
<button
onClick={toggleMute}
className="text-white hover:text-gray-300 transition-colors"
>
{isMuted ? '🔇' : '🔊'}
</button>
</div>
<p className="text-lg mb-4 text-white font-pokemon leading-relaxed">
{battleState.message}
</p>
{battleState.currentStep < battleSteps.length && (
<div className="space-y-4">
{/* Show battle choices */}
{(battleSteps[battleState.currentStep].action === 'choice' ||
battleSteps[battleState.currentStep].action === 'final_choice' ||
battleSteps[battleState.currentStep].action === 'host_choice') &&
!battleState.showHostInput &&
!battleState.processingAction && (
<div className="space-y-2">
{(typeof battleSteps[battleState.currentStep].choices === 'function'
? (
battleSteps[battleState.currentStep].choices as (
choice: string
) => string[]
)(battleState.lastChoice || '')
: (battleSteps[battleState.currentStep].choices as string[])
)?.map((choice: string) => (
<button
key={choice}
onClick={() => handleAction(choice)}
className="w-full text-left px-4 py-2 text-white font-pokemon hover:bg-[#2563EB] transition-colors rounded-lg group flex items-center"
>
<span className="opacity-0 group-hover:opacity-100 transition-opacity mr-2">
</span>
{choice}
</button>
))}
</div>
)}
{/* Show host input when needed */}
{battleState.showHostInput && !battleState.processingAction && (
<div className="space-y-4">
<p className="text-sm text-gray-300 font-pokemon">
Enter your Ollama host address:
</p>
<div className="flex gap-2">
<input
type="text"
className="flex-grow px-4 py-2 bg-[#374151] border-2 border-[#4B5563] rounded-lg text-white font-pokemon placeholder-gray-400 focus:outline-none focus:border-[#60A5FA]"
placeholder="http://localhost:11434"
onChange={(e) => handleAction(e.target.value)}
/>
<button
onClick={() => handleAction('')}
className="px-6 py-2 bg-[#2563EB] text-white font-pokemon rounded-lg hover:bg-[#1D4ED8] transition-colors focus:outline-none focus:ring-2 focus:ring-[#60A5FA] focus:ring-opacity-50"
>
Submit
</button>
</div>
</div>
)}
{/* Continue button for messages */}
{!battleSteps[battleState.currentStep].action && !battleState.processingAction && (
<button
onClick={() => handleAction('')}
className="mt-2 px-6 py-2 bg-[#2563EB] text-white font-pokemon rounded-lg hover:bg-[#1D4ED8] transition-colors focus:outline-none focus:ring-2 focus:ring-[#60A5FA] focus:ring-opacity-50"
>
Continue
</button>
)}
</div>
)}
</div>
{/* Black corners for that classic Pokemon feel */}
<div className="absolute top-0 left-0 w-4 h-4 bg-black rounded-tl-lg"></div>
<div className="absolute top-0 right-0 w-4 h-4 bg-black rounded-tr-lg"></div>
<div className="absolute bottom-0 left-0 w-4 h-4 bg-black rounded-bl-lg"></div>
<div className="absolute bottom-0 right-0 w-4 h-4 bg-black rounded-br-lg"></div>
</div>
</div>
);
}

View File

@@ -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<string, string[]>)[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 (
<div className="fixed inset-0 bg-black/20 dark:bg-white/20 backdrop-blur-sm transition-colors animate-[fadein_200ms_ease-in_forwards]">
<Card className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[500px] bg-bgApp rounded-xl overflow-hidden shadow-none p-[16px] pt-[24px] pb-0">
<div className="px-4 pb-0 space-y-8">
{/* Header */}
<div className="flex">
<h2 className="text-2xl font-regular text-textStandard">{headerText}</h2>
</div>
{provider.toLowerCase() === 'ollama' && shouldShowBattle ? (
<OllamaBattleGame onComplete={onSubmit} requiredKeys={requiredKeys} />
) : (
<form onSubmit={handleSubmit}>
<div className="mt-[24px] space-y-4">
{requiredKeys.map((keyName: string) => (
<div key={keyName}>
<Input
type={isSecretKey(keyName) ? 'password' : 'text'}
value={configValues[keyName] || ''}
onChange={(e) =>
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
/>
</div>
))}
<div
className="flex text-gray-600 dark:text-gray-300"
onClick={() => {
if (provider.toLowerCase() === 'ollama') {
onCancel();
onSubmit({ forceBattle: 'true' });
}
}}
>
<Lock className="w-6 h-6" />
<span className="text-sm font-light ml-4 mt-[2px]">{`Your configuration values will be stored securely in the keychain and used only for making requests to ${provider}`}</span>
</div>
</div>
{/* Actions */}
<div className="mt-[8px] -ml-8 -mr-8 pt-8">
<Button
type="submit"
variant="ghost"
className="w-full h-[60px] rounded-none border-t border-borderSubtle text-md hover:bg-bgSubtle text-textProminent font-regular"
>
Submit
</Button>
<Button
type="button"
variant="ghost"
onClick={onCancel}
className="w-full h-[60px] rounded-none border-t border-borderSubtle hover:text-textStandard text-textSubtle hover:bg-bgSubtle text-md font-regular"
>
Cancel
</Button>
</div>
</form>
)}
</div>
</Card>
</div>
);
}

View File

@@ -1,57 +1,19 @@
import React, { useState, useEffect } from 'react';
import { IpcRendererEvent } from 'electron';
import { ScrollArea } from '../ui/scroll-area'; 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 BackButton from '../ui/BackButton';
import { RecentModelsRadio } from './models/RecentModels'; import type { View, ViewOptions } from '../../App';
import { ExtensionItem } from './extensions/ExtensionItem'; import ExtensionsSection from './extensions/ExtensionsSection';
import { View, ViewOptions } from '../../App'; import ModelsSection from './models/ModelsSection';
import { ModeSelection } from './basic/ModeSelection'; import { ModeSection } from './mode/ModeSection';
import SessionSharingSection from './session/SessionSharingSection'; import { ToolSelectionStrategySection } from './tool_selection_strategy/ToolSelectionStrategySection';
import { toastSuccess } from '../../toasts'; 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'; 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 = { export type SettingsViewOptions = {
extensionId: string; deepLinkConfig?: ExtensionConfig;
showEnvVars: boolean; showEnvVars?: boolean;
}; };
export default function SettingsView({ export default function SettingsView({
@@ -63,133 +25,8 @@ export default function SettingsView({
setView: (view: View, viewOptions?: ViewOptions) => void; setView: (view: View, viewOptions?: ViewOptions) => void;
viewOptions: SettingsViewOptions; viewOptions: SettingsViewOptions;
}) { }) {
const [settings, setSettings] = React.useState<SettingsType>(() => {
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<FullExtensionConfig | null>(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 ( return (
<div className="h-screen w-full"> <div className="h-screen w-full animate-[fadein_200ms_ease-in_forwards]">
<MoreMenuLayout showMenu={false} /> <MoreMenuLayout showMenu={false} />
<ScrollArea className="h-full w-full"> <ScrollArea className="h-full w-full">
@@ -200,114 +37,29 @@ export default function SettingsView({
</div> </div>
{/* Content Area */} {/* Content Area */}
<div className="flex-1 py-8 pt-[20px]"> <div className="flex-1 pt-[20px]">
<div className="space-y-8"> <div className="space-y-8">
{/*Models Section*/} {/* Models Section */}
<section id="models"> <ModelsSection setView={setView} />
<RecentModelsRadio setView={setView} /> {/* Extensions Section */}
</section> <ExtensionsSection
<section id="extensions"> deepLinkConfig={viewOptions.deepLinkConfig}
<div className="flex justify-between items-center mb-6 border-b border-borderSubtle px-8"> showEnvVars={viewOptions.showEnvVars}
<h2 className="text-xl font-semibold text-textStandard">Extensions</h2> />
<a {/* Goose Modes */}
href={EXTENSIONS_SITE_LINK} <ModeSection setView={setView} />
target="_blank" {/*Session sharing*/}
className="text-indigo-500 hover:text-indigo-600 text-sm" <SessionSharingSection />
rel="noreferrer" {/* Response Styles */}
> <ResponseStylesSection />
Browse {/* Tool Selection Strategy */}
</a> <ToolSelectionStrategySection setView={setView} />
</div> {/* App Settings */}
<AppSettingsSection />
<div className="px-8">
<p className="text-sm text-textStandard mb-4">{EXTENSIONS_DESCRIPTION}</p>
{settings.extensions.length === 0 ? (
<p className="text-textSubtle text-center py-4">No Extensions Added</p>
) : (
<div className="grid grid-cols-2 gap-2">
{settings.extensions.map((ext) => (
<ExtensionItem
key={ext.id}
{...ext}
canConfigure={true}
onToggle={handleExtensionToggle}
onConfigure={(extension) => setExtensionBeingConfigured(extension)}
/>
))}
<button
onClick={() => setIsManualModalOpen(true)}
className="text-indigo-500 hover:text-indigo-600 text-sm"
title="Add Manually"
>
<div className="rounded-lg border border-dashed border-borderSubtle hover:border-borderStandard p-4 transition-colors">
Add custom extension
</div>
</button>
</div>
)}
</div>
</section>
<section id="mode">
<div className="flex justify-between items-center mb-6 border-b border-borderSubtle px-8">
<h2 className="text-xl font-semibold text-textStandard">Mode Selection</h2>
</div>
<div className="px-8">
<p className="text-sm text-textStandard mb-4">
Configure how Goose interacts with tools and extensions
</p>
<ModeSelection />
</div>
</section>
<section id="session-sharing">
<SessionSharingSection />
</section>
</div> </div>
</div> </div>
</div> </div>
</ScrollArea> </ScrollArea>
{extensionBeingConfigured && isBuiltIn(extensionBeingConfigured.id) ? (
<ConfigureBuiltInExtensionModal
isOpen={!!extensionBeingConfigured && isBuiltIn(extensionBeingConfigured.id)}
onClose={() => {
setExtensionBeingConfigured(null);
}}
extension={extensionBeingConfigured}
onSubmit={handleExtensionConfigSubmit}
/>
) : (
<ConfigureExtensionModal
isOpen={!!extensionBeingConfigured}
onClose={() => {
setExtensionBeingConfigured(null);
}}
extension={extensionBeingConfigured}
onSubmit={handleExtensionConfigSubmit}
onRemove={handleExtensionRemove}
/>
)}
<ManualExtensionModal
isOpen={isManualModalOpen}
onClose={() => 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?
}
}}
/>
</div> </div>
); );
} }

View File

@@ -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<string[]>([]); // 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 (
<ActiveKeysContext.Provider value={{ activeKeys, setActiveKeys }}>
{!isLoading ? children : <SuspenseLoader />}
</ActiveKeysContext.Provider>
);
};
// 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;
};

View File

@@ -1,13 +0,0 @@
export interface ProviderResponse {
supported: boolean;
name?: string;
description?: string;
models?: string[];
config_status: Record<string, ConfigDetails>;
}
export interface ConfigDetails {
key: string;
is_set: boolean;
location?: string;
}

View File

@@ -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<string[]> {
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<Record<string, ProviderResponse>> {
// 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<string, ProviderResponse> = {};
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<Record<string, ConfigDetails>>(
(acc: Record<string, ConfigDetails>, key: string) => {
acc[key] = {
key,
is_set: provider.is_configured,
location: provider.is_configured ? 'config' : undefined,
};
return acc;
},
{}
),
};
});
return data;
}

View File

@@ -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 (
<div className="fixed inset-0 bg-black/20 backdrop-blur-sm z-10">
<Card className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[440px] bg-white dark:bg-gray-800 rounded-xl shadow-xl overflow-hidden p-0">
<div className="px-4 pb-0 space-y-6">
<div className="p-[16px] pt-[24px] pb-0">
{/* Header */}
<div className="flex">
<h2 className="text-2xl font-regular dark:text-white text-gray-900">
Configure Approve Mode
</h2>
</div>
<div className="mt-[24px]">
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
Approve requests can either be given to all tool requests or determine which actions
may need integration
</p>
<div className="space-y-4">
{approveModes.map((mode) => (
<ModeSelectionItem
key={mode.key}
mode={mode}
showDescription={true}
currentMode={approveMode || ''}
isApproveModeConfigure={true}
handleModeChange={(newMode) => {
setApproveMode(newMode);
}}
/>
))}
</div>
</div>
</div>
</div>
{/* Actions */}
<div className="mt-[8px] ml-[-24px] mr-[-24px] pt-[16px]">
<Button
type="submit"
variant="ghost"
disabled={isSubmitting}
onClick={handleModeSubmit}
className="w-full h-[60px] rounded-none border-t text-lg hover:bg-gray-50 dark:hover:text-black dark:text-white dark:border-gray-600 font-regular"
>
{isSubmitting ? 'Saving...' : 'Save Mode'}
</Button>
<Button
type="button"
variant="ghost"
disabled={isSubmitting}
onClick={onClose}
className="w-full h-[60px] rounded-none border-t text-gray-400 dark:hover:text-black hover:bg-gray-50 dark:border-gray-600 text-lg font-regular"
>
Cancel
</Button>
</div>
</Card>
</div>
);
}

View File

@@ -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 (
<div>
<div>
{filterGooseModes(currentMode, all_goose_modes, previousApproveModel).map((mode) => (
<ModeSelectionItem
key={mode.key}
mode={mode}
currentMode={currentMode}
showDescription={true}
isApproveModeConfigure={false}
handleModeChange={handleModeChange}
/>
))}
</div>
</div>
);
};

View File

@@ -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 (
<div>
<div className="group hover:cursor-pointer" onClick={() => handleModeChange(mode.key)}>
<div className="flex items-center justify-between text-textStandard mb-4">
<div className="flex">
<h3 className="text-textStandard">{mode.label}</h3>
{showDescription && (
<p className="text-xs text-textSubtle mt-[2px]">{mode.description}</p>
)}
</div>
</div>
<div className="relative flex items-center gap-3">
{!isApproveModeConfigure && (mode.key == 'approve' || mode.key == 'smart_approve') && (
<button
onClick={() => {
setIsDislogOpen(true);
}}
>
<Gear className="w-5 h-5 text-textSubtle hover:text-textStandard" />
</button>
)}
<input
type="radio"
name="modes"
value={mode.key}
checked={checked}
onChange={() => handleModeChange(mode.key)}
className="peer sr-only"
/>
<div
className="h-4 w-4 rounded-full border border-gray-400 dark:border-gray-500
peer-checked:border-[6px] peer-checked:border-black dark:peer-checked:border-white
peer-checked:bg-white dark:peer-checked:bg-black
transition-all duration-200 ease-in-out"
></div>
</div>
</div>
<div>
<div>
{isDislogOpen ? (
<ConfigureApproveMode
onClose={() => {
setIsDislogOpen(false);
}}
handleModeChange={handleModeChange}
currentMode={currentMode}
/>
) : null}
</div>
</div>
</div>
);
}

View File

@@ -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<Record<string, string>>({});
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 (
<div className="fixed inset-0 bg-black/20 backdrop-blur-sm">
<Card className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[440px] bg-white dark:bg-gray-800 rounded-xl shadow-xl overflow-hidden p-[16px] pt-[24px] pb-0">
<div className="px-8 pb-0 space-y-8">
{/* Header */}
<div className="flex">
<h2 className="text-2xl font-regular dark:text-white text-gray-900">
Configure {extension.name}
</h2>
</div>
{/* Form */}
<form onSubmit={handleExtensionConfigSubmit}>
<div className="mt-[24px]">
{extension.env_keys && extension.env_keys.length > 0 ? (
<>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
Please provide the required environment variables for this extension:
</p>
<div className="space-y-4">
{extension.env_keys.map((envVarName) => (
<div key={envVarName}>
<label
htmlFor={envVarName}
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
{envVarName}
</label>
<Input
type="text"
id={envVarName}
name={envVarName}
placeholder={envVarName}
value={envValues[envVarName] || ''}
onChange={(e) =>
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
/>
</div>
))}
</div>
</>
) : (
<p className="text-sm text-gray-500 dark:text-gray-400">
This extension doesn't require any environment variables.
</p>
)}
</div>
{/* Actions */}
<div className="mt-[8px] ml-[-24px] mr-[-24px] pt-[16px]">
<Button
type="submit"
variant="ghost"
disabled={isSubmitting}
className="w-full h-[60px] rounded-none border-t dark:border-gray-600 text-indigo-500 hover:bg-indigo-50 dark:hover:bg-indigo-900/20 dark:border-gray-600 text-lg font-regular"
>
{isSubmitting ? 'Saving...' : 'Save Configuration'}
</Button>
<Button
type="button"
variant="ghost"
onClick={onClose}
disabled={isSubmitting}
className="w-full h-[60px] rounded-none border-t dark:border-gray-600 text-gray-400 hover:bg-gray-50 dark:border-gray-600 text-lg font-regular"
>
Cancel
</Button>
</div>
</form>
</div>
</Card>
</div>
);
}

View File

@@ -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<Record<string, string>>({});
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 (
<div className="fixed inset-0 bg-black/20 backdrop-blur-sm">
<Card className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[440px] bg-white dark:bg-gray-800 rounded-xl shadow-xl overflow-hidden p-[16px] pt-[24px] pb-0">
<div className="px-8 pb-0 space-y-8">
{/* Header */}
<div className="flex">
<h2 className="text-2xl font-regular dark:text-white text-gray-900">
Configure {extension.name}
</h2>
</div>
{/* Form */}
<form onSubmit={handleExtensionConfigSubmit}>
<div className="mt-[24px]">
{extension.env_keys && extension.env_keys.length > 0 ? (
<>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
Please provide the required environment variables for this extension:
</p>
<div className="space-y-4">
{extension.env_keys.map((envVarName) => (
<div key={envVarName}>
<label
htmlFor={envVarName}
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
{envVarName}
</label>
<Input
type="text"
id={envVarName}
name={envVarName}
placeholder={envVarName}
value={envValues[envVarName] || ''}
onChange={(e) =>
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
/>
</div>
))}
</div>
</>
) : (
<p className="text-sm text-gray-500 dark:text-gray-400">
This extension doesn't require any environment variables.
</p>
)}
</div>
{/* Actions */}
<div className="mt-[8px] ml-[-24px] mr-[-24px] pt-[16px]">
<Button
type="submit"
variant="ghost"
disabled={isSubmitting}
className="w-full h-[60px] rounded-none border-t dark:border-gray-600 text-lg hover:bg-gray-50 hover:dark:text-black dark:text-white dark:border-gray-600 font-regular"
>
{isSubmitting ? 'Saving...' : 'Save Configuration'}
</Button>
<Button
type="button"
variant="ghost"
onClick={onRemove}
disabled={isSubmitting}
className="w-full h-[60px] rounded-none border-t dark:border-gray-600 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 dark:border-gray-600 text-lg font-regular"
>
Remove Extension
</Button>
<Button
type="button"
variant="ghost"
onClick={onClose}
disabled={isSubmitting}
className="w-full h-[60px] rounded-none border-t dark:border-gray-600 text-gray-400 hover:bg-gray-50 dark:border-gray-600 text-lg font-regular"
>
Cancel
</Button>
</div>
</form>
</div>
</Card>
</div>
);
}

View File

@@ -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<ExtensionItemProps> = (props) => {
const { id, name, description, enabled, onToggle, onConfigure, canConfigure } = props;
return (
<div className="rounded-lg border border-borderSubtle p-4">
<div className="flex justify-between items-center">
<div className="">
<div className="flex items-center">
<h3 className="text-sm font-semibold text-textStandard">{name}</h3>
</div>
<p className="text-xs text-textSubtle mt-[2px]">{description}</p>
</div>
<div className="flex items-center gap-3">
{canConfigure && ( // Conditionally render the gear icon
<button onClick={() => onConfigure(props)} className="">
<Gear className="w-5 h-5 text-textSubtle hover:text-textStandard" />
</button>
)}
<button
onClick={() => onToggle(id)}
className={`relative inline-flex h-6 w-11 items-center rounded-full ${
enabled ? 'bg-indigo-500' : 'bg-bgProminent'
} transition-colors duration-200 ease-in-out focus:outline-none`}
>
<span
className={`inline-block h-5 w-5 transform rounded-full bg-white shadow ${
enabled ? 'translate-x-[22px]' : 'translate-x-[2px]'
} transition-transform duration-200 ease-in-out`}
/>
</button>
</div>
</div>
</div>
);
};

View File

@@ -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<FullExtensionConfig> & { commandInput?: string }
>({
type: 'stdio',
enabled: true,
args: [],
commandInput: '',
timeout: DEFAULT_EXTENSION_TIMEOUT,
});
const [envKey, setEnvKey] = useState('');
const [envValue, setEnvValue] = useState('');
const [envVars, setEnvVars] = useState<Array<{ key: string; value: string }>>([]);
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 (
<div className="fixed flex items-center justify-center inset-0 bg-black/20 dark:bg-white/20 backdrop-blur-sm">
<Card className="w-[500px] bg-bgApp rounded-xl overflow-hidden shadow-none p-[16px] pt-[24px] pb-0 max-h-[90vh] overflow-y-auto">
<div className="px-4 pb-0 space-y-8">
<div className="flex">
<h2 className="text-2xl font-regular text-textStandard">Add custom extension</h2>
</div>
<form onSubmit={handleSubmit}>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-textStandard mb-2">Type</label>
<Select
options={typeOptions}
value={typeOptions.find((option) => option.value === formData.type)}
onChange={(newValue: unknown) => {
const option = newValue as { value: string; label: string } | null;
setFormData({
...formData,
type: option?.value as FullExtensionConfig['type'],
});
}}
/>
</div>
<div>
<label className="block text-sm font-medium text-textStandard mb-2">ID *</label>
<Input
type="text"
value={formData.id || ''}
onChange={(e) => setFormData({ ...formData, id: e.target.value })}
className="w-full"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-textStandard mb-2">Name *</label>
<Input
type="text"
value={formData.name || ''}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-textStandard mb-2">
Description *
</label>
<Input
type="text"
value={formData.description || ''}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full"
required
/>
</div>
{formData.type === 'stdio' && (
<div>
<label className="block text-sm font-medium text-textStandard mb-2">
Command * (command and arguments separated by spaces)
</label>
<Input
type="text"
value={formData.commandInput || ''}
onChange={(e) => setFormData({ ...formData, commandInput: e.target.value })}
placeholder="e.g. goosed mcp example"
className="w-full"
required
/>
</div>
)}
{formData.type === 'sse' && (
<div>
<label className="block text-sm font-medium text-textStandard mb-2">URI *</label>
<Input
type="text"
value={formData.uri || ''}
onChange={(e) => setFormData({ ...formData, uri: e.target.value })}
className="w-full"
required
/>
</div>
)}
<div>
<label className="block text-sm font-medium text-textStandard mb-2">
Environment Variables
</label>
<div className="flex gap-2 mb-2">
<Input
type="text"
value={envKey}
onChange={(e) => setEnvKey(e.target.value)}
placeholder="Environment variable name"
className="flex-1"
/>
<Input
type="text"
value={envValue}
onChange={(e) => setEnvValue(e.target.value)}
placeholder="Value"
className="flex-1"
/>
<Button
type="button"
onClick={handleAddEnvVar}
className="bg-bgApp hover:bg-bgApp shadow-none border border-borderSubtle hover:border-borderStandard transition-colors text-textStandard"
>
Add
</Button>
</div>
{envVars.length > 0 && (
<div className="space-y-2">
{envVars.map((envVar) => (
<div key={envVar.key} className="flex items-center justify-between gap-1">
<div className="flex-1 whitespace-nowrap overflow-x-auto bg-gray-100 dark:bg-gray-700 p-2 rounded">
<div className="flex-1">
<span className="text-sm font-medium">{envVar.key}</span>
<span className="text-sm text-gray-500 dark:text-gray-400 ml-2">
= {envVar.value}
</span>
</div>
</div>
<div className="bg-gray-100 dark:bg-gray-700 p-2 rounded">
<button
type="button"
onClick={() => handleRemoveEnvVar(envVar.key)}
className="text-red-500 hover:text-red-700 ml-2"
>
Remove
</button>
</div>
</div>
))}
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-textStandard mb-2">
Timeout (secs)*
</label>
<Input
type="number"
value={formData.timeout || DEFAULT_EXTENSION_TIMEOUT}
onChange={(e) => setFormData({ ...formData, timeout: parseInt(e.target.value) })}
className="w-full"
required
/>
</div>
</div>
<div className="mt-[8px] -ml-8 -mr-8 pt-8">
<Button
type="submit"
variant="ghost"
className="w-full h-[60px] rounded-none border-t border-borderSubtle text-md hover:bg-bgSubtle text-textProminent font-regular"
>
Add
</Button>
<Button
type="button"
variant="ghost"
onClick={() => {
resetForm();
onClose();
}}
className="w-full h-[60px] rounded-none border-t border-borderSubtle hover:text-textStandard text-textSubtle hover:bg-bgSubtle text-md font-regular"
>
Cancel
</Button>
</div>
</form>
</div>
</Card>
</div>
);
}

View File

@@ -184,3 +184,18 @@ export function removeShims(cmd: string) {
// If it's not a shim, return the original command // If it's not a shim, return the original command
return cmd; 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';
}

View File

@@ -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';
}

View File

@@ -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<string | null>(null);
const [modelName, setModelName] = useState<string>('');
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 (
<div className="mb-6">
<form className="grid grid-cols-[1.5fr_2fr_auto] gap-4 items-center">
<Select
options={providerOptions}
value={providerOptions.find((option) => option.value === selectedProvider) || null}
onChange={(newValue: unknown) => {
const option = newValue as { value: string | null } | null;
setSelectedProvider(option?.value || null);
setModelName(''); // Clear model name when provider changes
setFilteredModels([]);
}}
placeholder="Select provider"
isClearable
styles={createDarkSelectStyles('200px')}
theme={darkSelectTheme}
/>
<div className="relative" style={{ minWidth: '150px' }}>
<Input
type="text"
placeholder="Model name"
value={modelName}
onChange={(e) => setModelName(e.target.value)}
onBlur={handleBlur}
/>
{showSuggestions && (
<div className="absolute z-10 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg">
{filteredModels.map((model) => (
<div
key={model.id}
className="p-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-white"
onClick={() => handleSelectSuggestion(model)}
>
{model.name}
</div>
))}
</div>
)}
</div>
<Button
type="button"
className="bg-black text-white hover:bg-black/90 dark:bg-white dark:text-black dark:hover:bg-white/90"
onClick={handleSubmit}
>
<Plus className="h-4 w-4" /> Add Model
</Button>
</form>
</div>
);
}

View File

@@ -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' },
];

View File

@@ -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<ModelContextValue | undefined>(undefined);
export const ModelProvider = ({ children }: { children: ReactNode }) => {
const [currentModel, setCurrentModel] = useState<Model | null>(
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 (
<ModelContext.Provider value={{ currentModel, setCurrentModel: updateModel, switchModel }}>
{children}
</ModelContext.Provider>
);
};
export const useModel = () => {
const context = useContext(ModelContext);
if (!context) throw new Error('useModel must be used within a ModelProvider');
return context;
};

View File

@@ -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 (
<div className="flex justify-between items-center mb-6 border-b border-borderSubtle px-8">
<h2 className="text-xl font-medium text-textStandard">Models</h2>
<button
onClick={() => {
setView('moreModels');
}}
className="text-indigo-500 hover:text-indigo-600 text-sm"
>
Browse
</button>
</div>
);
}
export function ModelRadioList({ renderItem, className = '' }: ModelRadioListProps) {
const { recentModels } = useRecentModels();
const { currentModel } = useModel();
const handleModelSelection = useHandleModelSelection();
const [selectedModel, setSelectedModel] = useState<string | null>(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 (
<div className={className}>
{recentModels.map((model) =>
renderItem({
model,
isSelected: selectedModel === model.name,
onSelect: () => handleRadioChange(model),
})
)}
</div>
);
}

View File

@@ -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 (
<div className="h-screen w-full">
<MoreMenuLayout showMenu={false} />
<ScrollArea className="h-full w-full">
<div className="px-8 pt-6 pb-4">
<BackButton onClick={onClose} />
<h1 className="text-3xl font-medium text-textStandard mt-1">Browse models</h1>
</div>
{/* Content Area */}
<div className="flex-1 py-8 pt-[20px]">
<div className="max-w-full md:max-w-3xl mx-auto space-y-12">
<div className="flex justify-between items-center mb-6 border-b border-borderSubtle px-8">
<h2 className="text-xl font-medium text-textStandard">Models</h2>
<button
onClick={() => setView('configureProviders')}
className="text-indigo-500 hover:text-indigo-600 text-sm"
>
Configure
</button>
</div>
<div className="px-8 space-y-8">
{/* Search Section */}
<section>
<h2 className="text-md font-medium text-textStandard mb-3">Search Models</h2>
<SearchBar />
</section>
{/* Add Model Section */}
<section>
<h2 className="text-md font-medium text-textStandard mb-3">Add Model</h2>
<AddModelInline />
</section>
{/* Provider Section */}
<section>
<h2 className="text-md font-medium text-textStandard mb-3">Browse by Provider</h2>
<div>
<ProviderButtons />
</div>
</section>
{/* Recent Models Section */}
<section>
<div className="flex items-center justify-between mb-3">
<h2 className="text-md font-medium text-textStandard">Recently used</h2>
</div>
<div>
<RecentModels />
</div>
</section>
</div>
</div>
</div>
</ScrollArea>
</div>
);
}

View File

@@ -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<string, string> = model_docs_link.reduce(
(acc, { name, href }) => {
acc[name] = href;
return acc;
},
{} as Record<string, string>
);
export function ProviderButtons() {
const { activeKeys } = useActiveKeys();
const [selectedProvider, setSelectedProvider] = useState<string | null>(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 (
<div className="space-y-4">
<div className="overflow-x-auto">
<div className="flex items-center gap-2 min-w-min">
{activeKeys.map((provider) => (
<Button
key={provider}
variant="ghost"
className={`text-sm whitespace-nowrap shrink-0
${
selectedProvider === provider
? 'bg-bgSubtle text-textStandard border-borderStandard'
: 'bg-bgApp border-borderSubtle text-textSubtle'
}
rounded-full shadow-none border`}
onClick={() => {
setSelectedProvider(selectedProvider === provider ? null : provider);
}}
>
{provider}
</Button>
))}
</div>
</div>
{/* Models List */}
{selectedProvider && (
<div className="mt-6">
<div>
{providerModels.map((model) => (
<div
key={model.id}
className="py-2 px-1 cursor-pointer text-gray-600
dark:text-gray-300 hover:text-gray-900
dark:hover:text-white transition-colors
flex justify-between items-center"
>
<span>{model.name}</span>
<Switch
variant="mono"
checked={model.id === currentModel?.id}
onCheckedChange={() => handleModelSelection(model, 'ProviderButtons')}
/>
</div>
))}
</div>
<a
href={providerLinks[selectedProvider]}
target="_blank"
rel="noopener noreferrer"
className="text-indigo-500 hover:text-indigo-600 text-sm"
>
Browse more {selectedProvider} models
</a>
</div>
)}
</div>
);
}

View File

@@ -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<Model[]>([]);
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<string | null>(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 (
<div className="space-y-2">
{recentModels.map((model) => (
<label key={model.name} className="flex justify-between items-center py-2 cursor-pointer">
<div className="relative" onClick={() => handleRadioChange(model)}>
<div className="flex flex-row items-center">
<input
type="radio"
name="recentModels"
value={model.name}
checked={selectedModel === model.name}
onChange={() => handleRadioChange(model)}
className="peer sr-only"
/>
<div
className="h-4 w-4 rounded-full border border-gray-400 dark:border-gray-500 mr-4
peer-checked:border-[6px] peer-checked:border-black dark:peer-checked:border-white
peer-checked:bg-white dark:peer-checked:bg-black
transition-all duration-200 ease-in-out"
></div>
<div className="">
<p className="text-sm text-textStandard">{model.name}</p>
<p className="text-xs text-textSubtle">{model.provider}</p>
</div>
</div>
</div>
<div className="flex items-center text-sm text-textSubtle">
<Clock className="w-4 h-4 mr-2" />
{model.lastUsed ? getRelativeTimeString(model.lastUsed) : 'N/A'}
</div>
</label>
))}
</div>
);
}
export function RecentModelsRadio({ setView }: { setView: (view: View) => void }) {
return (
<div>
<SeeMoreModelsButtons setView={setView} />
<div className="px-8">
<div className="space-y-2">
<ModelRadioList
renderItem={({ model, isSelected, onSelect }) => (
<label key={model.name} className="flex items-center py-2 cursor-pointer">
<div className="relative mr-4">
<input
type="radio"
name="recentModels"
value={model.name}
checked={isSelected}
onChange={onSelect}
className="peer sr-only"
/>
<div
className="h-4 w-4 rounded-full border border-gray-400 dark:border-gray-500
peer-checked:border-[6px] peer-checked:border-black dark:peer-checked:border-white
peer-checked:bg-white dark:peer-checked:bg-black
transition-all duration-200 ease-in-out"
></div>
</div>
<div className="">
<p className="text-sm text-textStandard">{model.alias ?? model.name}</p>
<p className="text-xs text-textSubtle">{model.subtext ?? model.provider}</p>
</div>
</label>
)}
/>
</div>
</div>
</div>
);
}

View File

@@ -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<HTMLDivElement>(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<HTMLInputElement>) => {
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 (
<div className="relative" ref={searchBarRef}>
<Search className="absolute left-3 top-[0.8rem] h-4 w-4 text-textSubtle" />
<input
type="text"
placeholder="Search models..."
value={search}
onChange={(e) => {
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 && (
<div className="absolute z-10 w-full mt-2 bg-white dark:bg-gray-800 border border-borderSubtle rounded-md shadow-lg">
{filteredModels.length > 0 ? (
filteredModels.map((model, index) => (
<div
key={model.id}
ref={(el) => (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' : ''
}`}
>
<div>
<span className="font-medium dark:text-white">{model.name}</span>
<span className="ml-2 text-xs text-gray-500 dark:text-gray-400 italic">
{model.provider}
</span>
</div>
<Switch
variant="mono"
checked={model.id === currentModel?.id}
onCheckedChange={() => handleModelSelection(model, 'SearchBar')}
/>
</div>
))
) : (
<div className="p-2 text-textSubtle dark:text-gray-400">No models found</div>
)}
</div>
)}
</div>
);
}

View File

@@ -1,7 +1,6 @@
import { Sliders } from 'lucide-react'; import { Sliders } from 'lucide-react';
import React, { useEffect, useState, useRef } from 'react'; import React, { useEffect, useState, useRef } from 'react';
import { useConfig } from '../../../ConfigContext'; import { useModelAndProvider } from '../../../ModelAndProviderContext';
import { getCurrentModelAndProviderForDisplay } from '../index';
import { AddModelModal } from '../subcomponents/AddModelModal'; import { AddModelModal } from '../subcomponents/AddModelModal';
import { View } from '../../../../App'; import { View } from '../../../../App';
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '../../../ui/Tooltip'; import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '../../../ui/Tooltip';
@@ -11,10 +10,10 @@ interface ModelsBottomBarProps {
setView: (view: View) => void; setView: (view: View) => void;
} }
export default function ModelsBottomBar({ dropdownRef, setView }: ModelsBottomBarProps) { export default function ModelsBottomBar({ dropdownRef, setView }: ModelsBottomBarProps) {
const { read, getProviders } = useConfig(); const { currentModel, currentProvider, getCurrentModelAndProviderForDisplay } =
useModelAndProvider();
const [isModelMenuOpen, setIsModelMenuOpen] = useState(false); const [isModelMenuOpen, setIsModelMenuOpen] = useState(false);
const [provider, setProvider] = useState<string | null>(null); const [displayProvider, setDisplayProvider] = useState<string | null>(null);
const [model, setModel] = useState<string>('');
const [isAddModelModalOpen, setIsAddModelModalOpen] = useState(false); const [isAddModelModalOpen, setIsAddModelModalOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
const [isModelTruncated, setIsModelTruncated] = useState(false); const [isModelTruncated, setIsModelTruncated] = useState(false);
@@ -22,16 +21,15 @@ export default function ModelsBottomBar({ dropdownRef, setView }: ModelsBottomBa
const modelRef = useRef<HTMLSpanElement>(null); const modelRef = useRef<HTMLSpanElement>(null);
const [isTooltipOpen, setIsTooltipOpen] = useState(false); const [isTooltipOpen, setIsTooltipOpen] = useState(false);
// Update display provider when current provider changes
useEffect(() => { useEffect(() => {
(async () => { if (currentProvider) {
const modelProvider = await getCurrentModelAndProviderForDisplay({ (async () => {
readFromConfig: read, const modelProvider = await getCurrentModelAndProviderForDisplay();
getProviders, setDisplayProvider(modelProvider.provider);
}); })();
setProvider(modelProvider.provider as string | null); }
setModel(modelProvider.model as string); }, [currentProvider, getCurrentModelAndProviderForDisplay]);
})();
});
useEffect(() => { useEffect(() => {
const checkTruncation = () => { const checkTruncation = () => {
@@ -42,7 +40,7 @@ export default function ModelsBottomBar({ dropdownRef, setView }: ModelsBottomBa
checkTruncation(); checkTruncation();
window.addEventListener('resize', checkTruncation); window.addEventListener('resize', checkTruncation);
return () => window.removeEventListener('resize', checkTruncation); return () => window.removeEventListener('resize', checkTruncation);
}, [model]); }, [currentModel]);
useEffect(() => { useEffect(() => {
setIsTooltipOpen(false); setIsTooltipOpen(false);
@@ -81,12 +79,12 @@ export default function ModelsBottomBar({ dropdownRef, setView }: ModelsBottomBa
ref={modelRef} ref={modelRef}
className="truncate max-w-[130px] md:max-w-[200px] lg:max-w-[360px] min-w-0 block" className="truncate max-w-[130px] md:max-w-[200px] lg:max-w-[360px] min-w-0 block"
> >
{model || 'Select Model'} {currentModel || 'Select Model'}
</span> </span>
</TooltipTrigger> </TooltipTrigger>
{isModelTruncated && ( {isModelTruncated && (
<TooltipContent className="max-w-96 overflow-auto scrollbar-thin" side="top"> <TooltipContent className="max-w-96 overflow-auto scrollbar-thin" side="top">
{model || 'Select Model'} {currentModel || 'Select Model'}
</TooltipContent> </TooltipContent>
)} )}
</Tooltip> </Tooltip>
@@ -99,7 +97,7 @@ export default function ModelsBottomBar({ dropdownRef, setView }: ModelsBottomBa
<div className=""> <div className="">
<div className="text-sm text-textProminent mt-2 ml-2">Current:</div> <div className="text-sm text-textProminent mt-2 ml-2">Current:</div>
<div className="flex items-center justify-between text-sm ml-2"> <div className="flex items-center justify-between text-sm ml-2">
{model} -- {provider} {currentModel} -- {displayProvider}
</div> </div>
<div <div
className="flex items-center justify-between text-textStandard p-2 cursor-pointer transition-colors hover:bg-bgStandard className="flex items-center justify-between text-textStandard p-2 cursor-pointer transition-colors hover:bg-bgStandard

View File

@@ -1,93 +0,0 @@
export const default_models = {
openai: 'gpt-4o',
anthropic: 'claude-3-5-sonnet-latest',
databricks: 'goose',
google: 'gemini-2.0-flash-exp',
groq: 'llama-3.3-70b-versatile',
openrouter: 'anthropic/claude-3.5-sonnet',
ollama: 'qwen2.5',
azure_openai: 'gpt-4o',
gcp_vertex_ai: 'gemini-2.0-flash-001',
aws_bedrock: 'us.anthropic.claude-3-7-sonnet-20250219-v1:0',
snowflake: 'claude-3-5-sonnet',
venice: 'llama-3.3-70b',
};
export function getDefaultModel(key: string): string | undefined {
return default_models[key as keyof typeof default_models] || undefined;
}
export const required_keys = {
OpenAI: ['OPENAI_API_KEY', 'OPENAI_HOST', 'OPENAI_BASE_PATH'],
Anthropic: ['ANTHROPIC_API_KEY', 'ANTHROPIC_HOST'],
Databricks: ['DATABRICKS_HOST'],
Groq: ['GROQ_API_KEY'],
Ollama: ['OLLAMA_HOST'],
Google: ['GOOGLE_API_KEY'],
OpenRouter: ['OPENROUTER_API_KEY'],
Snowflake: ['SNOWFLAKE_HOST', 'SNOWFLAKE_TOKEN'],
'Azure OpenAI': [
'AZURE_OPENAI_ENDPOINT',
'AZURE_OPENAI_DEPLOYMENT_NAME',
'AZURE_OPENAI_API_VERSION',
],
'GCP Vertex AI': ['GCP_PROJECT_ID', 'GCP_LOCATION'],
'AWS Bedrock': ['AWS_PROFILE'],
};
export const default_key_value = {
ANTHROPIC_HOST: 'https://api.anthropic.com',
OPENAI_HOST: 'https://api.openai.com',
OPENAI_BASE_PATH: 'v1/chat/completions',
OLLAMA_HOST: 'localhost',
GCP_LOCATION: 'us-central1',
AZURE_OPENAI_API_VERSION: '2024-10-21',
};
export const supported_providers = [
'OpenAI',
'Anthropic',
'Databricks',
'Groq',
'Google',
'Ollama',
'OpenRouter',
'Azure OpenAI',
'GCP Vertex AI',
'AWS Bedrock',
'Snowflake',
];
export const model_docs_link = [
{ name: 'OpenAI', href: 'https://platform.openai.com/docs/models' },
{ name: 'Anthropic', href: 'https://docs.anthropic.com/en/docs/about-claude/models' },
{ name: 'Google', href: 'https://ai.google/get-started/our-models/' },
{ name: 'Groq', href: 'https://console.groq.com/docs/models' },
{
name: 'Databricks',
href: 'https://docs.databricks.com/en/generative-ai/external-models/index.html',
},
{ name: 'OpenRouter', href: 'https://openrouter.ai/models' },
{ name: 'Ollama', href: 'https://ollama.com/library' },
{ name: 'GCP Vertex AI', href: 'https://cloud.google.com/vertex-ai' },
{ name: 'AWS Bedrock', href: 'https://console.aws.amazon.com/bedrock/home#/model-catalog' },
{
name: 'Snowflake',
href: 'https://docs.snowflake.com/user-guide/snowflake-cortex/llm-functions#availability',
},
];
export const provider_aliases = [
{ provider: 'OpenAI', alias: 'openai' },
{ provider: 'Anthropic', alias: 'anthropic' },
{ provider: 'Ollama', alias: 'ollama' },
{ provider: 'Groq', alias: 'groq' },
{ provider: 'Databricks', alias: 'databricks' },
{ provider: 'OpenRouter', alias: 'openrouter' },
{ provider: 'Google', alias: 'google' },
{ provider: 'Azure OpenAI', alias: 'azure_openai' },
{ provider: 'GCP Vertex AI', alias: 'gcp_vertex_ai' },
{ provider: 'AWS Bedrock', alias: 'aws_bedrock' },
{ provider: 'Snowflake', alias: 'snowflake' },
{ provider: 'Venice', alias: 'venice' },
];

View File

@@ -1,8 +1,7 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import Model from '../modelInterface'; import Model from '../modelInterface';
import { useRecentModels } from './recentModels'; import { useRecentModels } from './recentModels';
import { changeModel, getCurrentModelAndProvider } from '../index'; import { useModelAndProvider } from '../../../ModelAndProviderContext';
import { useConfig } from '../../../ConfigContext';
import { toastInfo } from '../../../../toasts'; import { toastInfo } from '../../../../toasts';
interface ModelRadioListProps { interface ModelRadioListProps {
@@ -30,7 +29,7 @@ export function BaseModelsList({
} else { } else {
modelList = providedModelList; modelList = providedModelList;
} }
const { read, upsert } = useConfig(); const { changeModel, getCurrentModelAndProvider } = useModelAndProvider();
const [selectedModel, setSelectedModel] = useState<Model | null>(null); const [selectedModel, setSelectedModel] = useState<Model | null>(null);
const [isInitialized, setIsInitialized] = useState(false); const [isInitialized, setIsInitialized] = useState(false);
@@ -40,10 +39,7 @@ export function BaseModelsList({
const initializeCurrentModel = async () => { const initializeCurrentModel = async () => {
try { try {
const result = await getCurrentModelAndProvider({ const result = await getCurrentModelAndProvider();
readFromConfig: read,
writeToConfig: upsert,
});
if (isMounted) { if (isMounted) {
// try to look up the model in the modelList // try to look up the model in the modelList
let currentModel: Model; let currentModel: Model;
@@ -72,10 +68,10 @@ export function BaseModelsList({
return () => { return () => {
isMounted = false; isMounted = false;
}; };
}, [read, modelList, upsert]); }, [getCurrentModelAndProvider, modelList]);
const handleModelSelection = async (model: Model) => { const handleModelSelection = async (model: Model) => {
await changeModel({ model: model, writeToConfig: upsert }); await changeModel(model);
}; };
const handleRadioChange = async (model: Model) => { const handleRadioChange = async (model: Model) => {
@@ -104,10 +100,7 @@ export function BaseModelsList({
// If the operation fails, revert to the previous state by simply // If the operation fails, revert to the previous state by simply
// re-calling the getCurrentModelAndProvider function // re-calling the getCurrentModelAndProvider function
try { try {
const result = await getCurrentModelAndProvider({ const result = await getCurrentModelAndProvider();
readFromConfig: read,
writeToConfig: upsert,
});
const currentModel = const currentModel =
modelList.find((m) => m.name === result.model && m.provider === result.provider) || modelList.find((m) => m.name === result.model && m.provider === result.provider) ||

View File

@@ -7,10 +7,9 @@ import { QUICKSTART_GUIDE_URL } from '../../providers/modal/constants';
import { Input } from '../../../ui/input'; import { Input } from '../../../ui/input';
import { Select } from '../../../ui/Select'; import { Select } from '../../../ui/Select';
import { useConfig } from '../../../ConfigContext'; import { useConfig } from '../../../ConfigContext';
import { changeModel } from '../index'; import { useModelAndProvider } from '../../../ModelAndProviderContext';
import type { View } from '../../../../App'; import type { View } from '../../../../App';
import Model, { getProviderMetadata } from '../modelInterface'; import Model, { getProviderMetadata } from '../modelInterface';
import { useModel } from '../../../settings/models/ModelContext';
const ModalButtons = ({ const ModalButtons = ({
onSubmit, onSubmit,
@@ -48,8 +47,8 @@ type AddModelModalProps = {
setView: (view: View) => void; setView: (view: View) => void;
}; };
export const AddModelModal = ({ onClose, setView }: AddModelModalProps) => { export const AddModelModal = ({ onClose, setView }: AddModelModalProps) => {
const { getProviders, upsert } = useConfig(); const { getProviders } = useConfig();
const { switchModel } = useModel(); const { changeModel } = useModelAndProvider();
const [providerOptions, setProviderOptions] = useState<{ value: string; label: string }[]>([]); const [providerOptions, setProviderOptions] = useState<{ value: string; label: string }[]>([]);
const [modelOptions, setModelOptions] = useState< const [modelOptions, setModelOptions] = useState<
{ options: { value: string; label: string; provider: string }[] }[] { options: { value: string; label: string; provider: string }[] }[]
@@ -97,13 +96,7 @@ export const AddModelModal = ({ onClose, setView }: AddModelModalProps) => {
const modelObj = { name: model, provider: provider, subtext: providerDisplayName } as Model; const modelObj = { name: model, provider: provider, subtext: providerDisplayName } as Model;
await changeModel({ await changeModel(modelObj);
model: modelObj,
writeToConfig: upsert,
});
// Update the model context
switchModel(modelObj);
onClose(); onClose();
} }

View File

@@ -1,87 +0,0 @@
import { useModel } from './ModelContext'; // Import the useModel hook
import { Model } from './ModelContext';
import { useMemo } from 'react';
import { gooseModels } from './GooseModels';
import { toastError, toastSuccess } from '../../../toasts';
import { initializeSystem } from '../../../utils/providerUtils';
import { useRecentModels } from './RecentModels';
export function useHandleModelSelection() {
const { switchModel, currentModel } = useModel(); // Access switchModel via useModel
const { addRecentModel } = useRecentModels(); // Access addRecentModel from useRecentModels
return async (model: Model, componentName?: string) => {
try {
// Check if the selected model is already the active model
if (currentModel && currentModel.id === model.id) {
console.log(`[${componentName}] Selected model is already active: ${model.name}`);
return;
}
// Call the context's switchModel to update the model
switchModel(model);
// Keep track of the recently used models
addRecentModel(model);
// switch models via the backend
// initialize agent
await initializeSystem(model.provider.toLowerCase(), model.name);
// Log to the console for tracking
console.log(`[${componentName}] Switched to model: ${model.name} (${model.provider})`);
// Display a success toast notification
toastSuccess({
title: 'Model changed',
msg: `Switched to ${model.alias ?? model.name}`,
});
} catch (error) {
// Handle errors gracefully
console.error(`[${componentName}] Failed to switch model:`, error);
// Display an error toast notification
toastError({
title: model.name,
msg: `Failed to switch to model`,
traceback: error instanceof Error ? error.message : String(error),
});
}
};
}
export function createSelectedModel(selectedProvider: string, modelName: string) {
let selectedModel = gooseModels.find(
(model) =>
model.provider.toLowerCase() === selectedProvider &&
model.name.toLowerCase() === modelName.toLowerCase()
);
if (!selectedModel) {
// Normalize the casing for the provider using the first matching model
const normalizedProvider =
gooseModels.find((model) => model.provider.toLowerCase() === selectedProvider)?.provider ||
selectedProvider;
// Construct a model object
selectedModel = {
name: modelName,
provider: normalizedProvider, // Use normalized provider
};
}
return selectedModel;
}
export function useFilteredModels(search: string, activeKeys: string[]) {
const filteredModels = useMemo(() => {
const modelOptions = gooseModels.filter((model) => activeKeys.includes(model.provider));
if (!search) {
return modelOptions; // Return all models if no search term
}
return modelOptions.filter((model) => model.name.toLowerCase().includes(search.toLowerCase()));
}, [search, activeKeys]);
return filteredModels;
}

View File

@@ -1,281 +0,0 @@
import { Check, Plus, Settings, X, Rocket } from 'lucide-react';
import { Button } from '../../ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../../ui/Tooltip';
import { Portal } from '@radix-ui/react-portal';
import { required_keys } from '../models/hardcoded_stuff';
// Common interfaces and helper functions
interface Provider {
id: string;
name: string;
isConfigured: boolean;
description: string;
}
interface BaseProviderCardProps {
name: string;
description: string;
isConfigured: boolean;
isSelected?: boolean;
isSelectable?: boolean;
onSelect?: () => void;
onAddKeys?: () => void;
onConfigure?: () => void;
showSettings?: boolean;
onDelete?: () => void;
showDelete?: boolean;
hasRequiredKeys?: boolean;
onTakeoff?: () => void;
showTakeoff?: boolean;
}
function getArticle(word: string): string {
return 'aeiouAEIOU'.indexOf(word[0]) >= 0 ? 'an' : 'a';
}
export function getProviderDescription(provider: string) {
const descriptions = {
OpenAI: 'Access GPT-4 and other OpenAI models, including OpenAI compatible ones',
Anthropic: 'Access Claude and other Anthropic models',
Google: 'Access Gemini and other Google AI models',
Groq: 'Access Mixtral and other Groq-hosted models',
Databricks: 'Access models hosted on your Databricks instance',
OpenRouter: 'Access a variety of AI models through OpenRouter',
Ollama: 'Run and use open-source models locally',
};
return descriptions[provider as keyof typeof descriptions] || `Access ${provider} models`;
}
function BaseProviderCard({
name,
description,
isConfigured,
isSelected,
isSelectable,
onSelect,
onAddKeys,
onConfigure,
showSettings,
onDelete,
showDelete = false,
hasRequiredKeys = false,
onTakeoff,
showTakeoff,
}: BaseProviderCardProps) {
const numRequiredKeys = (required_keys as Record<string, string[]>)[name]?.length || 0;
const tooltipText = numRequiredKeys === 1 ? `Add ${name} API Key` : `Add ${name} API Keys`;
return (
<div
className="relative h-full p-[2px] overflow-hidden rounded-[9px] group/card bg-borderSubtle hover:bg-transparent hover:duration-300"
data-testid={`provider-card-${name.toLowerCase()}`}
>
{/* Glowing ring */}
<div
className={`absolute pointer-events-none w-[260px] h-[260px] top-[-50px] left-[-30px] origin-center bg-[linear-gradient(45deg,#13BBAF,#FF4F00)] animate-[rotate_6s_linear_infinite] z-[-1] ${
isSelected ? 'opacity-100' : 'opacity-0 group-hover/card:opacity-100'
}`}
></div>
<div
onClick={() => 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' : ''}
`}
>
<div>
<div className="flex items-center">
<h3 className="text-base font-medium text-textStandard truncate mr-2">{name}</h3>
{/* Configured state: Green check */}
{isConfigured && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center justify-center w-5 h-5 rounded-full bg-green-100 dark:bg-green-900/30 shrink-0">
<Check className="h-3 w-3 text-green-600 dark:text-green-500" />
</div>
</TooltipTrigger>
<Portal>
<TooltipContent side="top" align="center" className="z-[9999]">
<p>
{hasRequiredKeys
? `You have ${getArticle(name)} ${name} API Key set in your environment`
: `${name} is installed and running on your machine`}
</p>
</TooltipContent>
</Portal>
</Tooltip>
</TooltipProvider>
)}
</div>
<p className="text-xs text-textSubtle mt-1.5 mb-3 leading-normal scrollbar-thin overflow-y-auto max-h-[54px] ">
{description}
</p>
</div>
<div className="space-x-2 text-center flex items-center justify-between">
<div className="space-x-2">
{/* Default "Add Keys" Button for other providers */}
{!isConfigured && onAddKeys && hasRequiredKeys && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="default"
size="sm"
onClick={(e) => {
e.stopPropagation();
onAddKeys();
}}
className="rounded-full h-7 w-7 p-0 bg-bgApp hover:bg-bgApp shadow-none text-textSubtle border border-borderSubtle hover:border-borderStandard hover:text-textStandard transition-colors"
>
<Plus className="!size-4" />
</Button>
</TooltipTrigger>
<Portal>
<TooltipContent side="top" align="center" className="z-[9999]">
<p>{tooltipText}</p>
</TooltipContent>
</Portal>
</Tooltip>
</TooltipProvider>
)}
{isConfigured && showSettings && hasRequiredKeys && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="rounded-full h-7 w-7 p-0 bg-bgApp hover:bg-bgApp shadow-none text-textSubtle border border-borderSubtle hover:border-borderStandard hover:text-textStandard transition-colors"
onClick={(e) => {
e.stopPropagation();
onConfigure?.();
}}
>
<Settings className="!size-4" />
</Button>
</TooltipTrigger>
<Portal>
<TooltipContent side="top" align="center" className="z-[9999]">
<p>Configure {name} settings</p>
</TooltipContent>
</Portal>
</Tooltip>
</TooltipProvider>
)}
{showDelete && hasRequiredKeys && isConfigured && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="rounded-full h-7 w-7 p-0 bg-bgApp hover:bg-bgApp shadow-none text-textSubtle border border-borderSubtle hover:border-borderStandard hover:text-textStandard transition-colors"
onClick={(e) => {
e.stopPropagation();
onDelete?.();
}}
>
<X className="!size-4" />
</Button>
</TooltipTrigger>
<Portal>
<TooltipContent side="top" align="center" className="z-[9999]">
<p>Remove {name} API Key or Host</p>
</TooltipContent>
</Portal>
</Tooltip>
</TooltipProvider>
)}
</div>
{isConfigured && onTakeoff && showTakeoff !== false && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
data-testid="provider-launch-button"
variant="default"
size="sm"
onClick={(e) => {
e.stopPropagation();
onTakeoff();
}}
className="rounded-full h-7 px-3 bg-bgApp hover:bg-bgApp shadow-none text-textSubtle border border-borderSubtle hover:border-transparent hover:bg-[#FF9772] hover:text-textProminent transition-colors"
>
<Rocket className="!size-4" />
Launch
</Button>
</TooltipTrigger>
<Portal>
<TooltipContent side="top" align="center" className="z-[9999]">
<p>Launch goose with {name}</p>
</TooltipContent>
</Portal>
</Tooltip>
</TooltipProvider>
)}
</div>
</div>
</div>
);
}
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 (
<div className="grid grid-cols-[repeat(auto-fill,_minmax(140px,_1fr))] gap-3 [&_*]:z-20">
{providers.map((provider) => {
const hasRequiredKeys =
(required_keys as Record<string, string[]>)[provider.name]?.length > 0;
return (
<BaseProviderCard
key={provider.id}
name={provider.name}
description={provider.description}
isConfigured={provider.isConfigured}
isSelected={selectedId === provider.id}
isSelectable={isSelectable}
onSelect={() => onSelect?.(provider.id)}
onAddKeys={() => onAddKeys?.(provider)}
onConfigure={() => onConfigure?.(provider)}
onDelete={() => onDelete?.(provider)}
onTakeoff={() => onTakeoff?.(provider)}
showSettings={showSettings}
showDelete={showDelete}
hasRequiredKeys={hasRequiredKeys}
showTakeoff={showTakeoff}
/>
);
})}
</div>
);
}

View File

@@ -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 (
<div className="fixed inset-0 bg-black/20 backdrop-blur-sm z-[9999]">
<div className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[400px] bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700">
<div className="p-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
Confirm Delete
</h2>
<p className="text-sm text-gray-600 dark:text-gray-300 mb-6">{message}</p>
<div className="flex justify-end gap-3">
<Button variant="outline" onClick={onCancel} className="rounded-full px-4">
Cancel
</Button>
<Button
variant="destructive"
onClick={onConfirm}
className="rounded-full px-4 bg-red-600 hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-700"
>
Delete
</Button>
</div>
</div>
</div>
</div>
);
}
// Settings version - non-selectable cards with settings gear
export function ConfigureProvidersGrid() {
const { activeKeys, setActiveKeys } = useActiveKeys();
const [showSetupModal, setShowSetupModal] = useState(false);
const [selectedForSetup, setSelectedForSetup] = useState<string | null>(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<string, string[]>)[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 (
<div className="space-y-4 max-w-[1400px] mx-auto">
<BaseProviderGrid
providers={providers}
showSettings={true}
showDelete={true}
onAddKeys={handleAddKeys}
onConfigure={handleConfigure}
onDelete={handleDelete}
showTakeoff={false}
/>
{showSetupModal && selectedForSetup && (
<div className="relative z-[9999]">
<ProviderSetupModal
provider={providers.find((p) => 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'}
/>
</div>
)}
{isConfirmationOpen && providerToDelete && (
<ConfirmationModal
message={`Are you sure you want to delete the configuration for ${providerToDelete.name}? This action cannot be undone.`}
onConfirm={confirmDelete}
onCancel={() => setIsConfirmationOpen(false)}
/>
)}
</div>
);
}

View File

@@ -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 (
<div className="h-screen w-full">
<MoreMenuLayout showMenu={false} />
<ScrollArea className="h-full w-full">
<div className="px-8 pt-6 pb-4">
<BackButton onClick={onClose} />
<h1 className="text-3xl font-medium text-textStandard mt-1">Configure</h1>
</div>
<div className=" py-8 pt-[20px]">
<div className="flex justify-between items-center mb-6 border-b border-borderSubtle px-8">
<h2 className="text-xl font-medium text-textStandard">Providers</h2>
</div>
{/* Content Area */}
<div className="max-w-5xl pt-4 px-8">
<div className="relative z-10">
<ConfigureProvidersGrid />
</div>
</div>
</div>
</ScrollArea>
</div>
);
}

View File

@@ -10,8 +10,8 @@ import { DefaultSubmitHandler } from './subcomponents/handlers/DefaultSubmitHand
import OllamaSubmitHandler from './subcomponents/handlers/OllamaSubmitHandler'; import OllamaSubmitHandler from './subcomponents/handlers/OllamaSubmitHandler';
import OllamaForm from './subcomponents/forms/OllamaForm'; import OllamaForm from './subcomponents/forms/OllamaForm';
import { useConfig } from '../../../ConfigContext'; import { useConfig } from '../../../ConfigContext';
import { useModelAndProvider } from '../../../ModelAndProviderContext';
import { AlertTriangle } from 'lucide-react'; import { AlertTriangle } from 'lucide-react';
import { getCurrentModelAndProvider } from '../../models'; // Import the utility
interface FormValues { interface FormValues {
[key: string]: string | number | boolean | null; [key: string]: string | number | boolean | null;
@@ -27,7 +27,8 @@ const customFormsMap: Record<string, unknown> = {
export default function ProviderConfigurationModal() { export default function ProviderConfigurationModal() {
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({}); const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
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 { isOpen, currentProvider, modalProps, closeModal } = useProviderModal();
const [configValues, setConfigValues] = useState<Record<string, string>>({}); const [configValues, setConfigValues] = useState<Record<string, string>>({});
const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false); const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false);
@@ -126,10 +127,7 @@ export default function ProviderConfigurationModal() {
const handleDelete = async () => { const handleDelete = async () => {
// Check if this is the currently active provider // Check if this is the currently active provider
try { try {
const providerModel = await getCurrentModelAndProvider({ const providerModel = await getCurrentModelAndProvider();
readFromConfig: read,
writeToConfig: upsert,
});
if (currentProvider.name === providerModel.provider) { if (currentProvider.name === providerModel.provider) {
// It's the active provider - set state and show warning // It's the active provider - set state and show warning
setIsActiveProvider(true); setIsActiveProvider(true);

Some files were not shown because too many files have changed in this diff Show More