diff --git a/server/bundles/io.cloudbeaver.product.ce/plugin.xml b/server/bundles/io.cloudbeaver.product.ce/plugin.xml index 799de4c3927..9b505a93b05 100644 --- a/server/bundles/io.cloudbeaver.product.ce/plugin.xml +++ b/server/bundles/io.cloudbeaver.product.ce/plugin.xml @@ -16,6 +16,10 @@ + + + + + + + + + + + + + + + + + + + diff --git a/server/bundles/io.cloudbeaver.service.ai/pom.xml b/server/bundles/io.cloudbeaver.service.ai/pom.xml new file mode 100644 index 00000000000..664111c4657 --- /dev/null +++ b/server/bundles/io.cloudbeaver.service.ai/pom.xml @@ -0,0 +1,15 @@ + + + 4.0.0 + + io.cloudbeaver + bundles + 1.0.0-SNAPSHOT + ../ + + io.cloudbeaver.service.ai + 1.0.0-SNAPSHOT + eclipse-plugin + diff --git a/server/bundles/io.cloudbeaver.service.ai/schema/service.ai.events.graphqls b/server/bundles/io.cloudbeaver.service.ai/schema/service.ai.events.graphqls new file mode 100644 index 00000000000..6c6a7ee06b5 --- /dev/null +++ b/server/bundles/io.cloudbeaver.service.ai/schema/service.ai.events.graphqls @@ -0,0 +1,58 @@ +extend enum CBServerEventId { + cb_ai_chat_message_chunk @since(version: "25.1.1") + cb_ai_chat_message_error @since(version: "25.1.1") + cb_ai_chat_message @since(version: "25.3.5") +} + +extend enum CBClientEventId { + cb_client_ai_function_call_confirmation @since(version: "26.0.3") +} + +extend enum CBEventTopic { + cb_ai @since(version: "25.1.1") +} + +type WSAIChatMessageChunkEvent implements CBServerEvent @since(version: "25.1.1") { + id: CBServerEventId! + topicId: CBEventTopic + conversationId: ID! + messageId: ID! + chunk: String + completed: Boolean! +} + +type WSAIChatMessageErrorEvent implements CBServerEvent @since(version: "25.1.1") { + id: CBServerEventId! + topicId: CBEventTopic + conversationId: ID! + messageId: ID! + errorMessage: String +} + +type WSAIChatMessageEvent implements CBServerEvent @since(version: "25.3.5") { + id: CBServerEventId! + topicId: CBEventTopic + conversationId: ID! + messageId: ID! + role: String! + content: String! + displayMessage: String! + time: String! + "Present only with CONFIRMATION role" + functionConfirmation: AIFunctionConfirmation + "Present only with FUNCTION role" + functionCall: AIFunctionCall + "Present only with FUNCTION role" + functionResult: AIFunctionResult +} + +type WSAIFunctionCallConfirmationEvent implements CBClientEvent @since(version: "26.0.3") { + id: CBClientEventId! + topicId: CBEventTopic + conversationId: ID! + messageId: ID! + "Returns a list of confirmed functions" + confirmedFunctionCalls: [ID!] + "Returns a list of declined functions" + declinedFunctionCalls: [ID!] +} diff --git a/server/bundles/io.cloudbeaver.service.ai/schema/service.ai.graphqls b/server/bundles/io.cloudbeaver.service.ai/schema/service.ai.graphqls new file mode 100644 index 00000000000..dc0c1ac1763 --- /dev/null +++ b/server/bundles/io.cloudbeaver.service.ai/schema/service.ai.graphqls @@ -0,0 +1,274 @@ +# AI admin queries +enum AIMessageType { + SYSTEM, + USER, + ASSISTANT, + ERROR, + WARNING, + CONFIRMATION, + ATTACHMENT, + FUNCTION +} + +""" +Scope of the AI chat conversation. +Can be a database connection, a current database, a current schema or a custom scope. +""" +enum AIDatabaseScope @since(version: "25.1.1") { + CURRENT_SCHEMA + CURRENT_DATABASE + CURRENT_DATASOURCE + CUSTOM +} + +type AISettingsInfo @since(version: "23.2.2") { + activeEngine: ID! + defaultConfiguration: ID + language: String +} + +type AIEngineInfo @since(version: "23.2.2") { + "Unique identifier of the AI engine." + id: ID! + "Name of the AI engine." + name: String! +} + +type AIChatConversationInfo @since(version: "25.1.1") { + dataSourceId: DataSourceId + id: ID! + promptGeneratorId: ID + caption: String! + "Last message creation time." + time: DateTime! + messages: [AIMessage!]! + settings: AIChatConversationSettings + waitingForResponse: Boolean! @since(version: "25.1.5") + metrics: AIChatConversationMetrics @since(version: "25.3.5") + "ID of the AI engine configuration profile used for this conversation." + profile: ID @since(version: "26.1.3") +} + +type AIChatConversationMetrics @since(version: "25.3.5") { + totalInputTokens: Int! + totalOutputTokens: Int! +} + +type AIChatConversationSettings { + metaTransferConfirmed: Boolean! + scope: AIDatabaseScope + "If the scope is set to CUSTOM, this field is required." + customObjectIds: [ID!] +} + +type AIMessage { + conversationId: ID! + id: ID! + role: AIMessageType! + content: String! + displayMessage: String! + time: DateTime! + "Present only with CONFIRMATION role" + functionConfirmation: AIFunctionConfirmation + "Present only with FUNCTION role" + functionCall: AIFunctionCall + "Present only with FUNCTION role" + functionResult: AIFunctionResult +} + +type AISendChatMessageInfo @since(version: "25.1.1") { + conversation: AIChatConversationInfo! + userMessage: AIMessage! + "Message that would be received via websockets." + assistantMessage: AIMessage! +} + +type AIDataSourceSettingsInfo @since(version: "25.3.3") { + "Indicates whether user confirmed meta transfer to AI for the current data source." + metaTransferConfirmed: Boolean! + "Default scope for AI operations in this data source." + scope: AIDatabaseScope + "If the scope is set to CUSTOM, this field is required." + customObjectIds: [ID!] +} + +type DataSourceId @since(version: "25.1.1") { + projectId: ID! + connectionId: ID! +} + +type AIConfigurationProfileInfo @since(version: "26.1.3") { + "Unique identifier of the AI engine profile." + id: ID! + "Name of the AI engine profile." + name: String! + "ID of the AI engine that this profile belongs to." + engineId: ID! +} + +type AIAdminConfigurationProfileInfo @since(version: "26.1.3") { + "Unique identifier of the AI engine profile." + id: ID! + "Name of the AI engine profile." + name: String! + "ID of the AI engine that this profile belongs to." + engineId: ID! + "Configuration of the AI engine profile." + configuration: [ObjectPropertyInfo!]! +} + +type AIFunction @since(version: "26.0.4") { + "Unique identifier of the function (combines toolbox ID and function ID)" + id: ID! + "Human-readable name of the function" + name: String! + "Detailed description of what the function does" + description: String + "URL or path to the function icon" + icon: String + "Indicates whether the function is a system function (can be auto-confirmed when required)" + system: Boolean! +} + +type AIFunctionCall @since(version: "26.0.3") { + id: ID! + functionName: String! + arguments: Object + hint: String +} + +type AIFunctionConfirmation @since(version: "26.0.3") { + functionCalls: [AIFunctionCall!]! +} + +enum AIFunctionType { + ACTION + INFORMATION +} + +type AIFunctionResult @since(version: "26.0.3") { + type: AIFunctionType! + value: Object! +} + +input AISettingsConfig @since(version: "23.2.2") { + "Default configuration profile ID. May be null if AI wasn't configured" + defaultConfiguration: ID + "Language to be used as a default for AI responses" + language: String +} + +input AIEngineConfig @since(version: "23.2.2") { + properties: Object +} + +input AIDatabaseScopeInput { + id: AIDatabaseScope! + "If custom scope is selected, this field is required." + customScopes: [ID!] +} + +input DataSourceIdInput @since(version: "25.1.1") { + projectId: ID! + connectionId: ID! +} + +input AIChatConversationInput { + dataSourceId: DataSourceIdInput + caption: String + settings: AIChatConversationSettingsInput + "ID of the prompt generator to use for this conversation (sql - by default)." + promptGeneratorId: ID +} + +input AIChatConversationSettingsInput { + metaTransferConfirmed: Boolean + scope: AIDatabaseScope + profile: ID + customObjectIds: [ID!] +} + +input AIDataSourceSettingsInput @since(version: "25.3.3") { + "Flag indicating whether MCP integration is enabled for the current data source." + mcpEnabled: Boolean + "Flag indicating whether user confirmed meta transfer to AI." + metaTransferConfirmed: Boolean + "Default scope for AI operations in this data source." + scope: AIDatabaseScope + "If custom scope is selected, this field is required." + customObjectIds: [ID!] +} + +input AIConfigurationProfileInput @since(version: "26.1.3") { + "Unique identifier of the AI engine profile." + profileId: ID! + "Name of the AI engine profile." + profileName: String + "ID of the AI engine that this profile belongs to." + engineId: ID + "Configuration of the AI engine profile." + configuration: AIEngineConfig +} + +extend type Query @since(version: "23.2.2") { + "Returns the list of available AI engines." + aiListEngines: [AIEngineInfo!] + "Returns the global AI settings." + aiSettings: AISettingsInfo! + "Returns the properties of the specified AI engine for displaying it in the UI. In case settings are passed, fills it with defaults and returns back." + aiListEngineProperties(engineId: ID!, profileId: ID, settings : AIEngineConfig): [ObjectPropertyInfo!]! + + "Return the list of available AI functions." + aiListFunctions: [AIFunction!] @since(version: "26.0.4") + + "Returns the list of available AI engine configuration profiles." + aiListProfiles: [AIConfigurationProfileInfo!]! @since(version: "26.1.3") + + "Returns AI chat conversation info for the specified conversation ID." + aiChatConversationInfo(conversationId: ID!, loadMetrics: Boolean): AIChatConversationInfo! @since(version: "25.1.1") + "Returns list of AI chat conversations for the specified connection." + aiListChatConversations(dataSourceId: DataSourceIdInput): [AIChatConversationInfo!]! @since(version: "25.1.1") + + "Returns AI settings for the specified connection." + aiDataSourceSettings(dataSourceId: DataSourceIdInput!): AIDataSourceSettingsInfo! @since(version: "25.3.3") +} + +extend type Mutation @since(version: "23.2.2") { + "Saves the global AI settings (active AI engine)." + aiSaveSettings(settings: AISettingsConfig!): AISettingsInfo! + "Saves the configuration of the specified AI engine." + aiSaveEngineConfiguration(profileId: ID!, settings : AIEngineConfig!): Boolean! @deprecated(reason: "use profiles api") + "Creates an anync task to perform AI query completion." + asyncAiPerformQueryCompletion(projectId: ID, connectionId: ID!, contextId: ID!, request: String!): AsyncTaskInfo! + "Retrieves the result of an async AI query completion task." + asyncAiPerformQueryCompletionResult(taskId: ID!): String + + "Creates a new AI engine configuration profile." + aiCreateProfile(config: AIConfigurationProfileInput!): AIAdminConfigurationProfileInfo! @since(version: "26.1.3") + "Updates the configuration of the specified AI engine configuration profile." + aiUpdateProfile(config: AIConfigurationProfileInput!): AIAdminConfigurationProfileInfo! @since(version: "26.1.3") + "Deletes the specified AI engine configuration profile." + aiDeleteProfile(profileId: ID!): Boolean! @since(version: "26.1.3") + + """ + Creates a new AI chat conversation. + Conversation is created in the context of a project and connection. + If the connection and project are not specified, the conversation will be created without a context. + """ + aiCreateChatConversation(config: AIChatConversationInput!): AIChatConversationInfo! @since(version: "25.1.1") + + "Updates the caption of the AI chat conversation" + aiUpdateChatConversation(conversationId: ID!, config: AIChatConversationInput!): AIChatConversationInfo! @since(version: "25.1.1") + + "Deletes the specified AI chat conversation" + aiDeleteChatConversation(conversationId: ID!): Boolean! @since(version: "25.1.1") + + "Sends a chat message to the AI chat conversation and returns a message ID from response. The response will be received partially via websockets." + aiSendChatMessage(conversationId: ID!, prompt: String!): AISendChatMessageInfo! @since(version: "25.1.1") + + "Clears the chat messages in the AI chat conversation. The messages will be removed from the conversation." + aiClearLastChatMessages(conversationId: ID!, messageId: ID!): Boolean! @since(version: "25.1.1") + + "Saves AI settings (e.g. supporting confirming metadata transfer) for the specified connection." + aiSaveDataSourceSettings(dataSourceId: DataSourceIdInput!, settings: AIDataSourceSettingsInput!): AIDataSourceSettingsInfo! @since(version: "25.3.3") +} diff --git a/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/WebAIFeatureProvider.java b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/WebAIFeatureProvider.java new file mode 100644 index 00000000000..6901e542787 --- /dev/null +++ b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/WebAIFeatureProvider.java @@ -0,0 +1,46 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2024 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package io.cloudbeaver.service.ai; + +import io.cloudbeaver.model.session.WebSession; +import io.cloudbeaver.service.navigator.DBWFeatureProvider; +import io.cloudbeaver.utils.ServletAppUtils; +import org.jkiss.code.NotNull; +import org.jkiss.dbeaver.model.ai.utils.AIUtils; +import org.jkiss.dbeaver.model.navigator.DBNDatabaseNode; +import org.jkiss.dbeaver.model.navigator.DBNNode; + +import java.util.ArrayList; +import java.util.List; + +public class WebAIFeatureProvider implements DBWFeatureProvider { + public static final String AI_FEATURE_ID = "ai"; + private static final String FEATURE_CAN_DESCRIBE_OBJECT = "canDescribeObject"; + + @NotNull + @Override + public List getNodeFeatures(@NotNull WebSession webSession, @NotNull DBNNode node) { + List features = new ArrayList<>(); + if (!ServletAppUtils.getServletApplication().getAppConfiguration().isFeatureEnabled(AI_FEATURE_ID)) { + return features; + } + if (node instanceof DBNDatabaseNode element && AIUtils.isEligible(element.getObject())) { + features.add(FEATURE_CAN_DESCRIBE_OBJECT); + } + return features; + } +} diff --git a/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/WebAIUtils.java b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/WebAIUtils.java new file mode 100644 index 00000000000..4a22f6c4902 --- /dev/null +++ b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/WebAIUtils.java @@ -0,0 +1,299 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.cloudbeaver.service.ai; + +import io.cloudbeaver.DBWebException; +import io.cloudbeaver.model.session.WebSession; +import io.cloudbeaver.service.ai.model.WebAIChatConversation; +import io.cloudbeaver.service.ai.model.WebAIMessage; +import io.cloudbeaver.service.ai.model.WebAISendChatMessageInfo; +import io.cloudbeaver.service.ai.model.WebAiChatResponseConsumer; +import io.cloudbeaver.service.ai.model.events.WSAiChatMessageEvent; +import io.cloudbeaver.utils.ServletAppUtils; +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; +import org.jkiss.dbeaver.DBException; +import org.jkiss.dbeaver.Log; +import org.jkiss.dbeaver.model.DBPDataSourceContainer; +import org.jkiss.dbeaver.model.DBUtils; +import org.jkiss.dbeaver.model.ai.*; +import org.jkiss.dbeaver.model.ai.qm.AIChatStorage; +import org.jkiss.dbeaver.model.ai.quota.UserTokenQuotaService; +import org.jkiss.dbeaver.model.ai.registry.AIAssistantRegistry; +import org.jkiss.dbeaver.model.ai.utils.AIUtils; +import org.jkiss.dbeaver.model.app.DBPProject; +import org.jkiss.dbeaver.model.navigator.DBNDatabaseNode; +import org.jkiss.dbeaver.model.navigator.DBNNode; +import org.jkiss.dbeaver.model.navigator.DBNUtils; +import org.jkiss.dbeaver.model.runtime.DBRProgressMonitor; +import org.jkiss.dbeaver.model.struct.DBSObject; +import org.jkiss.dbeaver.utils.RuntimeUtils; +import org.jkiss.utils.CommonUtils; + +import java.time.Clock; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +public class WebAIUtils { + private static final Log log = Log.getLog(WebAIUtils.class); + private static final String AI_WAITING_ATTR = "ai.waiting."; + public static final String AI_CHAT_ATTR = "ai_chat"; + public static final String AI_CHAT_USER_ATTR = "ai_chat_user"; + + @NotNull + public static List convertObjectIdsToNodePaths( + @NotNull DBRProgressMonitor monitor, + @NotNull DBPProject project, + @NotNull String[] objectIds + ) { + List customObjectIdList = new ArrayList<>(); + for (String id : objectIds) { + if (id != null && !id.isEmpty()) { + try { + DBSObject dbpObject = DBUtils.findObjectById(monitor, project, id); + if (dbpObject != null) { + DBNDatabaseNode dbNode = DBNUtils.getNodeByObject(monitor, dbpObject, false); + if (dbNode != null) { + customObjectIdList.add(dbNode.getNodeUri()); + } + } + } catch (DBException e) { + log.error("Error finding object by ID: " + id, e); + } + } + } + return customObjectIdList; + } + + @NotNull + public static String[] convertNodePathsToObjectIds( + @NotNull WebSession session, + @NotNull DBPProject project, + @NotNull String[] nodePaths + ) { + List nodes = new ArrayList<>(); + for (String nodePath : nodePaths) { + if (nodePath != null && !nodePath.isEmpty()) { + try { + DBNNode node = session.getNavigatorModelOrThrow().getNodeByPath(session.getProgressMonitor(), project, nodePath); + if (node != null) { + nodes.add(node); + } + } catch (DBException e) { + log.error("Error finding object by ID: " + nodePath, e); + } + } + } + return nodes.stream() + .map(DBNDatabaseNode.class::cast) + .map(DBNDatabaseNode::getValueObject) + .map(DBSObject.class::cast) + .map(DBUtils::getObjectFullId) + .toArray(String[]::new); + } + + @Nullable + public static AIChatSession findAiChatSession(@NotNull WebSession webSession) { + return webSession.getAttribute(AI_CHAT_ATTR); + } + + @Nullable + public static AIContextSettingsChatConversation getConversationSettings( + @NotNull AIChatSession chatSession, + @NotNull AIChatConversation conversation + ) { + DBPDataSourceContainer container = conversation.getDataSource(); + if (container == null) { + return null; + } + AIContextSettingsChatConversation chatContextSettings = conversation.getCustomSettings(); + if (chatContextSettings == null) { + chatContextSettings = new AIContextSettingsChatConversation(chatSession, conversation); + conversation.setCustomSettings(chatContextSettings); + } + chatContextSettings.loadDataSourceDefaults(); + chatSession.updateScopeSettingsIfNeeded(chatContextSettings, container); + return chatContextSettings; + } + + @NotNull + public static CompletableFuture scheduleConversationSubmission( + @NotNull WebSession webSession, + @NotNull AIChatSession aiChatSession, + @NotNull AIChatConversation conversation, + @Nullable AIConfirmation confirmation, + @NotNull String jobName + ) { + webSession.setAttribute(getWaitingAttr(conversation), true); + CompletableFuture result = new CompletableFuture<>(); + RuntimeUtils.scheduleJob( + jobName, monitor -> { + try { + AIChatResponseConsumer subscriber = new WebAiChatResponseConsumer(conversation, webSession, aiChatSession); + aiChatSession.processAICompletion( + monitor, + conversation, + subscriber, + WebAIUtils.getConversationSettings(aiChatSession, conversation), + confirmation + ).whenComplete((submittedConversation, error) -> { + if (error != null) { + result.completeExceptionally(error); + } else { + result.complete(submittedConversation); + } + }); + } catch (DBException e) { + log.error("Error processing AI completion", e); + var errorMessage = conversation.addMessage(AIMessage.errorMessage(e)); + webSession.addSessionEvent(new WSAiChatMessageEvent(new WebAIMessage(errorMessage, conversation))); + aiChatSession.notifyMessageAdd(conversation, errorMessage); + } finally { + webSession.removeAttribute(getWaitingAttr(conversation)); + } + } + ); + return result; + } + + @NotNull + public static String getWaitingAttr(@NotNull AIChatConversation conversation) { + return AI_WAITING_ATTR + conversation.getId(); + } + + @NotNull + public static AIChatSession getAiChatSession(@NotNull WebSession webSession) throws DBWebException { + validateAiPluginEnabled(); + String aiChatSessionUser = webSession.getAttribute(AI_CHAT_USER_ATTR); + boolean sameUser = webSession.isAuthorizedInSecurityManager() && + CommonUtils.equalObjects(webSession.getUserId(), aiChatSessionUser); + if (sameUser || webSession.getUserId() == null) { + AIChatSession aiChatSession = webSession.getAttribute(AI_CHAT_ATTR); + if (aiChatSession != null) { + return aiChatSession; + } + } + return createChatSession(webSession); + } + + @NotNull + public static AIChatSession createChatSession(@NotNull WebSession webSession) { + AIAssistant assistant = AIAssistantRegistry.getInstance().getAssistant(webSession.getWorkspace()); + AIChatStorage qmChatStorage = assistant.createChatStorage(); + + AIChatSession aiChatSession = new AIChatSession( + webSession.getWorkspace(), + dataSourceContainer -> DBUtils.getDefaultContext(dataSourceContainer, false), + qmChatStorage, + assistant.getChatSessionProvider(), + new UserTokenQuotaService(Clock.systemUTC(), qmChatStorage) + ); + aiChatSession.addListener(new AIChatListener() { + @Override + public void messageAdded(@NotNull AIChatConversation conversation, @NotNull AIChatMessage message) { + if (message.message().getConfirmation() instanceof AIFunctionCallConfirmation fcc) { + for (AIFunctionCall fc : fcc.getFunctionCalls()) { + // functions are not resolved at the current step, we need to resolve them first + // otherwise no information about a function will be sent + AIFunctionDescriptor function = fc.getOrResolveFunction(aiChatSession.getAssistant().getToolboxManager()); + if (function == null) { + log.warn("Function is not found for function call " + fc.getFunctionName()); + } + } + WebAIMessage confirmationMessage = new WebAIMessage(message, conversation); + WSAiChatMessageEvent event = new WSAiChatMessageEvent(confirmationMessage); + webSession.addSessionEvent(event); + } + } + }); + webSession.setAttribute(AI_CHAT_ATTR, aiChatSession); + if (webSession.getUser() != null) { + String userId = webSession.getUserId(); + webSession.setAttribute(AI_CHAT_USER_ATTR, userId); + } + return aiChatSession; + } + + @NotNull + public static AIChatConversation getAiChatConversation( + @NotNull WebSession webSession, + @NotNull String conversationId + ) throws DBWebException { + try { + AIChatSession aiChatSession = getAiChatSession(webSession); + AIChatConversation conversation = aiChatSession.getConversation(conversationId); + if (conversation == null) { + throw new DBWebException("Invalid chat conversation ID " + conversationId); + } + return conversation; + } catch (DBException e) { + throw new DBWebException(e.getMessage(), e); + } + } + + @NotNull + public static WebAISendChatMessageInfo submitPrompt( + @NotNull WebSession webSession, + @NotNull AIChatSession aiChatSession, + @NotNull AIChatConversation conversation, + @NotNull AIMessage message + ) throws DBException { + if (!isMetaTransferConfirmed(conversation)) { + throw new DBWebException("AI services restricted for '%s'. Please contact your administrator if you need it.".formatted( + conversation.getDataSource())); + } + String caption = conversation.getCaption(); + AIChatMessage promptMessage = conversation.addMessage(message); + webSession.addSessionEvent(new WSAiChatMessageEvent(new WebAIMessage(promptMessage, conversation))); + aiChatSession.notifyMessageAdd(conversation, promptMessage); + if (!CommonUtils.equalObjects(caption, conversation.getCaption())) { + aiChatSession.notifyConversationRenamed(conversation, conversation.getCaption()); + } + if (!AIUtils.hasValidConfiguration()) { + throw new DBWebException("Invalid AI configuration"); + } + if (webSession.getAttribute(WebAIUtils.getWaitingAttr(conversation)) != null) { + throw new DBWebException("Conversation is already waiting for response"); + } + AIChatMessage result = new AIChatMessage(conversation.getNextMessageId(), AIMessage.assistantMessage("", null)); + WebAIUtils.scheduleConversationSubmission(webSession, aiChatSession, conversation, null, "AI completion"); + return new WebAISendChatMessageInfo( + new WebAIChatConversation(webSession, conversation), + new WebAIMessage(promptMessage, conversation), + new WebAIMessage(result, conversation) + ); + } + + private static boolean isMetaTransferConfirmed(@NotNull AIChatConversation conversation) { + DBPDataSourceContainer dataSourceContainer = conversation.getDataSource(); + if (dataSourceContainer == null) { + return true; + } + AIContextSettingsDataSource dsSettings = new AIContextSettingsDataSource(dataSourceContainer); + return dsSettings.isMetaTransferConfirmed(); + } + + + public static void validateAiPluginEnabled() throws DBWebException { + var isAiEnabled = ServletAppUtils.getServletApplication().getAppConfiguration() + .isFeatureEnabled(WebAIFeatureProvider.AI_FEATURE_ID); + if (!isAiEnabled) { + throw new DBWebException("AI feature is disabled"); + } + } +} diff --git a/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/WebServiceAIManager.java b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/WebServiceAIManager.java new file mode 100644 index 00000000000..ceb85f21d0e --- /dev/null +++ b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/WebServiceAIManager.java @@ -0,0 +1,36 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.cloudbeaver.service.ai; + +import io.cloudbeaver.DBWebException; +import io.cloudbeaver.service.ai.gql.WebAISettingsConfig; +import org.jkiss.code.Nullable; +import org.jkiss.dbeaver.model.ai.AIConstants; +import org.jkiss.dbeaver.runtime.DBWorkbench; + +public class WebServiceAIManager { + + public WebAISettingsConfig getAiSettings() throws DBWebException { + return null; + } + + @Nullable + private static String getAiLanguage() { + return DBWorkbench.getPlatform().getPreferenceStore() + .getString(AIConstants.AI_RESPONSE_LANGUAGE); + } +} diff --git a/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/gql/DBWServiceAI.java b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/gql/DBWServiceAI.java new file mode 100644 index 00000000000..975fc9b6e85 --- /dev/null +++ b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/gql/DBWServiceAI.java @@ -0,0 +1,176 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.cloudbeaver.service.ai.gql; + +import io.cloudbeaver.*; +import io.cloudbeaver.model.WebAsyncTaskInfo; +import io.cloudbeaver.model.WebPropertyInfo; +import io.cloudbeaver.model.session.WebSession; +import io.cloudbeaver.service.DBWService; +import io.cloudbeaver.service.ai.model.*; +import io.cloudbeaver.service.ai.model.inputs.DataSourceId; +import io.cloudbeaver.service.ai.model.inputs.WebAIChatConversationInput; +import io.cloudbeaver.service.ai.model.inputs.WebAIConfigurationProfileInput; +import io.cloudbeaver.service.ai.model.inputs.WebAiChatCompletionSettingsInput; +import io.cloudbeaver.service.sql.WebSQLContextInfo; +import jakarta.servlet.http.HttpServletRequest; +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; +import org.jkiss.dbeaver.model.rm.RMConstants; + +import java.util.List; +import java.util.Map; + +/** + * Service for executing GraphQL functions for AI module. + */ +public interface DBWServiceAI extends DBWService { + + @NotNull + @WebAction(requirePermissions = DBWConstants.PERMISSION_ADMIN) + WebAISettingsConfig getAiSettings() throws DBWebException; + + @NotNull + @WebAction(requirePermissions = DBWConstants.PERMISSION_ADMIN) + List getEngineConfigurations() throws DBWebException; + + @NotNull + @WebAction(requirePermissions = DBWConstants.PERMISSION_ADMIN) + List getEngineConfigurationParameters( + @NotNull WebSession webSession, + @NotNull String engineId, + @Nullable String profileId, + @Nullable Map engineSettings + ) throws DBWebException; + + @NotNull + @WebAction + List getFunctions(@NotNull WebSession webSession) throws DBWebException; + + @NotNull + @WebAction + List getProfiles(@NotNull WebSession webSession) throws DBWebException; + + @NotNull + @WebAction(requirePermissions = DBWConstants.PERMISSION_ADMIN) + WebAISettingsConfig saveAiSettings( + @NotNull WebSession webSession, + @NotNull WebAISettingsConfig config + ) throws DBWebException; + + @WebAction(requirePermissions = DBWConstants.PERMISSION_ADMIN) + boolean saveEngineConfiguration( + @NotNull WebSession webSession, + @NotNull String profileId, + @WebParameterSecure @NotNull Map engineSettings + ) throws DBWebException; + + @NotNull + @WebAction + WebAsyncTaskInfo performQueryCompletion( + @NotNull WebSession webSession, + @NotNull WebSQLContextInfo contextInfo, + @NotNull String request + ) throws DBWebException; + + @Nullable + @WebAction + String performQueryCompletionResult(@NotNull WebSession webSession, @NotNull String taskId) throws DBWebException; + + @NotNull + @WebAction + WebAIChatConversation createChat( + @NotNull WebSession webSession, + @NotNull WebAIChatConversationInput conversationInput + ) throws DBWebException; + + @NotNull + @WebAction + WebAIChatConversation updateChatConversation( + @NotNull WebSession webSession, + @NotNull String conversationId, + @NotNull WebAIChatConversationInput conversationInput + ) throws DBWebException; + + @WebAction + boolean deleteChatConversation(@NotNull WebSession webSession, @NotNull String conversationId) throws DBWebException; + + @NotNull + @WebAction + List getChatConversations( + @NotNull WebSession webSession, + @Nullable DataSourceId dataSourceId + ) throws DBWebException; + + @NotNull + @WebAction + WebAIChatConversation getChatConversationInfo( + @NotNull WebSession webSession, + @NotNull String conversationId, + @Nullable Boolean loadMetrics + ) throws DBWebException; + + @NotNull + @WebAction + WebAISendChatMessageInfo asyncSendChatMessage( + @NotNull WebSession webSession, + @NotNull String conversationId, + @NotNull String prompt + ) throws DBWebException; + + @WebAction + boolean setLastChatMessage( + @NotNull WebSession webSession, + @NotNull String conversationId, + @NotNull String messageId + ) throws DBWebException; + + @NotNull + @WebAction + WebAIDataSourceSettings getDataSourceAiSettings( + @NotNull HttpServletRequest request, + @NotNull WebSession webSession, + @NotNull DataSourceId dataSourceId + ) throws DBWebException; + + @NotNull + @WebProjectAction(requireProjectPermissions = RMConstants.PERMISSION_PROJECT_DATASOURCES_EDIT) + WebAIDataSourceSettings saveDataSourceAiSettings( + @NotNull HttpServletRequest request, + @NotNull WebSession webSession, + @NotNull @WebObjectId String projectId, + @NotNull DataSourceId dataSourceId, + @NotNull WebAiChatCompletionSettingsInput settings + ) throws DBWebException; + + @NotNull + @WebAction(requirePermissions = DBWConstants.PERMISSION_ADMIN) + WebAIConfigurationProfile createProfile( + @NotNull WebSession webSession, + @WebParameterSecure @NotNull WebAIConfigurationProfileInput config + ) throws DBWebException; + + @NotNull + @WebAction(requirePermissions = DBWConstants.PERMISSION_ADMIN) + WebAIConfigurationProfile updateProfile( + @NotNull WebSession webSession, + @WebParameterSecure @NotNull WebAIConfigurationProfileInput config + ) throws DBWebException; + + @WebAction(requirePermissions = DBWConstants.PERMISSION_ADMIN) + boolean deleteProfile(@NotNull WebSession webSession, @NotNull String profileId) throws DBWebException; +} diff --git a/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/gql/WebAIEngine.java b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/gql/WebAIEngine.java new file mode 100644 index 00000000000..f9a565dba22 --- /dev/null +++ b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/gql/WebAIEngine.java @@ -0,0 +1,44 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2024 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package io.cloudbeaver.service.ai.gql; + +import org.jkiss.code.NotNull; +import org.jkiss.dbeaver.model.ai.registry.AIEngineDescriptor; +import org.jkiss.dbeaver.model.meta.Property; + +public class WebAIEngine { + + @NotNull + private final AIEngineDescriptor descriptor; + + public WebAIEngine(@NotNull AIEngineDescriptor descriptor) { + this.descriptor = descriptor; + } + + @NotNull + @Property + public String getId() { + return descriptor.getId(); + } + + @NotNull + @Property + public String getName() { + return descriptor.getLabel(); + } + +} diff --git a/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/gql/WebAISettingsConfig.java b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/gql/WebAISettingsConfig.java new file mode 100644 index 00000000000..cb0de5f2b5b --- /dev/null +++ b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/gql/WebAISettingsConfig.java @@ -0,0 +1,51 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.cloudbeaver.service.ai.gql; + +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; + +public class WebAISettingsConfig { + + private final String activeEngine; + private final String defaultConfiguration; + private final String language; + + public WebAISettingsConfig( + @NotNull String engineId, + @Nullable String defaultConfiguration, + @Nullable String language + ) { + this.defaultConfiguration = defaultConfiguration; + this.activeEngine = engineId; + this.language = language; + } + + @NotNull + public String getActiveEngine() { + return activeEngine; + } + + @Nullable + public String getDefaultConfiguration() { + return defaultConfiguration; + } + + public String getLanguage() { + return language; + } +} diff --git a/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/gql/WebServiceAI.java b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/gql/WebServiceAI.java new file mode 100644 index 00000000000..c76bfbb00a7 --- /dev/null +++ b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/gql/WebServiceAI.java @@ -0,0 +1,679 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.cloudbeaver.service.ai.gql; + +import io.cloudbeaver.DBWebException; +import io.cloudbeaver.WebServiceUtils; +import io.cloudbeaver.model.WebAsyncTaskInfo; +import io.cloudbeaver.model.WebPropertyInfo; +import io.cloudbeaver.model.session.WebAsyncTaskProcessor; +import io.cloudbeaver.model.session.WebSession; +import io.cloudbeaver.server.CBApplication; +import io.cloudbeaver.service.ai.WebAIUtils; +import io.cloudbeaver.service.ai.model.*; +import io.cloudbeaver.service.ai.model.inputs.DataSourceId; +import io.cloudbeaver.service.ai.model.inputs.WebAIChatConversationInput; +import io.cloudbeaver.service.ai.model.inputs.WebAIConfigurationProfileInput; +import io.cloudbeaver.service.ai.model.inputs.WebAiChatCompletionSettingsInput; +import io.cloudbeaver.service.sql.WebSQLContextInfo; +import io.cloudbeaver.service.sql.WebSQLProcessor; +import io.cloudbeaver.utils.ServletAppUtils; +import io.cloudbeaver.utils.WebDataSourceUtils; +import io.cloudbeaver.utils.WebEventUtils; +import jakarta.servlet.http.HttpServletRequest; +import org.jkiss.code.NotNull; +import org.jkiss.code.NotNullWhen; +import org.jkiss.code.Nullable; +import org.jkiss.dbeaver.DBException; +import org.jkiss.dbeaver.Log; +import org.jkiss.dbeaver.model.DBPDataSourceContainer; +import org.jkiss.dbeaver.model.ai.*; +import org.jkiss.dbeaver.model.ai.engine.AIDatabaseContext; +import org.jkiss.dbeaver.model.ai.engine.AIEngine; +import org.jkiss.dbeaver.model.ai.engine.AIEngineProperties; +import org.jkiss.dbeaver.model.ai.engine.AIModel; +import org.jkiss.dbeaver.model.ai.prompt.AIPromptGenerateSql; +import org.jkiss.dbeaver.model.ai.registry.*; +import org.jkiss.dbeaver.model.app.DBPProject; +import org.jkiss.dbeaver.model.data.json.JSONUtils; +import org.jkiss.dbeaver.model.logical.DBSLogicalDataSource; +import org.jkiss.dbeaver.model.preferences.DBPPropertyDescriptor; +import org.jkiss.dbeaver.model.runtime.DBRProgressMonitor; +import org.jkiss.dbeaver.model.websocket.event.WSWorkspaceConfigurationChangedEvent; +import org.jkiss.dbeaver.runtime.DBWorkbench; +import org.jkiss.dbeaver.runtime.properties.PropertySourceEditable; +import org.jkiss.utils.CommonUtils; + +import java.lang.reflect.InvocationTargetException; +import java.util.*; +import java.util.stream.Collectors; + +public class WebServiceAI implements DBWServiceAI { + + private static final Log log = Log.getLog(WebServiceAI.class); + public static final String AI_WAITING_ATTR = "ai.waiting."; + + @NotNull + @Override + public WebAISettingsConfig getAiSettings() throws DBWebException { + WebAIUtils.validateAiPluginEnabled(); + AISettings settings = AISettingsManager.getInstance().getSettings(); + try { + AIConfigurationProfile configuration = settings.getDefaultConfiguration(); + return new WebAISettingsConfig( + configuration.getEngineId(), + configuration.getProfileId(), + getAiLanguage() + ); + } catch (DBException e) { + throw new DBWebException(e); + } + } + + @NotNull + @Override + public List getEngineConfigurations() throws DBWebException { + WebAIUtils.validateAiPluginEnabled(); + List result = new ArrayList<>(); + AIEngineRegistry.getInstance().getCompletionEngines().forEach( + engineDescriptor -> result.add(new WebAIEngine(engineDescriptor)) + ); + return result; + } + + @NotNull + @Override + public List getEngineConfigurationParameters( + @NotNull WebSession webSession, + @NotNull String engineId, + @Nullable String profileId, + @Nullable Map settingsInput + ) throws DBWebException { + WebAIUtils.validateAiPluginEnabled(); + try { + AIConfigurationProfile profile = getDefaultConfiguration(engineId, profileId); + + AIEngineProperties engineConfiguration; + if (settingsInput != null) { + engineConfiguration = toEngineConfiguration( + webSession.getProgressMonitor(), + profile, + fillModelDefaults(webSession.getProgressMonitor(), profile, settingsInput) + ); + } else { + engineConfiguration = profile.getConfiguration(); + } + + return List.of( + WebServiceUtils.getObjectFilteredProperties(webSession, engineConfiguration, null) + ); + } catch (DBException e) { + throw new DBWebException("Error getting engine configuration parameters", e); + } + } + + @NotNull + private AIConfigurationProfile getDefaultConfiguration(@NotNull String engineId, @Nullable String profileId) throws DBException { + AISettings aiSettings = AISettingsManager.getInstance().getSettings(); + if (profileId != null) { + return aiSettings.getConfiguration(profileId); + } + + AIConfigurationProfile profile = new AIConfigurationProfile(); + profile.setEngineId(engineId); + profile.getEngineDescriptor(); // validate engine descriptor + return profile; + } + + @NotNull + @Override + public List getFunctions(@NotNull WebSession webSession) throws DBWebException { + AIAssistant assistant = AIAssistantRegistry.getInstance().getAssistant(webSession.getWorkspace()); + return assistant.getToolboxManager().getAllFunctions(AIFunctionPurpose.TOOL).stream() + .map(WebAIFunctionInfo::new) + .toList(); + } + + @NotNull + @Override + public List getProfiles(@NotNull WebSession webSession) { + return Arrays.stream(AISettingsManager.getInstance().getSettings().getConfigurations()) + .map(profile -> new WebAIConfigurationProfile(webSession, profile)) + .toList(); + } + + @NotNull + @Override + public WebAISettingsConfig saveAiSettings(@NotNull WebSession webSession, @NotNull WebAISettingsConfig config) throws DBWebException { + WebAIUtils.validateAiPluginEnabled(); + + try { + setAiLanguage(webSession, config.getLanguage()); + } catch (DBException e) { + throw new DBWebException("Error when saving AI language", e); + } + AISettings aiSettings = AISettingsManager.getInstance().getSettings(); + AIConfigurationProfile profile = aiSettings.getConfigurationOrNull(config.getDefaultConfiguration()); + aiSettings.setDefaultConfiguration(profile); + AISettingsManager.getInstance().saveSettings(); + + addAISettingsChangedEvent(webSession); + return new WebAISettingsConfig( + profile == null ? "" : profile.getEngineId(), + profile == null ? null : profile.getProfileId(), + getAiLanguage() + ); + } + + @Override + public boolean saveEngineConfiguration( + @NotNull WebSession webSession, + @NotNull String profileId, + @NotNull Map engineSettingsInput + ) throws DBWebException { + WebAIUtils.validateAiPluginEnabled(); + try { + AISettings settings = AISettingsManager.getInstance().getSettings(); + AIConfigurationProfile profile = settings.getConfiguration(profileId); + profile.setConfiguration(toEngineConfiguration(webSession.getProgressMonitor(), profile, engineSettingsInput)); + AISettingsManager.getInstance().saveSettings(); + addAISettingsChangedEvent(webSession); + return true; + } catch (DBException e) { + throw new DBWebException("Error saving AI configuration " + profileId); + } + } + + @NotNull + @Override + public WebAsyncTaskInfo performQueryCompletion( + @NotNull WebSession webSession, + @NotNull WebSQLContextInfo contextInfo, + @NotNull String request + ) throws DBWebException { + WebAIUtils.validateAiPluginEnabled(); + WebAsyncTaskProcessor runnable = new WebAsyncTaskProcessor<>() { + @Override + public void run(DBRProgressMonitor monitor) throws InvocationTargetException { + monitor.beginTask("Perform query completion", 1); + monitor.subTask("Perform query completion for " + request); + try { + WebSQLProcessor processor = contextInfo.getProcessor(); + DBSLogicalDataSource lDataSource = new DBSLogicalDataSource( + processor.getConnection().getDataSourceContainer(), + "AI logical wrapper", + null + ); + AIDatabaseContext dbContext = new AIDatabaseContext.Builder(lDataSource) + .setExecutionContext(processor.getExecutionContext()) + .build(); + + AIAssistant assistant = AIAssistantRegistry.getInstance().getAssistant(webSession.getWorkspace()); + + AIFunctionContext fc = new AIFunctionContext(monitor, dbContext, new AIPromptGenerateSql()); + AIMessage userMessage = AIMessage.userMessage(request); + AIAssistantResponse result = assistant.generateText( + monitor, + AISettingsManager.getStaticSettings().getDefaultConfiguration(), + fc, + List.of(userMessage) + ); + + if (result.isText()) { + this.result = AITextUtils.extractGeneratedSqlQuery( + monitor, dbContext, userMessage, result.getText()); + } else { + this.result = ""; + } + } catch (DBException e) { + throw new InvocationTargetException(e); + } finally { + monitor.done(); + } + } + }; + + return webSession.createAndRunAsyncTask("AI perform completion query", runnable); + } + + @Nullable + @Override + public String performQueryCompletionResult(@NotNull WebSession webSession, @NotNull String taskId) throws DBWebException { + WebAIUtils.validateAiPluginEnabled(); + WebAsyncTaskInfo taskStatus = webSession.asyncTaskStatus(taskId, false); + return CommonUtils.toString(taskStatus.getResult()); + } + + @NotNull + @Override + public WebAIChatConversation createChat( + @NotNull WebSession webSession, + @NotNull WebAIChatConversationInput conversationInput + ) throws DBWebException { + AIChatSession aiChatSession = WebAIUtils.getAiChatSession(webSession); + DBPDataSourceContainer dataSource = getDataSource(webSession, conversationInput.dataSourceId()); + try { + String promptGeneratorId = conversationInput.promptGeneratorId(); + if (promptGeneratorId == null) { + // by default use SQL generator + promptGeneratorId = AIPromptGenerateSql.SQL_GENERATOR_ID; + } + AIPromptGeneratorDescriptor descriptor = AIPromptGeneratorRegistry.getInstance().getPromptGenerator(promptGeneratorId); + if (descriptor == null) { + throw new DBWebException("Invalid prompt generator ID " + promptGeneratorId); + } + AIChatConversation newConversation = new AIChatConversation( + conversationInput.caption() != null ? conversationInput.caption() : "New conversation", + descriptor.createGenerator(), + dataSource + ); + aiChatSession.addConversation(newConversation); + saveCompletionSettings(webSession, aiChatSession, newConversation, conversationInput.settings()); + return new WebAIChatConversation(webSession, newConversation); + } catch (DBException e) { + throw new DBWebException("Error creating conversation", e); + } + } + + @NotNull + @Override + public WebAIChatConversation updateChatConversation( + @NotNull WebSession webSession, + @NotNull String conversationId, + @NotNull WebAIChatConversationInput input + ) throws DBWebException { + WebAIUtils.validateAiPluginEnabled(); + AIChatSession aiChatSession = WebAIUtils.getAiChatSession(webSession); + AIChatConversation conversation = WebAIUtils.getAiChatConversation(webSession, conversationId); + String caption = input.caption(); + if (caption != null) { + renameConversation(aiChatSession, conversation, caption); + } + saveCompletionSettings(webSession, aiChatSession, conversation, input.settings()); + return new WebAIChatConversation(webSession, conversation); + } + + @Override + public boolean deleteChatConversation(@NotNull WebSession webSession, @NotNull String conversationId) throws DBWebException { + try { + AIChatSession aiChatSession = WebAIUtils.getAiChatSession(webSession); + AIChatConversation conversation = aiChatSession.getConversation(conversationId); + if (conversation == null) { + throw new DBWebException("Invalid conversation ID " + conversationId); + } + aiChatSession.removeConversation(conversation); + } catch (DBException e) { + throw new DBWebException(e.getMessage(), e); + } + return true; + } + + @NotNull + @Override + public List getChatConversations( + @NotNull WebSession webSession, + @Nullable DataSourceId dataSourceId + ) throws DBWebException { + AIChatSession session = WebAIUtils.getAiChatSession(webSession); + DBPDataSourceContainer connectionInfo = getDataSource(webSession, dataSourceId); + try { + return session.getAllConversations(connectionInfo).stream() + .map(c -> new WebAIChatConversation(webSession, c)) + .collect(Collectors.toList()); + } catch (DBException e) { + throw new DBWebException(e.getMessage(), e); + } + } + + @NotNull + @Override + public WebAIChatConversation getChatConversationInfo( + @NotNull WebSession webSession, + @NotNull String conversationId, + @Nullable Boolean loadMetrics + ) throws DBWebException { + WebAIUtils.validateAiPluginEnabled(); + AIChatConversation conversation = WebAIUtils.getAiChatConversation(webSession, conversationId); + WebAIChatConversationMetrics metrics = null; + if (loadMetrics != null && loadMetrics) { + AIExtendedUsage aiExtendedUsage = conversation.computeUsage(); + metrics = WebAIChatConversationMetrics.from(aiExtendedUsage); + } + return new WebAIChatConversation(webSession, conversation, metrics); + } + + @NotNull + @Override + public WebAISendChatMessageInfo asyncSendChatMessage( + @NotNull WebSession webSession, + @NotNull String conversationId, + @NotNull String prompt + ) throws DBWebException { + WebAIUtils.validateAiPluginEnabled(); + try { + AIChatSession aiChatSession = WebAIUtils.getAiChatSession(webSession); + AIChatConversation conversation = aiChatSession.getConversation(conversationId); + if (conversation == null) { + throw new DBWebException("Invalid chat conversation ID " + conversationId); + } + if (prompt.trim().isEmpty()) { + throw new DBWebException("Invalid prompt"); + } + AIMessage message = AIMessage.userMessage(prompt.trim()); + return WebAIUtils.submitPrompt(webSession, aiChatSession, conversation, message); + } catch (DBException e) { + throw new DBWebException(e.getMessage(), e); + } + } + + @Override + public boolean setLastChatMessage( + @NotNull WebSession webSession, + @NotNull String conversationId, + @NotNull String messageId + ) throws DBWebException { + AIChatSession chatSession = WebAIUtils.getAiChatSession(webSession); + AIChatConversation conversation = WebAIUtils.getAiChatConversation(webSession, conversationId); + AIChatMessage message = conversation.getMessages().stream() + .filter(m -> messageId.equals(CommonUtils.toString(m.id()))) + .findFirst() + .orElseThrow(() -> new DBWebException("Invalid message ID " + messageId)); + chatSession.notifyMessagesRemove(conversation, message); + conversation.clearMessagesAfter(message); + return true; + } + + @NotNull + @Override + public WebAIDataSourceSettings getDataSourceAiSettings( + @NotNull HttpServletRequest request, + @NotNull WebSession webSession, + @NotNull DataSourceId dataSourceId + ) throws DBWebException { + DBPDataSourceContainer dataSourceContainer = getDataSource(webSession, dataSourceId); + AIContextSettingsDataSource settings = new AIContextSettingsDataSource(dataSourceContainer); + String userOrigin = ServletAppUtils.getOriginFromRequest(request); + return new WebAIDataSourceSettings(settings, userOrigin); + } + + @NotNull + @Override + public WebAIDataSourceSettings saveDataSourceAiSettings( + @NotNull HttpServletRequest request, + @NotNull WebSession webSession, + @NotNull String projectId, + @NotNull DataSourceId dataSourceId, + @NotNull WebAiChatCompletionSettingsInput settingsInput + ) throws DBWebException { + DBPDataSourceContainer dataSourceContainer = getDataSource(webSession, dataSourceId); + AIContextSettingsDataSource aiSettings = new AIContextSettingsDataSource(dataSourceContainer); + if (settingsInput.mcpEnabled() != null) { + aiSettings.setMcpEnabled(settingsInput.mcpEnabled()); + } + if (settingsInput.metaTransferConfirmed() != null) { + aiSettings.setMetaTransferConfirmed(settingsInput.metaTransferConfirmed()); + } + if (settingsInput.scope() != null) { + aiSettings.setScope(settingsInput.scope()); + } + if (settingsInput.customObjectIds() != null) { + aiSettings.setCustomObjectIds( + WebAIUtils.convertNodePathsToObjectIds( + webSession, + dataSourceContainer.getProject(), + settingsInput.customObjectIds() + ) + ); + } + aiSettings.saveSettings(); + String userOrigin = ServletAppUtils.getOriginFromRequest(request); + return new WebAIDataSourceSettings(aiSettings, userOrigin); + } + + @NotNull + @Override + public WebAIConfigurationProfile createProfile( + @NotNull WebSession webSession, + @NotNull WebAIConfigurationProfileInput input + ) throws DBWebException { + WebAIUtils.validateAiPluginEnabled(); + AISettings settings = AISettingsManager.getInstance().getSettings(); + if (CommonUtils.isEmpty(input.profileId())) { + throw new DBWebException("Profile ID is not specified"); + } + if (CommonUtils.isEmpty(input.engineId())) { + throw new DBWebException("Engine ID is not specified"); + } + if (CommonUtils.isEmpty(input.profileName())) { + throw new DBWebException("Profile name is not specified"); + } + AIEngineDescriptor engineDescriptor = AIEngineRegistry.getInstance().getEngineDescriptor(input.engineId()); + if (engineDescriptor == null) { + throw new DBWebException("Invalid AI engine configuration ID " + input.engineId()); + } + try { + settings.createConfiguration(input.profileId(), engineDescriptor); + AIConfigurationProfile profile = settings.getConfiguration(input.profileId()); + profile.setProfileName(input.profileName()); + if (input.configuration() != null) { + profile.setConfiguration(toEngineConfiguration(webSession.getProgressMonitor(), profile, input.configuration())); + } + AISettingsManager.getInstance().saveSettings(); + addAISettingsChangedEvent(webSession); + return new WebAIConfigurationProfile(webSession, settings.getConfiguration(input.profileId())); + } catch (DBException e) { + + throw new DBWebException("Error creating AI configuration " + input.profileId(), e); + } + } + + @NotNull + @Override + public WebAIConfigurationProfile updateProfile( + @NotNull WebSession webSession, + @NotNull WebAIConfigurationProfileInput input + ) throws DBWebException { + WebAIUtils.validateAiPluginEnabled(); + try { + AISettings settings = AISettingsManager.getInstance().getSettings(); + AIConfigurationProfile profile = settings.getConfiguration(input.profileId()); + if (input.profileName() != null) { + profile.setProfileName(input.profileName()); + } + if (input.configuration() != null) { + profile.setConfiguration(toEngineConfiguration(webSession.getProgressMonitor(), profile, input.configuration())); + } + AISettingsManager.getInstance().saveSettings(); + addAISettingsChangedEvent(webSession); + return new WebAIConfigurationProfile(webSession, profile); + } catch (DBException e) { + throw new DBWebException("Error updating AI configuration " + input.profileId(), e); + } + } + + @Override + public boolean deleteProfile(@NotNull WebSession webSession, @NotNull String profileId) throws DBWebException { + WebAIUtils.validateAiPluginEnabled(); + try { + AISettings settings = AISettingsManager.getInstance().getSettings(); + AIConfigurationProfile profile = settings.getConfiguration(profileId); + settings.removeConfiguration(profile); + } catch (DBException e) { + throw new DBWebException("Error deleting AI configuration " + profileId, e); + } + return true; + } + + @NotNullWhen("dataSourceId != null") + private DBPDataSourceContainer getDataSource( + @NotNull WebSession webSession, + @Nullable DataSourceId dataSourceId + ) throws DBWebException { + if (dataSourceId == null) { + return null; + } + return WebDataSourceUtils.getWebConnectionInfo(webSession, dataSourceId.projectId(), dataSourceId.connectionId()) + .getDataSourceContainer(); + } + + private void addAISettingsChangedEvent(@NotNull WebSession webSession) { + ServletAppUtils.getServletApplication().getEventController().addEvent( + new WSWorkspaceConfigurationChangedEvent( + AISettingsManager.AI_CONFIGURATION_FILE_NAME, + WebEventUtils.getSmSessionId(webSession), + webSession.getUserId() + ) + ); + } + + @NotNull + private Map fillModelDefaults( + @NotNull DBRProgressMonitor monitor, + @NotNull AIConfigurationProfile profile, + @NotNull Map engineSettingsInput + ) throws DBException { + // TODO: Think how default values can be set without modifying input map and not affecting the code model + Map inputProperties = JSONUtils.getObject(engineSettingsInput, "properties"); + + AIEngineProperties configuration; + try { + configuration = profile.getConfiguration(); + + if (!configuration.isValidConfiguration()) { + inputProperties.put(AIConstants.AI_CONTEXT_SIZE_PROPERTY, AIConstants.DEFAULT_CONTEXT_WINDOW_SIZE); + return engineSettingsInput; + } + } catch (DBException e) { + throw new DBWebException("Error converting engine configuration", e); + } + + if (!profile.getEngineDescriptor().isProvidesMetadata()) { + return engineSettingsInput; + } + try (AIEngine engine = profile.getEngineDescriptor().createEngineInstance(configuration)) { + Object modelId = inputProperties.get(AIConstants.AI_MODEL_PROPERTY); + AIModel model = engine.getModels(monitor) + .stream() + .filter(m -> Objects.equals(m.name(), modelId)) + .findFirst() + .orElse(null); + + if (model != null) { + inputProperties.put(AIConstants.AI_CONTEXT_SIZE_PROPERTY, model.contextWindowSize()); + inputProperties.put(AIConstants.AI_TEMPERATURE_PROPERTY, model.defaultTemperature()); + } else { + inputProperties.put(AIConstants.AI_CONTEXT_SIZE_PROPERTY, AIConstants.DEFAULT_CONTEXT_WINDOW_SIZE); + log.info("Model " + modelId + " not found"); + } + } catch (DBException e) { + log.info("Error fetching models ", e); + } + return engineSettingsInput; + } + + @NotNull + private AIEngineProperties toEngineConfiguration( + @NotNull DBRProgressMonitor progressMonitor, + @NotNull AIConfigurationProfile profile, + @NotNull Map engineSettingsInput + ) throws DBWebException { + Map engineProperties = JSONUtils.getObject(engineSettingsInput, "properties"); + // TODO: Think how default values can be set without modifying input map and not affecting the code model + if (engineProperties.get(AIConstants.AI_TEMPERATURE_PROPERTY) instanceof String stringValue && stringValue.isBlank()) { + engineProperties.put(AIConstants.AI_TEMPERATURE_PROPERTY, 0.0); + } + if (engineProperties.get(AIConstants.AI_CONTEXT_SIZE_PROPERTY) instanceof String stringValue && stringValue.isBlank()) { + engineProperties.put(AIConstants.AI_CONTEXT_SIZE_PROPERTY, AIConstants.DEFAULT_CONTEXT_WINDOW_SIZE); + } + + try { + AIEngineProperties configuration = profile.getConfiguration(); + PropertySourceEditable pse = new PropertySourceEditable(configuration, configuration); + pse.collectProperties(); + for (DBPPropertyDescriptor pd : pse.getProperties()) { + Object value = engineProperties.get(pd.getId()); + if (value != null) { + pse.setPropertyValue(progressMonitor, pd.getId(), value); + } + } + return configuration; + } catch (DBException e) { + throw new DBWebException("Error converting engine configuration", e); + } + } + + private void renameConversation( + @NotNull AIChatSession chatSession, + @NotNull AIChatConversation conversation, + @NotNull String newName + ) { + conversation.setCaption(newName.trim()); + chatSession.notifyConversationRenamed(conversation, newName.trim()); + } + + private static void saveCompletionSettings( + @NotNull WebSession webSession, + @NotNull AIChatSession session, + @NotNull AIChatConversation conversation, + @Nullable WebAiChatCompletionSettingsInput settingsInput + ) throws DBWebException { + AIContextSettingsChatConversation settings = WebAIUtils.getConversationSettings(session, conversation); + if (settings == null || settingsInput == null) { + return; + } + if (settingsInput.profile() != null) { + AIConfigurationProfile profile = AISettingsManager.getInstance().getSettings().getConfigurationOrNull(settingsInput.profile()); + if (profile == null) { + throw new DBWebException("Invalid AI configuration profile " + settingsInput.profile()); + } + conversation.setProfile(profile); + } + if (settingsInput.metaTransferConfirmed() != null) { + settings.setMetaTransferConfirmed(settingsInput.metaTransferConfirmed()); + } + if (settingsInput.scope() != null) { + settings.setScope(settingsInput.scope()); + } + if (settingsInput.customObjectIds() != null) { + DBPProject project = conversation.getDataSource() == null ? null : conversation.getDataSource().getProject(); + if (project != null) { + settings.setCustomObjectIds(WebAIUtils.convertNodePathsToObjectIds(webSession, project, settingsInput.customObjectIds())); + } + } + try { + settings.saveSettings(); + } catch (DBException e) { + throw new DBWebException("Error saving AI completion settings", e); + } + } + + private static void setAiLanguage(@NotNull WebSession webSession, @Nullable String language) throws DBException { + if (language != null) { + CBApplication.getInstance().saveProductConfiguration( + webSession, + Map.of(AIConstants.AI_RESPONSE_LANGUAGE, language) + ); + } + } + + @Nullable + private static String getAiLanguage() { + return DBWorkbench.getPlatform().getPreferenceStore() + .getString(AIConstants.AI_RESPONSE_LANGUAGE); + } + +} diff --git a/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/gql/WebServiceAIConfigurator.java b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/gql/WebServiceAIConfigurator.java new file mode 100644 index 00000000000..eb594615277 --- /dev/null +++ b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/gql/WebServiceAIConfigurator.java @@ -0,0 +1,66 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package io.cloudbeaver.service.ai.gql; + +import io.cloudbeaver.model.app.ServletAppConfiguration; +import io.cloudbeaver.model.app.ServletApplication; +import io.cloudbeaver.model.app.ServletServerConfiguration; +import io.cloudbeaver.model.session.WebSession; +import io.cloudbeaver.service.DBWServiceServerConfigurator; +import io.cloudbeaver.service.ai.WebAIFeatureProvider; +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; +import org.jkiss.dbeaver.DBException; +import org.jkiss.dbeaver.model.ai.registry.AISettingsManager; +import org.jkiss.dbeaver.model.websocket.event.WSWorkspaceConfigurationChangedEvent; + +public class WebServiceAIConfigurator implements DBWServiceServerConfigurator { + @Override + public void configureServer( + @NotNull ServletApplication application, + @Nullable WebSession session, + @NotNull ServletServerConfiguration serverConfiguration, + @NotNull ServletAppConfiguration appConfig + ) throws DBException { + var aiDisabled = !appConfig.isFeatureEnabled(WebAIFeatureProvider.AI_FEATURE_ID); + + if (aiDisabled && !AISettingsManager.isConfigExists()) { + // do nothing, ai disabled and settings not exists + return; + } + var settings = AISettingsManager.getInstance().getSettings(); + settings.setAiDisabled(aiDisabled); + application.getEventController().addEvent( + new WSWorkspaceConfigurationChangedEvent( + AISettingsManager.AI_CONFIGURATION_FILE_NAME, + null, + null + ) + ); + } + + + @Override + public void migrateConfigurationIfNeeded(@NotNull ServletApplication application) throws DBException { + + } + + @Override + public void reloadConfiguration(@NotNull ServletAppConfiguration appConfig) throws DBException { + + } +} diff --git a/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/gql/WebServiceBindingAI.java b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/gql/WebServiceBindingAI.java new file mode 100644 index 00000000000..0615a4731ad --- /dev/null +++ b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/gql/WebServiceBindingAI.java @@ -0,0 +1,215 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.cloudbeaver.service.ai.gql; + +import io.cloudbeaver.server.CBApplication; +import io.cloudbeaver.server.graphql.GraphQLEndpoint; +import io.cloudbeaver.service.DBWBindingContext; +import io.cloudbeaver.service.DBWServiceInitializer; +import io.cloudbeaver.service.WebServiceBindingBase; +import io.cloudbeaver.service.ai.WebAIFeatureProvider; +import io.cloudbeaver.service.ai.model.inputs.DataSourceId; +import io.cloudbeaver.service.ai.model.inputs.WebAIChatConversationInput; +import io.cloudbeaver.service.ai.model.inputs.WebAIConfigurationProfileInput; +import io.cloudbeaver.service.ai.model.inputs.WebAiChatCompletionSettingsInput; +import io.cloudbeaver.service.sql.WebServiceBindingSQL; +import org.jkiss.code.NotNull; +import org.jkiss.dbeaver.Log; +import org.jkiss.dbeaver.model.ai.AIConstants; +import org.jkiss.dbeaver.model.ai.registry.AISettingsManager; +import org.jkiss.dbeaver.model.data.json.JSONUtils; +import org.jkiss.dbeaver.model.preferences.DBPPreferenceStore; +import org.jkiss.dbeaver.runtime.DBWorkbench; +import org.jkiss.dbeaver.utils.PrefUtils; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public class WebServiceBindingAI extends WebServiceBindingBase implements DBWServiceInitializer { + private static final Log log = Log.getLog(WebServiceBindingAI.class); + + public WebServiceBindingAI() { + super(DBWServiceAI.class, new WebServiceAI(), "schema/service.ai.graphqls"); + DBPPreferenceStore store = DBWorkbench.getPlatform().getPreferenceStore(); + PrefUtils.setDefaultPreferenceValue(store, AIConstants.AI_INCLUDE_SOURCE_TEXT_IN_QUERY_COMMENT, true); + } + + @Override + public void bindWiring(DBWBindingContext model) { + model.getQueryType() + .dataFetcher("aiSettings", env -> getService(env).getAiSettings()) + .dataFetcher("aiListEngines", env -> getService(env).getEngineConfigurations()) + .dataFetcher( + "aiListEngineProperties", + env -> getService(env).getEngineConfigurationParameters( + getWebSession(env), + getArgumentVal(env, "engineId"), + getArgument(env, "profileId"), + env.getArgument("settings") + ) + ).dataFetcher("aiListFunctions", env -> getService(env).getFunctions(getWebSession(env)) + ).dataFetcher("aiListProfiles", env -> getService(env).getProfiles(getWebSession(env)) + ).dataFetcher( + "aiListChatConversations", + env -> getService(env).getChatConversations( + getWebSession(env), + JSONUtils.deserializeObject(getArgument(env, "dataSourceId"), DataSourceId.class) + ) + ).dataFetcher( + "aiChatConversationInfo", + env -> getService(env).getChatConversationInfo( + getWebSession(env), + getArgumentVal(env, "conversationId"), + env.getArgument("loadMetrics") + ) + ).dataFetcher( + "aiDataSourceSettings", + env -> getService(env).getDataSourceAiSettings( + GraphQLEndpoint.getServletRequestOrThrow(env), + getWebSession(env), + JSONUtils.deserializeObject(getArgumentVal(env, "dataSourceId"), DataSourceId.class) + ) + ); + model.getMutationType() + .dataFetcher( + "aiSaveSettings", + env -> getService(env).saveAiSettings( + getWebSession(env), + JSONUtils.deserializeObject(getArgumentVal(env, "settings"), WebAISettingsConfig.class) + ) + ) + .dataFetcher( + "aiSaveDataSourceSettings", + + env -> { + DataSourceId dataSourceId = JSONUtils.deserializeObject( + getArgumentVal(env, "dataSourceId"), + DataSourceId.class + ); + return getService(env).saveDataSourceAiSettings( + GraphQLEndpoint.getServletRequestOrThrow(env), + getWebSession(env), + dataSourceId.projectId(), + dataSourceId, + JSONUtils.deserializeObject(getArgumentVal(env, "settings"), WebAiChatCompletionSettingsInput.class) + ); + } + ) + .dataFetcher( + "aiSaveEngineConfiguration", + env -> getService(env).saveEngineConfiguration( + getWebSession(env), + getArgumentVal(env, "profileId"), + getArgumentVal(env, "settings") + ) + ).dataFetcher( + "asyncAiPerformQueryCompletion", + env -> getService(env).performQueryCompletion( + getWebSession(env), + WebServiceBindingSQL.getSQLContext(env), + getArgumentVal(env, "request") + ) + ).dataFetcher( + "asyncAiPerformQueryCompletionResult", + env -> getService(env).performQueryCompletionResult( + getWebSession(env), + getArgumentVal(env, "taskId") + ) + ).dataFetcher( + "aiCreateChatConversation", + env -> getService(env).createChat( + getWebSession(env), + JSONUtils.deserializeObject(getArgument(env, "config"), WebAIChatConversationInput.class) + ) + ).dataFetcher( + "aiUpdateChatConversation", + env -> getService(env).updateChatConversation( + getWebSession(env), + getArgumentVal(env, "conversationId"), + JSONUtils.deserializeObject(getArgument(env, "config"), WebAIChatConversationInput.class) + ) + ).dataFetcher( + "aiDeleteChatConversation", + env -> getService(env).deleteChatConversation( + getWebSession(env), + getArgumentVal(env, "conversationId") + ) + ).dataFetcher( + "aiSendChatMessage", + env -> getService(env).asyncSendChatMessage( + getWebSession(env), + getArgumentVal(env, "conversationId"), + getArgumentVal(env, "prompt") + ) + ).dataFetcher( + "aiClearLastChatMessages", + env -> getService(env).setLastChatMessage( + getWebSession(env), + getArgumentVal(env, "conversationId"), + getArgumentVal(env, "messageId") + ) + ).dataFetcher( + "aiCreateProfile", env -> getService(env).createProfile( + getWebSession(env), + JSONUtils.deserializeObject(getArgumentVal(env, "config"), WebAIConfigurationProfileInput.class) + ) + ).dataFetcher( + "aiUpdateProfile", env -> getService(env).updateProfile( + getWebSession(env), + JSONUtils.deserializeObject(getArgumentVal(env, "config"), WebAIConfigurationProfileInput.class) + ) + ).dataFetcher( + "aiDeleteProfile", env -> getService(env).deleteProfile( + getWebSession(env), + getArgumentVal(env, "profileId") + ) + ); + } + + @Override + public void initializeService(@NotNull CBApplication application) { + // adds event listener to config file changer + AISettingsManager.getInstance().addChangedListener( + x -> { + // check if AI is enabled + boolean aiEnabled = !x.getSettings().isAiDisabled(); + List enabledFeatures = new ArrayList<>(Arrays.asList(application.getAppConfiguration().getEnabledFeatures())); + boolean aiFeatureEnabled = enabledFeatures.contains(WebAIFeatureProvider.AI_FEATURE_ID); + // remove AI feature if it is disabled (and do not remove if it is enabled) + if ((aiEnabled && aiFeatureEnabled) || (!aiEnabled && !aiFeatureEnabled)) { + // nothing to change + return; + } + if (aiEnabled) { + enabledFeatures.add(WebAIFeatureProvider.AI_FEATURE_ID); + } else { + enabledFeatures.remove(WebAIFeatureProvider.AI_FEATURE_ID); + } + application.getAppConfiguration().setEnabledFeatures(enabledFeatures.toArray(String[]::new)); + if (application.isConfigurationMode()) { + return; + } + try { + application.flushConfiguration(); + } catch (Exception e) { + log.error("Failed to save server configuration", e); + } + } + ); + } +} diff --git a/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/WebAIChatConversation.java b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/WebAIChatConversation.java new file mode 100644 index 00000000000..8e16bd39384 --- /dev/null +++ b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/WebAIChatConversation.java @@ -0,0 +1,132 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.cloudbeaver.service.ai.model; + +import io.cloudbeaver.model.session.WebSession; +import io.cloudbeaver.service.ai.gql.WebServiceAI; +import io.cloudbeaver.service.ai.model.inputs.DataSourceId; +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; +import org.jkiss.dbeaver.model.DBPDataSourceContainer; +import org.jkiss.dbeaver.model.ai.AIChatConversation; +import org.jkiss.dbeaver.model.ai.AIContextSettingsChatConversation; + +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.List; +import java.util.UUID; + +public class WebAIChatConversation { + + @NotNull + private final WebSession webSession; + @NotNull + private final AIChatConversation conversation; + @Nullable + private final WebAIChatConversationMetrics metrics; + + public WebAIChatConversation( + @NotNull WebSession webSession, + @NotNull AIChatConversation conversation + ) { + this.webSession = webSession; + this.conversation = conversation; + this.metrics = null; + } + + public WebAIChatConversation( + @NotNull WebSession webSession, + @NotNull AIChatConversation conversation, + @Nullable WebAIChatConversationMetrics metrics + ) { + this.webSession = webSession; + this.conversation = conversation; + this.metrics = metrics; + } + + @Nullable + public DataSourceId getDataSourceId() { + DBPDataSourceContainer dataSourceContainer = conversation.getDataSource(); + if (dataSourceContainer == null) { + return null; + } + return new DataSourceId( + dataSourceContainer.getProject().getId(), + dataSourceContainer.getId() + ); + } + + @NotNull + public UUID getId() { + return conversation.getId(); + } + + @NotNull + public String getPromptGeneratorId() { + return conversation.getPromptGenerator().generatorId(); + } + + @NotNull + public String getCaption() { + return conversation.getCaption(); + } + + @Nullable + public WebAIChatConversationSettings getSettings() { + AIContextSettingsChatConversation settings = conversation.getCustomSettings(); + if (settings == null) { + return null; + } + return new WebAIChatConversationSettings(webSession, settings, conversation.getDataSource()); + } + + @NotNull + public OffsetDateTime getTime() { + LocalDateTime time = conversation.getMessages().isEmpty() ? conversation.getTime() : conversation.getLastMessageTime(); + return time.atZone(ZoneId.systemDefault()) + .withZoneSameInstant(ZoneOffset.UTC) + .toOffsetDateTime(); + } + + @NotNull + public List getMessages() { + return conversation.getMessages().stream() + .map(message -> new WebAIMessage(message, conversation)) + .toList(); + } + + public boolean isWaitingForResponse() { + return webSession.getAttribute(WebServiceAI.AI_WAITING_ATTR + conversation.getId()) != null; + } + + @Nullable + public WebAIChatConversationMetrics getMetrics() { + return metrics; + } + + @Nullable + public String getProfile() { + return conversation.getProfile() == null ? null : conversation.getProfile().getProfileId(); + } + + @NotNull + public AIChatConversation getConversation() { + return conversation; + } +} diff --git a/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/WebAIChatConversationMetrics.java b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/WebAIChatConversationMetrics.java new file mode 100644 index 00000000000..7e4daaa5bec --- /dev/null +++ b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/WebAIChatConversationMetrics.java @@ -0,0 +1,44 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package io.cloudbeaver.service.ai.model; + +import org.jkiss.dbeaver.model.ai.AIExtendedUsage; + +public class WebAIChatConversationMetrics { + private final int totalInputTokens; + private final int totalOutputTokens; + + private WebAIChatConversationMetrics(int totalInputTokens, int totalOutputTokens) { + this.totalInputTokens = totalInputTokens; + this.totalOutputTokens = totalOutputTokens; + } + + public int getTotalInputTokens() { + return totalInputTokens; + } + + public int getTotalOutputTokens() { + return totalOutputTokens; + } + + public static WebAIChatConversationMetrics from(AIExtendedUsage aiExtendedUsage) { + return new WebAIChatConversationMetrics( + aiExtendedUsage.totalInputTokens(), + aiExtendedUsage.totalOutputTokens() + ); + } +} diff --git a/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/WebAIChatConversationSettings.java b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/WebAIChatConversationSettings.java new file mode 100644 index 00000000000..d98caaf4eeb --- /dev/null +++ b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/WebAIChatConversationSettings.java @@ -0,0 +1,73 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.cloudbeaver.service.ai.model; + +import io.cloudbeaver.model.session.WebSession; +import io.cloudbeaver.service.ai.WebAIUtils; +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; +import org.jkiss.dbeaver.Log; +import org.jkiss.dbeaver.model.DBPDataSourceContainer; +import org.jkiss.dbeaver.model.ai.AIContextSettingsChatConversation; +import org.jkiss.dbeaver.model.ai.AIDatabaseScope; +import org.jkiss.dbeaver.model.app.DBPProject; + +import java.util.List; + +public class WebAIChatConversationSettings { + private static final Log log = Log.getLog(WebAIChatConversationSettings.class); + + @NotNull + private final WebSession webSession; + @NotNull + private final AIContextSettingsChatConversation settings; + @Nullable + private final DBPProject project; + + public WebAIChatConversationSettings( + @NotNull WebSession webSession, + @NotNull AIContextSettingsChatConversation settings, + @Nullable DBPDataSourceContainer container + ) { + this.webSession = webSession; + this.settings = settings; + this.project = container == null ? null : container.getProject(); + } + + public boolean isMetaTransferConfirmed() { + return true; //FIXME: we need to implement this + } + + public AIDatabaseScope getScope() { + return settings.getScope(); + } + + public List getCustomObjectIds() { + if (project == null) { + return List.of(); + } + String[] customObjectIds = settings.getCustomObjectIds(); + if (customObjectIds == null || customObjectIds.length == 0) { + return List.of(); + } + return WebAIUtils.convertObjectIdsToNodePaths( + webSession.getProgressMonitor(), + project, + customObjectIds + ); + } +} diff --git a/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/WebAIConfigurationProfile.java b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/WebAIConfigurationProfile.java new file mode 100644 index 00000000000..a811b32ad42 --- /dev/null +++ b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/WebAIConfigurationProfile.java @@ -0,0 +1,57 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package io.cloudbeaver.service.ai.model; + +import io.cloudbeaver.WebServiceUtils; +import io.cloudbeaver.model.WebPropertyInfo; +import io.cloudbeaver.model.session.WebSession; +import org.jkiss.code.NotNull; +import org.jkiss.dbeaver.DBException; +import org.jkiss.dbeaver.model.ai.AIConfigurationProfile; + +public class WebAIConfigurationProfile { + + @NotNull + private final transient WebSession webSession; + @NotNull + private final AIConfigurationProfile profile; + + public WebAIConfigurationProfile(@NotNull WebSession webSession, @NotNull AIConfigurationProfile profile) { + this.webSession = webSession; + this.profile = profile; + } + + @NotNull + public String getId() { + return profile.getProfileId(); + } + + @NotNull + public String getName() { + return profile.getProfileName(); + } + + @NotNull + public String getEngineId() { + return profile.getEngineId(); + } + + @NotNull + public WebPropertyInfo[] getConfiguration() throws DBException { + return WebServiceUtils.getObjectFilteredProperties(webSession, profile.getConfiguration(), null); + } +} diff --git a/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/WebAIDataSourceSettings.java b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/WebAIDataSourceSettings.java new file mode 100644 index 00000000000..0db60a93523 --- /dev/null +++ b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/WebAIDataSourceSettings.java @@ -0,0 +1,64 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.cloudbeaver.service.ai.model; + +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; +import org.jkiss.dbeaver.model.DBPDataSourceContainer; +import org.jkiss.dbeaver.model.ai.AIContextSettings; +import org.jkiss.dbeaver.model.ai.AIContextSettingsDataSource; +import org.jkiss.dbeaver.model.ai.AIDatabaseScope; +import org.jkiss.dbeaver.model.meta.Property; + +public class WebAIDataSourceSettings { + @NotNull + private final AIContextSettingsDataSource settings; + + @NotNull + private final String userOrigin; + + public WebAIDataSourceSettings(@NotNull AIContextSettingsDataSource settings, @NotNull String userOrigin) { + this.settings = settings; + this.userOrigin = userOrigin; + } + + @Property + public boolean isMetaTransferConfirmed() { + return settings.isMetaTransferConfirmed(); + } + + @Nullable + @Property + public AIDatabaseScope getScope() { + return settings.getScope(); + } + + @NotNull + public DBPDataSourceContainer getDataSourceContainer() { + return settings.getDataSourceContainer(); + } + + @NotNull + public AIContextSettings getSettings() { + return settings; + } + + @NotNull + public String getUserOrigin() { + return userOrigin; + } +} diff --git a/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/WebAIFunctionInfo.java b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/WebAIFunctionInfo.java new file mode 100644 index 00000000000..9ba1aba83df --- /dev/null +++ b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/WebAIFunctionInfo.java @@ -0,0 +1,56 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.cloudbeaver.service.ai.model; + +import io.cloudbeaver.WebServiceUtils; +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; +import org.jkiss.dbeaver.model.ai.AIFunctionDescriptor; + +public class WebAIFunctionInfo { + + @NotNull + private final transient AIFunctionDescriptor descriptor; + + public WebAIFunctionInfo(@NotNull AIFunctionDescriptor descriptor) { + this.descriptor = descriptor; + } + + @NotNull + public String getId() { + return descriptor.getFullId(); + } + + @NotNull + public String getName() { + return descriptor.getName(); + } + + @Nullable + public String getDescription() { + return descriptor.getAiDescription(); + } + + public boolean isSystem() { + return descriptor.isSystem(); + } + + @Nullable + public String getIcon() { + return WebServiceUtils.makeIconId(descriptor.getIcon()); + } +} diff --git a/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/WebAIMessage.java b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/WebAIMessage.java new file mode 100644 index 00000000000..d23edb2d26b --- /dev/null +++ b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/WebAIMessage.java @@ -0,0 +1,91 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package io.cloudbeaver.service.ai.model; + +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; +import org.jkiss.dbeaver.model.ai.*; + +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.List; + +public class WebAIMessage { + + private final AIChatMessage message; + private final AIChatConversation conversation; + + public WebAIMessage(@NotNull AIChatMessage message, @NotNull AIChatConversation conversation) { + this.message = message; + this.conversation = conversation; + } + + @NotNull + public String getConversationId() { + return conversation.getId().toString(); + } + + public int getId() { + return message.id(); + } + + @NotNull + public AIMessageType getRole() { + return message.message().getRole(); + } + + @NotNull + public String getContent() { + return message.message().getContent(); + } + + @NotNull + public String getDisplayMessage() { + return message.message().getDisplayMessage(); + } + + @Nullable + public AIFunctionCall getFunctionCall() { + return message.message().getFunctionCall(); + } + + @Nullable + public List getConfirmationFunctionCalls() { + if (message.message().getConfirmation() instanceof AIFunctionCallConfirmation fcc) { + return fcc.getFunctionCalls(); + } + return null; + } + + @Nullable + public AIConfirmation getFunctionConfirmation() { + return message.message().getConfirmation(); + } + + @Nullable + public AIFunctionResult getFunctionResult() { + return message.message().getFunctionResult(); + } + + @NotNull + public OffsetDateTime getTime() { + return message.message().getTime().atZone(ZoneId.systemDefault()) + .withZoneSameInstant(ZoneOffset.UTC) + .toOffsetDateTime(); + } +} diff --git a/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/WebAISendChatMessageInfo.java b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/WebAISendChatMessageInfo.java new file mode 100644 index 00000000000..768aaa1341a --- /dev/null +++ b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/WebAISendChatMessageInfo.java @@ -0,0 +1,27 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.cloudbeaver.service.ai.model; + +import org.jkiss.code.NotNull; + +public record WebAISendChatMessageInfo( + @NotNull WebAIChatConversation conversation, + @NotNull WebAIMessage userMessage, + @NotNull WebAIMessage assistantMessage +) { + +} \ No newline at end of file diff --git a/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/WebAiChatResponseConsumer.java b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/WebAiChatResponseConsumer.java new file mode 100644 index 00000000000..77296210d41 --- /dev/null +++ b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/WebAiChatResponseConsumer.java @@ -0,0 +1,105 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package io.cloudbeaver.service.ai.model; + +import io.cloudbeaver.model.session.WebSession; +import io.cloudbeaver.service.ai.model.events.WSAiChatMessageChunkEvent; +import io.cloudbeaver.service.ai.model.events.WSAiChatMessageErrorEvent; +import io.cloudbeaver.service.ai.model.events.WSAiChatMessageEvent; +import org.jkiss.code.NotNull; +import org.jkiss.dbeaver.model.ai.*; +import org.jkiss.utils.CommonUtils; + +import java.util.List; + +public class WebAiChatResponseConsumer implements AIChatResponseConsumer { + private final StringBuilder responseBuilder; + private final AIChatConversation conversation; + private final WebSession webSession; + private final AIChatSession chatSession; + + public WebAiChatResponseConsumer( + @NotNull AIChatConversation conversation, + @NotNull WebSession webSession, + @NotNull AIChatSession chatSession + ) { + this.conversation = conversation; + this.webSession = webSession; + this.chatSession = chatSession; + this.responseBuilder = new StringBuilder(); + } + + @Override + public void nextMessageChunk(@NotNull String item) { + if (CommonUtils.isEmpty(item)) { + return; + } + if (responseBuilder.isEmpty()) { + var chatMessage = new AIChatMessage(conversation.getNextMessageId(), AIMessage.assistantMessage("", null)); + webSession.addSessionEvent(new WSAiChatMessageEvent(new WebAIMessage(chatMessage, conversation))); + } + responseBuilder.append(item); + webSession.addSessionEvent(new WSAiChatMessageChunkEvent( + conversation.getId(), + conversation.getNextMessageId(), + item, + false + )); + } + + @Override + public void processFunctionCall(@NotNull AIMessage fcMessage) { + // Send function call to front-end over web sockets + AIChatMessage chatMessage = conversation.addMessage(fcMessage); + WebAIMessage webMessage = new WebAIMessage(chatMessage, conversation); + webSession.addSessionEvent(new WSAiChatMessageEvent(webMessage)); + chatSession.notifyMessageAdd(conversation, chatMessage); + } + + @Override + public void warning(@NotNull String message) { + AIChatMessage chatMessage = conversation.addMessage(AIMessage.warningMessage(message)); + webSession.addSessionEvent(new WSAiChatMessageEvent(new WebAIMessage(chatMessage, conversation))); + } + + @Override + public void error(@NotNull Throwable throwable) { + var errorMessage = conversation.addMessage(AIMessage.errorMessage(throwable)); + if (responseBuilder.isEmpty()) { + webSession.addSessionEvent( + new WSAiChatMessageEvent(new WebAIMessage(errorMessage, conversation))); + } else { + webSession.addSessionEvent( + new WSAiChatMessageErrorEvent( + conversation.getId(), + conversation.getNextMessageId(), + throwable + )); + } + chatSession.notifyMessageAdd(conversation, errorMessage); + } + + @Override + public void complete(@NotNull List meta, boolean finishConversation) { + if (responseBuilder.isEmpty()) { + return; + } + AIChatMessage responseMessage = conversation.addMessage(AIMessage.assistantMessage(responseBuilder.toString(), meta)); + chatSession.notifyMessageAdd(conversation, responseMessage); + webSession.addSessionEvent(new WSAiChatMessageChunkEvent(conversation.getId(), responseMessage.id(), null, true)); + } +} diff --git a/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/events/WSAiChatMessageChunkEvent.java b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/events/WSAiChatMessageChunkEvent.java new file mode 100644 index 00000000000..41c3b3f2d0b --- /dev/null +++ b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/events/WSAiChatMessageChunkEvent.java @@ -0,0 +1,61 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2024 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package io.cloudbeaver.service.ai.model.events; + +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; +import org.jkiss.dbeaver.model.websocket.WSConstants; +import org.jkiss.dbeaver.model.websocket.event.session.WSAbstractSessionEvent; +import org.jkiss.utils.CommonUtils; + +import java.util.UUID; + +public class WSAiChatMessageChunkEvent extends WSAbstractSessionEvent { + + private final String messageId; + @NotNull + private final String conversationId; + @Nullable + private final String chunk; + private final boolean completed; + + public WSAiChatMessageChunkEvent(@NotNull UUID conversationId, int messageId, @Nullable String chunk, boolean completed) { + super("cb_ai_chat_message_chunk", WSConstants.TOPIC_AI); + this.messageId = CommonUtils.toString(messageId); + this.conversationId = conversationId.toString(); + this.chunk = chunk; + this.completed = completed; + } + + @NotNull + public String getConversationId() { + return conversationId; + } + + public String getMessageId() { + return messageId; + } + + @Nullable + public String getChunk() { + return chunk; + } + + public boolean isCompleted() { + return completed; + } +} diff --git a/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/events/WSAiChatMessageErrorEvent.java b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/events/WSAiChatMessageErrorEvent.java new file mode 100644 index 00000000000..cda6692369f --- /dev/null +++ b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/events/WSAiChatMessageErrorEvent.java @@ -0,0 +1,56 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2024 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package io.cloudbeaver.service.ai.model.events; + +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; +import org.jkiss.dbeaver.model.websocket.WSConstants; +import org.jkiss.dbeaver.model.websocket.event.session.WSAbstractSessionEvent; +import org.jkiss.utils.CommonUtils; + +import java.util.UUID; + +public class WSAiChatMessageErrorEvent extends WSAbstractSessionEvent { + + private final String messageId; + @NotNull + private final String conversationId; + @Nullable + private final String errorMessage; + + public WSAiChatMessageErrorEvent(@NotNull UUID conversationId, int messageId, @Nullable Throwable throwable) { + super("cb_ai_chat_message_error", WSConstants.TOPIC_AI); + this.conversationId = conversationId.toString(); + this.messageId = CommonUtils.toString(messageId); + this.errorMessage = CommonUtils.getAllExceptionMessages(throwable); + } + + @NotNull + public String getConversationId() { + return conversationId; + } + + @NotNull + public String getMessageId() { + return messageId; + } + + @Nullable + public String getErrorMessage() { + return errorMessage; + } +} diff --git a/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/events/WSAiChatMessageEvent.java b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/events/WSAiChatMessageEvent.java new file mode 100644 index 00000000000..3c52e0dc9a5 --- /dev/null +++ b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/events/WSAiChatMessageEvent.java @@ -0,0 +1,113 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package io.cloudbeaver.service.ai.model.events; + +import io.cloudbeaver.service.ai.model.WebAIMessage; +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; +import org.jkiss.dbeaver.model.ai.AIFunctionCall; +import org.jkiss.dbeaver.model.ai.AIFunctionCallConfirmation; +import org.jkiss.dbeaver.model.ai.AIFunctionResult; +import org.jkiss.dbeaver.model.websocket.WSConstants; +import org.jkiss.dbeaver.model.websocket.event.session.WSAbstractSessionEvent; +import org.jkiss.utils.CommonUtils; + +import java.time.format.DateTimeFormatter; + +public class WSAiChatMessageEvent extends WSAbstractSessionEvent { + private static final String ID = "cb_ai_chat_message"; + + private final String messageId; + @NotNull + private final String conversationId; + @Nullable + private final String content; + @Nullable + private final String displayMessage; + @NotNull + private final String role; + + private final String time; + + @Nullable + private AIFunctionCallConfirmation functionConfirmation; + @Nullable + private final AIFunctionCall functionCall; + @Nullable + private final AIFunctionResult functionResult; + + public WSAiChatMessageEvent(@NotNull WebAIMessage message) { + super(ID, WSConstants.TOPIC_AI); + this.messageId = CommonUtils.toString(message.getId()); + this.conversationId = message.getConversationId(); + this.displayMessage = message.getDisplayMessage(); + this.content = message.getContent(); + this.role = message.getRole().name(); + this.time = message.getTime().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME); + this.functionCall = message.getFunctionCall(); + this.functionResult = message.getFunctionResult(); + + if (message.getConfirmationFunctionCalls() != null) { + this.functionConfirmation = new AIFunctionCallConfirmation( + message.getConfirmationFunctionCalls()); + } + } + + @NotNull + public String getConversationId() { + return conversationId; + } + + public String getMessageId() { + return messageId; + } + + @Nullable + public String getDisplayMessage() { + return displayMessage; + } + + @Nullable + public String getContent() { + return content; + } + + @NotNull + public String getRole() { + return role; + } + + @NotNull + public String getTime() { + return time; + } + + @Nullable + public AIFunctionCallConfirmation getFunctionConfirmation() { + return functionConfirmation; + } + + @Nullable + public AIFunctionCall getFunctionCall() { + return functionCall; + } + + @Nullable + public AIFunctionResult getFunctionResult() { + return functionResult; + } +} diff --git a/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/events/WSAiFunctionCallConfirmationClientEvent.java b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/events/WSAiFunctionCallConfirmationClientEvent.java new file mode 100644 index 00000000000..a1eda084ffb --- /dev/null +++ b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/events/WSAiFunctionCallConfirmationClientEvent.java @@ -0,0 +1,68 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package io.cloudbeaver.service.ai.model.events; + +import org.jkiss.code.NotNull; +import org.jkiss.dbeaver.model.websocket.WSConstants; +import org.jkiss.dbeaver.model.websocket.event.WSClientEvent; + +import java.util.List; + +public class WSAiFunctionCallConfirmationClientEvent extends WSClientEvent { + + public static final String ID = "cb_client_ai_function_call_confirmation"; + + @NotNull + private final String conversationId; + @NotNull + private final String messageId; + private final List confirmedFunctionCalls; + private final List declinedFunctionCalls; + + public WSAiFunctionCallConfirmationClientEvent( + @NotNull String conversationId, + @NotNull String messageId, + @NotNull List confirmedFunctionCalls, + @NotNull List declinedFunctionCalls + ) { + super(ID, WSConstants.TOPIC_AI); + this.conversationId = conversationId; + this.messageId = messageId; + this.confirmedFunctionCalls = confirmedFunctionCalls; + this.declinedFunctionCalls = declinedFunctionCalls; + } + + @NotNull + public String getConversationId() { + return conversationId; + } + + @NotNull + public String getMessageId() { + return messageId; + } + + @NotNull + public List getConfirmedFunctionCalls() { + return confirmedFunctionCalls == null ? List.of() : confirmedFunctionCalls; + } + + @NotNull + public List getDeclinedFunctionCalls() { + return declinedFunctionCalls == null ? List.of() : declinedFunctionCalls; + } +} diff --git a/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/events/WSAiFunctionCallConfirmationHandler.java b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/events/WSAiFunctionCallConfirmationHandler.java new file mode 100644 index 00000000000..20a2b314ee0 --- /dev/null +++ b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/events/WSAiFunctionCallConfirmationHandler.java @@ -0,0 +1,101 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.cloudbeaver.service.ai.model.events; + +import io.cloudbeaver.model.session.WebSession; +import io.cloudbeaver.service.ai.WebAIUtils; +import org.jkiss.code.NotNull; +import org.jkiss.dbeaver.DBException; +import org.jkiss.dbeaver.Log; +import org.jkiss.dbeaver.model.ai.*; +import org.jkiss.dbeaver.model.ai.utils.AIUtils; +import org.jkiss.dbeaver.model.auth.SMSession; +import org.jkiss.dbeaver.model.websocket.event.WSClientEventHandler; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +public class WSAiFunctionCallConfirmationHandler implements WSClientEventHandler { + public static final Log log = Log.getLog(WSAiFunctionCallConfirmationHandler.class); + + @Override + public void handleEvent(@NotNull SMSession session, @NotNull WSAiFunctionCallConfirmationClientEvent event) throws DBException { + if (session instanceof WebSession webSession) { + AIChatSession chatSession = WebAIUtils.findAiChatSession(webSession); + if (chatSession == null) { + webSession.addSessionError(new DBException("Chat session '" + event.getConversationId() + "' not found.")); + return; + } + AIChatConversation conversation = chatSession.getConversation(event.getConversationId()); + if (conversation == null) { + webSession.addSessionError(new DBException("Chat conversation '" + event.getConversationId() + "' not found")); + return; + } + AIChatMessage message = conversation.getMessage(event.getMessageId()); + if (message == null || !(message.message().getConfirmation() instanceof AIFunctionCallConfirmation fcc)) { + webSession.addSessionError(new DBException("Chat conversation confirmation '" + event.getMessageId() + "' not found")); + return; + } + List declinedCalls = new ArrayList<>(); + List approvedCalls = new ArrayList<>(); + + List confirmedFunctions = event.getConfirmedFunctionCalls(); + List declinedFunctions = event.getDeclinedFunctionCalls(); + + for (AIFunctionCall fc : fcc.getFunctionCalls()) { + if (confirmedFunctions.contains(fc.getId().toString())) { + approvedCalls.add(fc); + } else if (declinedFunctions.contains(fc.getId().toString())) { + declinedCalls.add(fc); + } + } + + chatSession.declineFunctionCalls(conversation, declinedCalls); + CompletableFuture submission; + if (approvedCalls.isEmpty()) { + submission = WebAIUtils.scheduleConversationSubmission( + webSession, + chatSession, + conversation, + null, + "Continue AI conversation" + ); + } else { + submission = WebAIUtils.scheduleConversationSubmission( + webSession, + chatSession, + conversation, + new AIFunctionCallConfirmation(approvedCalls), + "Confirm function calls" + ); + AIToolboxManager toolboxManager = chatSession.getAssistant().getToolboxManager(); + if (!declinedCalls.isEmpty() && !AIUtils.hasInformationFunctions(toolboxManager, approvedCalls)) { + submission = submission.thenCompose(ignored -> WebAIUtils.scheduleConversationSubmission( + webSession, + chatSession, + conversation, + null, + "Continue AI conversation" + )); + } + + } + + } + } +} diff --git a/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/inputs/DataSourceId.java b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/inputs/DataSourceId.java new file mode 100644 index 00000000000..86ad8cd4708 --- /dev/null +++ b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/inputs/DataSourceId.java @@ -0,0 +1,22 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2024 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package io.cloudbeaver.service.ai.model.inputs; + +import org.jkiss.code.NotNull; + +public record DataSourceId(@NotNull String projectId, @NotNull String connectionId) { +} \ No newline at end of file diff --git a/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/inputs/WebAIChatConversationInput.java b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/inputs/WebAIChatConversationInput.java new file mode 100644 index 00000000000..e53525a27bb --- /dev/null +++ b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/inputs/WebAIChatConversationInput.java @@ -0,0 +1,27 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2024 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package io.cloudbeaver.service.ai.model.inputs; + +import org.jkiss.code.Nullable; + +public record WebAIChatConversationInput( + @Nullable String promptGeneratorId, + @Nullable DataSourceId dataSourceId, + @Nullable String caption, + @Nullable WebAiChatCompletionSettingsInput settings +) { +} diff --git a/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/inputs/WebAIConfigurationProfileInput.java b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/inputs/WebAIConfigurationProfileInput.java new file mode 100644 index 00000000000..3a5d319d2f0 --- /dev/null +++ b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/inputs/WebAIConfigurationProfileInput.java @@ -0,0 +1,30 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package io.cloudbeaver.service.ai.model.inputs; + +import org.jkiss.code.NotNull; +import org.jkiss.code.Nullable; + +import java.util.Map; + +public record WebAIConfigurationProfileInput( + @NotNull String profileId, + @Nullable String profileName, + @Nullable String engineId, + @Nullable Map configuration +) { +} diff --git a/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/inputs/WebAiChatCompletionSettingsInput.java b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/inputs/WebAiChatCompletionSettingsInput.java new file mode 100644 index 00000000000..30197cd4c0c --- /dev/null +++ b/server/bundles/io.cloudbeaver.service.ai/src/io/cloudbeaver/service/ai/model/inputs/WebAiChatCompletionSettingsInput.java @@ -0,0 +1,28 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2026 DBeaver Corp + * + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of DBeaver Corp and its suppliers, if any. + * The intellectual and technical concepts contained + * herein are proprietary to DBeaver Corp and its suppliers + * and may be covered by U.S. and Foreign Patents, + * patents in process, and are protected by trade secret or copyright law. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from DBeaver Corp. + */ +package io.cloudbeaver.service.ai.model.inputs; + +import org.jkiss.code.Nullable; +import org.jkiss.dbeaver.model.ai.AIDatabaseScope; + +public record WebAiChatCompletionSettingsInput( + @Nullable Boolean metaTransferConfirmed, + @Nullable AIDatabaseScope scope, + @Nullable String[] customObjectIds, + @Nullable String profile, + @Nullable Boolean mcpEnabled) { +} diff --git a/server/bundles/pom.xml b/server/bundles/pom.xml index 7b6c56ccfaa..6b25ef6d7f3 100644 --- a/server/bundles/pom.xml +++ b/server/bundles/pom.xml @@ -19,6 +19,7 @@ io.cloudbeaver.server.ce io.cloudbeaver.slf4j + io.cloudbeaver.service.ai io.cloudbeaver.service.admin io.cloudbeaver.service.auth io.cloudbeaver.service.metadata diff --git a/server/features/io.cloudbeaver.server.feature/feature.xml b/server/features/io.cloudbeaver.server.feature/feature.xml index 3e427300967..3c8f1252067 100644 --- a/server/features/io.cloudbeaver.server.feature/feature.xml +++ b/server/features/io.cloudbeaver.server.feature/feature.xml @@ -32,6 +32,7 @@ + diff --git a/webapp/packages/core-root/src/ServerConfigResource.ts b/webapp/packages/core-root/src/ServerConfigResource.ts index b0a78238573..4431996f023 100644 --- a/webapp/packages/core-root/src/ServerConfigResource.ts +++ b/webapp/packages/core-root/src/ServerConfigResource.ts @@ -14,6 +14,7 @@ import { DataSynchronizationService } from './DataSynchronization/DataSynchroniz import { ServerConfigEventHandler } from './ServerConfigEventHandler.js'; export const FEATURE_GIT_ID = 'git'; +export const FEATURE_AI_ID = 'ai'; export type ServerConfig = ServerConfigFragment; diff --git a/webapp/packages/core-sdk/src/queries/ai/createAiProfile.gql b/webapp/packages/core-sdk/src/queries/ai/createAiProfile.gql new file mode 100644 index 00000000000..cf85c3db2b1 --- /dev/null +++ b/webapp/packages/core-sdk/src/queries/ai/createAiProfile.gql @@ -0,0 +1,10 @@ +mutation createAiProfile($config: AIConfigurationProfileInput!) { + profile: aiCreateProfile(config: $config) { + id + name + engineId + configuration { + ...ObjectPropertyInfo + } + } +} diff --git a/webapp/packages/core-sdk/src/queries/ai/createChatConversation.gql b/webapp/packages/core-sdk/src/queries/ai/createChatConversation.gql new file mode 100644 index 00000000000..077bdf55302 --- /dev/null +++ b/webapp/packages/core-sdk/src/queries/ai/createChatConversation.gql @@ -0,0 +1,5 @@ +mutation createChatConversation($config: AIChatConversationInput!) { + conversation: aiCreateChatConversation(config: $config) { + ...AiChatConversation + } +} diff --git a/webapp/packages/core-sdk/src/queries/ai/deleteAiProfile.gql b/webapp/packages/core-sdk/src/queries/ai/deleteAiProfile.gql new file mode 100644 index 00000000000..29498e53da3 --- /dev/null +++ b/webapp/packages/core-sdk/src/queries/ai/deleteAiProfile.gql @@ -0,0 +1,3 @@ +mutation deleteAiProfile($profileId: ID!) { + result: aiDeleteProfile(profileId: $profileId) +} diff --git a/webapp/packages/core-sdk/src/queries/ai/deleteChatConversation.gql b/webapp/packages/core-sdk/src/queries/ai/deleteChatConversation.gql new file mode 100644 index 00000000000..fdf5595d42e --- /dev/null +++ b/webapp/packages/core-sdk/src/queries/ai/deleteChatConversation.gql @@ -0,0 +1,3 @@ +mutation deleteChatConversation($conversationId: ID!) { + result: aiDeleteChatConversation(conversationId: $conversationId) +} diff --git a/webapp/packages/core-sdk/src/queries/ai/deleteChatConversationMessages.gql b/webapp/packages/core-sdk/src/queries/ai/deleteChatConversationMessages.gql new file mode 100644 index 00000000000..f5ca5b61e78 --- /dev/null +++ b/webapp/packages/core-sdk/src/queries/ai/deleteChatConversationMessages.gql @@ -0,0 +1,3 @@ +mutation deleteChatConversationMessage($conversationId: ID!, $messageId: ID!) { + aiClearLastChatMessages(conversationId: $conversationId, messageId: $messageId) +} diff --git a/webapp/packages/core-sdk/src/queries/ai/getAiFunctions.gql b/webapp/packages/core-sdk/src/queries/ai/getAiFunctions.gql new file mode 100644 index 00000000000..9fa66244ae9 --- /dev/null +++ b/webapp/packages/core-sdk/src/queries/ai/getAiFunctions.gql @@ -0,0 +1,9 @@ +query getAiFunctions { + functions: aiListFunctions { + id + name + description + icon + system + } +} diff --git a/webapp/packages/core-sdk/src/queries/ai/getAiProfiles.gql b/webapp/packages/core-sdk/src/queries/ai/getAiProfiles.gql new file mode 100644 index 00000000000..6bd5cfeb014 --- /dev/null +++ b/webapp/packages/core-sdk/src/queries/ai/getAiProfiles.gql @@ -0,0 +1,7 @@ +query getAiProfiles { + profiles: aiListProfiles { + id + name + engineId + } +} diff --git a/webapp/packages/core-sdk/src/queries/ai/getAiSettings.gql b/webapp/packages/core-sdk/src/queries/ai/getAiSettings.gql new file mode 100644 index 00000000000..57b67e0e67a --- /dev/null +++ b/webapp/packages/core-sdk/src/queries/ai/getAiSettings.gql @@ -0,0 +1,7 @@ +query getAiSettings { + settings: aiSettings { + activeEngine + defaultConfiguration + language + } +} diff --git a/webapp/packages/core-sdk/src/queries/ai/getChatConversation.gql b/webapp/packages/core-sdk/src/queries/ai/getChatConversation.gql new file mode 100644 index 00000000000..f60553ad28f --- /dev/null +++ b/webapp/packages/core-sdk/src/queries/ai/getChatConversation.gql @@ -0,0 +1,5 @@ +query getChatConversation($conversationId: ID!) { + conversation: aiChatConversationInfo(conversationId: $conversationId) { + ...AiChatConversation + } +} diff --git a/webapp/packages/core-sdk/src/queries/ai/getChatConversationMessages.gql b/webapp/packages/core-sdk/src/queries/ai/getChatConversationMessages.gql new file mode 100644 index 00000000000..b483af10f25 --- /dev/null +++ b/webapp/packages/core-sdk/src/queries/ai/getChatConversationMessages.gql @@ -0,0 +1,7 @@ +query getChatConversationMessages($conversationId: ID!) { + result: aiChatConversationInfo(conversationId: $conversationId) { + messages { + ...AiChatMessage + } + } +} diff --git a/webapp/packages/core-sdk/src/queries/ai/getChatConversationMetrics.gql b/webapp/packages/core-sdk/src/queries/ai/getChatConversationMetrics.gql new file mode 100644 index 00000000000..6ac9fee5bf9 --- /dev/null +++ b/webapp/packages/core-sdk/src/queries/ai/getChatConversationMetrics.gql @@ -0,0 +1,5 @@ +query getChatConversationMetrics($conversationId: ID!) { + result: aiChatConversationInfo(conversationId: $conversationId, loadMetrics: true) { + ...AiChatConversationMetrics + } +} diff --git a/webapp/packages/core-sdk/src/queries/ai/getChatConversationScope.gql b/webapp/packages/core-sdk/src/queries/ai/getChatConversationScope.gql new file mode 100644 index 00000000000..1d9e16c38ac --- /dev/null +++ b/webapp/packages/core-sdk/src/queries/ai/getChatConversationScope.gql @@ -0,0 +1,8 @@ +query getChatConversationScope($conversationId: ID!) { + result: aiChatConversationInfo(conversationId: $conversationId) { + id + settings { + scope + } + } +} diff --git a/webapp/packages/core-sdk/src/queries/ai/getChatConversations.gql b/webapp/packages/core-sdk/src/queries/ai/getChatConversations.gql new file mode 100644 index 00000000000..b037d080b95 --- /dev/null +++ b/webapp/packages/core-sdk/src/queries/ai/getChatConversations.gql @@ -0,0 +1,5 @@ +query getChatConversations($dataSourceId: DataSourceIdInput) { + conversations: aiListChatConversations(dataSourceId: $dataSourceId) { + ...AiChatConversation + } +} diff --git a/webapp/packages/core-sdk/src/queries/ai/getLanguageModelProperties.gql b/webapp/packages/core-sdk/src/queries/ai/getLanguageModelProperties.gql new file mode 100644 index 00000000000..229788ed36b --- /dev/null +++ b/webapp/packages/core-sdk/src/queries/ai/getLanguageModelProperties.gql @@ -0,0 +1,20 @@ +query getEngineProperties($engineId: ID!, $profileId: ID, $settings: AIEngineConfig) { + properties: aiListEngineProperties(engineId: $engineId, profileId: $profileId, settings: $settings) { + id + displayName + description + hint + value + category + required + dataType + defaultValue + validValues + length + features + order + conditions { + ...Condition + } + } +} diff --git a/webapp/packages/core-sdk/src/queries/ai/getLanguageModels.gql b/webapp/packages/core-sdk/src/queries/ai/getLanguageModels.gql new file mode 100644 index 00000000000..acc98280316 --- /dev/null +++ b/webapp/packages/core-sdk/src/queries/ai/getLanguageModels.gql @@ -0,0 +1,6 @@ +query getEngines { + engines: aiListEngines { + id + name + } +} diff --git a/webapp/packages/core-sdk/src/queries/ai/saveAiSettings.gql b/webapp/packages/core-sdk/src/queries/ai/saveAiSettings.gql new file mode 100644 index 00000000000..ff5e9ade3f1 --- /dev/null +++ b/webapp/packages/core-sdk/src/queries/ai/saveAiSettings.gql @@ -0,0 +1,7 @@ +mutation saveAiSettings($settings: AISettingsConfig!) { + result: aiSaveSettings(settings: $settings) { + activeEngine + defaultConfiguration + language + } +} diff --git a/webapp/packages/core-sdk/src/queries/ai/sendConversationMessage.gql b/webapp/packages/core-sdk/src/queries/ai/sendConversationMessage.gql new file mode 100644 index 00000000000..b14cf8a07a1 --- /dev/null +++ b/webapp/packages/core-sdk/src/queries/ai/sendConversationMessage.gql @@ -0,0 +1,5 @@ +mutation sendConversationMessage($conversationId: ID!, $prompt: String!) { + message: aiSendChatMessage(conversationId: $conversationId, prompt: $prompt) { + ...AiSendChatMessageInfo + } +} diff --git a/webapp/packages/core-sdk/src/queries/ai/updateAiProfile.gql b/webapp/packages/core-sdk/src/queries/ai/updateAiProfile.gql new file mode 100644 index 00000000000..5c23c6a2da8 --- /dev/null +++ b/webapp/packages/core-sdk/src/queries/ai/updateAiProfile.gql @@ -0,0 +1,10 @@ +mutation updateAiProfile($config: AIConfigurationProfileInput!) { + profile: aiUpdateProfile(config: $config) { + id + name + engineId + configuration { + ...ObjectPropertyInfo + } + } +} diff --git a/webapp/packages/core-sdk/src/queries/ai/updateChatConversation.gql b/webapp/packages/core-sdk/src/queries/ai/updateChatConversation.gql new file mode 100644 index 00000000000..672d6e964a3 --- /dev/null +++ b/webapp/packages/core-sdk/src/queries/ai/updateChatConversation.gql @@ -0,0 +1,8 @@ +mutation updateChatConversation($conversationId: ID!, $config: AIChatConversationInput!) { + conversation: aiUpdateChatConversation(conversationId: $conversationId, config: $config) { + ...AiChatConversation + settings { + scope + } + } +} diff --git a/webapp/packages/core-sdk/src/queries/connections/ai/getUserConnectionAiSettings.gql b/webapp/packages/core-sdk/src/queries/connections/ai/getUserConnectionAiSettings.gql new file mode 100644 index 00000000000..c3aaca4f568 --- /dev/null +++ b/webapp/packages/core-sdk/src/queries/connections/ai/getUserConnectionAiSettings.gql @@ -0,0 +1,5 @@ +query getUserConnectionAiSettings($dataSourceId: DataSourceIdInput!) { + settings: aiDataSourceSettings(dataSourceId: $dataSourceId) { + ...ConnectionInfoAiSettings + } +} diff --git a/webapp/packages/core-sdk/src/queries/connections/ai/saveUserConnectionAiSettings.gql b/webapp/packages/core-sdk/src/queries/connections/ai/saveUserConnectionAiSettings.gql new file mode 100644 index 00000000000..cb8671b184a --- /dev/null +++ b/webapp/packages/core-sdk/src/queries/connections/ai/saveUserConnectionAiSettings.gql @@ -0,0 +1,5 @@ +mutation saveUserConnectionAiSettings($dataSourceId: DataSourceIdInput!, $settings: AIDataSourceSettingsInput!) { + settings: aiSaveDataSourceSettings(dataSourceId: $dataSourceId, settings: $settings) { + ...ConnectionInfoAiSettings + } +} diff --git a/webapp/packages/core-sdk/src/queries/fragments/AiChatConversation.gql b/webapp/packages/core-sdk/src/queries/fragments/AiChatConversation.gql new file mode 100644 index 00000000000..a62eada2ab3 --- /dev/null +++ b/webapp/packages/core-sdk/src/queries/fragments/AiChatConversation.gql @@ -0,0 +1,16 @@ +fragment AiChatConversation on AIChatConversationInfo { + id + caption + time + dataSourceId { + projectId + connectionId + } + settings { + metaTransferConfirmed + customObjectIds + } + waitingForResponse + promptGeneratorId + profile +} diff --git a/webapp/packages/core-sdk/src/queries/fragments/AiChatConversationMetrics.gql b/webapp/packages/core-sdk/src/queries/fragments/AiChatConversationMetrics.gql new file mode 100644 index 00000000000..8a1f59e226b --- /dev/null +++ b/webapp/packages/core-sdk/src/queries/fragments/AiChatConversationMetrics.gql @@ -0,0 +1,7 @@ +fragment AiChatConversationMetrics on AIChatConversationInfo { + id + metrics { + totalInputTokens + totalOutputTokens + } +} diff --git a/webapp/packages/core-sdk/src/queries/fragments/AiChatMessage.gql b/webapp/packages/core-sdk/src/queries/fragments/AiChatMessage.gql new file mode 100644 index 00000000000..d4cf2da8ee3 --- /dev/null +++ b/webapp/packages/core-sdk/src/queries/fragments/AiChatMessage.gql @@ -0,0 +1,26 @@ +fragment AiChatMessage on AIMessage { + conversationId + id + role + content + displayMessage + time + functionCall { + id + functionName + arguments + hint + } + functionResult { + type + value + } + functionConfirmation { + functionCalls { + id + functionName + arguments + hint + } + } +} diff --git a/webapp/packages/core-sdk/src/queries/fragments/AiSendChatMessageInfo.gql b/webapp/packages/core-sdk/src/queries/fragments/AiSendChatMessageInfo.gql new file mode 100644 index 00000000000..a7a9d577fc4 --- /dev/null +++ b/webapp/packages/core-sdk/src/queries/fragments/AiSendChatMessageInfo.gql @@ -0,0 +1,21 @@ +fragment AiSendChatMessageInfo on AISendChatMessageInfo { + conversation { + id + dataSourceId { + projectId + connectionId + } + caption + time + waitingForResponse + promptGeneratorId + } + + userMessage { + ...AiChatMessage + } + + assistantMessage { + ...AiChatMessage + } +} diff --git a/webapp/packages/core-sdk/src/queries/fragments/ConnectionInfoAiSettings.gql b/webapp/packages/core-sdk/src/queries/fragments/ConnectionInfoAiSettings.gql new file mode 100644 index 00000000000..68d292e6ec5 --- /dev/null +++ b/webapp/packages/core-sdk/src/queries/fragments/ConnectionInfoAiSettings.gql @@ -0,0 +1,3 @@ +fragment ConnectionInfoAiSettings on AIDataSourceSettingsInfo { + metaTransferConfirmed +} diff --git a/webapp/packages/plugin-ai-administration/.gitignore b/webapp/packages/plugin-ai-administration/.gitignore new file mode 100644 index 00000000000..15bc16c7c31 --- /dev/null +++ b/webapp/packages/plugin-ai-administration/.gitignore @@ -0,0 +1,17 @@ +# dependencies +/node_modules + +# testing +/coverage + +# production +/lib + +# misc +.DS_Store +.env* + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/webapp/packages/plugin-ai-administration/package.json b/webapp/packages/plugin-ai-administration/package.json new file mode 100644 index 00000000000..f1af2508fa3 --- /dev/null +++ b/webapp/packages/plugin-ai-administration/package.json @@ -0,0 +1,53 @@ +{ + "name": "@cloudbeaver/plugin-ai-administration", + "type": "module", + "sideEffects": [ + "./lib/module.js", + "./lib/index.js", + "src/**/*.css", + "public/**/*" + ], + "version": "0.1.0", + "description": "", + "license": "Apache-2.0", + "exports": { + ".": "./lib/index.js", + "./module": "./lib/module.js" + }, + "scripts": { + "build": "tsc -b", + "clean": "rimraf --glob lib", + "lint": "eslint ./src/ --ext .ts,.tsx", + "validate-dependencies": "core-cli-validate-dependencies" + }, + "dependencies": { + "@cloudbeaver/core-administration": "workspace:*", + "@cloudbeaver/core-blocks": "workspace:*", + "@cloudbeaver/core-data-context": "workspace:*", + "@cloudbeaver/core-di": "workspace:*", + "@cloudbeaver/core-dialogs": "workspace:*", + "@cloudbeaver/core-events": "workspace:*", + "@cloudbeaver/core-executor": "workspace:*", + "@cloudbeaver/core-localization": "workspace:*", + "@cloudbeaver/core-resource": "workspace:*", + "@cloudbeaver/core-root": "workspace:*", + "@cloudbeaver/core-sdk": "workspace:*", + "@cloudbeaver/core-ui": "workspace:*", + "@cloudbeaver/core-utils": "workspace:*", + "@cloudbeaver/plugin-data-grid": "workspace:*", + "@dbeaver/js-helpers": "workspace:*", + "@dbeaver/ui-kit": "workspace:*", + "mobx": "^6", + "mobx-react-lite": "^4", + "react": "^19", + "react-dom": "^19", + "tslib": "^2" + }, + "devDependencies": { + "@cloudbeaver/core-cli": "workspace:*", + "@cloudbeaver/tsconfig": "workspace:*", + "@types/react": "^19", + "rimraf": "^6", + "typescript": "^5" + } +} diff --git a/webapp/packages/plugin-ai-administration/public/icons/ai-management.svg b/webapp/packages/plugin-ai-administration/public/icons/ai-management.svg new file mode 100644 index 00000000000..010133575b3 --- /dev/null +++ b/webapp/packages/plugin-ai-administration/public/icons/ai-management.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webapp/packages/plugin-ai-administration/src/ADMINISTRATION_AI_PAGE.ts b/webapp/packages/plugin-ai-administration/src/ADMINISTRATION_AI_PAGE.ts new file mode 100644 index 00000000000..eaf40a1967a --- /dev/null +++ b/webapp/packages/plugin-ai-administration/src/ADMINISTRATION_AI_PAGE.ts @@ -0,0 +1,9 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +export const ADMINISTRATION_AI_PAGE = 'ai'; diff --git a/webapp/packages/plugin-ai-administration/src/AIAdministrationBootstrap.ts b/webapp/packages/plugin-ai-administration/src/AIAdministrationBootstrap.ts new file mode 100644 index 00000000000..1f49d056176 --- /dev/null +++ b/webapp/packages/plugin-ai-administration/src/AIAdministrationBootstrap.ts @@ -0,0 +1,57 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import React from 'react'; + +import { AdministrationItemService, AdministrationItemType, type IAdministrationItem } from '@cloudbeaver/core-administration'; +import { Bootstrap, injectable } from '@cloudbeaver/core-di'; +import { FEATURE_AI_ID, ServerConfigResource } from '@cloudbeaver/core-root'; + +import { ADMINISTRATION_AI_PAGE } from './ADMINISTRATION_AI_PAGE.js'; +import { getCachedDataResourceLoaderState } from '@cloudbeaver/core-resource'; +import { AISettingsService } from './AISettingsService.js'; +import { EAIAdministrationSub } from './AIAdministrationNavigationService.js'; + +const AIAdministrationPanel = React.lazy(async () => { + const { AIAdministrationPanel } = await import('./AIAdministrationPanel.js'); + return { default: AIAdministrationPanel }; +}); + +const AIDrawerItem = React.lazy(async () => { + const { AIDrawerItem } = await import('./AIDrawerItem.js'); + return { default: AIDrawerItem }; +}); + +@injectable(() => [AdministrationItemService, ServerConfigResource, AISettingsService]) +export class AIAdministrationBootstrap extends Bootstrap { + administrationItem!: IAdministrationItem; + + constructor( + private readonly administrationItemService: AdministrationItemService, + private readonly serverConfigResource: ServerConfigResource, + private readonly aiSettingsService: AISettingsService, + ) { + super(); + } + + override register(): void { + this.administrationItem = this.administrationItemService.create({ + name: ADMINISTRATION_AI_PAGE, + type: AdministrationItemType.Administration, + order: 11, + defaultSub: EAIAdministrationSub.Settings, + getContentComponent: () => AIAdministrationPanel, + getDrawerComponent: () => AIDrawerItem, + getLoader: () => getCachedDataResourceLoaderState(this.serverConfigResource, () => undefined), + onActivate: this.aiSettingsService.create.bind(this.aiSettingsService), + onDeActivate: this.aiSettingsService.dispose.bind(this.aiSettingsService), + isHidden: () => !this.serverConfigResource.isFeatureEnabled(FEATURE_AI_ID, true), + }); + } + + override async load(): Promise {} +} diff --git a/webapp/packages/plugin-ai-administration/src/AIAdministrationMainTabPanel.tsx b/webapp/packages/plugin-ai-administration/src/AIAdministrationMainTabPanel.tsx new file mode 100644 index 00000000000..e5113d80bb2 --- /dev/null +++ b/webapp/packages/plugin-ai-administration/src/AIAdministrationMainTabPanel.tsx @@ -0,0 +1,23 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { observer } from 'mobx-react-lite'; + +import { useService } from '@cloudbeaver/core-di'; + +import { AIAdministrationPage } from './AIAdministrationPage.js'; +import { AISettingsService } from './AISettingsService.js'; + +export const AIAdministrationMainTabPanel = observer(function AIAdministrationMainTabPanel() { + const aiSettingsService = useService(AISettingsService); + + if (!aiSettingsService.formState) { + return null; + } + + return ; +}); diff --git a/webapp/packages/plugin-ai-administration/src/AIAdministrationNavigationService.ts b/webapp/packages/plugin-ai-administration/src/AIAdministrationNavigationService.ts new file mode 100644 index 00000000000..655134dd226 --- /dev/null +++ b/webapp/packages/plugin-ai-administration/src/AIAdministrationNavigationService.ts @@ -0,0 +1,28 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { AdministrationScreenService } from '@cloudbeaver/core-administration'; +import { injectable } from '@cloudbeaver/core-di'; + +import { ADMINISTRATION_AI_PAGE } from './ADMINISTRATION_AI_PAGE.js'; + +export enum EAIAdministrationSub { + Settings = 'settings', + Profiles = 'profiles', + ToolsAndMcp = 'tools-and-mcp', +} + +@injectable(() => [AdministrationScreenService]) +export class AIAdministrationNavigationService { + static ItemName = ADMINISTRATION_AI_PAGE; + + constructor(private readonly administrationScreenService: AdministrationScreenService) {} + + navToSub(sub: string): void { + this.administrationScreenService.navigateToItemSub(AIAdministrationNavigationService.ItemName, sub); + } +} diff --git a/webapp/packages/plugin-ai-administration/src/AIAdministrationPage.tsx b/webapp/packages/plugin-ai-administration/src/AIAdministrationPage.tsx new file mode 100644 index 00000000000..ea75ed02025 --- /dev/null +++ b/webapp/packages/plugin-ai-administration/src/AIAdministrationPage.tsx @@ -0,0 +1,127 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { observer } from 'mobx-react-lite'; + +import { + ColoredContainer, + Select, + Container, + Form, + Group, + GroupTitle, + ToolsAction, + ToolsPanel, + useAutoLoad, + useForm, + useResource, + useTranslate, + Combobox, +} from '@cloudbeaver/core-blocks'; +import { useService } from '@cloudbeaver/core-di'; +import { CachedMapAllKey } from '@cloudbeaver/core-resource'; +import { NotificationService } from '@cloudbeaver/core-events'; + +import { AIProfilesResource } from './AIProfiles/AIProfilesResource.js'; +import { getAdministrationAISettingsFormInfoPart } from './AISettingsForm/getAdministrationAISettingsFormInfoPart.js'; +import { LANGUAGE_OPTIONS } from './AISettingsForm/getLanguageOptions.js'; +import type { AdministrationAISettingsFormState } from './AISettingsForm/AdministrationAISettingsFormState.js'; +import { getFirstException } from '@cloudbeaver/core-utils'; +import { isDefined } from '@dbeaver/js-helpers'; + +export const AIAdministrationPage = observer<{ + formState: AdministrationAISettingsFormState; +}>(function AIAdministrationPage({ formState }) { + const translate = useTranslate(); + const notificationService = useService(NotificationService); + const profilesLoader = useResource(AIAdministrationPage, AIProfilesResource, CachedMapAllKey); + const profiles = profilesLoader.data.filter(isDefined); + + const settingsInfoPart = getAdministrationAISettingsFormInfoPart(formState); + useAutoLoad(AIAdministrationPage, settingsInfoPart); + + const changed = settingsInfoPart.isChanged; + const form = useForm({ + onSubmit: handleSave, + }); + + function handleLanguageChange(value: string | null) { + settingsInfoPart.state.language = value ?? ''; + } + + function selectDefaultProfile(value: string | null) { + if (value) { + settingsInfoPart.state.defaultConfiguration = value; + } + } + + async function handleSave() { + const saved = await formState.save(); + + if (saved) { + notificationService.logSuccess({ title: 'ai_administration_settings_save_success' }); + } else if (formState.isError) { + notificationService.logException(getFirstException(formState.exception), 'ai_administration_settings_save_fail'); + } + } + + function handleReset() { + settingsInfoPart.reset(); + } + + return ( +
+ + + + form.submit()} + > + {translate('ui_processing_save')} + + + {translate('ui_processing_cancel')} + + + + + + {translate('ai_administration_settings')} + + + + {translate('plugin_ai_administration_language_label')} + + + + +
+ ); +}); diff --git a/webapp/packages/plugin-ai-administration/src/AIAdministrationPanel.tsx b/webapp/packages/plugin-ai-administration/src/AIAdministrationPanel.tsx new file mode 100644 index 00000000000..bb34f27b30b --- /dev/null +++ b/webapp/packages/plugin-ai-administration/src/AIAdministrationPanel.tsx @@ -0,0 +1,55 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import type { AdministrationItemContentProps } from '@cloudbeaver/core-administration'; +import { s, SContext, type StyleRegistry, ToolsPanel, useS } from '@cloudbeaver/core-blocks'; +import { useService } from '@cloudbeaver/core-di'; +import { type ITabData, TabList, TabPanelList, TabPanelStyles, TabsState, TabStyles, TabTitleStyles } from '@cloudbeaver/core-ui'; +import { observer } from 'mobx-react-lite'; + +import style from './shared/AIAdministrationPanel.module.css'; +import tabStyle from './shared/AIAdministrationPanelTab.module.css'; +import tabPanelStyle from './shared/AIAdministrationPanelTabPanel.module.css'; +import TabTitleModuleStyles from './shared/AIAdministrationPanelTabTitle.module.css'; +import { AIAdministrationTabsService } from './AIAdministrationTabsService.js'; +import { AIAdministrationNavigationService, EAIAdministrationSub } from './AIAdministrationNavigationService.js'; + +const tabPanelRegistry: StyleRegistry = [[TabPanelStyles, { mode: 'append', styles: [tabPanelStyle] }]]; + +const mainTabsRegistry: StyleRegistry = [ + [TabStyles, { mode: 'append', styles: [tabStyle] }], + [TabTitleStyles, { mode: 'append', styles: [TabTitleModuleStyles] }], +]; + +export const AIAdministrationPanel = observer(function AIAdministrationPanel({ sub }) { + const aiAdministrationTabsService = useService(AIAdministrationTabsService); + const aiAdministrationNavigationService = useService(AIAdministrationNavigationService); + const styles = useS(style, tabStyle); + const subName = sub?.name ?? EAIAdministrationSub.Settings; + const hasOneTab = aiAdministrationTabsService.tabsContainer.getDisplayed().length === 1; + + function openSub({ tabId }: ITabData) { + if (subName === tabId) { + return; + } + aiAdministrationNavigationService.navToSub(tabId); + } + + return ( + + + + + + + ); +}); diff --git a/webapp/packages/plugin-ai-administration/src/AIAdministrationProfilesTabPanel.tsx b/webapp/packages/plugin-ai-administration/src/AIAdministrationProfilesTabPanel.tsx new file mode 100644 index 00000000000..6b0077c2990 --- /dev/null +++ b/webapp/packages/plugin-ai-administration/src/AIAdministrationProfilesTabPanel.tsx @@ -0,0 +1,23 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { observer } from 'mobx-react-lite'; + +import { useService } from '@cloudbeaver/core-di'; + +import { AIProfilesPanel } from './AIProfiles/AIProfilesPanel.js'; +import { AISettingsService } from './AISettingsService.js'; + +export const AIAdministrationProfilesTabPanel = observer(function AIAdministrationProfilesTabPanel() { + const aiSettingsService = useService(AISettingsService); + + if (!aiSettingsService.formState) { + return null; + } + + return ; +}); diff --git a/webapp/packages/plugin-ai-administration/src/AIAdministrationTabsService.ts b/webapp/packages/plugin-ai-administration/src/AIAdministrationTabsService.ts new file mode 100644 index 00000000000..65e1ae4b365 --- /dev/null +++ b/webapp/packages/plugin-ai-administration/src/AIAdministrationTabsService.ts @@ -0,0 +1,48 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { Bootstrap, injectable } from '@cloudbeaver/core-di'; +import { TabsContainer } from '@cloudbeaver/core-ui'; +import { importLazyComponent } from '@cloudbeaver/core-blocks'; +import { EAIAdministrationSub } from './AIAdministrationNavigationService.js'; +import { AIAdministrationBootstrap } from './AIAdministrationBootstrap.js'; + +const AIAdministrationMainTabPanel = importLazyComponent(() => + import('./AIAdministrationMainTabPanel.js').then(module => module.AIAdministrationMainTabPanel), +); + +const AIAdministrationProfilesTabPanel = importLazyComponent(() => + import('./AIAdministrationProfilesTabPanel.js').then(module => module.AIAdministrationProfilesTabPanel), +); + +@injectable(() => [AIAdministrationBootstrap]) +export class AIAdministrationTabsService extends Bootstrap { + readonly tabsContainer: TabsContainer; + + constructor(private readonly aiAdministrationBootstrap: AIAdministrationBootstrap) { + super(); + this.tabsContainer = new TabsContainer('AI Administration'); + } + + override register(): void { + this.tabsContainer.add({ + key: EAIAdministrationSub.Settings, + name: 'ui_settings', + order: 1, + panel: () => AIAdministrationMainTabPanel, + }); + + this.tabsContainer.add({ + key: EAIAdministrationSub.Profiles, + name: 'plugin_ai_administration_profiles_title', + order: 2, + panel: () => AIAdministrationProfilesTabPanel, + }); + + this.aiAdministrationBootstrap.administrationItem.sub.push({ name: EAIAdministrationSub.Settings }, { name: EAIAdministrationSub.Profiles }); + } +} diff --git a/webapp/packages/plugin-ai-administration/src/AIDrawerItem.tsx b/webapp/packages/plugin-ai-administration/src/AIDrawerItem.tsx new file mode 100644 index 00000000000..b7c4a40bec6 --- /dev/null +++ b/webapp/packages/plugin-ai-administration/src/AIDrawerItem.tsx @@ -0,0 +1,21 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import type { AdministrationItemDrawerProps } from '@cloudbeaver/core-administration'; +import { Translate } from '@cloudbeaver/core-blocks'; +import { Tab, TabIcon, TabTitle } from '@cloudbeaver/core-ui'; + +export const AIDrawerItem: React.FC = function AIDrawerItem({ item, onSelect, disabled }) { + return ( + onSelect(item.name)}> + + + + + + ); +}; diff --git a/webapp/packages/plugin-ai-administration/src/AIProfiles/AIEnginePropertiesResource.ts b/webapp/packages/plugin-ai-administration/src/AIProfiles/AIEnginePropertiesResource.ts new file mode 100644 index 00000000000..f643dbf09f4 --- /dev/null +++ b/webapp/packages/plugin-ai-administration/src/AIProfiles/AIEnginePropertiesResource.ts @@ -0,0 +1,49 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { runInAction } from 'mobx'; + +import { injectable } from '@cloudbeaver/core-di'; +import { CachedMapResource, type ResourceKeySimple, ResourceKeyUtils } from '@cloudbeaver/core-resource'; +import { EAdminPermission, SessionPermissionsResource } from '@cloudbeaver/core-root'; +import { GraphQLService, type AiEngineConfig, type IObjectPropertyInfo } from '@cloudbeaver/core-sdk'; + +export const MODEL_PROPERTY_ID = 'model'; + +@injectable(() => [GraphQLService, SessionPermissionsResource]) +export class AIEnginePropertiesResource extends CachedMapResource { + constructor( + private readonly graphQLService: GraphQLService, + permissionsResource: SessionPermissionsResource, + ) { + super(() => new Map(), []); + + permissionsResource.require(this, EAdminPermission.admin).outdateResource(this); + } + + async loadProperties(engineId: string, profileId?: string, settings?: AiEngineConfig): Promise { + const { properties } = await this.graphQLService.sdk.getEngineProperties({ engineId, profileId, settings }); + return properties; + } + + protected async loader(originalKey: ResourceKeySimple): Promise> { + await ResourceKeyUtils.forEachAsync(originalKey, async engineId => { + const { properties } = await this.graphQLService.sdk.getEngineProperties({ engineId }); + + runInAction(() => { + this.set(engineId, properties); + }); + }); + + return this.data; + } + + protected validateKey(key: string): boolean { + return typeof key === 'string'; + } +} diff --git a/webapp/packages/plugin-ai-administration/src/AIProfiles/AIProfileForm/AIProfileForm.tsx b/webapp/packages/plugin-ai-administration/src/AIProfiles/AIProfileForm/AIProfileForm.tsx new file mode 100644 index 00000000000..2ebc78c778d --- /dev/null +++ b/webapp/packages/plugin-ai-administration/src/AIProfiles/AIProfileForm/AIProfileForm.tsx @@ -0,0 +1,29 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { observer } from 'mobx-react-lite'; + +import { Loader } from '@cloudbeaver/core-blocks'; +import { useService } from '@cloudbeaver/core-di'; + +import { AIProfileFormService } from './AIProfileFormService.js'; +import { AIProfileFormPanel } from './AIProfileFormPanel.js'; + +export const AIProfileForm = observer(function AIProfileForm() { + const aiProfileFormService = useService(AIProfileFormService); + + if (!aiProfileFormService.formState) { + return null; + } + + return ( + + + + ); +}); diff --git a/webapp/packages/plugin-ai-administration/src/AIProfiles/AIProfileForm/AIProfileFormPanel.tsx b/webapp/packages/plugin-ai-administration/src/AIProfiles/AIProfileForm/AIProfileFormPanel.tsx new file mode 100644 index 00000000000..a845a6062d3 --- /dev/null +++ b/webapp/packages/plugin-ai-administration/src/AIProfiles/AIProfileForm/AIProfileFormPanel.tsx @@ -0,0 +1,77 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { observer } from 'mobx-react-lite'; + +import { Button, Form, GroupBack, GroupTitle, StatusMessage, useForm, useTranslate, Text } from '@cloudbeaver/core-blocks'; +import { useService } from '@cloudbeaver/core-di'; +import { ENotificationType, NotificationService } from '@cloudbeaver/core-events'; +import { FormMode, TabList, TabPanelList, TabsState } from '@cloudbeaver/core-ui'; +import { getFirstException } from '@cloudbeaver/core-utils'; + +import type { IAIProfileFormProps } from './IAIProfileFormProps.js'; +import { AIProfileFormService } from './AIProfileFormService.js'; + +export const AIProfileFormPanel = observer(function AIProfileFormPanel({ formState }) { + const aiProfileFormService = useService(AIProfileFormService); + const notificationService = useService(NotificationService); + const translate = useTranslate(); + const creating = formState.mode === FormMode.Create; + + const form = useForm({ + onSubmit: async function onSubmit() { + const saved = await formState.save(); + const exception = getFirstException(formState.exception); + + if (saved) { + notificationService.logSuccess({ + title: creating ? 'plugin_ai_administration_profile_created' : 'plugin_ai_administration_profile_updated', + message: formState.state.name, + }); + } else if (exception) { + notificationService.logException( + exception, + creating ? 'plugin_ai_administration_profile_create_error' : 'plugin_ai_administration_profile_save_error', + ); + } + }, + }); + + return ( +
+ +
+
+
+ + aiProfileFormService.close()}> + {formState.state.name || translate('ui_stepper_back')} + + +
+ +
+ +
+
+ + +
+
+
+ +
+
+
+
+ ); +}); diff --git a/webapp/packages/plugin-ai-administration/src/AIProfiles/AIProfileForm/AIProfileFormService.ts b/webapp/packages/plugin-ai-administration/src/AIProfiles/AIProfileForm/AIProfileFormService.ts new file mode 100644 index 00000000000..bd84a843947 --- /dev/null +++ b/webapp/packages/plugin-ai-administration/src/AIProfiles/AIProfileForm/AIProfileFormService.ts @@ -0,0 +1,106 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { action, makeObservable, observable } from 'mobx'; + +import { ConfirmationDialog, importLazyComponent } from '@cloudbeaver/core-blocks'; +import { injectable, IServiceProvider } from '@cloudbeaver/core-di'; +import { CommonDialogService, DialogueStateResult } from '@cloudbeaver/core-dialogs'; +import { NotificationService } from '@cloudbeaver/core-events'; +import { ExecutorInterrupter, type IExecutorHandler } from '@cloudbeaver/core-executor'; +import { LocalizationService } from '@cloudbeaver/core-localization'; +import { FormBaseService, FormMode, FormState, OptionsPanelService, type OptionsPanelCloseEventData } from '@cloudbeaver/core-ui'; +import { uuid } from '@cloudbeaver/core-utils'; + +import type { IAIProfileFormState } from './IAIProfileFormState.js'; + +const AIProfileForm = importLazyComponent(() => import('./AIProfileForm.js').then(m => m.AIProfileForm)); + +const formGetter = () => AIProfileForm; + +@injectable(() => [LocalizationService, NotificationService, OptionsPanelService, IServiceProvider, CommonDialogService]) +export class AIProfileFormService extends FormBaseService { + formState: FormState | null; + + constructor( + localizationService: LocalizationService, + notificationService: NotificationService, + private readonly optionsPanelService: OptionsPanelService, + private readonly serviceProvider: IServiceProvider, + private readonly commonDialogService: CommonDialogService, + ) { + super(localizationService, notificationService, 'AIProfileForm'); + + this.formState = null; + this.optionsPanelService.closeTask.addHandler(this.closeHandler); + + makeObservable(this, { + formState: observable.shallow, + open: action.bound, + close: action.bound, + }); + } + + async open(profileId: string | null, profileName?: string): Promise { + if (this.optionsPanelService.isOpen(formGetter)) { + return false; + } + + const opened = await this.optionsPanelService.open(formGetter); + + if (opened) { + this.formState?.dispose(); + this.formState = new FormState(this.serviceProvider, this, { + profileId: profileId ?? uuid(), + name: profileName ?? '', + }).setMode(profileId ? FormMode.Edit : FormMode.Create); + } + + return opened; + } + + async close(): Promise { + await this.optionsPanelService.close(); + } + + private readonly closeHandler: IExecutorHandler = async (data, contexts) => { + if (data === 'before') { + const confirmed = await this.showUnsavedChangesDialog(); + + if (!confirmed) { + ExecutorInterrupter.interrupt(contexts); + return; + } + + this.clearFormState(); + } + }; + + private async showUnsavedChangesDialog(): Promise { + if (!this.formState || !this.optionsPanelService.isOpen(formGetter)) { + return true; + } + + if (!this.formState.isChanged) { + return true; + } + + const { status } = await this.commonDialogService.open(ConfirmationDialog, { + title: 'plugin_ai_administration_profile_edit_cancel_title', + message: 'plugin_ai_administration_profile_edit_cancel_message', + confirmActionText: 'ui_processing_ok', + }); + + return status !== DialogueStateResult.Rejected; + } + + private clearFormState(): void { + this.formState?.dispose(); + this.formState = null; + } +} diff --git a/webapp/packages/plugin-ai-administration/src/AIProfiles/AIProfileForm/AIProfileFormTabBootstrap.ts b/webapp/packages/plugin-ai-administration/src/AIProfiles/AIProfileForm/AIProfileFormTabBootstrap.ts new file mode 100644 index 00000000000..b8c6ef40316 --- /dev/null +++ b/webapp/packages/plugin-ai-administration/src/AIProfiles/AIProfileForm/AIProfileFormTabBootstrap.ts @@ -0,0 +1,29 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { importLazyComponent } from '@cloudbeaver/core-blocks'; +import { Bootstrap, injectable } from '@cloudbeaver/core-di'; + +import { AIProfileFormService } from './AIProfileFormService.js'; + +const AIProfileOptions = importLazyComponent(() => import('./Options/AIProfileOptions.js').then(m => m.AIProfileOptions)); + +@injectable(() => [AIProfileFormService]) +export class AIProfileFormTabBootstrap extends Bootstrap { + constructor(private readonly aiProfileFormService: AIProfileFormService) { + super(); + } + + override register(): void { + this.aiProfileFormService.parts.add({ + key: 'options', + name: 'plugin_ai_administration_profile_form_tab_options', + order: 1, + panel: () => AIProfileOptions, + }); + } +} diff --git a/webapp/packages/plugin-ai-administration/src/AIProfiles/AIProfileForm/IAIProfileFormProps.ts b/webapp/packages/plugin-ai-administration/src/AIProfiles/AIProfileForm/IAIProfileFormProps.ts new file mode 100644 index 00000000000..285db52c2da --- /dev/null +++ b/webapp/packages/plugin-ai-administration/src/AIProfiles/AIProfileForm/IAIProfileFormProps.ts @@ -0,0 +1,13 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import type { IFormProps } from '@cloudbeaver/core-ui'; + +import type { IAIProfileFormState } from './IAIProfileFormState.js'; + +export interface IAIProfileFormProps extends IFormProps {} diff --git a/webapp/packages/plugin-ai-administration/src/AIProfiles/AIProfileForm/IAIProfileFormState.ts b/webapp/packages/plugin-ai-administration/src/AIProfiles/AIProfileForm/IAIProfileFormState.ts new file mode 100644 index 00000000000..9d54fbe4c8e --- /dev/null +++ b/webapp/packages/plugin-ai-administration/src/AIProfiles/AIProfileForm/IAIProfileFormState.ts @@ -0,0 +1,12 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +export interface IAIProfileFormState { + profileId: string; + name: string; +} diff --git a/webapp/packages/plugin-ai-administration/src/AIProfiles/AIProfileForm/Options/AIProfileFormPart.ts b/webapp/packages/plugin-ai-administration/src/AIProfiles/AIProfileForm/Options/AIProfileFormPart.ts new file mode 100644 index 00000000000..0722a61b4d5 --- /dev/null +++ b/webapp/packages/plugin-ai-administration/src/AIProfiles/AIProfileForm/Options/AIProfileFormPart.ts @@ -0,0 +1,199 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { runInAction } from 'mobx'; + +import { SAVED_VALUE_INDICATOR } from '@cloudbeaver/core-blocks'; +import { FormMode, FormPart, type IFormState } from '@cloudbeaver/core-ui'; +import { getUniqueName } from '@cloudbeaver/core-utils'; + +import { AIEnginePropertiesResource, MODEL_PROPERTY_ID } from '../../AIEnginePropertiesResource.js'; +import { type AIAdminProfile, type AIProfileInput, AIProfilesResource } from '../../AIProfilesResource.js'; +import { getObjectPropertiesValues } from '../../utils/getObjectPropertiesValues.js'; +import { prepareProperties } from '../../utils/prepareProperties.js'; +import type { IAIProfileFormState } from '../IAIProfileFormState.js'; +import type { IAIProfileOptionsState } from './AIProfileSchema.js'; + +const getDefaultState = (): IAIProfileOptionsState => ({ + name: '', + engineId: '', + properties: {}, +}); + +export class AIProfileFormPart extends FormPart { + constructor( + formState: IFormState, + private readonly aiProfilesResource: AIProfilesResource, + private readonly aiEnginePropertiesResource: AIEnginePropertiesResource, + ) { + super(formState, getDefaultState()); + } + + get isActiveModelChanged(): boolean { + return this.state.properties[MODEL_PROPERTY_ID] !== this.initialState.properties[MODEL_PROPERTY_ID]; + } + + override isOutdated(): boolean { + if (this.formState.mode !== FormMode.Edit) { + return false; + } + + if (this.aiProfilesResource.isOutdated(this.formState.state.profileId)) { + return true; + } + + const profile = this.aiProfilesResource.get(this.formState.state.profileId); + + if (!profile) { + return false; + } + + return this.aiEnginePropertiesResource.isOutdated(profile.engineId); + } + + override isLoaded(): boolean { + if (this.formState.mode !== FormMode.Edit) { + return this.loaded; + } + + if (!this.loaded || !this.aiProfilesResource.isLoaded(this.formState.state.profileId)) { + return false; + } + + const profile = this.aiProfilesResource.get(this.formState.state.profileId); + + if (!profile) { + return true; + } + + return this.aiEnginePropertiesResource.isLoaded(profile.engineId); + } + + async changeEngine(engineId: string): Promise { + if (this.formState.mode !== FormMode.Create) { + return; + } + + const propertiesInfo = await this.aiEnginePropertiesResource.load(engineId); + + runInAction(() => { + this.state.engineId = engineId; + this.state.properties = getObjectPropertiesValues(propertiesInfo ?? []); + }); + } + + async loadEngineProperties(): Promise { + if (!this.state.engineId) { + return; + } + + const profileId = this.formState.mode === FormMode.Edit ? this.formState.state.profileId : undefined; + const currentProperties = this.state.properties; + + const propertiesInfo = await this.aiEnginePropertiesResource.loadProperties(this.state.engineId, profileId, { + properties: prepareProperties({ + engineProperties: this.state.properties, + initialEngineProperties: this.initialState.properties, + infoProperties: this.aiEnginePropertiesResource.get(this.state.engineId) ?? [], + }), + }); + + runInAction(() => { + const properties = getObjectPropertiesValues(propertiesInfo); + + for (const property of propertiesInfo) { + if (!property.id || !property.features.includes('password')) { + continue; + } + + const current = currentProperties[property.id]; + + if (properties[property.id] === SAVED_VALUE_INDICATOR && current && current !== SAVED_VALUE_INDICATOR) { + properties[property.id] = current; + } + } + + this.state.properties = properties; + }); + } + + protected override format(): void { + this.state.name = this.state.name.trim(); + + if (this.formState.mode === FormMode.Create) { + const profileNames = this.aiProfilesResource.values.map(profile => profile.name); + this.state.name = getUniqueName(this.state.name, profileNames); + } + + for (const key of Object.keys(this.state.properties)) { + const value = this.state.properties[key]; + + if (typeof value === 'string') { + this.state.properties[key] = value.trim(); + } + } + } + + private getConfig(): AIProfileInput { + return { + profileId: this.formState.state.profileId, + profileName: this.state.name, + engineId: this.state.engineId, + configuration: { + properties: prepareProperties({ + engineProperties: this.state.properties, + initialEngineProperties: this.initialState.properties, + infoProperties: this.aiEnginePropertiesResource.get(this.state.engineId) ?? [], + }), + }, + }; + } + + protected override async saveChanges(): Promise { + const config = this.getConfig(); + + let profile: AIAdminProfile; + + if (this.formState.mode === FormMode.Create) { + profile = await this.aiProfilesResource.create(config); + this.formState.setMode(FormMode.Edit); + } else { + profile = await this.aiProfilesResource.update(config); + } + + this.formState.state.name = profile.name; + + this.setInitialState({ + name: profile.name, + engineId: profile.engineId, + properties: getObjectPropertiesValues(profile.configuration), + }); + } + + protected override async loader(): Promise { + if (this.formState.mode !== FormMode.Edit) { + this.setInitialState(getDefaultState()); + return; + } + + const profile = await this.aiProfilesResource.load(this.formState.state.profileId); + + if (!profile) { + this.setInitialState(getDefaultState()); + return; + } + + await this.aiEnginePropertiesResource.load(profile.engineId); + const propertiesInfo = await this.aiEnginePropertiesResource.loadProperties(profile.engineId, this.formState.state.profileId); + + this.setInitialState({ + name: profile.name, + engineId: profile.engineId, + properties: getObjectPropertiesValues(propertiesInfo), + }); + } +} diff --git a/webapp/packages/plugin-ai-administration/src/AIProfiles/AIProfileForm/Options/AIProfileOptions.tsx b/webapp/packages/plugin-ai-administration/src/AIProfiles/AIProfileForm/Options/AIProfileOptions.tsx new file mode 100644 index 00000000000..41e9a7c2c45 --- /dev/null +++ b/webapp/packages/plugin-ai-administration/src/AIProfiles/AIProfileForm/Options/AIProfileOptions.tsx @@ -0,0 +1,140 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { observer } from 'mobx-react-lite'; +import { useState } from 'react'; + +import { + ColoredContainer, + Combobox, + Container, + Group, + GroupTitle, + InputField, + ObjectPropertyInfoForm, + Select, + useAutoLoad, + useFormCustomInputValidation, + useResource, + useTranslate, +} from '@cloudbeaver/core-blocks'; +import { useService } from '@cloudbeaver/core-di'; +import { NotificationService } from '@cloudbeaver/core-events'; +import { FormMode, type TabContainerPanelComponent } from '@cloudbeaver/core-ui'; +import { getObjectPropertyOptionName, getObjectPropertyOptionValue } from '@cloudbeaver/core-sdk'; + +import { EnginesResource } from '../../../Engines/EnginesResource.js'; +import { AIEnginePropertiesResource, MODEL_PROPERTY_ID } from '../../AIEnginePropertiesResource.js'; +import type { IAIProfileFormProps } from '../IAIProfileFormProps.js'; +import { AI_PROFILE_NAME_MAX_LENGTH, AI_PROFILE_NAME_MIN_LENGTH } from './AIProfileSchema.js'; +import { getAIProfileFormPart } from './getAIProfileFormPart.js'; + +export const AIProfileOptions: TabContainerPanelComponent = observer(function AIProfileOptions({ formState }) { + const translate = useTranslate(); + const notificationService = useService(NotificationService); + const enginesLoader = useResource(AIProfileOptions, EnginesResource, undefined); + const part = getAIProfileFormPart(formState); + const propertiesLoader = useResource(AIProfileOptions, AIEnginePropertiesResource, part.state.engineId || null); + const propertiesInfo = propertiesLoader.data ?? []; + const isEditMode = formState.mode === FormMode.Edit; + const [isLoading, setIsLoading] = useState(false); + + useAutoLoad(AIProfileOptions, part); + + const { ref: nameRef } = useFormCustomInputValidation(value => { + if (value.trim().length > AI_PROFILE_NAME_MAX_LENGTH) { + return translate('plugin_ai_administration_profile_name_max_length', undefined, { length: AI_PROFILE_NAME_MAX_LENGTH }); + } + + if (value.trim().length < AI_PROFILE_NAME_MIN_LENGTH) { + return translate('plugin_ai_administration_profile_name_min_length', undefined, { length: AI_PROFILE_NAME_MIN_LENGTH }); + } + + return null; + }); + + const modelProperty = propertiesInfo.find(property => property.id === MODEL_PROPERTY_ID); + const models = [...(modelProperty?.validValues ?? [])].sort((a, b) => getObjectPropertyOptionName(a).localeCompare(getObjectPropertyOptionName(b))); + const hasModels = !!modelProperty; + const currentEngineProperties = propertiesInfo.filter(property => property.id !== MODEL_PROPERTY_ID); + + async function handleModelBlur() { + if (!part.isActiveModelChanged) { + return; + } + + try { + setIsLoading(true); + await part.loadEngineProperties(); + } catch (error: any) { + notificationService.logException(error, 'ai_administration_settings_preview_fail'); + part.state.properties[MODEL_PROPERTY_ID] = ''; + } finally { + setIsLoading(false); + } + } + + function handleModelChange(value: string | null) { + part.state.properties[MODEL_PROPERTY_ID] = value; + } + + return ( + + + + + {translate('plugin_ai_administration_profile_form_field_name')} + + + + {!!part.state.engineId && ( + + {translate('ai_administration_language_model_settings')} + + {hasModels && ( + + {translate('ai_administration_select_language_model_selector_title')} + + )} + + + + )} + + + ); +}); diff --git a/webapp/packages/plugin-ai-administration/src/AIProfiles/AIProfileForm/Options/AIProfileSchema.ts b/webapp/packages/plugin-ai-administration/src/AIProfiles/AIProfileForm/Options/AIProfileSchema.ts new file mode 100644 index 00000000000..fe86a0faa88 --- /dev/null +++ b/webapp/packages/plugin-ai-administration/src/AIProfiles/AIProfileForm/Options/AIProfileSchema.ts @@ -0,0 +1,20 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { schema } from '@cloudbeaver/core-utils'; + +export const AI_PROFILE_NAME_MIN_LENGTH = 1; +export const AI_PROFILE_NAME_MAX_LENGTH = 100; + +export const AIProfileSchema = schema.object({ + name: schema.string().min(AI_PROFILE_NAME_MIN_LENGTH).max(AI_PROFILE_NAME_MAX_LENGTH), + engineId: schema.string().min(1), + properties: schema.record(schema.string(), schema.any()), +}); + +export type IAIProfileOptionsState = schema.infer; diff --git a/webapp/packages/plugin-ai-administration/src/AIProfiles/AIProfileForm/Options/getAIProfileFormPart.ts b/webapp/packages/plugin-ai-administration/src/AIProfiles/AIProfileForm/Options/getAIProfileFormPart.ts new file mode 100644 index 00000000000..84e1c3f1335 --- /dev/null +++ b/webapp/packages/plugin-ai-administration/src/AIProfiles/AIProfileForm/Options/getAIProfileFormPart.ts @@ -0,0 +1,26 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { createDataContext, DATA_CONTEXT_DI_PROVIDER } from '@cloudbeaver/core-data-context'; +import type { IFormState } from '@cloudbeaver/core-ui'; + +import { AIEnginePropertiesResource } from '../../AIEnginePropertiesResource.js'; +import { AIProfilesResource } from '../../AIProfilesResource.js'; +import type { IAIProfileFormState } from '../IAIProfileFormState.js'; +import { AIProfileFormPart } from './AIProfileFormPart.js'; + +const DATA_CONTEXT_AI_PROFILE_FORM_PART = createDataContext('ai-profile-form-part'); + +export function getAIProfileFormPart(formState: IFormState): AIProfileFormPart { + return formState.getPart(DATA_CONTEXT_AI_PROFILE_FORM_PART, context => { + const di = context.get(DATA_CONTEXT_DI_PROVIDER)!; + const aiProfilesResource = di.getService(AIProfilesResource); + const aiEnginePropertiesResource = di.getService(AIEnginePropertiesResource); + + return new AIProfileFormPart(formState, aiProfilesResource, aiEnginePropertiesResource); + }); +} diff --git a/webapp/packages/plugin-ai-administration/src/AIProfiles/AIProfilesPanel.tsx b/webapp/packages/plugin-ai-administration/src/AIProfiles/AIProfilesPanel.tsx new file mode 100644 index 00000000000..332d20f136a --- /dev/null +++ b/webapp/packages/plugin-ai-administration/src/AIProfiles/AIProfilesPanel.tsx @@ -0,0 +1,103 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { observer } from 'mobx-react-lite'; + +import { + ColoredContainer, + Container, + Group, + SContext, + type StyleRegistry, + ToolsAction, + ToolsActionStyles, + ToolsPanel, + ToolsPanelStyles, + useAutoLoad, + useResource, + useTranslate, +} from '@cloudbeaver/core-blocks'; +import { useService } from '@cloudbeaver/core-di'; +import { CachedMapAllKey } from '@cloudbeaver/core-resource'; +import { TableSelectionContext, useTableSelection } from '@cloudbeaver/plugin-data-grid'; +import { isDefined } from '@dbeaver/js-helpers'; + +import type { AdministrationAISettingsFormState } from '../AISettingsForm/AdministrationAISettingsFormState.js'; +import { getAdministrationAISettingsFormInfoPart } from '../AISettingsForm/getAdministrationAISettingsFormInfoPart.js'; +import { AIProfileFormService } from './AIProfileForm/AIProfileFormService.js'; +import { AIProfilesResource } from './AIProfilesResource.js'; +import AIProfilesToolsPanelStyles from './AIProfilesToolsPanel.module.css'; +import { AIProfilesTable } from './AIProfilesTable.js'; +import { useAIProfilesTable } from './useAIProfilesTable.js'; + +interface Props { + formState: AdministrationAISettingsFormState; +} + +const toolsPanelRegistry: StyleRegistry = [ + [ToolsPanelStyles, { mode: 'append', styles: [AIProfilesToolsPanelStyles] }], + [ToolsActionStyles, { mode: 'append', styles: [AIProfilesToolsPanelStyles] }], +]; + +export const AIProfilesPanel = observer(function AIProfilesPanel({ formState }) { + const translate = useTranslate(); + const aiProfileFormService = useService(AIProfileFormService); + + const settingsInfoPart = getAdministrationAISettingsFormInfoPart(formState); + useAutoLoad(AIProfilesPanel, settingsInfoPart); + + const profilesLoader = useResource(AIProfilesPanel, AIProfilesResource, CachedMapAllKey); + const profiles = profilesLoader.data.filter(isDefined); + const defaultProfileId = settingsInfoPart.initialState.defaultConfiguration; + + const selection = useTableSelection(profiles.map(p => p.id)); + const table = useAIProfilesTable(selection); + + return ( + + + + + aiProfileFormService.open(null)} + > + {translate('ui_create')} + + table.refresh()} + > + {translate('ui_refresh')} + + table.delete()} + > + {translate('ui_delete')} + + + + + + + + + + + ); +}); diff --git a/webapp/packages/plugin-ai-administration/src/AIProfiles/AIProfilesResource.ts b/webapp/packages/plugin-ai-administration/src/AIProfiles/AIProfilesResource.ts new file mode 100644 index 00000000000..f3b9a1ecaa9 --- /dev/null +++ b/webapp/packages/plugin-ai-administration/src/AIProfiles/AIProfilesResource.ts @@ -0,0 +1,79 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { injectable } from '@cloudbeaver/core-di'; +import { CachedMapAllKey, CachedMapResource, resourceKeyList } from '@cloudbeaver/core-resource'; +import { EAdminPermission, ServerConfigResource, SessionPermissionsResource } from '@cloudbeaver/core-root'; +import { + GraphQLService, + type AiAdminConfigurationProfileInfo, + type AiConfigurationProfileInfo, + type AiConfigurationProfileInput, +} from '@cloudbeaver/core-sdk'; + +import { AISettingsResource } from '../AISettingsResource.js'; + +export type AIProfile = AiConfigurationProfileInfo; +export type AIAdminProfile = AiAdminConfigurationProfileInfo; +export type AIProfileInput = AiConfigurationProfileInput; + +@injectable(() => [GraphQLService, SessionPermissionsResource, ServerConfigResource, AISettingsResource]) +export class AIProfilesResource extends CachedMapResource { + constructor( + private readonly graphQLService: GraphQLService, + permissionsResource: SessionPermissionsResource, + serverConfigResource: ServerConfigResource, + aiSettingsResource: AISettingsResource, + ) { + super(); + + this.sync( + serverConfigResource, + () => undefined, + () => CachedMapAllKey, + ); + + permissionsResource.require(this, EAdminPermission.admin).outdateResource(this); + + this.onItemDelete.addHandler(() => aiSettingsResource.markOutdated()); + } + + async create(config: AIProfileInput): Promise { + const { profile } = await this.graphQLService.sdk.createAiProfile({ config }); + + this.set(profile.id, profile); + + return profile; + } + + async update(config: AIProfileInput): Promise { + const { profile } = await this.graphQLService.sdk.updateAiProfile({ config }); + + this.set(profile.id, profile); + + return profile; + } + + async deleteProfile(profileId: string): Promise { + await this.graphQLService.sdk.deleteAiProfile({ profileId }); + + this.delete(profileId); + } + + protected async loader(): Promise> { + const { profiles } = await this.graphQLService.sdk.getAiProfiles(); + + const key = resourceKeyList(profiles.map(profile => profile.id)); + this.replace(key, profiles); + + return this.data; + } + + protected validateKey(key: string): boolean { + return typeof key === 'string'; + } +} diff --git a/webapp/packages/plugin-ai-administration/src/AIProfiles/AIProfilesTable.tsx b/webapp/packages/plugin-ai-administration/src/AIProfiles/AIProfilesTable.tsx new file mode 100644 index 00000000000..1f2738a9672 --- /dev/null +++ b/webapp/packages/plugin-ai-administration/src/AIProfiles/AIProfilesTable.tsx @@ -0,0 +1,133 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { reaction } from 'mobx'; +import { observer } from 'mobx-react-lite'; + +import { Link, s, TextPlaceholder, useResource, useS, useTranslate } from '@cloudbeaver/core-blocks'; +import { useService } from '@cloudbeaver/core-di'; +import { ADMINISTRATION_TABLE_DEFAULT_ROW_HEIGHT, AdministrationTableStyles } from '@cloudbeaver/core-administration'; +import { DataGrid, TableRowSelect, useCreateGridReactiveValue } from '@cloudbeaver/plugin-data-grid'; +import { Command } from '@dbeaver/ui-kit'; + +import { EnginesResource } from '../Engines/EnginesResource.js'; +import { AIProfileFormService } from './AIProfileForm/AIProfileFormService.js'; +import type { AIProfile } from './AIProfilesResource.js'; + +interface Props { + profiles: AIProfile[]; + defaultProfileId: string | null; +} + +const SELECT_COLUMN = { key: 'select', label: '' }; +const NAME_COLUMN = { key: 'name', label: 'plugin_ai_administration_profile_column_name' }; +const ENGINE_COLUMN = { key: 'engine', label: 'plugin_ai_administration_profile_column_engine' }; + +const COLUMNS = [SELECT_COLUMN, NAME_COLUMN, ENGINE_COLUMN]; + +export const AIProfilesTable = observer(function AIProfilesTable({ profiles, defaultProfileId }) { + const translate = useTranslate(); + const styles = useS(AdministrationTableStyles); + const aiProfileFormService = useService(AIProfileFormService); + const enginesLoader = useResource(AIProfilesTable, EnginesResource, undefined); + + const columnsCount = useCreateGridReactiveValue(() => COLUMNS.length, null, [COLUMNS]); + const rowsCount = useCreateGridReactiveValue( + () => profiles.length, + onValueChange => reaction(() => profiles.length, onValueChange), + [profiles], + ); + + function getCell(rowIdx: number, colIdx: number) { + const profile = profiles[rowIdx]; + const column = COLUMNS[colIdx]; + + if (!profile || !column) { + return null; + } + + if (column.key === SELECT_COLUMN.key) { + return ; + } + + if (column.key === NAME_COLUMN.key) { + return ( + } + tabIndex={0} + title={profile.name} + className="tw:flex tw:cursor-pointer tw:items-center tw:gap-2 tw:outline-none" + onClick={() => aiProfileFormService.open(profile.id, profile.name)} + > + {profile.name} + {profile.id === defaultProfileId && ( + {translate('plugin_ai_administration_profile_default_badge')} + )} + + ); + } + + if (column.key === ENGINE_COLUMN.key) { + const engine = enginesLoader.data.find(engine => engine.id === profile.engineId); + return {engine?.name ?? profile.engineId}; + } + + return null; + } + + const cell = useCreateGridReactiveValue(getCell, (onValueChange, rowIdx, colIdx) => reaction(() => getCell(rowIdx, colIdx), onValueChange), [ + COLUMNS, + profiles, + defaultProfileId, + aiProfileFormService, + enginesLoader.data, + ]); + + function getHeaderText(colIdx: number) { + return translate(COLUMNS[colIdx]?.label) ?? ''; + } + + function getHeaderElement(colIdx: number) { + if (colIdx === 0) { + return ; + } + + return getHeaderText(colIdx); + } + + const headerElement = useCreateGridReactiveValue( + getHeaderElement, + (onValueChange, colIdx) => reaction(() => getHeaderElement(colIdx), onValueChange), + [COLUMNS, translate], + ); + + const headerText = useCreateGridReactiveValue(getHeaderText, (onValueChange, colIdx) => reaction(() => getHeaderText(colIdx), onValueChange), [ + COLUMNS, + translate, + ]); + + if (!profiles.length) { + return {translate('plugin_ai_administration_profiles_table_empty_placeholder')}; + } + + return ( +
+ colIdx > 0} + getRowHeight={() => ADMINISTRATION_TABLE_DEFAULT_ROW_HEIGHT} + getHeaderPinned={colIdx => colIdx <= 0} + headerText={headerText} + headerElement={headerElement} + cell={cell} + className={s(styles, { table: true })} + /> +
+ ); +}); diff --git a/webapp/packages/plugin-ai-administration/src/AIProfiles/AIProfilesToolsPanel.module.css b/webapp/packages/plugin-ai-administration/src/AIProfiles/AIProfilesToolsPanel.module.css new file mode 100644 index 00000000000..e7d15712e53 --- /dev/null +++ b/webapp/packages/plugin-ai-administration/src/AIProfiles/AIProfilesToolsPanel.module.css @@ -0,0 +1,15 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +.toolsPanel { + height: 44px !important; +} + +.buttonLabel { + font-size: 14px !important; +} diff --git a/webapp/packages/plugin-ai-administration/src/AIProfiles/useAIProfilesTable.ts b/webapp/packages/plugin-ai-administration/src/AIProfiles/useAIProfilesTable.ts new file mode 100644 index 00000000000..ecac4fca5d9 --- /dev/null +++ b/webapp/packages/plugin-ai-administration/src/AIProfiles/useAIProfilesTable.ts @@ -0,0 +1,106 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { action, observable } from 'mobx'; + +import { ConfirmationDialogDelete, useObservableRef, useTranslate } from '@cloudbeaver/core-blocks'; +import { useService } from '@cloudbeaver/core-di'; +import { CommonDialogService, DialogueStateResult } from '@cloudbeaver/core-dialogs'; +import { NotificationService } from '@cloudbeaver/core-events'; +import { CachedMapAllKey } from '@cloudbeaver/core-resource'; +import type { ITableSelection } from '@cloudbeaver/plugin-data-grid'; + +import { AIProfilesResource } from './AIProfilesResource.js'; + +interface State { + processing: boolean; + aiProfilesResource: AIProfilesResource; + notificationService: NotificationService; + dialogService: CommonDialogService; + selection: ITableSelection; + refresh: () => Promise; + delete: () => Promise; +} + +export function useAIProfilesTable(selection: ITableSelection): Readonly { + const notificationService = useService(NotificationService); + const dialogService = useService(CommonDialogService); + const aiProfilesResource = useService(AIProfilesResource); + const translate = useTranslate(); + + return useObservableRef( + () => ({ + processing: false, + async refresh() { + if (this.processing) { + return; + } + + try { + this.processing = true; + await this.aiProfilesResource.refresh(CachedMapAllKey); + this.notificationService.logSuccess({ title: 'plugin_ai_administration_profiles_refresh_success' }); + } catch (exception: any) { + this.notificationService.logException(exception, 'plugin_ai_administration_profiles_refresh_error'); + } finally { + this.processing = false; + } + }, + async delete() { + if (this.processing) { + return; + } + + const deletionList = this.selection.selected; + + if (deletionList.length === 0) { + return; + } + + const names = deletionList.map(id => `"${this.aiProfilesResource.get(id)?.name ?? id}"`).join(', '); + const message = `${translate('plugin_ai_administration_profile_delete_confirmation')}${names}.\n\n${translate('ui_are_you_sure')}`; + + const { status } = await this.dialogService.open(ConfirmationDialogDelete, { + title: 'ui_data_delete_confirmation', + message, + confirmActionText: 'ui_delete', + }); + + if (status === DialogueStateResult.Rejected) { + return; + } + + try { + this.processing = true; + const results = await Promise.allSettled(deletionList.map(profileId => this.aiProfilesResource.deleteProfile(profileId))); + const failed = results.filter((r): r is PromiseRejectedResult => r.status === 'rejected'); + + if (failed.length === 0) { + this.notificationService.logSuccess({ title: 'plugin_ai_administration_profile_delete_success' }); + } else { + this.notificationService.logError({ + title: 'plugin_ai_administration_profile_delete_error', + message: Array.from(new Set(failed.map(f => (f.reason instanceof Error ? f.reason.message : String(f.reason))))).join('\n'), + }); + } + + this.selection.clear(); + } finally { + this.processing = false; + } + }, + }), + { + processing: observable.ref, + selection: observable.ref, + refresh: action.bound, + delete: action.bound, + }, + { aiProfilesResource, selection, notificationService, dialogService }, + ); +} diff --git a/webapp/packages/plugin-ai-administration/src/AIProfiles/utils/getObjectPropertiesValues.ts b/webapp/packages/plugin-ai-administration/src/AIProfiles/utils/getObjectPropertiesValues.ts new file mode 100644 index 00000000000..66f9e57d769 --- /dev/null +++ b/webapp/packages/plugin-ai-administration/src/AIProfiles/utils/getObjectPropertiesValues.ts @@ -0,0 +1,21 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { getObjectPropertyValue, type IObjectPropertyInfo } from '@cloudbeaver/core-sdk'; + +export function getObjectPropertiesValues(properties: IObjectPropertyInfo[]): Record { + const result: Record = {}; + + for (const property of properties) { + if (property.id && property.value !== undefined) { + result[property.id] = getObjectPropertyValue(property); + } + } + + return result; +} diff --git a/webapp/packages/plugin-ai-administration/src/AIProfiles/utils/prepareProperties.ts b/webapp/packages/plugin-ai-administration/src/AIProfiles/utils/prepareProperties.ts new file mode 100644 index 00000000000..4508ecc0ecd --- /dev/null +++ b/webapp/packages/plugin-ai-administration/src/AIProfiles/utils/prepareProperties.ts @@ -0,0 +1,53 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import type { IObjectPropertyInfo } from '@cloudbeaver/core-sdk'; + +interface PreparePropertiesParams { + engineProperties: Record; + initialEngineProperties: Record; + infoProperties: IObjectPropertyInfo[]; +} + +export function prepareProperties({ engineProperties, initialEngineProperties, infoProperties }: PreparePropertiesParams): Record { + const result: Record = {}; + const passwordsProperties = infoProperties.filter(property => property.features.includes('password')); + + for (const key of Object.keys(engineProperties)) { + let value = engineProperties[key]; + const initial = initialEngineProperties[key]; + + if (typeof value === 'string') { + value = value.trim(); + } + + if (initial !== value) { + result[key] = value; + } + } + + for (const passwordProperty of passwordsProperties) { + const id = passwordProperty.id; + + if (!id) { + continue; + } + + const password = result[id]; + + if (!password || typeof password !== 'string') { + continue; + } + + if (password.split('').every(char => char === '*')) { + delete result[id]; + } + } + + return result; +} diff --git a/webapp/packages/plugin-ai-administration/src/AISettingsForm/AdministrationAISettingsFormService.ts b/webapp/packages/plugin-ai-administration/src/AISettingsForm/AdministrationAISettingsFormService.ts new file mode 100644 index 00000000000..27054506d4e --- /dev/null +++ b/webapp/packages/plugin-ai-administration/src/AISettingsForm/AdministrationAISettingsFormService.ts @@ -0,0 +1,20 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { injectable } from '@cloudbeaver/core-di'; +import { NotificationService } from '@cloudbeaver/core-events'; +import { LocalizationService } from '@cloudbeaver/core-localization'; +import { FormBaseService, type IFormProps } from '@cloudbeaver/core-ui'; + +export type AdministrationAISettingsFormProps = IFormProps; + +@injectable(() => [LocalizationService, NotificationService]) +export class AdministrationAISettingsFormService extends FormBaseService { + constructor(localizationService: LocalizationService, notificationService: NotificationService) { + super(localizationService, notificationService, 'Administration AI Settings form'); + } +} diff --git a/webapp/packages/plugin-ai-administration/src/AISettingsForm/AdministrationAISettingsFormState.ts b/webapp/packages/plugin-ai-administration/src/AISettingsForm/AdministrationAISettingsFormState.ts new file mode 100644 index 00000000000..30cd8d4cbd2 --- /dev/null +++ b/webapp/packages/plugin-ai-administration/src/AISettingsForm/AdministrationAISettingsFormState.ts @@ -0,0 +1,17 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import type { IServiceProvider } from '@cloudbeaver/core-di'; +import { FormState } from '@cloudbeaver/core-ui'; + +import type { AdministrationAISettingsFormService } from './AdministrationAISettingsFormService.js'; + +export class AdministrationAISettingsFormState extends FormState { + constructor(serviceProvider: IServiceProvider, service: AdministrationAISettingsFormService) { + super(serviceProvider, service, null); + } +} diff --git a/webapp/packages/plugin-ai-administration/src/AISettingsForm/AdministrationAISettingsInfoPart.ts b/webapp/packages/plugin-ai-administration/src/AISettingsForm/AdministrationAISettingsInfoPart.ts new file mode 100644 index 00000000000..12ab45753d4 --- /dev/null +++ b/webapp/packages/plugin-ai-administration/src/AISettingsForm/AdministrationAISettingsInfoPart.ts @@ -0,0 +1,79 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { CachedMapAllKey } from '@cloudbeaver/core-resource'; +import type { IExecutionContextProvider } from '@cloudbeaver/core-executor'; +import { FormPart, formValidationContext, type IFormState } from '@cloudbeaver/core-ui'; + +import { AIProfilesResource } from '../AIProfiles/AIProfilesResource.js'; +import type { AISettingsResource } from '../AISettingsResource.js'; +import { LANGUAGE_VALIDATION_REGEX } from './getLanguageOptions.js'; +import type { IAdministrationAIInfoState } from './IAdministrationAIInfoState.js'; + +const DEFAULT_STATE_GETTER: () => IAdministrationAIInfoState = () => ({ + defaultConfiguration: null, + language: null, +}); + +export class AdministrationAISettingsInfoPart extends FormPart { + constructor( + formState: IFormState, + private readonly aiSettingsResource: AISettingsResource, + private readonly aiProfilesResource: AIProfilesResource, + ) { + super(formState, DEFAULT_STATE_GETTER()); + } + + override isOutdated(): boolean { + return this.aiSettingsResource.isOutdated() || this.aiProfilesResource.isOutdated(CachedMapAllKey); + } + + override isLoaded(): boolean { + return this.loaded && this.aiSettingsResource.isLoaded() && this.aiProfilesResource.isLoaded(CachedMapAllKey); + } + + protected override format(): void { + this.state.language = this.state.language?.trim() ?? null; + } + + protected override validate(data: IFormState, contexts: IExecutionContextProvider>): void { + const validation = contexts.getContext(formValidationContext); + const language = this.state.language?.trim(); + + if (language && !LANGUAGE_VALIDATION_REGEX.test(language)) { + validation.error('plugin_ai_administration_language_validation_error'); + } + + if (language && language.length > 128) { + validation.error('plugin_ai_administration_language_length_validation_error'); + } + } + + protected override async saveChanges(): Promise { + if (!this.state.defaultConfiguration) { + return; + } + + await this.aiSettingsResource.saveSettings({ + defaultConfiguration: this.state.defaultConfiguration, + language: this.state.language ?? undefined, + }); + } + + protected override async loader(): Promise { + const [settings] = await Promise.all([this.aiSettingsResource.load(), this.aiProfilesResource.load(CachedMapAllKey)]); + + const defaultConfiguration = settings?.defaultConfiguration ?? null; + const hasDefaultConfiguration = + defaultConfiguration !== null && this.aiProfilesResource.values.some(profile => profile.id === defaultConfiguration); + + this.setInitialState({ + defaultConfiguration: hasDefaultConfiguration ? defaultConfiguration : null, + language: settings?.language ?? null, + }); + } +} diff --git a/webapp/packages/plugin-ai-administration/src/AISettingsForm/IAdministrationAIInfoState.ts b/webapp/packages/plugin-ai-administration/src/AISettingsForm/IAdministrationAIInfoState.ts new file mode 100644 index 00000000000..9066f98a924 --- /dev/null +++ b/webapp/packages/plugin-ai-administration/src/AISettingsForm/IAdministrationAIInfoState.ts @@ -0,0 +1,12 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +export interface IAdministrationAIInfoState { + defaultConfiguration: string | null; + language: string | null; +} diff --git a/webapp/packages/plugin-ai-administration/src/AISettingsForm/getAdministrationAISettingsFormInfoPart.ts b/webapp/packages/plugin-ai-administration/src/AISettingsForm/getAdministrationAISettingsFormInfoPart.ts new file mode 100644 index 00000000000..17767f712a7 --- /dev/null +++ b/webapp/packages/plugin-ai-administration/src/AISettingsForm/getAdministrationAISettingsFormInfoPart.ts @@ -0,0 +1,27 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { createDataContext, DATA_CONTEXT_DI_PROVIDER } from '@cloudbeaver/core-data-context'; +import type { IFormState } from '@cloudbeaver/core-ui'; + +import { AIProfilesResource } from '../AIProfiles/AIProfilesResource.js'; +import { AISettingsResource } from '../AISettingsResource.js'; +import { AdministrationAISettingsInfoPart } from './AdministrationAISettingsInfoPart.js'; + +const DATA_CONTEXT_ADMINISTRATION_AI_SETTINGS_FORM_INFO_PART = createDataContext( + 'Administration AI Settings Info Part', +); + +export function getAdministrationAISettingsFormInfoPart(formState: IFormState): AdministrationAISettingsInfoPart { + return formState.getPart(DATA_CONTEXT_ADMINISTRATION_AI_SETTINGS_FORM_INFO_PART, context => { + const di = context.get(DATA_CONTEXT_DI_PROVIDER)!; + const aiSettingsResource = di.getService(AISettingsResource); + const aiProfilesResource = di.getService(AIProfilesResource); + + return new AdministrationAISettingsInfoPart(formState, aiSettingsResource, aiProfilesResource); + }); +} diff --git a/webapp/packages/plugin-ai-administration/src/AISettingsForm/getLanguageOptions.ts b/webapp/packages/plugin-ai-administration/src/AISettingsForm/getLanguageOptions.ts new file mode 100644 index 00000000000..309495acd5b --- /dev/null +++ b/webapp/packages/plugin-ai-administration/src/AISettingsForm/getLanguageOptions.ts @@ -0,0 +1,15 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { getLocalizedDisplayName } from '@dbeaver/js-helpers'; + +export const LOCALES = ['en', 'ru', 'zh', 'es', 'fr', 'de', 'ja', 'pt', 'ar', 'hi']; + +export const LANGUAGE_OPTIONS = LOCALES.map(locale => getLocalizedDisplayName(locale)).toSorted((a, b) => a.localeCompare(b)); + +export const LANGUAGE_VALIDATION_REGEX = /^[\p{L}\p{M}\s-]+$/u; diff --git a/webapp/packages/plugin-ai-administration/src/AISettingsResource.ts b/webapp/packages/plugin-ai-administration/src/AISettingsResource.ts new file mode 100644 index 00000000000..dd128c560a1 --- /dev/null +++ b/webapp/packages/plugin-ai-administration/src/AISettingsResource.ts @@ -0,0 +1,53 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { injectable } from '@cloudbeaver/core-di'; +import { CachedDataResource } from '@cloudbeaver/core-resource'; +import { EAdminPermission, ServerConfigResource, SessionPermissionsResource } from '@cloudbeaver/core-root'; +import { GraphQLService, type AiSettingsConfig, type AiSettingsInfo } from '@cloudbeaver/core-sdk'; +import { isObjectsEqual } from '@cloudbeaver/core-utils'; + +@injectable(() => [GraphQLService, SessionPermissionsResource, ServerConfigResource]) +export class AISettingsResource extends CachedDataResource { + constructor( + private readonly graphQLService: GraphQLService, + permissionsResource: SessionPermissionsResource, + serverConfigResource: ServerConfigResource, + ) { + super(() => null); + + this.sync(serverConfigResource); + + permissionsResource.require(this, EAdminPermission.admin).outdateResource(this); + } + + async saveSettings(settings: AiSettingsConfig) { + await this.performUpdate(undefined, undefined, async () => { + const { result } = await this.graphQLService.sdk.saveAiSettings({ + settings, + }); + + this.setData(result); + this.onDataOutdated.execute(); + + return true; + }); + } + + isChanged(settings: AiSettingsConfig) { + if (!this.data) { + return false; + } + + return !isObjectsEqual(settings, this.data); + } + + protected async loader() { + const { settings } = await this.graphQLService.sdk.getAiSettings(); + return settings; + } +} diff --git a/webapp/packages/plugin-ai-administration/src/AISettingsService.ts b/webapp/packages/plugin-ai-administration/src/AISettingsService.ts new file mode 100644 index 00000000000..bf54bd443da --- /dev/null +++ b/webapp/packages/plugin-ai-administration/src/AISettingsService.ts @@ -0,0 +1,35 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { injectable, IServiceProvider } from '@cloudbeaver/core-di'; +import { FormMode } from '@cloudbeaver/core-ui'; + +import { AdministrationAISettingsFormService } from './AISettingsForm/AdministrationAISettingsFormService.js'; +import { AdministrationAISettingsFormState } from './AISettingsForm/AdministrationAISettingsFormState.js'; + +@injectable(() => [AdministrationAISettingsFormService, IServiceProvider]) +export class AISettingsService { + formState: AdministrationAISettingsFormState | null; + + constructor( + private readonly administrationAISettingsFormService: AdministrationAISettingsFormService, + private readonly serviceProvider: IServiceProvider, + ) { + this.formState = null; + } + + create(): void { + this.dispose(); + this.formState = new AdministrationAISettingsFormState(this.serviceProvider, this.administrationAISettingsFormService); + this.formState.setMode(FormMode.Edit); + } + + dispose() { + this.formState?.dispose(); + this.formState = null; + } +} diff --git a/webapp/packages/plugin-ai-administration/src/Engines/EnginesResource.ts b/webapp/packages/plugin-ai-administration/src/Engines/EnginesResource.ts new file mode 100644 index 00000000000..f5b17cca88b --- /dev/null +++ b/webapp/packages/plugin-ai-administration/src/Engines/EnginesResource.ts @@ -0,0 +1,34 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { injectable } from '@cloudbeaver/core-di'; +import { CachedDataResource } from '@cloudbeaver/core-resource'; +import { EAdminPermission, ServerConfigResource, SessionPermissionsResource } from '@cloudbeaver/core-root'; +import { GraphQLService, type AiEngineConfig, type AiEngineInfo } from '@cloudbeaver/core-sdk'; + +export type EngineInfo = AiEngineInfo; +export type EngineConfig = AiEngineConfig; + +@injectable(() => [GraphQLService, SessionPermissionsResource, ServerConfigResource]) +export class EnginesResource extends CachedDataResource { + constructor( + private readonly graphQLService: GraphQLService, + permissionsResource: SessionPermissionsResource, + serverConfigResource: ServerConfigResource, + ) { + super(() => []); + + this.sync(serverConfigResource); + + permissionsResource.require(this, EAdminPermission.admin).outdateResource(this); + } + + protected async loader(): Promise { + const { engines } = await this.graphQLService.sdk.getEngines(); + return engines ?? []; + } +} diff --git a/webapp/packages/plugin-ai-administration/src/LocaleService.ts b/webapp/packages/plugin-ai-administration/src/LocaleService.ts new file mode 100644 index 00000000000..a12eacd4143 --- /dev/null +++ b/webapp/packages/plugin-ai-administration/src/LocaleService.ts @@ -0,0 +1,33 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { Bootstrap, injectable } from '@cloudbeaver/core-di'; +import { LocalizationService } from '@cloudbeaver/core-localization'; + +@injectable(() => [LocalizationService]) +export class LocaleService extends Bootstrap { + constructor(private localizationService: LocalizationService) { + super(); + } + + override register(): void { + this.localizationService.addProvider(this.provider.bind(this)); + } + + private async provider(locale: string) { + switch (locale) { + case 'ru': + return (await import('./locales/ru.js')).default; + case 'de': + return (await import('./locales/de.js')).default; + case 'fr': + return (await import('./locales/fr.js')).default; + default: + return (await import('./locales/en.js')).default; + } + } +} diff --git a/webapp/packages/plugin-ai-administration/src/index.ts b/webapp/packages/plugin-ai-administration/src/index.ts new file mode 100644 index 00000000000..6d931339d7b --- /dev/null +++ b/webapp/packages/plugin-ai-administration/src/index.ts @@ -0,0 +1,13 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import './module.js'; + +export { AIAdministrationTabsService } from './AIAdministrationTabsService.js'; +export { AIAdministrationNavigationService, EAIAdministrationSub } from './AIAdministrationNavigationService.js'; +export { AIAdministrationBootstrap } from './AIAdministrationBootstrap.js'; diff --git a/webapp/packages/plugin-ai-administration/src/locales/de.ts b/webapp/packages/plugin-ai-administration/src/locales/de.ts new file mode 100644 index 00000000000..654e24633bd --- /dev/null +++ b/webapp/packages/plugin-ai-administration/src/locales/de.ts @@ -0,0 +1,51 @@ +export default [ + ['ai_administration_tab_title', 'KI Einstellungen'], + ['ai_administration_tab_main', 'Allgemein'], + ['ai_administration_settings', 'KI Einstellungen'], + ['ai_administration_language_model_settings', 'Modelleinstellungen'], + ['ai_administration_select_language_model_selector_title', 'Modell'], + ['ai_administration_settings_save_success', 'KI-Einstellungen gespeichert'], + ['ai_administration_settings_save_fail', 'KI-Einstellungen konnten nicht gespeichert werden'], + ['ai_administration_settings_load_fail', 'KI-Einstellungen konnten nicht geladen werden'], + ['ai_administration_language_model_settings_save_fail', 'Einstellungen der KI-Engine konnten nicht gespeichert werden'], + ['ai_administration_settings_preview_fail', 'Modelleigenschaften können nicht geladen werden'], + ['plugin_ai_administration_rag_label', 'Nur relevante Objekte an die KI senden (Experimentell)'], + [ + 'plugin_ai_administration_rag_description', + 'DBeaver analysiert Ihre Anfrage und sendet nur relevante Objekte an die KI, wodurch die Reaktionszeit und Genauigkeit verbessert und der Tokenverbrauch (RAG) reduziert wird. Derzeit nur bei Verwendung von PostgreSQL Cloudbeaver DB und OpenAI/Copilot-Engines unterstützt.', + ], + ['plugin_ai_administration_language_label', 'Sprache'], + ['plugin_ai_administration_language_description', 'Standardsprache für KI-Antworten'], + ['plugin_ai_administration_language_validation_error_title', 'Sprachvalidierungsfehler'], + ['plugin_ai_administration_language_validation_error', 'Die Sprache darf nur Buchstaben und Bindestriche enthalten'], + ['plugin_ai_administration_language_length_validation_error', 'Die Sprache darf nicht länger als 128 Zeichen sein'], + ['plugin_ai_administration_default_profile_label', 'Standardprofil'], + ['plugin_ai_administration_default_profile_description', 'Profil, das standardmäßig für KI-Funktionen verwendet wird'], + ['plugin_ai_administration_profiles_title', 'Profile'], + ['plugin_ai_administration_profiles_table_empty_placeholder', 'Keine Profile gefunden. Erstellen Sie ein neues Profil'], + ['plugin_ai_administration_profile_column_name', 'Name'], + ['plugin_ai_administration_profile_column_engine', 'Engine'], + ['plugin_ai_administration_profile_default_badge', '(Standard)'], + ['plugin_ai_administration_profile_add_tooltip', 'Neues Profil erstellen'], + ['plugin_ai_administration_profile_refresh_tooltip', 'Profilliste aktualisieren'], + ['plugin_ai_administration_profile_delete_tooltip', 'Ausgewählte Profile löschen'], + ['plugin_ai_administration_profiles_refresh_success', 'Profilliste aktualisiert'], + ['plugin_ai_administration_profiles_refresh_error', 'Profilliste konnte nicht aktualisiert werden'], + ['plugin_ai_administration_profile_delete_confirmation', 'Sie sind dabei, folgende Profile zu löschen: '], + ['plugin_ai_administration_profile_delete_success', 'Ausgewählte Profile gelöscht'], + ['plugin_ai_administration_profile_delete_error', 'Profile konnten nicht gelöscht werden'], + ['plugin_ai_administration_profile_created', 'Profil erstellt'], + ['plugin_ai_administration_profile_updated', 'Profil aktualisiert'], + ['plugin_ai_administration_profile_create_error', 'Profil konnte nicht erstellt werden'], + ['plugin_ai_administration_profile_save_error', 'Profil konnte nicht gespeichert werden'], + ['plugin_ai_administration_profile_form_field_name', 'Profilname'], + ['plugin_ai_administration_profile_form_field_engine', 'Engine'], + ['plugin_ai_administration_profile_form_tab_options', 'Profil'], + ['plugin_ai_administration_profile_edit_cancel_title', 'Abbruch bestätigen'], + [ + 'plugin_ai_administration_profile_edit_cancel_message', + 'Sie sind dabei, die Profiländerungen abzubrechen. Nicht gespeicherte Änderungen gehen verloren. Sind Sie sicher?', + ], + ['plugin_ai_administration_profile_name_max_length', 'Der Profilname darf {arg:length} Zeichen nicht überschreiten'], + ['plugin_ai_administration_profile_name_min_length', 'Der Profilname muss mindestens {arg:length} Zeichen lang sein'], +]; diff --git a/webapp/packages/plugin-ai-administration/src/locales/en.ts b/webapp/packages/plugin-ai-administration/src/locales/en.ts new file mode 100644 index 00000000000..20c25e5b3cc --- /dev/null +++ b/webapp/packages/plugin-ai-administration/src/locales/en.ts @@ -0,0 +1,48 @@ +export default [ + ['ai_administration_tab_title', 'AI Settings'], + ['ai_administration_tab_main', 'Main'], + ['ai_administration_settings', 'AI Settings'], + ['ai_administration_language_model_settings', 'Model settings'], + ['ai_administration_select_language_model_selector_title', 'Model'], + ['ai_administration_settings_save_success', 'AI settings saved'], + ['ai_administration_settings_save_fail', 'Failed to save AI settings'], + ['ai_administration_settings_load_fail', 'Failed to load AI settings'], + ['ai_administration_language_model_settings_save_fail', 'Failed to save engine settings'], + ['ai_administration_settings_preview_fail', 'Cannot load model properties'], + ['plugin_ai_administration_rag_label', 'Send only relevant objects to AI (Experimental)'], + [ + 'plugin_ai_administration_rag_description', + 'DBeaver analyzes your request and sends only relevant objects to AI, improving response time and accuracy while reducing token usage (RAG). Currently supported only when using PostgreSQL Cloudbeaver DB and OpenAI/Copilot engines.', + ], + ['plugin_ai_administration_language_label', 'Language'], + ['plugin_ai_administration_language_description', 'Default language for AI responses'], + ['plugin_ai_administration_language_validation_error_title', 'Language validation error'], + ['plugin_ai_administration_language_validation_error', 'Language must contain only letters and hyphens'], + ['plugin_ai_administration_language_length_validation_error', 'Language must not be longer than 128 characters'], + ['plugin_ai_administration_default_profile_label', 'Default profile'], + ['plugin_ai_administration_default_profile_description', 'Profile used by default for AI features'], + ['plugin_ai_administration_profiles_title', 'Profiles'], + ['plugin_ai_administration_profiles_table_empty_placeholder', 'No profiles found. Create a new profile'], + ['plugin_ai_administration_profile_column_name', 'Name'], + ['plugin_ai_administration_profile_column_engine', 'Engine'], + ['plugin_ai_administration_profile_default_badge', '(Default)'], + ['plugin_ai_administration_profile_add_tooltip', 'Create new profile'], + ['plugin_ai_administration_profile_refresh_tooltip', 'Refresh profiles list'], + ['plugin_ai_administration_profile_delete_tooltip', 'Delete selected profiles'], + ['plugin_ai_administration_profiles_refresh_success', 'Profiles list updated'], + ['plugin_ai_administration_profiles_refresh_error', 'Failed to refresh profiles list'], + ['plugin_ai_administration_profile_delete_confirmation', 'You are about to delete profile(s): '], + ['plugin_ai_administration_profile_delete_success', 'Selected profiles deleted'], + ['plugin_ai_administration_profile_delete_error', 'Failed to delete profiles'], + ['plugin_ai_administration_profile_created', 'Profile created'], + ['plugin_ai_administration_profile_updated', 'Profile updated'], + ['plugin_ai_administration_profile_create_error', 'Failed to create profile'], + ['plugin_ai_administration_profile_save_error', 'Failed to save profile'], + ['plugin_ai_administration_profile_form_field_name', 'Profile name'], + ['plugin_ai_administration_profile_form_field_engine', 'Engine'], + ['plugin_ai_administration_profile_form_tab_options', 'Profile'], + ['plugin_ai_administration_profile_edit_cancel_title', 'Cancel confirmation'], + ['plugin_ai_administration_profile_edit_cancel_message', "You're going to cancel profile changes. Unsaved changes will be lost. Are you sure?"], + ['plugin_ai_administration_profile_name_max_length', 'Profile name must not exceed {arg:length} characters'], + ['plugin_ai_administration_profile_name_min_length', 'Profile name must be at least {arg:length} characters'], +]; diff --git a/webapp/packages/plugin-ai-administration/src/locales/fr.ts b/webapp/packages/plugin-ai-administration/src/locales/fr.ts new file mode 100644 index 00000000000..80619cf63a5 --- /dev/null +++ b/webapp/packages/plugin-ai-administration/src/locales/fr.ts @@ -0,0 +1,51 @@ +export default [ + ['ai_administration_tab_title', "Paramètres d'IA"], + ['ai_administration_tab_main', 'Principal'], + ['ai_administration_settings', "Paramètres d'IA"], + ['ai_administration_language_model_settings', 'Paramètres du modèle'], + ['ai_administration_select_language_model_selector_title', 'Modèle'], + ['ai_administration_settings_save_success', "Les paramètres de l'IA ont été sauvegardé"], + ['ai_administration_settings_save_fail', "Échec de l'enregistrement des réglages de l'IA"], + ['ai_administration_settings_load_fail', "Échec du chargement des paramètres de l'IA"], + ['ai_administration_language_model_settings_save_fail', "Échec de l'enregistrement des réglages du modèle"], + ['ai_administration_settings_preview_fail', 'Impossible de charger les propriétés du modèle'], + ['plugin_ai_administration_rag_label', 'Envoyer uniquement les objets pertinents à l’IA (Expérimental)'], + [ + 'plugin_ai_administration_rag_description', + 'DBeaver analyse votre requête et n’envoie à l’IA que les objets pertinents, améliorant ainsi le temps de réponse et la précision tout en réduisant l’utilisation des tokens (RAG). Actuellement pris en charge uniquement lors de l’utilisation de PostgreSQL Cloudbeaver DB et des modèles OpenAI/Copilot.', + ], + ['plugin_ai_administration_language_label', 'Langue'], + ['plugin_ai_administration_language_description', "Langue par défaut pour les réponses de l'IA"], + ['plugin_ai_administration_language_validation_error_title', 'Erreur de validation de la langue'], + ['plugin_ai_administration_language_validation_error', 'La langue ne doit contenir que des lettres et des tirets'], + ['plugin_ai_administration_language_length_validation_error', 'La langue ne doit pas dépasser 128 caractères'], + ['plugin_ai_administration_default_profile_label', 'Profil par défaut'], + ['plugin_ai_administration_default_profile_description', "Profil utilisé par défaut pour les fonctionnalités d'IA"], + ['plugin_ai_administration_profiles_title', 'Profils'], + ['plugin_ai_administration_profiles_table_empty_placeholder', 'Aucun profil trouvé. Créez un nouveau profil'], + ['plugin_ai_administration_profile_column_name', 'Nom'], + ['plugin_ai_administration_profile_column_engine', "Modèle d'IA"], + ['plugin_ai_administration_profile_default_badge', '(Par défaut)'], + ['plugin_ai_administration_profile_add_tooltip', 'Créer un nouveau profil'], + ['plugin_ai_administration_profile_refresh_tooltip', 'Actualiser la liste des profils'], + ['plugin_ai_administration_profile_delete_tooltip', 'Supprimer les profils sélectionnés'], + ['plugin_ai_administration_profiles_refresh_success', 'Liste des profils mise à jour'], + ['plugin_ai_administration_profiles_refresh_error', 'Échec de l’actualisation de la liste des profils'], + ['plugin_ai_administration_profile_delete_confirmation', 'Vous êtes sur le point de supprimer le(s) profil(s) : '], + ['plugin_ai_administration_profile_delete_success', 'Profils sélectionnés supprimés'], + ['plugin_ai_administration_profile_delete_error', 'Échec de la suppression des profils'], + ['plugin_ai_administration_profile_created', 'Profil créé'], + ['plugin_ai_administration_profile_updated', 'Profil mis à jour'], + ['plugin_ai_administration_profile_create_error', 'Échec de la création du profil'], + ['plugin_ai_administration_profile_save_error', 'Échec de l’enregistrement du profil'], + ['plugin_ai_administration_profile_form_field_name', 'Nom du profil'], + ['plugin_ai_administration_profile_form_field_engine', "Modèle d'IA"], + ['plugin_ai_administration_profile_form_tab_options', 'Profil'], + ['plugin_ai_administration_profile_edit_cancel_title', "Confirmation d'annulation"], + [ + 'plugin_ai_administration_profile_edit_cancel_message', + 'Vous êtes sur le point d’annuler les modifications du profil. Les modifications non enregistrées seront perdues. Êtes-vous sûr ?', + ], + ['plugin_ai_administration_profile_name_max_length', 'Le nom du profil ne doit pas dépasser {arg:length} caractères'], + ['plugin_ai_administration_profile_name_min_length', 'Le nom du profil doit contenir au moins {arg:length} caractères'], +]; diff --git a/webapp/packages/plugin-ai-administration/src/locales/ru.ts b/webapp/packages/plugin-ai-administration/src/locales/ru.ts new file mode 100644 index 00000000000..46fcbcad3f4 --- /dev/null +++ b/webapp/packages/plugin-ai-administration/src/locales/ru.ts @@ -0,0 +1,51 @@ +export default [ + ['ai_administration_tab_title', 'AI Настройки'], + ['ai_administration_tab_main', 'Основные'], + ['ai_administration_settings', 'AI Настройки'], + ['ai_administration_language_model_settings', 'Настройки модели'], + ['ai_administration_select_language_model_selector_title', 'Модель'], + ['ai_administration_settings_save_success', 'Настройки AI сохранены'], + ['ai_administration_settings_save_fail', 'Не удалось сохранить настройки AI'], + ['ai_administration_settings_load_fail', 'Не удалось загрузить настройки AI'], + ['ai_administration_language_model_settings_save_fail', 'Не удалось сохранить настройки энджина'], + ['ai_administration_settings_preview_fail', 'Не удалось загрузить свойства модели'], + ['plugin_ai_administration_rag_label', 'Отправлять в ИИ только релевантные объекты (Экспериментально)'], + [ + 'plugin_ai_administration_rag_description', + 'DBeaver анализирует ваш запрос и отправляет в ИИ только релевантные объекты, что улучшает время отклика и точность, а также снижает использование токенов (RAG). В настоящее время поддерживается только при использовании PostgreSQL Cloudbeaver DB и OpenAI/Copilot.', + ], + ['plugin_ai_administration_language_label', 'Язык'], + ['plugin_ai_administration_language_description', 'Язык по умолчанию для ответов ИИ'], + ['plugin_ai_administration_language_validation_error_title', 'Ошибка проверки языка'], + ['plugin_ai_administration_language_validation_error', 'Язык должен содержать только буквы и дефисы'], + ['plugin_ai_administration_language_length_validation_error', 'Язык не должен превышать 128 символов'], + ['plugin_ai_administration_default_profile_label', 'Профиль по умолчанию'], + ['plugin_ai_administration_default_profile_description', 'Профиль, используемый по умолчанию для функций ИИ'], + ['plugin_ai_administration_profiles_title', 'Профили'], + ['plugin_ai_administration_profiles_table_empty_placeholder', 'Профили не найдены. Создайте новый профиль'], + ['plugin_ai_administration_profile_column_name', 'Название'], + ['plugin_ai_administration_profile_column_engine', 'Энджин'], + ['plugin_ai_administration_profile_default_badge', '(По умолчанию)'], + ['plugin_ai_administration_profile_add_tooltip', 'Создать новый профиль'], + ['plugin_ai_administration_profile_refresh_tooltip', 'Обновить список профилей'], + ['plugin_ai_administration_profile_delete_tooltip', 'Удалить выбранные профили'], + ['plugin_ai_administration_profiles_refresh_success', 'Список профилей обновлён'], + ['plugin_ai_administration_profiles_refresh_error', 'Не удалось обновить список профилей'], + ['plugin_ai_administration_profile_delete_confirmation', 'Вы собираетесь удалить профили(ь): '], + ['plugin_ai_administration_profile_delete_success', 'Выбранные профили удалены'], + ['plugin_ai_administration_profile_delete_error', 'Не удалось удалить профили'], + ['plugin_ai_administration_profile_created', 'Профиль создан'], + ['plugin_ai_administration_profile_updated', 'Профиль обновлён'], + ['plugin_ai_administration_profile_create_error', 'Не удалось создать профиль'], + ['plugin_ai_administration_profile_save_error', 'Не удалось сохранить профиль'], + ['plugin_ai_administration_profile_form_field_name', 'Название профиля'], + ['plugin_ai_administration_profile_form_field_engine', 'Энджин'], + ['plugin_ai_administration_profile_form_tab_options', 'Профиль'], + ['plugin_ai_administration_profile_edit_cancel_title', 'Подтверждение отмены'], + [ + 'plugin_ai_administration_profile_edit_cancel_message', + 'Вы собираетесь отменить изменения профиля. Несохранённые изменения будут потеряны. Вы уверены?', + ], + ['plugin_ai_administration_profile_name_max_length', 'Название профиля не должно превышать {arg:length} символов'], + ['plugin_ai_administration_profile_name_min_length', 'Название профиля должно содержать не менее {arg:length} символов'], +]; diff --git a/webapp/packages/plugin-ai-administration/src/module.ts b/webapp/packages/plugin-ai-administration/src/module.ts new file mode 100644 index 00000000000..4de3f38477b --- /dev/null +++ b/webapp/packages/plugin-ai-administration/src/module.ts @@ -0,0 +1,47 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { Bootstrap, Dependency, ModuleRegistry, proxy } from '@cloudbeaver/core-di'; +import { LocaleService } from './LocaleService.js'; +import { EnginesResource } from './Engines/EnginesResource.js'; +import { AISettingsResource } from './AISettingsResource.js'; +import { AISettingsService } from './AISettingsService.js'; +import { AIEnginePropertiesResource } from './AIProfiles/AIEnginePropertiesResource.js'; +import { AIProfilesResource } from './AIProfiles/AIProfilesResource.js'; +import { AIProfileFormService } from './AIProfiles/AIProfileForm/AIProfileFormService.js'; +import { AIProfileFormTabBootstrap } from './AIProfiles/AIProfileForm/AIProfileFormTabBootstrap.js'; +import { AdministrationAISettingsFormService } from './AISettingsForm/AdministrationAISettingsFormService.js'; +import { AIAdministrationBootstrap } from './AIAdministrationBootstrap.js'; +import { AIAdministrationTabsService } from './AIAdministrationTabsService.js'; +import { AIAdministrationNavigationService } from './AIAdministrationNavigationService.js'; + +export default ModuleRegistry.add({ + name: '@cloudbeaver/plugin-ai-administration', + + configure: serviceCollection => { + serviceCollection + .addSingleton(Bootstrap, proxy(AIAdministrationBootstrap)) + .addSingleton(AIAdministrationBootstrap) + .addSingleton(Bootstrap, LocaleService) + .addSingleton(Bootstrap, proxy(AIAdministrationTabsService)) + .addSingleton(Dependency, proxy(EnginesResource)) + .addSingleton(Dependency, proxy(AISettingsResource)) + .addSingleton(Dependency, proxy(AIProfilesResource)) + .addSingleton(Dependency, proxy(AIEnginePropertiesResource)) + .addSingleton(EnginesResource) + .addSingleton(AISettingsResource) + .addSingleton(AISettingsService) + .addSingleton(AIProfilesResource) + .addSingleton(AIEnginePropertiesResource) + .addSingleton(AIProfileFormService) + .addSingleton(Bootstrap, AIProfileFormTabBootstrap) + .addSingleton(AIAdministrationTabsService) + .addSingleton(AdministrationAISettingsFormService) + .addSingleton(AIAdministrationNavigationService); + }, +}); diff --git a/webapp/packages/plugin-ai-administration/src/shared/AIAdministrationPanel.module.css b/webapp/packages/plugin-ai-administration/src/shared/AIAdministrationPanel.module.css new file mode 100644 index 00000000000..eca294599e3 --- /dev/null +++ b/webapp/packages/plugin-ai-administration/src/shared/AIAdministrationPanel.module.css @@ -0,0 +1,6 @@ +.tabList { + position: relative; + flex-shrink: 0; + align-items: center; + padding: 0 24px; +} diff --git a/webapp/packages/plugin-ai-administration/src/shared/AIAdministrationPanelTab.module.css b/webapp/packages/plugin-ai-administration/src/shared/AIAdministrationPanelTab.module.css new file mode 100644 index 00000000000..b75fcefafac --- /dev/null +++ b/webapp/packages/plugin-ai-administration/src/shared/AIAdministrationPanelTab.module.css @@ -0,0 +1,16 @@ +.administrationTabs { + & .tab { + height: 100%; + font-size: 14px; + font-weight: 700; + padding: 0 4px; + } + + & .tabInner { + height: 100%; + } + + & .tabOuter { + height: 100%; + } +} diff --git a/webapp/packages/plugin-ai-administration/src/shared/AIAdministrationPanelTabPanel.module.css b/webapp/packages/plugin-ai-administration/src/shared/AIAdministrationPanelTabPanel.module.css new file mode 100644 index 00000000000..fc8fc508d05 --- /dev/null +++ b/webapp/packages/plugin-ai-administration/src/shared/AIAdministrationPanelTabPanel.module.css @@ -0,0 +1,3 @@ +.tabPanel { + overflow: auto; +} diff --git a/webapp/packages/plugin-ai-administration/src/shared/AIAdministrationPanelTabTitle.module.css b/webapp/packages/plugin-ai-administration/src/shared/AIAdministrationPanelTabTitle.module.css new file mode 100644 index 00000000000..fe72df1e427 --- /dev/null +++ b/webapp/packages/plugin-ai-administration/src/shared/AIAdministrationPanelTabTitle.module.css @@ -0,0 +1,3 @@ +.tabTitle { + font-weight: 700; +} diff --git a/webapp/packages/plugin-ai-administration/tsconfig.json b/webapp/packages/plugin-ai-administration/tsconfig.json new file mode 100644 index 00000000000..d568f19078d --- /dev/null +++ b/webapp/packages/plugin-ai-administration/tsconfig.json @@ -0,0 +1,72 @@ +{ + "extends": "@cloudbeaver/tsconfig/tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib", + "tsBuildInfoFile": "lib/tsconfig.tsbuildinfo", + "composite": true + }, + "exclude": [ + "lib/**/*", + "**/node_modules" + ], + "references": [ + { + "path": "../../common-react/@dbeaver/ui-kit" + }, + { + "path": "../../common-typescript/@dbeaver/js-helpers" + }, + { + "path": "../core-administration" + }, + { + "path": "../core-blocks" + }, + { + "path": "../core-cli" + }, + { + "path": "../core-data-context" + }, + { + "path": "../core-di" + }, + { + "path": "../core-dialogs" + }, + { + "path": "../core-events" + }, + { + "path": "../core-executor" + }, + { + "path": "../core-localization" + }, + { + "path": "../core-resource" + }, + { + "path": "../core-root" + }, + { + "path": "../core-sdk" + }, + { + "path": "../core-ui" + }, + { + "path": "../core-utils" + }, + { + "path": "../plugin-data-grid" + } + ], + "include": [ + "__custom_mocks__/**/*", + "src/**/*", + "src/**/*.json", + "src/**/*.css" + ] +} diff --git a/webapp/packages/plugin-ai-chat/package.json b/webapp/packages/plugin-ai-chat/package.json new file mode 100644 index 00000000000..9121cc7bccd --- /dev/null +++ b/webapp/packages/plugin-ai-chat/package.json @@ -0,0 +1,70 @@ +{ + "name": "@cloudbeaver/plugin-ai-chat", + "type": "module", + "sideEffects": [ + "./lib/module.js", + "./lib/index.js", + "src/**/*.css", + "public/**/*" + ], + "version": "0.1.0", + "description": "", + "license": "Apache-2.0", + "exports": { + ".": "./lib/index.js", + "./module": "./lib/module.js" + }, + "scripts": { + "build": "tsc -b", + "clean": "rimraf --glob lib", + "lint": "eslint ./src/ --ext .ts,.tsx", + "test": "dbeaver-test", + "validate-dependencies": "core-cli-validate-dependencies" + }, + "dependencies": { + "@cloudbeaver/core-authentication": "workspace:*", + "@cloudbeaver/core-blocks": "workspace:*", + "@cloudbeaver/core-connections": "workspace:*", + "@cloudbeaver/core-di": "workspace:*", + "@cloudbeaver/core-dialogs": "workspace:*", + "@cloudbeaver/core-events": "workspace:*", + "@cloudbeaver/core-executor": "workspace:*", + "@cloudbeaver/core-localization": "workspace:*", + "@cloudbeaver/core-navigation-tree": "workspace:*", + "@cloudbeaver/core-projects": "workspace:*", + "@cloudbeaver/core-resource": "workspace:*", + "@cloudbeaver/core-root": "workspace:*", + "@cloudbeaver/core-sdk": "workspace:*", + "@cloudbeaver/core-settings": "workspace:*", + "@cloudbeaver/core-ui": "workspace:*", + "@cloudbeaver/core-utils": "workspace:*", + "@cloudbeaver/core-view": "workspace:*", + "@cloudbeaver/plugin-codemirror6": "workspace:*", + "@cloudbeaver/plugin-datasource-context-switch": "workspace:*", + "@cloudbeaver/plugin-navigation-tabs": "workspace:*", + "@cloudbeaver/plugin-navigation-tree": "workspace:*", + "@cloudbeaver/plugin-sql-editor": "workspace:*", + "@cloudbeaver/plugin-sql-editor-codemirror": "workspace:*", + "@cloudbeaver/plugin-sql-editor-navigation-tab": "workspace:*", + "@cloudbeaver/plugin-top-app-bar": "workspace:*", + "@dbeaver/js-helpers": "workspace:*", + "@dbeaver/ui-kit": "workspace:*", + "marked": "^18", + "mobx": "^6", + "mobx-react-lite": "^4", + "react": "^19", + "react-dom": "^19", + "react-markdown": "^10", + "remark-gfm": "^4", + "tslib": "^2" + }, + "devDependencies": { + "@cloudbeaver/core-cli": "workspace:*", + "@cloudbeaver/tsconfig": "workspace:*", + "@dbeaver/cli": "workspace:*", + "@types/react": "^19", + "rimraf": "^6", + "typescript": "^5", + "typescript-plugin-css-modules": "^5" + } +} diff --git a/webapp/packages/plugin-ai-chat/public/icons/add_chat.svg b/webapp/packages/plugin-ai-chat/public/icons/add_chat.svg new file mode 100644 index 00000000000..b2b5b4d14ea --- /dev/null +++ b/webapp/packages/plugin-ai-chat/public/icons/add_chat.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/webapp/packages/plugin-ai-chat/public/icons/add_chat_m.svg b/webapp/packages/plugin-ai-chat/public/icons/add_chat_m.svg new file mode 100644 index 00000000000..55853993914 --- /dev/null +++ b/webapp/packages/plugin-ai-chat/public/icons/add_chat_m.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/webapp/packages/plugin-ai-chat/public/icons/add_chat_sm.svg b/webapp/packages/plugin-ai-chat/public/icons/add_chat_sm.svg new file mode 100644 index 00000000000..a1d80461323 --- /dev/null +++ b/webapp/packages/plugin-ai-chat/public/icons/add_chat_sm.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/webapp/packages/plugin-ai-chat/public/icons/ai.svg b/webapp/packages/plugin-ai-chat/public/icons/ai.svg new file mode 100644 index 00000000000..38ae586c7b2 --- /dev/null +++ b/webapp/packages/plugin-ai-chat/public/icons/ai.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/webapp/packages/plugin-ai-chat/public/icons/ai_conversation_type_default.svg b/webapp/packages/plugin-ai-chat/public/icons/ai_conversation_type_default.svg new file mode 100644 index 00000000000..3aa8c939de3 --- /dev/null +++ b/webapp/packages/plugin-ai-chat/public/icons/ai_conversation_type_default.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/webapp/packages/plugin-ai-chat/public/icons/ai_conversation_type_default_m.svg b/webapp/packages/plugin-ai-chat/public/icons/ai_conversation_type_default_m.svg new file mode 100644 index 00000000000..8d6e14b11f1 --- /dev/null +++ b/webapp/packages/plugin-ai-chat/public/icons/ai_conversation_type_default_m.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/webapp/packages/plugin-ai-chat/public/icons/ai_conversation_type_default_sm.svg b/webapp/packages/plugin-ai-chat/public/icons/ai_conversation_type_default_sm.svg new file mode 100644 index 00000000000..2a49c5bc057 --- /dev/null +++ b/webapp/packages/plugin-ai-chat/public/icons/ai_conversation_type_default_sm.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/webapp/packages/plugin-ai-chat/public/icons/ai_conversation_type_describe_object.svg b/webapp/packages/plugin-ai-chat/public/icons/ai_conversation_type_describe_object.svg new file mode 100644 index 00000000000..6f19598fbef --- /dev/null +++ b/webapp/packages/plugin-ai-chat/public/icons/ai_conversation_type_describe_object.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/webapp/packages/plugin-ai-chat/public/icons/ai_conversation_type_describe_object_m.svg b/webapp/packages/plugin-ai-chat/public/icons/ai_conversation_type_describe_object_m.svg new file mode 100644 index 00000000000..740fd46981e --- /dev/null +++ b/webapp/packages/plugin-ai-chat/public/icons/ai_conversation_type_describe_object_m.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/webapp/packages/plugin-ai-chat/public/icons/ai_conversation_type_describe_object_sm.svg b/webapp/packages/plugin-ai-chat/public/icons/ai_conversation_type_describe_object_sm.svg new file mode 100644 index 00000000000..e71e35a445a --- /dev/null +++ b/webapp/packages/plugin-ai-chat/public/icons/ai_conversation_type_describe_object_sm.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/webapp/packages/plugin-ai-chat/public/icons/ai_conversation_type_explain_query.svg b/webapp/packages/plugin-ai-chat/public/icons/ai_conversation_type_explain_query.svg new file mode 100644 index 00000000000..c6e55f4aed0 --- /dev/null +++ b/webapp/packages/plugin-ai-chat/public/icons/ai_conversation_type_explain_query.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/webapp/packages/plugin-ai-chat/public/icons/ai_conversation_type_explain_query_m.svg b/webapp/packages/plugin-ai-chat/public/icons/ai_conversation_type_explain_query_m.svg new file mode 100644 index 00000000000..bb3d0662029 --- /dev/null +++ b/webapp/packages/plugin-ai-chat/public/icons/ai_conversation_type_explain_query_m.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/webapp/packages/plugin-ai-chat/public/icons/ai_conversation_type_explain_query_sm.svg b/webapp/packages/plugin-ai-chat/public/icons/ai_conversation_type_explain_query_sm.svg new file mode 100644 index 00000000000..1c97329fbf5 --- /dev/null +++ b/webapp/packages/plugin-ai-chat/public/icons/ai_conversation_type_explain_query_sm.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/webapp/packages/plugin-ai-chat/public/icons/ai_conversation_type_fix_error.svg b/webapp/packages/plugin-ai-chat/public/icons/ai_conversation_type_fix_error.svg new file mode 100644 index 00000000000..00493bdc2b6 --- /dev/null +++ b/webapp/packages/plugin-ai-chat/public/icons/ai_conversation_type_fix_error.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/webapp/packages/plugin-ai-chat/public/icons/ai_conversation_type_fix_error_m.svg b/webapp/packages/plugin-ai-chat/public/icons/ai_conversation_type_fix_error_m.svg new file mode 100644 index 00000000000..d392619379b --- /dev/null +++ b/webapp/packages/plugin-ai-chat/public/icons/ai_conversation_type_fix_error_m.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/webapp/packages/plugin-ai-chat/public/icons/ai_conversation_type_fix_error_sm.svg b/webapp/packages/plugin-ai-chat/public/icons/ai_conversation_type_fix_error_sm.svg new file mode 100644 index 00000000000..bfd3d2c4341 --- /dev/null +++ b/webapp/packages/plugin-ai-chat/public/icons/ai_conversation_type_fix_error_sm.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/webapp/packages/plugin-ai-chat/public/icons/ai_copy_query.svg b/webapp/packages/plugin-ai-chat/public/icons/ai_copy_query.svg new file mode 100644 index 00000000000..3c156f07229 --- /dev/null +++ b/webapp/packages/plugin-ai-chat/public/icons/ai_copy_query.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/webapp/packages/plugin-ai-chat/public/icons/ai_copy_query_m.svg b/webapp/packages/plugin-ai-chat/public/icons/ai_copy_query_m.svg new file mode 100644 index 00000000000..83e981a010e --- /dev/null +++ b/webapp/packages/plugin-ai-chat/public/icons/ai_copy_query_m.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/webapp/packages/plugin-ai-chat/public/icons/ai_copy_query_sm.svg b/webapp/packages/plugin-ai-chat/public/icons/ai_copy_query_sm.svg new file mode 100644 index 00000000000..1ac1f43ced5 --- /dev/null +++ b/webapp/packages/plugin-ai-chat/public/icons/ai_copy_query_sm.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/webapp/packages/plugin-ai-chat/public/icons/ai_history.svg b/webapp/packages/plugin-ai-chat/public/icons/ai_history.svg new file mode 100644 index 00000000000..b0b70fc8a59 --- /dev/null +++ b/webapp/packages/plugin-ai-chat/public/icons/ai_history.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/webapp/packages/plugin-ai-chat/public/icons/ai_history_m.svg b/webapp/packages/plugin-ai-chat/public/icons/ai_history_m.svg new file mode 100644 index 00000000000..965ec8580ea --- /dev/null +++ b/webapp/packages/plugin-ai-chat/public/icons/ai_history_m.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/webapp/packages/plugin-ai-chat/public/icons/ai_history_sm.svg b/webapp/packages/plugin-ai-chat/public/icons/ai_history_sm.svg new file mode 100644 index 00000000000..a9ef25d9624 --- /dev/null +++ b/webapp/packages/plugin-ai-chat/public/icons/ai_history_sm.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/webapp/packages/plugin-ai-chat/public/icons/ai_insert_query.svg b/webapp/packages/plugin-ai-chat/public/icons/ai_insert_query.svg new file mode 100644 index 00000000000..9e18704e5e7 --- /dev/null +++ b/webapp/packages/plugin-ai-chat/public/icons/ai_insert_query.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/webapp/packages/plugin-ai-chat/public/icons/ai_insert_query_m.svg b/webapp/packages/plugin-ai-chat/public/icons/ai_insert_query_m.svg new file mode 100644 index 00000000000..ea18daf8314 --- /dev/null +++ b/webapp/packages/plugin-ai-chat/public/icons/ai_insert_query_m.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/webapp/packages/plugin-ai-chat/public/icons/ai_insert_query_sm.svg b/webapp/packages/plugin-ai-chat/public/icons/ai_insert_query_sm.svg new file mode 100644 index 00000000000..080f216c751 --- /dev/null +++ b/webapp/packages/plugin-ai-chat/public/icons/ai_insert_query_sm.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/webapp/packages/plugin-ai-chat/public/icons/ai_m.svg b/webapp/packages/plugin-ai-chat/public/icons/ai_m.svg new file mode 100644 index 00000000000..e281b50874e --- /dev/null +++ b/webapp/packages/plugin-ai-chat/public/icons/ai_m.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/webapp/packages/plugin-ai-chat/public/icons/ai_sm.svg b/webapp/packages/plugin-ai-chat/public/icons/ai_sm.svg new file mode 100644 index 00000000000..57687517c4d --- /dev/null +++ b/webapp/packages/plugin-ai-chat/public/icons/ai_sm.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/webapp/packages/plugin-ai-chat/public/icons/ai_toggle.svg b/webapp/packages/plugin-ai-chat/public/icons/ai_toggle.svg new file mode 100644 index 00000000000..935b214cd77 --- /dev/null +++ b/webapp/packages/plugin-ai-chat/public/icons/ai_toggle.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/webapp/packages/plugin-ai-chat/public/icons/erase.svg b/webapp/packages/plugin-ai-chat/public/icons/erase.svg new file mode 100644 index 00000000000..f9c4a6f1a73 --- /dev/null +++ b/webapp/packages/plugin-ai-chat/public/icons/erase.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/webapp/packages/plugin-ai-chat/public/icons/erase_m.svg b/webapp/packages/plugin-ai-chat/public/icons/erase_m.svg new file mode 100644 index 00000000000..4b195b23f38 --- /dev/null +++ b/webapp/packages/plugin-ai-chat/public/icons/erase_m.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/webapp/packages/plugin-ai-chat/public/icons/erase_sm.svg b/webapp/packages/plugin-ai-chat/public/icons/erase_sm.svg new file mode 100644 index 00000000000..8cbe87755b6 --- /dev/null +++ b/webapp/packages/plugin-ai-chat/public/icons/erase_sm.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/webapp/packages/plugin-ai-chat/public/icons/scope.svg b/webapp/packages/plugin-ai-chat/public/icons/scope.svg new file mode 100644 index 00000000000..cd2848d2c48 --- /dev/null +++ b/webapp/packages/plugin-ai-chat/public/icons/scope.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/webapp/packages/plugin-ai-chat/public/icons/scope_m.svg b/webapp/packages/plugin-ai-chat/public/icons/scope_m.svg new file mode 100644 index 00000000000..a96914140f0 --- /dev/null +++ b/webapp/packages/plugin-ai-chat/public/icons/scope_m.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/webapp/packages/plugin-ai-chat/public/icons/scope_sm.svg b/webapp/packages/plugin-ai-chat/public/icons/scope_sm.svg new file mode 100644 index 00000000000..b3ecc713609 --- /dev/null +++ b/webapp/packages/plugin-ai-chat/public/icons/scope_sm.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/webapp/packages/plugin-ai-chat/public/icons/send.svg b/webapp/packages/plugin-ai-chat/public/icons/send.svg new file mode 100644 index 00000000000..d06e9da4931 --- /dev/null +++ b/webapp/packages/plugin-ai-chat/public/icons/send.svg @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/webapp/packages/plugin-ai-chat/public/icons/send_m.svg b/webapp/packages/plugin-ai-chat/public/icons/send_m.svg new file mode 100644 index 00000000000..75918be9807 --- /dev/null +++ b/webapp/packages/plugin-ai-chat/public/icons/send_m.svg @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/webapp/packages/plugin-ai-chat/public/icons/send_sm.svg b/webapp/packages/plugin-ai-chat/public/icons/send_sm.svg new file mode 100644 index 00000000000..14efc84a694 --- /dev/null +++ b/webapp/packages/plugin-ai-chat/public/icons/send_sm.svg @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/webapp/packages/plugin-ai-chat/src/AIChat/AIChatContext.ts b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatContext.ts new file mode 100644 index 00000000000..9862e3e7822 --- /dev/null +++ b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatContext.ts @@ -0,0 +1,20 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { createContext } from 'react'; + +import type { IConnectionInfoParams } from '@cloudbeaver/core-connections'; + +interface IAIChatContext { + disabled: boolean; + connectionKey?: IConnectionInfoParams; + catalog?: string; + schema?: string; +} + +export const AIChatContext = createContext({ disabled: false }); diff --git a/webapp/packages/plugin-ai-chat/src/AIChat/AIChatContext/AIChatContextManager.tsx b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatContext/AIChatContextManager.tsx new file mode 100644 index 00000000000..59872be4980 --- /dev/null +++ b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatContext/AIChatContextManager.tsx @@ -0,0 +1,92 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import type { ReactElement } from 'react'; +import { observer } from 'mobx-react-lite'; + +import { isNotNullDefined } from '@dbeaver/js-helpers'; +import { Combobox, useResource, useTranslate } from '@cloudbeaver/core-blocks'; +import { + compareConnectionsInfo, + ConnectionInfoActiveProjectKey, + ConnectionInfoResource, + createConnectionParam, + DBDriverResource, +} from '@cloudbeaver/core-connections'; +import { useService } from '@cloudbeaver/core-di'; +import { CachedMapAllKey } from '@cloudbeaver/core-resource'; + +import { AIChatContextService } from './AIChatContextService.js'; +import { AIChatContextManagerConnectionIcon } from './AIChatContextManagerConnectionIcon.js'; + +interface IItem { + id: string; + label: string; + icon?: ReactElement | string; +} + +const NO_CONTEXT_ID = 'no-context'; + +interface Props { + disabled?: boolean; +} + +export const AIChatContextManager = observer(function AIChatContextManager({ disabled }) { + const translate = useTranslate(); + const aiChatContextService = useService(AIChatContextService); + const dbDriverResource = useResource(AIChatContextManager, DBDriverResource, CachedMapAllKey); + const connectionInfoResource = useResource(AIChatContextManager, ConnectionInfoResource, ConnectionInfoActiveProjectKey); + + const items: IItem[] = [{ id: NO_CONTEXT_ID, label: translate('plugin_ai_chat_no_context'), icon: '/icons/database_sm.svg' }]; + const connections = connectionInfoResource.data.filter(isNotNullDefined).sort((a, b) => { + if (a.connected === b.connected) { + return compareConnectionsInfo(a, b); + } + + return Number(b.connected) - Number(a.connected); + }); + + for (const connection of connections) { + const driver = dbDriverResource.data.find(d => d?.id === connection.driverId); + + items.push({ + id: connection.id, + label: connection.name, + icon: driver?.icon ? : undefined, + }); + } + + const value = aiChatContextService.currentContext?.connectionKey.connectionId ?? NO_CONTEXT_ID; + + function changeContext(contextId: string): void { + if (contextId === NO_CONTEXT_ID) { + aiChatContextService.setContext(null); + } else { + const connection = connectionInfoResource.data.find(c => c?.id === contextId); + + if (connection) { + aiChatContextService.setContext({ connectionKey: createConnectionParam(connection) }); + } + } + } + + return ( +
+ v.id} + valueSelector={v => v.label} + titleSelector={v => v.label} + iconSelector={v => v.icon} + onSelect={changeContext} + /> +
+ ); +}); diff --git a/webapp/packages/plugin-ai-chat/src/AIChat/AIChatContext/AIChatContextManagerConnectionIcon.tsx b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatContext/AIChatContextManagerConnectionIcon.tsx new file mode 100644 index 00000000000..91f461b9d6a --- /dev/null +++ b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatContext/AIChatContextManagerConnectionIcon.tsx @@ -0,0 +1,24 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { observer } from 'mobx-react-lite'; + +import { ConnectionImageWithMask } from '@cloudbeaver/core-blocks'; + +interface Props { + icon: string; + connected: boolean; +} + +export const AIChatContextManagerConnectionIcon = observer(function AIChatContextManagerConnectionIcon({ icon, connected }) { + return ( +
+ +
+ ); +}); diff --git a/webapp/packages/plugin-ai-chat/src/AIChat/AIChatContext/AIChatContextService.ts b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatContext/AIChatContextService.ts new file mode 100644 index 00000000000..344d2b9719a --- /dev/null +++ b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatContext/AIChatContextService.ts @@ -0,0 +1,135 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { makeObservable, observable, reaction, type IReactionDisposer } from 'mobx'; + +import { Bootstrap, injectable } from '@cloudbeaver/core-di'; +import { ProjectsService } from '@cloudbeaver/core-projects'; +import { ConnectionSchemaManagerService } from '@cloudbeaver/plugin-datasource-context-switch'; +import { + createConnectionParam, + isConnectionInfoParamEqual, + type IConnectionExecutionContextInfo, + type IConnectionInfoParams, +} from '@cloudbeaver/core-connections'; +import { SyncExecutor } from '@cloudbeaver/core-executor'; +import { UserInfoResource } from '@cloudbeaver/core-authentication'; + +export interface IAIChatContextInfo { + connectionKey: IConnectionInfoParams; + catalog?: string; + schema?: string; +} + +const SYNC_CONTEXT_DELAY = 500; + +@injectable(() => [ConnectionSchemaManagerService, UserInfoResource, ProjectsService]) +export class AIChatContextService extends Bootstrap { + onContextChange: SyncExecutor; + + get currentContext(): IAIChatContextInfo | null { + return this.context; + } + + private context: IAIChatContextInfo | null = null; + private reactionDisposer: IReactionDisposer | null; + + constructor( + private readonly connectionSchemaManagerService: ConnectionSchemaManagerService, + userInfoResource: UserInfoResource, + projectsService: ProjectsService, + ) { + super(); + + this.onContextChange = new SyncExecutor(); + this.reactionDisposer = null; + + userInfoResource.onUserChange.addHandler(() => { + this.setContext(null); + }); + + projectsService.onActiveProjectChange.addHandler(data => { + if (data.type === 'after' && this.currentContext) { + const hasProject = data.projects.includes(this.currentContext.connectionKey.projectId); + + if (!hasProject) { + this.setContext(null); + } + } + }); + + makeObservable(this, { + context: observable, + }); + } + + setContext(context: IAIChatContextInfo | null): void { + this.context = context; + this.onContextChange.execute(this.context); + } + + getContext(): IAIChatContextInfo | null { + const context = this.connectionSchemaManagerService.activeExecutionContext; + + if (context) { + return this.transformContext(context); + } + + return null; + } + + override register(): void | Promise { + this.reactionDisposer = reaction( + () => this.connectionSchemaManagerService.activeExecutionContext, + context => { + if (context) { + const newContext = this.transformContext(context); + + if (!this.context || !this.isContextsEqual(this.context, newContext)) { + this.setContext(newContext); + } + } + }, + { + delay: SYNC_CONTEXT_DELAY, + equals: (a, b) => { + if (!a || !b) { + return a === b; + } + + const aContext = this.transformContext(a); + const bContext = this.transformContext(b); + + return this.isContextsEqual(aContext, bContext); + }, + }, + ); + } + + private transformContext(executionContext: IConnectionExecutionContextInfo): IAIChatContextInfo { + return { + connectionKey: createConnectionParam(executionContext.projectId, executionContext.connectionId), + catalog: executionContext.defaultCatalog, + schema: executionContext.defaultSchema, + }; + } + + private isContextsEqual(contextA: IAIChatContextInfo, contextB: IAIChatContextInfo): boolean { + return ( + isConnectionInfoParamEqual(contextA.connectionKey, contextB.connectionKey) && + contextA.catalog === contextB.catalog && + contextA.schema === contextB.schema + ); + } + + override dispose(): void { + if (this.reactionDisposer) { + this.reactionDisposer(); + } + } +} diff --git a/webapp/packages/plugin-ai-chat/src/AIChat/AIChatConversation/AIChatConversationMetrics.tsx b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatConversation/AIChatConversationMetrics.tsx new file mode 100644 index 00000000000..2a98c8b9400 --- /dev/null +++ b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatConversation/AIChatConversationMetrics.tsx @@ -0,0 +1,38 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { observer } from 'mobx-react-lite'; + +import { useResource, useTranslate } from '@cloudbeaver/core-blocks'; + +import { AIChatConversationMetricsResource } from './AIChatConversationMetricsResource.js'; + +interface Props { + conversationId: string; +} + +export const AIChatConversationMetrics = observer(function AIChatConversationMetrics({ conversationId }) { + const translate = useTranslate(); + const aiChatConversationMetricsResource = useResource(AIChatConversationMetrics, AIChatConversationMetricsResource, conversationId); + + const input = aiChatConversationMetricsResource.data?.metrics?.totalInputTokens ?? 0; + const output = aiChatConversationMetricsResource.data?.metrics?.totalOutputTokens ?? 0; + + return ( +
+
+
+
{input}↑
+ / +
{output}↓
+
+ {translate('plugin_ai_chat_settings_metrics_tokens')} +
+
+ ); +}); diff --git a/webapp/packages/plugin-ai-chat/src/AIChat/AIChatConversation/AIChatConversationMetricsResource.ts b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatConversation/AIChatConversationMetricsResource.ts new file mode 100644 index 00000000000..ad8e692aff3 --- /dev/null +++ b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatConversation/AIChatConversationMetricsResource.ts @@ -0,0 +1,59 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { injectable } from '@cloudbeaver/core-di'; +import { CachedMapResource, isResourceAlias, resourceKeyList, ResourceKeyUtils, type ResourceKey } from '@cloudbeaver/core-resource'; +import { type AiChatConversationMetricsFragment, GraphQLService } from '@cloudbeaver/core-sdk'; + +import { AIChatMessagesResource } from '../AIChatMessage/AIChatMessagesResource.js'; +import { AIChatConversationsResource } from './AIChatConversationsResource.js'; + +type AIChatConversationMetrics = AiChatConversationMetricsFragment; + +@injectable(() => [GraphQLService, AIChatMessagesResource, AIChatConversationsResource]) +export class AIChatConversationMetricsResource extends CachedMapResource { + constructor( + private readonly graphQLService: GraphQLService, + aiChatMessagesResource: AIChatMessagesResource, + aiChatConversationsResource: AIChatConversationsResource, + ) { + super(); + + this.sync(aiChatConversationsResource); + + aiChatMessagesResource.onMessageStream.addHandler(({ param: { conversationId }, state }) => { + if (state === 'end') { + this.markOutdated(conversationId); + } + }); + } + + protected async loader(originalKey: ResourceKey): Promise> { + if (isResourceAlias(originalKey)) { + throw new Error('Aliases are not supported in this resource'); + } + + const conversationMetrics: AIChatConversationMetrics[] = []; + + await ResourceKeyUtils.forEachAsync(originalKey, async conversationId => { + const { result } = await this.graphQLService.sdk.getChatConversationMetrics({ conversationId }); + + if (result.metrics) { + conversationMetrics.push(result); + } + }); + + this.set(resourceKeyList(conversationMetrics.map(c => c.id)), conversationMetrics); + + return this.data; + } + + protected validateKey(key: string): boolean { + return typeof key === 'string'; + } +} diff --git a/webapp/packages/plugin-ai-chat/src/AIChat/AIChatConversation/AIChatConversationScope/AIChatConversationProfile.tsx b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatConversation/AIChatConversationScope/AIChatConversationProfile.tsx new file mode 100644 index 00000000000..185bd195bd9 --- /dev/null +++ b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatConversation/AIChatConversationScope/AIChatConversationProfile.tsx @@ -0,0 +1,72 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { observer } from 'mobx-react-lite'; + +import { MenuGroup, MenuGroupLabel, MenuItemRadio } from '@dbeaver/ui-kit'; +import { RadioIndicator, useTranslate } from '@cloudbeaver/core-blocks'; +import { useService } from '@cloudbeaver/core-di'; +import { NotificationService } from '@cloudbeaver/core-events'; + +import type { AIChatProfile } from '../../../AIChatProfilesResource.js'; +import type { AIChatConversationInfo } from '../AIChatConversationsResource.js'; +import { AIChatConversationsService } from '../AIChatConversationsService.js'; + +interface Props { + conversation: AIChatConversationInfo; + profiles: AIChatProfile[]; + disabled?: boolean; +} + +export const AIChatConversationProfile = observer(function AIChatConversationProfile({ conversation, profiles, disabled }) { + const translate = useTranslate(); + const notificationService = useService(NotificationService); + const aiChatConversationsService = useService(AIChatConversationsService); + + async function selectProfile(profileId: string) { + try { + await aiChatConversationsService.updateConversationProfile(conversation.id, profileId); + } catch (exception: any) { + notificationService.logException(exception, 'plugin_ai_chat_profile_change_fail'); + } + } + + if (profiles.length === 0) { + return null; + } + + return ( + + {translate('plugin_ai_chat_profile_group')} + + {profiles.map(profile => { + const isCurrent = conversation.profile === profile.id; + + return ( + selectProfile(profile.id)} + > +
+ + {profile.name} +
+
+ ); + })} +
+ ); +}); diff --git a/webapp/packages/plugin-ai-chat/src/AIChat/AIChatConversation/AIChatConversationScope/AIChatConversationScope.tsx b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatConversation/AIChatConversationScope/AIChatConversationScope.tsx new file mode 100644 index 00000000000..98882378969 --- /dev/null +++ b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatConversation/AIChatConversationScope/AIChatConversationScope.tsx @@ -0,0 +1,141 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { observer } from 'mobx-react-lite'; +import { useMemo } from 'react'; + +import { Menu, MenuProvider, MenuButton, MenuItemRadio, MenuGroup, MenuGroupLabel } from '@dbeaver/ui-kit'; +import { ActionIconButton, RadioIndicator, useResource, useTranslate } from '@cloudbeaver/core-blocks'; +import { CommonDialogService, DialogueStateResult } from '@cloudbeaver/core-dialogs'; +import { useService } from '@cloudbeaver/core-di'; +import { ConnectionsManagerService, ContainerResource } from '@cloudbeaver/core-connections'; +import { NotificationService } from '@cloudbeaver/core-events'; +import { AiDatabaseScope } from '@cloudbeaver/core-sdk'; + +import { AIChatConversationScopeCustomDialog } from './AIChatConversationScopeCustom/AIChatConversationScopeCustomDialog.js'; +import { AIChatConversationProfile } from './AIChatConversationProfile.js'; +import { AIChatProfilesResource } from '../../../AIChatProfilesResource.js'; +import type { AIChatConversationInfo } from '../AIChatConversationsResource.js'; +import { AIChatConversationsService } from '../AIChatConversationsService.js'; +import { AIChatConversationScopeResource } from '../AIChatConversationScopeResource.js'; + +interface IScopeItem { + id: AiDatabaseScope; + label: string; +} + +interface Props { + conversation: AIChatConversationInfo; + catalog?: string; + schema?: string; + disabled?: boolean; +} + +export const AIChatConversationScope = observer(function AIChatConversationScope({ conversation, catalog, schema, disabled }) { + const translate = useTranslate(); + const commonDialogService = useService(CommonDialogService); + const notificationService = useService(NotificationService); + const connectionsManagerService = useService(ConnectionsManagerService); + const aiChatConversationsService = useService(AIChatConversationsService); + + const { data: container } = useResource(AIChatConversationScope, ContainerResource, conversation.dataSourceId ?? null); + const { data: currentScope } = useResource(AIChatConversationScope, AIChatConversationScopeResource, conversation.id); + const { data: profiles } = useResource(AIChatConversationScope, AIChatProfilesResource, undefined); + + async function selectScope(scope: AiDatabaseScope) { + if (!conversation.dataSourceId) { + throw new Error('Conversation data source is not defined'); + } + + try { + if (scope === AiDatabaseScope.Custom) { + const nodes = conversation.settings?.customObjectIds ?? []; + const connection = await connectionsManagerService.requireConnection(conversation.dataSourceId); + + if (!connection) { + return; + } + + const { status, result } = await commonDialogService.open(AIChatConversationScopeCustomDialog, { + connectionKey: conversation.dataSourceId, + nodes, + }); + + if (status !== DialogueStateResult.Resolved) { + return; + } + + await aiChatConversationsService.updateConversation(conversation.id, { + settings: { + customObjectIds: result, + }, + }); + } + + await aiChatConversationsService.updateConversationScope(conversation.id, scope); + } catch (exception: any) { + notificationService.logException(exception, 'plugin_ai_chat_scope_change_fail'); + } + } + + const scopes = useMemo(() => { + const result: IScopeItem[] = [{ id: AiDatabaseScope.CurrentDatasource, label: translate('plugin_ai_chat_scope_connection') }]; + const fallback = translate('ui_not_selected'); + + if (container?.supportsCatalogChange) { + const defaultValue = container.defaultCatalog ?? fallback; + result.push({ id: AiDatabaseScope.CurrentDatabase, label: `${translate('plugin_ai_chat_scope_database')} (${catalog ?? defaultValue})` }); + } + + if (container?.supportsSchemaChange) { + const defaultValue = container.defaultSchema ?? fallback; + result.push({ id: AiDatabaseScope.CurrentSchema, label: `${translate('plugin_ai_chat_scope_schema')} (${schema ?? defaultValue})` }); + } + + result.push({ id: AiDatabaseScope.Custom, label: translate('plugin_ai_chat_scope_custom') }); + return result; + }, [container, catalog, schema, translate]); + + return ( + + } /> + + + {translate('plugin_ai_chat_scope_change')} + + {scopes.map(scope => { + const isCurrent = currentScope?.scope === scope.id; + const label = scope.label; + + return ( + selectScope(scope.id)} + > +
+ + {label} +
+
+ ); + })} +
+ + +
+
+ ); +}); diff --git a/webapp/packages/plugin-ai-chat/src/AIChat/AIChatConversation/AIChatConversationScope/AIChatConversationScopeCustom/AIChatConversationScopeCustomDialog.tsx b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatConversation/AIChatConversationScope/AIChatConversationScopeCustom/AIChatConversationScopeCustomDialog.tsx new file mode 100644 index 00000000000..a0195d58991 --- /dev/null +++ b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatConversation/AIChatConversationScope/AIChatConversationScopeCustom/AIChatConversationScopeCustomDialog.tsx @@ -0,0 +1,167 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { observer } from 'mobx-react-lite'; +import { useMemo } from 'react'; + +import { + Button, + CommonDialogBody, + CommonDialogFooter, + CommonDialogHeader, + CommonDialogWrapper, + Translate, + useResource, + useTranslate, +} from '@cloudbeaver/core-blocks'; +import type { DialogComponent } from '@cloudbeaver/core-dialogs'; +import { ConnectionInfoResource, type IConnectionInfoParams } from '@cloudbeaver/core-connections'; +import { EObjectFeature, NavNodeInfoResource, NodeManagerUtils, type NavNode } from '@cloudbeaver/core-navigation-tree'; +import { + Tree, + type INodeRenderer, + Node, + useTreeData, + type INode, + NavigationTreeService, + type ITreeNodeState, +} from '@cloudbeaver/plugin-navigation-tree'; +import { useService } from '@cloudbeaver/core-di'; +import { MetadataMap } from '@cloudbeaver/core-utils'; +import { NotificationService } from '@cloudbeaver/core-events'; + +import { AIChatConversationScopeCustomNodeControl } from './AIChatConversationScopeCustomNodeControl.js'; + +interface IDialogPayload { + connectionKey: IConnectionInfoParams; + nodes: string[]; +} + +export const AIChatConversationScopeCustomDialog: DialogComponent = observer(function AIChatConversationScopeCustomDialog({ + payload, + resolveDialog, + rejectDialog, +}) { + const translate = useTranslate(); + const navigationTreeService = useService(NavigationTreeService); + const navNodeInfoResource = useService(NavNodeInfoResource); + const notificationService = useService(NotificationService); + + const key = payload.connectionKey; + const connectionInfoResource = useResource(AIChatConversationScopeCustomDialog, ConnectionInfoResource, key); + const root = connectionInfoResource.data?.nodePath ?? NodeManagerUtils.connectionIdToConnectionNodeId(key.projectId, key.connectionId); + + const parents = new Set(); + + for (const nodeId of payload.nodes) { + const parent = navNodeInfoResource.getParent(nodeId); + + if (parent && parents.has(parent)) { + continue; + } + + const nodes = navNodeInfoResource.getParents(nodeId); + + for (const node of nodes) { + parents.add(node); + } + } + + const treeState = new MetadataMap(nodeId => { + const selected = payload.nodes.includes(nodeId); + const expanded = parents.has(nodeId) || nodeId === root; + + return { + showInFilter: false, + expanded, + selected, + }; + }); + + const nodeRenderers: INodeRenderer[] = useMemo( + () => [ + (nodeId: string) => { + const navNode = navNodeInfoResource.get(nodeId); + if (navNode) { + return ({ ...props }) => ; + } + return null; + }, + ], + [navNodeInfoResource], + ); + + const treeData = useTreeData({ + rootId: root ?? '', + externalState: treeState, + getNode: (id: string): INode => { + const navNode = navNodeInfoResource.get(id); + if (!navNode) { + throw new Error(`Node ${id} not found`); + } + + return { + name: navNode.name || navNode.uri, + leaf: isLeaf(navNode), + }; + }, + getChildren: (nodeId: string) => navigationTreeService.getChildren(nodeId) || [], + getParent: (nodeId: string) => navNodeInfoResource.getParent(nodeId) || null, + load: async (id: string): Promise => { + try { + await navigationTreeService.loadNestedNodes(id); + } catch (exception: any) { + notificationService.logException(exception); + return Promise.reject(exception); + } + }, + }); + + function apply() { + const result = []; + + for (const [node, selection] of treeState.entries()) { + if (selection.selected) { + result.push(node); + } + } + + resolveDialog(result); + } + + return ( + + + + 30} + emptyPlaceholder={() => ( +
+ +
+ )} + /> +
+ + + + +
+ ); +}); + +function isLeaf(node: NavNode) { + return node.objectFeatures.includes(EObjectFeature.entity); +} diff --git a/webapp/packages/plugin-ai-chat/src/AIChat/AIChatConversation/AIChatConversationScope/AIChatConversationScopeCustom/AIChatConversationScopeCustomNodeControl.tsx b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatConversation/AIChatConversationScope/AIChatConversationScopeCustom/AIChatConversationScopeCustomNodeControl.tsx new file mode 100644 index 00000000000..450787a1be7 --- /dev/null +++ b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatConversation/AIChatConversationScope/AIChatConversationScopeCustom/AIChatConversationScopeCustomNodeControl.tsx @@ -0,0 +1,49 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { forwardRef, use } from 'react'; +import { observer } from 'mobx-react-lite'; + +import { TreeNodeControl, TreeNodeSelect, TreeNodeExpand, TreeNodeIcon, TreeNodeName, TreeNodeContext } from '@cloudbeaver/core-blocks'; +import { useService } from '@cloudbeaver/core-di'; +import { ENodeFeature, NavNodeInfoResource } from '@cloudbeaver/core-navigation-tree'; +import { TreeContext, TreeDataContext, type NodeControlComponent } from '@cloudbeaver/plugin-navigation-tree'; + +const ENABLED_OBJECTS: ENodeFeature[] = [ENodeFeature.objectContainer, ENodeFeature.entity]; + +export const AIChatConversationScopeCustomNodeControl: NodeControlComponent = observer( + forwardRef(function AIChatConversationScopeCustomNodeControl({ nodeId }, ref) { + const data = use(TreeDataContext)!; + const tree = use(TreeContext)!; + const context = use(TreeNodeContext); + const navNodeInfoResource = useService(NavNodeInfoResource); + + const node = data.getNode(nodeId); + const height = tree.getNodeHeight(nodeId); + const navNode = navNodeInfoResource.get(nodeId); + + if (!navNode) { + return null; + } + + const emptyFolder = !node.leaf && context.expanded && data.getChildren(nodeId).length === 0; + const selected = data.getState(navNode.uri).selected; + const disabled = emptyFolder || !navNode.objectFeatures?.some(f => ENABLED_OBJECTS.includes(f as ENodeFeature)); + + return ( + + tree.selectNode(navNode.uri, !selected)} /> + + + +
{navNode.name}
+
+
+ ); + }), +); diff --git a/webapp/packages/plugin-ai-chat/src/AIChat/AIChatConversation/AIChatConversationScopeResource.ts b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatConversation/AIChatConversationScopeResource.ts new file mode 100644 index 00000000000..ea7ce221db3 --- /dev/null +++ b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatConversation/AIChatConversationScopeResource.ts @@ -0,0 +1,66 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { injectable } from '@cloudbeaver/core-di'; +import { CachedMapResource, isResourceAlias, resourceKeyList, ResourceKeyUtils, type ResourceKey } from '@cloudbeaver/core-resource'; +import { type AiDatabaseScope, GraphQLService } from '@cloudbeaver/core-sdk'; + +import { AIChatConversationsResource } from './AIChatConversationsResource.js'; + +export interface IAIChatConversationScope { + id: string; + scope: AiDatabaseScope | null; +} + +@injectable(() => [GraphQLService, AIChatConversationsResource]) +export class AIChatConversationScopeResource extends CachedMapResource { + constructor( + private readonly graphQLService: GraphQLService, + private readonly aiChatConversationsResource: AIChatConversationsResource, + ) { + super(); + + this.sync(this.aiChatConversationsResource); + this.aiChatConversationsResource.onItemDelete.addHandler(this.delete.bind(this)); + } + + async updateScope(conversationId: string, scope: AiDatabaseScope): Promise { + const { conversation } = await this.graphQLService.sdk.updateChatConversation({ + conversationId, + config: { + settings: { + scope, + }, + }, + }); + + this.set(conversation.id, { id: conversation.id, scope: conversation.settings?.scope ?? null }); + return this.get(conversation.id)!; + } + + protected async loader(originalKey: ResourceKey): Promise> { + if (isResourceAlias(originalKey)) { + throw new Error('Alias is not supported in this resource'); + } + + const scopeList: IAIChatConversationScope[] = []; + + await ResourceKeyUtils.forEachAsync(originalKey, async key => { + const { result } = await this.graphQLService.sdk.getChatConversationScope({ conversationId: key }); + scopeList.push({ id: result.id, scope: result.settings?.scope ?? null }); + }); + + this.set(resourceKeyList(scopeList.map(c => c.id)), scopeList); + + return this.data; + } + + protected validateKey(key: string): boolean { + return typeof key === 'string'; + } +} diff --git a/webapp/packages/plugin-ai-chat/src/AIChat/AIChatConversation/AIChatConversationType.tsx b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatConversation/AIChatConversationType.tsx new file mode 100644 index 00000000000..3b96d25f01a --- /dev/null +++ b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatConversation/AIChatConversationType.tsx @@ -0,0 +1,38 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { observer } from 'mobx-react-lite'; + +import { Icon } from '@cloudbeaver/core-blocks'; + +import type { AIChatConversationInfo } from './AIChatConversationsResource.js'; +import { EAIConversationPromptGeneratorId } from '../../EAIConversationPromptGeneratorId.js'; + +const CONVERSATION_TYPE_ICON: Record = { + [EAIConversationPromptGeneratorId.OBJECT_DESCRIBE]: '/icons/ai_conversation_type_describe_object_sm.svg', + [EAIConversationPromptGeneratorId.SQL_EXPLAIN]: '/icons/ai_conversation_type_explain_query_sm.svg', + [EAIConversationPromptGeneratorId.SQL_FIX]: '/icons/ai_conversation_type_fix_error_sm.svg', + [EAIConversationPromptGeneratorId.SQL_EXECUTION_PLAN]: '/icons/ai_conversation_type_explain_query_sm.svg', +}; + +const DEFAULT_CONVERSATION_TYPE_ICON = '/icons/ai_conversation_type_default_sm.svg'; + +interface Props { + conversation: AIChatConversationInfo; +} + +export const AIChatConversationType = observer(function AIChatConversationType({ conversation }) { + const type = conversation.promptGeneratorId as EAIConversationPromptGeneratorId | undefined; + let icon = DEFAULT_CONVERSATION_TYPE_ICON; + + if (type && CONVERSATION_TYPE_ICON[type]) { + icon = CONVERSATION_TYPE_ICON[type]; + } + + return ; +}); diff --git a/webapp/packages/plugin-ai-chat/src/AIChat/AIChatConversation/AIChatConversationsResource.ts b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatConversation/AIChatConversationsResource.ts new file mode 100644 index 00000000000..d5926e7e09b --- /dev/null +++ b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatConversation/AIChatConversationsResource.ts @@ -0,0 +1,124 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { UserInfoResource } from '@cloudbeaver/core-authentication'; +import { isConnectionInfoParamEqual, type IConnectionInfoParams } from '@cloudbeaver/core-connections'; +import { injectable } from '@cloudbeaver/core-di'; +import { + CachedMapResource, + isResourceAlias, + resourceKeyList, + resourceKeyListAliasFactory, + ResourceKeyUtils, + type ResourceKey, +} from '@cloudbeaver/core-resource'; +import { type AiChatConversationFragment, type AiChatConversationInput, GraphQLService } from '@cloudbeaver/core-sdk'; + +import type { EAIConversationPromptGeneratorId } from '../../EAIConversationPromptGeneratorId.js'; + +export type AIChatConversationInfo = Omit; + +export type AIChatConversationConfig = AiChatConversationInput; + +export const ChatConversationConnectionKey = resourceKeyListAliasFactory( + '@ai-chat-conversations/connection', + (connectionKey?: IConnectionInfoParams) => ({ + connectionKey, + }), +); + +@injectable(() => [GraphQLService, UserInfoResource]) +export class AIChatConversationsResource extends CachedMapResource { + constructor( + private readonly graphQLService: GraphQLService, + userInfoResource: UserInfoResource, + ) { + super(); + + userInfoResource.onUserChange.addHandler(() => { + this.clear(); + }); + + this.aliases.add(ChatConversationConnectionKey, param => + resourceKeyList( + this.values + .filter(v => { + if (!param.options.connectionKey) { + return !v.dataSourceId; + } + + return v.dataSourceId && isConnectionInfoParamEqual(v.dataSourceId, param.options.connectionKey); + }) + .map(c => c.id), + ), + ); + } + + async createConversation( + connectionKey: IConnectionInfoParams | null, + generatorId?: EAIConversationPromptGeneratorId, + ): Promise { + const { conversation } = await this.graphQLService.sdk.createChatConversation({ + config: { + dataSourceId: connectionKey ?? undefined, + promptGeneratorId: generatorId, + }, + }); + + this.set(conversation.id, conversation); + return this.get(conversation.id)!; + } + + async deleteConversation(conversationId: string): Promise { + await this.performUpdate(conversationId, undefined, async () => { + await this.graphQLService.sdk.deleteChatConversation({ conversationId }); + this.delete(conversationId); + }); + } + + async updateConversation(conversationId: string, config: AIChatConversationConfig): Promise { + const { conversation } = await this.graphQLService.sdk.updateChatConversation({ + conversationId, + config, + }); + + this.set(conversation.id, conversation); + + return this.get(conversation.id)!; + } + + protected async loader(originalKey: ResourceKey): Promise> { + const conversationList: AIChatConversationInfo[] = []; + + await ResourceKeyUtils.forEachAsync(originalKey, async key => { + const alias = this.aliases.isAlias(key, ChatConversationConnectionKey); + + if (alias) { + const { conversations } = await this.graphQLService.sdk.getChatConversations({ dataSourceId: alias.options.connectionKey }); + conversationList.push(...conversations); + } else if (isResourceAlias(key)) { + throw new Error('Alias is not supported in this resource'); + } else { + const { conversation } = await this.graphQLService.sdk.getChatConversation({ conversationId: key }); + conversationList.push(conversation); + } + }); + + this.set(resourceKeyList(conversationList.map(c => c.id)), conversationList); + + return this.data; + } + + protected validateKey(key: string): boolean { + return typeof key === 'string'; + } +} + +export function compareConversations(a: AIChatConversationInfo, b: AIChatConversationInfo): number { + return new Date(b.time).getTime() - new Date(a.time).getTime(); +} diff --git a/webapp/packages/plugin-ai-chat/src/AIChat/AIChatConversation/AIChatConversationsService.ts b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatConversation/AIChatConversationsService.ts new file mode 100644 index 00000000000..96e5fa06857 --- /dev/null +++ b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatConversation/AIChatConversationsService.ts @@ -0,0 +1,109 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { computed, makeObservable, observable } from 'mobx'; + +import { injectable } from '@cloudbeaver/core-di'; +import { isDefined } from '@dbeaver/js-helpers'; +import { type IConnectionInfoParams } from '@cloudbeaver/core-connections'; +import type { AiDatabaseScope } from '@cloudbeaver/core-sdk'; + +import { + AIChatConversationsResource, + ChatConversationConnectionKey, + compareConversations, + type AIChatConversationConfig, + type AIChatConversationInfo, +} from './AIChatConversationsResource.js'; +import { AIChatContextService } from '../AIChatContext/AIChatContextService.js'; +import { EAIConversationPromptGeneratorId } from '../../EAIConversationPromptGeneratorId.js'; +import { AIChatConversationScopeResource, type IAIChatConversationScope } from './AIChatConversationScopeResource.js'; + +@injectable(() => [AIChatContextService, AIChatConversationsResource, AIChatConversationScopeResource]) +export class AIChatConversationsService { + get defaultConversation(): AIChatConversationInfo | null { + const context = this.aiChatContextService.currentContext; + const conversations = this.aiChatConversationsResource.get(ChatConversationConnectionKey(context?.connectionKey)); + const sorted = conversations.filter(isDefined).sort(compareConversations); + + return sorted[0] ?? null; + } + + get currentConversationId(): string | null { + return this.conversationId ?? this.defaultConversation?.id ?? null; + } + + get processing(): boolean { + const conversationId = this.currentConversationId; + + if (conversationId) { + const conversation = this.aiChatConversationsResource.get(conversationId); + return conversation?.waitingForResponse ?? false; + } + + return false; + } + + private conversationId: string | null; + + constructor( + private readonly aiChatContextService: AIChatContextService, + private readonly aiChatConversationsResource: AIChatConversationsResource, + private readonly aiChatConversationScopeResource: AIChatConversationScopeResource, + ) { + this.conversationId = null; + + this.aiChatContextService.onContextChange.addHandler(() => { + this.selectConversation(null); + }); + + this.aiChatConversationsResource.onItemDelete.addHandler(conversationId => { + if (this.conversationId === conversationId) { + this.selectConversation(null); + } + }); + + makeObservable(this, { + conversationId: observable.ref, + currentConversationId: computed, + defaultConversation: computed, + processing: computed, + }); + } + + selectConversation(conversationId: string | null): void { + if (this.conversationId !== conversationId) { + this.conversationId = conversationId; + } + } + + async deleteConversation(conversationId: string): Promise { + await this.aiChatConversationsResource.deleteConversation(conversationId); + } + + async updateConversation(conversationId: string, config: AIChatConversationConfig): Promise { + const conversation = await this.aiChatConversationsResource.updateConversation(conversationId, config); + return conversation; + } + + async updateConversationScope(conversationId: string, scope: AiDatabaseScope): Promise { + const result = await this.aiChatConversationScopeResource.updateScope(conversationId, scope); + return result; + } + + async updateConversationProfile(conversationId: string, profileId: string): Promise { + const conversation = await this.aiChatConversationsResource.updateConversation(conversationId, { settings: { profile: profileId } }); + return conversation; + } + + async createConversation(key: IConnectionInfoParams | null, generatorId?: EAIConversationPromptGeneratorId): Promise { + const conversation = await this.aiChatConversationsResource.createConversation(key, generatorId); + this.selectConversation(conversation.id); + return conversation; + } +} diff --git a/webapp/packages/plugin-ai-chat/src/AIChat/AIChatConversationsHistory.tsx b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatConversationsHistory.tsx new file mode 100644 index 00000000000..5073a770a74 --- /dev/null +++ b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatConversationsHistory.tsx @@ -0,0 +1,186 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { observer } from 'mobx-react-lite'; + +import { Menu, MenuProvider, MenuItem, MenuButton, MenuGroup, MenuGroupLabel } from '@dbeaver/ui-kit'; +import { isNotNullDefined } from '@dbeaver/js-helpers'; +import { ActionIconButton, ConfirmationDialogDelete, useResource, useTranslate } from '@cloudbeaver/core-blocks'; +import type { TLocalizationToken } from '@cloudbeaver/core-localization'; +import { useService } from '@cloudbeaver/core-di'; +import { CommonDialogService, DialogueStateResult } from '@cloudbeaver/core-dialogs'; +import type { IConnectionInfoParams } from '@cloudbeaver/core-connections'; + +import { AIChatConversationsService } from './AIChatConversation/AIChatConversationsService.js'; +import { + AIChatConversationsResource, + ChatConversationConnectionKey, + compareConversations, + type AIChatConversationInfo, +} from './AIChatConversation/AIChatConversationsResource.js'; +import { AIChatConversationType } from './AIChatConversation/AIChatConversationType.js'; + +interface Props { + currentConnectionKey?: IConnectionInfoParams; + disabled?: boolean; +} + +enum EConversationGroup { + TODAY = 'TODAY', + YESTERDAY = 'YESTERDAY', + LAST_7_DAYS = 'LAST_7_DAYS', + OVER_WEEK_AGO = 'OVER_WEEK_AGO', +} + +export const AIChatConversationsHistory = observer(function AIChatConversationsHistory({ currentConnectionKey, disabled }) { + const translate = useTranslate(); + const aiChatConversationsService = useService(AIChatConversationsService); + const commonDialogService = useService(CommonDialogService); + + const aiChatConversationsResource = useResource( + AIChatConversationsHistory, + AIChatConversationsResource, + ChatConversationConnectionKey(currentConnectionKey), + ); + + const conversations = aiChatConversationsResource.data.filter(isNotNullDefined).sort(compareConversations); + + function selectConversation(conversationId: string): void { + aiChatConversationsService.selectConversation(conversationId); + } + + async function deleteConversation(conversation: AIChatConversationInfo) { + const { status } = await commonDialogService.open(ConfirmationDialogDelete, { + title: 'ui_data_delete_confirmation', + message: translate('ui_delete_confirmation_message', undefined, { item: `"${conversation.caption}"` }), + confirmActionText: 'ui_delete', + }); + + if (status === DialogueStateResult.Rejected) { + return; + } + + await aiChatConversationsService.deleteConversation(conversation.id); + } + + const conversationGroups = Object.entries(groupByDate(conversations)); + + return ( + + + } + /> + + {conversationGroups.map(([group, groupConversations]) => ( + + {translate(getGroupLabel(group as EConversationGroup))} + + {groupConversations.map(conversation => { + const isCurrent = conversation.id === aiChatConversationsService.currentConversationId; + + return ( + selectConversation(conversation.id)} + > +
+
+ + {conversation.caption} +
+ +
+ {isCurrent && ( + {translate('plugin_ai_chat_current_conversation_hint')} + )} + { + e.stopPropagation(); + await deleteConversation(conversation); + }} + /> +
+
+
+ ); + })} +
+ ))} +
+
+ ); +}); + +function groupByDate(items: AIChatConversationInfo[]) { + const result: Record = {}; + + const now = new Date(); + now.setHours(0, 0, 0, 0); + + for (const item of items) { + const itemDate = new Date(item.time); + itemDate.setHours(0, 0, 0, 0); + + const diffTime = Math.abs(now.getTime() - itemDate.getTime()); + const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)); + + const group = getDateGroup(diffDays); + + if (!result[group]) { + result[group] = []; + } + + result[group].push(item); + } + + return result; +} + +function getDateGroup(diffDays: number): EConversationGroup { + if (diffDays === 0) { + return EConversationGroup.TODAY; + } + + if (diffDays === 1) { + return EConversationGroup.YESTERDAY; + } + + if (diffDays <= 7) { + return EConversationGroup.LAST_7_DAYS; + } + + return EConversationGroup.OVER_WEEK_AGO; +} + +function getGroupLabel(group: EConversationGroup): TLocalizationToken { + switch (group) { + case EConversationGroup.TODAY: + return 'plugin_ai_chat_conversation_history_date_group_today'; + case EConversationGroup.YESTERDAY: + return 'plugin_ai_chat_conversation_history_date_group_yesterday'; + case EConversationGroup.LAST_7_DAYS: + return 'plugin_ai_chat_conversation_history_date_group_last_7_days'; + case EConversationGroup.OVER_WEEK_AGO: + return 'plugin_ai_chat_conversation_history_date_group_over_week_ago'; + default: + return 'plugin_ai_chat_conversation_history_date_group_over_week_ago'; + } +} diff --git a/webapp/packages/plugin-ai-chat/src/AIChat/AIChatHeader.module.css b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatHeader.module.css new file mode 100644 index 00000000000..99c55a01eab --- /dev/null +++ b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatHeader.module.css @@ -0,0 +1,20 @@ +.container { + composes: theme-border-color-background from global; + display: flex; + flex-direction: column; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid; +} + +.title { + composes: theme-background-secondary from global; + padding: 4px 8px; + border-radius: var(--theme-form-element-radius); + font-weight: 600; + overflow: hidden; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; +} diff --git a/webapp/packages/plugin-ai-chat/src/AIChat/AIChatHeader.tsx b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatHeader.tsx new file mode 100644 index 00000000000..2b4289eba8e --- /dev/null +++ b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatHeader.tsx @@ -0,0 +1,96 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { observer } from 'mobx-react-lite'; +import { useContext } from 'react'; + +import { ActionIconButton, Fill, RenameDialog, s, useResource, useS, useTranslate } from '@cloudbeaver/core-blocks'; +import { useService } from '@cloudbeaver/core-di'; +import { CommonDialogService, DialogueStateResult } from '@cloudbeaver/core-dialogs'; +import { ConnectionInfoResource } from '@cloudbeaver/core-connections'; + +import { AIChatContextManager } from './AIChatContext/AIChatContextManager.js'; +import { AIChatConversationsResource, type AIChatConversationInfo } from './AIChatConversation/AIChatConversationsResource.js'; +import { AIChatConversationsService } from './AIChatConversation/AIChatConversationsService.js'; +import { AIChatConversationsHistory } from './AIChatConversationsHistory.js'; +import { AIChatContext } from './AIChatContext.js'; +import { AIChatConversationScope } from './AIChatConversation/AIChatConversationScope/AIChatConversationScope.js'; +import { AIChatConversationType } from './AIChatConversation/AIChatConversationType.js'; +import { AISettings } from './AISettings.js'; +import classes from './AIChatHeader.module.css'; +import { EAIConversationPromptGeneratorId } from '../EAIConversationPromptGeneratorId.js'; + +interface Props { + currentConversationId: string | null; +} + +export const AIChatHeader = observer(function AIChatHeader({ currentConversationId }) { + const styles = useS(classes); + const translate = useTranslate(); + const context = useContext(AIChatContext); + const aiChatConversationsService = useService(AIChatConversationsService); + const commonDialogService = useService(CommonDialogService); + + const aiChatConversationResource = useResource(AIChatHeader, AIChatConversationsResource, currentConversationId); + const currentConversation = aiChatConversationResource.data; + + const connectionInfoResource = useResource(AIChatHeader, ConnectionInfoResource, currentConversation?.dataSourceId ?? null); + + async function createConversation() { + await aiChatConversationsService.createConversation(context.connectionKey ?? null); + } + + async function updateCaption(conversation: AIChatConversationInfo) { + const { status, result: caption } = await commonDialogService.open(RenameDialog, { name: conversation.caption }); + + if (status === DialogueStateResult.Resolved && caption !== undefined) { + await aiChatConversationsService.updateConversation(conversation.id, { caption }); + } + } + + const disabled = context.disabled; + const isScope = + currentConversation && + currentConversation.promptGeneratorId !== EAIConversationPromptGeneratorId.OBJECT_DESCRIBE && + connectionInfoResource.data?.connected; + + return ( +
+
+ + +
+ {isScope && ( + + )} + +
+
+ +
+ {currentConversation && ( +
updateCaption(currentConversation)}> + + {currentConversation.caption} +
+ )} + +
+ + +
+
+
+ ); +}); diff --git a/webapp/packages/plugin-ai-chat/src/AIChat/AIChatMessage/AIChatAssistentFormatter/AIChatAssistentFormatter.module.css b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatMessage/AIChatAssistentFormatter/AIChatAssistentFormatter.module.css new file mode 100644 index 00000000000..777d6004653 --- /dev/null +++ b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatMessage/AIChatAssistentFormatter/AIChatAssistentFormatter.module.css @@ -0,0 +1,78 @@ +.markdown h1 { + margin-top: 0; + margin-bottom: 8px; +} + +.markdown h2 { + margin-top: 12px; + margin-bottom: 8px; +} + +.markdown h3 { + margin-top: 10px; + margin-bottom: 8px; +} + +.markdown h4 { + margin-bottom: 8px; +} + +.markdown h5, +.markdown h6 { + margin: 0; +} + +.markdown p { + margin-bottom: 4px; +} + +.markdown hr { + margin-bottom: 8px; + margin-top: 8px; +} + +.markdown blockquote { + margin-top: 8px; + margin-bottom: 8px; +} + +.markdown p:not(:first-child) { + margin-top: 4px; +} + +.markdown ul, +.markdown ol { + margin-bottom: 4px; + padding-left: 14px; +} + +.markdown p + :where(ol, ul) { + margin-top: 0; +} + +.markdown li { + margin-bottom: 4px; + margin-top: 4px; + white-space: normal; +} + +.markdown li p { + display: inline; +} + +.markdown table { + margin: 8px 0; +} + +.markdown li::marker { + font-weight: bold; + color: var(--theme-primary); +} + +.markdown :where(ol, ul) > li > :last-child { + margin-bottom: 0; +} + +.markdown p + :where(ol, ul) { + margin-top: 0; +} diff --git a/webapp/packages/plugin-ai-chat/src/AIChat/AIChatMessage/AIChatAssistentFormatter/AIChatAssistentFormatter.tsx b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatMessage/AIChatAssistentFormatter/AIChatAssistentFormatter.tsx new file mode 100644 index 00000000000..5be562fdd46 --- /dev/null +++ b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatMessage/AIChatAssistentFormatter/AIChatAssistentFormatter.tsx @@ -0,0 +1,56 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { useMemo } from 'react'; +import { observer } from 'mobx-react-lite'; +import { marked } from 'marked'; + +import { clsx } from '@dbeaver/ui-kit'; +import { ActionIconButton, useClipboard, useTranslate } from '@cloudbeaver/core-blocks'; +import type { AiMessage } from '@cloudbeaver/core-sdk'; + +import { MarkdownFormatter } from '../MarkdownFormatter.js'; +import { AIChatMessageActions } from '../AIChatMessageActions.js'; +import classes from './AIChatAssistentFormatter.module.css'; + +interface Props { + message: AiMessage; +} + +export const AIChatAssistentFormatter = observer(function AIChatAssistentFormatter({ message }) { + const translate = useTranslate(); + const copy = useClipboard(); + const blocks = useMemo(() => getBlocks(message.content), [message.content]); + + return ( +
+ {translate('plugin_ai_chat_message_sr_assistant_description')} + {blocks.map((block, index) => { + const key = `${message.id}-block_${index}`; + return ; + })} + + + copy(message.displayMessage || message.content, true)} + /> + +
+ ); +}); + +function getBlocks(markdown: string): string[] { + const tokens = marked.lexer(markdown); + return tokens.map(token => token.raw); +} diff --git a/webapp/packages/plugin-ai-chat/src/AIChat/AIChatMessage/AIChatFunctionFormatter/AIChatFunctionCalls.tsx b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatMessage/AIChatFunctionFormatter/AIChatFunctionCalls.tsx new file mode 100644 index 00000000000..5dcda85fcfd --- /dev/null +++ b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatMessage/AIChatFunctionFormatter/AIChatFunctionCalls.tsx @@ -0,0 +1,90 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import type { PropsWithChildren } from 'react'; +import { observer } from 'mobx-react-lite'; + +import { useService } from '@cloudbeaver/core-di'; +import { Expandable } from '@cloudbeaver/core-blocks'; +import type { AiFunctionCall } from '@cloudbeaver/core-sdk'; + +import { AIChatFunctionsService } from '../../../AIChatFunctionsService.js'; +import { AIChatFunctionParams } from './AIChatFunctionParams.js'; + +interface Props { + calls: AiFunctionCall[]; +} + +export const AIChatFunctionCalls = observer>(function AIChatFunctionCalls({ calls, children }) { + const aiChatFunctionsService = useService(AIChatFunctionsService); + + if (calls.length === 1) { + const call = calls[0]!; + const func = aiChatFunctionsService.getFunction(call.functionName); + const args: Array<[string, any]> = Object.entries(call.arguments || {}); + + return ( +
+ {func?.description && ( +
+ {func.description} +
+ )} + {args.length > 0 && } + {children} +
+ ); + } + + return ( +
+ {calls.map((call, index) => { + const func = aiChatFunctionsService.getFunction(call.functionName); + const name = func?.name || call.functionName; + const args: Array<[string, any]> = Object.entries(call.arguments || {}); + const firstArg = args[0]?.[1]; + + return ( +
+ + + {name} + + {firstArg && ( + + {String(firstArg)} + + )} +
+ } + > +
+ {func?.description && ( +
+ {func.description} +
+ )} + {args.length > 0 && } +
+ +
+ ); + })} + + ); +}); diff --git a/webapp/packages/plugin-ai-chat/src/AIChat/AIChatMessage/AIChatFunctionFormatter/AIChatFunctionConfirmationActions.tsx b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatMessage/AIChatFunctionFormatter/AIChatFunctionConfirmationActions.tsx new file mode 100644 index 00000000000..64f70e5e3ea --- /dev/null +++ b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatMessage/AIChatFunctionFormatter/AIChatFunctionConfirmationActions.tsx @@ -0,0 +1,35 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { observer } from 'mobx-react-lite'; + +import { Button } from '@dbeaver/ui-kit'; +import { useService } from '@cloudbeaver/core-di'; +import { useTranslate } from '@cloudbeaver/core-blocks'; + +import { AIChatMessagesResource, type IAIFunctionConfirmationMessage } from '../AIChatMessagesResource.js'; + +interface Props { + message: IAIFunctionConfirmationMessage; +} + +export const AIChatFunctionConfirmationActions = observer(function AIChatFunctionConfirmationActions({ message }) { + const translate = useTranslate(); + const aiChatMessagesResource = useService(AIChatMessagesResource); + + return ( +
+ + +
+ ); +}); diff --git a/webapp/packages/plugin-ai-chat/src/AIChat/AIChatMessage/AIChatFunctionFormatter/AIChatFunctionConfirmationFormatter.tsx b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatMessage/AIChatFunctionFormatter/AIChatFunctionConfirmationFormatter.tsx new file mode 100644 index 00000000000..1e7e09750da --- /dev/null +++ b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatMessage/AIChatFunctionFormatter/AIChatFunctionConfirmationFormatter.tsx @@ -0,0 +1,112 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { observer } from 'mobx-react-lite'; + +import { clsx } from '@dbeaver/ui-kit'; +import { Expandable, useTranslate } from '@cloudbeaver/core-blocks'; +import { useService } from '@cloudbeaver/core-di'; + +import { type IAIFunctionConfirmationMessage } from '../AIChatMessagesResource.js'; +import { AIChatMessageService, EAiFunctionStatus } from '../AIChatMessageService.js'; +import { AIChatFunctionsService } from '../../../AIChatFunctionsService.js'; +import { AIChatFunctionCalls } from './AIChatFunctionCalls.js'; +import { AIChatFunctionConfirmationActions } from './AIChatFunctionConfirmationActions.js'; + +interface Props { + message: IAIFunctionConfirmationMessage; +} + +const CONTAINER_CLASSES = + 'tw:rounded-(--theme-group-element-radius) tw:bg-(--theme-background)/10 theme-border-color-background tw:border tw:p-2 tw:my-2'; + +export const AIChatFunctionConfirmationFormatter = observer(function AIChatFunctionConfirmationFormatter({ message }) { + const translate = useTranslate(); + const aiChatMessageService = useService(AIChatMessageService); + const aiChatFunctionsService = useService(AIChatFunctionsService); + + const calls = message.functionConfirmation?.functionCalls; + const status = aiChatMessageService.getFunctionStatus(message); + + if (!calls || calls.length === 0) { + return null; + } + + if (status === EAiFunctionStatus.REJECTED) { + const names = calls.map(call => aiChatFunctionsService.getFunction(call.functionName)?.name || call.functionName).join(', '); + + return ( +
+ + + + {names} + +
+ } + > +
1 && CONTAINER_CLASSES)}> + +
+ + + ); + } + + const shouldConfirm = aiChatMessageService.isFunctionConfirmationRequired(message); + + if (!shouldConfirm) { + return null; + } + + if (calls.length === 1) { + const call = calls[0]!; + const func = aiChatFunctionsService.getFunction(call.functionName); + const name = func?.name || call.functionName; + + return ( +
+ +
+ {translate('ui_allow')} + {/* @TODO change to the mcp provider name here and below once implemented */} + CloudBeaver + {translate('plugin_ai_chat_mcp_single_function_request')} + {name} +
+
+ } + > + + + + + + ); + } + + return ( +
+
+
+ CloudBeaver + {translate('plugin_ai_chat_mcp_function_request')} +
+
+ + + +
+ ); +}); diff --git a/webapp/packages/plugin-ai-chat/src/AIChat/AIChatMessage/AIChatFunctionFormatter/AIChatFunctionFormatter.tsx b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatMessage/AIChatFunctionFormatter/AIChatFunctionFormatter.tsx new file mode 100644 index 00000000000..009667f6258 --- /dev/null +++ b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatMessage/AIChatFunctionFormatter/AIChatFunctionFormatter.tsx @@ -0,0 +1,100 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { observer } from 'mobx-react-lite'; + +import { Expandable, Link, useTranslate } from '@cloudbeaver/core-blocks'; +import { useService } from '@cloudbeaver/core-di'; +import type { TranslateFn } from '@cloudbeaver/core-localization'; +import { NotificationService } from '@cloudbeaver/core-events'; +import { AiFunctionType } from '@cloudbeaver/core-sdk'; + +import { UnstyledButton } from '@dbeaver/ui-kit'; + +import type { IAIFunctionMessage } from '../AIChatMessagesResource.js'; +import { AIChatFunctionsService, parseAIFunction, type AIFunctionName, type AIParamsFor } from '../../../AIChatFunctionsService.js'; +import { AIChatFunctionCalls } from './AIChatFunctionCalls.js'; + +interface Props { + message: IAIFunctionMessage; +} + +export const AIChatFunctionFormatter = observer(function AIChatFunctionFormatter({ message }) { + const translate = useTranslate(); + const aiChatFunctionsService = useService(AIChatFunctionsService); + const notificationService = useService(NotificationService); + + if (message.functionResult.type !== AiFunctionType.Action) { + const func = aiChatFunctionsService.getFunction(message.functionCall.functionName); + const name = func?.name || message.functionCall.functionName; + const resultValue = + typeof message.functionResult.value === 'string' ? message.functionResult.value : JSON.stringify(message.functionResult.value); + + return ( +
+ + + + {name} + +
+ } + > + +
+
{translate('plugin_ai_chat_function_result')}
+
+ {resultValue} +
+
+
+ + + ); + } + + function executeFunction(functionName: string, params: unknown) { + try { + aiChatFunctionsService.executeFunction(functionName as AIFunctionName, params as AIParamsFor); + } catch (exception: any) { + notificationService.logException(exception, 'plugin_ai_chat_function_execution_fail'); + } + } + + return ( +
+ } + onClick={() => executeFunction(message.functionCall.functionName, message.functionResult.value)} + > + {getFunctionActionLabel(message.functionCall.functionName, message.functionResult.value, translate)} + +
+ ); +}); + +function getFunctionActionLabel(fn: string, params: unknown, translate: TranslateFn): string { + const parsed = parseAIFunction(fn, params); + + if (!parsed) { + return fn; + } + + switch (parsed.fn) { + case 'db_openTableDataEditor': + return `${translate('ui_open')} "${parsed.params.objectName}"`; + case 'db_openSQLEditor': + return `${translate('ui_open')} ${translate('sql_editor_script_editor')}`; + default: + return translate('plugin_ai_chat_action_unknown'); + } +} diff --git a/webapp/packages/plugin-ai-chat/src/AIChat/AIChatMessage/AIChatFunctionFormatter/AIChatFunctionParams.tsx b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatMessage/AIChatFunctionFormatter/AIChatFunctionParams.tsx new file mode 100644 index 00000000000..cf3a05b6726 --- /dev/null +++ b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatMessage/AIChatFunctionFormatter/AIChatFunctionParams.tsx @@ -0,0 +1,40 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { observer } from 'mobx-react-lite'; + +import { clsx } from '@dbeaver/ui-kit'; +import { useTranslate } from '@cloudbeaver/core-blocks'; + +interface Props { + args: Array<[string, any]>; + className?: string; +} + +export const AIChatFunctionParams = observer(function AIChatFunctionParams({ args, className }) { + const translate = useTranslate(); + + return ( +
+
+ {translate('plugin_ai_chat_function_parameters')} +
+ {args.map(([key, value]) => { + const val = String(value); + return ( +
+
{key}:
+ + {val} + +
+ ); + })} +
+ ); +}); diff --git a/webapp/packages/plugin-ai-chat/src/AIChat/AIChatMessage/AIChatMessage.tsx b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatMessage/AIChatMessage.tsx new file mode 100644 index 00000000000..888bab61e9b --- /dev/null +++ b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatMessage/AIChatMessage.tsx @@ -0,0 +1,31 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { observer } from 'mobx-react-lite'; + +import { clsx } from '@dbeaver/ui-kit'; +import { isSameDay } from '@cloudbeaver/core-utils'; + +import { AIChatMessageFormatter } from './AIChatMessageFormatter/AIChatMessageFormatter.js'; +import type { AiMessage } from '@cloudbeaver/core-sdk'; + +interface Props extends React.HTMLAttributes { + message: AiMessage; +} + +export const AIChatMessage = observer(function AIChatMessage({ message, className, ...rest }) { + const date = new Date(message.time); + const fullTime = date.toLocaleString(); + const displayTime = isSameDay(date, new Date()) ? date.toLocaleTimeString() : fullTime; + + return ( +
+ +
+ ); +}); diff --git a/webapp/packages/plugin-ai-chat/src/AIChat/AIChatMessage/AIChatMessageActions.tsx b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatMessage/AIChatMessageActions.tsx new file mode 100644 index 00000000000..5e419fc52f6 --- /dev/null +++ b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatMessage/AIChatMessageActions.tsx @@ -0,0 +1,18 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { observer } from 'mobx-react-lite'; +import type { PropsWithChildren } from 'react'; + +export const AIChatMessageActions = observer(function AIChatMessageActions(props: PropsWithChildren) { + return ( +
+ {props.children} +
+ ); +}); diff --git a/webapp/packages/plugin-ai-chat/src/AIChat/AIChatMessage/AIChatMessageActionsService.ts b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatMessage/AIChatMessageActionsService.ts new file mode 100644 index 00000000000..1dcd5189a95 --- /dev/null +++ b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatMessage/AIChatMessageActionsService.ts @@ -0,0 +1,234 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { computed, makeObservable } from 'mobx'; + +import { injectable } from '@cloudbeaver/core-di'; +import { createConnectionParam, isConnectionInfoParamEqual, type IConnectionInfoParams } from '@cloudbeaver/core-connections'; +import { NavigationTabsService } from '@cloudbeaver/plugin-navigation-tabs'; +import { isSQLEditorTab, SqlEditorNavigatorService } from '@cloudbeaver/plugin-sql-editor-navigation-tab'; +import { + ESqlDataSourceFeatures, + LocalStorageSqlDataSource, + SqlDataSourceService, + SqlEditorService, + type ILocalStorageSqlDataSourceState, + SqlEditorSettingsService, + type ISqlEditorTabState, +} from '@cloudbeaver/plugin-sql-editor'; +import { NotificationService } from '@cloudbeaver/core-events'; +import { LocalizationService, type TLocalizationToken } from '@cloudbeaver/core-localization'; +import { CommonDialogService, DialogueStateResult } from '@cloudbeaver/core-dialogs'; +import { ConfirmationDialog } from '@cloudbeaver/core-blocks'; + +import { AIChatContextService } from '../AIChatContext/AIChatContextService.js'; +import { AIChatConversationsResource } from '../AIChatConversation/AIChatConversationsResource.js'; + +const AI_CHAT_ID_METADATA_KEY = 'aiChatId'; + +@injectable(() => [ + SqlEditorService, + NavigationTabsService, + SqlEditorNavigatorService, + AIChatContextService, + AIChatConversationsResource, + NotificationService, + SqlDataSourceService, + LocalizationService, + CommonDialogService, + SqlEditorSettingsService, +]) +export class AIChatMessageActionsService { + get isAllowed(): boolean { + return this.sqlEditorSettingsService.scriptExecutionEnabled; + } + get isDisabled(): boolean { + return !this.aiChatContextService.currentContext; + } + + constructor( + private readonly sqlEditorService: SqlEditorService, + private readonly navigationTabsService: NavigationTabsService, + private readonly sqlEditorNavigatorService: SqlEditorNavigatorService, + private readonly aiChatContextService: AIChatContextService, + private readonly aiChatConversationsResource: AIChatConversationsResource, + private readonly notificationService: NotificationService, + private readonly sqlDataSourceService: SqlDataSourceService, + private readonly localizationService: LocalizationService, + private readonly commonDialogService: CommonDialogService, + private readonly sqlEditorSettingsService: SqlEditorSettingsService, + ) { + makeObservable(this, { + isDisabled: computed, + }); + } + + async executeQuery(conversationId: string | undefined | null, connectionKey: IConnectionInfoParams | null, query: string): Promise { + const currentTab = this.navigationTabsService.currentTab; + + const isOnlyResultsTab = this.isApplicableSqlEditorTab(connectionKey, conversationId, 'results'); + + const tabs = [...this.navigationTabsService.findTabs(isOnlyResultsTab)]; + + if (currentTab && isOnlyResultsTab(currentTab)) { + tabs.unshift(currentTab); + } + + if (tabs.length === 0) { + const contextProvider = await this.sqlEditorNavigatorService.openNewEditor({ + connectionKey: connectionKey || undefined, + dataSourceKey: LocalStorageSqlDataSource.key, + query, + name: this.getTabName('plugin_ai_chat_query_result_name', conversationId), + dataSourceState: { showOnlyResults: true } as Partial, + metadata: { + [AI_CHAT_ID_METADATA_KEY]: conversationId, + }, + }); + const tabContext = contextProvider.getContext(this.navigationTabsService.navigationTabContext); + + if (tabContext.tab) { + tabs.push(tabContext.tab); + } + } + + const tab = tabs[0]; + + if (!tab) { + this.notificationService.logError({ title: 'plugin_ai_chat_execute_query_error' }); + return; + } + + if (tab.id !== currentTab?.id) { + this.navigationTabsService.selectTab(tab.id); + } + + const dataSource = this.sqlDataSourceService.get(tab.handlerState.editorId); + const context = dataSource?.executionContext; + + if (!context) { + throw new Error('Execution context is not provided'); + } + + const data = await this.sqlEditorService.parseSQLScript(context.projectId, context.connectionId, query); + const queries = data.queries.map(q => query.substring(q.start, q.end)); + + if (queries.length === 1) { + await this.sqlEditorNavigatorService.executeEditorQuery(tab.id, queries[0]!); + return; + } + + await this.sqlEditorNavigatorService.executeQueries(tab.id, queries); + } + + async insertInEditor(conversationId: string | undefined | null, connectionKey: IConnectionInfoParams | null, query: string): Promise { + const currentTab = this.navigationTabsService.currentTab; + + const isScriptTab = this.isApplicableSqlEditorTab(connectionKey, conversationId, 'editor'); + + const tabs = [...this.navigationTabsService.findTabs(isScriptTab)]; + if (currentTab && isScriptTab(currentTab)) { + tabs.unshift(currentTab); + } + const tab = tabs.find(tab => tab.handlerState.metadata?.[AI_CHAT_ID_METADATA_KEY] === conversationId) ?? tabs[0]; + + if (tab) { + if (tab.id !== currentTab?.id) { + this.navigationTabsService.selectTab(tab.id); + + const { status } = await this.commonDialogService.open(ConfirmationDialog, { + title: 'plugin_ai_chat_query_replace_confirmation_title', + message: 'plugin_ai_chat_query_replace_confirmation_message', + confirmActionText: 'ui_replace', + }); + + if (status === DialogueStateResult.Rejected) { + return; + } + } + + // TODO: this will work only if executed when editor component already mounted, make sure that when switching tabs there is delay + await this.sqlEditorService.updateActiveQuery.execute({ + editorId: tab.handlerState.editorId, + update: { + query, + type: 'replace', + }, + }); + } else { + const contextProvider = await this.sqlEditorNavigatorService.openNewEditor({ + connectionKey: connectionKey || undefined, + dataSourceKey: LocalStorageSqlDataSource.key, + query, + name: this.getTabName('plugin_ai_chat_editor_name', conversationId), + metadata: { + [AI_CHAT_ID_METADATA_KEY]: conversationId, + }, + }); + const tabContext = contextProvider.getContext(this.navigationTabsService.navigationTabContext); + + if (!tabContext.tab) { + this.notificationService.logError({ title: 'plugin_ai_chat_execute_query_error' }); + } + } + } + + private getTabName(prefix: TLocalizationToken, conversationId: string | undefined | null): string { + let name = this.localizationService.translate(prefix); + + if (conversationId) { + const conversation = this.aiChatConversationsResource.get(conversationId); + + if (conversation) { + name += ` (${conversation.caption})`; + } + } + return name; + } + + private isApplicableSqlEditorTab( + connectionKey: IConnectionInfoParams | null, + conversationId: string | undefined | null, + type: 'results' | 'editor', + ) { + return isSQLEditorTab(tab => { + const dataSource = this.sqlDataSourceService.get(tab.handlerState.editorId); + + let isConversationIdMatch = tab.handlerState.metadata?.[AI_CHAT_ID_METADATA_KEY] === conversationId; + + if (type === 'editor') { + // allow to use usual sql editor without attachment to conversation + isConversationIdMatch = isConversationIdMatch || !tab?.handlerState.metadata?.[AI_CHAT_ID_METADATA_KEY]; + } + + if (!isConversationIdMatch) { + return false; + } + + // Check if connection matches + let isSameConnection = false; + if (dataSource?.executionContext && connectionKey) { + const param = createConnectionParam(dataSource.executionContext.projectId, dataSource.executionContext.connectionId); + isSameConnection = isConnectionInfoParamEqual(param, connectionKey); + } else { + isSameConnection = dataSource?.executionContext === connectionKey; + } + + if (!isSameConnection) { + return false; + } + + if (dataSource instanceof LocalStorageSqlDataSource) { + return type === 'results' ? !dataSource.hasFeature(ESqlDataSourceFeatures.script) : dataSource.hasFeature(ESqlDataSourceFeatures.script); + } + + // allow to use usual sql editor for editor only mode + return type === 'results' ? false : true; + }); + } +} diff --git a/webapp/packages/plugin-ai-chat/src/AIChat/AIChatMessage/AIChatMessageEventHandler.ts b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatMessage/AIChatMessageEventHandler.ts new file mode 100644 index 00000000000..25bc864a997 --- /dev/null +++ b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatMessage/AIChatMessageEventHandler.ts @@ -0,0 +1,28 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { injectable } from '@cloudbeaver/core-di'; +import { type ISessionEvent, type SessionEventId, SessionEventSource, TopicEventHandler } from '@cloudbeaver/core-root'; +import { CbEventTopic, type WsaiChatMessageChunkEvent, type WsaiChatMessageErrorEvent, type WsaiChatMessageEvent } from '@cloudbeaver/core-sdk'; + +export type IAiChatMessageChunkEvent = WsaiChatMessageChunkEvent; +export type IAiChatMessageErrorEvent = WsaiChatMessageErrorEvent; +export type IAiChatMessageEvent = WsaiChatMessageEvent; + +type Event = IAiChatMessageChunkEvent | IAiChatMessageErrorEvent | IAiChatMessageEvent; + +@injectable(() => [SessionEventSource]) +export class AIChatMessageEventHandler extends TopicEventHandler, SessionEventId, string> { + constructor(sessionEventSource: SessionEventSource) { + super(CbEventTopic.CbAi, sessionEventSource); + } + + map(event: any): Event { + return event; + } +} diff --git a/webapp/packages/plugin-ai-chat/src/AIChat/AIChatMessage/AIChatMessageForm.module.css b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatMessage/AIChatMessageForm.module.css new file mode 100644 index 00000000000..36d7b927787 --- /dev/null +++ b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatMessage/AIChatMessageForm.module.css @@ -0,0 +1,32 @@ +.container { + composes: theme-border-color-background from global; + padding: 8px; + border-top: 1px solid; +} + +.textareaContainer { + composes: theme-border-color-background from global; + display: flex; + align-items: center; + flex: 1; + border: 2px solid; + border-radius: 8px; + background: var(--theme-surface); +} + +.textareaContainer button { + width: 28px !important; + height: 28px !important; + margin-right: 8px !important; +} + +.textarea { + display: flex; + flex: 1; +} + +.textarea textarea { + border: none; + resize: none; + background: none; +} diff --git a/webapp/packages/plugin-ai-chat/src/AIChat/AIChatMessage/AIChatMessageForm.tsx b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatMessage/AIChatMessageForm.tsx new file mode 100644 index 00000000000..949bd2babee --- /dev/null +++ b/webapp/packages/plugin-ai-chat/src/AIChat/AIChatMessage/AIChatMessageForm.tsx @@ -0,0 +1,76 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { observer } from 'mobx-react-lite'; +import { useContext, useState } from 'react'; + +import { ActionIconButton, Form, s, Textarea, useS, useTranslate } from '@cloudbeaver/core-blocks'; +import { useService } from '@cloudbeaver/core-di'; +import { getOS, OperatingSystem } from '@cloudbeaver/core-utils'; +import { NotificationService } from '@cloudbeaver/core-events'; + +import { AIChatMessageService } from './AIChatMessageService.js'; +import { AIChatConversationsService } from '../AIChatConversation/AIChatConversationsService.js'; +import { AIChatContext } from '../AIChatContext.js'; +import classes from './AIChatMessageForm.module.css'; + +interface Props { + currentConversationId: string | null; +} + +export const AIChatMessageForm = observer(function AIChatMessageForm({ currentConversationId }) { + const styles = useS(classes); + const translate = useTranslate(); + const context = useContext(AIChatContext); + const notificationService = useService(NotificationService); + const aiChatMessageService = useService(AIChatMessageService); + const aiChatConversationsService = useService(AIChatConversationsService); + + const [value, setValue] = useState(''); + + async function sendMessage() { + let conversationId = currentConversationId; + + try { + if (!conversationId) { + const conversation = await aiChatConversationsService.createConversation(context.connectionKey ?? null); + conversationId = conversation.id; + } + + const v = value.trim(); + + const message = await aiChatMessageService.sendMessage(conversationId, v); + + if (message) { + setValue(''); + } + } catch (exception: any) { + notificationService.logException(exception, 'plugin_ai_chat_send_message_error'); + } + } + + function getPlaceholder() { + const OS = getOS(); + const symbol = OS === OperatingSystem.macOS ? '⌘' : 'Ctrl'; + + return translate('plugin_ai_chat_submit_message_placeholder', undefined, { symbol }); + } + + const disabled = !value.trim() || context.disabled; + + return ( +
+
+
+