UI Recipe/Custom Agents (#2119)

Co-authored-by: Kalvin Chau <kalvin@block.xyz>
This commit is contained in:
Zaki Ali
2025-04-11 13:28:29 -07:00
committed by GitHub
parent b030f845ce
commit 454e4a47f4
21 changed files with 1321 additions and 246 deletions

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { IpcRendererEvent } from 'electron';
import { addExtensionFromDeepLink } from './extensions';
import { openSharedSessionFromDeepLink } from './sessionLinks';
@@ -27,7 +27,9 @@ import ConfigureProvidersView from './components/settings/providers/ConfigurePro
import SessionsView from './components/sessions/SessionsView';
import SharedSessionView from './components/sessions/SharedSessionView';
import ProviderSettings from './components/settings_v2/providers/ProviderSettingsPage';
import RecipeEditor from './components/RecipeEditor';
import { useChat } from './hooks/useChat';
import { addExtension as addExtensionDirect, FullExtensionConfig } from './extensions';
import 'react-toastify/dist/ReactToastify.css';
import { useConfig, MalformedConfigError } from './components/ConfigContext';
@@ -46,7 +48,8 @@ export type View =
| 'settingsV2'
| 'sessions'
| 'sharedSession'
| 'loading';
| 'loading'
| 'recipeEditor';
export type ViewOptions =
| SettingsViewOptions
@@ -59,16 +62,47 @@ export type ViewConfig = {
viewOptions?: ViewOptions;
};
const getInitialView = (): ViewConfig => {
const urlParams = new URLSearchParams(window.location.search);
const viewFromUrl = urlParams.get('view');
const windowConfig = window.electron.getConfig();
if (viewFromUrl === 'recipeEditor' && windowConfig?.recipeConfig) {
return {
view: 'recipeEditor',
viewOptions: {
config: windowConfig.recipeConfig,
},
};
}
// Any other URL-specified view
if (viewFromUrl) {
return {
view: viewFromUrl as View,
viewOptions: {},
};
}
// Default case
return {
view: 'welcome',
viewOptions: {},
};
};
export default function App() {
const [fatalError, setFatalError] = useState<string | null>(null);
const [modalVisible, setModalVisible] = useState(false);
const [pendingLink, setPendingLink] = useState<string | null>(null);
const [modalMessage, setModalMessage] = useState<string>('');
const [{ view, viewOptions }, setInternalView] = useState<ViewConfig>({
view: 'loading',
viewOptions: {},
});
const { getExtensions, addExtension, read } = useConfig();
const [{ view, viewOptions }, setInternalView] = useState<ViewConfig>(getInitialView());
const {
getExtensions,
addExtension: addExtensionToConfig,
disableAllExtensions,
read,
} = useConfig();
const initAttemptedRef = useRef(false);
// Utility function to extract the command from the link
@@ -90,7 +124,153 @@ export default function App() {
setInternalView({ view, viewOptions });
};
// Single initialization effect that handles both v1 and v2 settings
const disableAllStoredExtensions = () => {
const userSettingsStr = localStorage.getItem('user_settings');
if (!userSettingsStr) return;
try {
const userSettings = JSON.parse(userSettingsStr);
// Store original state before modifying
localStorage.setItem('user_settings_backup', userSettingsStr);
console.log('Backing up user_settings');
// Disable all extensions
userSettings.extensions = userSettings.extensions.map((ext) => ({
...ext,
enabled: false,
}));
localStorage.setItem('user_settings', JSON.stringify(userSettings));
console.log('Disabled all stored extensions');
window.electron.emit('settings-updated');
} catch (error) {
console.error('Error disabling stored extensions:', error);
}
};
// Function to restore original extension states for new non-recipe windows
const restoreOriginalExtensionStates = () => {
const backupStr = localStorage.getItem('user_settings_backup');
if (backupStr) {
localStorage.setItem('user_settings', backupStr);
console.log('Restored original extension states');
}
};
const updateUserSettingsWithConfig = (extensions: FullExtensionConfig[]) => {
try {
const userSettingsStr = localStorage.getItem('user_settings');
const userSettings = userSettingsStr ? JSON.parse(userSettingsStr) : { extensions: [] };
// For each extension in the passed in config
extensions.forEach((newExtension) => {
// Find if this extension already exists
const existingIndex = userSettings.extensions.findIndex(
(ext) => ext.id === newExtension.id
);
if (existingIndex !== -1) {
// Extension exists - just set its enabled to true
userSettings.extensions[existingIndex].enabled = true;
} else {
// Extension is new - add it to the array
userSettings.extensions.push({
...newExtension,
enabled: true,
});
}
});
localStorage.setItem('user_settings', JSON.stringify(userSettings));
console.log('Updated user settings with new/enabled extensions:', userSettings.extensions);
// Notify any listeners (like the settings page) that settings have changed
window.electron.emit('settings-updated');
} catch (error) {
console.error('Error updating user settings:', error);
}
};
const enableRecipeConfigExtensions = async (extensions: FullExtensionConfig[]) => {
if (!extensions?.length) {
console.log('No extensions to enable from bot config');
return;
}
console.log(`Enabling ${extensions.length} extensions from bot config:`, extensions);
disableAllStoredExtensions();
// Wait for initial server readiness
await new Promise((resolve) => setTimeout(resolve, 2000));
for (const extension of extensions) {
try {
console.log(`Enabling extension: ${extension.name}`);
const extensionConfig = {
...extension,
enabled: true,
};
// Try to add the extension
const response = await addExtensionDirect(extensionConfig, false);
if (!response.ok) {
console.error(
`Failed to enable extension ${extension.name}: Server returned ${response.status}`
);
// If it's a 428, retry once
if (response.status === 428) {
console.log('Server not ready, waiting and will retry...');
await new Promise((resolve) => setTimeout(resolve, 2000));
try {
await addExtensionDirect(extensionConfig, true);
console.log(`Successfully enabled extension ${extension.name} on retry`);
} catch (retryError) {
console.error(`Failed to enable extension ${extension.name} on retry:`, retryError);
}
}
continue;
}
updateUserSettingsWithConfig(extensions);
console.log(`Successfully enabled extension: ${extension.name}`);
} catch (error) {
console.error(`Failed to enable extension ${extension.name}:`, error);
}
}
console.log('Finished enabling bot config extensions');
};
const enableRecipeConfigExtensionsV2 = useCallback(
async (extensions: FullExtensionConfig[]) => {
if (!extensions?.length) {
console.log('No extensions to enable from bot config');
return;
}
try {
await disableAllExtensions();
console.log('Disabled all existing extensions');
for (const extension of extensions) {
try {
console.log('Enabling extension: ${extension.name}');
await addExtensionToConfig(extension.name, extension, true);
} catch (error) {
console.error(`Failed to enable extension ${extension.name}:`, error);
}
}
} catch (error) {
console.error('Failed to enable bot extensions');
}
console.log('Finished enabling bot config extensions');
},
[disableAllExtensions, addExtensionToConfig]
);
// settings v2 initialization
useEffect(() => {
if (!settingsV2Enabled) {
return;
@@ -105,6 +285,27 @@ export default function App() {
console.log(`Initializing app with settings v2`);
const urlParams = new URLSearchParams(window.location.search);
const viewType = urlParams.get('view');
const recipeConfig = window.appConfig.get('recipeConfig');
// Handle bot config extensions first
if (recipeConfig?.extensions?.length > 0 && viewType != 'recipeEditor') {
console.log('Found extensions in bot config:', recipeConfig.extensions);
enableRecipeConfigExtensionsV2(recipeConfig.extensions);
}
// If we have a specific view type in the URL, use that and skip provider detection
if (viewType) {
if (viewType === 'recipeEditor' && recipeConfig) {
console.log('Setting view to recipeEditor with config:', recipeConfig);
setView('recipeEditor', { config: recipeConfig });
} else {
setView(viewType as View);
}
return;
}
const initializeApp = async () => {
try {
// Initialize config first
@@ -121,7 +322,7 @@ export default function App() {
try {
await initializeSystem(provider, model, {
getExtensions,
addExtension,
addExtensionToConfig,
});
} catch (error) {
console.error('Error in initialization:', error);
@@ -153,7 +354,7 @@ export default function App() {
console.error('Unhandled error in initialization:', error);
setFatalError(`${error instanceof Error ? error.message : 'Unknown error'}`);
});
}, [read, getExtensions, addExtension]);
}, [read, getExtensions, addExtensionToConfig, enableRecipeConfigExtensionsV2]);
const [isGoosehintsModalOpen, setIsGoosehintsModalOpen] = useState(false);
const [isLoadingSession, setIsLoadingSession] = useState(false);
@@ -243,6 +444,24 @@ export default function App() {
setView(newView);
};
// Get initial view and config
const urlParams = new URLSearchParams(window.location.search);
const viewFromUrl = urlParams.get('view');
if (viewFromUrl) {
// Get the config from the electron window config
const windowConfig = window.electron.getConfig();
if (viewFromUrl === 'recipeEditor') {
const initialViewOptions = {
recipeConfig: windowConfig?.recipeConfig,
view: viewFromUrl,
};
setView(viewFromUrl, initialViewOptions);
} else {
setView(viewFromUrl);
}
}
window.electron.on('set-view', handleSetView);
return () => window.electron.off('set-view', handleSetView);
}, []);
@@ -250,7 +469,7 @@ export default function App() {
// Add cleanup for session states when view changes
useEffect(() => {
console.log(`View changed to: ${view}`);
if (view !== 'chat') {
if (view !== 'chat' && view !== 'recipeEditor') {
console.log('Not in chat view, clearing loading session state');
setIsLoadingSession(false);
}
@@ -291,7 +510,7 @@ export default function App() {
setModalVisible(false); // Dismiss modal immediately
try {
if (settingsV2Enabled) {
await addExtensionFromDeepLinkV2(pendingLink, addExtension, setView);
await addExtensionFromDeepLinkV2(pendingLink, addExtensionToConfig, setView);
} else {
await addExtensionFromDeepLink(pendingLink, setView);
}
@@ -318,12 +537,40 @@ export default function App() {
const { addRecentModel } = useRecentModels(); // TODO: remove
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const viewType = urlParams.get('view');
const recipeConfig = window.appConfig.get('recipeConfig');
if (settingsV2Enabled) {
return;
}
console.log(`Initializing app with settings v1`);
// Handle bot config extensions first
if (recipeConfig?.extensions?.length > 0 && viewType != 'recipeEditor') {
console.log('Found extensions in bot config:', recipeConfig.extensions);
enableRecipeConfigExtensions(recipeConfig.extensions);
}
// If we have a specific view type in the URL, use that and skip provider detection
if (viewType) {
if (viewType === 'recipeEditor' && recipeConfig) {
console.log('Setting view to recipeEditor with config:', recipeConfig);
setView('recipeEditor', { config: recipeConfig });
} else {
setView(viewType as View);
}
return;
}
// if not in any of the states above (in a regular chat)
if (!recipeConfig) {
restoreOriginalExtensionStates();
}
console.log(`Initializing app with settings v1`);
// Attempt to detect config for a stored provider
const detectStoredProvider = () => {
try {
@@ -510,6 +757,27 @@ export default function App() {
}}
/>
)}
{view === 'recipeEditor' && (
<RecipeEditor
key={viewOptions?.config ? 'with-config' : 'no-config'}
config={viewOptions?.config || window.electron.getConfig().recipeConfig}
onClose={() => setView('chat')}
setView={setView}
onSave={(config) => {
console.log('Saving recipe config:', config);
window.electron.createChatWindow(
undefined,
undefined,
undefined,
undefined,
config,
'recipeEditor',
{ config }
);
setView('chat');
}}
/>
)}
</div>
</div>
{isGoosehintsModalOpen && (