feat: [alpha] Providers grid refactor (#1345)

Co-authored-by: Yingjie He <yingjiehe@squareup.com>
This commit is contained in:
lily-de
2025-02-24 13:03:13 -05:00
committed by GitHub
parent d786ecac61
commit c0c9fcccd5
45 changed files with 1445 additions and 21 deletions
+6
View File
@@ -63,6 +63,12 @@ run-ui:
@echo "Running UI..."
cd ui/desktop && npm install && npm run start-gui
# Run UI with alpha changes
run-ui-alpha:
@just release-binary
@echo "Running UI..."
cd ui/desktop && npm install && ALPHA=true npm run start-alpha-gui
# Run UI with latest (Windows version)
run-ui-windows:
@just release-windows
+40
View File
@@ -2358,6 +2358,18 @@
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/source-map": {
"version": "0.3.6",
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz",
"integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.25"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
@@ -15614,6 +15626,34 @@
"rimraf": "bin.js"
}
},
"node_modules/terser": {
"version": "5.39.0",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.39.0.tgz",
"integrity": "sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.8.2",
"commander": "^2.20.0",
"source-map-support": "~0.5.20"
},
"bin": {
"terser": "bin/terser"
},
"engines": {
"node": ">=10"
}
},
"node_modules/terser/node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"dev": true,
"optional": true,
"peer": true
},
"node_modules/text-table": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
+2 -1
View File
@@ -19,7 +19,8 @@
"lint:check": "eslint \"src/**/*.{ts,tsx}\"",
"format": "prettier --write \"src/**/*.{ts,tsx,css,json}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,css,json}\"",
"prepare": "cd ../.. && husky install"
"prepare": "cd ../.. && husky install",
"start-alpha-gui": "ALPHA=true npm run start-gui"
},
"devDependencies": {
"@electron-forge/cli": "^7.5.0",
+6 -1
View File
@@ -16,6 +16,7 @@ import ChatView from './components/ChatView';
import SettingsView, { type SettingsViewOptions } from './components/settings/SettingsView';
import MoreModelsView from './components/settings/models/MoreModelsView';
import ConfigureProvidersView from './components/settings/providers/ConfigureProvidersView';
import ProviderSettings from './components/settings/providers/providers/NewProviderSettingsPage';
import 'react-toastify/dist/ReactToastify.css';
@@ -26,7 +27,8 @@ export type View =
| 'settings'
| 'moreModels'
| 'configureProviders'
| 'configPage';
| 'configPage'
| 'alphaConfigureProviders';
export type ViewConfig = {
view: View;
@@ -224,6 +226,9 @@ export default function App() {
}}
/>
)}
{view === 'alphaConfigureProviders' && (
<ProviderSettings onClose={() => setView('chat')} />
)}
{view === 'chat' && <ChatView setView={setView} />}
</div>
</div>
+12 -2
View File
@@ -1,8 +1,7 @@
import { Popover, PopoverContent, PopoverTrigger, PopoverPortal } from '@radix-ui/react-popover';
import React, { useEffect, useState } from 'react';
import { More } from './icons';
import type { View } from '../ChatWindow';
import { View } from '../App';
interface VersionInfo {
current_version: string;
available_versions: string[];
@@ -260,6 +259,17 @@ export default function MoreMenu({ setView }: { setView: (view: View) => void })
>
Reset Provider
</button>
{process.env.ALPHA && (
<button
onClick={() => {
setOpen(false);
setView('alphaConfigureProviders');
}}
className="w-full text-left p-2 text-sm hover:bg-bgSubtle transition-colors text-indigo-800"
>
See new providers grid
</button>
)}
</div>
</PopoverContent>
</PopoverPortal>
@@ -24,22 +24,22 @@ export async function getActiveProviders(): Promise<string[]> {
const configSettings = await getConfigSettings();
const activeProviders = Object.values(configSettings)
.filter((provider) => {
// 1. Get provider's config_status
const configStatus = provider.config_status ?? {};
.filter((provider) => {
// 1. Get provider's config_status
const configStatus = provider.config_status ?? {};
// 2. Collect only the keys *not* in default_key_value
const requiredKeyEntries = Object.entries(configStatus).filter(([k]) => isRequiredKey(k));
// 2. Collect only the keys *not* in default_key_value
const requiredKeyEntries = Object.entries(configStatus).filter(([k]) => isRequiredKey(k));
// 3. If there are *no* non-default keys, it is NOT active
if (requiredKeyEntries.length === 0) {
return false;
}
// 3. If there are *no* non-default keys, it is NOT active
if (requiredKeyEntries.length === 0) {
return false;
}
// 4. Otherwise, all non-default keys must be `is_set`
return requiredKeyEntries.every(([_, value]) => value?.is_set);
})
.map((provider) => provider.name || 'Unknown Provider');
// 4. Otherwise, all non-default keys must be `is_set`
return requiredKeyEntries.every(([_, value]) => value?.is_set);
})
.map((provider) => provider.name || 'Unknown Provider');
console.log('[GET ACTIVE PROVIDERS]:', activeProviders);
return activeProviders;
@@ -93,4 +93,4 @@ export async function getProvidersList(): Promise<Provider[]> {
models: item.details?.models || [], // Nested models array
requiredKeys: item.details?.required_keys || [], // Nested required keys array
}));
}
}
@@ -235,8 +235,8 @@ export function ConfigureProvidersGrid() {
<div className="relative z-[9999]">
<ProviderSetupModal
provider={providers.find((p) => p.id === selectedForSetup)?.name || ''}
model="Example Model"
endpoint="Example Endpoint"
_model="Example Model"
_endpoint="Example Endpoint"
title={
modalMode === 'edit'
? `Edit ${providers.find((p) => p.id === selectedForSetup)?.name} Configuration`
@@ -0,0 +1,78 @@
import React from 'react';
import { ScrollArea } from '../../../ui/scroll-area';
import BackButton from '../../../ui/BackButton';
import ProviderGrid from './ProviderGrid';
import ProviderState from './interfaces/ProviderState';
const fakeProviderState: ProviderState[] = [
{
id: 'openai',
name: 'OpenAI',
isConfigured: true,
metadata: null,
},
{
id: 'anthropic',
name: 'Anthropic',
isConfigured: false,
metadata: null,
},
{
id: 'groq',
name: 'Groq',
isConfigured: false,
metadata: null,
},
{
id: 'google',
name: 'Google',
isConfigured: false,
metadata: null,
},
{
id: 'openrouter',
name: 'OpenRouter',
isConfigured: false,
metadata: null,
},
{
id: 'databricks',
name: 'Databricks',
isConfigured: false,
metadata: null,
},
{
id: 'ollama',
name: 'Ollama',
isConfigured: false,
metadata: { location: null },
},
];
export default function ProviderSettings({ onClose }: { onClose: () => void }) {
return (
<div className="h-screen w-full">
<div className="relative flex items-center h-[36px] w-full bg-bgSubtle"></div>
<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">
<ProviderGrid providers={fakeProviderState} />
</div>
</div>
</div>
</ScrollArea>
</div>
);
}
@@ -0,0 +1,47 @@
import React from 'react';
import { ProviderCard } from './subcomponents/ProviderCard';
import ProviderState from './interfaces/ProviderState';
import OnShowModal from './callbacks/ShowModal';
import OnAdd from './callbacks/AddProviderParameters';
import OnDelete from './callbacks/DeleteProviderParameters';
import OnShowSettings from './callbacks/UpdateProviderParameters';
import OnRefresh from './callbacks/RefreshActiveProviders';
import DefaultProviderActions from './subcomponents/actions/DefaultProviderActions';
function GridLayout({ children }: { children: React.ReactNode }) {
return (
<div className="grid grid-cols-3 sm:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 gap-3 auto-rows-fr max-w-full [&_*]:z-20">
{children}
</div>
);
}
function ProviderCards({ providers }: { providers: ProviderState[] }) {
const providerCallbacks = {
onShowModal: OnShowModal,
onAdd: OnAdd,
onDelete: OnDelete,
onShowSettings: OnShowSettings,
onRefresh: OnRefresh,
};
return (
<>
{providers.map((provider) => (
<ProviderCard
key={provider.name} // helps React efficiently update and track components when rendering lists
provider={provider}
providerCallbacks={providerCallbacks}
/>
))}
</>
);
}
export default function ProviderGrid({ providers }: { providers: ProviderState[] }) {
console.log('got these providers', providers);
return (
<GridLayout>
<ProviderCards providers={providers} />
</GridLayout>
);
}
@@ -0,0 +1,242 @@
import React from 'react';
import ProviderDetails from './interfaces/ProviderDetails';
import DefaultProviderActions from './subcomponents/actions/DefaultProviderActions';
import OllamaActions from './subcomponents/actions/OllamaActions';
export interface ProviderRegistry {
name: string;
details: ProviderDetails;
}
export const PROVIDER_REGISTRY: ProviderRegistry[] = [
{
name: 'OpenAI',
details: {
id: 'openai',
name: 'OpenAI',
description: 'Access GPT-4, GPT-3.5 Turbo, and other OpenAI models',
parameters: [
{
name: 'OPENAI_API_KEY',
is_secret: true,
},
],
getActions: (provider, callbacks) => {
const { onAdd, onDelete, onShowSettings } = callbacks || {};
return [
{
id: 'default-provider-actions',
renderButton: () => (
<DefaultProviderActions
name={provider.name}
isConfigured={provider.isConfigured}
onAdd={onAdd}
onDelete={onDelete}
onShowSettings={onShowSettings}
/>
),
},
];
},
},
},
{
name: 'Anthropic',
details: {
id: 'anthropic',
name: 'Anthropic',
description: 'Access Claude and other Anthropic models',
parameters: [
{
name: 'ANTHROPIC_API_KEY',
is_secret: true,
},
],
getActions: (provider, callbacks) => {
const { onAdd, onDelete, onShowSettings } = callbacks || {};
return [
{
id: 'default-provider-actions',
renderButton: () => (
<DefaultProviderActions
name={provider.name}
isConfigured={provider.isConfigured}
onAdd={onAdd}
onDelete={onDelete}
onShowSettings={onShowSettings}
/>
),
},
];
},
},
},
{
name: 'Google',
details: {
id: 'google',
name: 'Google',
description: 'Access Gemini and other Google AI models',
parameters: [
{
name: 'GOOGLE_API_KEY',
is_secret: true,
},
],
getActions: (provider, callbacks) => {
const { onAdd, onDelete, onShowSettings } = callbacks || {};
return [
{
id: 'default-provider-actions',
renderButton: () => (
<DefaultProviderActions
name={provider.name}
isConfigured={provider.isConfigured}
onAdd={onAdd}
onDelete={onDelete}
onShowSettings={onShowSettings}
/>
),
},
];
},
},
},
{
name: 'Groq',
details: {
id: 'groq',
name: 'Groq',
description: 'Access Mixtral and other Groq-hosted models',
parameters: [
{
name: 'GROQ_API_KEY',
is_secret: true,
},
],
getActions: (provider, callbacks) => {
const { onAdd, onDelete, onShowSettings } = callbacks || {};
return [
{
id: 'default-provider-actions',
renderButton: () => (
<DefaultProviderActions
name={provider.name}
isConfigured={provider.isConfigured}
onAdd={onAdd}
onDelete={onDelete}
onShowSettings={onShowSettings}
/>
),
},
];
},
},
},
{
name: 'Databricks',
details: {
id: 'databricks',
name: 'Databricks',
description: 'Access models hosted on your Databricks instance',
parameters: [
{
name: 'DATABRICKS_HOST',
is_secret: false,
},
],
getActions: (provider, callbacks) => {
const { onAdd, onDelete, onShowSettings } = callbacks || {};
return [
{
id: 'default-provider-actions',
renderButton: () => (
<DefaultProviderActions
name={provider.name}
isConfigured={provider.isConfigured}
onAdd={onAdd}
onDelete={onDelete}
onShowSettings={onShowSettings}
/>
),
},
];
},
},
},
{
name: 'OpenRouter',
details: {
id: 'openrouter',
name: 'OpenRouter',
description: 'Access a variety of AI models through OpenRouter',
parameters: [
{
name: 'OPENROUTER_API_KEY',
is_secret: true,
},
],
getActions: (provider, callbacks) => {
const { onAdd, onDelete, onShowSettings } = callbacks || {};
return [
{
id: 'default-provider-actions',
renderButton: () => (
<DefaultProviderActions
name={provider.name}
isConfigured={provider.isConfigured}
onAdd={onAdd}
onDelete={onDelete}
onShowSettings={onShowSettings}
/>
),
},
];
},
},
},
{
name: 'Ollama',
details: {
id: 'ollama',
name: 'Ollama',
description: 'Run and use open-source models locally',
parameters: [
{
name: 'OLLAMA_HOST',
is_secret: false,
},
],
getActions: (provider, callbacks) => {
const { onAdd, onDelete, onRefresh, onShowSettings } = callbacks || {};
return [
{
id: 'ollama-actions',
renderButton: () => (
<OllamaActions
isConfigured={provider.isConfigured}
ollamaMetadata={provider.metadata}
onAdd={onAdd}
onRefresh={onRefresh}
onDelete={onDelete}
onShowSettings={onShowSettings}
/>
),
},
];
},
},
},
];
// const ACTION_IMPLEMENTATIONS = {
// 'default': (provider, callbacks) => [{
// id: 'default-provider-actions',
// renderButton: () => <DefaultProviderActions {...} />
// }],
//
// 'ollama': (provider, callbacks) => [{
// id: 'ollama-actions',
// renderButton: () => <OllamaActions {...} />
// }]
// };
@@ -0,0 +1,5 @@
import { toast } from 'react-toastify';
export default function OnAdd() {
toast.success('adding worked!');
}
@@ -0,0 +1,45 @@
// First define the default handlers separately
import OnAdd from './AddProviderParameters';
import OnDelete from './DeleteProviderParameters';
const DEFAULT_HANDLERS = {
onAdd: (providerId: string, config: any) => {
OnAdd();
},
onDelete: (providerId: string) => {
OnDelete();
},
onShowSettings: (providerId: string) => {
/* default settings behavior */
},
};
// Then use them in the registry
export const CALLBACK_REGISTRY = {
default: DEFAULT_HANDLERS,
anthropic: {
onAdd: (providerId: string, config: any) => {
/* Anthropic-specific add */
},
// Fall back to default handlers
onDelete: DEFAULT_HANDLERS.onDelete,
onShowSettings: DEFAULT_HANDLERS.onShowSettings,
},
ollama: {
onAdd: (providerId: string, config: any) => {
/* Ollama-specific add */
},
onDelete: (providerId: string) => {
/* Ollama-specific delete */
},
onRefresh: (providerId: string) => {
/* Ollama-specific refresh */
},
},
} as const;
// Type for the handlers
export type ActionHandler = typeof DEFAULT_HANDLERS;
export type ProviderId = keyof typeof CALLBACK_REGISTRY;
@@ -0,0 +1,5 @@
import { toast } from 'react-toastify';
export default function OnDelete() {
toast.success('deleting worked!');
}
@@ -0,0 +1,5 @@
import { toast } from 'react-toastify';
export default function OnRefresh() {
toast.success('refreshing worked!');
}
@@ -0,0 +1,5 @@
import { toast } from 'react-toastify';
export default function OnShowModal() {
toast.success('here is the modal yay!');
}
@@ -0,0 +1,5 @@
import { toast } from 'react-toastify';
export default function OnShowSettings() {
toast.success('settings update worked!');
}
@@ -0,0 +1,7 @@
// contains basic, common actions like edit, add, delete etc
// logic for whether or not these buttons get shown is stored in the actions/ folder
// specific providers may want specific methods to handle these operations -- TODO
export default interface ConfigurationAction {
id: string;
renderButton: () => React.JSX.Element;
}
@@ -0,0 +1,7 @@
export default interface ProviderCallbacks {
onShowModal?: () => void;
onAdd?: () => void;
onDelete?: () => void;
onShowSettings?: () => void;
onRefresh?: () => void;
}
@@ -0,0 +1,3 @@
export default interface OllamaMetadata {
location: 'app' | 'host' | null;
}
@@ -0,0 +1,5 @@
export default interface ParameterSchema {
name: string;
is_secret: boolean;
location?: string; // env, config.yaml, and/or keychain
}
@@ -0,0 +1,14 @@
// metadata and action builder
import ProviderState from './ProviderState';
import ConfigurationAction from './ConfigurationAction';
import ParameterSchema from '../parameters/interfaces/ParameterSchema';
import ProviderCallbacks from './ConfigurationCallbacks';
export default interface ProviderDetails {
id: string;
name: string;
description: string;
parameters: ParameterSchema[];
getTags?: (name: string) => string[];
getActions?: (provider: ProviderState, callbacks: ProviderCallbacks) => ConfigurationAction[];
}
@@ -0,0 +1,7 @@
// runtime data per instance
export default interface ProviderState {
id: string;
name: string;
isConfigured: boolean;
metadata: any;
}
@@ -0,0 +1,41 @@
import React from 'react';
import { Card } from '../../../../ui/card';
import ProviderSetupOverlay from './configuration_modal_subcomponents/ProviderSetupOverlay';
import ProviderSetupHeader from './configuration_modal_subcomponents/ProviderSetupHeader';
import ProviderSetupForm from './configuration_modal_subcomponents/ProviderSetupForm';
import ProviderSetupActions from './configuration_modal_subcomponents/ProviderSetupActions';
import ProviderConfiguationModalProps from './interfaces/ProviderConfigurationModalProps';
export default function ProviderConfigurationModal({
provider,
title,
onSubmit,
onCancel,
}: ProviderConfiguationModalProps) {
const [configValues, setConfigValues] = React.useState<{ [key: string]: string }>({});
const headerText = title || `Setup ${provider}`;
const handleSubmitForm = (e: React.FormEvent) => {
e.preventDefault();
onSubmit(configValues);
};
return (
<ProviderSetupOverlay>
<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">
<ProviderSetupHeader headerText={headerText} />
<ProviderSetupForm
configValues={configValues}
setConfigValues={setConfigValues}
onSubmit={handleSubmitForm}
provider={provider}
/>
<ProviderSetupActions onCancel={onCancel} />
</div>
</Card>
</ProviderSetupOverlay>
);
}
@@ -0,0 +1,33 @@
import React from 'react';
import { Button } from '../../../../../ui/button';
interface ProviderSetupActionsProps {
onCancel: () => void;
}
/**
* Renders the "Submit" and "Cancel" buttons at the bottom.
* Notice we rely on the parent's `onSubmit` in the form, so we only handle Cancel here.
*/
export default function ProviderSetupActions({ onCancel }: ProviderSetupActionsProps) {
return (
<div className="mt-[8px] -ml-8 -mr-8 pt-8">
{/* We rely on the <form> "onSubmit" for the actual Submit logic */}
<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>
);
}
@@ -0,0 +1,50 @@
import React from 'react';
import { Input } from '../../../../../ui/input';
import { Lock } from 'lucide-react';
import { isSecretKey } from '../../../../api_keys/utils';
import ProviderSetupFormProps from '../interfaces/ProviderSetupFormProps';
import ParameterSchema from '../../interfaces/ParameterSchema';
/**
* Renders the form with required input fields and the "lock" info row.
* The submit/cancel buttons are in a separate ProviderSetupActions component.
*/
export default function ProviderSetupForm({
configValues,
setConfigValues,
onSubmit,
provider,
}: ProviderSetupFormProps) {
const parameters: ParameterSchema[] = provider.parameters;
return (
<form onSubmit={onSubmit}>
<div className="mt-[24px] space-y-4">
{parameters.map((parameter) => (
<div key={parameter.name}>
<Input
type={parameter.is_secret ? 'password' : 'text'}
value={configValues[parameter.name] || ''}
onChange={(e) =>
setConfigValues((prev) => ({
...prev,
[parameter.name]: e.target.value,
}))
}
placeholder={parameter.name}
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">
<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.name}.
</span>
</div>
</div>
{/* The action buttons are not in this form; they're in ProviderSetupActions. */}
</form>
);
}
@@ -0,0 +1,16 @@
import React from 'react';
interface ProviderSetupHeaderProps {
headerText: string;
}
/**
* Renders the header (title) for the modal.
*/
export default function ProviderSetupHeader({ headerText }: ProviderSetupHeaderProps) {
return (
<div className="flex">
<h2 className="text-2xl font-regular text-textStandard">{headerText}</h2>
</div>
);
}
@@ -0,0 +1,16 @@
import React from 'react';
interface ProviderSetupOverlayProps {
children: React.ReactNode;
}
/**
* Renders the semi-transparent backdrop + blur for the modal.
*/
export default function ProviderSetupOverlay({ children }: ProviderSetupOverlayProps) {
return (
<div className="fixed inset-0 bg-black/20 dark:bg-white/20 backdrop-blur-sm transition-colors animate-[fadein_200ms_ease-in_forwards]">
{children}
</div>
);
}
@@ -0,0 +1,9 @@
// used both for initial config and editing config
import ProviderDetails from '../../interfaces/ProviderDetails';
export default interface ProviderConfiguationModalProps {
provider: ProviderDetails;
title?: string;
onSubmit: (configValues: { [key: string]: string }) => void;
onCancel: () => void;
}
@@ -0,0 +1,9 @@
import React from 'react';
import ProviderDetails from '../../interfaces/ProviderDetails';
export default interface ProviderSetupFormProps {
configValues: { [key: string]: string };
setConfigValues: React.Dispatch<React.SetStateAction<{ [key: string]: string }>>;
onSubmit: (e: React.FormEvent) => void;
provider: ProviderDetails;
}
@@ -0,0 +1,48 @@
import React, { createContext, useContext, useState, ReactNode, useEffect } from 'react';
import { getActiveProviders } from './utils';
// 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 : <div>Loading...</div>} {/* Conditional rendering */}
</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;
};
@@ -0,0 +1,21 @@
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;
}
export interface Provider {
id: string; // Lowercase key (e.g., "openai")
name: string; // Provider name (e.g., "OpenAI")
description: string; // Description of the provider
models: string[]; // List of supported models
requiredKeys: string[]; // List of required keys
}
@@ -0,0 +1,98 @@
import { Provider, ProviderResponse } from './types';
import { getApiUrl, getSecretKey } from '../../../config';
import { special_provider_cases } from '../providers/utils';
export function isSecretKey(keyName: string): boolean {
// Endpoints and hosts should not be stored as secrets
const nonSecretKeys = [
'DATABRICKS_HOST',
'OLLAMA_HOST',
'AZURE_OPENAI_ENDPOINT',
'AZURE_OPENAI_DEPLOYMENT_NAME',
];
return !nonSecretKeys.includes(keyName);
}
export async function getActiveProviders(): Promise<string[]> {
try {
// Fetch the secrets settings
const configSettings = await getConfigSettings();
console.log('[getActiveProviders]:', configSettings);
// Check for special provider cases (e.g. ollama running locally)
const specialCasesResults = await Promise.all(
Object.entries(special_provider_cases).map(async ([providerName, checkFunction]) => {
const isActive = await checkFunction(); // Dynamically re-check status
console.log(`Special case result for ${providerName}:`, isActive);
return isActive ? providerName : null;
})
);
// Extract active providers based on `is_set` in `secret_status` or providers with no keys
const activeProviders = Object.values(configSettings) // Convert object to array
.filter((provider) => {
const apiKeyStatus = Object.values(provider.config_status || {}); // Get all key statuses
// Include providers if all required keys are set
return apiKeyStatus.length > 0 && apiKeyStatus.every((key) => key.is_set);
})
.map((provider) => provider.name || 'Unknown Provider'); // Extract provider name
// Combine active providers from secrets settings and special cases (avoiding repeats)
const allActiveProviders = activeProviders.concat(
specialCasesResults.filter(
(provider) => provider !== null && !activeProviders.includes(provider)
)
);
return allActiveProviders;
} catch (error) {
console.error('Failed to get active providers:', error);
return [];
}
}
export async function getConfigSettings(): Promise<Record<string, ProviderResponse>> {
const providerList = await getProvidersList();
// Extract the list of IDs
const providerIds = providerList.map((provider) => provider.id);
// Fetch configs state (set/unset) using the provider IDs
const response = await fetch(getApiUrl('/configs/providers'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Secret-Key': getSecretKey(),
},
body: JSON.stringify({
providers: providerIds,
}),
});
if (!response.ok) {
throw new Error('Failed to fetch secrets');
}
const data = (await response.json()) as Record<string, ProviderResponse>;
return data;
}
export async function getProvidersList(): Promise<Provider[]> {
const response = await fetch(getApiUrl('/agent/providers'), {
method: 'GET',
});
if (!response.ok) {
throw new Error(`Failed to fetch providers: ${response.statusText}`);
}
const data = await response.json();
// Format the response into an array of providers
return data.map((item: any) => ({
id: item.id, // Root-level ID
name: item.details?.name || 'Unknown Provider', // Nested name in details
description: item.details?.description || 'No description available.', // Nested description
models: item.details?.models || [], // Nested models array
requiredKeys: item.details?.required_keys || [], // Nested required keys array
}));
}
@@ -0,0 +1,18 @@
import React from 'react';
import ConfigurationAction from '../interfaces/ConfigurationAction';
interface CardActionsProps {
actions: ConfigurationAction[];
}
export default function CardActions({ actions }: CardActionsProps) {
return (
<div className="space-x-2">
{actions.map((action) => {
// Store the rendered button in a variable first
const ButtonElement = action.renderButton();
return <React.Fragment key={action.id}>{ButtonElement}</React.Fragment>;
})}
</div>
);
}
@@ -0,0 +1,16 @@
import React from 'react';
import CardActions from './CardActions';
import ConfigurationAction from '../interfaces/ConfigurationAction';
interface CardBodyProps {
actions: ConfigurationAction[];
}
export default function CardBody({ actions }: CardBodyProps) {
console.log('in card body');
return (
<div className="space-x-2 text-center flex items-center justify-between">
<CardActions actions={actions} />
</div>
);
}
@@ -0,0 +1,37 @@
import React from 'react';
interface CardContainerProps {
header: React.ReactNode;
body: React.ReactNode;
}
function GlowingRing() {
return (
<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]
opacity-0 group-hover/card:opacity-100`}
/>
);
}
interface HeaderContainerProps {
children: React.ReactNode;
}
function HeaderContainer({ children }: HeaderContainerProps) {
return <div>{children}</div>;
}
export default function CardContainer({ header, body }: CardContainerProps) {
return (
<div className="relative h-full p-[2px] overflow-hidden rounded-[9px] group/card bg-borderSubtle hover:bg-transparent hover:duration-300">
<GlowingRing />
<div className="relative bg-bgApp rounded-lg p-3 transition-all duration-200 h-[160px] flex flex-col justify-between hover:border-borderStandard">
<HeaderContainer>{header}</HeaderContainer>
{body}
</div>
</div>
);
}
@@ -0,0 +1,51 @@
import React from 'react';
import { ExclamationButton, GreenCheckButton } from './actions/ActionButtons';
import {
ConfiguredProviderTooltipMessage,
OllamaNotConfiguredTooltipMessage,
ProviderDescription,
} from './utils/StringUtils';
interface CardHeaderProps {
name: string;
description: string;
isConfigured: boolean;
}
// Make CardTitle a proper React component
function CardTitle({ name }: { name: string }) {
return <h3 className="text-base font-medium text-textStandard truncate mr-2">{name}</h3>;
}
// Properly type ProviderNameAndStatus props
interface ProviderNameAndStatusProps {
name: string;
isConfigured: boolean;
}
function ProviderNameAndStatus({ name, isConfigured }: ProviderNameAndStatusProps) {
console.log(`Provider Name: ${name}, Is Configured: ${isConfigured}`);
const ollamaNotConfigured = !isConfigured && name === 'Ollama';
return (
<div className="flex items-center">
<CardTitle name={name} />
{/* Configured state: Green check */}
{isConfigured && <GreenCheckButton tooltip={ConfiguredProviderTooltipMessage(name)} />}
{/* Not Configured + Ollama => Exclamation */}
{ollamaNotConfigured && <ExclamationButton tooltip={OllamaNotConfiguredTooltipMessage()} />}
</div>
);
}
// Add a container div to the CardHeader
export default function CardHeader({ name, description, isConfigured }: CardHeaderProps) {
return (
<>
<ProviderNameAndStatus name={name} isConfigured={isConfigured} />
<ProviderDescription description={description} />
</>
);
}
@@ -0,0 +1,50 @@
import React from 'react';
import CardContainer from './CardContainer';
import CardHeader from './CardHeader';
import ProviderState from '../interfaces/ProviderState';
import CardBody from './CardBody';
import ProviderCallbacks from '../interfaces/ConfigurationCallbacks';
import { PROVIDER_REGISTRY } from '../ProviderRegistry';
interface ProviderCardProps {
provider: ProviderState;
providerCallbacks: ProviderCallbacks;
}
export function ProviderCard({ provider, providerCallbacks }: ProviderCardProps) {
const providerEntry = PROVIDER_REGISTRY.find((p) => p.name === provider.name);
// Add safety check
if (!providerEntry) {
console.error(`Provider ${provider.name} not found in registry`);
return null;
}
const providerDetails = providerEntry.details;
// Add another safety check
if (!providerDetails) {
console.error(`Provider ${provider.name} has no details`);
return null;
}
console.log('provider details', providerDetails);
try {
const actions = providerDetails.getActions(provider, providerCallbacks);
return (
<CardContainer
header={
<CardHeader
name={providerDetails.name}
description={providerDetails.description}
isConfigured={provider.isConfigured}
/>
}
body={<CardBody actions={actions} />}
/>
);
} catch (error) {
console.error(`Error rendering provider card for ${provider.name}:`, error);
return null;
}
}
@@ -0,0 +1,105 @@
import React from 'react';
import { Button } from '../../../../../ui/button';
import clsx from 'clsx';
import { TooltipWrapper } from './TooltipWrapper';
import { Check, CircleHelp, Plus, RefreshCw, Rocket, Settings, X } from 'lucide-react';
interface ActionButtonProps extends React.ComponentProps<typeof Button> {
/** Icon component to render, e.g. `RefreshCw` from lucide-react */
icon?: React.ComponentType<React.SVGProps<globalThis.SVGSVGElement>>;
/** Tooltip text to show; optional if you want no tooltip. */
tooltip?: React.ReactNode;
/** Additional classes for styling. */
className?: string;
}
// className is the styling for the <Button/> component -- below is the default
const baseActionButtonClasses = `
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
`;
export function ActionButton({
icon: Icon,
size = 'sm',
variant = 'default',
tooltip,
className,
...props
}: ActionButtonProps) {
const ButtonElement = (
<Button
size={size}
variant={variant}
className={clsx(baseActionButtonClasses, className)}
{...props}
>
{Icon && <Icon className="!size-4" />}
</Button>
);
// If a tooltip is provided, wrap the Button in a tooltip.
// Otherwise, just return the button as is.
if (tooltip) {
return <TooltipWrapper tooltipContent={tooltip}>{ButtonElement}</TooltipWrapper>;
}
return ButtonElement;
}
export function GreenCheckButton({
tooltip,
className = '', // Provide a default value to prevent undefined errors
...props
}: ActionButtonProps) {
return (
<ActionButton
icon={Check}
tooltip={tooltip}
className={`
bg-green-100
dark:bg-green-900/30
text-green-600
dark:text-green-500
hover:bg-green-100
hover:text-green-600
border-none
shadow-none
w-5 h-5
cursor-default
${className} // Removed the nullish coalescing operator as default is provided
`}
onClick={() => {}}
{...props}
/>
);
}
export function ExclamationButton({ tooltip, className, ...props }: ActionButtonProps) {
return <ActionButton icon={CircleHelp} tooltip={tooltip} onClick={() => {}} {...props} />;
}
export function GearSettingsButton({ tooltip, className, ...props }: ActionButtonProps) {
return <ActionButton icon={Settings} tooltip={tooltip} className={className} {...props} />;
}
export function AddButton({ tooltip, className, ...props }: ActionButtonProps) {
return <ActionButton icon={Plus} tooltip={tooltip} className={className} {...props} />;
}
export function DeleteButton({ tooltip, className, ...props }: ActionButtonProps) {
return <ActionButton icon={X} tooltip={tooltip} className={className} {...props} />;
}
export function RefreshButton({ tooltip, className, ...props }: ActionButtonProps) {
return <ActionButton icon={RefreshCw} tooltip={tooltip} className={className} {...props} />;
}
export function RocketButton({ tooltip, className, ...props }: ActionButtonProps) {
return <ActionButton icon={Rocket} tooltip={tooltip} className={className} {...props} />;
}
@@ -0,0 +1,67 @@
import React from 'react';
import { AddButton, DeleteButton, GearSettingsButton } from './ActionButtons';
interface ProviderActionsProps {
name: string;
isConfigured: boolean;
onAdd?: () => void;
onConfigure?: () => void;
onDelete?: () => void;
onShowSettings?: () => void;
}
function getDefaultTooltipMessages(name: string, actionType: string) {
switch (actionType) {
case 'add':
return `Configure ${name} settings`;
case 'edit':
return `Edit ${name} settings`;
case 'delete':
return `Delete ${name} settings`;
default:
return null;
}
}
export default function DefaultProviderActions({
name,
isConfigured,
onAdd,
onDelete,
onShowSettings,
}: ProviderActionsProps) {
return (
<>
{/*Set up an unconfigured provider */}
{!isConfigured && (
<AddButton
tooltip={getDefaultTooltipMessages(name, 'add')}
onClick={(e) => {
e.stopPropagation();
onAdd?.();
}}
/>
)}
{/*Edit settings of configured provider*/}
{isConfigured && (
<GearSettingsButton
tooltip={getDefaultTooltipMessages(name, 'edit')}
onClick={(e) => {
e.stopPropagation();
onShowSettings?.();
}}
/>
)}
{/*Delete configuration*/}
{isConfigured && (
<DeleteButton
tooltip={getDefaultTooltipMessages(name, 'delete')}
onClick={(e) => {
e.stopPropagation();
onDelete?.();
}}
/>
)}
</>
);
}
@@ -0,0 +1,87 @@
import React from 'react';
import { AddButton, DeleteButton, GearSettingsButton, RefreshButton } from './ActionButtons';
import OllamaMetadata from '../../interfaces/OllamaMetadata';
interface OllamaActionsProps {
isConfigured: boolean;
ollamaMetadata: OllamaMetadata;
onRefresh?: (e: React.MouseEvent) => void;
onAdd?: () => void;
onDelete?: () => void;
onShowSettings?: () => void;
}
export default function OllamaActions({
isConfigured,
ollamaMetadata,
onRefresh,
onAdd,
onDelete,
onShowSettings,
}: OllamaActionsProps) {
const showHostDeleteButton = isConfigured && ollamaMetadata.location === 'host' && !onDelete;
const showRefreshButton = !isConfigured && onRefresh;
// add host url to overwrite the app url OR if not configured at all yet
const showAddHostUrlButton =
(isConfigured && ollamaMetadata.location === 'app' && onAdd) || (!isConfigured && onAdd);
const showHostUrlSettingsButton =
isConfigured && ollamaMetadata.location === 'host' && onShowSettings;
// Well figure out which buttons to render:
// 1) Refresh button if not configured
// 2) If configured via app => show "plus" to switch to host config
// 3) If configured via host => show "X" to remove the host and "gear" to edit
return (
// TODO: is this the right class name?
<div className="flex items-center space-x-2">
{/* (1) Refresh button if not configured */}
{showRefreshButton && (
<RefreshButton
tooltip="Refresh to check if Ollama is running."
onClick={(e) => {
e.stopPropagation();
onRefresh?.(e);
}}
></RefreshButton>
)}
{/* (2) If configured location = 'app', show a plus button to switch / set host */}
{showAddHostUrlButton && (
<AddButton
tooltip="Switch to custom OLLAMA_HOST."
onClick={(e) => {
e.stopPropagation();
onAdd?.();
}}
></AddButton>
)}
{/* (3) If configured location = 'host', show an X to delete or revert config */}
{showHostDeleteButton && (
<DeleteButton
tooltip="Delete OLLAMA_HOST."
onClick={(e) => {
e.stopPropagation();
onDelete?.();
}}
></DeleteButton>
)}
{/* (4) If configured location = 'host', show a gear to view and edit config */}
{showHostUrlSettingsButton && (
<GearSettingsButton
tooltip={'View and edit OLLAMA_HOST'}
onClick={(e) => {
e.stopPropagation();
onShowSettings?.();
}}
></GearSettingsButton>
)}
</div>
);
}
@@ -0,0 +1,38 @@
// TooltipWrapper.tsx
import React from 'react';
import {
Tooltip,
TooltipTrigger,
TooltipContent,
TooltipProvider,
} from '../../../../../ui/Tooltip';
import { Portal } from '@radix-ui/react-portal';
interface TooltipWrapperProps {
children: React.ReactNode;
tooltipContent: React.ReactNode;
side?: 'top' | 'bottom' | 'left' | 'right';
align?: 'start' | 'center' | 'end';
className?: string;
}
export function TooltipWrapper({
children,
tooltipContent,
side = 'top',
align = 'center',
className = '',
}: TooltipWrapperProps) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<Portal>
<TooltipContent side={side} align={align} className={className}>
{typeof tooltipContent === 'string' ? <p>{tooltipContent}</p> : tooltipContent}
</TooltipContent>
</Portal>
</Tooltip>
</TooltipProvider>
);
}
@@ -0,0 +1,35 @@
import React from 'react';
// Functions for string / string-based element creation (e.g. tooltips for each provider, descriptions, etc)
export function OllamaNotConfiguredTooltipMessage() {
return (
<p>
To use, either the{' '}
<a
href="https://ollama.com/download"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 underline hover:text-blue-800"
>
Ollama app
</a>{' '}
must be installed on your machine and open, or you must enter a value for OLLAMA_HOST.
</p>
);
}
export function ConfiguredProviderTooltipMessage(name: string) {
return `${name} provider is configured`;
}
interface ProviderDescriptionProps {
description: string;
}
export function ProviderDescription({ description }: ProviderDescriptionProps) {
return (
<p className="text-xs text-textSubtle mt-1.5 mb-3 leading-normal overflow-y-auto max-h-[54px]">
{description}
</p>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { getApiUrl, getSecretKey } from '../config';
import { required_keys } from '../components/settings/models/hardcoded_stuff';
export async function DeleteProviderKeysFromKeychain() {
for (const [provider, keys] of Object.entries(required_keys)) {
for (const keyName of keys) {
try {
const deleteResponse = await fetch(getApiUrl('/configs/delete'), {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-Secret-Key': getSecretKey(),
},
body: JSON.stringify({
key: keyName,
is_secret: true, // get rid of keychain keys only
}),
});
if (!deleteResponse.ok) {
const errorText = await deleteResponse.text();
console.error('Delete response error:', errorText);
throw new Error('Failed to delete key: ' + keyName);
} else {
console.log('Successfully deleted key:', keyName);
}
} catch (error) {
console.error('Error deleting key:', keyName, error);
}
}
}
}
+6 -1
View File
@@ -1,4 +1,9 @@
import { defineConfig } from 'vite';
// https://vitejs.dev/config
export default defineConfig({});
export default defineConfig({
define: {
// This replaces process.env.ALPHA with a literal at build time
'process.env.ALPHA': JSON.stringify(process.env.ALPHA === 'true'),
},
});