diff --git a/redisinsight/api/config/features-config.json b/redisinsight/api/config/features-config.json index df6e14e41b..27a305ab0a 100644 --- a/redisinsight/api/config/features-config.json +++ b/redisinsight/api/config/features-config.json @@ -1,5 +1,5 @@ { - "version": 11, + "version": 12, "features": { "appUpdateStrategySettings": { "flag": true, @@ -33,6 +33,11 @@ } ] }, + "agentMemory": { + "flag": true, + "perc": [[0, 100]], + "filters": [] + }, "insightsRecommendations": { "flag": true, "perc": [[0, 100]] diff --git a/redisinsight/api/src/modules/feature/constants/index.ts b/redisinsight/api/src/modules/feature/constants/index.ts index 77f2de794c..7be7eb831b 100644 --- a/redisinsight/api/src/modules/feature/constants/index.ts +++ b/redisinsight/api/src/modules/feature/constants/index.ts @@ -28,6 +28,7 @@ export enum KnownFeatures { DocumentationChat = 'documentationChat', DatabaseChat = 'databaseChat', Rdi = 'redisDataIntegration', + AgentMemory = 'agentMemory', HashFieldExpiration = 'hashFieldExpiration', EnhancedCloudUI = 'enhancedCloudUI', DatabaseManagement = 'databaseManagement', diff --git a/redisinsight/api/src/modules/feature/constants/known-features.ts b/redisinsight/api/src/modules/feature/constants/known-features.ts index 99c98bfb63..e33d147678 100644 --- a/redisinsight/api/src/modules/feature/constants/known-features.ts +++ b/redisinsight/api/src/modules/feature/constants/known-features.ts @@ -46,6 +46,10 @@ export const knownFeatures: Record = { name: KnownFeatures.Rdi, storage: FeatureStorage.Database, }, + [KnownFeatures.AgentMemory]: { + name: KnownFeatures.AgentMemory, + storage: FeatureStorage.Database, + }, [KnownFeatures.EnhancedCloudUI]: { name: KnownFeatures.EnhancedCloudUI, storage: FeatureStorage.Database, diff --git a/redisinsight/api/src/modules/feature/providers/feature-flag/feature-flag.provider.ts b/redisinsight/api/src/modules/feature/providers/feature-flag/feature-flag.provider.ts index c928ad27d2..94584a9f52 100644 --- a/redisinsight/api/src/modules/feature/providers/feature-flag/feature-flag.provider.ts +++ b/redisinsight/api/src/modules/feature/providers/feature-flag/feature-flag.provider.ts @@ -31,6 +31,10 @@ export class FeatureFlagProvider { KnownFeatures.Rdi, new CommonFlagStrategy(this.featuresConfigService, this.settingsService), ); + this.strategies.set( + KnownFeatures.AgentMemory, + new CommonFlagStrategy(this.featuresConfigService, this.settingsService), + ); this.strategies.set( KnownFeatures.CloudSso, new CloudSsoFlagStrategy( diff --git a/redisinsight/ui/src/assets/img/agent-memory/agent-memory-icon.svg b/redisinsight/ui/src/assets/img/agent-memory/agent-memory-icon.svg new file mode 100755 index 0000000000..1b1e9da2a4 --- /dev/null +++ b/redisinsight/ui/src/assets/img/agent-memory/agent-memory-icon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/redisinsight/ui/src/components/base/utils/VisuallyHidden.ts b/redisinsight/ui/src/components/base/utils/VisuallyHidden.ts new file mode 100644 index 0000000000..cfedc40226 --- /dev/null +++ b/redisinsight/ui/src/components/base/utils/VisuallyHidden.ts @@ -0,0 +1,24 @@ +import { HTMLAttributes } from 'react' +import styled, { css } from 'styled-components' + +/** + * Standard screen-reader-only ruleset: the element stays in the + * accessibility tree but is removed from the visual layout. + * Apply to any styled component via `${visuallyHiddenCss}` or render + * the ready-made span. + */ +export const visuallyHiddenCss = css` + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +` + +export const VisuallyHidden = styled.span>` + ${visuallyHiddenCss} +` diff --git a/redisinsight/ui/src/components/home-tabs/constants.ts b/redisinsight/ui/src/components/home-tabs/constants.ts index 21bf3c20b0..d24d2cac1b 100644 --- a/redisinsight/ui/src/components/home-tabs/constants.ts +++ b/redisinsight/ui/src/components/home-tabs/constants.ts @@ -20,6 +20,13 @@ const tabs: HomeTab[] = [ path: Pages.rdi, featureFlag: FeatureFlags.rdi, }, + { + value: 'agent-memory', + label: 'Agent Memory', + content: null, + path: Pages.agentMemory, + featureFlag: FeatureFlags.agentMemory, + }, ] export { tabs } diff --git a/redisinsight/ui/src/components/main-router/constants/defaultRoutes.ts b/redisinsight/ui/src/components/main-router/constants/defaultRoutes.ts index 0cbbbd7b04..eececeafa3 100644 --- a/redisinsight/ui/src/components/main-router/constants/defaultRoutes.ts +++ b/redisinsight/ui/src/components/main-router/constants/defaultRoutes.ts @@ -22,6 +22,8 @@ import RdiPage from 'uiSrc/pages/rdi/home' import RdiInstancePage from 'uiSrc/pages/rdi/instance' import RdiStatisticsPage from 'uiSrc/pages/rdi/statistics' import PipelineManagementPage from 'uiSrc/pages/rdi/pipeline-management' +import AgentMemoryPage from 'uiSrc/pages/agent-memory/home' +import AgentMemoryWorkspacePage from 'uiSrc/pages/agent-memory/workspace' import { ANALYTICS_ROUTES, RDI_PIPELINE_MANAGEMENT_ROUTES } from './sub-routes' import COMMON_ROUTES from './commonRoutes' import { getRouteIncludedByEnv, LAZY_LOAD } from '../config' @@ -67,6 +69,10 @@ const LazyRdiStatisticsPage = lazy(() => import('uiSrc/pages/rdi/statistics')) const LazyPipelineManagementPage = lazy( () => import('uiSrc/pages/rdi/pipeline-management'), ) +const LazyAgentMemoryPage = lazy(() => import('uiSrc/pages/agent-memory/home')) +const LazyAgentMemoryWorkspacePage = lazy( + () => import('uiSrc/pages/agent-memory/workspace'), +) const INSTANCE_ROUTES: IRoute[] = [ { @@ -186,6 +192,19 @@ const ROUTES: IRoute[] = [ routes: RDI_INSTANCE_ROUTES, featureFlag: FeatureFlags.rdi, }, + { + path: Pages.agentMemory, + component: LAZY_LOAD ? LazyAgentMemoryPage : AgentMemoryPage, + exact: true, + featureFlag: FeatureFlags.agentMemory, + }, + { + path: Pages.agentMemoryWorkspace(':endpointId', ':tab?'), + component: LAZY_LOAD + ? LazyAgentMemoryWorkspacePage + : AgentMemoryWorkspacePage, + featureFlag: FeatureFlags.agentMemory, + }, ]), { path: '/:instanceId', diff --git a/redisinsight/ui/src/components/notifications/success-messages.tsx b/redisinsight/ui/src/components/notifications/success-messages.tsx index 56fd5b30ec..493c4368d6 100644 --- a/redisinsight/ui/src/components/notifications/success-messages.tsx +++ b/redisinsight/ui/src/components/notifications/success-messages.tsx @@ -60,6 +60,18 @@ export default { ), }), + ADDED_NEW_AGENT_MEMORY_ENDPOINT: (endpointName: string) => ({ + title: i18n.t('notification.success.addedAgentMemoryEndpoint.title'), + message: ( + + + + ), + }), DELETE_INSTANCE: (instanceName: string) => ({ title: i18n.t('notification.success.deleteInstance.title'), message: ( diff --git a/redisinsight/ui/src/constants/api.ts b/redisinsight/ui/src/constants/api.ts index 42707157e2..c7636648e2 100644 --- a/redisinsight/ui/src/constants/api.ts +++ b/redisinsight/ui/src/constants/api.ts @@ -193,6 +193,16 @@ enum ApiEndpoints { RDI_PIPELINE_STOP = 'pipeline/stop', RDI_PIPELINE_START = 'pipeline/start', RDI_PIPELINE_RESET = 'pipeline/reset', + + AGENT_MEMORY_ENDPOINTS = 'agent-memory', + AGENT_MEMORY_CONNECT = 'connect', + AGENT_MEMORY_SESSIONS = 'sessions', + AGENT_MEMORY_WORKING_MEMORY = 'working-memory', + AGENT_MEMORY_LTM_SEARCH = 'long-term-memory/search', + AGENT_MEMORY_LTM = 'long-term-memory', + AGENT_MEMORY_DISCOVERY = 'discovery', + AGENT_MEMORY_CONFIG = 'config', + AGENT_MEMORY_SUMMARY_VIEWS = 'summary-views', } export enum CustomHeaders { diff --git a/redisinsight/ui/src/constants/featureFlags.ts b/redisinsight/ui/src/constants/featureFlags.ts index f29e738bd0..23468ec390 100644 --- a/redisinsight/ui/src/constants/featureFlags.ts +++ b/redisinsight/ui/src/constants/featureFlags.ts @@ -6,6 +6,7 @@ export enum FeatureFlags { documentationChat = 'documentationChat', envDependent = 'envDependent', rdi = 'redisDataIntegration', + agentMemory = 'agentMemory', hashFieldExpiration = 'hashFieldExpiration', enhancedCloudUI = 'enhancedCloudUI', cloudAds = 'cloudAds', diff --git a/redisinsight/ui/src/constants/pages.ts b/redisinsight/ui/src/constants/pages.ts index 23145f7481..986c1b8c27 100644 --- a/redisinsight/ui/src/constants/pages.ts +++ b/redisinsight/ui/src/constants/pages.ts @@ -37,6 +37,7 @@ const redisCloud = '/redis-cloud' const sentinel = '/sentinel' const azure = '/azure' const rdi = '/integrate' +const agentMemory = '/agent-memory' // Query-param keys used to deep-link into the home page's edit-database dialog // and (optionally) reveal one of its fields. @@ -89,4 +90,10 @@ export const Pages = { rdiPipelineJobs: (rdiInstance: string, jobName: string) => `${rdi}/${rdiInstance}/${PageNames.rdiPipelineManagement}/${PageNames.rdiPipelineJobs}/${jobName}`, rdiStatistics: (rdiInstance: string) => `${rdi}/${rdiInstance}/statistics`, + // agent memory pages + agentMemory, + agentMemoryWorkspace: (endpointId: string, tab?: string) => + tab + ? `${agentMemory}/${endpointId}/${tab}` + : `${agentMemory}/${endpointId}`, } diff --git a/redisinsight/ui/src/constants/storage.ts b/redisinsight/ui/src/constants/storage.ts index 4305ee9bcb..e6459914f8 100644 --- a/redisinsight/ui/src/constants/storage.ts +++ b/redisinsight/ui/src/constants/storage.ts @@ -19,6 +19,7 @@ enum BrowserStorageItem { treeViewSort = 'treeViewSort', treeViewPrefixLength = 'treeViewDelimiterPrefixLength', autoRefreshRate = 'autoRefreshRate', + autoRefreshEnabled = 'autoRefreshEnabled', bulkActionDeleteId = 'bulkActionDeleteId', dbConfig = 'dbConfig_', RunQueryMode = 'RunQueryMode', @@ -50,6 +51,8 @@ enum BrowserStorageItem { prodModeCtaActioned = 'prodModeCtaActioned', whatsNewLastVersionSeen = 'whatsNewLastVersionSeen', valueDecoderRules = 'valueDecoderRules_', + agentMemoryPanelSizes = 'agentMemoryPanelSizes', + agentMemoryLtmPanelSizes = 'agentMemoryLtmPanelSizes', } export default BrowserStorageItem diff --git a/redisinsight/ui/src/i18n/locales/bg.json b/redisinsight/ui/src/i18n/locales/bg.json index f6d507bd46..b15ab1ea79 100644 --- a/redisinsight/ui/src/i18n/locales/bg.json +++ b/redisinsight/ui/src/i18n/locales/bg.json @@ -1413,6 +1413,8 @@ "notification.infinite.successDeployPipeline.message": "Поздравления!", "notification.success.addLibrary.message": "{{name}} беше добавена.", "notification.success.addLibrary.title": "Библиотеката беше добавена", + "notification.success.addedAgentMemoryEndpoint.message": "{{name}} беше добавена към RedisInsight.", + "notification.success.addedAgentMemoryEndpoint.title": "Крайната точка за агентна памет беше добавена", "notification.success.addedInstance.message": "{{name}} беше добавена към Redis Insight.", "notification.success.addedInstance.title": "Базата данни беше добавена", "notification.success.addedKey.message": "{{name}} беше добавен.", diff --git a/redisinsight/ui/src/i18n/locales/en.json b/redisinsight/ui/src/i18n/locales/en.json index 9af649eff2..295d515728 100644 --- a/redisinsight/ui/src/i18n/locales/en.json +++ b/redisinsight/ui/src/i18n/locales/en.json @@ -1413,6 +1413,8 @@ "notification.infinite.successDeployPipeline.message": "Congratulations!", "notification.success.addLibrary.message": "{{name}} has been added.", "notification.success.addLibrary.title": "Library has been added", + "notification.success.addedAgentMemoryEndpoint.message": "{{name}} has been added to RedisInsight.", + "notification.success.addedAgentMemoryEndpoint.title": "Agent memory endpoint has been added", "notification.success.addedInstance.message": "{{name}} has been added to Redis Insight.", "notification.success.addedInstance.title": "Database has been added", "notification.success.addedKey.message": "{{name}} has been added.", diff --git a/redisinsight/ui/src/pages/agent-memory/home/AgentMemoryPage.styles.ts b/redisinsight/ui/src/pages/agent-memory/home/AgentMemoryPage.styles.ts new file mode 100644 index 0000000000..30179da120 --- /dev/null +++ b/redisinsight/ui/src/pages/agent-memory/home/AgentMemoryPage.styles.ts @@ -0,0 +1,17 @@ +import styled from 'styled-components' +import { Theme } from 'uiSrc/components/base/theme/types' +import { FlexItem } from 'uiSrc/components/base/layout/flex' +import { Page } from 'uiSrc/components/base/layout/page' + +export const HomePage = styled(Page)` + padding: 1px ${({ theme }: { theme: Theme }) => theme.core.space.space200} + ${({ theme }: { theme: Theme }) => theme.core.space.space200}; +` + +export const EmptyPageContainer = styled(FlexItem)` + padding: ${({ theme }: { theme: Theme }) => theme.core.space.space300}; + border: 1px solid + ${({ theme }: { theme: Theme }) => theme.semantic.color.border.neutral500}; + border-radius: ${({ theme }: { theme: Theme }) => + theme.components.card.borderRadius}; +` diff --git a/redisinsight/ui/src/pages/agent-memory/home/AgentMemoryPage.tsx b/redisinsight/ui/src/pages/agent-memory/home/AgentMemoryPage.tsx new file mode 100644 index 0000000000..e544df4caa --- /dev/null +++ b/redisinsight/ui/src/pages/agent-memory/home/AgentMemoryPage.tsx @@ -0,0 +1,122 @@ +import React, { useEffect, useState } from 'react' +import { useHistory } from 'react-router-dom' + +import { useAppSelector } from 'uiSrc/slices/hooks' +import { dispatch } from 'uiSrc/slices/store' +import { + agentMemoryEndpointsSelector, + connectEndpointAction, + createEndpointAction, + editEndpointAction, + fetchEndpointsAction, +} from 'uiSrc/slices/agentMemory/endpoints' +import { AgentMemoryEndpoint } from 'uiSrc/slices/interfaces/agentMemory' +import { Pages } from 'uiSrc/constants' +import { setTitle, Nullable } from 'uiSrc/utils' +import HomePageTemplate from 'uiSrc/templates/home-page-template' +import { PageBody } from 'uiSrc/components/base/layout/page' +import { Row } from 'uiSrc/components/base/layout/flex' +import { Spacer } from 'uiSrc/components/base/layout/spacer' +import { PrimaryButton } from 'uiSrc/components/base/forms/buttons' +import { PlusIcon } from 'uiSrc/components/base/icons' +import { RiBadge } from 'uiSrc/components/base/display/badge/RiBadge' +import { Title } from 'uiSrc/components/base/text' + +import EndpointsList from './components/endpoints-list/EndpointsList' +import EmptyMessage from './components/empty-message/EmptyMessage' +import EndpointConnectionFormWrapper from './components/connection-form/EndpointConnectionFormWrapper' +import * as S from './AgentMemoryPage.styles' + +const PAGE_TITLE = 'Agent Memory' + +const AgentMemoryPage = () => { + const history = useHistory() + const { data, loading, loadingChanging } = useAppSelector( + agentMemoryEndpointsSelector, + ) + + const [isFormOpen, setIsFormOpen] = useState(false) + const [editEndpoint, setEditEndpoint] = + useState>(null) + + const hideList = data.length === 0 && !loading && !loadingChanging + + useEffect(() => { + dispatch(fetchEndpointsAction()) + setTitle(PAGE_TITLE) + }, []) + + const handleOpenForm = () => { + setEditEndpoint(null) + setIsFormOpen(true) + } + + const handleCloseForm = () => { + setEditEndpoint(null) + setIsFormOpen(false) + } + + const handleEdit = (endpoint: AgentMemoryEndpoint) => { + setEditEndpoint(endpoint) + setIsFormOpen(true) + } + + const handleConnect = (endpoint: AgentMemoryEndpoint) => { + dispatch( + connectEndpointAction(endpoint.id, () => + history.push(Pages.agentMemoryWorkspace(endpoint.id)), + ), + ) + } + + const handleFormSubmit = (endpoint: Partial) => { + if (editEndpoint) { + dispatch(editEndpointAction(editEndpoint.id, endpoint, handleCloseForm)) + } else { + dispatch(createEndpointAction(endpoint, handleCloseForm)) + } + } + + return ( + + + + + + {PAGE_TITLE} + + + {!hideList && ( + + Agent memory endpoint + + )} + + + {hideList ? ( + + ) : ( + + )} + + + + + ) +} + +export default AgentMemoryPage diff --git a/redisinsight/ui/src/pages/agent-memory/home/components/connection-form/EndpointConnectionForm.spec.tsx b/redisinsight/ui/src/pages/agent-memory/home/components/connection-form/EndpointConnectionForm.spec.tsx new file mode 100644 index 0000000000..97f4dcaee0 --- /dev/null +++ b/redisinsight/ui/src/pages/agent-memory/home/components/connection-form/EndpointConnectionForm.spec.tsx @@ -0,0 +1,201 @@ +import React from 'react' +import { faker } from '@faker-js/faker' + +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, +} from 'uiSrc/utils/test-utils' +import { + AgentMemoryBackendType, + AgentMemoryEndpoint, +} from 'uiSrc/slices/interfaces/agentMemory' + +import EndpointConnectionForm, { + EndpointConnectionFormProps, +} from './EndpointConnectionForm' + +const buildEndpoint = ( + overrides: Partial = {}, +): AgentMemoryEndpoint => ({ + id: faker.string.uuid(), + name: faker.lorem.words(2), + url: faker.internet.url(), + backendType: AgentMemoryBackendType.Cloud, + storeId: faker.string.alphanumeric(8), + ...overrides, +}) + +describe('EndpointConnectionForm', () => { + const defaultProps: EndpointConnectionFormProps = { + onSubmit: jest.fn(), + onCancel: jest.fn(), + editEndpoint: null, + isLoading: false, + } + + const renderComponent = ( + propsOverride?: Partial, + ) => { + const props = { ...defaultProps, ...propsOverride } + + return render( + <> + +
+ , + ) + } + + const fillCreateForm = () => { + fireEvent.change(screen.getByTestId('endpoint-form-name-input'), { + target: { value: faker.lorem.word() }, + }) + fireEvent.change(screen.getByTestId('endpoint-form-url-input'), { + target: { value: faker.internet.url() }, + }) + fireEvent.change(screen.getByTestId('endpoint-form-store-id-input'), { + target: { value: faker.string.alphanumeric(8) }, + }) + fireEvent.change(screen.getByTestId('endpoint-form-api-key-input'), { + target: { value: faker.string.alphanumeric(16) }, + }) + } + + beforeEach(() => { + cleanup() + jest.clearAllMocks() + }) + + it('should render', () => { + expect(renderComponent()).toBeTruthy() + }) + + it('should render name, url, store id and api key fields (no backend picker)', () => { + renderComponent() + + expect(screen.getByTestId('endpoint-form-name-input')).toBeInTheDocument() + expect(screen.getByTestId('endpoint-form-url-input')).toBeInTheDocument() + expect( + screen.getByTestId('endpoint-form-store-id-input'), + ).toBeInTheDocument() + expect( + screen.getByTestId('endpoint-form-api-key-input'), + ).toBeInTheDocument() + expect(screen.queryByTestId('endpoint-form-backend-select')).toBeNull() + }) + + it('should disable submit button when required fields are empty', async () => { + renderComponent() + + await waitFor(() => { + expect(screen.getByTestId('endpoint-form-submit-button')).toBeDisabled() + }) + }) + + it('should keep submit disabled when only some required fields are provided', async () => { + renderComponent() + + await act(() => { + fireEvent.change(screen.getByTestId('endpoint-form-name-input'), { + target: { value: faker.lorem.word() }, + }) + fireEvent.change(screen.getByTestId('endpoint-form-url-input'), { + target: { value: faker.internet.url() }, + }) + }) + + await waitFor(() => { + expect(screen.getByTestId('endpoint-form-submit-button')).toBeDisabled() + }) + }) + + it('should enable submit when name, url, store id and api key are provided', async () => { + renderComponent() + + await act(() => { + fillCreateForm() + }) + + await waitFor(() => { + expect( + screen.getByTestId('endpoint-form-submit-button'), + ).not.toBeDisabled() + }) + }) + + it('should not require api key when editing an endpoint (stored key is kept)', async () => { + const editEndpoint = buildEndpoint() + renderComponent({ editEndpoint }) + + await waitFor(() => { + expect( + screen.getByTestId('endpoint-form-submit-button'), + ).not.toBeDisabled() + }) + }) + + it('should call onSubmit with only the changed fields', async () => { + const onSubmit = jest.fn() + const editEndpoint = buildEndpoint() + const newName = faker.lorem.words(3) + renderComponent({ editEndpoint, onSubmit }) + + await act(() => { + fireEvent.change(screen.getByTestId('endpoint-form-name-input'), { + target: { value: newName }, + }) + }) + + await waitFor(() => { + expect( + screen.getByTestId('endpoint-form-submit-button'), + ).not.toBeDisabled() + }) + + fireEvent.click(screen.getByTestId('endpoint-form-submit-button')) + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith({ name: newName }) + }) + }) + + it('should call onSubmit without the untouched api key when editing', async () => { + const onSubmit = jest.fn() + const editEndpoint = buildEndpoint() + renderComponent({ editEndpoint, onSubmit }) + + await waitFor(() => { + expect( + screen.getByTestId('endpoint-form-submit-button'), + ).not.toBeDisabled() + }) + + fireEvent.click(screen.getByTestId('endpoint-form-submit-button')) + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith({}) + }) + }) + + it('should call onCancel when cancel button is clicked', async () => { + const onCancel = jest.fn() + renderComponent({ onCancel }) + + fireEvent.click(await screen.findByTestId('endpoint-form-cancel-button')) + + expect(onCancel).toHaveBeenCalledTimes(1) + }) + + it('should disable submit button when isLoading is true', async () => { + const editEndpoint = buildEndpoint() + renderComponent({ editEndpoint, isLoading: true }) + + await waitFor(() => { + expect(screen.getByTestId('endpoint-form-submit-button')).toBeDisabled() + }) + }) +}) diff --git a/redisinsight/ui/src/pages/agent-memory/home/components/connection-form/EndpointConnectionForm.tsx b/redisinsight/ui/src/pages/agent-memory/home/components/connection-form/EndpointConnectionForm.tsx new file mode 100644 index 0000000000..fc9b222dc3 --- /dev/null +++ b/redisinsight/ui/src/pages/agent-memory/home/components/connection-form/EndpointConnectionForm.tsx @@ -0,0 +1,228 @@ +import React, { useEffect, useState } from 'react' +import { Field, FieldInputProps, Form, Formik, FormikErrors } from 'formik' +import ReactDOM from 'react-dom' + +import { Nullable, getFormUpdates } from 'uiSrc/utils' +import { useModalHeader } from 'uiSrc/contexts/ModalTitleProvider' +import { Col, FlexItem, Row } from 'uiSrc/components/base/layout/flex' +import { + PrimaryButton, + SecondaryButton, +} from 'uiSrc/components/base/forms/buttons' +import { FormField } from 'uiSrc/components/base/forms/FormField' +import { PasswordInput, TextInput } from 'uiSrc/components/base/inputs' +import { Title } from 'uiSrc/components/base/text/Title' +import { + AgentMemoryBackendType, + AgentMemoryEndpoint, +} from 'uiSrc/slices/interfaces/agentMemory' + +export interface ConnectionFormValues { + name: string + url: string + backendType: AgentMemoryBackendType + storeId: string + apiKey: Nullable +} + +export interface EndpointConnectionFormProps { + onSubmit: (endpoint: Partial) => void + onCancel: () => void + editEndpoint: Nullable + isLoading: boolean +} + +const getInitialValues = ( + values: Nullable, +): ConnectionFormValues => ({ + name: values?.name || '', + url: values?.url || '', + // Redis Agent Memory is the only supported backend. + backendType: AgentMemoryBackendType.Cloud, + storeId: values?.storeId || '', + // null on edit so an untouched masked field is not sent as an update + apiKey: values ? null : '', +}) + +const EndpointConnectionForm = (props: EndpointConnectionFormProps) => { + const { onSubmit, onCancel, editEndpoint, isLoading } = props + + const [initialFormValues, setInitialFormValues] = useState( + getInitialValues(editEndpoint), + ) + const { setModalHeader } = useModalHeader() + + useEffect(() => { + setInitialFormValues(getInitialValues(editEndpoint)) + setModalHeader( + + {editEndpoint ? 'Edit endpoint' : 'Add agent memory endpoint'} + , + ) + }, [editEndpoint]) + + const validate = (values: ConnectionFormValues) => { + const errors: FormikErrors = {} + + if (!values.name) { + errors.name = 'Name' + } + if (!values.url) { + errors.url = 'URL' + } + if (!values.storeId) { + errors.storeId = 'Store ID' + } + // Editing an existing endpoint implies a stored key (keys are never + // sent back to the UI), so only require one when creating. + if (!editEndpoint && !values.apiKey) { + errors.apiKey = 'API Key' + } + + return errors + } + + const handleSubmit = (values: ConnectionFormValues) => { + const updates = getFormUpdates(values, editEndpoint || {}) + onSubmit(updates) + } + + const Footer = ({ + isValid, + onSubmit: onFormSubmit, + }: { + isValid: boolean + onSubmit: () => void + }) => { + const footerEl = document.getElementById('footerDatabaseForm') + + if (!footerEl) return null + + return ReactDOM.createPortal( + + + + Cancel + + + + + {editEndpoint ? 'Apply Changes' : 'Add Endpoint'} + + + , + footerEl, + ) + } + + return ( + + {({ isValid, submitForm }) => ( +
+ + + + {({ field }: { field: FieldInputProps }) => ( + + field.onChange({ target: { name: field.name, value } }) + } + /> + )} + + + + + {({ field }: { field: FieldInputProps }) => ( + + field.onChange({ target: { name: field.name, value } }) + } + /> + )} + + + + + {({ field }: { field: FieldInputProps }) => ( + + field.onChange({ + target: { name: field.name, value }, + }) + } + /> + )} + + + + + {({ field }: { field: FieldInputProps }) => ( + + )} + + + +