diff --git a/app/client/src/ce/actions/aiAssistantActions.ts b/app/client/src/ce/actions/aiAssistantActions.ts new file mode 100644 index 000000000000..5ee5f624d788 --- /dev/null +++ b/app/client/src/ce/actions/aiAssistantActions.ts @@ -0,0 +1,100 @@ +import type { ReduxAction } from "actions/ReduxActionTypes"; +import { ReduxActionTypes } from "ee/constants/ReduxActionConstants"; + +export interface AIMessage { + role: "user" | "assistant"; + content: string; + timestamp: number; +} + +export interface UpdateAISettingsPayload { + provider?: string; + hasApiKey?: boolean; + isEnabled?: boolean; +} + +export const updateAISettings = ( + payload: UpdateAISettingsPayload, +): ReduxAction => ({ + type: ReduxActionTypes.UPDATE_AI_SETTINGS, + payload, +}); + +export interface FetchAIResponsePayload { + prompt: string; + context?: { + functionName?: string; + cursorLineNumber?: number; + functionString?: string; + mode?: string; + currentValue?: string; + entityId?: string; + }; +} + +export const fetchAIResponse = ( + payload: FetchAIResponsePayload, +): ReduxAction => ({ + type: ReduxActionTypes.FETCH_AI_RESPONSE, + payload, +}); + +export const fetchAIResponseSuccess = (payload: { + response: string; +}): ReduxAction<{ response: string }> => ({ + type: ReduxActionTypes.FETCH_AI_RESPONSE_SUCCESS, + payload, +}); + +export const fetchAIResponseError = (payload: { + error: string; +}): ReduxAction<{ error: string }> => ({ + type: ReduxActionTypes.FETCH_AI_RESPONSE_ERROR, + payload, +}); + +export const loadAISettings = (): ReduxAction => ({ + type: ReduxActionTypes.LOAD_AI_SETTINGS, + payload: undefined, +}); + +export const clearAIResponse = (): ReduxAction => ({ + type: ReduxActionTypes.CLEAR_AI_RESPONSE, + payload: undefined, +}); + +export const openAIPanel = (): ReduxAction => ({ + type: ReduxActionTypes.OPEN_AI_PANEL, + payload: undefined, +}); + +export const closeAIPanel = (): ReduxAction => ({ + type: ReduxActionTypes.CLOSE_AI_PANEL, + payload: undefined, +}); + +export interface AIEditorContextPayload { + functionName?: string; + cursorLineNumber?: number; + functionString?: string; + mode?: string; + currentValue?: string; + editorId?: string; + entityName?: string; + entityId?: string; + propertyPath?: string; +} + +export const updateAIContext = ( + context: AIEditorContextPayload, +): ReduxAction<{ context: AIEditorContextPayload }> => ({ + type: ReduxActionTypes.UPDATE_AI_CONTEXT, + payload: { context }, +}); + +export const openAIPanelWithContext = ( + context: AIEditorContextPayload, +): ReduxAction<{ context: AIEditorContextPayload }> => ({ + type: ReduxActionTypes.OPEN_AI_PANEL_WITH_CONTEXT, + payload: { context }, +}); diff --git a/app/client/src/ce/components/editorComponents/GPT/AISidePanel.tsx b/app/client/src/ce/components/editorComponents/GPT/AISidePanel.tsx new file mode 100644 index 000000000000..5733a3d25f6f --- /dev/null +++ b/app/client/src/ce/components/editorComponents/GPT/AISidePanel.tsx @@ -0,0 +1,8 @@ +export interface AISidePanelProps { + onClose: () => void; +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function AISidePanel(_props: AISidePanelProps) { + return null; +} diff --git a/app/client/src/ce/components/editorComponents/GPT/index.tsx b/app/client/src/ce/components/editorComponents/GPT/index.tsx index a545494abc3c..9dcf8651698c 100644 --- a/app/client/src/ce/components/editorComponents/GPT/index.tsx +++ b/app/client/src/ce/components/editorComponents/GPT/index.tsx @@ -7,6 +7,8 @@ import type { EntityNavigationData } from "entities/DataTree/dataTreeTypes"; import React from "react"; import type CodeMirror from "codemirror"; +export { AISidePanel } from "./AISidePanel"; + export type AIEditorContext = Partial<{ functionName: string; cursorLineNumber: number; diff --git a/app/client/src/ce/components/editorComponents/GPT/trigger.tsx b/app/client/src/ce/components/editorComponents/GPT/trigger.tsx index b491abc6a22b..9a2af2e8dcdc 100644 --- a/app/client/src/ce/components/editorComponents/GPT/trigger.tsx +++ b/app/client/src/ce/components/editorComponents/GPT/trigger.tsx @@ -5,7 +5,15 @@ import type { EntityTypeValue } from "ee/entities/DataTree/types"; export const APPSMITH_AI = "Appsmith AI"; -export function isAIEnabled(ff: FeatureFlags, mode: TEditorModes) { +export function isAISupportedMode(mode: TEditorModes) { + return false; +} + +export function isAIEnabled( + ff: FeatureFlags, + mode: TEditorModes, + hasApiKey?: boolean, +) { return false; } diff --git a/app/client/src/ce/components/editorComponents/GlobalAISidePanel/index.tsx b/app/client/src/ce/components/editorComponents/GlobalAISidePanel/index.tsx new file mode 100644 index 000000000000..b0b4bb57ffc3 --- /dev/null +++ b/app/client/src/ce/components/editorComponents/GlobalAISidePanel/index.tsx @@ -0,0 +1,3 @@ +export function GlobalAISidePanel() { + return null; +} diff --git a/app/client/src/ce/constants/ReduxActionConstants.tsx b/app/client/src/ce/constants/ReduxActionConstants.tsx index 731dfdc6fa73..2d08cb91cd83 100644 --- a/app/client/src/ce/constants/ReduxActionConstants.tsx +++ b/app/client/src/ce/constants/ReduxActionConstants.tsx @@ -1318,6 +1318,15 @@ const OneClickBindingActionTypes = { const AIActionTypes = { UPDATE_AI_CONTEXT: "UPDATE_AI_CONTEXT", UPDATE_AI_TRIGGERED: "UPDATE_AI_TRIGGERED", + UPDATE_AI_SETTINGS: "UPDATE_AI_SETTINGS", + LOAD_AI_SETTINGS: "LOAD_AI_SETTINGS", + FETCH_AI_RESPONSE: "FETCH_AI_RESPONSE", + FETCH_AI_RESPONSE_SUCCESS: "FETCH_AI_RESPONSE_SUCCESS", + FETCH_AI_RESPONSE_ERROR: "FETCH_AI_RESPONSE_ERROR", + CLEAR_AI_RESPONSE: "CLEAR_AI_RESPONSE", + OPEN_AI_PANEL: "OPEN_AI_PANEL", + CLOSE_AI_PANEL: "CLOSE_AI_PANEL", + OPEN_AI_PANEL_WITH_CONTEXT: "OPEN_AI_PANEL_WITH_CONTEXT", }; const PlatformActionErrorTypes = { diff --git a/app/client/src/ce/selectors/aiAssistantSelectors.ts b/app/client/src/ce/selectors/aiAssistantSelectors.ts new file mode 100644 index 000000000000..d88b331ab380 --- /dev/null +++ b/app/client/src/ce/selectors/aiAssistantSelectors.ts @@ -0,0 +1,47 @@ +import type { DefaultRootState } from "react-redux"; + +export function getAIAssistantState(state: DefaultRootState) { + return undefined; +} + +export function getHasAIApiKey(_state: DefaultRootState): boolean { + return false; +} + +export function getAIProvider(_state: DefaultRootState): string | undefined { + return undefined; +} + +export function getIsAILoading(_state: DefaultRootState): boolean { + return false; +} + +export function getAIMessages(_state: DefaultRootState): never[] { + return []; +} + +export function getAILastResponse( + _state: DefaultRootState, +): string | undefined { + return undefined; +} + +export function getAIError(_state: DefaultRootState): string | undefined { + return undefined; +} + +export function getIsAIEnabled(_state: DefaultRootState): boolean { + return false; +} + +export function getIsAIConfigLoaded(_state: DefaultRootState): boolean { + return false; +} + +export function getIsAIPanelOpen(_state: DefaultRootState): boolean { + return false; +} + +export function getAIEditorContext(_state: DefaultRootState): null { + return null; +} diff --git a/app/client/src/components/editorComponents/CodeEditor/generateQuickCommands.tsx b/app/client/src/components/editorComponents/CodeEditor/generateQuickCommands.tsx index 7f8d45e6216f..d1ac57b17807 100644 --- a/app/client/src/components/editorComponents/CodeEditor/generateQuickCommands.tsx +++ b/app/client/src/components/editorComponents/CodeEditor/generateQuickCommands.tsx @@ -400,7 +400,6 @@ export const generateQuickCommands = ( displayText: APPSMITH_AI, shortcut: Shortcuts.ASK_AI, triggerCompletionsPostPick: true, - isBeta: true, action: () => { executeCommand({ actionType: SlashCommand.ASK_AI, diff --git a/app/client/src/components/editorComponents/CodeEditor/index.tsx b/app/client/src/components/editorComponents/CodeEditor/index.tsx index 5fd802b4dad1..a9cd6f77ae2c 100644 --- a/app/client/src/components/editorComponents/CodeEditor/index.tsx +++ b/app/client/src/components/editorComponents/CodeEditor/index.tsx @@ -135,10 +135,23 @@ import { } from "./utils/saveAndAutoIndent"; import { getAssetUrl } from "ee/utils/airgapHelpers"; import { selectFeatureFlags } from "ee/selectors/featureFlagsSelectors"; -import { AIWindow } from "ee/components/editorComponents/GPT"; import { AskAIButton } from "ee/components/editorComponents/GPT/AskAIButton"; import classNames from "classnames"; -import { isAIEnabled } from "ee/components/editorComponents/GPT/trigger"; +import { + isAIEnabled, + getAIContext, +} from "ee/components/editorComponents/GPT/trigger"; +import { + getHasAIApiKey, + getIsAIConfigLoaded, + getIsAIPanelOpen, +} from "ee/selectors/aiAssistantSelectors"; +import { + loadAISettings, + openAIPanelWithContext, + closeAIPanel, +} from "ee/actions/aiAssistantActions"; +import type { AIEditorContextPayload } from "ee/actions/aiAssistantActions"; import { getAllDatasourceTableKeys, selectInstalledLibraries, @@ -278,7 +291,6 @@ interface State { }) | undefined; isDynamic: boolean; - showAIWindow: boolean; ternToolTipActive: boolean; } @@ -316,7 +328,6 @@ class CodeEditor extends Component { hinterOpen: false, ctrlPressed: false, peekOverlayProps: undefined, - showAIWindow: false, ternToolTipActive: false, }; this.updatePropertyValue = this.updatePropertyValue.bind(this); @@ -333,15 +344,20 @@ class CodeEditor extends Component { props.input.value, ); this.multiplexConfig = MULTIPLEXING_MODE_CONFIGS[this.props.mode]; - /** - * Decides if AI is enabled by looking at repo, feature flags, props and environment - */ this.AIEnabled = - isAIEnabled(this.props.featureFlags, this.props.mode) && - Boolean(this.props.AIAssisted); + isAIEnabled( + this.props.featureFlags, + this.props.mode, + this.props.hasAIApiKey, + ) && Boolean(this.props.AIAssisted); } componentDidMount(): void { + // Load AI settings if this is an AI-assisted editor and settings aren't loaded yet + if (this.props.AIAssisted && !this.props.isAIConfigLoaded) { + this.props.loadAISettings(); + } + if (this.codeEditorTarget.current) { const options: EditorConfiguration = { autoRefresh: true, @@ -571,26 +587,20 @@ class CodeEditor extends Component { }, 200); componentDidUpdate(prevProps: Props): void { + // Recalculate AIEnabled when hasAIApiKey changes (e.g., after saga loads settings) + if (prevProps.hasAIApiKey !== this.props.hasAIApiKey) { + this.AIEnabled = + isAIEnabled( + this.props.featureFlags, + this.props.mode, + this.props.hasAIApiKey, + ) && Boolean(this.props.AIAssisted); + } + const identifierHasChanged = getEditorIdentifier(this.props) !== getEditorIdentifier(prevProps); - const entityInformation = this.getEntityInformation(); - const isWidgetType = entityInformation.entityType === ENTITY_TYPE.WIDGET; - - const hasFocusedValueChanged = - getEditorIdentifier(this.props) !== this.props.focusedProperty; - - if (hasFocusedValueChanged && isWidgetType) { - if (this.state.showAIWindow) { - this.setState({ showAIWindow: false }); - } - } - if (identifierHasChanged) { - if (this.state.showAIWindow) { - this.setState({ showAIWindow: false }); - } - if (shouldFocusOnPropertyControl()) { setTimeout(() => { if (this.props.editorIsFocused) { @@ -1682,9 +1692,7 @@ class CodeEditor extends Component { } const showSlashCommandButton = - showLightningMenu !== false && - !this.state.isFocused && - !this.state.showAIWindow; + showLightningMenu !== false && !this.state.isFocused; /* Evaluation results for snippet arguments. The props below can be used to set the validation errors when computed from parent component */ if (this.props.errors) { @@ -1698,7 +1706,6 @@ class CodeEditor extends Component { const showEvaluatedValue = this.showFeatures() && (this.state.isDynamic || isInvalid) && - !this.state.showAIWindow && !this.state.peekOverlayProps && !this.editor.state.completionActive && !this.state.ternToolTipActive; @@ -1734,15 +1741,56 @@ class CodeEditor extends Component { -
- { - this.setState({ showAIWindow: true }); - }} - /> -
+ {this.AIEnabled && ( +
+ { + try { + const currentValue = + typeof this.props.input.value === "string" + ? this.props.input.value + : ""; + + let aiContext = { + functionName: "", + cursorLineNumber: 0, + functionString: "", + }; + + if (this.editor) { + const cursorPosition = this.editor.getCursor(); + + aiContext = getAIContext({ + cursorPosition, + editor: this.editor, + }); + } + + this.props.openAIPanelWithContext({ + functionName: aiContext.functionName, + cursorLineNumber: aiContext.cursorLineNumber, + functionString: aiContext.functionString, + mode: this.props.mode, + currentValue, + editorId: getEditorIdentifier(this.props), + entityName: entityInformation?.entityName, + entityId: entityInformation?.entityId, + propertyPath: entityInformation?.propertyPath, + }); + } catch (error) { + // eslint-disable-next-line no-console + console.error("Error opening AI panel:", error); + this.props.openAIPanelWithContext({ + mode: this.props.mode, + currentValue: "", + }); + } + }} + /> +
+ )} { theme={theme || EditorTheme.LIGHT} useValidationMessage={useValidationMessage} > - { - this.setState({ showAIWindow }); - }} - triggerContext={this.props.expected} - update={this.updateValueWithAIResponse} + onMouseMove={this.handleLintTooltip} + onMouseOver={this.handleMouseMove} + ref={this.editorWrapperRef} + removeHoverAndFocusStyle={this.props?.removeHoverAndFocusStyle} + showFocusVisible={!this.props.isJSObject} + size={size} > - this.hidePeekOverlay()} + {...this.state.peekOverlayProps} + /> + )} + {this.props.leftIcon && ( + {this.props.leftIcon} + )} + + {this.props.leftImage && ( + img + )} +
- {this.state.peekOverlayProps && ( - this.hidePeekOverlay()} - {...this.state.peekOverlayProps} - /> - )} - {this.props.leftIcon && ( - {this.props.leftIcon} - )} - - {this.props.leftImage && ( - img - )} -
+
+ + {this.props.link && ( + - -
- - {this.props.link && ( - - API documentation - - )} - {this.props.rightIcon && ( - {this.props.rightIcon} - )} -
-
+ API documentation + + )} + {this.props.rightIcon && ( + {this.props.rightIcon} + )} +
); @@ -1885,6 +1917,9 @@ const mapStateToProps = (state: DefaultRootState, props: EditorProps) => { datasourceTableKeys: getAllDatasourceTableKeys(state, props.dataTreePath), installedLibraries: selectInstalledLibraries(state), focusedProperty: getFocusablePropertyPaneField(state), + hasAIApiKey: getHasAIApiKey(state), + isAIConfigLoaded: getIsAIConfigLoaded(state), + isAIPanelOpen: getIsAIPanelOpen(state), }; }; @@ -1898,6 +1933,10 @@ const mapDispatchToProps = (dispatch: any) => ({ dispatch(setEditorFieldFocusAction(payload)), setActiveField: (path: string) => dispatch(setActiveEditorField(path)), resetActiveField: () => dispatch(resetActiveEditorField()), + loadAISettings: () => dispatch(loadAISettings()), + closeAIPanel: () => dispatch(closeAIPanel()), + openAIPanelWithContext: (context: AIEditorContextPayload) => + dispatch(openAIPanelWithContext(context)), }); export default connect(mapStateToProps, mapDispatchToProps)(CodeEditor); diff --git a/app/client/src/components/editorComponents/form/fields/DynamicTextField.tsx b/app/client/src/components/editorComponents/form/fields/DynamicTextField.tsx index 1ac2408f8d78..044e20e61cb2 100644 --- a/app/client/src/components/editorComponents/form/fields/DynamicTextField.tsx +++ b/app/client/src/components/editorComponents/form/fields/DynamicTextField.tsx @@ -13,7 +13,7 @@ import { TabBehaviour, } from "components/editorComponents/CodeEditor/EditorConfig"; import LazyCodeEditor from "components/editorComponents/LazyCodeEditor"; -import { editorSQLModes } from "components/editorComponents/CodeEditor/sql/config"; +import { isSqlMode } from "components/editorComponents/CodeEditor/sql/config"; class DynamicTextField extends React.Component< BaseFieldProps & @@ -28,15 +28,27 @@ class DynamicTextField extends React.Component< height?: string; disabled?: boolean; evaluatedPopUpLabel?: string; + AIAssisted?: boolean; } > { render() { + const isSQLMode = this.props.mode && isSqlMode(this.props.mode); + const isGraphQLMode = + this.props.mode === EditorModes.GRAPHQL || + this.props.mode === EditorModes.GRAPHQL_WITH_BINDING; + const isJavaScriptMode = this.props.mode === EditorModes.JAVASCRIPT; + const isJSONMode = + this.props.mode === EditorModes.JSON || + this.props.mode === EditorModes.JSON_WITH_BINDING; + const editorProps = { mode: this.props.mode || EditorModes.TEXT_WITH_BINDING, tabBehaviour: this.props.tabBehaviour || TabBehaviour.INPUT, theme: this.props.theme || EditorTheme.LIGHT, size: this.props.size || EditorSize.COMPACT, - AIAssisted: this.props.mode === editorSQLModes.POSTGRESQL_WITH_BINDING, + AIAssisted: + this.props.AIAssisted ?? + (isSQLMode || isGraphQLMode || isJavaScriptMode || isJSONMode), }; return ( diff --git a/app/client/src/ee/actions/aiAssistantActions.ts b/app/client/src/ee/actions/aiAssistantActions.ts new file mode 100644 index 000000000000..599508a6e28b --- /dev/null +++ b/app/client/src/ee/actions/aiAssistantActions.ts @@ -0,0 +1 @@ +export * from "ce/actions/aiAssistantActions"; diff --git a/app/client/src/ee/components/editorComponents/GPT/AISidePanel.tsx b/app/client/src/ee/components/editorComponents/GPT/AISidePanel.tsx new file mode 100644 index 000000000000..1223c9e2b74b --- /dev/null +++ b/app/client/src/ee/components/editorComponents/GPT/AISidePanel.tsx @@ -0,0 +1 @@ +export * from "ce/components/editorComponents/GPT/AISidePanel"; diff --git a/app/client/src/ee/components/editorComponents/GlobalAISidePanel/index.tsx b/app/client/src/ee/components/editorComponents/GlobalAISidePanel/index.tsx new file mode 100644 index 000000000000..0bf26cd8fc32 --- /dev/null +++ b/app/client/src/ee/components/editorComponents/GlobalAISidePanel/index.tsx @@ -0,0 +1 @@ +export * from "ce/components/editorComponents/GlobalAISidePanel"; diff --git a/app/client/src/ee/selectors/aiAssistantSelectors.ts b/app/client/src/ee/selectors/aiAssistantSelectors.ts new file mode 100644 index 000000000000..6d86825cc6c7 --- /dev/null +++ b/app/client/src/ee/selectors/aiAssistantSelectors.ts @@ -0,0 +1 @@ +export * from "ce/selectors/aiAssistantSelectors"; diff --git a/app/client/src/pages/AppIDE/layouts/AnimatedLayout.tsx b/app/client/src/pages/AppIDE/layouts/AnimatedLayout.tsx index 312957bdeb05..920abdce5f5f 100644 --- a/app/client/src/pages/AppIDE/layouts/AnimatedLayout.tsx +++ b/app/client/src/pages/AppIDE/layouts/AnimatedLayout.tsx @@ -15,6 +15,7 @@ import MainPane from "./routers/MainPane"; import RightPane from "./routers/RightPane"; import { Areas } from "./constants"; import { ProtectedCallout } from "../components/ProtectedCallout"; +import { GlobalAISidePanel } from "ee/components/editorComponents/GlobalAISidePanel"; function GitProtectedBranchCallout() { const isGitModEnabled = useGitModEnabled(); @@ -61,6 +62,7 @@ function AnimatedLayout() { + diff --git a/app/client/src/pages/AppIDE/layouts/StaticLayout.tsx b/app/client/src/pages/AppIDE/layouts/StaticLayout.tsx index b30d56595853..798a4aaab804 100644 --- a/app/client/src/pages/AppIDE/layouts/StaticLayout.tsx +++ b/app/client/src/pages/AppIDE/layouts/StaticLayout.tsx @@ -19,6 +19,7 @@ import { GridContainer, LayoutContainer, } from "IDE/Components/LayoutComponents"; +import { GlobalAISidePanel } from "ee/components/editorComponents/GlobalAISidePanel"; function GitProtectedBranchCallout() { const isGitModEnabled = useGitModEnabled(); @@ -65,6 +66,7 @@ export const StaticLayout = React.memo(() => { +