diff --git a/.cursor/rules/README.md b/.cursor/rules/README.md index 4ebac2ac29ea..a9a0df038164 100644 --- a/.cursor/rules/README.md +++ b/.cursor/rules/README.md @@ -4,6 +4,9 @@ This directory contains the rules that Cursor AI uses to validate and improve co ## Rule Categories +- **Project guide** (always applied): + - `appsmith-project-guide.mdc`: Project overview, tech stack, EE/CE architecture, code style, conventions, testing, and common commands. Sourced from the project's `cursorrules` file. + - **commit/**: Rules for validating commit messages and pull requests - `semantic-pr.md`: Guidelines for semantic pull request titles diff --git a/.cursor/rules/appsmith-project-guide.mdc b/.cursor/rules/appsmith-project-guide.mdc new file mode 100644 index 000000000000..c59aee0f8d9d --- /dev/null +++ b/.cursor/rules/appsmith-project-guide.mdc @@ -0,0 +1,328 @@ +--- +description: Project overview, tech stack, EE/CE architecture, code style, and conventions for Appsmith +globs: +alwaysApply: true +--- + +# Appsmith Project Guide + +Use this guide when working in the Appsmith codebase. Follow the conventions and patterns described below. + +## Project Overview + +Appsmith is a low-code platform for building internal tools. It's a monorepo with three main components: +- **Frontend (Client)**: React + Redux application at `app/client/` +- **Backend (Server)**: Spring Boot Java application at `app/server/` +- **RTS (Realtime Server)**: Node.js Express server at `app/client/packages/rts/` + +## Tech Stack + +### Frontend +- **Framework**: React 17.0.2 with TypeScript 5.5.4 +- **State Management**: Redux + Redux Saga, Redux Toolkit 2.4.0 +- **Styling**: Styled Components 5.3.6, Tailwind CSS 3.3.3, SASS +- **Build Tool**: Webpack 5.98.0 +- **Testing**: Jest (unit), Cypress 13.13.0 (E2E) +- **Package Manager**: Yarn 3.5.1 (Workspaces) +- **Key Libraries**: Blueprint.js, React Router 5.x, React DnD, CodeMirror 5.x, ECharts + +### Backend +- **Framework**: Spring Boot 3.3.13 +- **Language**: Java 17 +- **Build Tool**: Maven +- **Database**: MongoDB (reactive), Redis (caching) +- **Key Libraries**: Spring WebFlux, Spring Security, GraphQL Java, Project Reactor + +### RTS +- **Platform**: Node.js 20.11.1 +- **Framework**: Express.js +- **Language**: TypeScript + +## Directory Structure + +``` +app/ +├── client/ # Frontend React application +│ ├── src/ +│ │ ├── ce/ # Community Edition code +│ │ └── ee/ # Enterprise Edition code +│ ├── cypress/ # E2E tests +│ └── packages/ # Monorepo workspaces +│ ├── ast/ # AST parsing (@shared/ast) +│ ├── dsl/ # Domain-specific language (@shared/dsl) +│ ├── rts/ # Real-time server +│ │ └── src/ +│ │ ├── ce/ # RTS Community Edition +│ │ └── ee/ # RTS Enterprise Edition +│ ├── design-system/ # UI components (@appsmith/wds) +│ ├── icons/ # Icon library +│ └── utils/ # Shared utilities +├── server/ # Backend Spring Boot +│ ├── appsmith-server/ +│ │ └── src/main/java/com/appsmith/server/ +│ │ ├── services/ce/ # CE service implementations +│ │ ├── services/ # Wrapper services (extend CE) +│ │ └── ... +│ ├── appsmith-plugins/ # Plugin framework (28+ plugins) +│ ├── appsmith-interfaces/ # Plugin interfaces +│ ├── appsmith-git/ # Git integration +│ └── reactive-caching/ # Caching layer +└── util/ # Shared utilities +``` + +## EE vs CE Architecture (IMPORTANT) + +Appsmith maintains parallel folder structures for Enterprise Edition (EE) and Community Edition (CE). **This is a critical pattern to understand.** + +### Folder Structure + +Both editions mirror identical directory structures: +``` +ce/ ee/ +├── actions/ ├── actions/ +├── api/ ├── api/ +├── components/ ├── components/ +├── constants/ ├── constants/ +├── entities/ ├── entities/ +├── hooks/ ├── hooks/ +├── pages/ ├── pages/ +├── reducers/ ├── reducers/ +├── sagas/ ├── sagas/ +├── selectors/ ├── selectors/ +├── services/ ├── services/ +├── utils/ ├── utils/ +└── workers/ +``` + +### When to Create Files in EE vs CE + +#### **ALWAYS prefer EE folder** when creating new files for: +1. **New Features** - Put new feature code in `ee/` first +2. **Premium/Enterprise Features** - SSO, audit logs, advanced RBAC, license-gated features +3. **Enhanced Components** - UI improvements or additional functionality over CE +4. **Organization/Permission Features** - Role-based access control, team features +5. **Advanced Selectors** - Permission checks, organization-specific logic + +#### **Use CE folder** only for: +1. **Core Infrastructure** - Base components, hooks, utilities needed by both editions +2. **Bug Fixes to Existing CE Code** - When fixing bugs in existing CE files +3. **Shared Types/Interfaces** - Type definitions used by both editions +4. **Basic UI Components** - Standard widgets without enterprise features + +### The Re-export Pattern + +**When EE doesn't need to customize CE code, it re-exports from CE:** + +```typescript +// ee/actions/applicationActions.ts +export * from "ce/actions/applicationActions"; + +// ee/AppRouter.tsx +export * from "ce/AppRouter"; +import { default as CE_AppRouter } from "ce/AppRouter"; +export default CE_AppRouter; + +// ee/hooks/useCreateDatasource.ts +export * from "ce/PluginActionEditor/hooks/useCreateDatasource"; +``` + +**When EE needs to extend or override CE code:** + +```typescript +// ee/components/MyComponent/index.tsx +import { BaseComponent } from "ce/components/MyComponent"; + +export function MyComponent(props) { + // Enhanced EE implementation + // Can use BaseComponent internally or completely override +} +``` + +### Import Conventions + +**Always use absolute path aliases, never relative paths across editions:** + +```typescript +// CORRECT - absolute imports +import { Component } from "ce/components/Button"; +import { enhancedHook } from "ee/hooks/useFeature"; + +// WRONG - relative imports crossing editions +import { Component } from "../../ce/components/Button"; +``` + +### Backend EE/CE Pattern (Java) + +**Interface-based inheritance:** + +```java +// 1. CE Interface (in services/ce/) +public interface UserServiceCE { + Mono findById(String id); +} + +// 2. CE Implementation (in services/ce/) +public class UserServiceCEImpl implements UserServiceCE { + // Base implementation +} + +// 3. Wrapper Interface (in services/) - no CE suffix +public interface UserService extends UserServiceCE {} + +// 4. Wrapper Implementation (in services/) - extends CE +@Service +public class UserServiceImpl extends UserServiceCEImpl implements UserService { + // Can override or add EE-specific methods +} +``` + +### Pre-push Hook Protection + +The pre-push hook prevents accidentally pushing EE code to the CE repository: +- Checks for files in `app/client/src/ee` pattern +- Blocks pushes to CE repo (appsmithorg/appsmith.git) if EE files are included +- Allows pushes to EE repo (appsmith-ee.git) + +### Feature Flags + +EE features are often gated by feature flags: +```typescript +if (FEATURE_FLAG.license_gac_enabled) { + // EE-only functionality +} +``` + +### Key Principles + +1. **EE Extends CE** - EE adds to CE, never replaces core functionality +2. **Mirror Structure** - EE mirrors CE directory structure exactly +3. **Re-export When Unchanged** - If EE doesn't modify, just re-export from CE +4. **Single Source of Truth** - CE contains the base implementation +5. **No Breaking Changes** - CE functionality remains untouched by EE additions +6. **New Files Go to EE** - Default to EE folder for new feature development + +## Code Style & Conventions + +### TypeScript/JavaScript (Frontend) + +**ESLint Configuration** (`.eslintrc.base.json`): +- Parser: `@typescript-eslint/parser` +- Extends: react/recommended, @typescript-eslint/recommended, cypress/recommended, prettier +- Strict TypeScript mode enabled + +**Key Rules**: +- Use ESLint with auto-fix: `eslint --fix --cache` +- Run Prettier on CSS, MD, JSON files +- Avoid circular dependencies (checked by CI) +- Use lazy loading for CodeEditor and heavy components +- Import restrictions: avoid direct CodeMirror, lottie-web imports + +**Prettier Configuration** (`.prettierrc`): +- printWidth: 80, tabWidth: 2, useTabs: false, semi: true, singleQuote: false, trailingComma: "all", arrowParens: "always" + +### Java (Backend) + +**Spotless/Google Java Format**: +- Uses Palantir's Google Java Format +- Import ordering: java → javax → others → static imports +- Automatic unused import removal +- Run via Maven: `mvn spotless:apply` + +**POM Formatting**: +- Uses sortPom with 4-space indentation +- Sorted dependencies and plugins + +### EditorConfig + +**Root Settings** (`.editorconfig`): +- Charset: UTF-8, Line endings: LF +- Default indent: 2 spaces; Java/POM/Python/SQL: 4 spaces +- Insert final newline: true +- Trim trailing whitespace: true (except Markdown) + +## Pre-commit Hooks (Husky) + +Hooks are in `app/client/.husky/`: + +### pre-commit +1. **Server changes** (`app/server/**`): Runs `mvn spotless:apply` +2. **Client changes** (`app/client/**`): Runs `npx lint-staged` + +### lint-staged (`.lintstagedrc.json`) +- `src/**/*.{js,ts,tsx}`: eslint --fix --cache +- `src/**/*.{css,md,json}`: prettier --write --cache +- `cypress/**/*.{js,ts}`: cypress eslint +- `packages/**/*.{js,ts,tsx}`: eslint --fix --cache +- `packages/**/*.{css,mdx,json}`: prettier --write --cache +- All staged: gitleaks protect --staged + +### pre-push +- Prevents pushing EE files to CE repository (checks `app/client/src/ee`) + +## CI/CD Requirements + +### Quality Checks (Every PR) +- **Server**: `mvn spotless:check`, unit tests +- **Client**: `yarn lint:ci`, `yarn prettier:ci`, `yarn test:unit:ci`, cyclic dependency check (dpdm) + +### Build Pipeline +Server build (Maven), Client build (Webpack), RTS build, Docker image, Cypress E2E (60 parallel jobs) + +### Branch Strategy +- `master`: Development, nightly builds +- `release`: Stable release +- `pg`: PostgreSQL variant +- Feature branches: PR-based testing + +## Testing Guidelines + +- **Unit**: Frontend Jest + React Testing Library; Backend JUnit + Spring Test. Run: `yarn test:unit` (client), `mvn test` (server) +- **E2E**: Cypress 13.13.0 in `app/client/cypress/` +- **Type check**: `yarn check-types` or `yarn tsc --noEmit` + +## Common Commands + +### Frontend (from `app/client/`) +- `yarn install`, `yarn start`, `yarn build` +- `yarn test:unit`, `yarn lint`, `yarn prettier`, `yarn check-types` + +### Backend (from `app/server/`) +- `mvn clean install`, `mvn spotless:apply`, `mvn spotless:check`, `mvn test` + +## Security + +- **Gitleaks**: Scans staged files for secrets +- Never commit `.env` with real credentials +- Use environment variables for sensitive config + +## Plugin Development + +Backend plugins in `app/server/appsmith-plugins/`: Maven modules implementing `appsmith-interfaces`; 28+ plugins (databases, APIs, AI services). + +## AI Integration + +- AI plugins: anthropicPlugin (Claude), openAiPlugin, googleAiPlugin +- RTS uses LlamaIndex for RAG + +## Key Patterns + +1. **Reactive Programming**: Spring WebFlux + Project Reactor +2. **Plugin Architecture**: Extensible data source connectors +3. **Feature Flags**: Dynamic feature management +4. **Multi-tenant**: Organizations and workspaces +5. **Real-time**: WebSocket via RTS + +## File Naming Conventions + +- **React Components**: PascalCase (e.g., `Button.tsx`, `UserProfile.tsx`) +- **Utilities/Hooks**: camelCase (e.g., `useAuth.ts`, `formatDate.ts`) +- **Tests**: `*.test.ts`, `*.test.tsx`, or `*.spec.ts` +- **Styles**: Component-colocated or in `styles/` + +## Import Order (Frontend) + +1. React/React-related +2. Third-party libraries +3. Internal modules (absolute paths) +4. Relative imports +5. Style imports diff --git a/.cursor/rules/index.mdc b/.cursor/rules/index.mdc index c09ccef5f156..5a12122e24c2 100644 --- a/.cursor/rules/index.mdc +++ b/.cursor/rules/index.mdc @@ -35,6 +35,10 @@ This is the main entry point for Cursor AI rules for the Appsmith codebase. Thes ## Available Rules +### 0. [Appsmith Project Guide](mdc:appsmith-project-guide.mdc) + +Project overview, tech stack, EE/CE architecture, code style, and conventions. **Always applied** so Cursor follows Appsmith patterns (directory structure, EE vs CE, imports, Java backend pattern, testing, and common commands). + ### 1. [Semantic PR Validator](mdc:semantic_pr_validator.mdc) Ensures pull request titles follow the Conventional Commits specification. diff --git a/.gitignore b/.gitignore index a10c91d1b285..1c7723e93b74 100644 --- a/.gitignore +++ b/.gitignore @@ -45,4 +45,20 @@ mongo-data** # ignore the task file as it will be different for different project implementations. TASKS.md -mongodb* \ No newline at end of file +mongodb* + +# Security audit document +GitInMemoryAudit.pdf + +# Diagnostic logs +*.log +appsmith-diag-* + +# Security audit/review documents +SECURITY_*.md + +# AI tool configs +.cursor/plans/ +cursorrules +CLAUDE.md +.claude/ diff --git a/app/client/package.json b/app/client/package.json index 50e28f273169..211c5386eb23 100644 --- a/app/client/package.json +++ b/app/client/package.json @@ -186,6 +186,7 @@ "react-helmet": "^5.2.1", "react-hook-form": "^7.28.0", "react-json-view": "^1.21.3", + "react-markdown": "^9.0.1", "react-media-recorder": "^1.6.1", "react-modal": "^3.15.1", "react-page-visibility": "^7.0.0", @@ -211,6 +212,7 @@ "redux": "^4.0.1", "redux-form": "^8.2.6", "redux-saga": "^1.1.3", + "remark-gfm": "^4.0.0", "remixicon-react": "^1.0.0", "reselect": "^4.0.0", "resize-observer-polyfill": "^1.5.1", diff --git a/app/client/src/ce/actions/aiAssistantActions.ts b/app/client/src/ce/actions/aiAssistantActions.ts new file mode 100644 index 000000000000..691c98ffeaa1 --- /dev/null +++ b/app/client/src/ce/actions/aiAssistantActions.ts @@ -0,0 +1,101 @@ +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; + databaseSchema?: string; + datasourceType?: 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; + propertyPath?: string; +} + +export const updateAIContext = ( + context: AIEditorContextPayload, +): ReduxAction<{ context: AIEditorContextPayload }> => ({ + type: ReduxActionTypes.UPDATE_AI_CONTEXT, + payload: { context }, +}); + +// Combined action: updates context and opens panel +export const openAIPanelWithContext = ( + context: AIEditorContextPayload, +): ReduxAction<{ context: AIEditorContextPayload }> => ({ + type: ReduxActionTypes.OPEN_AI_PANEL_WITH_CONTEXT, + payload: { context }, +}); diff --git a/app/client/src/ce/api/OrganizationApi.ts b/app/client/src/ce/api/OrganizationApi.ts index 473784fcc4c4..9b1d924854b7 100644 --- a/app/client/src/ce/api/OrganizationApi.ts +++ b/app/client/src/ce/api/OrganizationApi.ts @@ -20,6 +20,57 @@ export interface UpdateOrganizationConfigRequest { apiConfig?: AxiosRequestConfig; } +export interface OllamaModel { + name: string; + size?: number; + details?: { + parameter_size?: string; + quantization_level?: string; + }; +} + +export interface AIConfigResponse { + isAIAssistantEnabled: boolean; + provider: string | null; + hasClaudeApiKey: boolean; + hasOpenaiApiKey: boolean; + hasCopilotApiKey: boolean; + localLlmUrl?: string; + localLlmContextSize?: number; + localLlmModel?: string; + copilotEndpoint?: string; + hasAzureOpenaiApiKey: boolean; + azureOpenaiEndpoint?: string; + azureOpenaiDeploymentName?: string; + azureOpenaiApiVersion?: string; + azureOpenaiMaxCompletionTokens?: number; + claudeModel?: string; + claudeBaseUrl?: string; + openaiModel?: string; + openaiBaseUrl?: string; +} + +export interface AIConfigRequest { + claudeApiKey?: string; + openaiApiKey?: string; + copilotApiKey?: string; + copilotEndpoint?: string; + azureOpenaiApiKey?: string; + azureOpenaiEndpoint?: string; + azureOpenaiDeploymentName?: string; + azureOpenaiApiVersion?: string; + azureOpenaiMaxCompletionTokens?: number; + localLlmUrl?: string; + localLlmContextSize?: number; + localLlmModel?: string; + claudeModel?: string; + claudeBaseUrl?: string; + openaiModel?: string; + openaiBaseUrl?: string; + provider: string; + isAIAssistantEnabled: boolean; +} + export type FetchMyOrganizationsResponse = ApiResponse<{ organizations: Organization[]; }>; @@ -59,6 +110,56 @@ export class OrganizationApi extends Api { > { return Api.get(`${OrganizationApi.meUrl}/organizations`); } + + static async getAIConfig(): Promise< + AxiosPromise> + > { + return Api.get(`${OrganizationApi.tenantsUrl}/ai-config`); + } + + static async updateAIConfig( + request: AIConfigRequest, + ): Promise>> { + return Api.put(`${OrganizationApi.tenantsUrl}/ai-config`, request); + } + + static async testLlmConnection( + url: string, + ): Promise>>> { + return Api.post(`${OrganizationApi.tenantsUrl}/ai-config/test-connection`, { + url, + }); + } + + static async testApiKey( + provider: string, + apiKey?: string, + endpoint?: string, + deploymentName?: string, + apiVersion?: string, + baseUrl?: string, + model?: string, + ): Promise>>> { + return Api.post(`${OrganizationApi.tenantsUrl}/ai-config/test-api-key`, { + provider, + apiKey, + endpoint, + deploymentName, + apiVersion, + baseUrl, + model, + }); + } + + static async fetchLlmModels( + url: string, + ): Promise< + AxiosPromise> + > { + return Api.post(`${OrganizationApi.tenantsUrl}/ai-config/fetch-models`, { + url, + }); + } } export default OrganizationApi; diff --git a/app/client/src/ce/api/UserApi.tsx b/app/client/src/ce/api/UserApi.tsx index c6a2e1f506c2..21ca69c4005a 100644 --- a/app/client/src/ce/api/UserApi.tsx +++ b/app/client/src/ce/api/UserApi.tsx @@ -196,6 +196,33 @@ export class UserApi extends Api { static async resendEmailVerification(email: string) { return Api.post(UserApi.resendEmailVerificationURL, { email }); } + + static async requestAIResponse( + provider: string, + prompt: string, + context: { + functionName?: string; + cursorLineNumber?: number; + functionString?: string; + mode?: string; + currentValue?: string; + }, + conversationHistory?: Array<{ role: string; content: string }>, + ): Promise< + AxiosPromise> + > { + return Api.post( + `${UserApi.usersURL}/ai-assistant/request`, + { + provider, + prompt, + context, + conversationHistory, + }, + undefined, + { timeout: 180000 }, // 180s - LLM responses can be slow (model loading) + ); + } } export default UserApi; 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..76f12c018049 --- /dev/null +++ b/app/client/src/ce/components/editorComponents/GPT/AISidePanel.tsx @@ -0,0 +1,290 @@ +import React, { useState, useCallback, useMemo, useEffect } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import styled from "styled-components"; +import { Button, Icon, Text, Tooltip } from "@appsmith/ads"; +import type CodeMirror from "codemirror"; +import { + fetchAIResponse, + clearAIResponse, +} from "ee/actions/aiAssistantActions"; +import { + getAILastResponse, + getIsAILoading, + getAIError, +} from "ee/selectors/aiAssistantSelectors"; +import { getAIContext } from "./trigger"; +import type { TEditorModes } from "components/editorComponents/CodeEditor/EditorConfig"; +import { + slideIn, + PanelHeader, + HeaderTitle, + PanelContent, + InputSection, + PromptInput, + InputActions, + SendButton, + QuickActionsSection, + QuickActionsLabel, + QuickActionsGrid, + QuickActionChip, + ContextSection, + ContextLabel, + ResponseSection, + LoadingState, + LoadingText, + ErrorState, + EmptyState, + EmptyStateText, + getModeLabel, + QUICK_ACTIONS, + AIMarkdownRenderer, +} from "./shared"; + +// ============================================================================ +// Types +// ============================================================================ + +export interface AISidePanelProps { + isOpen: boolean; + onClose: () => void; + currentValue: string; + mode: TEditorModes; + editor: CodeMirror.Editor; +} + +// ============================================================================ +// Panel-specific styled components +// ============================================================================ + +const PanelContainer = styled.div<{ isOpen: boolean }>` + display: ${(props) => (props.isOpen ? "flex" : "none")}; + flex-direction: column; + width: 380px; + min-width: 320px; + max-width: 480px; + height: 100%; + background: var(--ads-v2-color-bg); + border-left: 1px solid var(--ads-v2-color-border); + animation: ${slideIn} 0.25s ease-out; + position: relative; + overflow: hidden; +`; + +// ============================================================================ +// Main Component +// ============================================================================ + +export function AISidePanel(props: AISidePanelProps) { + const { currentValue, editor, isOpen, mode, onClose } = props; + + const dispatch = useDispatch(); + const [prompt, setPrompt] = useState(""); + const lastResponse = useSelector(getAILastResponse); + const isLoading = useSelector(getIsAILoading); + const error = useSelector(getAIError); + + useEffect(() => { + dispatch(clearAIResponse()); + setPrompt(""); + }, [mode, dispatch]); + + const contextInfo = useMemo(() => { + if (!editor) return null; + + const cursorPosition = editor.getCursor(); + const context = getAIContext({ + cursorPosition, + editor, + }); + + return { + functionName: context.functionName, + lineNumber: cursorPosition.line + 1, + mode: getModeLabel(mode), + }; + }, [editor, mode]); + + const handleSend = useCallback(() => { + if (!prompt.trim() || !editor) return; + + const cursorPosition = editor.getCursor(); + const context = getAIContext({ + cursorPosition, + editor, + }); + + dispatch( + fetchAIResponse({ + prompt: prompt.trim(), + context: { + ...context, + currentValue, + mode, + }, + }), + ); + }, [prompt, editor, mode, currentValue, dispatch]); + + const handleQuickAction = useCallback( + (actionPrompt: string) => { + setPrompt(actionPrompt); + + setTimeout(() => { + if (!editor) return; + + const cursorPosition = editor.getCursor(); + const context = getAIContext({ + cursorPosition, + editor, + }); + + dispatch( + fetchAIResponse({ + prompt: actionPrompt, + context: { + ...context, + currentValue, + mode, + }, + }), + ); + }, 100); + }, + [editor, mode, currentValue, dispatch], + ); + + const handleClearChat = useCallback(() => { + dispatch(clearAIResponse()); + setPrompt(""); + }, [dispatch]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + handleSend(); + } + }, + [handleSend], + ); + + if (!isOpen) return null; + + return ( + + + + + AI Assistant + + + -
- { - 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, + 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 +1916,9 @@ const mapStateToProps = (state: DefaultRootState, props: EditorProps) => { datasourceTableKeys: getAllDatasourceTableKeys(state, props.dataTreePath), installedLibraries: selectInstalledLibraries(state), focusedProperty: getFocusablePropertyPaneField(state), + hasAIApiKey: getHasAIApiKey(state), + isAIConfigLoaded: getIsAIEnabled(state) !== undefined, + isAIPanelOpen: getIsAIPanelOpen(state), }; }; @@ -1898,6 +1932,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..55d5761c0ed3 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 & @@ -31,12 +31,18 @@ class DynamicTextField extends React.Component< } > { 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 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: isSQLMode || isGraphQLMode || isJavaScriptMode, }; 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..58a1dc275d8e --- /dev/null +++ b/app/client/src/ee/components/editorComponents/GPT/AISidePanel.tsx @@ -0,0 +1,387 @@ +import React, { + useState, + useCallback, + useMemo, + useEffect, + useRef, +} from "react"; +import { useDispatch, useSelector } from "react-redux"; +import styled from "styled-components"; +import { Button, Icon, Text, Tooltip } from "@appsmith/ads"; +import type CodeMirror from "codemirror"; +import { + fetchAIResponse, + clearAIResponse, + type AIMessage, +} from "ee/actions/aiAssistantActions"; +import { + getAIMessages, + getIsAILoading, + getAIError, +} from "ee/selectors/aiAssistantSelectors"; +import { getAIContext } from "./trigger"; +import type { TEditorModes } from "components/editorComponents/CodeEditor/EditorConfig"; +import { + slideIn, + fadeIn, + PanelHeader, + HeaderTitle, + PanelContent, + InputSection, + PromptInput, + InputActions, + SendButton, + QuickActionsSection, + QuickActionsLabel, + QuickActionsGrid, + QuickActionChip, + ContextSection, + ContextLabel, + ResponseSection, + LoadingState, + LoadingText, + ErrorState, + EmptyState, + EmptyStateText, + getModeLabel, + QUICK_ACTIONS, + AIMarkdownRenderer, +} from "ee/components/editorComponents/GPT/shared"; + +// ============================================================================ +// Types +// ============================================================================ + +export interface AISidePanelProps { + isOpen: boolean; + onClose: () => void; + currentValue: string; + mode: TEditorModes; + editor: CodeMirror.Editor; +} + +// ============================================================================ +// EE-specific styled components +// ============================================================================ + +const PanelContainer = styled.div<{ isOpen: boolean }>` + display: ${(props) => (props.isOpen ? "flex" : "none")}; + flex-direction: column; + width: 380px; + min-width: 320px; + max-width: 480px; + height: 100%; + background: var(--ads-v2-color-bg); + border-left: 1px solid var(--ads-v2-color-border); + animation: ${slideIn} 0.25s ease-out; + position: relative; + overflow: hidden; +`; + +const ChatMessages = styled.div` + display: flex; + flex-direction: column; + gap: 16px; + padding-bottom: 16px; +`; + +const MessageBubble = styled.div<{ isUser: boolean }>` + display: flex; + flex-direction: column; + gap: 8px; + max-width: 95%; + align-self: ${(props) => (props.isUser ? "flex-end" : "flex-start")}; + animation: ${fadeIn} 0.2s ease-out; +`; + +const MessageHeader = styled.div<{ isUser: boolean }>` + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--ads-v2-color-fg-muted); + ${(props) => props.isUser && "justify-content: flex-end;"} +`; + +const MessageContent = styled.div<{ isUser: boolean }>` + padding: 12px 14px; + border-radius: 12px; + font-size: 13px; + line-height: 1.5; + ${(props) => + props.isUser + ? ` + background: var(--ads-v2-color-bg-brand); + color: white; + border-bottom-right-radius: 4px; + ` + : ` + background: var(--ads-v2-color-bg-subtle); + color: var(--ads-v2-color-fg); + border-bottom-left-radius: 4px; + `} +`; + +const ClearChatButton = styled(Button)` + opacity: 0.7; + &:hover { + opacity: 1; + } +`; + +// ============================================================================ +// Main Component +// ============================================================================ + +export function AISidePanel(props: AISidePanelProps) { + const { currentValue, editor, isOpen, mode, onClose } = props; + + const dispatch = useDispatch(); + const [prompt, setPrompt] = useState(""); + const messages = useSelector(getAIMessages); + const isLoading = useSelector(getIsAILoading); + const error = useSelector(getAIError); + const messagesEndRef = useRef(null); + + useEffect(() => { + dispatch(clearAIResponse()); + setPrompt(""); + }, [mode, dispatch]); + + useEffect(() => { + if (messagesEndRef.current) { + messagesEndRef.current.scrollIntoView({ behavior: "smooth" }); + } + }, [messages]); + + const contextInfo = useMemo(() => { + if (!editor) return null; + + const cursorPosition = editor.getCursor(); + const context = getAIContext({ + cursorPosition, + editor, + }); + + return { + functionName: context.functionName, + lineNumber: cursorPosition.line + 1, + mode: getModeLabel(mode), + }; + }, [editor, mode]); + + const handleClearChat = useCallback(() => { + dispatch(clearAIResponse()); + setPrompt(""); + }, [dispatch]); + + const handleSend = useCallback(() => { + if (!prompt.trim() || !editor) return; + + const cursorPosition = editor.getCursor(); + const context = getAIContext({ + cursorPosition, + editor, + }); + + dispatch( + fetchAIResponse({ + prompt: prompt.trim(), + context: { + ...context, + currentValue, + mode, + }, + }), + ); + }, [prompt, editor, mode, currentValue, dispatch]); + + const handleQuickAction = useCallback( + (actionPrompt: string) => { + setPrompt(actionPrompt); + + setTimeout(() => { + if (!editor) return; + + const cursorPosition = editor.getCursor(); + const context = getAIContext({ + cursorPosition, + editor, + }); + + dispatch( + fetchAIResponse({ + prompt: actionPrompt, + context: { + ...context, + currentValue, + mode, + }, + }), + ); + }, 100); + }, + [editor, mode, currentValue, dispatch], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + handleSend(); + } + }, + [handleSend], + ); + + if (!isOpen) return null; + + return ( + + + + + AI Assistant + +
+ {messages.length > 0 && ( + + + + )} + +
+
+ + + + setPrompt(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="What do you want to do? (⌘+Enter to send)" + value={prompt} + /> + + + {prompt.length > 0 && `${prompt.length} chars`} + + + Send + + + + + + Quick Actions + + {QUICK_ACTIONS.map((action) => ( + handleQuickAction(action.prompt)} + title={action.prompt} + type="button" + > + + {action.label} + + ))} + {messages.length > 0 && ( + + + Clear Chat + + )} + + + + {contextInfo && ( + + + + Context: {contextInfo.mode} + {contextInfo.functionName && ( + <> + {" · "} + {contextInfo.functionName} + + )} + {" · "}Line {contextInfo.lineNumber} + + + )} + + + {messages.length === 0 && !isLoading && !error && ( + + + + Ask me anything about your code. I can explain, fix errors, + refactor, or help you write new functionality. + + + )} + + {messages.length > 0 && ( + + {messages.map((message: AIMessage, msgIndex: number) => ( + + + + {message.role === "user" ? "You" : "AI Assistant"} + + {message.role === "user" ? ( + {message.content} + ) : ( + + )} + + ))} +
+ + )} + + {isLoading && ( + + + + Thinking... + + + )} + + {error && !isLoading && {error}} + + + + ); +} + +export default AISidePanel; diff --git a/app/client/src/ee/components/editorComponents/GPT/AskAIButton.tsx b/app/client/src/ee/components/editorComponents/GPT/AskAIButton.tsx index 5f59b4402c9c..425dbe6fb5b3 100644 --- a/app/client/src/ee/components/editorComponents/GPT/AskAIButton.tsx +++ b/app/client/src/ee/components/editorComponents/GPT/AskAIButton.tsx @@ -1 +1,69 @@ -export * from "ce/components/editorComponents/GPT/AskAIButton"; +import React from "react"; +import styled from "styled-components"; +import { Button, Tooltip } from "@appsmith/ads"; +import type { + FieldEntityInformation, + TEditorModes, +} from "components/editorComponents/CodeEditor/EditorConfig"; +import { useSelector } from "react-redux"; +import { getIsAIEnabled } from "ee/selectors/aiAssistantSelectors"; + +interface AskAIButtonProps { + mode: TEditorModes; + onClick: () => void; + entity: FieldEntityInformation; +} + +const StyledButton = styled(Button)` + background: linear-gradient( + 135deg, + var(--ads-v2-color-bg) 0%, + var(--ads-v2-color-bg-subtle) 100% + ); + border: 1px solid var(--ads-v2-color-border); + transition: all 0.2s ease; + + &:hover { + background: var(--ads-v2-color-bg-emphasis); + border-color: var(--ads-v2-color-border-emphasis); + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + } + + &:active { + transform: translateY(0); + } + + .sparkle-icon { + color: var(--ads-v2-color-fg-brand); + } +`; + +const KeyboardHint = styled.span` + font-size: 10px; + color: var(--ads-v2-color-fg-muted); + margin-left: 4px; + opacity: 0.7; +`; + +export function AskAIButton(props: AskAIButtonProps) { + const isAIEnabled = useSelector(getIsAIEnabled); + + if (!isAIEnabled) { + return null; + } + + return ( + + + Ask AI + ⌘I + + + ); +} diff --git a/app/client/src/ee/components/editorComponents/GPT/index.tsx b/app/client/src/ee/components/editorComponents/GPT/index.tsx index 7048ebdd0524..af093e439e93 100644 --- a/app/client/src/ee/components/editorComponents/GPT/index.tsx +++ b/app/client/src/ee/components/editorComponents/GPT/index.tsx @@ -1 +1,95 @@ -export * from "ce/components/editorComponents/GPT"; +import type { CodeEditorExpected } from "components/editorComponents/CodeEditor"; +import type { + FieldEntityInformation, + TEditorModes, +} from "components/editorComponents/CodeEditor/EditorConfig"; +import type { EntityNavigationData } from "entities/DataTree/dataTreeTypes"; +import React from "react"; +import type CodeMirror from "codemirror"; +import styled from "styled-components"; +import { AISidePanel } from "./AISidePanel"; + +// Re-export the new components +export { AISidePanel } from "./AISidePanel"; + +export type AIEditorContext = Partial<{ + functionName: string; + cursorLineNumber: number; + functionString: string; + cursorPosition: CodeMirror.Position; + cursorCoordinates: { + left: number; + top: number; + bottom: number; + }; + mode: string; +}>; + +export interface TAIWrapperProps { + children?: React.ReactNode; + isOpen: boolean; + currentValue: string; + update?: (value: string) => void; + triggerContext?: CodeEditorExpected; + enableAIAssistance: boolean; + dataTreePath?: string; + mode: TEditorModes; + entity: FieldEntityInformation; + entitiesForNavigation: EntityNavigationData; + editor: CodeMirror.Editor; + onOpenChanged: (isOpen: boolean) => void; +} + +// ============================================================================ +// Styled Components - Side Panel Layout +// ============================================================================ + +const LayoutContainer = styled.div` + display: flex; + width: 100%; + height: 100%; + position: relative; +`; + +const EditorSection = styled.div` + flex: 1; + min-width: 0; + height: 100%; + position: relative; +`; + +// ============================================================================ +// AIWindow Component - Now uses side panel layout +// ============================================================================ + +export function AIWindow(props: TAIWrapperProps) { + const { + children, + currentValue, + editor, + enableAIAssistance, + isOpen, + mode, + onOpenChanged, + } = props; + + if (!enableAIAssistance) { + return children as React.ReactElement; + } + + return ( + + {children} + + {editor && ( + onOpenChanged(false)} + /> + )} + + ); +} diff --git a/app/client/src/ee/components/editorComponents/GPT/shared/index.ts b/app/client/src/ee/components/editorComponents/GPT/shared/index.ts new file mode 100644 index 000000000000..333c4d7edb67 --- /dev/null +++ b/app/client/src/ee/components/editorComponents/GPT/shared/index.ts @@ -0,0 +1 @@ +export * from "ce/components/editorComponents/GPT/shared"; diff --git a/app/client/src/ee/components/editorComponents/GPT/trigger.tsx b/app/client/src/ee/components/editorComponents/GPT/trigger.tsx index c313d623a621..111fffdde1fc 100644 --- a/app/client/src/ee/components/editorComponents/GPT/trigger.tsx +++ b/app/client/src/ee/components/editorComponents/GPT/trigger.tsx @@ -1 +1,79 @@ -export * from "ce/components/editorComponents/GPT/trigger"; +import type { TEditorModes } from "components/editorComponents/CodeEditor/EditorConfig"; +import type { FeatureFlags } from "ee/entities/FeatureFlag"; +import type { EntityTypeValue } from "ee/entities/DataTree/types"; + +export const APPSMITH_AI = "Ask AI"; + +export function isAIEnabled( + ff: FeatureFlags, + mode: TEditorModes, + hasApiKey?: boolean, +) { + if (!hasApiKey) { + return false; + } + + const isJavaScriptMode = mode === "javascript"; + const isQueryMode = + mode === "sql" || mode === "graphql" || mode?.includes("sql"); + + return isJavaScriptMode || isQueryMode; +} + +export const isAISlashCommand = (editor: CodeMirror.Editor) => { + const cursor = editor.getCursor(); + const line = editor.getLine(cursor.line); + const textBeforeCursor = line.substring(0, cursor.ch); + + return ( + textBeforeCursor.trim().endsWith("/ask-ai") || + textBeforeCursor.trim().endsWith("/ai") + ); +}; + +export const getAIContext = ({ + cursorPosition, + editor, +}: { + entityType?: EntityTypeValue; + slashIndex?: number; + currentLineValue?: string; + cursorPosition: CodeMirror.Position; + editor: CodeMirror.Editor; +}) => { + const code = editor.getValue(); + const mode = editor.getMode().name; + + const functionName = ""; + let functionString = ""; + + if (mode === "javascript") { + // Use a window around the cursor for context + const lines = code.split("\n"); + const startLine = Math.max(0, cursorPosition.line - 50); + const endLine = Math.min(lines.length, cursorPosition.line + 50); + + functionString = lines.slice(startLine, endLine).join("\n"); + } else if (mode?.includes("sql")) { + const lines = code.split("\n"); + const startLine = Math.max(0, cursorPosition.line - 40); + const endLine = Math.min(lines.length, cursorPosition.line + 40); + + functionString = lines.slice(startLine, endLine).join("\n"); + } else if (mode === "graphql" || mode?.includes("graphql")) { + const lines = code.split("\n"); + const startLine = Math.max(0, cursorPosition.line - 40); + const endLine = Math.min(lines.length, cursorPosition.line + 40); + + functionString = lines.slice(startLine, endLine).join("\n"); + } + + return { + functionName, + cursorLineNumber: cursorPosition.line, + functionString, + mode, + cursorPosition, + cursorCoordinates: editor.cursorCoords(true, "local"), + }; +}; 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..ad3dc1885485 --- /dev/null +++ b/app/client/src/ee/components/editorComponents/GlobalAISidePanel/index.tsx @@ -0,0 +1,2 @@ +export * from "ce/components/editorComponents/GlobalAISidePanel"; +export { default } from "ce/components/editorComponents/GlobalAISidePanel"; diff --git a/app/client/src/ee/pages/AdminSettings/config/ai.tsx b/app/client/src/ee/pages/AdminSettings/config/ai.tsx new file mode 100644 index 000000000000..3c10ce906051 --- /dev/null +++ b/app/client/src/ee/pages/AdminSettings/config/ai.tsx @@ -0,0 +1,18 @@ +import type { AdminConfigType } from "ee/pages/AdminSettings/config/types"; +import { + CategoryType, + SettingCategories, + SettingTypes, +} from "ee/pages/AdminSettings/config/types"; +import AISettings from "pages/AdminSettings/AI"; + +export const config: AdminConfigType = { + type: SettingCategories.AI, + categoryType: CategoryType.ORGANIZATION, + controlType: SettingTypes.PAGE, + canSave: false, + title: "AI Assistant", + icon: "sparkling-filled", + component: AISettings, + isFeatureEnabled: true, +}; diff --git a/app/client/src/ee/reducers/aiAssistantReducer.ts b/app/client/src/ee/reducers/aiAssistantReducer.ts new file mode 100644 index 000000000000..92e237b0ff15 --- /dev/null +++ b/app/client/src/ee/reducers/aiAssistantReducer.ts @@ -0,0 +1,2 @@ +export * from "ce/reducers/aiAssistantReducer"; +export { aiAssistantReducer } from "ce/reducers/aiAssistantReducer"; diff --git a/app/client/src/ee/sagas/AIAssistantSagas.ts b/app/client/src/ee/sagas/AIAssistantSagas.ts new file mode 100644 index 000000000000..23ea33cd68bc --- /dev/null +++ b/app/client/src/ee/sagas/AIAssistantSagas.ts @@ -0,0 +1 @@ +export { default } from "ce/sagas/AIAssistantSagas"; 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/ee/utils/aiSchemaSerializer.ts b/app/client/src/ee/utils/aiSchemaSerializer.ts new file mode 100644 index 000000000000..a81312260487 --- /dev/null +++ b/app/client/src/ee/utils/aiSchemaSerializer.ts @@ -0,0 +1,5 @@ +export { + extractReferencedTableNames, + serializeTable, + serializeDatasourceSchema, +} from "ce/utils/aiSchemaSerializer"; diff --git a/app/client/src/pages/AdminSettings/AI/index.tsx b/app/client/src/pages/AdminSettings/AI/index.tsx new file mode 100644 index 000000000000..7c0795943b8f --- /dev/null +++ b/app/client/src/pages/AdminSettings/AI/index.tsx @@ -0,0 +1,1331 @@ +import React, { useState, useEffect } from "react"; +import { Button, Input, Select, Spinner, Switch, Text } from "@appsmith/ads"; +import styled from "styled-components"; +import OrganizationApi, { + type AIConfigRequest, + type OllamaModel, +} from "ee/api/OrganizationApi"; +import { toast } from "@appsmith/ads"; + +const DEFAULT_AZURE_API_VERSION = "2024-12-01-preview"; +const DEFAULT_AZURE_MAX_COMPLETION_TOKENS = "16384"; +const DEFAULT_CLAUDE_MODEL = "claude-sonnet-4-6"; +const DEFAULT_CLAUDE_BASE_URL = "https://api.anthropic.com"; +const DEFAULT_OPENAI_MODEL = "gpt-4"; +const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com"; + +// Response types (unwrapped by axios interceptors) +interface ExternalReferenceFile { + filename: string; + path: string; +} + +interface AIConfigData { + isAIAssistantEnabled?: boolean; + provider?: string; + hasClaudeApiKey?: boolean; + hasOpenaiApiKey?: boolean; + hasCopilotApiKey?: boolean; + copilotEndpoint?: string; + hasAzureOpenaiApiKey?: boolean; + azureOpenaiEndpoint?: string; + azureOpenaiDeploymentName?: string; + azureOpenaiApiVersion?: string; + azureOpenaiMaxCompletionTokens?: number; + localLlmUrl?: string; + localLlmContextSize?: number; + localLlmModel?: string; + claudeModel?: string; + claudeBaseUrl?: string; + openaiModel?: string; + openaiBaseUrl?: string; + hasExternalReferenceFiles?: boolean; + externalReferenceFiles?: ExternalReferenceFile[]; +} + +interface ApiResponseMeta { + success: boolean; + error?: { message: string }; +} + +interface UnwrappedApiResponse { + responseMeta: ApiResponseMeta; + data: T; +} + +const Wrapper = styled.div` + flex-basis: calc(100% - ${(props) => props.theme.homePage.leftPane.width}px); + padding: var(--ads-v2-spaces-7); + overflow: auto; +`; + +const ContentWrapper = styled.div` + display: flex; + flex-direction: column; + gap: var(--ads-v2-spaces-5); + max-width: 40rem; +`; + +const FieldWrapper = styled.div` + display: flex; + flex-direction: column; + gap: var(--ads-v2-spaces-2); + max-width: 500px; +`; + +const LabelWrapper = styled.div` + margin-bottom: var(--ads-v2-spaces-1); +`; + +const HintText = styled(Text)` + font-style: italic; +`; + +const EnabledSwitch = styled.div` + /* Override switch track color when checked to use green for better visibility */ + input:checked { + background-color: var(--ads-v2-color-green-600) !important; + } + input:checked:hover { + background-color: var(--ads-v2-color-green-700, #047857) !important; + } +`; + +const TestResultBox = styled.div<{ success?: boolean }>` + padding: var(--ads-v2-spaces-4); + border-radius: var(--ads-v2-border-radius); + background: ${(props) => + props.success + ? "var(--ads-v2-color-bg-success)" + : "var(--ads-v2-color-bg-error)"}; + border: 1px solid + ${(props) => + props.success + ? "var(--ads-v2-color-border-success)" + : "var(--ads-v2-color-border-error)"}; +`; + +const TestResultDetails = styled.div` + margin-top: var(--ads-v2-spaces-3); + padding: var(--ads-v2-spaces-3); + background: var(--ads-v2-color-bg-subtle); + border-radius: var(--ads-v2-border-radius); + font-family: monospace; + font-size: 12px; +`; + +const SuggestionList = styled.ul` + margin: var(--ads-v2-spaces-2) 0 0 var(--ads-v2-spaces-4); + padding: 0; +`; + +const ButtonRow = styled.div` + display: flex; + gap: var(--ads-v2-spaces-3); + align-items: center; +`; + +const StepList = styled.div` + display: flex; + flex-direction: column; + gap: var(--ads-v2-spaces-1); + margin-top: var(--ads-v2-spaces-2); +`; + +const STEP_STATUS_COLORS: Record<"success" | "error" | "pending", string> = { + success: "var(--ads-v2-color-fg-success)", + error: "var(--ads-v2-color-fg-error)", + pending: "var(--ads-v2-color-fg-muted)", +}; + +const StepItem = styled.div<{ status: "success" | "error" | "pending" }>` + display: flex; + align-items: center; + gap: var(--ads-v2-spaces-2); + font-size: 13px; + color: ${(props) => STEP_STATUS_COLORS[props.status]}; +`; + +const ErrorMessage = styled.div` + margin-top: var(--ads-v2-spaces-3); + color: var(--ads-v2-color-fg-error); +`; + +interface DiagnosticStep { + name: string; + status: "success" | "error" | "pending"; + detail?: string; +} + +interface TestResult { + success: boolean; + message?: string; + error?: string; + warning?: string; + responseTimeMs?: number; + httpStatus?: number; + host?: string; + port?: number; + resolvedIp?: string; + suggestions?: string[]; + steps?: DiagnosticStep[]; + responsePreview?: string; + testResponse?: string; +} + +const ResponsePreview = styled.div` + margin-top: var(--ads-v2-spaces-3); + padding: var(--ads-v2-spaces-3); + background: var(--ads-v2-color-bg-subtle); + border: 1px solid var(--ads-v2-color-border); + border-radius: var(--ads-v2-border-radius); + font-family: monospace; + font-size: 11px; + white-space: pre-wrap; + word-break: break-all; + max-height: 150px; + overflow-y: auto; +`; + +const ExternalFilesNotice = styled.div` + display: flex; + align-items: flex-start; + gap: var(--ads-v2-spaces-3); + padding: var(--ads-v2-spaces-4); + background: linear-gradient( + 135deg, + var(--ads-v2-color-bg-information) 0%, + var(--ads-v2-color-bg-information-secondary, var(--ads-v2-color-bg-subtle)) + 100% + ); + border: 1px solid var(--ads-v2-color-border-information); + border-radius: var(--ads-v2-border-radius); + margin-bottom: var(--ads-v2-spaces-2); +`; + +const NoticeIcon = styled.span` + font-size: 18px; + line-height: 1; + flex-shrink: 0; +`; + +const NoticeContent = styled.div` + display: flex; + flex-direction: column; + gap: var(--ads-v2-spaces-1); +`; + +const FileList = styled.div` + display: flex; + flex-wrap: wrap; + gap: var(--ads-v2-spaces-2); + margin-top: var(--ads-v2-spaces-1); +`; + +const FileChip = styled.span` + display: inline-flex; + align-items: center; + padding: 2px 8px; + background: var(--ads-v2-color-bg); + border: 1px solid var(--ads-v2-color-border); + border-radius: 4px; + font-family: monospace; + font-size: 11px; + color: var(--ads-v2-color-fg); +`; + +// Context size preset styles +const ContextSizeGroup = styled.div` + display: flex; + gap: 0; + border-radius: 6px; + overflow: hidden; + border: 1px solid var(--ads-v2-color-border); + width: fit-content; +`; + +const ContextPresetButton = styled.button<{ isActive: boolean }>` + padding: 8px 16px; + border: none; + background: ${(props) => + props.isActive + ? "var(--ads-v2-color-bg-emphasis)" + : "var(--ads-v2-color-bg)"}; + color: ${(props) => + props.isActive + ? "var(--ads-v2-color-fg-on-emphasis)" + : "var(--ads-v2-color-fg)"}; + font-family: "SF Mono", Monaco, "Courier New", monospace; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; + border-right: 1px solid var(--ads-v2-color-border); + + &:last-child { + border-right: none; + } + + &:hover:not(:disabled) { + background: ${(props) => + props.isActive + ? "var(--ads-v2-color-bg-emphasis-plus)" + : "var(--ads-v2-color-bg-subtle)"}; + } +`; + +const CustomContextInput = styled.div` + display: flex; + align-items: center; + gap: var(--ads-v2-spaces-2); + margin-top: var(--ads-v2-spaces-2); +`; + +// Model selector styles +const ModelSelectWrapper = styled.div<{ isLoading?: boolean }>` + position: relative; + opacity: ${(props) => (props.isLoading ? 0.6 : 1)}; + transition: opacity 0.2s ease; +`; + +const ModelLoadingOverlay = styled.div` + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: var(--ads-v2-spaces-2); + background: rgba(255, 255, 255, 0.8); + border-radius: var(--ads-v2-border-radius); +`; + +const ModelRevealWrapper = styled.div<{ isVisible: boolean }>` + max-height: ${(props) => (props.isVisible ? "200px" : "0")}; + opacity: ${(props) => (props.isVisible ? 1 : 0)}; + overflow: hidden; + transition: + max-height 0.3s ease, + opacity 0.3s ease; +`; + +const CONTEXT_PRESETS = [ + { label: "4K", value: 4096 }, + { label: "8K", value: 8192 }, + { label: "16K", value: 16384 }, + { label: "32K", value: 32768 }, + { label: "128K", value: 131072 }, + { label: "Custom", value: "custom" }, +] as const; + +const STEP_ICONS: Record<"success" | "error" | "pending", string> = { + success: "\u2713", + error: "\u2717", + pending: "\u25CB", +}; + +function getStepIcon(status: "success" | "error" | "pending"): string { + return STEP_ICONS[status]; +} + +interface TestResultDisplayProps { + result: TestResult; + successLabel: string; + failureLabel: string; + showConnectionDetails?: boolean; +} + +function TestResultDisplay({ + failureLabel, + result, + showConnectionDetails = false, + successLabel, +}: TestResultDisplayProps): JSX.Element { + return ( + + + {result.success ? successLabel : failureLabel} + + + {result.steps && result.steps.length > 0 && ( + + {result.steps.map((step, index) => ( + + {getStepIcon(step.status)} + + {step.name} + {step.detail && ` - ${step.detail}`} + + + ))} + + )} + + {result.message && ( + + {result.message} + + )} + + {result.testResponse && ( + + Response: "{result.testResponse}" + + )} + + {result.error && ( + + + {result.error} + + + )} + + {result.warning && ( + + {"\u26A0"} {result.warning} + + )} + + {showConnectionDetails && + (result.host || result.responseTimeMs !== undefined) && ( + + {result.host &&
Host: {result.host}
} + {result.resolvedIp &&
Resolved IP: {result.resolvedIp}
} + {result.port &&
Port: {result.port}
} + {result.httpStatus &&
HTTP Status: {result.httpStatus}
} + {result.responseTimeMs !== undefined && ( +
Response Time: {result.responseTimeMs}ms
+ )} +
+ )} + + {!showConnectionDetails && result.responseTimeMs !== undefined && ( + +
Response Time: {result.responseTimeMs}ms
+ {result.httpStatus &&
HTTP Status: {result.httpStatus}
} +
+ )} + + {result.responsePreview && ( + <> + + Response from {showConnectionDetails ? "server" : "API"}: + + {result.responsePreview} + + )} + + {result.suggestions && result.suggestions.length > 0 && ( + <> + + Suggestions: + + + {result.suggestions.map((suggestion, index) => ( +
  • + {suggestion} +
  • + ))} +
    + + )} +
    + ); +} + +function ApiKeyTestResult({ result }: { result: TestResult }): JSX.Element { + return ( + + ); +} + +function AISettings() { + const [provider, setProvider] = useState("CLAUDE"); + const [claudeApiKey, setClaudeApiKey] = useState(""); + const [openaiApiKey, setOpenaiApiKey] = useState(""); + const [azureOpenaiApiKey, setAzureOpenaiApiKey] = useState(""); + const [azureOpenaiEndpoint, setAzureOpenaiEndpoint] = useState(""); + const [azureOpenaiDeploymentName, setAzureOpenaiDeploymentName] = + useState(""); + const [azureOpenaiApiVersion, setAzureOpenaiApiVersion] = useState( + DEFAULT_AZURE_API_VERSION, + ); + const [azureOpenaiMaxCompletionTokens, setAzureOpenaiMaxCompletionTokens] = + useState(DEFAULT_AZURE_MAX_COMPLETION_TOKENS); + const [localLlmUrl, setLocalLlmUrl] = useState(""); + const [localLlmContextSize, setLocalLlmContextSize] = useState(""); + const [localLlmModel, setLocalLlmModel] = useState(""); + const [claudeModel, setClaudeModel] = useState(DEFAULT_CLAUDE_MODEL); + const [claudeBaseUrl, setClaudeBaseUrl] = useState( + DEFAULT_CLAUDE_BASE_URL, + ); + const [openaiModel, setOpenaiModel] = useState(DEFAULT_OPENAI_MODEL); + const [openaiBaseUrl, setOpenaiBaseUrl] = useState( + DEFAULT_OPENAI_BASE_URL, + ); + const [isAIAssistantEnabled, setIsAIAssistantEnabled] = + useState(false); + const [isSaving, setIsSaving] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [isTesting, setIsTesting] = useState(false); + const [testResult, setTestResult] = useState(null); + const [isTestingApiKey, setIsTestingApiKey] = useState(false); + const [apiKeyTestResult, setApiKeyTestResult] = useState( + null, + ); + const [externalReferenceFiles, setExternalReferenceFiles] = useState< + ExternalReferenceFile[] + >([]); + // New state for model selection and context presets + const [availableModels, setAvailableModels] = useState([]); + const [isFetchingModels, setIsFetchingModels] = useState(false); + const [contextSizePreset, setContextSizePreset] = useState( + null, + ); + + useEffect(function fetchAIConfigOnMount() { + const fetchAIConfig = async () => { + try { + // Note: response interceptor unwraps axios response, so response = { responseMeta, data } + const response = + (await OrganizationApi.getAIConfig()) as unknown as UnwrappedApiResponse; + + if (response.responseMeta.success) { + const config = response.data; + + setIsAIAssistantEnabled(config.isAIAssistantEnabled || false); + // Empty string means not set - default to CLAUDE + // Handle legacy COPILOT -> AZURE_OPENAI migration + let loadedProvider = + config.provider && config.provider !== "" + ? config.provider + : "CLAUDE"; + + if (loadedProvider === "COPILOT") { + loadedProvider = "AZURE_OPENAI"; + } + + setProvider(loadedProvider); + setClaudeApiKey(config.hasClaudeApiKey ? "••••••••" : ""); + setOpenaiApiKey(config.hasOpenaiApiKey ? "••••••••" : ""); + setAzureOpenaiApiKey(config.hasAzureOpenaiApiKey ? "••••••••" : ""); + setAzureOpenaiEndpoint(config.azureOpenaiEndpoint || ""); + setAzureOpenaiDeploymentName(config.azureOpenaiDeploymentName || ""); + setAzureOpenaiApiVersion( + config.azureOpenaiApiVersion || DEFAULT_AZURE_API_VERSION, + ); + setAzureOpenaiMaxCompletionTokens( + config.azureOpenaiMaxCompletionTokens + ? String(config.azureOpenaiMaxCompletionTokens) + : DEFAULT_AZURE_MAX_COMPLETION_TOKENS, + ); + setLocalLlmUrl(config.localLlmUrl || ""); + // -1 is sentinel value meaning "not set" + const contextSize = config.localLlmContextSize; + + setLocalLlmContextSize( + contextSize && contextSize > 0 ? contextSize.toString() : "", + ); + setLocalLlmModel(config.localLlmModel || ""); + setClaudeModel(config.claudeModel || DEFAULT_CLAUDE_MODEL); + setClaudeBaseUrl(config.claudeBaseUrl || DEFAULT_CLAUDE_BASE_URL); + setOpenaiModel(config.openaiModel || DEFAULT_OPENAI_MODEL); + setOpenaiBaseUrl(config.openaiBaseUrl || DEFAULT_OPENAI_BASE_URL); + + // Determine context size preset from saved value + if (contextSize && contextSize > 0) { + const matchingPreset = CONTEXT_PRESETS.find( + (p) => p.value === contextSize, + ); + + if (matchingPreset && matchingPreset.value !== "custom") { + setContextSizePreset(null); + } else { + setContextSizePreset("custom"); + } + } + + // Set external reference files if present + if ( + config.hasExternalReferenceFiles && + config.externalReferenceFiles + ) { + setExternalReferenceFiles(config.externalReferenceFiles); + } + } + } catch (error) { + toast.show("Failed to load AI settings", { kind: "error" }); + } finally { + setIsLoading(false); + } + }; + + fetchAIConfig(); + }, []); + + const fetchAvailableModels = async (url: string) => { + if (!url.trim()) return; + + setIsFetchingModels(true); + try { + const response = (await OrganizationApi.fetchLlmModels( + url.trim(), + )) as unknown as UnwrappedApiResponse<{ + success: boolean; + models: OllamaModel[]; + }>; + + if (response.responseMeta.success && response.data.success) { + setAvailableModels(response.data.models || []); + } else { + toast.show("Could not fetch available models", { kind: "warning" }); + setAvailableModels([]); + } + } catch (error) { + toast.show("Could not fetch available models", { kind: "warning" }); + setAvailableModels([]); + } finally { + setIsFetchingModels(false); + } + }; + + const handleSave = async () => { + setIsSaving(true); + try { + const request: AIConfigRequest = { + provider, + isAIAssistantEnabled, + }; + + if (provider === "CLAUDE") { + if (claudeApiKey && claudeApiKey !== "••••••••") { + request.claudeApiKey = claudeApiKey; + } + + request.claudeModel = claudeModel; + request.claudeBaseUrl = claudeBaseUrl; + } + + if (provider === "OPENAI") { + if (openaiApiKey && openaiApiKey !== "••••••••") { + request.openaiApiKey = openaiApiKey; + } + + request.openaiModel = openaiModel; + request.openaiBaseUrl = openaiBaseUrl; + } + + if (provider === "AZURE_OPENAI") { + if (azureOpenaiApiKey && azureOpenaiApiKey !== "••••••••") { + request.azureOpenaiApiKey = azureOpenaiApiKey; + } + + if (azureOpenaiEndpoint) { + request.azureOpenaiEndpoint = azureOpenaiEndpoint; + } + + if (azureOpenaiDeploymentName) { + request.azureOpenaiDeploymentName = azureOpenaiDeploymentName; + } + + if (azureOpenaiApiVersion) { + request.azureOpenaiApiVersion = azureOpenaiApiVersion; + } + + if (azureOpenaiMaxCompletionTokens) { + request.azureOpenaiMaxCompletionTokens = parseInt( + azureOpenaiMaxCompletionTokens, + 10, + ); + } + } + + if (provider === "LOCAL_LLM") { + if (localLlmUrl) { + request.localLlmUrl = localLlmUrl; + } + + if (localLlmContextSize) { + request.localLlmContextSize = parseInt(localLlmContextSize, 10); + } + + if (localLlmModel) { + request.localLlmModel = localLlmModel; + } + } + + // Note: response interceptor unwraps axios response + const response = (await OrganizationApi.updateAIConfig( + request, + )) as unknown as UnwrappedApiResponse; + + if (response.responseMeta.success) { + toast.show("AI configuration saved successfully", { kind: "success" }); + + if (claudeApiKey && claudeApiKey !== "••••••••") { + setClaudeApiKey("••••••••"); + } + + if (openaiApiKey && openaiApiKey !== "••••••••") { + setOpenaiApiKey("••••••••"); + } + + if (azureOpenaiApiKey && azureOpenaiApiKey !== "••••••••") { + setAzureOpenaiApiKey("••••••••"); + } + } else { + toast.show("Failed to save AI configuration", { kind: "error" }); + } + } catch (error) { + toast.show("Failed to save AI configuration", { kind: "error" }); + } finally { + setIsSaving(false); + } + }; + + const handleTestConnection = async () => { + if (!localLlmUrl.trim()) { + toast.show("Please enter a URL to test", { kind: "warning" }); + + return; + } + + setIsTesting(true); + setTestResult(null); + + try { + const response = (await OrganizationApi.testLlmConnection( + localLlmUrl.trim(), + )) as unknown as UnwrappedApiResponse; + + if (response.responseMeta.success) { + setTestResult(response.data); + + // Auto-fetch models on successful connection + if (response.data.success) { + await fetchAvailableModels(localLlmUrl); + } + } else { + setTestResult({ + success: false, + error: "Failed to test connection", + }); + } + } catch (error) { + setTestResult({ + success: false, + error: "Failed to test connection - server error", + }); + } finally { + setIsTesting(false); + } + }; + + const handleTestApiKey = async () => { + setIsTestingApiKey(true); + setApiKeyTestResult(null); + + try { + // Pass the current key if it's been modified (not the masked placeholder) + let keyToTest: string | undefined; + + if (provider === "CLAUDE") { + keyToTest = claudeApiKey !== "••••••••" ? claudeApiKey : undefined; + } else if (provider === "OPENAI") { + keyToTest = openaiApiKey !== "••••••••" ? openaiApiKey : undefined; + } else if (provider === "AZURE_OPENAI") { + keyToTest = + azureOpenaiApiKey !== "••••••••" ? azureOpenaiApiKey : undefined; + } + + const response = (await OrganizationApi.testApiKey( + provider, + keyToTest, + provider === "AZURE_OPENAI" ? azureOpenaiEndpoint : undefined, + provider === "AZURE_OPENAI" ? azureOpenaiDeploymentName : undefined, + provider === "AZURE_OPENAI" ? azureOpenaiApiVersion : undefined, + provider === "CLAUDE" + ? claudeBaseUrl + : provider === "OPENAI" + ? openaiBaseUrl + : undefined, + provider === "CLAUDE" + ? claudeModel + : provider === "OPENAI" + ? openaiModel + : undefined, + )) as unknown as UnwrappedApiResponse; + + if (response.responseMeta.success) { + setApiKeyTestResult(response.data); + } else { + setApiKeyTestResult({ + success: false, + error: "Failed to test API key", + }); + } + } catch (error) { + setApiKeyTestResult({ + success: false, + error: "Failed to test API key - server error", + }); + } finally { + setIsTestingApiKey(false); + } + }; + + if (isLoading) { + return Loading...; + } + + return ( + + + + AI Assistant Configuration + + + Configure AI assistant for your organization. API keys are encrypted + and stored securely. Only organization administrators can configure + these settings. + + + {externalReferenceFiles.length > 0 && ( + + 📁 + + + Custom AI Context Files Active + + + External reference files are being used instead of system + defaults. These customize the AI assistant's knowledge for + your environment. + + + {externalReferenceFiles.map((file) => ( + + {file.filename} + + ))} + + + + )} + + + + + + Enable AI Assistant + + + + + When enabled, all users in your organization can use AI assistance + in JavaScript modules and queries. + + + + + + AI Provider + + + + Get your API key from https://console.anthropic.com/ + + + + + + Model + + + + Claude model to use (e.g. claude-sonnet-4-6, + claude-haiku-4-5-20251001) + + + + + + Base URL + + + + API base URL. Change only if using a proxy or custom endpoint. + + + + + + + {isTestingApiKey && ( + + Testing API key... + + )} + + + {apiKeyTestResult && ( + + )} + + + )} + + {provider === "OPENAI" && ( + <> + + + OpenAI API Key + + + + Get your API key from https://platform.openai.com/api-keys + + + + + + Model + + + + OpenAI model to use (e.g. gpt-4, gpt-4o, gpt-3.5-turbo) + + + + + + Base URL + + + + API base URL. Change only if using a proxy or custom endpoint. + + + + + + + {isTestingApiKey && ( + + Testing API key... + + )} + + + {apiKeyTestResult && ( + + )} + + + )} + + {provider === "AZURE_OPENAI" && ( + <> + + + Azure OpenAI Endpoint + + + + Your Azure OpenAI resource endpoint from the Azure Portal + + + + + + Deployment Name + + + + The name of your model deployment in Azure OpenAI Studio + + + + + + API Version + + + + Azure OpenAI API version (e.g. 2024-12-01-preview) + + + + + + Max Completion Tokens + + + + Maximum tokens in AI response + + + + + + Azure OpenAI API Key + + + + Get KEY 1 or KEY 2 from Azure Portal > Your OpenAI Resource + > Keys and Endpoint + + + + + {isTestingApiKey && ( + + Testing API key... + + )} + + + {apiKeyTestResult && ( + + )} + + + )} + + {provider === "LOCAL_LLM" && ( + <> + + + Local LLM URL + + + + Enter your Ollama endpoint URL (e.g., + http://localhost:11434/api/generate) + + + + + + + {isTesting && ( + + Testing connection from server... + + )} + + + {testResult && ( + + )} + + + {/* Model Selection - Only shown after successful connection */} + 0} + > + + + Model + + + + + tokens + + + )} + + + Maximum context window size. Larger values use more memory but + allow longer conversations. + + + + )} + + + + + + + ); +} + +export default AISettings; 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(() => { + diff --git a/app/client/src/sagas/ActionSagas.ts b/app/client/src/sagas/ActionSagas.ts index 0b173f42cc72..9348f559af00 100644 --- a/app/client/src/sagas/ActionSagas.ts +++ b/app/client/src/sagas/ActionSagas.ts @@ -1166,11 +1166,11 @@ function* executeCommandSaga(actionPayload: ReduxAction) { const isJavascriptMode = context.mode === EditorModes.TEXT_WITH_BINDING; const noOfTimesAIPromptTriggered: number = yield select( - (state) => state.ai.noOfTimesAITriggered, + (state) => state.aiAssistant.noOfTimesAITriggered, ); const noOfTimesAIPromptTriggeredForQuery: number = yield select( - (state) => state.ai.noOfTimesAITriggeredForQuery, + (state) => state.aiAssistant.noOfTimesAITriggeredForQuery, ); const triggerCount = isJavascriptMode @@ -1195,6 +1195,11 @@ function* executeCommandSaga(actionPayload: ReduxAction) { context, }, }); + + // Open the AI panel when triggered via slash command + yield put({ + type: ReduxActionTypes.OPEN_AI_PANEL, + }); break; } } diff --git a/app/client/yarn.lock b/app/client/yarn.lock index 8a6b9cbd1093..4cd17ee8b753 100644 --- a/app/client/yarn.lock +++ b/app/client/yarn.lock @@ -13916,6 +13916,7 @@ __metadata: react-hook-form: ^7.28.0 react-is: ^16.12.0 react-json-view: ^1.21.3 + react-markdown: ^9.0.1 react-media-recorder: ^1.6.1 react-modal: ^3.15.1 react-page-visibility: ^7.0.0 @@ -13945,6 +13946,7 @@ __metadata: redux-mock-store: ^1.5.4 redux-saga: ^1.1.3 redux-saga-test-plan: ^4.0.6 + remark-gfm: ^4.0.0 remixicon-react: ^1.0.0 reselect: ^4.0.0 resize-observer-polyfill: ^1.5.1 diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/constants/AIConstants.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/constants/AIConstants.java new file mode 100644 index 000000000000..e362d7753d90 --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/constants/AIConstants.java @@ -0,0 +1,12 @@ +package com.appsmith.server.constants; + +public final class AIConstants { + private AIConstants() {} + + public static final String DEFAULT_AZURE_API_VERSION = "2024-12-01-preview"; + public static final int DEFAULT_AZURE_MAX_COMPLETION_TOKENS = 16384; + public static final String DEFAULT_CLAUDE_MODEL = "claude-sonnet-4-6"; + public static final String DEFAULT_CLAUDE_BASE_URL = "https://api.anthropic.com"; + public static final String DEFAULT_OPENAI_MODEL = "gpt-4"; + public static final String DEFAULT_OPENAI_BASE_URL = "https://api.openai.com"; +} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/OrganizationController.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/OrganizationController.java index 45de4b18e74a..7c9e89c7cc54 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/OrganizationController.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/OrganizationController.java @@ -2,6 +2,7 @@ import com.appsmith.server.constants.Url; import com.appsmith.server.controllers.ce.OrganizationControllerCE; +import com.appsmith.server.services.AIReferenceService; import com.appsmith.server.services.OrganizationService; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.RequestMapping; @@ -12,7 +13,7 @@ @RequestMapping(Url.ORGANIZATION_URL) public class OrganizationController extends OrganizationControllerCE { - public OrganizationController(OrganizationService service) { - super(service); + public OrganizationController(OrganizationService service, AIReferenceService aiReferenceService) { + super(service, aiReferenceService); } } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/UserController.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/UserController.java index 56065e1d6a8b..60dfd47f9672 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/UserController.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/UserController.java @@ -6,6 +6,7 @@ import com.appsmith.server.services.UserDataService; import com.appsmith.server.services.UserService; import com.appsmith.server.services.UserWorkspaceService; +import com.appsmith.server.services.ce.AIAssistantServiceCE; import com.appsmith.server.solutions.UserAndAccessManagementService; import com.appsmith.server.solutions.UserSignup; import lombok.extern.slf4j.Slf4j; @@ -23,7 +24,8 @@ public UserController( UserWorkspaceService userWorkspaceService, UserSignup userSignup, UserDataService userDataService, - UserAndAccessManagementService userAndAccessManagementService) { + UserAndAccessManagementService userAndAccessManagementService, + AIAssistantServiceCE aiAssistantService) { super( service, @@ -31,6 +33,7 @@ public UserController( userWorkspaceService, userSignup, userDataService, - userAndAccessManagementService); + userAndAccessManagementService, + aiAssistantService); } } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/ce/OrganizationControllerCE.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/ce/OrganizationControllerCE.java index e58bd7b06e0a..bc42c1a2e5bd 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/ce/OrganizationControllerCE.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/ce/OrganizationControllerCE.java @@ -2,29 +2,63 @@ import com.appsmith.external.views.Views; import com.appsmith.server.constants.Url; +import com.appsmith.server.domains.AIProvider; import com.appsmith.server.domains.Organization; import com.appsmith.server.domains.OrganizationConfiguration; +import com.appsmith.server.dtos.AIConfigDTO; import com.appsmith.server.dtos.ResponseDTO; +import com.appsmith.server.exceptions.AppsmithError; +import com.appsmith.server.exceptions.AppsmithException; +import com.appsmith.server.services.AIReferenceService; import com.appsmith.server.services.OrganizationService; +import com.appsmith.util.WebClientUtils; import com.fasterxml.jackson.annotation.JsonView; +import io.netty.channel.ConnectTimeoutException; +import io.netty.handler.ssl.SslHandshakeTimeoutException; +import jakarta.validation.Valid; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.ObjectUtils; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientRequestException; +import org.springframework.web.reactive.function.client.WebClientResponseException; import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; +import reactor.netty.http.client.HttpClient; +import java.net.URI; +import java.net.URLEncoder; +import java.net.UnknownHostException; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; import java.util.Map; +import static com.appsmith.server.acl.AclPermission.MANAGE_ORGANIZATION; +import static com.appsmith.server.constants.AIConstants.DEFAULT_AZURE_API_VERSION; +import static com.appsmith.server.constants.AIConstants.DEFAULT_AZURE_MAX_COMPLETION_TOKENS; +import static com.appsmith.server.constants.AIConstants.DEFAULT_CLAUDE_BASE_URL; +import static com.appsmith.server.constants.AIConstants.DEFAULT_CLAUDE_MODEL; +import static com.appsmith.server.constants.AIConstants.DEFAULT_OPENAI_BASE_URL; +import static com.appsmith.server.constants.AIConstants.DEFAULT_OPENAI_MODEL; + @Slf4j @RequestMapping(Url.ORGANIZATION_URL) public class OrganizationControllerCE { private final OrganizationService service; + private final AIReferenceService aiReferenceService; - public OrganizationControllerCE(OrganizationService service) { + public OrganizationControllerCE(OrganizationService service, AIReferenceService aiReferenceService) { this.service = service; + this.aiReferenceService = aiReferenceService; } /** @@ -49,4 +83,1421 @@ public Mono> updateOrganizationConfiguration( return service.updateOrganizationConfiguration(organizationConfiguration) .map(organization -> new ResponseDTO<>(HttpStatus.OK, organization)); } + + @JsonView(Views.Public.class) + @PutMapping("/ai-config") + public Mono>> updateAIConfig(@RequestBody @Valid AIConfigDTO aiConfig) { + return service.getCurrentUserOrganizationId() + .flatMap(organizationId -> service.findById(organizationId, MANAGE_ORGANIZATION) + .switchIfEmpty(Mono.error(new AppsmithException( + AppsmithError.ACL_NO_RESOURCE_FOUND, "organization", organizationId))) + .flatMap(organization -> { + OrganizationConfiguration config = organization.getOrganizationConfiguration(); + if (config == null) { + config = new OrganizationConfiguration(); + } + + // Validate and set all fields; each method throws on validation failure + try { + validateApiKey( + aiConfig.getClaudeApiKey(), config::setClaudeApiKey, 500, "Claude API key"); + validateApiKey( + aiConfig.getOpenaiApiKey(), config::setOpenaiApiKey, 500, "OpenAI API key"); + validateApiKey( + aiConfig.getCopilotApiKey(), config::setCopilotApiKey, 500, "Copilot API key"); + validateTrimmedString( + aiConfig.getCopilotEndpoint(), + config::setCopilotEndpoint, + 2000, + "Copilot endpoint URL"); + validateApiKey( + aiConfig.getAzureOpenaiApiKey(), + config::setAzureOpenaiApiKey, + 500, + "Azure OpenAI API key"); + validateTrimmedString( + aiConfig.getAzureOpenaiEndpoint(), + config::setAzureOpenaiEndpoint, + 2000, + "Azure OpenAI endpoint URL"); + validateTrimmedString( + aiConfig.getAzureOpenaiDeploymentName(), + config::setAzureOpenaiDeploymentName, + 200, + "Deployment name"); + validateTrimmedString( + aiConfig.getAzureOpenaiApiVersion(), + config::setAzureOpenaiApiVersion, + 50, + "API version"); + validateTrimmedString(aiConfig.getLocalLlmUrl(), config::setLocalLlmUrl, 2000, "URL"); + validateTrimmedString( + aiConfig.getLocalLlmModel(), config::setLocalLlmModel, 200, "Model name"); + validateTrimmedString( + aiConfig.getClaudeModel(), config::setClaudeModel, 200, "Claude model name"); + validateTrimmedString( + aiConfig.getClaudeBaseUrl(), config::setClaudeBaseUrl, 2000, "Claude base URL"); + validateTrimmedString( + aiConfig.getOpenaiModel(), config::setOpenaiModel, 200, "OpenAI model name"); + validateTrimmedString( + aiConfig.getOpenaiBaseUrl(), config::setOpenaiBaseUrl, 2000, "OpenAI base URL"); + } catch (AppsmithException e) { + return Mono.error(e); + } + + if (aiConfig.getProvider() != null) { + config.setAiProvider(aiConfig.getProvider()); + } + if (aiConfig.getIsAIAssistantEnabled() != null) { + config.setIsAIAssistantEnabled(aiConfig.getIsAIAssistantEnabled()); + } + if (aiConfig.getLocalLlmContextSize() != null) { + config.setLocalLlmContextSize(aiConfig.getLocalLlmContextSize()); + } + if (aiConfig.getAzureOpenaiMaxCompletionTokens() != null) { + config.setAzureOpenaiMaxCompletionTokens(aiConfig.getAzureOpenaiMaxCompletionTokens()); + } + + return service.updateOrganizationConfiguration(organizationId, config) + .map(updatedOrg -> + buildAIConfigResponse(updatedOrg.getOrganizationConfiguration())); + })) + .map(result -> new ResponseDTO<>(HttpStatus.OK, result)) + .onErrorResume(error -> { + String errorMessage = "Failed to update AI configuration"; + if (error instanceof AppsmithException appsmithError) { + if (appsmithError.getError() == AppsmithError.ACL_NO_RESOURCE_FOUND) { + errorMessage = "You do not have permission to update this configuration"; + } else { + errorMessage = appsmithError.getError().getMessage(); + } + } + return Mono.just(new ResponseDTO>( + HttpStatus.BAD_REQUEST.value(), null, errorMessage, false)); + }); + } + + @JsonView(Views.Public.class) + @GetMapping("/ai-config") + public Mono>> getAIConfig() { + return service.getCurrentUserOrganization() + .map(organization -> { + Map response = + buildAIConfigResponseForGet(organization.getOrganizationConfiguration()); + + // Add AI reference files info + List> externalFiles = + aiReferenceService.getReferenceFilesInfo().entrySet().stream() + .filter(entry -> + "external".equals(entry.getValue().source())) + .map(entry -> Map.of( + "filename", + entry.getKey(), + "path", + entry.getValue().path())) + .toList(); + + response.put("hasExternalReferenceFiles", !externalFiles.isEmpty()); + response.put("externalReferenceFiles", externalFiles); + + return response; + }) + .map(result -> new ResponseDTO<>(HttpStatus.OK, result)); + } + + @JsonView(Views.Public.class) + @PostMapping("/ai-config/test-connection") + public Mono>> testLlmConnection(@RequestBody Map request) { + return service.getCurrentUserOrganizationId() + .flatMap(organizationId -> service.findById(organizationId, MANAGE_ORGANIZATION) + .switchIfEmpty(Mono.error(new AppsmithException( + AppsmithError.ACL_NO_RESOURCE_FOUND, "organization", organizationId))) + .flatMap(organization -> testLlmConnectionInternal(request))); + } + + private Mono>> testLlmConnectionInternal(Map request) { + String rawUrl = request.get("url"); + if (rawUrl == null || rawUrl.trim().isEmpty()) { + Map response = new HashMap<>(); + response.put("success", false); + response.put("error", "URL is required"); + return Mono.just(new ResponseDTO<>(HttpStatus.BAD_REQUEST, response)); + } + + final String url = rawUrl.trim(); + List> steps = new ArrayList<>(); + + // Step 1: URL Parsing + URI uri; + try { + uri = URI.create(url); + if (uri.getHost() == null) { + throw new IllegalArgumentException("No host specified"); + } + steps.add(createStep("URL Parsing", "success", "Valid URL format")); + } catch (Exception e) { + steps.add(createStep("URL Parsing", "error", e.getMessage())); + Map response = new HashMap<>(); + response.put("success", false); + response.put("steps", steps); + response.put("error", "Invalid URL format: " + e.getMessage()); + response.put( + "suggestions", + List.of( + "Ensure URL starts with http:// or https://", + "Check for typos in the hostname", + "Example: http://localhost:11434/api/generate")); + return Mono.just(new ResponseDTO<>(HttpStatus.OK, response)); + } + + final String host = uri.getHost(); + final int port = uri.getPort() != -1 ? uri.getPort() : (uri.getScheme().equals("https") ? 443 : 80); + final String scheme = uri.getScheme(); + + // Step 2: DNS Resolution (off event loop to avoid blocking Netty threads) + return Mono.fromCallable(() -> java.net.InetAddress.getByName(host)) + .subscribeOn(Schedulers.boundedElastic()) + .flatMap(resolvedAddress -> { + steps.add(createStep("DNS Resolution", "success", host + " → " + resolvedAddress.getHostAddress())); + final String resolvedIp = resolvedAddress.getHostAddress(); + + // Create WebClient with timeout and SSRF protection + HttpClient httpClient = HttpClient.create() + .responseTimeout(Duration.ofSeconds(10)) + .option(io.netty.channel.ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000); + + WebClient webClient = WebClientUtils.builder(httpClient).build(); + + final long startTime = System.currentTimeMillis(); + final List> finalSteps = new ArrayList<>(steps); + + String path = uri.getPath(); + boolean isOllamaEndpoint = + path != null && (path.contains("/api/generate") || path.contains("/api/chat")); + String testUrl = isOllamaEndpoint ? uri.resolve("/api/tags").toString() : url; + + WebClient.RequestHeadersSpec requestSpec = isOllamaEndpoint + ? webClient.get().uri(testUrl) + : webClient + .post() + .uri(testUrl) + .header("Content-Type", "application/json") + .bodyValue("{\"model\":\"test\",\"prompt\":\"Say hi\",\"stream\":false}"); + + return requestSpec + .exchangeToMono(clientResponse -> { + // Connection succeeded if we got here + finalSteps.add( + createStep("TCP Connection", "success", "Connected to " + host + ":" + port)); + + if ("https".equals(scheme)) { + finalSteps.add( + createStep("TLS Handshake", "success", "Secure connection established")); + } + + int statusCode = clientResponse.statusCode().value(); + finalSteps.add(createStep( + "HTTP Response", "success", "Endpoint responded with HTTP " + statusCode)); + + // Read response body to analyze + return clientResponse + .bodyToMono(String.class) + .defaultIfEmpty("") + .map(responseBody -> { + long responseTime = System.currentTimeMillis() - startTime; + Map response = new HashMap<>(); + + response.put("responseTimeMs", responseTime); + response.put("httpStatus", statusCode); + response.put("host", host); + response.put("port", port); + response.put("resolvedIp", resolvedIp); + + // Analyze the response to determine if it's an LLM endpoint + boolean looksLikeLlm = false; + String contentType = clientResponse + .headers() + .contentType() + .map(Object::toString) + .orElse("unknown"); + + // Truncate response for display + String truncatedResponse = responseBody.length() > 500 + ? responseBody.substring(0, 500) + "..." + : responseBody; + + if (statusCode == 404) { + finalSteps.add(createStep( + "Endpoint Check", "error", "Endpoint not found (404)")); + response.put("success", false); + response.put( + "error", + "Endpoint not found - the path '" + uri.getPath() + + "' does not exist on this server"); + response.put("responsePreview", truncatedResponse); + response.put( + "suggestions", + List.of( + "Verify the endpoint path is correct", + "Common Ollama endpoints: /api/generate, /api/chat", + "Common OpenAI-compatible endpoints: /v1/completions, /v1/chat/completions", + "Check your LLM server documentation for the correct endpoint")); + } else if (contentType.contains("text/html")) { + finalSteps.add(createStep( + "Endpoint Check", "error", "Received HTML instead of JSON")); + response.put("success", false); + response.put( + "error", + "This doesn't appear to be an LLM API endpoint - received HTML response"); + response.put("responsePreview", truncatedResponse); + response.put( + "suggestions", + List.of( + "The URL points to a web page, not an API endpoint", + "Check that the URL includes the API path (e.g., /api/generate)", + "Verify you're using the correct port for the API")); + } else if (contentType.contains("application/json") + || responseBody.trim().startsWith("{")) { + // It's JSON - check if it looks like an LLM response + String lowerBody = responseBody.toLowerCase(); + if (lowerBody.contains("\"response\"") + || lowerBody.contains("\"content\"") + || lowerBody.contains("\"text\"") + || lowerBody.contains("\"output\"") + || lowerBody.contains("\"choices\"") + || lowerBody.contains("\"message\"") + || lowerBody.contains("\"generated\"")) { + looksLikeLlm = true; + finalSteps.add(createStep( + "Endpoint Check", + "success", + "Looks like a valid LLM endpoint")); + } else if (lowerBody.contains("\"error\"") + || lowerBody.contains("\"model\"")) { + // Error response but from an LLM-like API + looksLikeLlm = true; + finalSteps.add(createStep( + "Endpoint Check", + "success", + "LLM endpoint responded (with error/model info)")); + response.put( + "warning", + "Endpoint responded with an error - this may be normal for a test request without a valid model"); + } else { + finalSteps.add(createStep( + "Endpoint Check", + "pending", + "JSON response but unclear if LLM")); + response.put( + "warning", + "Received JSON but couldn't confirm this is an LLM endpoint - please verify manually"); + } + response.put("responsePreview", truncatedResponse); + } else { + finalSteps.add(createStep( + "Endpoint Check", + "pending", + "Unexpected content type: " + contentType)); + response.put( + "warning", "Received unexpected content type: " + contentType); + response.put("responsePreview", truncatedResponse); + } + + if (statusCode >= 200 && statusCode < 300 && looksLikeLlm) { + response.put("success", true); + } else if (statusCode >= 200 + && statusCode < 500 + && !response.containsKey("error")) { + // Got a response, might be usable + response.put("success", looksLikeLlm); + if (!looksLikeLlm && !response.containsKey("error")) { + response.put( + "error", "Could not verify this is a valid LLM endpoint"); + response.put( + "suggestions", + List.of( + "The server responded but the response doesn't look like an LLM API", + "Verify the complete URL path is correct", + "Check your LLM server documentation")); + } + } else if (!response.containsKey("error")) { + response.put("success", false); + response.put("error", "Server returned HTTP " + statusCode); + response.put("suggestions", getHttpErrorSuggestions(statusCode)); + } + + response.put("steps", finalSteps); + return new ResponseDTO<>(HttpStatus.OK, response); + }); + }) + .onErrorResume(error -> { + long responseTime = System.currentTimeMillis() - startTime; + Map response = new HashMap<>(); + response.put("success", false); + response.put("responseTimeMs", responseTime); + response.put("host", host); + response.put("port", port); + response.put("resolvedIp", resolvedIp); + + if (error instanceof WebClientRequestException) { + Throwable cause = error.getCause(); + if (cause instanceof ConnectTimeoutException) { + finalSteps.add( + createStep("TCP Connection", "error", "Connection timed out after 5s")); + response.put( + "error", + "Connection timed out - server not responding on port " + port); + response.put( + "suggestions", + List.of( + "Check if the LLM server is running on port " + port, + "Verify firewall allows connections to port " + port, + "If running in Docker, ensure proper network configuration", + "Try: curl -v " + url)); + } else if (cause instanceof java.net.ConnectException) { + finalSteps.add(createStep("TCP Connection", "error", "Connection refused")); + response.put( + "error", + "Connection refused - no service listening on " + host + ":" + port); + response.put( + "suggestions", + List.of( + "Start the LLM server (e.g., 'ollama serve' for Ollama)", + "Check if the service is listening on the correct port", + "Verify the port number in your URL", + "Try: lsof -i :" + port + + " (Mac/Linux) or netstat -an | findstr " + port + + " (Windows)")); + } else if (cause instanceof SslHandshakeTimeoutException + || (cause != null + && cause.getClass() + .getName() + .contains("Ssl"))) { + finalSteps.add(createStep( + "TCP Connection", "success", "Connected to " + host + ":" + port)); + finalSteps.add( + createStep("TLS Handshake", "error", "SSL/TLS handshake failed")); + response.put("error", "SSL/TLS handshake failed"); + response.put( + "suggestions", + List.of( + "Try using http:// instead of https:// for local servers", + "Check if the server's SSL certificate is valid", + "Verify the server supports TLS")); + } else { + finalSteps.add(createStep("TCP Connection", "error", "Connection failed")); + response.put( + "error", + "Connection failed: " + + (cause != null ? cause.getMessage() : error.getMessage())); + response.put( + "suggestions", + List.of( + "Check if the server is running and accessible", + "Verify network connectivity from the Appsmith server", + "Check server logs for more details")); + } + } else if (error instanceof WebClientResponseException wcre) { + finalSteps.add(createStep("TCP Connection", "success", "Connected")); + finalSteps.add(createStep( + "HTTP Request", + "error", + "HTTP " + wcre.getStatusCode().value())); + response.put("error", "HTTP error: " + wcre.getStatusCode()); + response.put( + "httpStatus", wcre.getStatusCode().value()); + response.put( + "suggestions", + getHttpErrorSuggestions( + wcre.getStatusCode().value())); + } else { + finalSteps.add(createStep("Connection", "error", error.getMessage())); + response.put("error", "Unexpected error: " + error.getMessage()); + response.put( + "suggestions", + List.of( + "Check the Appsmith server logs for more details", + "Verify the URL is correct and accessible")); + } + + response.put("steps", finalSteps); + return Mono.just(new ResponseDTO<>(HttpStatus.OK, response)); + }); + }) + .onErrorResume(UnknownHostException.class, e -> { + steps.add(createStep("DNS Resolution", "error", "Could not resolve hostname")); + Map response = new HashMap<>(); + response.put("success", false); + response.put("host", host); + response.put("port", port); + response.put("steps", steps); + response.put("error", "DNS resolution failed - hostname not found: " + host); + response.put( + "suggestions", + List.of( + "Check if the hostname is spelled correctly", + "If using localhost, ensure that's correct for your setup", + "Try using IP address (e.g., 127.0.0.1) instead of hostname", + "Check your DNS settings or /etc/hosts file")); + return Mono.just(new ResponseDTO<>(HttpStatus.OK, response)); + }); + } + + private Map createStep(String name, String status, String detail) { + return Map.of("name", name, "status", status, "detail", detail); + } + + @JsonView(Views.Public.class) + @PostMapping("/ai-config/fetch-models") + public Mono>> fetchLlmModels(@RequestBody Map request) { + return service.getCurrentUserOrganizationId() + .flatMap(organizationId -> service.findById(organizationId, MANAGE_ORGANIZATION) + .switchIfEmpty(Mono.error(new AppsmithException( + AppsmithError.ACL_NO_RESOURCE_FOUND, "organization", organizationId))) + .flatMap(organization -> fetchLlmModelsInternal(request))); + } + + private Mono>> fetchLlmModelsInternal(Map request) { + String rawUrl = request.get("url"); + if (rawUrl == null || rawUrl.trim().isEmpty()) { + Map response = new HashMap<>(); + response.put("success", false); + response.put("error", "URL is required"); + response.put("models", List.of()); + return Mono.just(new ResponseDTO<>(HttpStatus.BAD_REQUEST, response)); + } + + // Extract base URL - remove /api/generate, /api/chat, etc. + String baseUrl = rawUrl.trim(); + if (baseUrl.contains("/api/")) { + int apiIndex = baseUrl.indexOf("/api/"); + baseUrl = baseUrl.substring(0, apiIndex); + } + // Ensure no trailing slash + if (baseUrl.endsWith("/")) { + baseUrl = baseUrl.substring(0, baseUrl.length() - 1); + } + + final String tagsUrl = baseUrl + "/api/tags"; + + // Create WebClient with timeout and SSRF protection + HttpClient httpClient = HttpClient.create() + .responseTimeout(Duration.ofSeconds(10)) + .option(io.netty.channel.ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000); + + WebClient webClient = WebClientUtils.builder(httpClient).build(); + + return webClient + .get() + .uri(tagsUrl) + .exchangeToMono(clientResponse -> { + int statusCode = clientResponse.statusCode().value(); + + return clientResponse + .bodyToMono(String.class) + .defaultIfEmpty("") + .map(responseBody -> { + Map response = new HashMap<>(); + + if (statusCode == 200) { + // Parse the Ollama response to extract models + List> models = parseOllamaModels(responseBody); + response.put("success", true); + response.put("models", models); + } else { + response.put("success", false); + response.put("error", "Failed to fetch models: HTTP " + statusCode); + response.put("models", List.of()); + } + + return new ResponseDTO<>(HttpStatus.OK, response); + }); + }) + .onErrorResume(error -> { + Map response = new HashMap<>(); + response.put("success", false); + response.put("error", "Failed to connect to Ollama: " + error.getMessage()); + response.put("models", List.of()); + return Mono.just(new ResponseDTO<>(HttpStatus.OK, response)); + }); + } + + private List> parseOllamaModels(String responseBody) { + List> models = new ArrayList<>(); + try { + // Simple JSON parsing for Ollama's /api/tags response + // Response format: {"models":[{"name":"llama3.2:latest","model":"llama3.2:latest","size":2019393189,...}]} + if (responseBody.contains("\"models\"")) { + int modelsStart = responseBody.indexOf('[', responseBody.indexOf("\"models\"")); + int modelsEnd = findMatchingBracket(responseBody, modelsStart); + if (modelsStart > 0 && modelsEnd > modelsStart) { + String modelsArray = responseBody.substring(modelsStart, modelsEnd + 1); + // Parse each model object + int index = 0; + while (index < modelsArray.length()) { + int objStart = modelsArray.indexOf('{', index); + if (objStart < 0) break; + int objEnd = findMatchingBrace(modelsArray, objStart); + if (objEnd < 0) break; + + String modelObj = modelsArray.substring(objStart, objEnd + 1); + Map model = parseModelObject(modelObj); + if (model != null && model.containsKey("name")) { + models.add(model); + } + index = objEnd + 1; + } + } + } + } catch (Exception e) { + log.warn("Failed to parse Ollama models response: {}", e.getMessage()); + } + return models; + } + + private Map parseModelObject(String json) { + Map model = new HashMap<>(); + try { + // Extract name + String name = extractJsonString(json, "name"); + if (name != null) { + model.put("name", name); + } + + // Extract size + String sizeStr = extractJsonNumber(json, "size"); + if (sizeStr != null) { + try { + model.put("size", Long.parseLong(sizeStr)); + } catch (NumberFormatException ignored) { + } + } + + // Extract details if present + int detailsStart = json.indexOf("\"details\""); + if (detailsStart > 0) { + int detailsObjStart = json.indexOf('{', detailsStart); + int detailsObjEnd = findMatchingBrace(json, detailsObjStart); + if (detailsObjStart > 0 && detailsObjEnd > detailsObjStart) { + String detailsJson = json.substring(detailsObjStart, detailsObjEnd + 1); + Map details = new HashMap<>(); + String paramSize = extractJsonString(detailsJson, "parameter_size"); + if (paramSize != null) { + details.put("parameter_size", paramSize); + } + String quantLevel = extractJsonString(detailsJson, "quantization_level"); + if (quantLevel != null) { + details.put("quantization_level", quantLevel); + } + if (!details.isEmpty()) { + model.put("details", details); + } + } + } + } catch (Exception e) { + log.warn("Failed to parse model object: {}", e.getMessage()); + } + return model.isEmpty() ? null : model; + } + + private String extractJsonString(String json, String key) { + String pattern = "\"" + key + "\""; + int keyIndex = json.indexOf(pattern); + if (keyIndex < 0) return null; + + int colonIndex = json.indexOf(':', keyIndex); + if (colonIndex < 0) return null; + + int valueStart = json.indexOf("\"", colonIndex); + if (valueStart < 0) return null; + + int valueEnd = json.indexOf("\"", valueStart + 1); + if (valueEnd < 0) return null; + + return json.substring(valueStart + 1, valueEnd); + } + + private String extractJsonNumber(String json, String key) { + String pattern = "\"" + key + "\""; + int keyIndex = json.indexOf(pattern); + if (keyIndex < 0) return null; + + int colonIndex = json.indexOf(':', keyIndex); + if (colonIndex < 0) return null; + + int start = colonIndex + 1; + while (start < json.length() && Character.isWhitespace(json.charAt(start))) { + start++; + } + + int end = start; + while (end < json.length() && (Character.isDigit(json.charAt(end)) || json.charAt(end) == '.')) { + end++; + } + + if (end > start) { + return json.substring(start, end); + } + return null; + } + + private int findMatchingBracket(String str, int start) { + if (start < 0 || start >= str.length() || str.charAt(start) != '[') return -1; + int count = 1; + for (int i = start + 1; i < str.length(); i++) { + char c = str.charAt(i); + if (c == '[') count++; + else if (c == ']') { + count--; + if (count == 0) return i; + } + } + return -1; + } + + private int findMatchingBrace(String str, int start) { + if (start < 0 || start >= str.length() || str.charAt(start) != '{') return -1; + int count = 1; + for (int i = start + 1; i < str.length(); i++) { + char c = str.charAt(i); + if (c == '{') count++; + else if (c == '}') { + count--; + if (count == 0) return i; + } + } + return -1; + } + + @JsonView(Views.Public.class) + @PostMapping("/ai-config/test-api-key") + public Mono>> testApiKey(@RequestBody Map request) { + return service.getCurrentUserOrganizationId() + .flatMap(organizationId -> service.findById(organizationId, MANAGE_ORGANIZATION) + .switchIfEmpty(Mono.error(new AppsmithException( + AppsmithError.ACL_NO_RESOURCE_FOUND, "organization", organizationId))) + .flatMap(organization -> testApiKeyInternal(request))); + } + + private Mono>> testApiKeyInternal(Map request) { + String provider = request.get("provider"); + String apiKey = request.get("apiKey"); + String endpoint = request.get("endpoint"); + String deploymentName = request.get("deploymentName"); + String apiVersion = request.get("apiVersion"); + String baseUrl = request.get("baseUrl"); + String model = request.get("model"); + + if (provider == null || provider.trim().isEmpty()) { + Map response = new HashMap<>(); + response.put("success", false); + response.put("error", "Provider is required"); + return Mono.just(new ResponseDTO<>(HttpStatus.BAD_REQUEST, response)); + } + + // If no API key provided, try to use the stored one + if (apiKey == null || apiKey.trim().isEmpty() || apiKey.equals("••••••••")) { + return service.getCurrentUserOrganization().flatMap(organization -> { + OrganizationConfiguration config = organization.getOrganizationConfiguration(); + if (config == null) { + Map response = new HashMap<>(); + response.put("success", false); + response.put("error", "No API key configured"); + return Mono.just(new ResponseDTO<>(HttpStatus.OK, response)); + } + + String storedKey = null; + if ("CLAUDE".equalsIgnoreCase(provider)) { + storedKey = config.getClaudeApiKey(); + } else if ("OPENAI".equalsIgnoreCase(provider)) { + storedKey = config.getOpenaiApiKey(); + } else if ("COPILOT".equalsIgnoreCase(provider)) { + storedKey = config.getCopilotApiKey(); + } else if ("AZURE_OPENAI".equalsIgnoreCase(provider)) { + storedKey = config.getAzureOpenaiApiKey(); + if (storedKey == null || storedKey.isEmpty()) { + // Fall back to copilot key for migration + storedKey = config.getCopilotApiKey(); + } + } + + if (storedKey == null || storedKey.isEmpty()) { + Map response = new HashMap<>(); + response.put("success", false); + response.put("error", "No " + provider + " API key configured. Please enter an API key first."); + return Mono.just(new ResponseDTO<>(HttpStatus.OK, response)); + } + + // For Azure OpenAI, also resolve endpoint/deployment from stored config + if ("AZURE_OPENAI".equalsIgnoreCase(provider)) { + String resolvedEndpoint = endpoint; + String resolvedDeployment = deploymentName; + if (resolvedEndpoint == null || resolvedEndpoint.trim().isEmpty()) { + resolvedEndpoint = config.getAzureOpenaiEndpoint(); + if (resolvedEndpoint == null || resolvedEndpoint.isEmpty()) { + resolvedEndpoint = config.getCopilotEndpoint(); + } + } + if (resolvedDeployment == null || resolvedDeployment.trim().isEmpty()) { + resolvedDeployment = config.getAzureOpenaiDeploymentName(); + } + return testAzureOpenaiKey(storedKey, resolvedEndpoint, resolvedDeployment, apiVersion); + } + + // Resolve baseUrl and model from stored config if not provided in request + String resolvedBaseUrl = baseUrl; + String resolvedModel = model; + if ("CLAUDE".equalsIgnoreCase(provider)) { + if (!hasValue(resolvedBaseUrl)) { + resolvedBaseUrl = config.getClaudeBaseUrl(); + } + if (!hasValue(resolvedModel)) { + resolvedModel = config.getClaudeModel(); + } + } else if ("OPENAI".equalsIgnoreCase(provider)) { + if (!hasValue(resolvedBaseUrl)) { + resolvedBaseUrl = config.getOpenaiBaseUrl(); + } + if (!hasValue(resolvedModel)) { + resolvedModel = config.getOpenaiModel(); + } + } + + return testApiKeyWithProvider(provider, storedKey, resolvedBaseUrl, resolvedModel); + }); + } + + // For Azure OpenAI, route to the specialized test method + if ("AZURE_OPENAI".equalsIgnoreCase(provider)) { + return testAzureOpenaiKey(apiKey.trim(), endpoint, deploymentName, apiVersion); + } + + return testApiKeyWithProvider(provider, apiKey.trim(), baseUrl, model); + } + + private Mono>> testApiKeyWithProvider( + String provider, String apiKey, String baseUrl, String model) { + HttpClient httpClient = HttpClient.create().responseTimeout(Duration.ofSeconds(30)); + + // Use SSRF-protected WebClient for consistency + WebClient webClient = WebClientUtils.builder(httpClient).build(); + + final long startTime = System.currentTimeMillis(); + List> steps = new ArrayList<>(); + + if ("OPENAI".equalsIgnoreCase(provider)) { + String effectiveBaseUrl = hasValue(baseUrl) ? baseUrl.trim() : DEFAULT_OPENAI_BASE_URL; + String effectiveModel = hasValue(model) ? model.trim() : DEFAULT_OPENAI_MODEL; + // Use cheap test model only when on default base URL + String testModel = DEFAULT_OPENAI_BASE_URL.equals(effectiveBaseUrl) ? "gpt-3.5-turbo" : effectiveModel; + return testOpenAIKey(webClient, apiKey, startTime, steps, effectiveBaseUrl, testModel); + } else if ("CLAUDE".equalsIgnoreCase(provider)) { + String effectiveBaseUrl = hasValue(baseUrl) ? baseUrl.trim() : DEFAULT_CLAUDE_BASE_URL; + String effectiveModel = hasValue(model) ? model.trim() : DEFAULT_CLAUDE_MODEL; + // Use cheap test model only when on default base URL + String testModel = + DEFAULT_CLAUDE_BASE_URL.equals(effectiveBaseUrl) ? "claude-haiku-4-5-20251001" : effectiveModel; + return testClaudeKey(webClient, apiKey, startTime, steps, effectiveBaseUrl, testModel); + } else { + Map response = new HashMap<>(); + response.put("success", false); + response.put("error", "Unknown provider: " + provider); + return Mono.just(new ResponseDTO<>(HttpStatus.OK, response)); + } + } + + private Mono>> testOpenAIKey( + WebClient webClient, + String apiKey, + long startTime, + List> steps, + String baseUrl, + String model) { + + steps.add(createStep("API Key Format", "success", "Key starts with 'sk-'")); + + // Use chat completions endpoint with minimal request + String payload = "{\"model\":\"" + model + + "\",\"messages\":[{\"role\":\"user\",\"content\":\"Say hello\"}],\"max_tokens\":5}"; + + String testUrl = stripTrailingSlash(baseUrl) + "/v1/chat/completions"; + + return webClient + .post() + .uri(testUrl) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + apiKey) + .bodyValue(payload) + .exchangeToMono(clientResponse -> { + steps.add(createStep("API Connection", "success", "Connected to OpenAI API")); + + return clientResponse + .bodyToMono(String.class) + .defaultIfEmpty("") + .map(responseBody -> { + long responseTime = System.currentTimeMillis() - startTime; + Map response = new HashMap<>(); + int statusCode = clientResponse.statusCode().value(); + + response.put("responseTimeMs", responseTime); + response.put("httpStatus", statusCode); + response.put("provider", "OpenAI"); + + if (statusCode == 200) { + steps.add(createStep("Authentication", "success", "API key is valid")); + steps.add(createStep("Test Request", "success", "Successfully generated response")); + response.put("success", true); + response.put("message", "OpenAI API key is working correctly!"); + + // Try to extract a preview of the response + try { + if (responseBody.contains("\"content\"")) { + int start = responseBody.indexOf("\"content\""); + int contentStart = responseBody.indexOf("\"", start + 10) + 1; + int contentEnd = responseBody.indexOf("\"", contentStart); + if (contentEnd > contentStart && contentEnd - contentStart < 200) { + String content = responseBody.substring(contentStart, contentEnd); + response.put("testResponse", content); + } + } + } catch (Exception ignored) { + } + } else if (statusCode == 401) { + steps.add(createStep("Authentication", "error", "Invalid API key")); + response.put("success", false); + response.put("error", "Invalid API key - authentication failed"); + response.put( + "suggestions", + List.of( + "Check that the API key is correct", + "Ensure the key hasn't been revoked", + "Get a new key from https://platform.openai.com/api-keys")); + } else if (statusCode == 429) { + steps.add(createStep("Authentication", "success", "API key is valid")); + steps.add(createStep("Rate Limit", "error", "Rate limited or quota exceeded")); + response.put("success", false); + response.put("error", "Rate limited or quota exceeded"); + response.put( + "suggestions", + List.of( + "Your API key is valid but you've hit rate limits", + "Check your OpenAI usage and billing", + "Wait a moment and try again")); + } else if (statusCode == 403) { + steps.add(createStep("Authentication", "error", "Access denied")); + response.put("success", false); + response.put("error", "Access denied - check API key permissions"); + response.put( + "suggestions", + List.of( + "Verify the API key has access to chat completions", + "Check if your OpenAI account is in good standing")); + } else { + steps.add(createStep("API Request", "error", "HTTP " + statusCode)); + response.put("success", false); + response.put("error", "OpenAI API returned HTTP " + statusCode); + // Include error details from response + if (responseBody.length() < 500) { + response.put("responsePreview", responseBody); + } + } + + response.put("steps", steps); + return new ResponseDTO<>(HttpStatus.OK, response); + }); + }) + .onErrorResume(error -> { + long responseTime = System.currentTimeMillis() - startTime; + Map response = new HashMap<>(); + response.put("success", false); + response.put("responseTimeMs", responseTime); + response.put("provider", "OpenAI"); + + steps.add(createStep("API Connection", "error", "Failed to connect")); + response.put("error", "Failed to connect to OpenAI API: " + error.getMessage()); + response.put( + "suggestions", + List.of( + "Check your internet connection", + "Verify OpenAI API is accessible from your server", + "Check if there's a firewall blocking the connection")); + response.put("steps", steps); + + return Mono.just(new ResponseDTO<>(HttpStatus.OK, response)); + }); + } + + private Mono>> testClaudeKey( + WebClient webClient, + String apiKey, + long startTime, + List> steps, + String baseUrl, + String model) { + + steps.add(createStep("API Key Format", "success", "Key format accepted")); + + // Use Claude messages endpoint with minimal request + String payload = "{\"model\":\"" + model + + "\",\"max_tokens\":10,\"messages\":[{\"role\":\"user\",\"content\":\"Say hello\"}]}"; + + String testUrl = stripTrailingSlash(baseUrl) + "/v1/messages"; + + return webClient + .post() + .uri(testUrl) + .header("Content-Type", "application/json") + .header("x-api-key", apiKey) + .header("anthropic-version", "2023-06-01") + .bodyValue(payload) + .exchangeToMono(clientResponse -> { + steps.add(createStep("API Connection", "success", "Connected to Anthropic API")); + + return clientResponse + .bodyToMono(String.class) + .defaultIfEmpty("") + .map(responseBody -> { + long responseTime = System.currentTimeMillis() - startTime; + Map response = new HashMap<>(); + int statusCode = clientResponse.statusCode().value(); + + response.put("responseTimeMs", responseTime); + response.put("httpStatus", statusCode); + response.put("provider", "Claude"); + + if (statusCode == 200) { + steps.add(createStep("Authentication", "success", "API key is valid")); + steps.add(createStep("Test Request", "success", "Successfully generated response")); + response.put("success", true); + response.put("message", "Claude API key is working correctly!"); + + // Try to extract a preview of the response + try { + if (responseBody.contains("\"text\"")) { + int start = responseBody.indexOf("\"text\""); + int textStart = responseBody.indexOf("\"", start + 7) + 1; + int textEnd = responseBody.indexOf("\"", textStart); + if (textEnd > textStart && textEnd - textStart < 200) { + String text = responseBody.substring(textStart, textEnd); + response.put("testResponse", text); + } + } + } catch (Exception ignored) { + } + } else if (statusCode == 401) { + steps.add(createStep("Authentication", "error", "Invalid API key")); + response.put("success", false); + response.put("error", "Invalid API key - authentication failed"); + response.put( + "suggestions", + List.of( + "Check that the API key is correct", + "Ensure the key hasn't been revoked", + "Get a new key from https://console.anthropic.com/")); + } else if (statusCode == 429) { + steps.add(createStep("Authentication", "success", "API key is valid")); + steps.add(createStep("Rate Limit", "error", "Rate limited")); + response.put("success", false); + response.put("error", "Rate limited - too many requests"); + response.put( + "suggestions", + List.of( + "Your API key is valid but you've hit rate limits", + "Wait a moment and try again", + "Check your Anthropic usage limits")); + } else if (statusCode == 403) { + steps.add(createStep("Authentication", "error", "Access denied")); + response.put("success", false); + response.put("error", "Access denied - check API key permissions"); + response.put( + "suggestions", + List.of( + "Verify the API key has proper permissions", + "Check if your Anthropic account is active")); + } else if (statusCode == 400) { + // 400 could mean key is valid but request format issue + if (responseBody.contains("invalid_api_key") + || responseBody.contains("authentication")) { + steps.add(createStep("Authentication", "error", "Invalid API key")); + response.put("success", false); + response.put("error", "Invalid API key"); + } else { + steps.add(createStep("Authentication", "success", "API key accepted")); + steps.add(createStep("Request", "error", "Bad request")); + response.put("success", false); + response.put("error", "API request error (key may still be valid)"); + } + if (responseBody.length() < 500) { + response.put("responsePreview", responseBody); + } + } else { + steps.add(createStep("API Request", "error", "HTTP " + statusCode)); + response.put("success", false); + response.put("error", "Anthropic API returned HTTP " + statusCode); + if (responseBody.length() < 500) { + response.put("responsePreview", responseBody); + } + } + + response.put("steps", steps); + return new ResponseDTO<>(HttpStatus.OK, response); + }); + }) + .onErrorResume(error -> { + long responseTime = System.currentTimeMillis() - startTime; + Map response = new HashMap<>(); + response.put("success", false); + response.put("responseTimeMs", responseTime); + response.put("provider", "Claude"); + + steps.add(createStep("API Connection", "error", "Failed to connect")); + response.put("error", "Failed to connect to Anthropic API: " + error.getMessage()); + response.put( + "suggestions", + List.of( + "Check your internet connection", + "Verify Anthropic API is accessible from your server", + "Check if there's a firewall blocking the connection")); + response.put("steps", steps); + + return Mono.just(new ResponseDTO<>(HttpStatus.OK, response)); + }); + } + + private Mono>> testAzureOpenaiKey( + String apiKey, String endpoint, String deploymentName, String apiVersion) { + + final long startTime = System.currentTimeMillis(); + List> steps = new ArrayList<>(); + + steps.add(createStep("API Key Format", "success", "Key format accepted")); + + if (apiKey.length() < 20) { + steps.add(createStep("Key Validation", "error", "Key appears too short")); + Map response = new HashMap<>(); + response.put("success", false); + response.put("error", "API key appears to be invalid - too short"); + response.put("steps", steps); + response.put( + "suggestions", + List.of( + "Azure OpenAI API keys are typically 32 characters", + "Get your key from Azure Portal > Your OpenAI Resource > Keys and Endpoint")); + return Mono.just(new ResponseDTO<>(HttpStatus.OK, response)); + } + + steps.add(createStep("Key Validation", "success", "Key length validated")); + + if (endpoint == null || endpoint.trim().isEmpty()) { + steps.add(createStep("Endpoint", "error", "No endpoint provided")); + Map response = new HashMap<>(); + response.put("success", false); + response.put("error", "Azure OpenAI endpoint is required for testing"); + response.put("steps", steps); + response.put("suggestions", List.of("Enter your Azure OpenAI endpoint URL")); + return Mono.just(new ResponseDTO<>(HttpStatus.OK, response)); + } + + if (deploymentName == null || deploymentName.trim().isEmpty()) { + steps.add(createStep("Deployment", "error", "No deployment name provided")); + Map response = new HashMap<>(); + response.put("success", false); + response.put("error", "Deployment name is required for testing"); + response.put("steps", steps); + response.put("suggestions", List.of("Enter your Azure OpenAI deployment name")); + return Mono.just(new ResponseDTO<>(HttpStatus.OK, response)); + } + + // Construct the full Azure OpenAI URL + String trimmedEndpoint = stripTrailingSlash(endpoint.trim()); + String effectiveApiVersion = hasValue(apiVersion) ? apiVersion.trim() : DEFAULT_AZURE_API_VERSION; + String encodedDeployment = URLEncoder.encode(deploymentName.trim(), StandardCharsets.UTF_8); + String url = trimmedEndpoint + "/openai/deployments/" + encodedDeployment + "/chat/completions?api-version=" + + effectiveApiVersion; + + steps.add(createStep("URL Construction", "success", "Built Azure OpenAI URL")); + + String payload = "{\"messages\":[{\"role\":\"user\",\"content\":\"Say hello\"}],\"max_completion_tokens\":5}"; + + HttpClient httpClient = HttpClient.create().responseTimeout(Duration.ofSeconds(30)); + WebClient webClient = WebClientUtils.builder(httpClient).build(); + + return webClient + .post() + .uri(url) + .header("Content-Type", "application/json") + .header("api-key", apiKey) + .bodyValue(payload) + .exchangeToMono(clientResponse -> { + steps.add(createStep("API Connection", "success", "Connected to Azure OpenAI")); + + return clientResponse + .bodyToMono(String.class) + .defaultIfEmpty("") + .map(responseBody -> { + long responseTime = System.currentTimeMillis() - startTime; + Map response = new HashMap<>(); + int statusCode = clientResponse.statusCode().value(); + + response.put("responseTimeMs", responseTime); + response.put("httpStatus", statusCode); + response.put("provider", "Azure OpenAI"); + + if (statusCode == 200) { + steps.add(createStep("Authentication", "success", "API key is valid")); + steps.add(createStep("Test Request", "success", "Successfully generated response")); + response.put("success", true); + response.put("message", "Azure OpenAI API key is working correctly!"); + + try { + if (responseBody.contains("\"content\"")) { + int start = responseBody.indexOf("\"content\""); + int contentStart = responseBody.indexOf("\"", start + 10) + 1; + int contentEnd = responseBody.indexOf("\"", contentStart); + if (contentEnd > contentStart && contentEnd - contentStart < 200) { + String content = responseBody.substring(contentStart, contentEnd); + response.put("testResponse", content); + } + } + } catch (Exception ignored) { + } + } else if (statusCode == 401) { + steps.add(createStep("Authentication", "error", "Invalid API key")); + response.put("success", false); + response.put("error", "Invalid API key - authentication failed"); + response.put( + "suggestions", + List.of( + "Check that the API key is correct", + "Ensure the key hasn't been revoked", + "Get KEY 1 or KEY 2 from Azure Portal > Your OpenAI Resource > Keys and Endpoint")); + } else if (statusCode == 404) { + steps.add(createStep("Authentication", "success", "API key accepted")); + steps.add(createStep("Deployment", "error", "Deployment not found")); + response.put("success", false); + response.put( + "error", "Deployment '" + deploymentName + "' not found at this endpoint"); + response.put( + "suggestions", + List.of( + "Verify the deployment name matches your Azure OpenAI Studio deployment", + "Check the endpoint URL matches your Azure OpenAI resource", + "Ensure the deployment is active in Azure OpenAI Studio")); + } else if (statusCode == 429) { + steps.add(createStep("Authentication", "success", "API key is valid")); + steps.add(createStep("Rate Limit", "error", "Rate limited or quota exceeded")); + response.put("success", false); + response.put("error", "Rate limited or quota exceeded"); + response.put( + "suggestions", + List.of( + "Your API key is valid but you've hit rate limits", + "Check your Azure OpenAI usage and quotas", + "Wait a moment and try again")); + } else { + steps.add(createStep("API Request", "error", "HTTP " + statusCode)); + response.put("success", false); + response.put("error", "Azure OpenAI API returned HTTP " + statusCode); + if (responseBody.length() < 500) { + response.put("responsePreview", responseBody); + } + } + + response.put("steps", steps); + return new ResponseDTO<>(HttpStatus.OK, response); + }); + }) + .onErrorResume(error -> { + long responseTime = System.currentTimeMillis() - startTime; + Map response = new HashMap<>(); + response.put("success", false); + response.put("responseTimeMs", responseTime); + response.put("provider", "Azure OpenAI"); + + steps.add(createStep("API Connection", "error", "Failed to connect")); + response.put("error", "Failed to connect to Azure OpenAI: " + error.getMessage()); + response.put( + "suggestions", + List.of( + "Check that the endpoint URL is correct", + "Verify the Azure OpenAI resource is accessible", + "Check if there's a firewall blocking the connection")); + response.put("steps", steps); + + return Mono.just(new ResponseDTO<>(HttpStatus.OK, response)); + }); + } + + private List getHttpErrorSuggestions(int statusCode) { + List suggestions = new ArrayList<>(); + if (statusCode == 401 || statusCode == 403) { + suggestions.add("Check if authentication is required"); + suggestions.add("Verify API key or credentials if needed"); + } else if (statusCode == 404) { + suggestions.add("Verify the endpoint path is correct"); + suggestions.add("Check the LLM server documentation for the correct API endpoint"); + suggestions.add("Common endpoints: /api/generate, /api/chat, /v1/completions"); + } else if (statusCode >= 500) { + suggestions.add("The LLM server encountered an internal error"); + suggestions.add("Check the LLM server logs"); + suggestions.add("Verify the server has enough resources (memory, disk)"); + } + return suggestions; + } + + /** + * Validates and sets an API key on the config if present. Throws if validation fails. + */ + private void validateApiKey( + String value, java.util.function.Consumer setter, int maxLength, String fieldName) { + if (value == null || value.trim().isEmpty()) { + return; + } + String trimmed = value.trim(); + if (trimmed.length() > maxLength) { + throw new AppsmithException(AppsmithError.INVALID_PARAMETER, fieldName + " is too long"); + } + setter.accept(trimmed); + } + + /** + * Validates and sets a trimmed string value on the config if present. + * Empty strings are converted to null. Throws if validation fails. + */ + private void validateTrimmedString( + String value, java.util.function.Consumer setter, int maxLength, String fieldName) { + if (value == null) { + return; + } + String trimmed = value.trim(); + if (trimmed.length() > maxLength) { + throw new AppsmithException(AppsmithError.INVALID_PARAMETER, fieldName + " is too long"); + } + setter.accept(trimmed.isEmpty() ? null : trimmed); + } + + /** + * Builds the AI config response map from an organization configuration (for update responses). + */ + private Map buildAIConfigResponse(OrganizationConfiguration config) { + Map response = new HashMap<>(); + response.put("isAIAssistantEnabled", config.getIsAIAssistantEnabled()); + response.put("provider", config.getAiProvider()); + response.put("hasClaudeApiKey", hasValue(config.getClaudeApiKey())); + response.put("hasOpenaiApiKey", hasValue(config.getOpenaiApiKey())); + response.put("hasCopilotApiKey", hasValue(config.getCopilotApiKey())); + response.put("copilotEndpoint", config.getCopilotEndpoint()); + response.put("hasAzureOpenaiApiKey", hasValue(config.getAzureOpenaiApiKey())); + response.put("azureOpenaiEndpoint", config.getAzureOpenaiEndpoint()); + response.put("azureOpenaiDeploymentName", config.getAzureOpenaiDeploymentName()); + response.put("azureOpenaiApiVersion", config.getAzureOpenaiApiVersion()); + response.put("azureOpenaiMaxCompletionTokens", config.getAzureOpenaiMaxCompletionTokens()); + response.put("localLlmUrl", config.getLocalLlmUrl()); + response.put("localLlmContextSize", config.getLocalLlmContextSize()); + response.put("localLlmModel", config.getLocalLlmModel()); + addProviderDefaults(response, config); + return response; + } + + /** + * Builds the AI config response map for GET requests, with proper defaults for null values. + */ + private Map buildAIConfigResponseForGet(OrganizationConfiguration config) { + Map response = new HashMap<>(); + if (config == null) { + return buildEmptyAIConfigResponse(); + } + + response.put("isAIAssistantEnabled", Boolean.TRUE.equals(config.getIsAIAssistantEnabled())); + + // Migrate COPILOT -> AZURE_OPENAI at read time + AIProvider storedProvider = config.getAiProvider(); + if (storedProvider == AIProvider.COPILOT) { + storedProvider = AIProvider.AZURE_OPENAI; + } + response.put("provider", storedProvider != null ? storedProvider.name() : ""); + + response.put("hasClaudeApiKey", hasValue(config.getClaudeApiKey())); + response.put("hasOpenaiApiKey", hasValue(config.getOpenaiApiKey())); + response.put("hasCopilotApiKey", hasValue(config.getCopilotApiKey())); + response.put("copilotEndpoint", defaultString(config.getCopilotEndpoint())); + + // Azure OpenAI fields - fall back to copilot fields for migration + boolean hasAzureKey = hasValue(config.getAzureOpenaiApiKey()) || hasValue(config.getCopilotApiKey()); + String azureEndpoint = ObjectUtils.defaultIfNull(config.getAzureOpenaiEndpoint(), config.getCopilotEndpoint()); + + response.put("hasAzureOpenaiApiKey", hasAzureKey); + response.put("azureOpenaiEndpoint", defaultString(azureEndpoint)); + response.put("azureOpenaiDeploymentName", defaultString(config.getAzureOpenaiDeploymentName())); + response.put( + "azureOpenaiApiVersion", + ObjectUtils.defaultIfNull(config.getAzureOpenaiApiVersion(), DEFAULT_AZURE_API_VERSION)); + response.put( + "azureOpenaiMaxCompletionTokens", + ObjectUtils.defaultIfNull( + config.getAzureOpenaiMaxCompletionTokens(), DEFAULT_AZURE_MAX_COMPLETION_TOKENS)); + + response.put("localLlmUrl", defaultString(config.getLocalLlmUrl())); + response.put("localLlmContextSize", ObjectUtils.defaultIfNull(config.getLocalLlmContextSize(), -1)); + response.put("localLlmModel", defaultString(config.getLocalLlmModel())); + + addProviderDefaults(response, config); + + return response; + } + + private Map buildEmptyAIConfigResponse() { + Map response = new HashMap<>(); + response.put("isAIAssistantEnabled", false); + response.put("provider", ""); + response.put("hasClaudeApiKey", false); + response.put("hasOpenaiApiKey", false); + response.put("hasCopilotApiKey", false); + response.put("copilotEndpoint", ""); + response.put("hasAzureOpenaiApiKey", false); + response.put("azureOpenaiEndpoint", ""); + response.put("azureOpenaiDeploymentName", ""); + response.put("azureOpenaiApiVersion", DEFAULT_AZURE_API_VERSION); + response.put("azureOpenaiMaxCompletionTokens", DEFAULT_AZURE_MAX_COMPLETION_TOKENS); + response.put("localLlmUrl", ""); + response.put("localLlmContextSize", -1); + response.put("localLlmModel", ""); + addProviderDefaults(response, null); + return response; + } + + private void addProviderDefaults(Map response, OrganizationConfiguration config) { + response.put( + "claudeModel", + config != null + ? ObjectUtils.defaultIfNull(config.getClaudeModel(), DEFAULT_CLAUDE_MODEL) + : DEFAULT_CLAUDE_MODEL); + response.put( + "claudeBaseUrl", + config != null + ? ObjectUtils.defaultIfNull(config.getClaudeBaseUrl(), DEFAULT_CLAUDE_BASE_URL) + : DEFAULT_CLAUDE_BASE_URL); + response.put( + "openaiModel", + config != null + ? ObjectUtils.defaultIfNull(config.getOpenaiModel(), DEFAULT_OPENAI_MODEL) + : DEFAULT_OPENAI_MODEL); + response.put( + "openaiBaseUrl", + config != null + ? ObjectUtils.defaultIfNull(config.getOpenaiBaseUrl(), DEFAULT_OPENAI_BASE_URL) + : DEFAULT_OPENAI_BASE_URL); + } + + private boolean hasValue(String value) { + return value != null && !value.isEmpty(); + } + + private String defaultString(String value) { + return value != null ? value : ""; + } + + private String stripTrailingSlash(String url) { + return url.endsWith("/") ? url.substring(0, url.length() - 1) : url; + } } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/ce/UserControllerCE.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/ce/UserControllerCE.java index df45b419343e..2e6b9be497d9 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/ce/UserControllerCE.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/controllers/ce/UserControllerCE.java @@ -5,19 +5,24 @@ import com.appsmith.server.domains.Application; import com.appsmith.server.domains.User; import com.appsmith.server.domains.UserData; +import com.appsmith.server.dtos.AIRequestDTO; import com.appsmith.server.dtos.InviteUsersDTO; import com.appsmith.server.dtos.ResendEmailVerificationDTO; import com.appsmith.server.dtos.ResetUserPasswordDTO; import com.appsmith.server.dtos.ResponseDTO; import com.appsmith.server.dtos.UserProfileDTO; import com.appsmith.server.dtos.UserUpdateDTO; +import com.appsmith.server.exceptions.AppsmithError; +import com.appsmith.server.exceptions.AppsmithException; import com.appsmith.server.services.SessionUserService; import com.appsmith.server.services.UserDataService; import com.appsmith.server.services.UserService; import com.appsmith.server.services.UserWorkspaceService; +import com.appsmith.server.services.ce.AIAssistantServiceCE; import com.appsmith.server.solutions.UserAndAccessManagementService; import com.appsmith.server.solutions.UserSignup; import com.fasterxml.jackson.annotation.JsonView; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; @@ -52,6 +57,7 @@ public class UserControllerCE { private final UserSignup userSignup; private final UserDataService userDataService; private final UserAndAccessManagementService userAndAccessManagementService; + private final AIAssistantServiceCE aiAssistantService; @JsonView(Views.Public.class) @PostMapping(consumes = {MediaType.APPLICATION_FORM_URLENCODED_VALUE}) @@ -208,6 +214,41 @@ public Mono verifyEmailVerificationToken(ServerWebExchange exchange) { return service.verifyEmailVerificationToken(exchange); } + @JsonView(Views.Public.class) + @PostMapping("/ai-assistant/request") + public Mono>> requestAIResponse(@RequestBody @Valid AIRequestDTO request) { + return aiAssistantService + .getAIResponse( + request.getProvider(), + request.getPrompt(), + request.getContext(), + request.getConversationHistory()) + .map(response -> Map.of("response", response, "provider", request.getProvider())) + .map(result -> new ResponseDTO<>(HttpStatus.OK, result)) + .onErrorResume(error -> { + String errorMessage = getAIErrorMessage(error); + return Mono.just(new ResponseDTO>( + HttpStatus.BAD_REQUEST.value(), null, errorMessage, false)); + }); + } + + private String getAIErrorMessage(Throwable error) { + if (!(error instanceof AppsmithException appsmithError)) { + log.error("Non-Appsmith AI error: {}", error.getMessage(), error); + return "Failed to get AI response. Please try again or check your AI configuration."; + } + if (appsmithError.getError() == AppsmithError.INVALID_CREDENTIALS) { + return "Invalid API key. Please check your API key in settings."; + } + if (appsmithError.getMessage() != null && appsmithError.getMessage().contains("Rate limit")) { + return "Rate limit exceeded. Please try again later."; + } + // Use getMessage() which includes the formatted args (e.g. actual error details) + return appsmithError.getMessage() != null + ? appsmithError.getMessage() + : appsmithError.getError().getMessage(); + } + /** * Toggle favorite status for an application * @param applicationId Application ID to toggle favorite status for diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/AIProvider.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/AIProvider.java new file mode 100644 index 000000000000..49bd36fde839 --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/AIProvider.java @@ -0,0 +1,10 @@ +package com.appsmith.server.domains; + +public enum AIProvider { + CLAUDE, + OPENAI, + @Deprecated + COPILOT, // Use AZURE_OPENAI instead + LOCAL_LLM, + AZURE_OPENAI +} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/ce/OrganizationConfigurationCE.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/ce/OrganizationConfigurationCE.java index 0b6ee27834ba..ddedbb500d49 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/ce/OrganizationConfigurationCE.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/domains/ce/OrganizationConfigurationCE.java @@ -1,12 +1,18 @@ package com.appsmith.server.domains.ce; +import com.appsmith.external.annotations.encryption.Encrypted; +import com.appsmith.external.views.Views; import com.appsmith.server.constants.FeatureMigrationType; import com.appsmith.server.constants.LicensePlan; import com.appsmith.server.constants.MigrationStatus; +import com.appsmith.server.domains.AIProvider; import com.appsmith.server.domains.License; import com.appsmith.server.domains.OrganizationConfiguration; import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonView; import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; import lombok.experimental.FieldNameConstants; import org.apache.commons.lang3.ObjectUtils; import org.springframework.data.annotation.Transient; @@ -63,6 +69,82 @@ public class OrganizationConfigurationCE implements Serializable { private Boolean isAtomicPushAllowed = false; + @JsonView(Views.Internal.class) + @ToString.Exclude + @EqualsAndHashCode.Exclude + @Encrypted private String claudeApiKey; + + @JsonView(Views.Internal.class) + @ToString.Exclude + @EqualsAndHashCode.Exclude + @Encrypted private String openaiApiKey; + + @JsonView(Views.Internal.class) + @ToString.Exclude + @EqualsAndHashCode.Exclude + @Encrypted private String copilotApiKey; + + @JsonView(Views.Internal.class) + @JsonInclude + private String copilotEndpoint; + + @JsonView(Views.Internal.class) + @ToString.Exclude + @EqualsAndHashCode.Exclude + @Encrypted private String azureOpenaiApiKey; + + @JsonView(Views.Internal.class) + @JsonInclude + private String azureOpenaiEndpoint; + + @JsonView(Views.Internal.class) + @JsonInclude + private String azureOpenaiDeploymentName; + + @JsonView(Views.Internal.class) + @JsonInclude + private String azureOpenaiApiVersion; + + @JsonView(Views.Internal.class) + @JsonInclude + private Integer azureOpenaiMaxCompletionTokens; + + @JsonView(Views.Public.class) + @JsonInclude + private AIProvider aiProvider; + + @JsonView(Views.Public.class) + @JsonInclude + private Boolean isAIAssistantEnabled = false; + + @JsonView(Views.Internal.class) + @JsonInclude + private String localLlmUrl; + + @JsonView(Views.Internal.class) + @JsonInclude + private Integer localLlmContextSize; + + @JsonView(Views.Internal.class) + @JsonInclude + private String localLlmModel; + + @JsonView(Views.Internal.class) + @JsonInclude + private String claudeModel; + + @JsonView(Views.Internal.class) + @JsonInclude + private String claudeBaseUrl; + + @JsonView(Views.Internal.class) + @JsonInclude + private String openaiModel; + + @JsonView(Views.Internal.class) + @JsonInclude + private String openaiBaseUrl; + public void addThirdPartyAuth(String auth) { if (thirdPartyAuths == null) { thirdPartyAuths = new ArrayList<>(); @@ -90,6 +172,26 @@ public void copyNonSensitiveValues(OrganizationConfiguration organizationConfigu migrationStatus = organizationConfiguration.getMigrationStatus(); isStrongPasswordPolicyEnabled = organizationConfiguration.getIsStrongPasswordPolicyEnabled(); isAtomicPushAllowed = organizationConfiguration.getIsAtomicPushAllowed(); + copilotEndpoint = ObjectUtils.defaultIfNull(organizationConfiguration.getCopilotEndpoint(), copilotEndpoint); + azureOpenaiEndpoint = + ObjectUtils.defaultIfNull(organizationConfiguration.getAzureOpenaiEndpoint(), azureOpenaiEndpoint); + azureOpenaiDeploymentName = ObjectUtils.defaultIfNull( + organizationConfiguration.getAzureOpenaiDeploymentName(), azureOpenaiDeploymentName); + azureOpenaiApiVersion = + ObjectUtils.defaultIfNull(organizationConfiguration.getAzureOpenaiApiVersion(), azureOpenaiApiVersion); + azureOpenaiMaxCompletionTokens = ObjectUtils.defaultIfNull( + organizationConfiguration.getAzureOpenaiMaxCompletionTokens(), azureOpenaiMaxCompletionTokens); + aiProvider = ObjectUtils.defaultIfNull(organizationConfiguration.getAiProvider(), aiProvider); + isAIAssistantEnabled = + ObjectUtils.defaultIfNull(organizationConfiguration.getIsAIAssistantEnabled(), isAIAssistantEnabled); + localLlmUrl = ObjectUtils.defaultIfNull(organizationConfiguration.getLocalLlmUrl(), localLlmUrl); + localLlmContextSize = + ObjectUtils.defaultIfNull(organizationConfiguration.getLocalLlmContextSize(), localLlmContextSize); + localLlmModel = ObjectUtils.defaultIfNull(organizationConfiguration.getLocalLlmModel(), localLlmModel); + claudeModel = ObjectUtils.defaultIfNull(organizationConfiguration.getClaudeModel(), claudeModel); + claudeBaseUrl = ObjectUtils.defaultIfNull(organizationConfiguration.getClaudeBaseUrl(), claudeBaseUrl); + openaiModel = ObjectUtils.defaultIfNull(organizationConfiguration.getOpenaiModel(), openaiModel); + openaiBaseUrl = ObjectUtils.defaultIfNull(organizationConfiguration.getOpenaiBaseUrl(), openaiBaseUrl); } protected static T getComputedValue(T defaultValue, T updatedValue, T currentValue) { diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/AIConfigDTO.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/AIConfigDTO.java new file mode 100644 index 000000000000..a185c9fe8c46 --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/AIConfigDTO.java @@ -0,0 +1,59 @@ +package com.appsmith.server.dtos; + +import com.appsmith.server.domains.AIProvider; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class AIConfigDTO { + @Size(max = 500, message = "API key is too long") + private String claudeApiKey; + + @Size(max = 500, message = "API key is too long") + private String openaiApiKey; + + @Size(max = 500, message = "API key is too long") + private String copilotApiKey; + + @Size(max = 2000, message = "Copilot endpoint URL is too long") + private String copilotEndpoint; + + @Size(max = 500, message = "API key is too long") + private String azureOpenaiApiKey; + + @Size(max = 2000, message = "Azure OpenAI endpoint URL is too long") + private String azureOpenaiEndpoint; + + @Size(max = 200, message = "Deployment name is too long") + private String azureOpenaiDeploymentName; + + @Size(max = 50, message = "API version is too long") + private String azureOpenaiApiVersion; + + private Integer azureOpenaiMaxCompletionTokens; + + @NotNull(message = "Provider is required") private AIProvider provider; + + @NotNull(message = "Enabled flag is required") private Boolean isAIAssistantEnabled; + + @Size(max = 2000, message = "URL is too long") + private String localLlmUrl; + + private Integer localLlmContextSize; + + @Size(max = 200, message = "Model name is too long") + private String localLlmModel; + + @Size(max = 200, message = "Model name is too long") + private String claudeModel; + + @Size(max = 2000, message = "URL is too long") + private String claudeBaseUrl; + + @Size(max = 200, message = "Model name is too long") + private String openaiModel; + + @Size(max = 2000, message = "URL is too long") + private String openaiBaseUrl; +} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/AIEditorContextDTO.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/AIEditorContextDTO.java new file mode 100644 index 000000000000..9d271a676992 --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/AIEditorContextDTO.java @@ -0,0 +1,31 @@ +package com.appsmith.server.dtos; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class AIEditorContextDTO { + @Size(max = 200, message = "Function name cannot exceed 200 characters") + private String functionName; + + @Min(value = 0, message = "Cursor line number must be non-negative") + @Max(value = 1000000, message = "Cursor line number is too large") + private Integer cursorLineNumber; + + @Size(max = 50000, message = "Function string cannot exceed 50000 characters") + private String functionString; + + @Size(max = 100, message = "Mode cannot exceed 100 characters") + private String mode; + + @Size(max = 100000, message = "Current value cannot exceed 100000 characters") + private String currentValue; + + @Size(max = 15000, message = "Database schema cannot exceed 15000 characters") + private String databaseSchema; + + @Size(max = 100, message = "Datasource type cannot exceed 100 characters") + private String datasourceType; +} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/AIMessageDTO.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/AIMessageDTO.java new file mode 100644 index 000000000000..200c9e094c18 --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/AIMessageDTO.java @@ -0,0 +1,17 @@ +package com.appsmith.server.dtos; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class AIMessageDTO { + @NotBlank(message = "Role is required") + @Pattern(regexp = "^(user|assistant)$", message = "Role must be 'user' or 'assistant'") + private String role; + + @NotBlank(message = "Content is required") + @Size(max = 50000, message = "Message content cannot exceed 50000 characters") + private String content; +} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/AIRequestDTO.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/AIRequestDTO.java new file mode 100644 index 000000000000..41f814c43a7f --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/dtos/AIRequestDTO.java @@ -0,0 +1,27 @@ +package com.appsmith.server.dtos; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; + +@Data +public class AIRequestDTO { + @NotBlank(message = "Provider is required") + private String provider; + + @NotBlank(message = "Prompt is required") + @Size(max = 10000, message = "Prompt cannot exceed 10000 characters") + private String prompt; + + @NotNull(message = "Context is required") @Valid + private AIEditorContextDTO context; + + // Optional conversation history for multi-turn chat + @Valid + @Size(max = 20, message = "Conversation history cannot exceed 20 messages") + private List conversationHistory; +} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/migrations/db/ce/Migration075AddIsAIAssistantEnabledToOrganizationConfiguration.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/migrations/db/ce/Migration075AddIsAIAssistantEnabledToOrganizationConfiguration.java new file mode 100644 index 000000000000..24c8a4e19eef --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/migrations/db/ce/Migration075AddIsAIAssistantEnabledToOrganizationConfiguration.java @@ -0,0 +1,64 @@ +package com.appsmith.server.migrations.db.ce; + +import com.appsmith.server.domains.Organization; +import com.mongodb.client.result.UpdateResult; +import io.mongock.api.annotations.ChangeUnit; +import io.mongock.api.annotations.Execution; +import io.mongock.api.annotations.RollbackExecution; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; + +@Slf4j +@RequiredArgsConstructor +@ChangeUnit(order = "075", id = "add-is-ai-assistant-enabled-to-organization-configuration") +public class Migration075AddIsAIAssistantEnabledToOrganizationConfiguration { + + private final MongoTemplate mongoTemplate; + + @RollbackExecution + public void rollbackExecution() {} + + @Execution + public void executeMigration() { + // Ensure isAIAssistantEnabled exists on all organizations so that + // getAIConfig and other code can rely on the field being present. + + // Case 1: organizationConfiguration is null — must set the whole object + // because MongoDB cannot use $set on a dotted path when the parent is null. + Criteria nullConfig = + Criteria.where(Organization.Fields.organizationConfiguration).is(null); + + // Case 2: organizationConfiguration exists but the flag is missing + Criteria missingFlagOnExistingConfig = new Criteria() + .andOperator( + Criteria.where(Organization.Fields.organizationConfiguration) + .ne(null), + Criteria.where("organizationConfiguration.isAIAssistantEnabled") + .exists(false)); + + // For null configs, set the whole object to avoid "Cannot create field in null element" error + UpdateResult nullResult = mongoTemplate.updateMulti( + Query.query(nullConfig), + new Update().set("organizationConfiguration", new org.bson.Document("isAIAssistantEnabled", false)), + Organization.class); + + // For existing configs, just set the nested field + UpdateResult missingResult = mongoTemplate.updateMulti( + Query.query(missingFlagOnExistingConfig), + new Update().set("organizationConfiguration.isAIAssistantEnabled", false), + Organization.class); + + long total = nullResult.getModifiedCount() + missingResult.getModifiedCount(); + if (total > 0) { + log.info( + "Added isAIAssistantEnabled for {} organization(s) ({} null config, {} missing flag).", + total, + nullResult.getModifiedCount(), + missingResult.getModifiedCount()); + } + } +} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/AIReferenceService.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/AIReferenceService.java new file mode 100644 index 000000000000..d0f1d4313d61 --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/AIReferenceService.java @@ -0,0 +1,5 @@ +package com.appsmith.server.services; + +import com.appsmith.server.services.ce.AIReferenceServiceCE; + +public interface AIReferenceService extends AIReferenceServiceCE {} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/AIReferenceServiceImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/AIReferenceServiceImpl.java new file mode 100644 index 000000000000..98ef9131360c --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/AIReferenceServiceImpl.java @@ -0,0 +1,14 @@ +package com.appsmith.server.services; + +import com.appsmith.server.services.ce.AIReferenceServiceCEImpl; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +public class AIReferenceServiceImpl extends AIReferenceServiceCEImpl implements AIReferenceService { + + public AIReferenceServiceImpl() { + super(); + } +} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/AIAssistantServiceCE.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/AIAssistantServiceCE.java new file mode 100644 index 000000000000..f4c0bf1cce47 --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/AIAssistantServiceCE.java @@ -0,0 +1,14 @@ +package com.appsmith.server.services.ce; + +import com.appsmith.server.dtos.AIEditorContextDTO; +import com.appsmith.server.dtos.AIMessageDTO; +import reactor.core.publisher.Mono; + +import java.util.List; + +public interface AIAssistantServiceCE { + Mono getAIResponse(String provider, String prompt, AIEditorContextDTO context); + + Mono getAIResponse( + String provider, String prompt, AIEditorContextDTO context, List conversationHistory); +} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/AIAssistantServiceCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/AIAssistantServiceCEImpl.java new file mode 100644 index 000000000000..54544ae3e89f --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/AIAssistantServiceCEImpl.java @@ -0,0 +1,762 @@ +package com.appsmith.server.services.ce; + +import com.appsmith.server.domains.AIProvider; +import com.appsmith.server.dtos.AIEditorContextDTO; +import com.appsmith.server.dtos.AIMessageDTO; +import com.appsmith.server.exceptions.AppsmithError; +import com.appsmith.server.exceptions.AppsmithException; +import com.appsmith.server.services.AIReferenceService; +import com.appsmith.server.services.OrganizationService; +import com.appsmith.util.WebClientUtils; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.ExchangeStrategies; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; +import reactor.netty.http.client.HttpClient; + +import java.net.InetAddress; +import java.net.URI; +import java.net.URLEncoder; +import java.net.UnknownHostException; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static com.appsmith.server.constants.AIConstants.DEFAULT_AZURE_API_VERSION; +import static com.appsmith.server.constants.AIConstants.DEFAULT_AZURE_MAX_COMPLETION_TOKENS; +import static com.appsmith.server.constants.AIConstants.DEFAULT_CLAUDE_BASE_URL; +import static com.appsmith.server.constants.AIConstants.DEFAULT_CLAUDE_MODEL; +import static com.appsmith.server.constants.AIConstants.DEFAULT_OPENAI_BASE_URL; +import static com.appsmith.server.constants.AIConstants.DEFAULT_OPENAI_MODEL; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AIAssistantServiceCEImpl implements AIAssistantServiceCE { + + private final OrganizationService organizationService; + private final AIReferenceService aiReferenceService; + + private static final WebClient claudeWebClient = WebClientUtils.builder( + HttpClient.create().responseTimeout(Duration.ofSeconds(60))) + .baseUrl(DEFAULT_CLAUDE_BASE_URL) + .build(); + + private static final WebClient openaiWebClient = WebClientUtils.builder( + HttpClient.create().responseTimeout(Duration.ofSeconds(60))) + .baseUrl(DEFAULT_OPENAI_BASE_URL) + .build(); + + /** + * Returns the cached WebClient if the baseUrl matches the default, otherwise builds a new one. + */ + private static WebClient getOrBuildWebClient(String baseUrl, String defaultBaseUrl, WebClient cachedClient) { + if (baseUrl == null || baseUrl.isEmpty() || defaultBaseUrl.equals(baseUrl)) { + return cachedClient; + } + return WebClientUtils.builder(HttpClient.create().responseTimeout(Duration.ofSeconds(60))) + .baseUrl(baseUrl) + .build(); + } + + private static String resolveConfig(String configValue, String defaultValue) { + return (configValue != null && !configValue.isEmpty()) ? configValue : defaultValue; + } + + @Override + public Mono getAIResponse(String provider, String prompt, AIEditorContextDTO context) { + return getAIResponse(provider, prompt, context, null); + } + + @Override + public Mono getAIResponse( + String provider, String prompt, AIEditorContextDTO context, List conversationHistory) { + if (provider == null || provider.trim().isEmpty() || provider.length() > 50) { + return Mono.error(new AppsmithException(AppsmithError.INVALID_PARAMETER, "Invalid provider")); + } + + AIProvider providerEnum; + try { + providerEnum = AIProvider.valueOf(provider.toUpperCase().trim()); + } catch (IllegalArgumentException e) { + return Mono.error(new AppsmithException(AppsmithError.INVALID_PARAMETER, "Invalid provider")); + } + + return organizationService.getCurrentUserOrganization().flatMap(organization -> { + if (organization == null || organization.getOrganizationConfiguration() == null) { + return Mono.error(new AppsmithException(AppsmithError.NO_RESOURCE_FOUND, "Organization not found")); + } + + var orgConfig = organization.getOrganizationConfiguration(); + if (!Boolean.TRUE.equals(orgConfig.getIsAIAssistantEnabled())) { + return Mono.error(new AppsmithException( + AppsmithError.INVALID_PARAMETER, + "AI Assistant is disabled. Please contact your administrator.")); + } + + // LOCAL_LLM uses URL + model, not API key + if (providerEnum == AIProvider.LOCAL_LLM) { + String url = orgConfig.getLocalLlmUrl(); + String model = orgConfig.getLocalLlmModel(); + if (url == null || url.trim().isEmpty()) { + return Mono.error( + new AppsmithException(AppsmithError.NO_RESOURCE_FOUND, "Local LLM URL not configured")); + } + if (model == null || model.trim().isEmpty()) { + return Mono.error( + new AppsmithException(AppsmithError.NO_RESOURCE_FOUND, "Local LLM model not configured")); + } + return callLocalLLMAPI(url.trim(), model.trim(), prompt, context, conversationHistory); + } + + // AZURE_OPENAI uses endpoint + deployment name + API key + if (providerEnum == AIProvider.AZURE_OPENAI || providerEnum == AIProvider.COPILOT) { + // Try new azure fields first, fall back to copilot fields for migration + String apiKey = orgConfig.getAzureOpenaiApiKey(); + if (apiKey == null || apiKey.trim().isEmpty()) { + apiKey = orgConfig.getCopilotApiKey(); + } + String endpoint = orgConfig.getAzureOpenaiEndpoint(); + if (endpoint == null || endpoint.trim().isEmpty()) { + endpoint = orgConfig.getCopilotEndpoint(); + } + String deploymentName = orgConfig.getAzureOpenaiDeploymentName(); + + if (apiKey == null || apiKey.trim().isEmpty()) { + return Mono.error(new AppsmithException( + AppsmithError.NO_RESOURCE_FOUND, "Azure OpenAI API key not configured")); + } + if (endpoint == null || endpoint.trim().isEmpty()) { + return Mono.error( + new AppsmithException( + AppsmithError.NO_RESOURCE_FOUND, + "Azure OpenAI endpoint not configured. Add your Azure OpenAI resource endpoint (e.g. https://YOUR_RESOURCE.openai.azure.com/)")); + } + if (deploymentName == null || deploymentName.trim().isEmpty()) { + return Mono.error( + new AppsmithException( + AppsmithError.NO_RESOURCE_FOUND, + "Azure OpenAI deployment name not configured. Add the name of your model deployment from Azure OpenAI Studio.")); + } + String apiVersion = orgConfig.getAzureOpenaiApiVersion(); + Integer maxCompletionTokens = orgConfig.getAzureOpenaiMaxCompletionTokens(); + return callAzureOpenAIAPI( + endpoint.trim(), + deploymentName.trim(), + apiKey, + prompt, + context, + conversationHistory, + apiVersion, + maxCompletionTokens); + } + + // CLAUDE and OPENAI use API key + String apiKey = + switch (providerEnum) { + case CLAUDE -> orgConfig.getClaudeApiKey(); + case OPENAI -> orgConfig.getOpenaiApiKey(); + default -> null; + }; + + if (apiKey == null || apiKey.trim().isEmpty()) { + return Mono.error(new AppsmithException( + AppsmithError.NO_RESOURCE_FOUND, "API key not configured for this provider")); + } + + return switch (providerEnum) { + case CLAUDE -> callClaudeAPI( + apiKey, + prompt, + context, + conversationHistory, + orgConfig.getClaudeModel(), + orgConfig.getClaudeBaseUrl()); + case OPENAI -> callOpenAIAPI( + apiKey, + prompt, + context, + conversationHistory, + orgConfig.getOpenaiModel(), + orgConfig.getOpenaiBaseUrl()); + default -> Mono.error( + new AppsmithException(AppsmithError.INVALID_PARAMETER, "Provider not supported: " + provider)); + }; + }); + } + + private Mono callClaudeAPI( + String apiKey, + String prompt, + AIEditorContextDTO context, + List conversationHistory, + String configModel, + String configBaseUrl) { + String systemPrompt = buildSystemPrompt(context); + String userPrompt = buildUserPrompt(prompt, context); + + if (userPrompt == null || userPrompt.trim().isEmpty()) { + return Mono.error(new AppsmithException(AppsmithError.INVALID_PARAMETER, "Prompt cannot be empty")); + } + + if (userPrompt.length() > 150000) { + return Mono.error(new AppsmithException(AppsmithError.INVALID_PARAMETER, "Prompt is too long")); + } + + List> messages = new ArrayList<>(); + + // Add conversation history if present + if (conversationHistory != null && !conversationHistory.isEmpty()) { + for (AIMessageDTO msg : conversationHistory) { + Map historyMsg = new HashMap<>(); + historyMsg.put("role", msg.getRole()); + historyMsg.put("content", msg.getContent()); + messages.add(historyMsg); + } + } + + // Add current user message with system context + Map messageContent = new HashMap<>(); + messageContent.put("role", "user"); + // Include system prompt only in first message or if no history + if (messages.isEmpty()) { + messageContent.put("content", systemPrompt + "\n\n" + userPrompt); + } else { + messageContent.put("content", userPrompt); + } + messages.add(messageContent); + + String effectiveModel = resolveConfig(configModel, DEFAULT_CLAUDE_MODEL); + String effectiveBaseUrl = resolveConfig(configBaseUrl, DEFAULT_CLAUDE_BASE_URL); + + Map requestBody = new HashMap<>(); + requestBody.put("model", effectiveModel); + requestBody.put("max_tokens", 8192); + requestBody.put("messages", messages); + // Add system prompt as separate field for Claude + if (!messages.isEmpty() && conversationHistory != null && !conversationHistory.isEmpty()) { + requestBody.put("system", systemPrompt); + } + + return getOrBuildWebClient(effectiveBaseUrl, DEFAULT_CLAUDE_BASE_URL, claudeWebClient) + .post() + .uri("/v1/messages") + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .header("x-api-key", apiKey) + .header("anthropic-version", "2023-06-01") + .body(BodyInserters.fromValue(requestBody)) + .retrieve() + .onStatus(HttpStatusCode::isError, response -> response.bodyToMono(String.class) + .flatMap(errorBody -> { + int statusCode = response.statusCode().value(); + if (statusCode == 401 || statusCode == 403) { + return Mono.error( + new AppsmithException(AppsmithError.INVALID_CREDENTIALS, "Invalid API key")); + } else if (statusCode == 429) { + return Mono.error(new AppsmithException( + AppsmithError.INTERNAL_SERVER_ERROR, + "Rate limit exceeded. Please try again later.")); + } + return Mono.error(new AppsmithException( + AppsmithError.INTERNAL_SERVER_ERROR, "AI API request failed")); + })) + .bodyToMono(JsonNode.class) + .map(this::extractClaudeResponse) + .doOnError(error -> { + if (error instanceof AppsmithException) { + log.error("Claude API error for provider: CLAUDE", error); + } else { + log.error("Unexpected Claude API error", error); + } + }); + } + + private Mono callOpenAIAPI( + String apiKey, + String prompt, + AIEditorContextDTO context, + List conversationHistory, + String configModel, + String configBaseUrl) { + String systemPrompt = buildSystemPrompt(context); + String userPrompt = buildUserPrompt(prompt, context); + + List> messages = new ArrayList<>(); + // Always add system prompt first for OpenAI + messages.add(Map.of("role", "system", "content", systemPrompt)); + + // Add conversation history if present + if (conversationHistory != null && !conversationHistory.isEmpty()) { + for (AIMessageDTO msg : conversationHistory) { + messages.add(Map.of("role", msg.getRole(), "content", msg.getContent())); + } + } + + // Add current user message + messages.add(Map.of("role", "user", "content", userPrompt)); + + String effectiveModel = resolveConfig(configModel, DEFAULT_OPENAI_MODEL); + String effectiveBaseUrl = resolveConfig(configBaseUrl, DEFAULT_OPENAI_BASE_URL); + + Map requestBody = new HashMap<>(); + requestBody.put("model", effectiveModel); + requestBody.put("messages", messages); + requestBody.put("temperature", 0.7); + + return getOrBuildWebClient(effectiveBaseUrl, DEFAULT_OPENAI_BASE_URL, openaiWebClient) + .post() + .uri("/v1/chat/completions") + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + apiKey) + .body(BodyInserters.fromValue(requestBody)) + .retrieve() + .onStatus(HttpStatusCode::isError, response -> response.bodyToMono(String.class) + .flatMap(errorBody -> { + int statusCode = response.statusCode().value(); + if (statusCode == 401 || statusCode == 403) { + return Mono.error( + new AppsmithException(AppsmithError.INVALID_CREDENTIALS, "Invalid API key")); + } else if (statusCode == 429) { + return Mono.error(new AppsmithException( + AppsmithError.INTERNAL_SERVER_ERROR, + "Rate limit exceeded. Please try again later.")); + } + return Mono.error(new AppsmithException( + AppsmithError.INTERNAL_SERVER_ERROR, "AI API request failed")); + })) + .bodyToMono(JsonNode.class) + .map(this::extractOpenAICompatibleResponse) + .doOnError(error -> { + if (error instanceof AppsmithException) { + log.error("OpenAI API error for provider: OPENAI", error); + } else { + log.error("Unexpected OpenAI API error", error); + } + }); + } + + private Mono callLocalLLMAPI( + String url, + String model, + String prompt, + AIEditorContextDTO context, + List conversationHistory) { + String systemPrompt = buildSystemPrompt(context); + String userPrompt = buildUserPrompt(prompt, context); + + if (userPrompt == null || userPrompt.trim().isEmpty()) { + return Mono.error(new AppsmithException(AppsmithError.INVALID_PARAMETER, "Prompt cannot be empty")); + } + + if (userPrompt.length() > 150000) { + return Mono.error(new AppsmithException(AppsmithError.INVALID_PARAMETER, "Prompt is too long")); + } + + // Validate URL scheme and host to prevent protocol-based attacks and authority-less URIs like "http:foo" + URI parsedUri; + try { + parsedUri = URI.create(url); + String scheme = parsedUri.getScheme(); + if (scheme == null || (!scheme.equalsIgnoreCase("http") && !scheme.equalsIgnoreCase("https"))) { + return Mono.error(new AppsmithException( + AppsmithError.INVALID_PARAMETER, "Local LLM URL must use http or https scheme")); + } + if (parsedUri.getHost() == null || parsedUri.getHost().isEmpty()) { + return Mono.error(new AppsmithException( + AppsmithError.INVALID_PARAMETER, + "Local LLM URL must include a valid host and use http or https scheme")); + } + } catch (IllegalArgumentException e) { + return Mono.error(new AppsmithException(AppsmithError.INVALID_PARAMETER, "Invalid Local LLM URL")); + } + + // Block cloud metadata endpoints (link-local 169.254.x.x) while allowing + // localhost, 10.x.x.x, 172.16.x.x, and 192.168.x.x for legitimate Ollama setups + String host = parsedUri.getHost(); + if (host != null) { + try { + InetAddress resolved = InetAddress.getByName(host); + if (resolved.isLinkLocalAddress()) { + return Mono.error(new AppsmithException( + AppsmithError.INVALID_PARAMETER, "Local LLM URL resolves to a link-local address")); + } + } catch (UnknownHostException e) { + // Let it proceed - will fail at connection time + } + } + + // Ollama uses /api/chat with messages format (OpenAI-compatible) + // Normalize the URL: if user provided /api/generate, swap to /api/chat + // If user provided a base URL (no /api/ path), append /api/chat + String chatUrl; + if (url.contains("/api/generate")) { + chatUrl = url.replace("/api/generate", "/api/chat"); + } else if (url.contains("/api/chat")) { + chatUrl = url; + } else { + // Base URL like http://localhost:11434 — append /api/chat + chatUrl = url.endsWith("/") ? url + "api/chat" : url + "/api/chat"; + } + URI chatUri = URI.create(chatUrl); + log.info("Local LLM request: model={}, endpoint={}", model, sanitizeUrlForLog(chatUri)); + + List> messages = new ArrayList<>(); + messages.add(Map.of("role", "system", "content", systemPrompt)); + if (conversationHistory != null && !conversationHistory.isEmpty()) { + for (AIMessageDTO msg : conversationHistory) { + messages.add(Map.of("role", msg.getRole(), "content", msg.getContent())); + } + } + messages.add(Map.of("role", "user", "content", userPrompt)); + + Map requestBody = new HashMap<>(); + requestBody.put("model", model); + requestBody.put("messages", messages); + requestBody.put("stream", false); + + // Build WebClient WITHOUT WebClientUtils SSRF protection since Local LLM + // is explicitly admin-configured and typically runs on localhost/private networks. + HttpClient httpClient = HttpClient.create().responseTimeout(Duration.ofSeconds(120)); + WebClient webClient = WebClient.builder() + .exchangeStrategies(ExchangeStrategies.builder() + .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(16 * 1024 * 1024)) + .build()) + .clientConnector(new ReactorClientHttpConnector(httpClient)) + .build(); + + return webClient + .post() + .uri(chatUrl) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .body(BodyInserters.fromValue(requestBody)) + .retrieve() + .onStatus(HttpStatusCode::isError, response -> response.bodyToMono(String.class) + .flatMap(errorBody -> { + int statusCode = response.statusCode().value(); + if (statusCode == 404) { + return Mono.error(new AppsmithException( + AppsmithError.INTERNAL_SERVER_ERROR, + "Model not found (404). Ensure '" + model + "' is pulled: ollama pull " + + model)); + } + if (statusCode == 401 || statusCode == 403) { + return Mono.error( + new AppsmithException(AppsmithError.INVALID_CREDENTIALS, "Access denied")); + } + return Mono.error(new AppsmithException( + AppsmithError.INTERNAL_SERVER_ERROR, "Local LLM request failed: " + errorBody)); + })) + .bodyToMono(JsonNode.class) + .map(this::extractLocalLLMResponse) + .onErrorMap(error -> { + if (error instanceof AppsmithException) { + return error; + } + if (error instanceof java.util.concurrent.TimeoutException + || error instanceof io.netty.handler.timeout.ReadTimeoutException) { + log.error( + "Local LLM timed out after 120s: model={}, endpoint={}", + model, + sanitizeUrlForLog(chatUri)); + return new AppsmithException( + AppsmithError.INTERNAL_SERVER_ERROR, + "Local LLM timed out. The model may be loading — try again in a moment, " + + "or use a smaller model. (timeout: 120s)"); + } + if (error instanceof java.net.ConnectException) { + log.error( + "Cannot connect to Local LLM at {}: {}", + sanitizeUrlForLog(chatUri), + error.getMessage()); + return new AppsmithException( + AppsmithError.INTERNAL_SERVER_ERROR, + "Cannot connect to Local LLM at " + sanitizeUrlForLog(chatUri) + + ". Ensure Ollama is running (ollama serve)."); + } + log.error("Unexpected Local LLM API error: {}", error.getMessage(), error); + return new AppsmithException( + AppsmithError.INTERNAL_SERVER_ERROR, "Local LLM error: " + error.getMessage()); + }); + } + + /** + * Sanitizes a URL for safe logging by stripping userinfo and query parameters. + * Returns only scheme://host:port/path. + */ + private static String sanitizeUrlForLog(URI uri) { + StringBuilder sb = new StringBuilder(); + if (uri.getScheme() != null) { + sb.append(uri.getScheme()).append("://"); + } + if (uri.getHost() != null) { + sb.append(uri.getHost()); + if (uri.getPort() != -1) { + sb.append(":").append(uri.getPort()); + } + } + if (uri.getPath() != null) { + sb.append(uri.getPath()); + } + return sb.toString(); + } + + private static final int MAX_RESPONSE_LENGTH = 200000; + + /** + * Truncates a response string to the maximum allowed length. + */ + private String truncateResponse(String response) { + if (response == null) { + return ""; + } + return response.length() <= MAX_RESPONSE_LENGTH ? response : response.substring(0, MAX_RESPONSE_LENGTH); + } + + /** + * Extracts the text content from a Claude Messages API response. + * Format: { "content": [{ "text": "..." }] } + */ + private String extractClaudeResponse(JsonNode json) { + if (json == null || !json.isObject()) { + return ""; + } + JsonNode contentArray = json.path("content"); + if (contentArray.isArray() && contentArray.size() > 0) { + JsonNode firstContent = contentArray.get(0); + if (firstContent != null && firstContent.isObject()) { + JsonNode textNode = firstContent.path("text"); + if (textNode != null && textNode.isTextual()) { + return truncateResponse(textNode.asText()); + } + } + } + return ""; + } + + /** + * Extracts the text content from an OpenAI-compatible chat completion response. + * Used by both OpenAI and Azure OpenAI since they share the same response format: + * { "choices": [{ "message": { "content": "..." } }] } + */ + private String extractOpenAICompatibleResponse(JsonNode json) { + if (json == null || !json.isObject()) { + return ""; + } + JsonNode choicesArray = json.path("choices"); + if (choicesArray.isArray() && choicesArray.size() > 0) { + JsonNode firstChoice = choicesArray.get(0); + if (firstChoice != null && firstChoice.isObject()) { + JsonNode messageNode = firstChoice.path("message"); + if (messageNode != null && messageNode.isObject()) { + JsonNode contentNode = messageNode.path("content"); + if (contentNode != null && contentNode.isTextual()) { + return truncateResponse(contentNode.asText()); + } + } + } + } + return ""; + } + + private String extractLocalLLMResponse(JsonNode json) { + if (json == null || !json.isObject()) { + return ""; + } + // Ollama /api/chat response: { "message": { "content": "..." } } + JsonNode messageNode = json.path("message"); + if (messageNode != null && messageNode.isObject()) { + JsonNode contentNode = messageNode.path("content"); + if (contentNode != null && contentNode.isTextual()) { + return truncateResponse(contentNode.asText()); + } + } + // Ollama /api/generate response: { "response": "..." } + JsonNode responseNode = json.path("response"); + if (responseNode != null && responseNode.isTextual()) { + return truncateResponse(responseNode.asText()); + } + return ""; + } + + private Mono callAzureOpenAIAPI( + String endpoint, + String deploymentName, + String apiKey, + String prompt, + AIEditorContextDTO context, + List conversationHistory, + String apiVersion, + Integer maxCompletionTokens) { + String systemPrompt = buildSystemPrompt(context); + String userPrompt = buildUserPrompt(prompt, context); + + // Construct Azure OpenAI URL from endpoint + deployment name + String trimmedEndpoint = endpoint.endsWith("/") ? endpoint.substring(0, endpoint.length() - 1) : endpoint; + String effectiveApiVersion = + (apiVersion != null && !apiVersion.trim().isEmpty()) ? apiVersion.trim() : DEFAULT_AZURE_API_VERSION; + int effectiveMaxTokens = (maxCompletionTokens != null && maxCompletionTokens > 0) + ? maxCompletionTokens + : DEFAULT_AZURE_MAX_COMPLETION_TOKENS; + String encodedDeployment = URLEncoder.encode(deploymentName.trim(), StandardCharsets.UTF_8); + String url = trimmedEndpoint + "/openai/deployments/" + encodedDeployment + "/chat/completions?api-version=" + + effectiveApiVersion; + + List> messages = new ArrayList<>(); + messages.add(Map.of("role", "system", "content", systemPrompt)); + if (conversationHistory != null && !conversationHistory.isEmpty()) { + for (AIMessageDTO msg : conversationHistory) { + messages.add(Map.of("role", msg.getRole(), "content", msg.getContent())); + } + } + messages.add(Map.of("role", "user", "content", userPrompt)); + + Map requestBody = new HashMap<>(); + requestBody.put("messages", messages); + requestBody.put("max_completion_tokens", effectiveMaxTokens); + + WebClient webClient = WebClientUtils.builder() + .clientConnector( + new ReactorClientHttpConnector(HttpClient.create().responseTimeout(Duration.ofSeconds(60)))) + .build(); + + return webClient + .post() + .uri(url) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .header("api-key", apiKey) + .body(BodyInserters.fromValue(requestBody)) + .retrieve() + .onStatus(HttpStatusCode::isError, response -> response.bodyToMono(String.class) + .flatMap(errorBody -> { + int statusCode = response.statusCode().value(); + log.error( + "Azure OpenAI returned HTTP {}: {}", + statusCode, + errorBody != null && errorBody.length() > 1000 + ? errorBody.substring(0, 1000) + : errorBody); + if (statusCode == 401 || statusCode == 403) { + return Mono.error( + new AppsmithException(AppsmithError.INVALID_CREDENTIALS, "Invalid API key")); + } else if (statusCode == 429) { + return Mono.error(new AppsmithException( + AppsmithError.INTERNAL_SERVER_ERROR, + "Rate limit exceeded. Please try again later.")); + } + return Mono.error(new AppsmithException( + AppsmithError.INTERNAL_SERVER_ERROR, "Azure OpenAI request failed: " + errorBody)); + })) + .bodyToMono(JsonNode.class) + .map(this::extractOpenAICompatibleResponse) + .doOnError(error -> { + if (error instanceof AppsmithException) { + log.error("Azure OpenAI API error", error); + } else { + log.error("Unexpected Azure OpenAI API error", error); + } + }); + } + + /** + * Returns true if the string is non-null and contains non-whitespace characters. + */ + private static boolean hasContent(String value) { + return value != null && !value.trim().isEmpty(); + } + + /** + * Truncates a string to the given max length after trimming. + */ + private static String trimAndLimit(String value, int maxLength) { + String trimmed = value.trim(); + return trimmed.length() > maxLength ? trimmed.substring(0, maxLength) : trimmed; + } + + private String buildSystemPrompt(AIEditorContextDTO context) { + String mode = context != null ? context.getMode() : null; + String modeReference = aiReferenceService.getReferenceContent(mode); + String commonIssues = aiReferenceService.getCommonIssuesContent(); + + StringBuilder systemPrompt = new StringBuilder(); + + if (hasContent(modeReference)) { + systemPrompt.append(modeReference); + } + + if (hasContent(commonIssues)) { + if (systemPrompt.length() > 0) { + systemPrompt.append("\n\n## Common Issues\n\n"); + } + systemPrompt.append(commonIssues); + } + + if (context != null && hasContent(context.getDatabaseSchema())) { + systemPrompt.append("\n\n## Database Schema\n\n"); + if (hasContent(context.getDatasourceType())) { + systemPrompt + .append("Database type: ") + .append(context.getDatasourceType().trim()) + .append("\n\n"); + } + systemPrompt.append(context.getDatabaseSchema().trim()); + systemPrompt.append( + "\n\nUse the schema above when writing queries. Reference actual table/collection and column/field names. Use the correct query syntax for this database type."); + } + + // Instruct the model to format responses using markdown + systemPrompt.append("\n\n## Response Formatting\n\n" + + "Format your responses using markdown:\n" + + "- Use headings (##, ###) to organize sections\n" + + "- Use **bold** for emphasis on key terms or important points\n" + + "- Use bullet points or numbered lists for steps and multiple items\n" + + "- Use fenced code blocks with language identifiers (```javascript, ```sql, etc.) for all code snippets\n" + + "- Use inline `code` for variable names, function names, and short code references\n" + + "- Keep explanations concise and well-structured"); + + return systemPrompt.toString(); + } + + private String buildUserPrompt(String prompt, AIEditorContextDTO context) { + String safePrompt = hasContent(prompt) ? prompt.trim() : ""; + + if (context == null) { + return "User request: " + safePrompt + "\n\nProvide the code solution:"; + } + + StringBuilder contextInfo = new StringBuilder(); + + if (hasContent(context.getFunctionName())) { + contextInfo + .append("Function: ") + .append(trimAndLimit(context.getFunctionName(), 200)) + .append("\n"); + } + if (hasContent(context.getFunctionString())) { + contextInfo + .append("Current function code:\n```\n") + .append(trimAndLimit(context.getFunctionString(), 50000)) + .append("\n```\n"); + } + if (context.getCursorLineNumber() != null + && context.getCursorLineNumber() >= 0 + && context.getCursorLineNumber() < 1000000) { + contextInfo + .append("Cursor at line: ") + .append((long) context.getCursorLineNumber() + 1) + .append("\n"); + } + + return contextInfo + "\nUser request: " + safePrompt + "\n\nProvide the code solution:"; + } +} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/AIReferenceServiceCE.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/AIReferenceServiceCE.java new file mode 100644 index 000000000000..c8776a73ba92 --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/AIReferenceServiceCE.java @@ -0,0 +1,48 @@ +package com.appsmith.server.services.ce; + +import java.util.Map; + +/** + * Service for loading AI reference documentation files. + * These files contain mode-specific context (JavaScript, SQL, GraphQL patterns) + * that enhance AI assistant system prompts. + * + * The service implements a fallback chain: + * 1. External path: /appsmith/config/ai-references/{mode}-reference.md (configurable) + * 2. Bundled resource: classpath:ai-references/{mode}-reference.md + * 3. Inline fallback: Hardcoded minimal prompt + */ +public interface AIReferenceServiceCE { + + /** + * Get the reference content for a specific mode. + * + * @param mode The editor mode (javascript, sql, graphql) + * @return The reference content, or inline fallback if files are unavailable + */ + String getReferenceContent(String mode); + + /** + * Get common issues content that applies across all modes. + * + * @return The common issues content, or empty string if unavailable + */ + String getCommonIssuesContent(); + + /** + * Get information about which AI reference files are being used. + * Returns a map with file names as keys and source info as values. + * + * @return Map of filename to source (e.g., "external:/path/to/file" or "bundled" or "inline-fallback") + */ + Map getReferenceFilesInfo(); + + /** + * Information about a reference file source. + */ + record ReferenceFileInfo( + String source, // "external", "bundled", or "inline-fallback" + String path, // Full path for external files, null otherwise + boolean exists // Whether the file exists + ) {} +} diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/AIReferenceServiceCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/AIReferenceServiceCEImpl.java new file mode 100644 index 000000000000..2f54f499fbca --- /dev/null +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/AIReferenceServiceCEImpl.java @@ -0,0 +1,239 @@ +package com.appsmith.server.services.ce; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ClassPathResource; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Implementation of AIReferenceServiceCE that loads reference documentation + * with a fallback chain: external file -> bundled resource -> inline fallback. + */ +@Slf4j +public class AIReferenceServiceCEImpl implements AIReferenceServiceCE { + + @Value("${appsmith.ai.references.path:/appsmith/config/ai-references}") + private String externalReferencesPath; + + // Simple in-memory cache to avoid repeated file I/O + private final Map contentCache = new ConcurrentHashMap<>(); + + // Inline fallback prompts for when no files are found + private static final Map INLINE_FALLBACKS = Map.of( + "javascript", + "You are an expert JavaScript developer helping with Appsmith code. " + + "Appsmith uses bindings in {{}} syntax. Provide clean, efficient code.", + "sql", + "You are an expert SQL developer helping with database queries in Appsmith. " + + "Provide optimized, correct SQL queries.", + "graphql", + "You are an expert GraphQL developer helping with GraphQL queries in Appsmith. " + + "Provide correct, efficient GraphQL queries."); + + private static final String COMMON_ISSUES_KEY = "common-issues"; + + public AIReferenceServiceCEImpl() {} + + @Override + public String getReferenceContent(String mode) { + if (mode == null || mode.trim().isEmpty()) { + return ""; + } + + String normalizedMode = mode.toLowerCase().trim(); + + // Check cache first + String cacheKey = "mode:" + normalizedMode; + String cachedContent = contentCache.get(cacheKey); + if (cachedContent != null) { + return cachedContent; + } + + // Try to load content with fallback chain + String content = loadReferenceWithFallback(normalizedMode); + + // Cache the result (even empty strings to avoid repeated lookups) + contentCache.put(cacheKey, content); + + return content; + } + + @Override + public String getCommonIssuesContent() { + // Check cache first + String cachedContent = contentCache.get(COMMON_ISSUES_KEY); + if (cachedContent != null) { + return cachedContent; + } + + // Try to load common issues content + String content = loadCommonIssuesWithFallback(); + + // Cache the result + contentCache.put(COMMON_ISSUES_KEY, content); + + return content; + } + + /** + * Load reference content with fallback chain: + * 1. External file + * 2. Bundled resource + * 3. Inline fallback + */ + private String loadReferenceWithFallback(String mode) { + String filename = mode + "-reference.md"; + + // Try external file first + String content = tryLoadExternalFile(filename); + if (content != null) { + log.debug("Loaded AI reference from external file: {}", filename); + return content; + } + + // Try bundled resource + content = tryLoadBundledResource("ai-references/" + filename); + if (content != null) { + log.debug("Loaded AI reference from bundled resource: {}", filename); + return content; + } + + // Fall back to inline content + String fallback = INLINE_FALLBACKS.getOrDefault(mode, ""); + if (!fallback.isEmpty()) { + log.debug("Using inline fallback for mode: {}", mode); + } else { + log.warn("No AI reference content found for mode: {}. Using empty string.", mode); + } + return fallback; + } + + /** + * Load common issues content with fallback chain. + * Unlike mode references, common issues has no inline fallback. + */ + private String loadCommonIssuesWithFallback() { + String filename = "common-issues.md"; + + // Try external file first + String content = tryLoadExternalFile(filename); + if (content != null) { + log.debug("Loaded common issues from external file"); + return content; + } + + // Try bundled resource + content = tryLoadBundledResource("ai-references/" + filename); + if (content != null) { + log.debug("Loaded common issues from bundled resource"); + return content; + } + + // No inline fallback for common issues - return empty string + log.debug("No common issues file found, using empty string"); + return ""; + } + + /** + * Try to load content from an external file path. + * + * @param filename The filename to load + * @return File content or null if not found/readable + */ + private String tryLoadExternalFile(String filename) { + try { + Path filePath = Paths.get(externalReferencesPath, filename); + if (Files.exists(filePath) && Files.isReadable(filePath)) { + return Files.readString(filePath, StandardCharsets.UTF_8); + } + } catch (IOException e) { + log.warn("Failed to read external AI reference file {}: {}", filename, e.getMessage()); + } catch (SecurityException e) { + log.warn("Security exception reading external AI reference file {}: {}", filename, e.getMessage()); + } + return null; + } + + /** + * Try to load content from a bundled classpath resource. + * + * @param resourcePath The classpath resource path + * @return Resource content or null if not found/readable + */ + private String tryLoadBundledResource(String resourcePath) { + try { + ClassPathResource resource = new ClassPathResource(resourcePath); + if (resource.exists()) { + try (InputStream inputStream = resource.getInputStream()) { + return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + } + } + } catch (IOException e) { + log.warn("Failed to read bundled AI reference resource {}: {}", resourcePath, e.getMessage()); + } + return null; + } + + @Override + public Map getReferenceFilesInfo() { + Map result = new LinkedHashMap<>(); + + // Check each known reference file type + String[] modes = {"javascript", "sql", "graphql"}; + for (String mode : modes) { + String filename = mode + "-reference.md"; + ReferenceFileInfo info = checkFileSource(filename, "ai-references/" + filename, mode); + result.put(filename, info); + } + + // Check common-issues.md + ReferenceFileInfo commonIssuesInfo = checkFileSource( + "common-issues.md", "ai-references/common-issues.md", null // No inline fallback for common issues + ); + result.put("common-issues.md", commonIssuesInfo); + + return result; + } + + /** + * Check where a reference file will be loaded from. + */ + private ReferenceFileInfo checkFileSource(String filename, String bundledPath, String modeForFallback) { + // Check external file first + try { + Path filePath = Paths.get(externalReferencesPath, filename); + if (Files.exists(filePath) && Files.isReadable(filePath)) { + return new ReferenceFileInfo("external", filePath.toString(), true); + } + } catch (Exception e) { + // Ignore - will fall through to bundled check + } + + // Check bundled resource + try { + ClassPathResource resource = new ClassPathResource(bundledPath); + if (resource.exists()) { + return new ReferenceFileInfo("bundled", null, true); + } + } catch (Exception e) { + // Ignore - will fall through to inline fallback + } + + // Check if there's an inline fallback + if (modeForFallback != null && INLINE_FALLBACKS.containsKey(modeForFallback)) { + return new ReferenceFileInfo("inline-fallback", null, true); + } + + // File doesn't exist anywhere + return new ReferenceFileInfo("none", null, false); + } +} diff --git a/app/server/appsmith-server/src/main/resources/ai-references/README.md b/app/server/appsmith-server/src/main/resources/ai-references/README.md new file mode 100644 index 000000000000..987a12147261 --- /dev/null +++ b/app/server/appsmith-server/src/main/resources/ai-references/README.md @@ -0,0 +1,227 @@ +# AI Reference Files + +These files provide context to the Appsmith AI Assistant, helping it give more accurate, Appsmith-specific responses. + +## Files + +| File | Purpose | Used When | +|------|---------|-----------| +| `javascript-reference.md` | JS patterns, bindings, async, global APIs | JavaScript editor | +| `sql-reference.md` | SQL patterns, parameterization, DB tips | SQL query editors | +| `graphql-reference.md` | GraphQL queries, mutations, pagination | GraphQL editor | +| `common-issues.md` | Troubleshooting gotchas | All editors (appended) | + +## Customizing AI References + +You can override these bundled files with your own custom references. + +### Option 1: Docker Volume Mount + +```bash +# Create custom references directory +mkdir -p /path/to/my-ai-references + +# Copy bundled files as starting point (optional) +# Then edit them to add your organization's patterns + +# Run Appsmith with volume mount +docker run -d \ + -v /path/to/my-ai-references:/appsmith/config/ai-references:ro \ + appsmith/appsmith-ee +``` + +### Option 2: Docker Compose + +```yaml +services: + appsmith: + image: appsmith/appsmith-ee + volumes: + - ./my-ai-references:/appsmith/config/ai-references:ro +``` + +### Option 3: Kubernetes ConfigMap + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: appsmith-ai-references +data: + javascript-reference.md: | + # Your Custom JavaScript Reference + + ## Your Patterns + ... + + sql-reference.md: | + # Your Custom SQL Reference + ... +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: appsmith +spec: + template: + spec: + containers: + - name: appsmith + volumeMounts: + - name: ai-references + mountPath: /appsmith/config/ai-references + readOnly: true + volumes: + - name: ai-references + configMap: + name: appsmith-ai-references +``` + +### Option 4: Custom Path via Environment Variable + +```bash +# Set custom path +export APPSMITH_AI_REFERENCES_PATH=/custom/path/to/references + +# Place your files there +/custom/path/to/references/ +├── javascript-reference.md +├── sql-reference.md +├── graphql-reference.md +└── common-issues.md +``` + +## File Format Guidelines + +Each reference file should: + +1. **Start with a heading**: `# Appsmith [Mode] Reference` + +2. **Include sections** with `##` headings for different topics + +3. **Provide code examples** in fenced code blocks: + ```javascript + {{Input1.text}} + ``` + +4. **Be concise**: ~400-800 words per file (too long = slower AI responses) + +5. **Focus on Appsmith-specific patterns**, not general programming + +### Example Structure + +```markdown +# Appsmith JavaScript Reference + +## Binding Syntax + +Use `{{ }}` for dynamic values. + +```javascript +{{Table1.selectedRow.id}} +{{Query1.data}} +``` + +## Global APIs + +```javascript +showAlert("Message", "success"); +storeValue("key", value); +navigateTo("PageName"); +``` + +## Your Organization's Patterns + +Add your team's specific patterns here... +``` + +## Fallback Behavior + +The AI Assistant loads references with this priority: + +1. **External path** (`/appsmith/config/ai-references/` or custom) +2. **Bundled files** (these files in the JAR) +3. **Inline fallback** (minimal hardcoded prompts) + +If external files are missing or unreadable, the bundled files are used automatically. The AI Assistant never fails due to missing reference files. + +## Generating Custom References + +For advanced users with large knowledge bases, see the [appsmith-ai-helper](https://github.com/appsmithorg/appsmith-ai-helper) tool that can generate reference files from: +- Documentation directories +- OpenAI vector stores +- Helpdesk solution exports + +## Architecture + +### Service Classes + +The AI reference system follows Appsmith's CE/EE pattern: + +| Class | Location | Purpose | +|-------|----------|---------| +| `AIReferenceServiceCE` | `services/ce/` | Interface defining `getReferenceContent(mode)` and `getCommonIssuesContent()` | +| `AIReferenceServiceCEImpl` | `services/ce/` | Implementation with three-tier fallback loading | +| `AIReferenceService` | `services/` | EE wrapper interface | +| `AIReferenceServiceImpl` | `services/` | EE wrapper implementation | + +### How References Are Used + +The `AIAssistantServiceCEImpl.buildSystemPrompt()` method constructs the AI system prompt: + +```java +private String buildSystemPrompt(AIEditorContextDTO context) { + String mode = context != null ? context.getMode() : null; + + // Get mode-specific reference (javascript, sql, or graphql) + String modeReference = aiReferenceService.getReferenceContent(mode); + + // Get common issues (appended for all modes) + String commonIssues = aiReferenceService.getCommonIssuesContent(); + + // Combine into system prompt + StringBuilder systemPrompt = new StringBuilder(); + if (modeReference != null && !modeReference.isEmpty()) { + systemPrompt.append(modeReference); + } + if (commonIssues != null && !commonIssues.isEmpty()) { + if (systemPrompt.length() > 0) { + systemPrompt.append("\n\n## Common Issues\n\n"); + } + systemPrompt.append(commonIssues); + } + return systemPrompt.toString(); +} +``` + +### Request Flow + +``` +Frontend (AI Panel) + ↓ +POST /api/v1/users/ai-assistant/request + ↓ +UserControllerCE.requestAIResponse() + ↓ +AIAssistantServiceCEImpl.getAIResponse() + ├── Extract mode from AIEditorContextDTO + ├── Load mode-specific reference via AIReferenceService + ├── Load common issues via AIReferenceService + ├── Build system prompt (mode reference + common issues) + ├── Call AI provider (Claude or OpenAI) + └── Return response +``` + +### Caching + +The `AIReferenceServiceCEImpl` uses `ConcurrentHashMap` to cache loaded references in memory, avoiding repeated file I/O on each request. + +### Configuration + +Set via `application-ce.properties`: + +```properties +appsmith.ai.references.path=${APPSMITH_AI_REFERENCES_PATH:/appsmith/config/ai-references} +``` + +Environment variable: `APPSMITH_AI_REFERENCES_PATH` diff --git a/app/server/appsmith-server/src/main/resources/ai-references/common-issues.md b/app/server/appsmith-server/src/main/resources/ai-references/common-issues.md new file mode 100644 index 000000000000..1796ea5ce81e --- /dev/null +++ b/app/server/appsmith-server/src/main/resources/ai-references/common-issues.md @@ -0,0 +1,219 @@ +# Appsmith Common Issues and Troubleshooting + +Appsmith is a powerful low-code platform that allows users to build custom applications quickly. However, like any platform, users may encounter common issues that can hinder their development process. This document provides comprehensive troubleshooting tips for common issues in Appsmith, including binding issues, query execution problems, type conversion challenges, and more. + +## Binding Issues + +Binding issues in Appsmith often arise when data doesn't update as expected or when incorrect values are displayed. Below are common binding problems and their solutions. + +### Binding Not Updating + +**Problem**: Widget shows stale data or binding doesn't reflect changes. + +**Solutions**: +- **Verify Widget Name**: Ensure the widget name is correct and case-sensitive. For example, use `{{Input1.text}}` instead of `{{input1.text}}`. +- **Correct Binding Syntax**: Always wrap bindings in double curly braces: `{{...}}`. +- **Check Widget Existence**: Confirm that the referenced widget exists on the current page. +- **Ensure Query Execution**: For query data, ensure the query has run. For example, `{{Query1.data}}` will be empty until `Query1` executes. + +### Binding Shows [object Object] + +**Problem**: Widget displays `[object Object]` instead of the expected value. + +**Solutions**: +- **Access Specific Properties**: Use dot notation to access specific properties, e.g., `{{Query1.data[0].name}}` instead of `{{Query1.data[0]}}`. +- **Use JSON.stringify()**: For debugging, convert objects to strings using `{{JSON.stringify(Query1.data)}}`. +- **Handle Arrays Properly**: Use `.map()` to iterate over arrays or access elements by index. + +### Stale Data After Query Run + +**Problem**: Data doesn't update after running a query. + +**Solutions**: +- **Await Query Completion**: Ensure the query has completed before accessing data. + ```javascript + // WRONG: Query hasn't completed yet + Query1.run(); + console.log(Query1.data); // Still shows old data + + // CORRECT: Wait for query completion + const data = await Query1.run(); + console.log(data); // Fresh data + + // Or use .then() + Query1.run().then(data => { + console.log(data); + }); + ``` + +## Query Execution Issues + +Query execution issues can prevent data from being retrieved or cause unexpected behavior in applications. + +### Query Not Running + +**Problem**: Query doesn't execute or returns no data. + +**Solutions**: +- **Check Query Name**: Ensure the query name matches exactly when calling it, e.g., `await Query1.run()`. +- **Verify Datasource Connection**: Ensure the datasource is properly configured and tested. +- **Check for Syntax Errors**: Review the query for any syntax errors. +- **Provide Required Parameters**: Ensure all required parameters are provided, e.g., `Query1.run({ param: value })`. +- **Run on Page Load**: Check the "Run on page load" setting in the query configuration. + +### Query Runs Multiple Times + +**Problem**: Query executes repeatedly or in an infinite loop. + +**Solutions**: +- **Avoid Binding Queries Directly**: Do not bind queries directly in widget properties. +- **Use JSObject Functions**: For complex logic, use JSObject functions to control query execution. +- **Check Page Load Settings**: Ensure "Run on page load" isn't triggering along with manual runs. +- **Debounce Input Events**: Use debounce for input events to prevent queries from running on every keystroke. + +## Type Conversion + +Type conversion issues can lead to unexpected results when handling data types such as strings, numbers, and dates. + +### String to Number Conversion + +**Problem**: Incorrect conversion from string to number. + +**Solutions**: +- **Use parseInt() or parseFloat()**: Convert strings to numbers using `parseInt(string)` or `parseFloat(string)`. + ```javascript + const num = parseInt("123"); // 123 + const floatNum = parseFloat("123.45"); // 123.45 + ``` + +### Date Formatting + +**Problem**: Incorrect date format or parsing issues. + +**Solutions**: +- **Use Date Object**: Convert strings to date objects using `new Date(string)`. + ```javascript + const date = new Date("2023-10-01"); // Sun Oct 01 2023 + ``` +- **Format Dates with Libraries**: Use libraries like `moment.js` for complex date formatting. + ```javascript + const formattedDate = moment("2023-10-01").format("MMMM Do YYYY"); // October 1st 2023 + ``` + +## Null and Undefined Handling + +Handling null and undefined values is crucial to prevent runtime errors and ensure smooth application functionality. + +### Optional Chaining + +**Problem**: Accessing properties of null or undefined objects. + +**Solutions**: +- **Use Optional Chaining**: Safely access nested properties using `?.`. + ```javascript + const userName = user?.profile?.name; // undefined if user or profile is null + ``` + +### Default Values + +**Problem**: Null or undefined values causing errors. + +**Solutions**: +- **Use Nullish Coalescing Operator**: Provide default values using `??`. + ```javascript + const displayName = user.name ?? "Guest"; // "Guest" if user.name is null or undefined + ``` + +## Array and Data Issues + +Working with arrays and nested data structures can lead to issues if not handled properly. + +### Empty Data + +**Problem**: Handling empty arrays or data sets. + +**Solutions**: +- **Check Array Length**: Verify if an array is empty using `.length`. + ```javascript + if (dataArray.length === 0) { + console.log("No data available"); + } + ``` + +### Nested Responses + +**Problem**: Accessing deeply nested data. + +**Solutions**: +- **Use Optional Chaining**: Safely access nested properties. + ```javascript + const nestedValue = response?.data?.items[0]?.name; + ``` +- **Iterate Over Nested Arrays**: Use loops or `.map()` to process nested arrays. + ```javascript + const itemNames = response.data.items.map(item => item.name); + ``` + +## Async/Await Issues + +Async/await issues can disrupt the flow of asynchronous operations, leading to unexpected behavior. + +### Promises and Execution Order + +**Problem**: Incorrect handling of promises and execution order. + +**Solutions**: +- **Await Promises**: Ensure promises are awaited before accessing their results. + ```javascript + const result = await fetchData(); + console.log(result); + ``` +- **Use .then() for Promises**: Alternatively, handle promises using `.then()`. + ```javascript + fetchData().then(result => { + console.log(result); + }); + ``` + +## Widget Reference Issues + +Widget reference issues occur when widgets are not found or when there are name mismatches. + +### Widget Not Found + +**Problem**: Widget not found or referenced incorrectly. + +**Solutions**: +- **Verify Widget Name**: Ensure the widget name is correct and matches exactly. +- **Check Widget Existence**: Confirm that the widget exists on the current page. + +### Name Mismatches + +**Problem**: Incorrect widget name causing errors. + +**Solutions**: +- **Consistent Naming**: Use consistent and descriptive names for widgets. +- **Update References**: Update all references when renaming widgets. + +## Performance Issues + +Performance issues can lead to slow application loading times and inefficient data handling. + +### Slow Loading + +**Problem**: Application loads slowly due to large datasets or inefficient queries. + +**Solutions**: +- **Paginate Large Datasets**: Use server-side pagination to handle large datasets efficiently. +- **Optimize Queries**: Review and optimize queries for performance. +- **Use Lazy Loading**: Load data only when needed to reduce initial load time. + +### Handling Large Datasets + +**Problem**: Performance issues with large datasets. + +**Solutions**: +- **Use Virtual Scrolling**: Implement virtual scrolling for large lists or tables. +- **Limit Data Fetching**: Fetch only necessary data and avoid over-fetching. + +By following these troubleshooting tips and solutions, you can effectively address common issues in Appsmith and enhance your application's performance and reliability. \ No newline at end of file diff --git a/app/server/appsmith-server/src/main/resources/ai-references/graphql-reference.md b/app/server/appsmith-server/src/main/resources/ai-references/graphql-reference.md new file mode 100644 index 000000000000..c683cb5bd55d --- /dev/null +++ b/app/server/appsmith-server/src/main/resources/ai-references/graphql-reference.md @@ -0,0 +1,275 @@ +# Appsmith GraphQL Reference + +## Query Setup + +In Appsmith, setting up a GraphQL query involves defining the query body and specifying any variables needed to execute the query dynamically. This setup allows you to interact with GraphQL APIs efficiently and flexibly. + +### Query Body + +The query body is where you define the GraphQL operation you want to perform. This can be a query to fetch data or a mutation to modify data. + +Example: +```graphql +query GetUsers($limit: Int, $offset: Int) { + users(limit: $limit, offset: $offset) { + id + name + email + createdAt + } +} +``` +- **GetUsers**: The name of the query. +- **$limit, $offset**: Variables used to control pagination. +- **users**: The field being queried, which returns a list of users. + +### Variables Section + +Variables are defined as a JSON object and can be dynamically set using Appsmith's `{{ }}` syntax. This allows you to bind widget data or other dynamic values to your GraphQL queries. + +Example: +```json +{ + "limit": {{Table1.pageSize}}, + "offset": {{(Table1.pageNo - 1) * Table1.pageSize}} +} +``` +- **limit**: Binds to the page size of a table widget. +- **offset**: Calculates the offset based on the current page number and page size. + +## Query Patterns + +GraphQL queries can be simple or complex, depending on the data requirements. Below are common patterns used in Appsmith. + +### Simple Query + +A simple query fetches data without any variables or conditions. + +Example: +```graphql +query { + users { + id + name + email + } +} +``` +- Fetches all users with their `id`, `name`, and `email`. + +### Query with Variables + +Variables allow you to parameterize queries, making them dynamic and reusable. + +Example: +```graphql +query GetUser($id: ID!) { + user(id: $id) { + id + name + email + orders { + id + total + } + } +} +``` +Variables: +```json +{ + "id": {{Table1.selectedRow.id}} +} +``` +- **GetUser**: Fetches a specific user and their orders using an `id` variable. + +### Query with Filtering + +Filtering allows you to narrow down the results based on certain criteria. + +Example: +```graphql +query SearchUsers($searchTerm: String, $status: UserStatus) { + users(where: { name_contains: $searchTerm, status: $status }) { + id + name + email + status + } +} +``` +Variables: +```json +{ + "searchTerm": {{Input_Search.text || null}}, + "status": {{Select_Status.selectedOptionValue || null}} +} +``` +- Filters users by name and status using input and select widgets. + +## Mutations + +Mutations in GraphQL are used to modify data on the server. They can create, update, or delete records. + +### Create Mutation + +To add new data, use a create mutation. + +Example: +```graphql +mutation CreateUser($name: String!, $email: String!, $date_of_birth: String!) { + createUser(name: $name, email: $email, date_of_birth: $date_of_birth) { + id + name + email + date_of_birth + } +} +``` +- **CreateUser**: Adds a new user with the specified details. + +### Update Mutation + +Updating existing data requires an update mutation. + +Example: +```graphql +mutation UpdateUser($id: Int!, $name: String, $email: String, $date_of_birth: String) { + updateUser(id: $id, name: $name, email: $email, date_of_birth: $date_of_birth) { + id + name + email + date_of_birth + } +} +``` +- **UpdateUser**: Modifies an existing user's details based on their `id`. + +### Delete Mutation + +To remove data, use a delete mutation. + +Example: +```graphql +mutation DeleteUser($id: Int!) { + deleteUser(id: $id) { + id + name + email + date_of_birth + } +} +``` +- **DeleteUser**: Deletes a user identified by `id`. + +## Pagination + +Pagination is crucial for handling large datasets efficiently. GraphQL supports both offset-based and cursor-based pagination. + +### Offset-Based Pagination + +Offset-based pagination uses a limit and offset to fetch data in chunks. + +Example: +```graphql +query GetProducts($limit: Int!, $offset: Int!) { + products(limit: $limit, offset: $offset) { + id + name + price + } + productsCount +} +``` +Variables: +```json +{ + "limit": {{Table1.pageSize}}, + "offset": {{(Table1.pageNo - 1) * Table1.pageSize}} +} +``` +- Fetches a specific number of products starting from a calculated offset. + +### Cursor-Based Pagination + +Cursor-based pagination uses cursors to navigate through data. + +Example: +```graphql +query GetProducts($first: Int!, $after: String) { + products(first: $first, after: $after) { + edges { + node { + id + name + price + } + cursor + } + pageInfo { + hasNextPage + endCursor + } + } +} +``` +Variables: +```json +{ + "first": 10, + "after": {{appsmith.store.lastCursor || null}} +} +``` +- Fetches the first set of products after a given cursor, supporting infinite scrolling. + +## Error Handling + +Handling errors in GraphQL queries and mutations is essential for robust applications. Appsmith provides mechanisms to manage errors gracefully. + +### Basic Error Handling + +GraphQL errors can be captured and displayed to users or logged for debugging. + +Example: +```javascript +{ + "query": "query GetUser($id: ID!) { user(id: $id) { id name email } }", + "variables": { "id": {{Input_UserId.text}} } +} +``` +- Use Appsmith's error handling features to display messages or logs. + +### Custom Error Messages + +You can customize error messages based on the type of error received. + +Example: +```javascript +if (response.errors) { + showAlert("Error fetching data: " + response.errors[0].message, "error"); +} +``` +- Displays a custom alert with the error message from the GraphQL response. + +### Retry Logic + +Implement retry logic for transient errors to improve reliability. + +Example: +```javascript +let retries = 3; +while (retries > 0) { + try { + // Execute GraphQL query + break; + } catch (error) { + retries--; + if (retries === 0) { + showAlert("Failed to fetch data after multiple attempts", "error"); + } + } +} +``` +- Attempts to retry the query a specified number of times before failing. + +By understanding and utilizing these patterns and techniques, you can effectively build and manage GraphQL-based applications in Appsmith. This reference guide provides a comprehensive overview of setting up queries, handling mutations, implementing pagination, and managing errors, ensuring you can leverage the full power of GraphQL within your Appsmith applications. \ No newline at end of file diff --git a/app/server/appsmith-server/src/main/resources/ai-references/javascript-reference.md b/app/server/appsmith-server/src/main/resources/ai-references/javascript-reference.md new file mode 100644 index 000000000000..784b061c37aa --- /dev/null +++ b/app/server/appsmith-server/src/main/resources/ai-references/javascript-reference.md @@ -0,0 +1,290 @@ +# Appsmith JavaScript Reference + +## Binding Syntax + +Appsmith uses mustache-style bindings with double curly braces `{{ }}` to dynamically bind data to widget properties, query parameters, and other elements within the application. This allows for seamless integration of dynamic values throughout the application. + +### Basic Bindings + +```javascript +// Bind the text property of an input widget +{{Input1.text}} + +// Bind the selected option value of a dropdown +{{Select1.selectedOptionValue}} + +// Bind the selected row's id from a table +{{Table1.selectedRow.id}} +``` + +### Expression Bindings + +```javascript +// Concatenate strings and variables +{{"Hello, " + Input1.text}} + +// Perform arithmetic operations +{{Table1.selectedRow.id * 2}} + +// Conditional logic +{{Checkbox1.isChecked ? "Checked" : "Unchecked"}} +``` + +### Theme Properties + +Appsmith allows you to access theme properties to ensure consistency across your application. + +```javascript +// Access the primary color from the theme +{{appsmith.theme.colors.primaryColor}} + +// Access the border radius setting +{{appsmith.theme.borderRadius.appBorderRadius}} +``` + +### Dynamic Visibility and Validation + +```javascript +// Make a widget visible based on a condition +{{Select1.selectedOptionValue === "Yes"}} + +// Validate input length and content +{{Input1.text.length > 10 && /\d/.test(Input1.text) ? true : false}} + +// Error message for validation +{{Input1.text.length > 10 || !/\d/.test(Input1.text) ? "Error: Length should be at least 10 characters and contain at least one digit" : ""}} +``` + +## Data Access Patterns + +### Query and API Data + +Accessing data from queries and APIs is straightforward in Appsmith. The `.data` property is commonly used to retrieve the results. + +```javascript +// Access all data from a query +{{fetchUserData.data}} + +// Access the first row of data from a query +{{fetchUserData.data[0]}} + +// Map over query data to transform it +{{fetchUserData.data.map(user => ({label: user.name, value: user.id}))}} +``` + +### Widget References + +Widgets in Appsmith can be referenced directly to access their properties. + +```javascript +// Access the text of an input widget +{{Input1.text}} + +// Access the selected option of a dropdown +{{Select1.selectedOptionValue}} + +// Access the selected row in a table +{{Table1.selectedRow}} + +// Access all selected rows in a multi-select table +{{Table1.selectedRows}} + +// Check if a checkbox is checked +{{Checkbox1.isChecked}} +``` + +### JSObject Functions + +JSObjects allow you to define reusable functions and variables. + +```javascript +// Call a function defined in a JSObject +{{JsObject1.myFunction()}} + +// Access a variable defined in a JSObject +{{JsObject1.myVariable}} +``` + +## Async Patterns + +JavaScript in Appsmith is inherently asynchronous. Using `async/await` ensures that operations like API calls and database queries are handled correctly. + +### Async Functions + +```javascript +export default { + async fetchData() { + try { + const data = await Api1.run(); + return data; + } catch (error) { + showAlert("Error fetching data", "error"); + } + } +} +``` + +### Running Queries Programmatically + +```javascript +// Execute a query and handle the result +const result = await Query1.run(); +console.log(result); + +// Execute a query with parameters +const user = await getUser.run({ id: Input1.text }); +``` + +### Conditional Execution + +```javascript +// Execute different queries based on a condition +{{ Select_Category.selectedOptionValue === 'Movies' ? fetchMovies.run() : fetchUsers.run(); }} +``` + +## Global APIs + +Appsmith provides several built-in functions to perform common tasks like navigation, storing values, and displaying alerts. + +### Navigation + +```javascript +// Navigate to a different page +{{navigateTo('HomePage')}} + +// Navigate with parameters +{{navigateTo('DetailsPage', { id: Table1.selectedRow.id }, 'SAME_WINDOW')}} +``` + +### Storing Values + +```javascript +// Store a value in the app's store +{{storeValue('userName', Input1.text)}} + +// Retrieve a stored value +{{appsmith.store.userName}} +``` + +### Alerts + +```javascript +// Show an alert with a message +{{showAlert("Operation successful", "success")}} + +// Show an alert after a delay +setTimeout(() => { showAlert("5 seconds have passed") }, 5000); +``` + +## Appsmith Object + +The `appsmith` object provides access to various properties and methods that give context about the application and user. + +### User Information + +```javascript +// Access the current user's email +{{appsmith.user.email}} + +// Access the current user's username +{{appsmith.user.username}} +``` + +### URL Parameters + +```javascript +// Access query parameters from the URL +{{appsmith.URL.queryParams.id}} + +// Access the full path of the URL +{{appsmith.URL.fullPath}} +``` + +### Store and Context + +```javascript +// Access a value stored in the app's store +{{appsmith.store.userName}} + +// Check if a stored value is null +{{appsmith.store.data == null ? false : true}} +``` + +## Error Handling + +Proper error handling ensures that your application can gracefully handle unexpected situations. + +### Try/Catch Patterns + +```javascript +export default { + async fetchData() { + try { + const data = await Api1.run(); + return data; + } catch (error) { + console.error("Error fetching data:", error); + showAlert("Failed to fetch data", "error"); + } + } +} +``` + +### Workflow Error Handling + +```javascript +export default { + async executeWorkflow(data) { + try { + const response = await approvalRequest.run(); + if (response.resolution === "Approve") { + await initiateRefund.run({ id: data.order_id }); + await notifyUser.run({ email: data.customer_email }); + } + } catch (error) { + console.error("Error executing workflow:", error); + } + } +} +``` + +## Common Patterns + +### Form Submission + +```javascript +// Submit form data to an API +export default { + async submitForm() { + try { + const response = await submitApi.run({ data: Form1.data }); + showAlert("Form submitted successfully", "success"); + } catch (error) { + showAlert("Error submitting form", "error"); + } + } +} +``` + +### Data Transformation + +```javascript +// Transform data before displaying +export default { + formatUserData(users) { + return users.map(user => ({ + fullName: `${user.firstName} ${user.lastName}`, + email: user.email + })); + } +} +``` + +### Conditional Logic + +```javascript +// Display a message based on a condition +{{Input1.text.length > 5 ? "Valid input" : "Input too short"}} +``` + +This comprehensive reference document provides a detailed overview of the various JavaScript patterns and practices within Appsmith, enabling developers to effectively utilize the platform's capabilities. By leveraging these patterns, you can build dynamic, responsive, and robust applications with ease. \ No newline at end of file diff --git a/app/server/appsmith-server/src/main/resources/ai-references/sql-reference.md b/app/server/appsmith-server/src/main/resources/ai-references/sql-reference.md new file mode 100644 index 000000000000..904495be0417 --- /dev/null +++ b/app/server/appsmith-server/src/main/resources/ai-references/sql-reference.md @@ -0,0 +1,197 @@ +# Appsmith SQL Reference + +## Binding Syntax in SQL + +In Appsmith, dynamic values can be injected into SQL queries using the `{{ }}` binding syntax. This allows for the creation of dynamic queries that can adapt to user inputs or other variables within the application. Appsmith automatically parameterizes these bindings to protect against SQL injection. + +### Basic Binding +```sql +-- Bind a text input value to a query +SELECT * FROM users WHERE id = {{Input1.text}} + +-- Bind a dropdown selected value to a query +SELECT * FROM orders WHERE status = {{Select_Status.selectedOptionValue}} +``` + +### Conditional Binding +```sql +-- Use conditional logic within bindings +SELECT * FROM users WHERE {{ Input1.text ? "name = '" + Input1.text + "'" : "1=1" }} + +-- Dynamic table name binding +SELECT * FROM {{ TableNamePicker.selectedOptionValue }} +``` + +### Complex Expressions +```sql +-- Use complex expressions in bindings +SELECT * FROM users WHERE age > {{AgeInput.text}} AND city = '{{CitySelect.selectedOptionValue}}' +``` + +## SELECT Patterns + +### Basic Selection +The `SELECT` statement is used to fetch data from a database. It can be used to retrieve all columns or specific columns from a table. + +```sql +-- Retrieve all columns from the users table +SELECT * FROM users + +-- Retrieve specific columns +SELECT id, name, email FROM users +``` + +### Pagination +Pagination is essential for handling large datasets by breaking them into manageable chunks. + +```sql +-- Implement pagination with LIMIT and OFFSET +SELECT * FROM users +ORDER BY id +LIMIT {{Table1.pageSize}} +OFFSET {{(Table1.pageNo - 1) * Table1.pageSize}} +``` + +### Search and Filtering +Search and filtering allow users to narrow down results based on specific criteria. + +```sql +-- Text search using ILIKE for case-insensitive matching +SELECT * FROM users WHERE name ILIKE '%' || {{Input_Search.text}} || '%' + +-- Filter based on multiple conditions +SELECT * FROM orders WHERE status = {{Select_Status.selectedOptionValue}} AND total > {{Input_MinTotal.text}} +``` + +### Sorting +Sorting is used to order query results based on one or more columns. + +```sql +-- Sort results dynamically based on user selection +SELECT * FROM users +ORDER BY {{Select_SortBy.selectedOptionValue || 'created_at'}} {{Select_SortDir.selectedOptionValue || 'DESC'}} +``` + +## INSERT Patterns + +### Insert from Form Inputs +Inserting data into a table can be done using values from form inputs or other widgets. + +```sql +-- Insert a new user record +INSERT INTO users (name, email, role) +VALUES ( + {{Input_Name.text}}, + {{Input_Email.text}}, + {{Select_Role.selectedOptionValue}} +) +``` + +### Bulk Insert +Bulk insert allows multiple records to be inserted in a single query, which can be more efficient. + +```sql +-- Insert multiple records from a JSON array +INSERT INTO users (id, name, email) +SELECT id, name, email +FROM json_populate_recordset(null::users, '{{FilePicker1.files[0].data}}') +``` + +## UPDATE Patterns + +### Update Single Record +Updating records involves modifying existing data in a table. + +```sql +-- Update a user's email based on their ID +UPDATE users +SET email = {{EmailInput.text}} +WHERE id = {{UsersTable.selectedRow.id}} +``` + +### Update Multiple Records +Updating multiple records can be achieved using conditional logic within the query. + +```sql +-- Update multiple users' names conditionally +UPDATE users +SET name = CASE + {{Table2.updatedRows.map((user) => `WHEN id = ${user.id} THEN '${user.updatedFields.name}'`).join('\n')}} +END +WHERE id IN ({{Table2.updatedRows.map((user) => user.allFields.id).join(',')}}) +``` + +## DELETE Patterns + +### Safe Deletion +Deleting records should be done carefully to avoid accidental data loss. + +```sql +-- Delete a user based on their ID +DELETE FROM users WHERE id = {{UsersTable.selectedRow.id}} +``` + +### Conditional Deletion +Use conditions to ensure only specific records are deleted. + +```sql +-- Delete products with a specific condition +DELETE FROM products WHERE category = {{Select_Category.selectedOptionValue}} AND price < {{Input_MaxPrice.text}} +``` + +## Database-Specific Tips + +### PostgreSQL +- Use `ILIKE` for case-insensitive searches. +- Utilize `jsonb` data type for storing JSON data efficiently. + +```sql +-- PostgreSQL case-insensitive search +SELECT * FROM users WHERE name ILIKE '%{{Input_Search.text}}%' +``` + +### MySQL +- Use `LIKE` for pattern matching. +- Consider using `ENUM` for fields with a limited set of values. + +```sql +-- MySQL pattern matching +SELECT * FROM users WHERE name LIKE '%{{Input_Search.text}}%' +``` + +### SQL Server +- Use `TOP` for limiting results instead of `LIMIT`. +- Use `CONVERT` for date formatting. + +```sql +-- SQL Server limit results +SELECT TOP {{Input_Limit.text}} * FROM users +``` + +## Working with Dates + +### Date Comparisons +Date comparisons are crucial for filtering records based on time. + +```sql +-- Select records within a date range +SELECT * FROM events WHERE event_date BETWEEN {{DatePicker_Start.selectedDate}} AND {{DatePicker_End.selectedDate}} +``` + +### Date Formatting +Formatting dates can be necessary for display purposes or further processing. + +```sql +-- Format a date in SQL Server +SELECT CONVERT(varchar, event_date, 101) AS formatted_date FROM events +``` + +### Using Moment.js +Appsmith supports Moment.js for date manipulation in queries. + +```sql +-- Use Moment.js to format dates +SELECT * FROM users WHERE dob > {{moment(DatePicker1.selectedDate).format('YYYY-MM-DD')}} +``` + +This reference document provides a comprehensive guide to using SQL within Appsmith, covering common patterns and best practices for dynamic queries, data manipulation, and database-specific tips. By following these guidelines, developers can efficiently build and manage data-driven applications on the Appsmith platform. \ No newline at end of file diff --git a/app/server/appsmith-server/src/main/resources/application-ce.properties b/app/server/appsmith-server/src/main/resources/application-ce.properties index e22d2c50f29b..9623574f5660 100644 --- a/app/server/appsmith-server/src/main/resources/application-ce.properties +++ b/app/server/appsmith-server/src/main/resources/application-ce.properties @@ -114,3 +114,9 @@ appsmith.index.lock.file.time=${APPSMITH_INDEX_LOCK_FILE_TIME:300} springdoc.api-docs.path=/v3/docs springdoc.swagger-ui.path=/v3/swagger + +# AI Assistant Configuration +# Path to external AI reference files (mode-specific prompts and common issues) +# Default: /appsmith/config/ai-references +# Files expected: javascript-reference.md, sql-reference.md, graphql-reference.md, common-issues.md +appsmith.ai.references.path=${APPSMITH_AI_REFERENCES_PATH:/appsmith/config/ai-references} diff --git a/docs/AI_PROMPTS_REFERENCE.md b/docs/AI_PROMPTS_REFERENCE.md new file mode 100644 index 000000000000..082587166435 --- /dev/null +++ b/docs/AI_PROMPTS_REFERENCE.md @@ -0,0 +1,133 @@ +# AI Prompts Reference (JS & Query Pages) + +Locations of prompts that shape AI responses for JavaScript and query editors. Use these when tuning or extending AI assistance. + +--- + +## 1. System prompts (what the model is “told” to be) + +These define the AI’s role and constraints. Same text is used on **client** (direct API) and **server** (proxy) paths. + +### JavaScript mode + +**Files:** + +- **Client:** `app/client/src/ce/services/AIAssistantService.ts` → `buildSystemPrompt()` (lines 132–137) +- **Server:** `app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/AIAssistantServiceCEImpl.java` → `JS_SYSTEM_PROMPT` (lines 271–273) + +**Current text:** + +```text +You are an expert JavaScript developer helping with Appsmith code. +Appsmith is a low-code platform. Provide clean, efficient JavaScript code that follows best practices. +Focus on the specific function or code block the user is working on. +``` + +### SQL / query mode + +**Files:** + +- **Client:** `app/client/src/ce/services/AIAssistantService.ts` → `buildSystemPrompt()` (lines 138–142) +- **Server:** `app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/AIAssistantServiceCEImpl.java` → `SQL_SYSTEM_PROMPT` (lines 275–278) + +**Current text:** + +```text +You are an expert SQL/query developer helping with database queries in Appsmith. +Provide optimized, correct SQL queries that follow best practices. +Consider the datasource type and ensure the query is syntactically correct. +``` + +**Note:** The client does not currently send datasource type in context; only `functionString`, `functionName`, and `cursorLineNumber` are sent (see “Context sent to the AI” below). To make “Consider the datasource type” effective, you’d need to add datasource (and optionally entity) info to the context. + +--- + +## 2. User prompt template (context + user request) + +The “user” message is built from editor context plus the user’s question. Same structure on client and server. + +**Files:** + +- **Client:** `app/client/src/ce/services/AIAssistantService.ts` → `buildUserPrompt()` (lines 144–162) +- **Server:** `app/server/appsmith-server/src/main/java/com/appsmith/server/services/ce/AIAssistantServiceCEImpl.java` → `buildUserPrompt()` (lines 286–326) + +**Structure:** + +- Optional: `Function: {functionName}` +- Optional: `Current function code:` + fenced block with `functionString` +- Optional: `Cursor at line: {cursorLineNumber + 1}` +- Then: `User request: {prompt}` +- Ending: `Provide the code solution:` + +So the model is explicitly asked to “Provide the code solution” for both JS and query modes. + +--- + +## 3. Context sent to the AI (what the model sees) + +**Built in:** `app/client/src/ce/components/editorComponents/GPT/trigger.tsx` → `getAIContext()` (lines 34–71). + +- **JavaScript:** ±15 lines around cursor. +- **SQL:** ±10 lines around cursor. +- **Sent fields:** `functionName` (currently always `""`), `cursorLineNumber`, `functionString`, `mode`, `cursorPosition`, `cursorCoordinates`. + +**Not sent today (but could improve responses):** + +- Datasource type (e.g. PostgreSQL, MySQL) for query mode +- Entity/action name (e.g. query or JS object name) +- App/page or widget names + +Extending `getAIContext()` and the `AIEditorContext` / `AIEditorContextDTO` types would allow system/user prompts to reference these (e.g. “Consider the datasource type”). + +--- + +## 4. Quick-action prompts (AI side panel) + +Predefined buttons that send a fixed prompt. Same in CE and EE. + +**File:** `app/client/src/ce/components/editorComponents/GPT/AISidePanel.tsx` → `QUICK_ACTIONS` (lines 386–408). + +| Label | Prompt | +|-------------|--------| +| Explain | `Explain what this code does step by step` | +| Fix Errors | `Find and fix any bugs or errors in this code` | +| Refactor | `Refactor this code to be cleaner and more efficient` | +| Add Comments| `Add helpful comments to explain this code` | + +These are passed through the same `buildUserPrompt()` so they get the same context (function code, cursor line, etc.). + +--- + +## 5. Table widget validation assist (inline edit) + +Not a “chat” prompt; it’s the short hint shown in the table inline-edit validation UI. Often used with “Ask AI” to generate validation expressions. + +**File:** `app/client/src/ce/constants/messages.ts` → `TABLE_WIDGET_VALIDATION_ASSIST_PROMPT` (lines 1234–1235). + +**Current text:** `Access the current cell using ` (incomplete in the constant; the rest may be concatenated or in the component). + +**Used in:** + +- `app/client/src/components/propertyControls/TableInlineEditValidationControl.tsx` +- `app/client/src/components/propertyControls/TableInlineEditValidPropertyControl.tsx` + +Improving this message can guide users (and any AI that reads the UI) on how to write table validation expressions. + +--- + +## 6. Where to change behavior + +| Goal | Where to edit | +|------|----------------| +| Change JS or SQL “persona” / instructions | System prompts in `AIAssistantService.ts` (CE) and `AIAssistantServiceCEImpl.java` (keep in sync). | +| Change how user message is formatted | `buildUserPrompt()` in the same two places. | +| Add datasource/entity/name to context | `getAIContext()` in `trigger.tsx` and the types/DTOs that carry context to the API. | +| Add or change quick-action prompts | `QUICK_ACTIONS` in `app/client/src/ce/components/editorComponents/GPT/AISidePanel.tsx`. | +| Improve table validation hint | `TABLE_WIDGET_VALIDATION_ASSIST_PROMPT` in `app/client/src/ce/constants/messages.ts` and the property controls that use it. | + +--- + +## 7. EE vs CE + +- **CE:** `app/client/src/ce/services/AIAssistantService.ts` and `app/client/src/ce/components/editorComponents/GPT/*`. +- **EE:** Re-exports or extends CE; prompt text and quick actions are defined in CE. Keep prompts in CE so one place controls behavior for both.