alexhancock/remove-settings-v1 (#2744)
Co-authored-by: Zane Staggs <zane@squareup.com>
@@ -14,22 +14,20 @@ import { type Recipe } from './recipe';
|
||||
|
||||
import ChatView from './components/ChatView';
|
||||
import SuspenseLoader from './suspense-loader';
|
||||
import { type 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 SettingsView, { SettingsViewOptions } from './components/settings/SettingsView';
|
||||
import SessionsView from './components/sessions/SessionsView';
|
||||
import SharedSessionView from './components/sessions/SharedSessionView';
|
||||
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 { useChat } from './hooks/useChat';
|
||||
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
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 PermissionSettingsView from './components/settings_v2/permission/PermissionSetting';
|
||||
import PermissionSettingsView from './components/settings/permission/PermissionSetting';
|
||||
|
||||
import { type SessionDetails } from './sessions';
|
||||
|
||||
@@ -462,7 +460,7 @@ export default function App() {
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModelAndProviderProvider>
|
||||
<ToastContainer
|
||||
aria-label="Toast notifications"
|
||||
toastClassName={() =>
|
||||
@@ -496,7 +494,7 @@ export default function App() {
|
||||
<ProviderSettings onClose={() => setView('chat')} isOnboarding={true} />
|
||||
)}
|
||||
{view === 'settings' && (
|
||||
<SettingsViewV2
|
||||
<SettingsView
|
||||
onClose={() => {
|
||||
setView('chat');
|
||||
}}
|
||||
@@ -504,21 +502,6 @@ export default function App() {
|
||||
viewOptions={viewOptions as SettingsViewOptions}
|
||||
/>
|
||||
)}
|
||||
{view === 'moreModels' && (
|
||||
<MoreModelsView
|
||||
onClose={() => {
|
||||
setView('settings');
|
||||
}}
|
||||
setView={setView}
|
||||
/>
|
||||
)}
|
||||
{view === 'configureProviders' && (
|
||||
<ConfigureProvidersView
|
||||
onClose={() => {
|
||||
setView('settings');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{view === 'ConfigureProviders' && (
|
||||
<ProviderSettings onClose={() => setView('chat')} isOnboarding={false} />
|
||||
)}
|
||||
@@ -579,6 +562,6 @@ export default function App() {
|
||||
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</ModelAndProviderProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import type {
|
||||
ExtensionQuery,
|
||||
ExtensionConfig,
|
||||
} from '../api/types.gen';
|
||||
import { removeShims } from './settings_v2/extensions/utils';
|
||||
import { removeShims } from './settings/extensions/utils';
|
||||
|
||||
export type { ExtensionConfig } from '../api/types.gen';
|
||||
|
||||
|
||||
190
ui/desktop/src/components/ModelAndProviderContext.tsx
Normal 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;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import RecipeActivityEditor from './RecipeActivityEditor';
|
||||
import RecipeInfoModal from './RecipeInfoModal';
|
||||
import RecipeExpandableInfo from './RecipeExpandableInfo';
|
||||
import { ScheduleFromRecipeModal } from './schedule/ScheduleFromRecipeModal';
|
||||
// import ExtensionList from './settings_v2/extensions/subcomponents/ExtensionList';
|
||||
|
||||
interface RecipeEditorProps {
|
||||
config?: Recipe;
|
||||
@@ -365,7 +364,14 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
|
||||
recipe={getCurrentConfig()}
|
||||
onCreateSchedule={(deepLink) => {
|
||||
// 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
|
||||
localStorage.setItem('pendingScheduleDeepLink', deepLink);
|
||||
}}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { snakeToTitleCase } from '../utils';
|
||||
import PermissionModal from './settings_v2/permission/PermissionModal';
|
||||
import PermissionModal from './settings/permission/PermissionModal';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { confirmPermission } from '../api';
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useModel } from '../settings/models/ModelContext';
|
||||
import { AlertType, useAlerts } from '../alerts';
|
||||
import { useToolCount } from '../alerts/useToolCount';
|
||||
import BottomMenuAlertPopover from './BottomMenuAlertPopover';
|
||||
import type { View, ViewOptions } from '../../App';
|
||||
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 { getCurrentModelAndProvider } from '../settings_v2/models';
|
||||
import { useModelAndProvider } from '../ModelAndProviderContext';
|
||||
import { Message } from '../../types/message';
|
||||
import { ManualSummarizeButton } from '../context_management/ManualSummaryButton';
|
||||
|
||||
@@ -34,11 +33,11 @@ export default function BottomMenu({
|
||||
setMessages: (messages: Message[]) => void;
|
||||
}) {
|
||||
const [isModelMenuOpen, setIsModelMenuOpen] = useState(false);
|
||||
const { currentModel } = useModel();
|
||||
const { alerts, addAlert, clearAlerts } = useAlerts();
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const toolCount = useToolCount();
|
||||
const { getProviders, read } = useConfig();
|
||||
const { getCurrentModelAndProvider, currentModel, currentProvider } = useModelAndProvider();
|
||||
const [tokenLimit, setTokenLimit] = useState<number>(TOKEN_LIMIT_DEFAULT);
|
||||
const [isTokenLimitLoaded, setIsTokenLimitLoaded] = useState(false);
|
||||
|
||||
@@ -72,7 +71,7 @@ export default function BottomMenu({
|
||||
setIsTokenLimitLoaded(false);
|
||||
|
||||
// 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) {
|
||||
console.log('No model or provider found');
|
||||
setIsTokenLimitLoaded(true);
|
||||
@@ -117,7 +116,7 @@ export default function BottomMenu({
|
||||
useEffect(() => {
|
||||
loadProviderDetails();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentModel]);
|
||||
}, [currentModel, currentProvider]);
|
||||
|
||||
// Handle tool count alerts and token usage
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { View, ViewOptions } from '../../App';
|
||||
import { Orbit } from 'lucide-react';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +1,19 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { IpcRendererEvent } from 'electron';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
import { Settings as SettingsType } from './types';
|
||||
import {
|
||||
FullExtensionConfig,
|
||||
addExtension,
|
||||
removeExtension,
|
||||
BUILT_IN_EXTENSIONS,
|
||||
} from '../../extensions';
|
||||
import { ConfigureExtensionModal } from './extensions/ConfigureExtensionModal';
|
||||
import { ManualExtensionModal } from './extensions/ManualExtensionModal';
|
||||
import { ConfigureBuiltInExtensionModal } from './extensions/ConfigureBuiltInExtensionModal';
|
||||
import BackButton from '../ui/BackButton';
|
||||
import { RecentModelsRadio } from './models/RecentModels';
|
||||
import { ExtensionItem } from './extensions/ExtensionItem';
|
||||
import { View, ViewOptions } from '../../App';
|
||||
import { ModeSelection } from './basic/ModeSelection';
|
||||
import SessionSharingSection from './session/SessionSharingSection';
|
||||
import { toastSuccess } from '../../toasts';
|
||||
import type { View, ViewOptions } from '../../App';
|
||||
import ExtensionsSection from './extensions/ExtensionsSection';
|
||||
import ModelsSection from './models/ModelsSection';
|
||||
import { ModeSection } from './mode/ModeSection';
|
||||
import { ToolSelectionStrategySection } from './tool_selection_strategy/ToolSelectionStrategySection';
|
||||
import SessionSharingSection from './sessions/SessionSharingSection';
|
||||
import { ResponseStylesSection } from './response_styles/ResponseStylesSection';
|
||||
import AppSettingsSection from './app/AppSettingsSection';
|
||||
import { ExtensionConfig } from '../../api';
|
||||
import MoreMenuLayout from '../more_menu/MoreMenuLayout';
|
||||
|
||||
const EXTENSIONS_DESCRIPTION =
|
||||
'The Model Context Protocol (MCP) is a system that allows AI models to securely connect with local or remote resources using standard server setups. It works like a client-server setup and expands AI capabilities using three main components: Prompts, Resources, and Tools.';
|
||||
|
||||
const EXTENSIONS_SITE_LINK = 'https://block.github.io/goose/v1/extensions/';
|
||||
|
||||
const DEFAULT_SETTINGS: SettingsType = {
|
||||
models: [
|
||||
{
|
||||
id: 'gpt4',
|
||||
name: 'GPT 4.0',
|
||||
description: 'Standard config',
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
id: 'gpt4lite',
|
||||
name: 'GPT 4.0 lite',
|
||||
description: 'Standard config',
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
id: 'claude',
|
||||
name: 'Claude',
|
||||
description: 'Standard config',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
extensions: BUILT_IN_EXTENSIONS,
|
||||
};
|
||||
|
||||
export type SettingsViewOptions = {
|
||||
extensionId: string;
|
||||
showEnvVars: boolean;
|
||||
deepLinkConfig?: ExtensionConfig;
|
||||
showEnvVars?: boolean;
|
||||
};
|
||||
|
||||
export default function SettingsView({
|
||||
@@ -63,133 +25,8 @@ export default function SettingsView({
|
||||
setView: (view: View, viewOptions?: ViewOptions) => void;
|
||||
viewOptions: SettingsViewOptions;
|
||||
}) {
|
||||
const [settings, setSettings] = React.useState<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 (
|
||||
<div className="h-screen w-full">
|
||||
<div className="h-screen w-full animate-[fadein_200ms_ease-in_forwards]">
|
||||
<MoreMenuLayout showMenu={false} />
|
||||
|
||||
<ScrollArea className="h-full w-full">
|
||||
@@ -200,114 +37,29 @@ export default function SettingsView({
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="flex-1 py-8 pt-[20px]">
|
||||
<div className="flex-1 pt-[20px]">
|
||||
<div className="space-y-8">
|
||||
{/*Models Section*/}
|
||||
<section id="models">
|
||||
<RecentModelsRadio setView={setView} />
|
||||
</section>
|
||||
<section id="extensions">
|
||||
<div className="flex justify-between items-center mb-6 border-b border-borderSubtle px-8">
|
||||
<h2 className="text-xl font-semibold text-textStandard">Extensions</h2>
|
||||
<a
|
||||
href={EXTENSIONS_SITE_LINK}
|
||||
target="_blank"
|
||||
className="text-indigo-500 hover:text-indigo-600 text-sm"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Browse
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<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)}
|
||||
{/* Models Section */}
|
||||
<ModelsSection setView={setView} />
|
||||
{/* Extensions Section */}
|
||||
<ExtensionsSection
|
||||
deepLinkConfig={viewOptions.deepLinkConfig}
|
||||
showEnvVars={viewOptions.showEnvVars}
|
||||
/>
|
||||
))}
|
||||
<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">
|
||||
{/* Goose Modes */}
|
||||
<ModeSection setView={setView} />
|
||||
{/*Session sharing*/}
|
||||
<SessionSharingSection />
|
||||
</section>
|
||||
{/* Response Styles */}
|
||||
<ResponseStylesSection />
|
||||
{/* Tool Selection Strategy */}
|
||||
<ToolSelectionStrategySection setView={setView} />
|
||||
{/* App Settings */}
|
||||
<AppSettingsSection />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -184,3 +184,18 @@ export function removeShims(cmd: string) {
|
||||
// If it's not a shim, return the original command
|
||||
return cmd;
|
||||
}
|
||||
|
||||
export function extractCommand(link: string): string {
|
||||
const url = new URL(link);
|
||||
const cmd = url.searchParams.get('cmd') || 'Unknown Command';
|
||||
const args = url.searchParams.getAll('arg').map(decodeURIComponent);
|
||||
|
||||
// Combine the command and its arguments into a reviewable format
|
||||
return `${cmd} ${args.join(' ')}`.trim();
|
||||
}
|
||||
|
||||
export function extractExtensionName(link: string): string {
|
||||
const url = new URL(link);
|
||||
const name = url.searchParams.get('name');
|
||||
return name ? decodeURIComponent(name) : 'Unknown Extension';
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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' },
|
||||
];
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Sliders } from 'lucide-react';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { useConfig } from '../../../ConfigContext';
|
||||
import { getCurrentModelAndProviderForDisplay } from '../index';
|
||||
import { useModelAndProvider } from '../../../ModelAndProviderContext';
|
||||
import { AddModelModal } from '../subcomponents/AddModelModal';
|
||||
import { View } from '../../../../App';
|
||||
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '../../../ui/Tooltip';
|
||||
@@ -11,10 +10,10 @@ interface ModelsBottomBarProps {
|
||||
setView: (view: View) => void;
|
||||
}
|
||||
export default function ModelsBottomBar({ dropdownRef, setView }: ModelsBottomBarProps) {
|
||||
const { read, getProviders } = useConfig();
|
||||
const { currentModel, currentProvider, getCurrentModelAndProviderForDisplay } =
|
||||
useModelAndProvider();
|
||||
const [isModelMenuOpen, setIsModelMenuOpen] = useState(false);
|
||||
const [provider, setProvider] = useState<string | null>(null);
|
||||
const [model, setModel] = useState<string>('');
|
||||
const [displayProvider, setDisplayProvider] = useState<string | null>(null);
|
||||
const [isAddModelModalOpen, setIsAddModelModalOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [isModelTruncated, setIsModelTruncated] = useState(false);
|
||||
@@ -22,16 +21,15 @@ export default function ModelsBottomBar({ dropdownRef, setView }: ModelsBottomBa
|
||||
const modelRef = useRef<HTMLSpanElement>(null);
|
||||
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
|
||||
|
||||
// Update display provider when current provider changes
|
||||
useEffect(() => {
|
||||
if (currentProvider) {
|
||||
(async () => {
|
||||
const modelProvider = await getCurrentModelAndProviderForDisplay({
|
||||
readFromConfig: read,
|
||||
getProviders,
|
||||
});
|
||||
setProvider(modelProvider.provider as string | null);
|
||||
setModel(modelProvider.model as string);
|
||||
const modelProvider = await getCurrentModelAndProviderForDisplay();
|
||||
setDisplayProvider(modelProvider.provider);
|
||||
})();
|
||||
});
|
||||
}
|
||||
}, [currentProvider, getCurrentModelAndProviderForDisplay]);
|
||||
|
||||
useEffect(() => {
|
||||
const checkTruncation = () => {
|
||||
@@ -42,7 +40,7 @@ export default function ModelsBottomBar({ dropdownRef, setView }: ModelsBottomBa
|
||||
checkTruncation();
|
||||
window.addEventListener('resize', checkTruncation);
|
||||
return () => window.removeEventListener('resize', checkTruncation);
|
||||
}, [model]);
|
||||
}, [currentModel]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsTooltipOpen(false);
|
||||
@@ -81,12 +79,12 @@ export default function ModelsBottomBar({ dropdownRef, setView }: ModelsBottomBa
|
||||
ref={modelRef}
|
||||
className="truncate max-w-[130px] md:max-w-[200px] lg:max-w-[360px] min-w-0 block"
|
||||
>
|
||||
{model || 'Select Model'}
|
||||
{currentModel || 'Select Model'}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{isModelTruncated && (
|
||||
<TooltipContent className="max-w-96 overflow-auto scrollbar-thin" side="top">
|
||||
{model || 'Select Model'}
|
||||
{currentModel || 'Select Model'}
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
@@ -99,7 +97,7 @@ export default function ModelsBottomBar({ dropdownRef, setView }: ModelsBottomBa
|
||||
<div className="">
|
||||
<div className="text-sm text-textProminent mt-2 ml-2">Current:</div>
|
||||
<div className="flex items-center justify-between text-sm ml-2">
|
||||
{model} -- {provider}
|
||||
{currentModel} -- {displayProvider}
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center justify-between text-textStandard p-2 cursor-pointer transition-colors hover:bg-bgStandard
|
||||
@@ -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' },
|
||||
];
|
||||
@@ -1,8 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Model from '../modelInterface';
|
||||
import { useRecentModels } from './recentModels';
|
||||
import { changeModel, getCurrentModelAndProvider } from '../index';
|
||||
import { useConfig } from '../../../ConfigContext';
|
||||
import { useModelAndProvider } from '../../../ModelAndProviderContext';
|
||||
import { toastInfo } from '../../../../toasts';
|
||||
|
||||
interface ModelRadioListProps {
|
||||
@@ -30,7 +29,7 @@ export function BaseModelsList({
|
||||
} else {
|
||||
modelList = providedModelList;
|
||||
}
|
||||
const { read, upsert } = useConfig();
|
||||
const { changeModel, getCurrentModelAndProvider } = useModelAndProvider();
|
||||
const [selectedModel, setSelectedModel] = useState<Model | null>(null);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
@@ -40,10 +39,7 @@ export function BaseModelsList({
|
||||
|
||||
const initializeCurrentModel = async () => {
|
||||
try {
|
||||
const result = await getCurrentModelAndProvider({
|
||||
readFromConfig: read,
|
||||
writeToConfig: upsert,
|
||||
});
|
||||
const result = await getCurrentModelAndProvider();
|
||||
if (isMounted) {
|
||||
// try to look up the model in the modelList
|
||||
let currentModel: Model;
|
||||
@@ -72,10 +68,10 @@ export function BaseModelsList({
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [read, modelList, upsert]);
|
||||
}, [getCurrentModelAndProvider, modelList]);
|
||||
|
||||
const handleModelSelection = async (model: Model) => {
|
||||
await changeModel({ model: model, writeToConfig: upsert });
|
||||
await changeModel(model);
|
||||
};
|
||||
|
||||
const handleRadioChange = async (model: Model) => {
|
||||
@@ -104,10 +100,7 @@ export function BaseModelsList({
|
||||
// If the operation fails, revert to the previous state by simply
|
||||
// re-calling the getCurrentModelAndProvider function
|
||||
try {
|
||||
const result = await getCurrentModelAndProvider({
|
||||
readFromConfig: read,
|
||||
writeToConfig: upsert,
|
||||
});
|
||||
const result = await getCurrentModelAndProvider();
|
||||
|
||||
const currentModel =
|
||||
modelList.find((m) => m.name === result.model && m.provider === result.provider) ||
|
||||
@@ -7,10 +7,9 @@ import { QUICKSTART_GUIDE_URL } from '../../providers/modal/constants';
|
||||
import { Input } from '../../../ui/input';
|
||||
import { Select } from '../../../ui/Select';
|
||||
import { useConfig } from '../../../ConfigContext';
|
||||
import { changeModel } from '../index';
|
||||
import { useModelAndProvider } from '../../../ModelAndProviderContext';
|
||||
import type { View } from '../../../../App';
|
||||
import Model, { getProviderMetadata } from '../modelInterface';
|
||||
import { useModel } from '../../../settings/models/ModelContext';
|
||||
|
||||
const ModalButtons = ({
|
||||
onSubmit,
|
||||
@@ -48,8 +47,8 @@ type AddModelModalProps = {
|
||||
setView: (view: View) => void;
|
||||
};
|
||||
export const AddModelModal = ({ onClose, setView }: AddModelModalProps) => {
|
||||
const { getProviders, upsert } = useConfig();
|
||||
const { switchModel } = useModel();
|
||||
const { getProviders } = useConfig();
|
||||
const { changeModel } = useModelAndProvider();
|
||||
const [providerOptions, setProviderOptions] = useState<{ value: string; label: string }[]>([]);
|
||||
const [modelOptions, setModelOptions] = useState<
|
||||
{ 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;
|
||||
|
||||
await changeModel({
|
||||
model: modelObj,
|
||||
writeToConfig: upsert,
|
||||
});
|
||||
|
||||
// Update the model context
|
||||
switchModel(modelObj);
|
||||
await changeModel(modelObj);
|
||||
|
||||
onClose();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -10,8 +10,8 @@ import { DefaultSubmitHandler } from './subcomponents/handlers/DefaultSubmitHand
|
||||
import OllamaSubmitHandler from './subcomponents/handlers/OllamaSubmitHandler';
|
||||
import OllamaForm from './subcomponents/forms/OllamaForm';
|
||||
import { useConfig } from '../../../ConfigContext';
|
||||
import { useModelAndProvider } from '../../../ModelAndProviderContext';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { getCurrentModelAndProvider } from '../../models'; // Import the utility
|
||||
|
||||
interface FormValues {
|
||||
[key: string]: string | number | boolean | null;
|
||||
@@ -27,7 +27,8 @@ const customFormsMap: Record<string, unknown> = {
|
||||
|
||||
export default function ProviderConfigurationModal() {
|
||||
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 [configValues, setConfigValues] = useState<Record<string, string>>({});
|
||||
const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false);
|
||||
@@ -126,10 +127,7 @@ export default function ProviderConfigurationModal() {
|
||||
const handleDelete = async () => {
|
||||
// Check if this is the currently active provider
|
||||
try {
|
||||
const providerModel = await getCurrentModelAndProvider({
|
||||
readFromConfig: read,
|
||||
writeToConfig: upsert,
|
||||
});
|
||||
const providerModel = await getCurrentModelAndProvider();
|
||||
if (currentProvider.name === providerModel.provider) {
|
||||
// It's the active provider - set state and show warning
|
||||
setIsActiveProvider(true);
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 951 B After Width: | Height: | Size: 951 B |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |