mirror of
https://github.com/dbeaver/cloudbeaver.git
synced 2026-10-16 14:50:15 +08:00
Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24ee5298f4 | ||
|
|
707cb3ea41 | ||
|
|
5be49b8a7e | ||
|
|
317750ea7d | ||
|
|
5fb7234e24 | ||
|
|
7a93d56459 | ||
|
|
bd1e926929 | ||
|
|
170650e6c2 | ||
|
|
ffc48e1979 | ||
|
|
c4b0dd4650 | ||
|
|
2d955087ea | ||
|
|
b5289c7865 | ||
|
|
740599f41d | ||
|
|
16915940ba | ||
|
|
1c789cf49b | ||
|
|
9474ec9718 | ||
|
|
46a07033ad | ||
|
|
eb907e95f4 | ||
|
|
cb01f6a988 | ||
|
|
b2065ffe62 | ||
|
|
9e8f4b83a6 | ||
|
|
5170fa7dfc | ||
|
|
ad26e0e849 | ||
|
|
eed9cd979e | ||
|
|
1b8ea64cd0 | ||
|
|
fb43b0f567 | ||
|
|
317ba5486a | ||
|
|
4d4049fcd1 | ||
|
|
27cfdb405f | ||
|
|
f1dc4e07bb | ||
|
|
57cc6e4808 | ||
|
|
6bfe58506f | ||
|
|
1d6e5c6ce6 |
@@ -28,6 +28,43 @@ You can see a live demo of CloudBeaver server here: https://demo.cloudbeaver.io
|
||||
|
||||
## Changelog
|
||||
|
||||
### 26.1.4 2026-08-03
|
||||
|
||||
- AI Assistant:
|
||||
- Added AI Chat to help users generate and fix queries and explore data more easily. It’s integrated with the SQL Editor to run generated queries instantly. Access it from the top toolbar. All AI features can be turned off in the Server configuration.
|
||||
- Added the option to configure AI profiles in the Administration panel, usable in the AI Chat and for the @ai command in the SQL Editor. Both OpenAI and Copilot engines can be utilized for configuration.
|
||||
- Connectivity:
|
||||
- Resolved the issue where the application could no longer connect to the database after running for an extended period.
|
||||
- Resolved the issue where user connection settings required re-login in a new session to apply their changes.
|
||||
- Accessibility:
|
||||
- Added a loader to the Save button in the Administration panel for visualizing the loading process.
|
||||
- Improved drag-and-drop in the Navigator Tree for objects by enlarging the auto-scroll trigger areas.
|
||||
- General:
|
||||
- Added the ability to export generated SQL to a .sql file from the Generate SQL dialog.
|
||||
- Improved "Open in SQL Editor" behavior by preserving the source table context for the Generate SQL dialog.
|
||||
- Fixed synchronization between instances eliminates the need to refresh to see the updated connection or folder in another instance.
|
||||
- Security:
|
||||
- Added a new "Auto-create users" setting for the reverse proxy provider, allowing new user creation on login. It is enabled by default.
|
||||
- Updated the PostgreSQL JDBC driver to version 42.7.13, including a fix for a high-severity security vulnerability (CVE-2026-54291).
|
||||
- Fixed the critical vulnerability (CVE-2026-59873) in the tar library. The library was updated to version 7.5.19.
|
||||
- Fixed the high vulnerability (CVE-2026-10050) in the jetty-security library. The library was updated to version 12.1.11.
|
||||
- Fixed the high vulnerability (CVE-2026-14257) in the brace-expansion library. The library was updated to version 5.0.8.
|
||||
- Fixed the high vulnerability (GHSA-r28c-9q8g-f849) in the postcss library. The library was updated to version 8.5.18.
|
||||
- Fixed the high vulnerability (GHSA-pm4m-ph32-ghv5) in the js-yaml library. The library was updated to version 5.2.2.
|
||||
- Fixed the high vulnerability (CVE-2026-59880) in the immutable library. The library was updated to version 5.1.8.
|
||||
- Fixed the high vulnerability (CVE-2026-59879) in the fast-uri library. The library was updated to version 3.1.3.
|
||||
- Fixed the high vulnerability (CVE-2026-13311) in the shell-quote library. The library was updated to version 1.9.0.
|
||||
- Fixed the high vulnerability (GHSA-gcfj-64vw-6mp9) in the axios library. The library was updated to version 1.18.0.
|
||||
- Databases and drivers:
|
||||
- ClickHouse
|
||||
- The driver was updated to version 0.10.0.
|
||||
- Improved JSON display in the Value panel.
|
||||
- MySQL: Fixed support for comments starting with '#' symbol in the SQL Editor.
|
||||
- PostgreSQL:
|
||||
- The driver was updated to version 42.7.13.
|
||||
- Fixed an error that occurred when applying filters to database nodes.
|
||||
- Fixed generated calls for procedures with OUT parameters.
|
||||
|
||||
### 26.1.3 2026-07-20
|
||||
|
||||
- Data Editor:
|
||||
|
||||
@@ -42,63 +42,58 @@ import java.util.*;
|
||||
* Web connection info
|
||||
*/
|
||||
public class WebPropertyInfo {
|
||||
private WebSession session;
|
||||
private DBPPropertyDescriptor property;
|
||||
private DBPPropertySource propertySource;
|
||||
private boolean showProtected;
|
||||
@NotNull
|
||||
private final WebSession session;
|
||||
@NotNull
|
||||
private final DBPPropertyDescriptor property;
|
||||
@Nullable
|
||||
private final DBPPropertySource propertySource;
|
||||
|
||||
private Object[] validValues;
|
||||
|
||||
private String[] supportedConfigurationTypes = new String[0];
|
||||
|
||||
private Object defaultValue;
|
||||
|
||||
public WebPropertyInfo(WebSession session, DBPPropertyDescriptor property, DBPPropertySource propertySource) {
|
||||
public WebPropertyInfo(
|
||||
@NotNull WebSession session,
|
||||
@NotNull DBPPropertyDescriptor property,
|
||||
@Nullable DBPPropertySource propertySource
|
||||
) {
|
||||
this.session = session;
|
||||
this.property = property;
|
||||
this.propertySource = propertySource;
|
||||
}
|
||||
|
||||
public WebPropertyInfo(WebSession session, DBPPropertyDescriptor property) {
|
||||
this.session = session;
|
||||
this.property = property;
|
||||
}
|
||||
|
||||
public boolean isShowProtected() {
|
||||
return showProtected;
|
||||
}
|
||||
|
||||
public void setShowProtected(boolean showProtected) {
|
||||
this.showProtected = showProtected;
|
||||
public WebPropertyInfo(@NotNull WebSession session, @NotNull DBPPropertyDescriptor property) {
|
||||
this(session, property, null);
|
||||
}
|
||||
|
||||
///////////////////////////////////
|
||||
// General properties
|
||||
///////////////////////////////////
|
||||
|
||||
@NotNull
|
||||
@Property
|
||||
public String getId() {
|
||||
return CommonUtils.toString(property.getId());
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Property
|
||||
public String getDisplayName() {
|
||||
if (property instanceof DBPNamedObjectLocalized) {
|
||||
return ((DBPNamedObjectLocalized) property).getLocalizedName(session.getLocale());
|
||||
if (property instanceof DBPNamedObjectLocalized localized) {
|
||||
return localized.getLocalizedName(session.getLocale());
|
||||
} else {
|
||||
return property.getDisplayName();
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Property
|
||||
public String getDescription() {
|
||||
if (property instanceof DBPObjectWithDescriptionLocalized) {
|
||||
return ((DBPObjectWithDescriptionLocalized) property).getLocalizedDescription(session.getLocale());
|
||||
if (property instanceof DBPObjectWithDescriptionLocalized localized) {
|
||||
return localized.getLocalizedDescription(session.getLocale());
|
||||
} else {
|
||||
return property.getDescription();
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Property
|
||||
public String getHint() {
|
||||
return property.getHint();
|
||||
@@ -109,38 +104,47 @@ public class WebPropertyInfo {
|
||||
return property instanceof ObjectPropertyDescriptor ? ((ObjectPropertyDescriptor) property).getOrderNumber() : -1;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Property
|
||||
public String getCategory() {
|
||||
return property.getCategory();
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Property
|
||||
public String getType() {
|
||||
return getDataType();
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Property
|
||||
public String getDataType() {
|
||||
Class<?> dataType = property.getDataType();
|
||||
return dataType == null ? null : dataType.getSimpleName();
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Property
|
||||
public PropertyLength getLength() {
|
||||
return property.getLength();
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Property
|
||||
public Object getDefaultValue() throws DBException {
|
||||
var defaultValue = property.getDefaultValue() == null ? this.defaultValue : property.getDefaultValue();
|
||||
return defaultValue == null ? getValue() : defaultValue;
|
||||
Object defaultValue = property.getDefaultValue();
|
||||
if (property instanceof ObjectPropertyDescriptor && defaultValue == null) {
|
||||
return getValue();
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Property
|
||||
public Object getValue() throws DBException {
|
||||
Object value = propertySource == null ? null : propertySource.getPropertyValue(session.getProgressMonitor(), property.getId());
|
||||
if (property instanceof ObjectPropertyDescriptor opd &&
|
||||
(!showProtected && opd.isPassword() || opd.isHidden())
|
||||
(opd.isPassword() || opd.isHidden())
|
||||
) {
|
||||
return maskValue(value);
|
||||
} else if (property instanceof PropertyDescriptor pd) {
|
||||
@@ -152,6 +156,7 @@ public class WebPropertyInfo {
|
||||
return value == null ? null : makePropertyValue(value);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Property
|
||||
public Object[] getValidValues() {
|
||||
if (property instanceof IPropertyValueListProvider) {
|
||||
@@ -164,17 +169,18 @@ public class WebPropertyInfo {
|
||||
}
|
||||
return validValues;
|
||||
}
|
||||
return validValues;
|
||||
}
|
||||
return validValues;
|
||||
return null;
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Property
|
||||
public String[] getFeatures() {
|
||||
String[] features = property.getFeatures();
|
||||
return features == null ? new String[0] : features;
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Property
|
||||
public String[] getSupportedConfigurationTypes() {
|
||||
if (property instanceof ProviderPropertyDescriptor) {
|
||||
@@ -182,7 +188,7 @@ public class WebPropertyInfo {
|
||||
.map(DBPDriverConfigurationType::toString)
|
||||
.toArray(String[]::new);
|
||||
}
|
||||
return supportedConfigurationTypes;
|
||||
return new String[0];
|
||||
}
|
||||
|
||||
@Property
|
||||
@@ -203,12 +209,14 @@ public class WebPropertyInfo {
|
||||
return false;
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Override
|
||||
public String toString() {
|
||||
return CommonUtils.toString(property.getId());
|
||||
}
|
||||
|
||||
private Object makePropertyValue(Object value) {
|
||||
@Nullable
|
||||
private Object makePropertyValue(@Nullable Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
// } else if (value instanceof DBSObject) {
|
||||
@@ -290,21 +298,6 @@ public class WebPropertyInfo {
|
||||
return conditions;
|
||||
}
|
||||
|
||||
|
||||
//TODO: delete after refactoring on front-end
|
||||
public void setDefaultValue(String defaultValue) {
|
||||
this.defaultValue = defaultValue;
|
||||
}
|
||||
//TODO: delete after refactoring on front-end
|
||||
public void setValidValues(Object[] validValues) {
|
||||
this.validValues = validValues;
|
||||
}
|
||||
|
||||
//TODO: delete after refactoring on front-end
|
||||
public void setSupportedConfigurationTypes(String[] supportedConfigurationTypes) {
|
||||
this.supportedConfigurationTypes = supportedConfigurationTypes;
|
||||
}
|
||||
|
||||
public record Condition(@NotNull String expression, @NotNull Type conditionType) {
|
||||
public enum Type {
|
||||
HIDE,
|
||||
|
||||
+18
-2
@@ -1028,8 +1028,24 @@ public class LocalResourceController extends BaseLocalResourceController {
|
||||
try {
|
||||
while (resourcePath.startsWith("/")) resourcePath = resourcePath.substring(1);
|
||||
Path targetPath = projectPath.resolve(resourcePath).normalize();
|
||||
if (!targetPath.startsWith(projectPath)) {
|
||||
throw new DBException("Invalid resource path");
|
||||
// hack for custom path implementations that returns / as root path
|
||||
Iterator<Path> projectParts = projectPath.iterator();
|
||||
Iterator<Path> targetParts = targetPath.iterator();
|
||||
Iterator<Path> unnormalizedTargetParts = projectPath.resolve(resourcePath).iterator();
|
||||
while (projectParts.hasNext()) {
|
||||
if (!targetParts.hasNext()) {
|
||||
throw new DBException("Invalid resource path");
|
||||
}
|
||||
Path projectPart = projectParts.next();
|
||||
Path targetPart = targetParts.next();
|
||||
unnormalizedTargetParts.next();
|
||||
if (!projectPart.equals(targetPart)) {
|
||||
throw new DBException("Invalid resource path");
|
||||
}
|
||||
}
|
||||
// validating unnormalized target parts to avoid invalid characters in resource names
|
||||
while (unnormalizedTargetParts.hasNext()) {
|
||||
GeneralUtils.validateResourceNameUnconditionally(unnormalizedTargetParts.next().toString());
|
||||
}
|
||||
return targetPath;
|
||||
} catch (InvalidPathException e) {
|
||||
|
||||
+6
@@ -59,6 +59,7 @@ public class WebAuthProviderDescriptor extends AbstractDescriptor {
|
||||
private final boolean isPrivate;
|
||||
private final boolean isAuthHidden;
|
||||
private final boolean isCaseInsensitive;
|
||||
private final boolean sameTabRedirectOnLogout;
|
||||
private final boolean serviceProvider;
|
||||
private final String[] requiredFeatures;
|
||||
private final boolean isRequired;
|
||||
@@ -75,6 +76,7 @@ public class WebAuthProviderDescriptor extends AbstractDescriptor {
|
||||
this.isRequired = CommonUtils.toBoolean(cfg.getAttribute(WebRegistryConstant.ATTR_REQUIRED));
|
||||
this.isAuthHidden = CommonUtils.toBoolean(cfg.getAttribute(WebRegistryConstant.ATTR_AUTH_HIDDEN));
|
||||
this.isCaseInsensitive = CommonUtils.toBoolean(cfg.getAttribute(WebRegistryConstant.ATTR_CASE_INSENSITIVE));
|
||||
this.sameTabRedirectOnLogout = CommonUtils.toBoolean(cfg.getAttribute(WebRegistryConstant.ATTR_SAME_TAB_LOGOUT_REDIRECT));
|
||||
this.serviceProvider = CommonUtils.toBoolean(cfg.getAttribute(WebRegistryConstant.ATTR_SERVICE_PROVIDER));
|
||||
|
||||
for (IConfigurationElement cfgElement : cfg.getChildren(WebRegistryConstant.TAG_CONFIGURATION)) {
|
||||
@@ -129,6 +131,10 @@ public class WebAuthProviderDescriptor extends AbstractDescriptor {
|
||||
return trusted;
|
||||
}
|
||||
|
||||
public boolean isSameTabRedirectOnLogout() {
|
||||
return sameTabRedirectOnLogout;
|
||||
}
|
||||
|
||||
public boolean isPrivate() {
|
||||
return isPrivate;
|
||||
}
|
||||
|
||||
+1
@@ -29,6 +29,7 @@ public class WebRegistryConstant {
|
||||
public static final String ATTR_REQUIRED = "required";
|
||||
public static final String ATTR_AUTH_HIDDEN = "authHidden";
|
||||
public static final String ATTR_CASE_INSENSITIVE = "caseInsensitive";
|
||||
public static final String ATTR_SAME_TAB_LOGOUT_REDIRECT = "sameTabRedirectOnLogout";
|
||||
public static final String ATTR_REQUIRED_FEATURES = "requiredFeatures";
|
||||
public static final String ATTR_CATEGORIES = "categories";
|
||||
public static final String ATTR_SERVICE_PROVIDER = "serviceProvider";
|
||||
|
||||
@@ -235,6 +235,14 @@ public class ServletAppUtils {
|
||||
response.addCookie(sessionCookie);
|
||||
}
|
||||
|
||||
public static String getHeaderOrLegacy(HttpServletRequest request, String headerName, String legacyHeaderName) {
|
||||
String value = request.getHeader(headerName);
|
||||
if (CommonUtils.isEmpty(value)) {
|
||||
value = request.getHeader(legacyHeaderName);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
public static String getRequestCookie(HttpServletRequest request, String cookieName) {
|
||||
Cookie[] cookies = request.getCookies();
|
||||
if (cookies != null) {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
</extension>
|
||||
|
||||
<extension point="io.cloudbeaver.feature">
|
||||
<feature id="ai" label="AI Integration" description="Enables AI Chat in the application."/>
|
||||
<feature id="ai" label="AI Integration" description="Enables AI Chat in the application." enabledByDefault="true"/>
|
||||
</extension>
|
||||
|
||||
<extension point="org.jkiss.dbeaver.application">
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
<driver id="generic:trino_jdbc"/>
|
||||
<driver id="duckdb:duckdb_jdbc"/>
|
||||
<driver id="generic:kyuubi_hive"/>
|
||||
<driver id="databend:databend"/>
|
||||
<driver id="databend:databend_jdbc"/>
|
||||
</extension>
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
####################################################
|
||||
# Core API
|
||||
####################################################
|
||||
|
||||
extend type ServerConfig {
|
||||
serverURL: String! @deprecated
|
||||
rootURI: String!
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
# Any object (JSON)
|
||||
####################################################
|
||||
# Base schema
|
||||
####################################################
|
||||
|
||||
"Any object (JSON)"
|
||||
scalar Object
|
||||
# Date/Time
|
||||
"Date/Time"
|
||||
scalar DateTime
|
||||
scalar Date
|
||||
|
||||
|
||||
@@ -556,7 +556,7 @@ type ConnectionInfo {
|
||||
supportedDataFormats: [ ResultDataFormat! ]!
|
||||
configurationType: DriverConfigurationType
|
||||
|
||||
#Access properties
|
||||
"Access properties"
|
||||
canViewSettings: Boolean!
|
||||
canEdit: Boolean!
|
||||
canDelete: Boolean!
|
||||
@@ -737,7 +737,7 @@ input ConnectionConfig {
|
||||
"ID of database driver"
|
||||
driverId: ID
|
||||
|
||||
# Custom connection parameters (all optional)
|
||||
"Custom connection parameters (all optional)"
|
||||
host: String
|
||||
port: String
|
||||
serverName: String
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
####################################################
|
||||
# Datasource events
|
||||
####################################################
|
||||
|
||||
extend enum CBServerEventId {
|
||||
|
||||
"Datasource created"
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
####################################################
|
||||
# SQL events
|
||||
####################################################
|
||||
|
||||
extend enum CBServerEventId {
|
||||
"Database output log updated (e.g., DBMS in Oracle)"
|
||||
cb_database_output_log_updated,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
####################################################
|
||||
# Nodes, objects and properties
|
||||
####################################################
|
||||
|
||||
type ObjectDescriptor {
|
||||
id: Int
|
||||
@@ -143,7 +145,7 @@ extend type Query {
|
||||
"Refreshes node based on the node path"
|
||||
navRefreshNode( nodePath: ID! ): Boolean @deprecated # Use navReloadNode method from Mutation (24.2.4)
|
||||
|
||||
# contextId currently not using
|
||||
"contextId currently not using"
|
||||
navGetStructContainers( projectId: ID, connectionId: ID!, contextId: ID, catalog: ID ): DatabaseStructContainers!
|
||||
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ type SQLCompletionProposal {
|
||||
# Data read / SQL execution
|
||||
####################################################
|
||||
|
||||
# SQL context must be created for each SQL editor
|
||||
"SQL context must be created for each SQL editor"
|
||||
type SQLContextInfo {
|
||||
|
||||
id: ID!
|
||||
@@ -153,10 +153,12 @@ type SQLResultSet {
|
||||
"Returns list of rows in the result set. Each row contains data and metadata"
|
||||
rowsWithMetaData: [SQLResultRowMetaData] @since(version: "23.3.5")
|
||||
|
||||
# True means that resultset was generated by single entity query
|
||||
# New rows can be added, old rows can be deleted
|
||||
"""
|
||||
True means that resultset was generated by single entity query
|
||||
New rows can be added, old rows can be deleted
|
||||
"""
|
||||
singleEntity: Boolean!
|
||||
# server always returns hasMoreData = false
|
||||
"server always returns hasMoreData = false"
|
||||
hasMoreData: Boolean!
|
||||
"Identifies if result set has row identifier. If true then it is possible update data or load LOB files"
|
||||
hasRowIdentifier: Boolean! @deprecated (reason: "use rowIdentifierState (25.3.4)")
|
||||
@@ -213,7 +215,7 @@ type SQLQueryResults {
|
||||
updateRowCount: Float
|
||||
sourceQuery: String
|
||||
|
||||
# Actual data format of this result
|
||||
"Actual data format of this result"
|
||||
dataFormat: ResultDataFormat
|
||||
|
||||
resultSet: SQLResultSet
|
||||
@@ -221,17 +223,17 @@ type SQLQueryResults {
|
||||
|
||||
type SQLExecuteInfo {
|
||||
|
||||
# Status message
|
||||
"Status message"
|
||||
statusMessage: String
|
||||
# Execute time (ms)
|
||||
"Execute time (ms)"
|
||||
duration: Int!
|
||||
# Actual conditions applied to query
|
||||
"Actual conditions applied to query"
|
||||
filterText: String
|
||||
# Full query that was executed, contains all used filters
|
||||
"Full query that was executed, contains all used filters"
|
||||
fullQuery: String
|
||||
# Original query that was executed, without modifications
|
||||
"Original query that was executed, without modifications"
|
||||
originalQuery: String
|
||||
# Results
|
||||
"Results"
|
||||
results: [ SQLQueryResults! ]!
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -168,7 +168,7 @@ public class CBEventsLongPollingServlet extends HttpServlet {
|
||||
log.debug("LP request rejected: access token expired");
|
||||
sendError(resp, HttpConstants.CODE_TOKEN_EXPIRED, e.getMessage());
|
||||
} catch (SecurityException e) {
|
||||
log.warn("LP session resolve failed", e);
|
||||
log.debug("LP request rejected: " + e.getMessage());
|
||||
sendError(resp, HttpConstants.CODE_UNAUTHORIZED, e.getMessage());
|
||||
}
|
||||
return null;
|
||||
@@ -215,6 +215,7 @@ public class CBEventsLongPollingServlet extends HttpServlet {
|
||||
|
||||
ps.handleWebSessionEvent(new WSSocketConnectedEvent(ws.getApplication().getApplicationRunId()));
|
||||
resp.setHeader(WSConstants.WS_SESSION_HEADER, sid);
|
||||
resp.setHeader(WSConstants.WS_SESSION_HEADER_LEGACY, sid);
|
||||
log.debug("HTTP Long-Poll channel opened for session " + sid);
|
||||
|
||||
return ps;
|
||||
@@ -304,7 +305,7 @@ public class CBEventsLongPollingServlet extends HttpServlet {
|
||||
@Override
|
||||
public BaseWebSession resolve(@NotNull HttpServletRequest req) throws SMException {
|
||||
|
||||
String sid = req.getHeader(WSConstants.WS_SESSION_HEADER);
|
||||
String sid = ServletAppUtils.getHeaderOrLegacy(req, WSConstants.WS_SESSION_HEADER, WSConstants.WS_SESSION_HEADER_LEGACY);
|
||||
String token = req.getHeader(WSConstants.WS_AUTH_HEADER);
|
||||
|
||||
if (CommonUtils.isEmpty(sid) && CommonUtils.isEmpty(token)) {
|
||||
|
||||
+6
-1
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
* DBeaver - Universal Database Manager
|
||||
* Copyright (C) 2010-2024 DBeaver Corp and others
|
||||
* 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.
|
||||
@@ -47,6 +47,11 @@ public class CBEventsWebSocket extends CBAbstractWebSocket implements CBWebSessi
|
||||
} else {
|
||||
this.webSession = (BaseWebSession) session.getUserProperties()
|
||||
.get(CBWebSocketServerConfigurator.PROP_WEB_SESSION);
|
||||
if (this.webSession == null) {
|
||||
log.debug("No web session for websocket connection, closing");
|
||||
close();
|
||||
return;
|
||||
}
|
||||
this.webSession.addEventHandler(this);
|
||||
handleEvent(new WSSocketConnectedEvent(webSession.getApplication().getApplicationRunId()));
|
||||
log.debug("EventWebSocket connected to the " + webSession.getSessionId() + " session");
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
* DBeaver - Universal Database Manager
|
||||
* Copyright (C) 2010-2025 DBeaver Corp and others
|
||||
* 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.
|
||||
@@ -88,7 +88,8 @@ public class CBWebSocketServerConfigurator extends ServerEndpointConfig.Configur
|
||||
throw new RuntimeException(e.getMessage(), e);
|
||||
}
|
||||
if (sec.getUserProperties().get(PROP_WEB_SESSION) == null) {
|
||||
throw new RuntimeException("No web session found for websocket request");
|
||||
// no throwing exception here. will be handled in onOpen
|
||||
log.debug("No web session found for websocket request, connection will be closed on open");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
####################################################
|
||||
# Administration API
|
||||
####################################################
|
||||
|
||||
"Describes the type of the administrative subject (user or team)"
|
||||
enum AdminSubjectType {
|
||||
"User"
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
####################################################
|
||||
# AI events
|
||||
####################################################
|
||||
|
||||
extend enum CBServerEventId {
|
||||
cb_ai_chat_message_chunk @since(version: "25.1.1")
|
||||
cb_ai_chat_message_error @since(version: "25.1.1")
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
####################################################
|
||||
# AI admin queries
|
||||
####################################################
|
||||
|
||||
enum AIMessageType {
|
||||
SYSTEM,
|
||||
USER,
|
||||
@@ -32,6 +35,8 @@ type AIEngineInfo @since(version: "23.2.2") {
|
||||
id: ID!
|
||||
"Name of the AI engine."
|
||||
name: String!
|
||||
"Path to the AI engine icon."
|
||||
icon: String
|
||||
}
|
||||
|
||||
type AIChatConversationInfo @since(version: "25.1.1") {
|
||||
@@ -269,6 +274,9 @@ extend type Mutation @since(version: "23.2.2") {
|
||||
"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")
|
||||
|
||||
"Cancels the in-progress AI response generation in the specified conversation."
|
||||
aiCancelChatMessage(conversationId: ID!): Boolean! @since(version: "26.1.5")
|
||||
|
||||
"Saves AI settings (e.g. supporting confirming metadata transfer) for the specified connection."
|
||||
aiSaveDataSourceSettings(dataSourceId: DataSourceIdInput!, settings: AIDataSourceSettingsInput!): AIDataSourceSettingsInfo! @since(version: "25.3.3")
|
||||
}
|
||||
|
||||
+41
-24
@@ -24,6 +24,8 @@ 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.eclipse.core.runtime.IStatus;
|
||||
import org.eclipse.core.runtime.Status;
|
||||
import org.jkiss.code.NotNull;
|
||||
import org.jkiss.code.Nullable;
|
||||
import org.jkiss.dbeaver.DBException;
|
||||
@@ -39,9 +41,9 @@ 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.AbstractJob;
|
||||
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;
|
||||
@@ -139,10 +141,11 @@ public class WebAIUtils {
|
||||
@Nullable AIConfirmation confirmation,
|
||||
@NotNull String jobName
|
||||
) {
|
||||
webSession.setAttribute(getWaitingAttr(conversation), true);
|
||||
CompletableFuture<AIChatConversation> result = new CompletableFuture<>();
|
||||
RuntimeUtils.scheduleJob(
|
||||
jobName, monitor -> {
|
||||
AbstractJob job = new AbstractJob(jobName) {
|
||||
@NotNull
|
||||
@Override
|
||||
protected IStatus run(@NotNull DBRProgressMonitor monitor) {
|
||||
try {
|
||||
AIChatResponseConsumer subscriber = new WebAiChatResponseConsumer(conversation, webSession, aiChatSession);
|
||||
aiChatSession.processAICompletion(
|
||||
@@ -159,15 +162,25 @@ public class WebAIUtils {
|
||||
}
|
||||
});
|
||||
} 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);
|
||||
if (monitor.isCanceled()) {
|
||||
log.debug("AI completion cancelled", e);
|
||||
} else {
|
||||
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));
|
||||
// Only clear the flag if it still points to this job
|
||||
if (webSession.getAttribute(getWaitingAttr(conversation)) == this) {
|
||||
webSession.removeAttribute(getWaitingAttr(conversation));
|
||||
}
|
||||
}
|
||||
return Status.OK_STATUS;
|
||||
}
|
||||
);
|
||||
};
|
||||
webSession.setAttribute(getWaitingAttr(conversation), job);
|
||||
job.schedule();
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -257,21 +270,25 @@ public class WebAIUtils {
|
||||
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());
|
||||
AIChatMessage promptMessage;
|
||||
AIChatMessage result;
|
||||
synchronized (conversation) {
|
||||
String caption = conversation.getCaption();
|
||||
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");
|
||||
}
|
||||
result = new AIChatMessage(conversation.getNextMessageId(), AIMessage.assistantMessage("", null));
|
||||
WebAIUtils.scheduleConversationSubmission(webSession, aiChatSession, conversation, null, "AI completion");
|
||||
}
|
||||
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),
|
||||
|
||||
+7
-1
@@ -45,7 +45,7 @@ public interface DBWServiceAI extends DBWService {
|
||||
WebAISettingsConfig getAiSettings() throws DBWebException;
|
||||
|
||||
@NotNull
|
||||
@WebAction(requirePermissions = DBWConstants.PERMISSION_ADMIN)
|
||||
@WebAction
|
||||
List<WebAIEngine> getEngineConfigurations() throws DBWebException;
|
||||
|
||||
@NotNull
|
||||
@@ -139,6 +139,12 @@ public interface DBWServiceAI extends DBWService {
|
||||
@NotNull String messageId
|
||||
) throws DBWebException;
|
||||
|
||||
@WebAction
|
||||
boolean cancelChatMessage(
|
||||
@NotNull WebSession webSession,
|
||||
@NotNull String conversationId
|
||||
) throws DBWebException;
|
||||
|
||||
@NotNull
|
||||
@WebAction
|
||||
WebAIDataSourceSettings getDataSourceAiSettings(
|
||||
|
||||
+8
@@ -16,7 +16,9 @@
|
||||
*/
|
||||
package io.cloudbeaver.service.ai.gql;
|
||||
|
||||
import io.cloudbeaver.WebServiceUtils;
|
||||
import org.jkiss.code.NotNull;
|
||||
import org.jkiss.code.Nullable;
|
||||
import org.jkiss.dbeaver.model.ai.registry.AIEngineDescriptor;
|
||||
import org.jkiss.dbeaver.model.meta.Property;
|
||||
|
||||
@@ -41,4 +43,10 @@ public class WebAIEngine {
|
||||
return descriptor.getLabel();
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Property
|
||||
public String getIcon() {
|
||||
return WebServiceUtils.makeIconId(descriptor.getIcon());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+30
@@ -25,6 +25,7 @@ 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.events.WSAiChatMessageEvent;
|
||||
import io.cloudbeaver.service.ai.model.inputs.DataSourceId;
|
||||
import io.cloudbeaver.service.ai.model.inputs.WebAIChatConversationInput;
|
||||
import io.cloudbeaver.service.ai.model.inputs.WebAIConfigurationProfileInput;
|
||||
@@ -46,12 +47,14 @@ 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.internal.AIChatMessages;
|
||||
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.AbstractJob;
|
||||
import org.jkiss.dbeaver.model.runtime.DBRProgressMonitor;
|
||||
import org.jkiss.dbeaver.model.websocket.event.WSWorkspaceConfigurationChangedEvent;
|
||||
import org.jkiss.dbeaver.runtime.DBWorkbench;
|
||||
@@ -397,6 +400,33 @@ public class WebServiceAI implements DBWServiceAI {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean cancelChatMessage(
|
||||
@NotNull WebSession webSession,
|
||||
@NotNull String conversationId
|
||||
) throws DBWebException {
|
||||
WebAIUtils.validateAiPluginEnabled();
|
||||
AIChatConversation conversation = WebAIUtils.getAiChatConversation(webSession, conversationId);
|
||||
synchronized (conversation) {
|
||||
boolean completionStarted = conversation.isActive();
|
||||
conversation.cancelConversation();
|
||||
boolean hadPendingJob = false;
|
||||
if (webSession.getAttribute(WebAIUtils.getWaitingAttr(conversation)) instanceof AbstractJob job) {
|
||||
hadPendingJob = true;
|
||||
job.cancel();
|
||||
}
|
||||
webSession.removeAttribute(WebAIUtils.getWaitingAttr(conversation));
|
||||
if (hadPendingJob && !completionStarted) {
|
||||
// The completion job was still queued, so its response consumer will not be called.
|
||||
// We need to add a cancellation message to the conversation and notify the client.
|
||||
AIChatMessage cancelMessage = conversation.addMessage(
|
||||
AIMessage.warningMessage(AIChatMessages.ai_chat_conversation_cancelled));
|
||||
webSession.addSessionEvent(new WSAiChatMessageEvent(new WebAIMessage(cancelMessage, conversation)));
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Override
|
||||
public WebAIDataSourceSettings getDataSourceAiSettings(
|
||||
|
||||
+6
@@ -163,6 +163,12 @@ public class WebServiceBindingAI extends WebServiceBindingBase<DBWServiceAI> imp
|
||||
getArgumentVal(env, "conversationId"),
|
||||
getArgumentVal(env, "messageId")
|
||||
)
|
||||
).dataFetcher(
|
||||
"aiCancelChatMessage",
|
||||
env -> getService(env).cancelChatMessage(
|
||||
getWebSession(env),
|
||||
getArgumentVal(env, "conversationId")
|
||||
)
|
||||
).dataFetcher(
|
||||
"aiCreateProfile", env -> getService(env).createProfile(
|
||||
getWebSession(env),
|
||||
|
||||
+27
-13
@@ -1,18 +1,18 @@
|
||||
/*
|
||||
* DBeaver - Universal Database Manager
|
||||
* Copyright (C) 2010-2026 DBeaver Corp
|
||||
* Copyright (C) 2010-2026 DBeaver Corp and others
|
||||
*
|
||||
* All Rights Reserved.
|
||||
* 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
|
||||
*
|
||||
* 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.
|
||||
* 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;
|
||||
|
||||
@@ -22,9 +22,11 @@ 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.dbeaver.model.ai.internal.AIChatMessages;
|
||||
import org.jkiss.utils.CommonUtils;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CancellationException;
|
||||
|
||||
public class WebAiChatResponseConsumer implements AIChatResponseConsumer {
|
||||
private final StringBuilder responseBuilder;
|
||||
@@ -78,7 +80,12 @@ public class WebAiChatResponseConsumer implements AIChatResponseConsumer {
|
||||
|
||||
@Override
|
||||
public void error(@NotNull Throwable throwable) {
|
||||
var errorMessage = conversation.addMessage(AIMessage.errorMessage(throwable));
|
||||
|
||||
AIMessage aiMessage = throwable instanceof CancellationException cancellationException
|
||||
? AIMessage.warningMessage(cancellationException.getMessage())
|
||||
: AIMessage.errorMessage(throwable);
|
||||
|
||||
AIChatMessage errorMessage = conversation.addMessage(aiMessage);
|
||||
if (responseBuilder.isEmpty()) {
|
||||
webSession.addSessionEvent(
|
||||
new WSAiChatMessageEvent(new WebAIMessage(errorMessage, conversation)));
|
||||
@@ -94,12 +101,19 @@ public class WebAiChatResponseConsumer implements AIChatResponseConsumer {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void complete(@NotNull List<AIMessageMeta> meta, boolean finishConversation) {
|
||||
public void complete(@NotNull List<AIMessageMeta> meta, boolean finishConversation, boolean isCanceled) {
|
||||
if (responseBuilder.isEmpty()) {
|
||||
if (isCanceled) {
|
||||
warning(AIChatMessages.ai_chat_conversation_cancelled);
|
||||
}
|
||||
return;
|
||||
}
|
||||
AIChatMessage responseMessage = conversation.addMessage(AIMessage.assistantMessage(responseBuilder.toString(), meta));
|
||||
chatSession.notifyMessageAdd(conversation, responseMessage);
|
||||
webSession.addSessionEvent(new WSAiChatMessageChunkEvent(conversation.getId(), responseMessage.id(), null, true));
|
||||
|
||||
if (isCanceled) {
|
||||
warning(AIChatMessages.ai_chat_conversation_cancelled);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
####################################################
|
||||
# Authentication API
|
||||
####################################################
|
||||
|
||||
enum AuthCredentialEncryption {
|
||||
none,
|
||||
plain,
|
||||
@@ -15,11 +19,11 @@ type AuthCredentialInfo {
|
||||
displayName: String!
|
||||
description: String
|
||||
|
||||
# This field must be shown in admin panel
|
||||
"This field must be shown in admin panel"
|
||||
admin: Boolean!
|
||||
# This field must be shown in login form
|
||||
"This field must be shown in login form"
|
||||
user: Boolean!
|
||||
# This field can be used as user identifier
|
||||
"This field can be used as user identifier"
|
||||
|
||||
identifying: Boolean!
|
||||
possibleValues: [String]
|
||||
@@ -104,8 +108,14 @@ type FederatedAuthResult @since(version: "25.0.3") {
|
||||
userTokens: [UserAuthToken!]! @since(version: "25.0.3")
|
||||
}
|
||||
|
||||
type LogoutLink @since(version: "26.1.4") {
|
||||
url: String!
|
||||
"If true, the URL must be opened as a full-page redirect in the current tab instead of a popup window"
|
||||
sameTabRedirect: Boolean!
|
||||
}
|
||||
|
||||
type LogoutInfo @since(version: "23.3.3") {
|
||||
redirectLinks: [String!]!
|
||||
redirectLinks: [LogoutLink!]!
|
||||
}
|
||||
|
||||
type UserAuthToken {
|
||||
|
||||
+1
-1
@@ -20,5 +20,5 @@ import org.jkiss.code.NotNull;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record WebLogoutInfo(@NotNull List<String> redirectLinks) {
|
||||
public record WebLogoutInfo(@NotNull List<WebLogoutLink> redirectLinks) {
|
||||
}
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* DBeaver - Universal Database Manager
|
||||
* Copyright (C) 2010-2025 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.auth;
|
||||
|
||||
import org.jkiss.code.NotNull;
|
||||
|
||||
/**
|
||||
* A single logout redirect link.
|
||||
*
|
||||
* @param url the URL to open on logout
|
||||
* @param sameTabRedirect if true, the URL must be opened as a full-page redirect
|
||||
* in the current tab instead of a popup window
|
||||
*/
|
||||
public record WebLogoutLink(@NotNull String url, boolean sameTabRedirect) {
|
||||
}
|
||||
+6
-3
@@ -249,7 +249,7 @@ public class WebServiceAuthImpl implements DBWServiceAuth {
|
||||
List<WebAuthInfo> removedInfos = webSession.removeAuthInfo(providerId);
|
||||
var cbApp = CBApplication.getInstance();
|
||||
|
||||
List<String> logoutUrls = new ArrayList<>();
|
||||
List<WebLogoutLink> redirectLinks = new ArrayList<>();
|
||||
String origin = ServletAppUtils.getOriginFromRequest(httpRequest);
|
||||
for (WebAuthInfo removedInfo : removedInfos) {
|
||||
if (removedInfo.getAuthProviderDescriptor()
|
||||
@@ -274,11 +274,14 @@ public class WebServiceAuthImpl implements DBWServiceAuth {
|
||||
);
|
||||
}
|
||||
if (CommonUtils.isNotEmpty(logoutUrl)) {
|
||||
logoutUrls.add(logoutUrl);
|
||||
redirectLinks.add(new WebLogoutLink(
|
||||
logoutUrl,
|
||||
removedInfo.getAuthProviderDescriptor().isSameTabRedirectOnLogout()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
return new WebLogoutInfo(logoutUrls);
|
||||
return new WebLogoutInfo(redirectLinks);
|
||||
} catch (DBException e) {
|
||||
throw new DBWebException("User logout failed", e);
|
||||
}
|
||||
|
||||
+4
@@ -1,4 +1,6 @@
|
||||
####################################################
|
||||
# Data transfer queries
|
||||
####################################################
|
||||
|
||||
type DataTransferProcessorInfo {
|
||||
id: ID!
|
||||
@@ -46,6 +48,8 @@ input DataTransferImportSettings {
|
||||
useBulkLoad: Boolean
|
||||
"Use transactions during import"
|
||||
useTransactions: Boolean
|
||||
"Open new connection(s) for the import task"
|
||||
openNewConnection: Boolean
|
||||
}
|
||||
|
||||
input DataTransferImportParameters {
|
||||
|
||||
+4
-1
@@ -425,7 +425,7 @@ public class WebServiceDataTransfer implements DBWServiceDataTransfer {
|
||||
throw new DBWebException("Import is canceled");
|
||||
}
|
||||
} catch (DBException e) {
|
||||
throw new DBWebException("Import failed cause: " + e.getMessage());
|
||||
throw new DBWebException("Import failed", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -446,6 +446,9 @@ public class WebServiceDataTransfer implements DBWServiceDataTransfer {
|
||||
consumerSettings.setUseTransactions(CommonUtils.getBoolean(
|
||||
settings.get(DTConstants.PROP_USE_TRANSACTIONS),
|
||||
consumerSettings.isUseTransactions()));
|
||||
consumerSettings.setOpenNewConnections(CommonUtils.getBoolean(
|
||||
settings.get(DTConstants.PROP_OPEN_NEW_CONNECTION),
|
||||
consumerSettings.isOpenNewConnections()));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
####################################################
|
||||
# File system API
|
||||
####################################################
|
||||
|
||||
type FSFile @since(version: "23.2.2") {
|
||||
"Name of the file or folder"
|
||||
name: String!
|
||||
|
||||
+22
-6
@@ -48,6 +48,7 @@ import java.util.*;
|
||||
import javax.naming.Context;
|
||||
import javax.naming.NamingEnumeration;
|
||||
import javax.naming.NamingException;
|
||||
import javax.naming.PartialResultException;
|
||||
import javax.naming.directory.*;
|
||||
import javax.naming.ldap.LdapName;
|
||||
import javax.naming.ldap.Rdn;
|
||||
@@ -562,12 +563,22 @@ public class LdapAuthProvider implements SMAuthProviderExternal<SMSession>, SMBr
|
||||
}
|
||||
|
||||
context = initConnection(environment);
|
||||
List<String> groupsByMemberOfAttribute = findGroupsByMemberOfAttribute(fullDN, context);
|
||||
log.debug("Found " + groupsByMemberOfAttribute.size() + " groups by memberOf attribute");
|
||||
result.addAll(groupsByMemberOfAttribute);
|
||||
List<String> groupsByMemberAttribute = findGroupsByMemberAttribute(fullDN, ldapSettings, context);
|
||||
log.debug("Found " + groupsByMemberAttribute.size() + " groups by member attribute");
|
||||
result.addAll(groupsByMemberAttribute);
|
||||
// Run both lookups independently: a failure in one must not discard the groups
|
||||
// already collected by the other.
|
||||
try {
|
||||
List<String> groupsByMemberOfAttribute = findGroupsByMemberOfAttribute(fullDN, context);
|
||||
log.debug("Found " + groupsByMemberOfAttribute.size() + " groups by memberOf attribute");
|
||||
result.addAll(groupsByMemberOfAttribute);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to fetch groups by memberOf attribute. " + e.getMessage());
|
||||
}
|
||||
try {
|
||||
List<String> groupsByMemberAttribute = findGroupsByMemberAttribute(fullDN, ldapSettings, context);
|
||||
log.debug("Found " + groupsByMemberAttribute.size() + " groups by member attribute");
|
||||
result.addAll(groupsByMemberAttribute);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to fetch groups by member attribute. " + e.getMessage());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Group not found. " + e.getMessage());
|
||||
} finally {
|
||||
@@ -579,6 +590,7 @@ public class LdapAuthProvider implements SMAuthProviderExternal<SMSession>, SMBr
|
||||
log.error("Close resource of ldap group search failed", e);
|
||||
}
|
||||
}
|
||||
log.debug("Resolved " + result.size() + " external group id(s) for member '" + fullDN + "': " + result);
|
||||
return new ArrayList<>(result);
|
||||
}
|
||||
|
||||
@@ -602,6 +614,8 @@ public class LdapAuthProvider implements SMAuthProviderExternal<SMSession>, SMBr
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (PartialResultException e) {
|
||||
log.debug("Ignoring LDAP continuation references while reading memberOf for '" + fullDN + "'");
|
||||
} finally {
|
||||
if (userRecord != null) {
|
||||
userRecord.close();
|
||||
@@ -634,6 +648,8 @@ public class LdapAuthProvider implements SMAuthProviderExternal<SMSession>, SMBr
|
||||
log.error("Failed fetch user group. " + e.getMessage());
|
||||
}
|
||||
}
|
||||
} catch (PartialResultException e) {
|
||||
log.debug("Ignoring LDAP continuation references during member group search for '" + fullDN + "'");
|
||||
} finally {
|
||||
if (searchResults != null) {
|
||||
searchResults.close();
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
####################################################
|
||||
# Metadata queries
|
||||
####################################################
|
||||
|
||||
extend type Query {
|
||||
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
####################################################
|
||||
# Resource manager events
|
||||
####################################################
|
||||
|
||||
extend enum CBServerEventId {
|
||||
"Resource manager resource created"
|
||||
cb_rm_resource_created,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
####################################################
|
||||
# Metadata queries
|
||||
####################################################
|
||||
|
||||
type RMProject {
|
||||
id: ID!
|
||||
@@ -17,7 +19,7 @@ type RMResource {
|
||||
name: String!
|
||||
folder: Boolean!
|
||||
length: Int!
|
||||
# Properties map
|
||||
"Properties map"
|
||||
properties: Object
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
description="Reverse proxy header based authentication"
|
||||
trusted="true"
|
||||
configurable="true"
|
||||
sameTabRedirectOnLogout="true"
|
||||
class="io.cloudbeaver.auth.provider.rp.RPAuthProvider"
|
||||
icon="platform:/plugin/org.jkiss.dbeaver.model/icons/tree/key.svg">
|
||||
<credentials>
|
||||
|
||||
+5
-2
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
* DBeaver - Universal Database Manager
|
||||
* Copyright (C) 2010-2024 DBeaver Corp and others
|
||||
* 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.
|
||||
@@ -48,7 +48,10 @@ public class AuthenticationTest extends CloudbeaverMockTest {
|
||||
private static final String GQL_AUTH_LOGOUT = """
|
||||
query authLogoutExtended($provider: ID, $configuration: ID) {
|
||||
result: authLogoutExtended(provider: $provider, configuration: $configuration) {
|
||||
redirectLinks
|
||||
redirectLinks {
|
||||
url
|
||||
sameTabRedirect
|
||||
}
|
||||
}
|
||||
}""";
|
||||
|
||||
|
||||
@@ -31,6 +31,10 @@
|
||||
opacity: 1;
|
||||
scale: 1;
|
||||
}
|
||||
|
||||
&[data-leave] {
|
||||
transition-duration: 0ms;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"dependencies": {
|
||||
"async-mutex": "^0",
|
||||
"color": "^5.0.2",
|
||||
"modern-screenshot": "^4",
|
||||
"p-debounce": "^5",
|
||||
"p-memoize": "^8.0.0",
|
||||
"quick-lru": "^7.1.0",
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* 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 IMAGE_EXPORT_FORMATS = ['SVG', 'PNG'] as const;
|
||||
|
||||
export type ImageExportFormat = (typeof IMAGE_EXPORT_FORMATS)[number];
|
||||
|
||||
export interface IImageExportOptions {
|
||||
fileName: string;
|
||||
format: ImageExportFormat;
|
||||
transparent: boolean;
|
||||
}
|
||||
|
||||
/** Turns the requested options into the name the file is saved under, see {@link timestampedImageFileName}. */
|
||||
export type ImageExportFileNameFormatter = (options: IImageExportOptions) => string;
|
||||
|
||||
export interface IImageExportResult {
|
||||
fileName: string;
|
||||
format: ImageExportFormat;
|
||||
blob: Blob;
|
||||
}
|
||||
|
||||
export type ImageExportReadyHandler = (result: IImageExportResult) => void | Promise<void>;
|
||||
|
||||
/** For consumers that cannot take a Blob, see {@link toDataUrlImageExport}. */
|
||||
export interface IImageExportDataUrlResult {
|
||||
fileName: string;
|
||||
format: ImageExportFormat;
|
||||
dataUrl: string;
|
||||
}
|
||||
|
||||
export type ImageExportDataUrlReadyHandler = (result: IImageExportDataUrlResult) => void;
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* 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 function blobToDataUrl(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
* CloudBeaver - Cloud Database Manager
|
||||
* Copyright (C) 2020-2024 DBeaver Corp and others
|
||||
* 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.
|
||||
@@ -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 { download } from './download.js';
|
||||
import type { IImageExportResult } from './IImageExportOptions.js';
|
||||
|
||||
export function downloadImageExport(result: IImageExportResult): void {
|
||||
download(result.blob, result.fileName);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* 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 { domToBlob, domToForeignObjectSvg, type Options } from 'modern-screenshot';
|
||||
|
||||
import { DEFAULT_MAX_IMAGE_SIDE, fitToPixelBudget, type IPixelBudget } from './fitToPixelBudget.js';
|
||||
import type { IImageExportOptions, ImageExportFileNameFormatter, ImageExportReadyHandler } from './IImageExportOptions.js';
|
||||
import { isImageBroken } from './isImageBroken.js';
|
||||
import { timestampedImageFileName } from './timestampedImageFileName.js';
|
||||
|
||||
export const IMAGE_EXPORT_BROKEN_ERROR = 'Something went wrong. Please try to select another file format';
|
||||
|
||||
const DEFAULT_BACKGROUND_COLOR = '#ffffff';
|
||||
const DEFAULT_PNG_SCALE = 2;
|
||||
|
||||
export interface IDomImageExportConfig extends IPixelBudget {
|
||||
/** In CSS pixels, defaults to the bounding box of `node`. Required for nodes with no box of their own. */
|
||||
width?: number;
|
||||
height?: number;
|
||||
/** Used when `options.transparent` is false. */
|
||||
backgroundColor?: string;
|
||||
/** Applied to the clone before rendering, the live DOM is left alone. */
|
||||
style?: Partial<CSSStyleDeclaration>;
|
||||
/** Return false to keep a node (and its children) out of the snapshot. */
|
||||
filter?: (node: Node) => boolean;
|
||||
/** Defaults to {@link timestampedImageFileName}. */
|
||||
formatFileName?: ImageExportFileNameFormatter;
|
||||
}
|
||||
|
||||
/**
|
||||
* PNG is scaled down to fit {@link fitToPixelBudget}, SVG is always 1:1
|
||||
*/
|
||||
export async function exportDomImage(
|
||||
node: Element,
|
||||
options: IImageExportOptions,
|
||||
onReady: ImageExportReadyHandler,
|
||||
config?: IDomImageExportConfig,
|
||||
): Promise<void> {
|
||||
const { width, height } = resolveSize(node, config);
|
||||
const scale = options.format === 'PNG' ? fitToPixelBudget(width, height, { ...config, scale: config?.scale ?? DEFAULT_PNG_SCALE }) : 1;
|
||||
|
||||
const screenshotOptions: Options = {
|
||||
// a size given here is forced onto the clone, so pass it on only when asked for
|
||||
...(config?.width === undefined ? {} : { width: config.width }),
|
||||
...(config?.height === undefined ? {} : { height: config.height }),
|
||||
scale,
|
||||
style: config?.style,
|
||||
filter: config?.filter,
|
||||
backgroundColor: options.transparent ? 'transparent' : (config?.backgroundColor ?? DEFAULT_BACKGROUND_COLOR),
|
||||
quality: 1,
|
||||
maximumCanvasSize: config?.maxSide ?? DEFAULT_MAX_IMAGE_SIDE,
|
||||
};
|
||||
|
||||
const blob = options.format === 'PNG' ? await renderPng(node, screenshotOptions) : await renderSvg(node, screenshotOptions);
|
||||
const uri = URL.createObjectURL(blob);
|
||||
const broken = await isImageBroken(uri);
|
||||
URL.revokeObjectURL(uri);
|
||||
|
||||
if (broken) {
|
||||
throw new Error(IMAGE_EXPORT_BROKEN_ERROR);
|
||||
}
|
||||
|
||||
const formatFileName = config?.formatFileName ?? timestampedImageFileName;
|
||||
|
||||
await onReady({ fileName: formatFileName(options), format: options.format, blob });
|
||||
}
|
||||
|
||||
function renderPng(node: Element, screenshotOptions: Options): Promise<Blob> {
|
||||
return domToBlob(node, { ...screenshotOptions, type: 'image/png' });
|
||||
}
|
||||
|
||||
async function renderSvg(node: Element, screenshotOptions: Options): Promise<Blob> {
|
||||
const svg = await domToForeignObjectSvg(node, screenshotOptions);
|
||||
const svgData = new XMLSerializer().serializeToString(svg);
|
||||
|
||||
return new Blob(['<?xml version="1.0" standalone="no"?>\r\n', svgData], { type: 'image/svg+xml;charset=utf-8' });
|
||||
}
|
||||
|
||||
function resolveSize(node: Element, config?: IDomImageExportConfig): { width: number; height: number } {
|
||||
if (config?.width !== undefined && config.height !== undefined) {
|
||||
return { width: config.width, height: config.height };
|
||||
}
|
||||
|
||||
const rect = node.getBoundingClientRect();
|
||||
|
||||
return { width: rect.width, height: rect.height };
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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 { describe, expect, test } from 'vitest';
|
||||
|
||||
import { DEFAULT_MAX_IMAGE_PIXELS, DEFAULT_MAX_IMAGE_SIDE, fitToPixelBudget } from './fitToPixelBudget.js';
|
||||
|
||||
describe('fitToPixelBudget', () => {
|
||||
test('should keep the requested scale when the content fits', () => {
|
||||
expect(fitToPixelBudget(1200, 800, { scale: 2 })).toBe(2);
|
||||
});
|
||||
|
||||
test('should default to scale 1', () => {
|
||||
expect(fitToPixelBudget(1200, 800)).toBe(1);
|
||||
});
|
||||
|
||||
test('should scale down when the total area exceeds the pixel budget', () => {
|
||||
const scale = fitToPixelBudget(12000, 9000, { scale: 2 });
|
||||
|
||||
expect(scale).toBeLessThan(1);
|
||||
expect(12000 * scale * 9000 * scale).toBeLessThanOrEqual(DEFAULT_MAX_IMAGE_PIXELS + 1);
|
||||
});
|
||||
|
||||
test('should scale down when a single side exceeds the max side', () => {
|
||||
const scale = fitToPixelBudget(20000, 300, { scale: 1 });
|
||||
|
||||
expect(20000 * scale).toBeLessThanOrEqual(DEFAULT_MAX_IMAGE_SIDE + 1);
|
||||
});
|
||||
|
||||
test('should respect custom budgets', () => {
|
||||
expect(fitToPixelBudget(2000, 2000, { scale: 1, maxSide: 1000 })).toBe(0.5);
|
||||
expect(fitToPixelBudget(2000, 2000, { scale: 1, maxPixels: 1_000_000 })).toBe(0.5);
|
||||
});
|
||||
|
||||
test('should not collapse to zero on an enormous content', () => {
|
||||
expect(fitToPixelBudget(10_000_000, 10_000_000, { scale: 1 })).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
/** Canvases stop rendering reliably somewhere around this side length. */
|
||||
export const DEFAULT_MAX_IMAGE_SIDE = 8192;
|
||||
|
||||
/** ~16 Mpx, about 64 MB of RGBA. */
|
||||
export const DEFAULT_MAX_IMAGE_PIXELS = 16_777_216;
|
||||
|
||||
const MIN_SCALE = 0.05;
|
||||
|
||||
export interface IPixelBudget {
|
||||
/** Device pixel ratio to aim for. Defaults to 1. */
|
||||
scale?: number;
|
||||
maxSide?: number;
|
||||
maxPixels?: number;
|
||||
}
|
||||
|
||||
export function fitToPixelBudget(width: number, height: number, budget?: IPixelBudget): number {
|
||||
const { scale = 1, maxSide = DEFAULT_MAX_IMAGE_SIDE, maxPixels = DEFAULT_MAX_IMAGE_PIXELS } = budget ?? {};
|
||||
|
||||
if (width <= 0 || height <= 0) {
|
||||
return scale;
|
||||
}
|
||||
|
||||
const fitted = Math.min(scale, maxSide / width, maxSide / height, Math.sqrt(maxPixels / (width * height)));
|
||||
|
||||
return Math.max(fitted, Math.min(scale, MIN_SCALE));
|
||||
}
|
||||
@@ -6,8 +6,18 @@
|
||||
* you may not use this file except in compliance with the License.
|
||||
*/
|
||||
|
||||
export * from './blobToDataUrl.js';
|
||||
export * from './ColorConvert.js';
|
||||
export * from './debouncePromise.js';
|
||||
export * from './download.js';
|
||||
export * from './downloadImageExport.js';
|
||||
export * from './exportDomImage.js';
|
||||
export * from './fitToPixelBudget.js';
|
||||
export * from './IImageExportOptions.js';
|
||||
export * from './isImageBroken.js';
|
||||
export * from './timestampedImageFileName.js';
|
||||
export * from './toDataUrlImageExport.js';
|
||||
export * from './withTimestamp.js';
|
||||
export * from './eventContext.js';
|
||||
export * from './eventStopPropagationFlag.js';
|
||||
export * from './isDefined.js';
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
* CloudBeaver - Cloud Database Manager
|
||||
* Copyright (C) 2020-2025 DBeaver Corp and others
|
||||
* 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.
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* 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 { describe, expect, test } from 'vitest';
|
||||
|
||||
import { timestampedImageFileName } from './timestampedImageFileName.js';
|
||||
|
||||
describe('timestampedImageFileName', () => {
|
||||
test('should append the timestamp and the extension', () => {
|
||||
const name = timestampedImageFileName({ fileName: 'Chart', format: 'PNG', transparent: false });
|
||||
|
||||
expect(name).toMatch(/^Chart \d{4}-\d{2}-\d{2} \d{2}-\d{2}-\d{2}\.png$/);
|
||||
});
|
||||
|
||||
test('should lowercase the extension per format', () => {
|
||||
const name = timestampedImageFileName({ fileName: 'Plan', format: 'SVG', transparent: false });
|
||||
|
||||
expect(name.endsWith('.svg')).toBe(true);
|
||||
});
|
||||
|
||||
test('should trim the requested name', () => {
|
||||
const name = timestampedImageFileName({ fileName: ' Chart ', format: 'PNG', transparent: false });
|
||||
|
||||
expect(name.startsWith('Chart 20')).toBe(true);
|
||||
});
|
||||
|
||||
test('should fall back when the requested name is blank', () => {
|
||||
const name = timestampedImageFileName({ fileName: '', format: 'PNG', transparent: false });
|
||||
|
||||
expect(name.startsWith('image')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* 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 { IImageExportOptions } from './IImageExportOptions.js';
|
||||
import { withTimestamp } from './withTimestamp.js';
|
||||
|
||||
const FALLBACK_FILE_NAME = 'image';
|
||||
|
||||
/** `{ fileName: 'Chart', format: 'PNG' }` → `'Chart 2026-07-27 10-30-00.png'` */
|
||||
export function timestampedImageFileName(options: IImageExportOptions): string {
|
||||
return `${withTimestamp(options.fileName.trim() || FALLBACK_FILE_NAME)}.${options.format.toLowerCase()}`;
|
||||
}
|
||||
@@ -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 { blobToDataUrl } from './blobToDataUrl.js';
|
||||
import type { ImageExportDataUrlReadyHandler, ImageExportReadyHandler } from './IImageExportOptions.js';
|
||||
|
||||
export function toDataUrlImageExport(handler: ImageExportDataUrlReadyHandler): ImageExportReadyHandler {
|
||||
return async result => {
|
||||
handler({ fileName: result.fileName, format: result.format, dataUrl: await blobToDataUrl(result.blob) });
|
||||
};
|
||||
}
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
/*
|
||||
* CloudBeaver - Cloud Database Manager
|
||||
* Copyright (C) 2020-2024 DBeaver Corp and others
|
||||
* 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 function withTimestamp(value: string) {
|
||||
export function withTimestamp(value: string): string {
|
||||
const now = new Date();
|
||||
return `${value} ${now.toISOString().slice(0, 10)} ${('0' + now.getHours()).slice(-2)}-${('0' + now.getMinutes()).slice(-2)}-${(
|
||||
'0' + now.getSeconds()
|
||||
@@ -1,6 +1,11 @@
|
||||
{
|
||||
"extends": "@dbeaver/tsconfig/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"ESNext",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"tsBuildInfoFile": "./lib/.tsbuildinfo",
|
||||
"rootDir": "src",
|
||||
"rootDirs": [
|
||||
|
||||
+6
-4
@@ -10,19 +10,21 @@
|
||||
"resolutions": {
|
||||
"lodash": ">4.17.23",
|
||||
"immutable": ">=5.1.8",
|
||||
"fast-uri": ">3.1.3",
|
||||
"fast-uri": ">=4.1.2",
|
||||
"glob": ">11.1.0",
|
||||
"inflight": ">1.0.6",
|
||||
"node-domexception": ">1.0.0",
|
||||
"shell-quote": ">=1.9.0",
|
||||
"esbuild": ">=0.28.1",
|
||||
"form-data": ">=4.0.6",
|
||||
"undici": ">=6.27.0",
|
||||
"undici": ">=8.9.0",
|
||||
"tar": ">=7.5.19",
|
||||
"js-yaml": ">=5.2.2",
|
||||
"brace-expansion": ">=5.0.8",
|
||||
"brace-expansion": ">=5.0.9",
|
||||
"axios": ">=1.18.0",
|
||||
"postcss": ">=8.5.18"
|
||||
"postcss": ">=8.5.18",
|
||||
"nanoid": ">=3.3.17",
|
||||
"less": ">=4.7.0"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "dbeaver-test",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
* CloudBeaver - Cloud Database Manager
|
||||
* Copyright (C) 2020-2025 DBeaver Corp and others
|
||||
* 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.
|
||||
@@ -92,7 +92,7 @@ export const ExportImageDialog = observer<DialogComponentProps<ExportImagePayloa
|
||||
<Button type="button" variant="secondary" onClick={() => props.rejectDialog()}>
|
||||
{translate('app_shared_inlineEditor_dialog_cancel')}
|
||||
</Button>
|
||||
<Button type="button" onClick={onExportHandler}>
|
||||
<Button type="button" loader onClick={onExportHandler}>
|
||||
{translate('ui_export')}
|
||||
</Button>
|
||||
</CommonDialogFooter>
|
||||
|
||||
@@ -45,7 +45,11 @@ export const AutoResizeTextarea: Props = observer(function AutoResizeTextarea({
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
adjustHeight();
|
||||
const frame = requestAnimationFrame(() => {
|
||||
adjustHeight();
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [value, adjustHeight]);
|
||||
|
||||
const mergedRef = useMergeRefs(...[innerRef, ref!].filter(Boolean));
|
||||
|
||||
@@ -10,22 +10,45 @@ import { observer } from 'mobx-react-lite';
|
||||
import { Composite, CompositeItem, Popover } from '@dbeaver/ui-kit';
|
||||
|
||||
import { type InputAutocompleteProposal } from './useInputAutocomplete.js';
|
||||
import type { IContextMenuPositionCoords } from '../Menu/useContextMenuPosition.js';
|
||||
import { useS } from '../useS.js';
|
||||
import { BaseDropdownStyles, IconOrImage, s, Text } from '../index.js';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
interface AutocompletionProps {
|
||||
proposals: InputAutocompleteProposal[];
|
||||
position: IContextMenuPositionCoords;
|
||||
className?: string;
|
||||
onSelect?: (proposal: InputAutocompleteProposal) => void;
|
||||
}
|
||||
|
||||
export const InputAutocompletionMenu = observer(
|
||||
forwardRef<HTMLDivElement, AutocompletionProps>(function InputAutocompletionMenu({ proposals, onSelect }, ref) {
|
||||
forwardRef<HTMLDivElement, AutocompletionProps>(function InputAutocompletionMenu({ proposals, position, onSelect }, ref) {
|
||||
const styles = useS(BaseDropdownStyles);
|
||||
|
||||
function getAnchorRect(anchor: HTMLElement | null) {
|
||||
if (!anchor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
x: rect.left + position.x,
|
||||
y: rect.top + position.y,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover.PopoverContent ref={ref} className={s(styles, { menu: true })} gutter={4} modal={false} autoFocusOnShow={false} unmountOnHide>
|
||||
<Popover.PopoverContent
|
||||
ref={ref}
|
||||
className={s(styles, { menu: true })}
|
||||
gutter={4}
|
||||
modal={false}
|
||||
autoFocusOnShow={false}
|
||||
getAnchorRect={getAnchorRect}
|
||||
unmountOnHide
|
||||
>
|
||||
<Composite>
|
||||
{proposals.map(proposal => (
|
||||
<CompositeItem key={proposal.title} className={s(styles, { menuItem: true }, 'tw:w-full')} onClick={() => onSelect?.(proposal)}>
|
||||
|
||||
@@ -115,6 +115,9 @@ export const useInputAutocomplete = (
|
||||
this.selectionStart = null;
|
||||
this.position = { x: 0, y: 0 };
|
||||
},
|
||||
setPosition(position: IContextMenuPositionCoords) {
|
||||
this.position = position;
|
||||
},
|
||||
}),
|
||||
{
|
||||
proposals: computed,
|
||||
@@ -123,6 +126,7 @@ export const useInputAutocomplete = (
|
||||
currentWord: computed,
|
||||
resetState: action.bound,
|
||||
replaceCurrentWord: action.bound,
|
||||
setPosition: action.bound,
|
||||
position: observable.ref,
|
||||
inputValue: observable.ref,
|
||||
},
|
||||
@@ -182,8 +186,13 @@ export const useInputAutocomplete = (
|
||||
});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const inputElement = inputRef.current!;
|
||||
const caretPosition = inputElement.selectionStart || 0;
|
||||
const inputElement = inputRef.current;
|
||||
|
||||
if (!inputElement || state.selectionStart === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const caretPosition = inputElement.selectionStart ?? state.selectionStart;
|
||||
const inputStyle = window.getComputedStyle(inputElement);
|
||||
|
||||
const span = document.createElement('span');
|
||||
@@ -199,14 +208,14 @@ export const useInputAutocomplete = (
|
||||
|
||||
document.body.appendChild(span);
|
||||
const spanRect = span.getBoundingClientRect();
|
||||
const letterWidth = spanRect.width / span.textContent.length;
|
||||
const letterWidth = span.textContent.length > 0 ? spanRect.width / span.textContent.length : 0;
|
||||
document.body.removeChild(span);
|
||||
|
||||
state.position = {
|
||||
state.setPosition({
|
||||
x: spanRect.width + letterWidth,
|
||||
y: spanRect.height + CONTEXT_INPUT_OFFSET_Y,
|
||||
};
|
||||
}, [state.inputValue]);
|
||||
});
|
||||
}, [state.inputValue, state.selectionStart]);
|
||||
|
||||
return state as Readonly<State>;
|
||||
};
|
||||
|
||||
+44
-1
@@ -17,7 +17,8 @@ import {
|
||||
getObjectPropertyValueType,
|
||||
type IObjectPropertyInfo,
|
||||
} from '@cloudbeaver/core-sdk';
|
||||
import { EMPTY_ARRAY, removeMetadataFromDataURL } from '@cloudbeaver/core-utils';
|
||||
import { EMPTY_ARRAY, getTextFileReadingProcess, removeMetadataFromDataURL } from '@cloudbeaver/core-utils';
|
||||
import { clsx } from '@dbeaver/ui-kit';
|
||||
|
||||
import { FieldCheckbox } from '../../FormControls/Checkboxes/FieldCheckbox.js';
|
||||
import { Select } from '../../FormControls/Select.js';
|
||||
@@ -30,6 +31,8 @@ import { Link } from '../../Link.js';
|
||||
import { useTranslate } from '../../localization/useTranslate.js';
|
||||
import { evaluate } from '../evaluate.js';
|
||||
import { SAVED_VALUE_INDICATOR } from '../../SAVED_VALUE_INDICATOR.js';
|
||||
import { UploadArea } from '../../UploadArea.js';
|
||||
import { Button } from '../../Button.js';
|
||||
|
||||
interface RenderFieldProps {
|
||||
property: IObjectPropertyInfo;
|
||||
@@ -204,6 +207,46 @@ export const RenderField = observer<RenderFieldProps>(function RenderField({
|
||||
);
|
||||
}
|
||||
|
||||
if (controlType === 'uploadable-textarea' && state && property.id) {
|
||||
async function handleFileUpload(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
|
||||
if (!file) {
|
||||
throw new Error('File is not found');
|
||||
}
|
||||
|
||||
const process = getTextFileReadingProcess(file);
|
||||
const result = await process.promise;
|
||||
|
||||
if (state && property.id) {
|
||||
state[property.id] = result;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tw:flex tw:flex-col tw:gap-2">
|
||||
<Textarea
|
||||
required={required}
|
||||
title={state[property.id]}
|
||||
labelTooltip={property.description || property.displayName}
|
||||
placeholder={placeholder}
|
||||
name={property.id}
|
||||
state={state}
|
||||
disabled={disabled}
|
||||
readOnly={readonly}
|
||||
className={clsx('tw:flex-0!', className)}
|
||||
>
|
||||
{property.displayName ?? ''}
|
||||
</Textarea>
|
||||
<UploadArea disabled={disabled || readonly} reset onChange={handleFileUpload}>
|
||||
<Button className="tw:w-max" tag="div" disabled={disabled || readonly} variant="secondary">
|
||||
{translate('ui_file')}
|
||||
</Button>
|
||||
</UploadArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (controlType === 'file' && state) {
|
||||
return (
|
||||
<InputFileTextContent
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
* CloudBeaver - Cloud Database Manager
|
||||
* Copyright (C) 2020-2024 DBeaver Corp and others
|
||||
* 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.
|
||||
@@ -20,4 +20,5 @@ export enum EObjectFeature {
|
||||
'dataSourceConnected' = 'dataSourceConnected',
|
||||
'entityContainer' = 'entityContainer',
|
||||
'supportsDataFilter' = 'supportsDataFilter',
|
||||
'supportsFullDdl' = 'supportsFullDdl', // some connections may not have the full DDL feature
|
||||
}
|
||||
|
||||
@@ -7,10 +7,12 @@
|
||||
*/
|
||||
import type { IObjectPropertyInfo } from './IObjectPropertyInfo.js';
|
||||
|
||||
export type ObjectPropertyType = 'checkbox' | 'selector' | 'link' | 'textarea' | 'file' | 'input';
|
||||
export type ObjectPropertyType = 'checkbox' | 'selector' | 'link' | 'textarea' | 'file' | 'input' | 'uploadable-textarea';
|
||||
|
||||
export function getObjectPropertyType(property: IObjectPropertyInfo): ObjectPropertyType {
|
||||
const dataType = property.dataType?.toLowerCase();
|
||||
const isTextarea = dataType === 'string' && property.length === 'MULTILINE';
|
||||
const isFile = property.features.includes('file');
|
||||
|
||||
if (dataType === 'boolean') {
|
||||
return 'checkbox';
|
||||
@@ -18,9 +20,11 @@ export function getObjectPropertyType(property: IObjectPropertyInfo): ObjectProp
|
||||
return 'selector';
|
||||
} else if (property.features.includes('href')) {
|
||||
return 'link';
|
||||
} else if (dataType === 'string' && property.length === 'MULTILINE') {
|
||||
} else if (isTextarea && isFile) {
|
||||
return 'uploadable-textarea';
|
||||
} else if (isTextarea) {
|
||||
return 'textarea';
|
||||
} else if (property.features.includes('file')) {
|
||||
} else if (isFile) {
|
||||
return 'file';
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
mutation cancelConversation($conversationId: ID!) {
|
||||
result: aiCancelChatMessage(conversationId: $conversationId)
|
||||
}
|
||||
@@ -2,5 +2,6 @@ query getEngines {
|
||||
engines: aiListEngines {
|
||||
id
|
||||
name
|
||||
icon
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
mutation asyncSqlGenerateEntityQuery($generatorId: String!, $nodePathList: [String!]!, $generatorOptions: SQLQueryGeneratorOptions) {
|
||||
taskInfo: asyncSqlGenerateEntityQuery(generatorId: $generatorId, nodePathList: $nodePathList, generatorOptions: $generatorOptions) {
|
||||
...AsyncTaskInfo
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
query authLogout($provider: ID, $configuration: ID) {
|
||||
result: authLogoutExtended(provider: $provider, configuration: $configuration) {
|
||||
redirectLinks
|
||||
redirectLinks {
|
||||
url
|
||||
sameTabRedirect
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
query getConnectionImportDriverConfiguration($projectId: ID, $connectionId: ID!) {
|
||||
connections: userConnections(projectId: $projectId, id: $connectionId) {
|
||||
id
|
||||
projectId
|
||||
driverConfiguration {
|
||||
...DriverConfiguration
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
fragment DriverConfiguration on DriverConfiguration {
|
||||
supportedInsertReplaceMethods {
|
||||
id
|
||||
name
|
||||
description
|
||||
}
|
||||
supportsBulkLoad
|
||||
supportsTransactions
|
||||
}
|
||||
+4
-1
@@ -11,4 +11,7 @@ fragment UserConnectionNetworkHandlerProperties on ObjectPropertyInfo {
|
||||
length
|
||||
features
|
||||
required
|
||||
}
|
||||
conditions {
|
||||
...Condition
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
query metadataGetNodeDDL( $nodeId: ID! ) {
|
||||
metadataGetNodeDDL( nodeId: $nodeId )
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
query sqlGenerateEntityQuery($generatorId: String!, $nodePathList: [String!]!, $generatorOptions: SQLQueryGeneratorOptions) {
|
||||
sqlGenerateEntityQuery(generatorId: $generatorId, nodePathList: $nodePathList, generatorOptions: $generatorOptions)
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
/*
|
||||
* CloudBeaver - Cloud Database Manager
|
||||
* Copyright (C) 2020-2025 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 { beforeEach, describe, expect, it, vitest } from 'vitest';
|
||||
|
||||
import { download } from './download.js';
|
||||
|
||||
describe('download', () => {
|
||||
beforeEach(() => {
|
||||
// Clean up any existing links from previous tests
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('should not create links after download', () => {
|
||||
const blob = new Blob(['test'], { type: 'text/plain' });
|
||||
const linksBefore = document.querySelectorAll('a');
|
||||
expect(linksBefore.length).toBe(0);
|
||||
|
||||
download(blob, 'test.txt');
|
||||
|
||||
const linksAfter = document.querySelectorAll('a');
|
||||
expect(linksAfter.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should create a link with url and click it to download file', () => {
|
||||
const element = document.createElement('a');
|
||||
const createElementSpy = vitest.spyOn(document, 'createElement').mockImplementation(() => element);
|
||||
const clickSpy = vitest.spyOn(element, 'click');
|
||||
const createObjectURLSpy = vitest.spyOn(URL, 'createObjectURL');
|
||||
const blob = new Blob(['test'], { type: 'text/plain' });
|
||||
|
||||
download(blob, 'test.txt');
|
||||
|
||||
expect(createElementSpy).toHaveBeenCalledWith('a');
|
||||
expect(createElementSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(clickSpy).toHaveBeenCalledWith();
|
||||
expect(clickSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(createObjectURLSpy).toHaveBeenCalledWith(blob);
|
||||
expect(createObjectURLSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
createElementSpy.mockRestore();
|
||||
clickSpy.mockRestore();
|
||||
createObjectURLSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
* CloudBeaver - Cloud Database Manager
|
||||
* Copyright (C) 2020-2025 DBeaver Corp and others
|
||||
* 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.
|
||||
@@ -8,8 +8,7 @@
|
||||
|
||||
import * as modernScreenshot from 'modern-screenshot';
|
||||
import type { Options } from 'modern-screenshot';
|
||||
import { download } from './download.js';
|
||||
import { isImageBroken } from './isBrokenImage.js';
|
||||
import { download, isImageBroken } from '@dbeaver/js-helpers';
|
||||
|
||||
export type { Options as IScreenshotOptions };
|
||||
|
||||
|
||||
@@ -62,7 +62,6 @@ export * from './getUniqueName.js';
|
||||
export * from './isMapsEqual.js';
|
||||
export * from './isObjectsEqual.js';
|
||||
export * from './openCenteredPopup.js';
|
||||
export * from './download.js';
|
||||
export * from './downloadFromURL.js';
|
||||
export * from './getTextFileReadingProcess.js';
|
||||
export * from './getTextBetween.js';
|
||||
@@ -81,7 +80,6 @@ export * from './removeMetadataFromDataURL.js';
|
||||
export * from './removeLineBreak.js';
|
||||
export * from './replaceSubstring.js';
|
||||
export * from './formatNumber.js';
|
||||
export * from './withTimestamp.js';
|
||||
export * from './toSafeHtmlString.js';
|
||||
export * from './getProgressPercent.js';
|
||||
export * from './types/UndefinedToNull.js';
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
/*
|
||||
* CloudBeaver - Cloud Database Manager
|
||||
* Copyright (C) 2020-2025 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { isImageBroken } from './isBrokenImage.js';
|
||||
|
||||
describe('isImageBroken', () => {
|
||||
const OriginalImage = globalThis.Image;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (OriginalImage) {
|
||||
globalThis.Image = OriginalImage;
|
||||
} else {
|
||||
delete (globalThis as Record<string, unknown>)['Image'];
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should return false if image loads successfully', async () => {
|
||||
const srcSpy = vi.fn();
|
||||
|
||||
class MockImage {
|
||||
public onload: (() => void) | null = null;
|
||||
public onerror: (() => void) | null = null;
|
||||
|
||||
set src(value: string) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
||||
const thisRef = this;
|
||||
srcSpy(value);
|
||||
setTimeout(() => {
|
||||
thisRef.onload?.();
|
||||
}, 0);
|
||||
vi.runAllTimers();
|
||||
}
|
||||
}
|
||||
|
||||
vi.stubGlobal('Image', MockImage);
|
||||
|
||||
await expect(isImageBroken('https://example.com/ok.png')).resolves.toBe(false);
|
||||
expect(srcSpy).toHaveBeenCalledWith('https://example.com/ok.png');
|
||||
});
|
||||
|
||||
it('should return true if image loading fails', async () => {
|
||||
const srcSpy = vi.fn();
|
||||
|
||||
class MockImage {
|
||||
public onload: (() => void) | null = null;
|
||||
public onerror: (() => void) | null = null;
|
||||
|
||||
set src(value: string) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
||||
const thisRef = this;
|
||||
srcSpy(value);
|
||||
setTimeout(() => {
|
||||
thisRef.onerror?.();
|
||||
}, 0);
|
||||
vi.runAllTimers();
|
||||
}
|
||||
}
|
||||
|
||||
vi.stubGlobal('Image', MockImage);
|
||||
|
||||
await expect(isImageBroken('https://example.com/broken.png')).resolves.toBe(true);
|
||||
expect(srcSpy).toHaveBeenCalledWith('https://example.com/broken.png');
|
||||
});
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
/*
|
||||
* CloudBeaver - Cloud Database Manager
|
||||
* Copyright (C) 2020-2025 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { withTimestamp } from './withTimestamp.js';
|
||||
|
||||
describe('withTimestamp', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should generate a value with timestamp at the end', () => {
|
||||
const mockDate = new Date('2020-09-09T14:13:20');
|
||||
vi.setSystemTime(mockDate);
|
||||
|
||||
const value = 'value';
|
||||
const expectedValue = `${value} 2020-09-09 14-13-20`;
|
||||
|
||||
expect(withTimestamp(value)).toEqual(expectedValue);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_11514_20)">
|
||||
<path d="M19 13.42H21V4.41998H19V13.42Z" fill="#3387C5"/>
|
||||
<path d="M23.54 8.36L20 4.83L16.46 8.36L15.05 6.95L20 2L24.95 6.95L23.54 8.36Z" fill="#3387C5"/>
|
||||
<path d="M21 23.42L12 14.42L3.71004 22.71L2.29004 21.3L12 11.59L21 20.59L25 16.59L29.71 21.3L28.29 22.71L25 19.42L21 23.42Z" fill="#3387C5"/>
|
||||
<path d="M25 4L27 6H28V26H4V6H13L15 4H2V28H30V4H25Z" fill="#3387C5"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_11514_20">
|
||||
<rect width="32" height="32" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 614 B |
@@ -0,0 +1,13 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_11514_2)">
|
||||
<path d="M14.3572 10.166H15.9286V2.94231H14.3572V10.166Z" fill="#3387C5"/>
|
||||
<path d="M17.9244 6.10471L15.1429 3.27143L12.3615 6.10471L11.2537 4.973L15.1429 1L19.0322 4.973L17.9244 6.10471Z" fill="#3387C5"/>
|
||||
<path d="M15.9286 18.5214L8.85719 11.2977L2.34362 17.9515L1.22791 16.8198L8.85719 9.02631L15.9286 16.2499L19.0715 13.0394L22.7722 16.8198L21.6565 17.9515L19.0715 15.3109L15.9286 18.5214Z" fill="#3387C5"/>
|
||||
<path d="M19.0714 2.93439L20.6429 4.53964H21.4286V21.3948H2.57143V4.53964H9.64286L11.2143 2.93439H1V23.0001H23V2.93439H19.0714Z" fill="#3387C5"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_11514_2">
|
||||
<rect width="24" height="24" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 802 B |
@@ -0,0 +1,13 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_11514_11)">
|
||||
<path d="M9.5 6.83289H10.5V2.23602H9.5V6.83289Z" fill="#3387C5"/>
|
||||
<path d="M11.77 4.24845L10 2.44546L8.23002 4.24845L7.52502 3.52827L10 1L12.475 3.52827L11.77 4.24845Z" fill="#3387C5"/>
|
||||
<path d="M10.5 12.15L6.00002 7.55312L1.85502 11.7873L1.14502 11.0672L6.00002 6.10767L10.5 10.7045L12.5 8.66148L14.855 11.0672L14.145 11.7873L12.5 10.1069L10.5 12.15Z" fill="#3387C5"/>
|
||||
<path d="M12.5 2.23096L13.5 3.25248H14V13.9785H2V3.25248H6.5L7.5 2.23096H1V15H15V2.23096H12.5Z" fill="#3387C5"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_11514_11">
|
||||
<rect width="16" height="16" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 730 B |
@@ -34,6 +34,7 @@
|
||||
"@cloudbeaver/core-sdk": "workspace:*",
|
||||
"@cloudbeaver/core-ui": "workspace:*",
|
||||
"@cloudbeaver/core-utils": "workspace:*",
|
||||
"@cloudbeaver/plugin-ai": "workspace:*",
|
||||
"@cloudbeaver/plugin-data-grid": "workspace:*",
|
||||
"@dbeaver/js-helpers": "workspace:*",
|
||||
"@dbeaver/ui-kit": "workspace:*",
|
||||
|
||||
@@ -32,6 +32,7 @@ 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';
|
||||
import { AiEnginesResource } from '@cloudbeaver/plugin-ai';
|
||||
|
||||
export const AIAdministrationPage = observer<{
|
||||
formState: AdministrationAISettingsFormState;
|
||||
@@ -39,6 +40,7 @@ export const AIAdministrationPage = observer<{
|
||||
const translate = useTranslate();
|
||||
const notificationService = useService(NotificationService);
|
||||
const profilesLoader = useResource(AIAdministrationPage, AIProfilesResource, CachedMapAllKey);
|
||||
const aiEnginesResource = useResource(AIAdministrationPage, AiEnginesResource, undefined);
|
||||
const profiles = profilesLoader.data.filter(isDefined);
|
||||
|
||||
const settingsInfoPart = getAdministrationAISettingsFormInfoPart(formState);
|
||||
@@ -102,6 +104,7 @@ export const AIAdministrationPage = observer<{
|
||||
value={settingsInfoPart.state.defaultConfiguration}
|
||||
description={translate('plugin_ai_administration_default_profile_description')}
|
||||
disabled={!profiles.length}
|
||||
iconSelector={value => aiEnginesResource.data.find(e => e.id === value.engineId)?.icon}
|
||||
small
|
||||
onSelect={selectDefaultProfile}
|
||||
>
|
||||
|
||||
+9
-2
@@ -42,15 +42,22 @@ export const AIProfileFormPanel = observer<IAIProfileFormProps>(function AIProfi
|
||||
},
|
||||
});
|
||||
|
||||
const label = translate(creating ? 'plugin_ai_administration_profile_create' : 'plugin_ai_administration_profile_edit');
|
||||
|
||||
return (
|
||||
<Form context={form} contents>
|
||||
<TabsState container={aiProfileFormService.parts} localState={formState.parts} formState={formState}>
|
||||
<div className="tw:flex tw:flex-col tw:flex-1 tw:h-full tw:overflow-auto theme-background-secondary theme-text-on-secondary">
|
||||
<div
|
||||
aria-label={label}
|
||||
className="tw:flex tw:flex-col tw:flex-1 tw:h-full tw:overflow-auto theme-background-secondary theme-text-on-secondary"
|
||||
>
|
||||
<div className="tw:relative tw:flex tw:items-end tw:border-b-2 theme-border-color-background theme-background-secondary theme-text-on-secondary">
|
||||
<div className="tw:flex-1 tw:overflow-hidden tw:pt-2 tw:pl-2">
|
||||
<GroupTitle header>
|
||||
<GroupBack onClick={() => aiProfileFormService.close()}>
|
||||
<Text truncate>{formState.state.name || translate('ui_stepper_back')}</Text>
|
||||
<Text truncate>
|
||||
{creating ? translate('plugin_ai_administration_profile_create') : `${translate('ui_edit')} "${formState.state.name}"`}
|
||||
</Text>
|
||||
</GroupBack>
|
||||
</GroupTitle>
|
||||
<div className="tw:p-2">
|
||||
|
||||
+3
-2
@@ -27,8 +27,8 @@ 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 { AiEnginesResource } from '@cloudbeaver/plugin-ai';
|
||||
|
||||
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';
|
||||
@@ -37,7 +37,7 @@ import { getAIProfileFormPart } from './getAIProfileFormPart.js';
|
||||
export const AIProfileOptions: TabContainerPanelComponent<IAIProfileFormProps> = observer(function AIProfileOptions({ formState }) {
|
||||
const translate = useTranslate();
|
||||
const notificationService = useService(NotificationService);
|
||||
const enginesLoader = useResource(AIProfileOptions, EnginesResource, undefined);
|
||||
const enginesLoader = useResource(AIProfileOptions, AiEnginesResource, undefined);
|
||||
const part = getAIProfileFormPart(formState);
|
||||
const propertiesLoader = useResource(AIProfileOptions, AIEnginePropertiesResource, part.state.engineId || null);
|
||||
const propertiesInfo = propertiesLoader.data ?? [];
|
||||
@@ -96,6 +96,7 @@ export const AIProfileOptions: TabContainerPanelComponent<IAIProfileFormProps> =
|
||||
keySelector={value => value.id}
|
||||
value={part.state.engineId}
|
||||
disabled={formState.isDisabled || isEditMode}
|
||||
iconSelector={value => value.icon}
|
||||
small
|
||||
required
|
||||
onSelect={value => value && part.changeEngine(value)}
|
||||
|
||||
@@ -55,7 +55,7 @@ export const AIProfilesPanel = observer<Props>(function AIProfilesPanel({ formSt
|
||||
const profiles = profilesLoader.data.filter(isDefined);
|
||||
const defaultProfileId = settingsInfoPart.initialState.defaultConfiguration;
|
||||
|
||||
const selection = useTableSelection(profiles.map(p => p.id));
|
||||
const selection = useTableSelection(profiles.filter(p => p.id !== defaultProfileId).map(p => p.id));
|
||||
const table = useAIProfilesTable(selection);
|
||||
|
||||
return (
|
||||
|
||||
@@ -9,13 +9,13 @@
|
||||
import { reaction } from 'mobx';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
|
||||
import { Link, s, TextPlaceholder, useResource, useS, useTranslate } from '@cloudbeaver/core-blocks';
|
||||
import { IconOrImage, 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 { AiEnginesResource } from '@cloudbeaver/plugin-ai';
|
||||
import { Command } from '@dbeaver/ui-kit';
|
||||
|
||||
import { EnginesResource } from '../Engines/EnginesResource.js';
|
||||
import { AIProfileFormService } from './AIProfileForm/AIProfileFormService.js';
|
||||
import type { AIProfile } from './AIProfilesResource.js';
|
||||
|
||||
@@ -34,7 +34,7 @@ export const AIProfilesTable = observer<Props>(function AIProfilesTable({ profil
|
||||
const translate = useTranslate();
|
||||
const styles = useS(AdministrationTableStyles);
|
||||
const aiProfileFormService = useService(AIProfileFormService);
|
||||
const enginesLoader = useResource(AIProfilesTable, EnginesResource, undefined);
|
||||
const enginesLoader = useResource(AIProfilesTable, AiEnginesResource, undefined);
|
||||
|
||||
const columnsCount = useCreateGridReactiveValue(() => COLUMNS.length, null, [COLUMNS]);
|
||||
const rowsCount = useCreateGridReactiveValue(
|
||||
@@ -51,8 +51,16 @@ export const AIProfilesTable = observer<Props>(function AIProfilesTable({ profil
|
||||
return null;
|
||||
}
|
||||
|
||||
const isDefault = profile.id === defaultProfileId;
|
||||
|
||||
if (column.key === SELECT_COLUMN.key) {
|
||||
return <TableRowSelect id={profile.id} />;
|
||||
return (
|
||||
<TableRowSelect
|
||||
id={profile.id}
|
||||
disabled={isDefault}
|
||||
title={isDefault ? translate('plugin_ai_administration_profile_default_delete_info') : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (column.key === NAME_COLUMN.key) {
|
||||
@@ -65,7 +73,7 @@ export const AIProfilesTable = observer<Props>(function AIProfilesTable({ profil
|
||||
onClick={() => aiProfileFormService.open(profile.id, profile.name)}
|
||||
>
|
||||
<Link truncate>{profile.name}</Link>
|
||||
{profile.id === defaultProfileId && (
|
||||
{isDefault && (
|
||||
<span className="tw:text-xs tw:opacity-60 tw:whitespace-nowrap">{translate('plugin_ai_administration_profile_default_badge')}</span>
|
||||
)}
|
||||
</Command>
|
||||
@@ -74,7 +82,18 @@ export const AIProfilesTable = observer<Props>(function AIProfilesTable({ profil
|
||||
|
||||
if (column.key === ENGINE_COLUMN.key) {
|
||||
const engine = enginesLoader.data.find(engine => engine.id === profile.engineId);
|
||||
return <span>{engine?.name ?? profile.engineId}</span>;
|
||||
const title = engine?.name ?? profile.engineId;
|
||||
|
||||
if (engine?.icon) {
|
||||
return (
|
||||
<div title={title} className="tw:flex tw:gap-2">
|
||||
<IconOrImage icon={engine.icon} />
|
||||
<span className="tw:truncate">{title}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <span title={title}>{title}</span>;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -43,4 +43,10 @@ export default [
|
||||
['plugin_ai_administration_profile_form_tab_options', 'Profil'],
|
||||
['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'],
|
||||
[
|
||||
'plugin_ai_administration_profile_default_delete_info',
|
||||
'Das Standardprofil kann nicht gelöscht werden. Bitte wählen Sie ein anderes Profil als Standard aus, bevor Sie dieses Profil löschen.',
|
||||
],
|
||||
['plugin_ai_administration_profile_create', 'Neues Profil'],
|
||||
['plugin_ai_administration_profile_edit', 'Profil bearbeiten'],
|
||||
];
|
||||
|
||||
@@ -43,4 +43,10 @@ export default [
|
||||
['plugin_ai_administration_profile_form_tab_options', 'Profile'],
|
||||
['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'],
|
||||
[
|
||||
'plugin_ai_administration_profile_default_delete_info',
|
||||
'The default profile cannot be deleted. Please select another profile as the default before deleting this profile.',
|
||||
],
|
||||
['plugin_ai_administration_profile_create', 'New Profile'],
|
||||
['plugin_ai_administration_profile_edit', 'Edit Profile'],
|
||||
];
|
||||
|
||||
@@ -43,4 +43,10 @@ export default [
|
||||
['plugin_ai_administration_profile_form_tab_options', 'Profil'],
|
||||
['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'],
|
||||
[
|
||||
'plugin_ai_administration_profile_default_delete_info',
|
||||
'Le profil par défaut ne peut pas être supprimé. Veuillez sélectionner un autre profil comme profil par défaut avant de supprimer ce profil.',
|
||||
],
|
||||
['plugin_ai_administration_profile_create', 'Nouveau profil'],
|
||||
['plugin_ai_administration_profile_edit', 'Modifier le profil'],
|
||||
];
|
||||
|
||||
@@ -43,4 +43,10 @@ export default [
|
||||
['plugin_ai_administration_profile_form_tab_options', 'Профиль'],
|
||||
['plugin_ai_administration_profile_name_max_length', 'Название профиля не должно превышать {arg:length} символов'],
|
||||
['plugin_ai_administration_profile_name_min_length', 'Название профиля должно содержать не менее {arg:length} символов'],
|
||||
[
|
||||
'plugin_ai_administration_profile_default_delete_info',
|
||||
'Профиль по умолчанию нельзя удалить. Перед удалением этого профиля выберите другой профиль по умолчанию.',
|
||||
],
|
||||
['plugin_ai_administration_profile_create', 'Новый профиль'],
|
||||
['plugin_ai_administration_profile_edit', 'Редактировать профиль'],
|
||||
];
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
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';
|
||||
@@ -29,11 +28,9 @@ export default ModuleRegistry.add({
|
||||
.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)
|
||||
|
||||
@@ -59,6 +59,9 @@
|
||||
{
|
||||
"path": "../core-utils"
|
||||
},
|
||||
{
|
||||
"path": "../plugin-ai"
|
||||
},
|
||||
{
|
||||
"path": "../plugin-data-grid"
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
"@cloudbeaver/core-ui": "workspace:*",
|
||||
"@cloudbeaver/core-utils": "workspace:*",
|
||||
"@cloudbeaver/core-view": "workspace:*",
|
||||
"@cloudbeaver/plugin-ai": "workspace:*",
|
||||
"@cloudbeaver/plugin-codemirror6": "workspace:*",
|
||||
"@cloudbeaver/plugin-datasource-context-switch": "workspace:*",
|
||||
"@cloudbeaver/plugin-navigation-tabs": "workspace:*",
|
||||
|
||||
+6
-1
@@ -9,9 +9,10 @@
|
||||
import { observer } from 'mobx-react-lite';
|
||||
|
||||
import { MenuGroup, MenuGroupLabel, MenuItemRadio } from '@dbeaver/ui-kit';
|
||||
import { RadioIndicator, useTranslate } from '@cloudbeaver/core-blocks';
|
||||
import { IconOrImage, RadioIndicator, useResource, useTranslate } from '@cloudbeaver/core-blocks';
|
||||
import { useService } from '@cloudbeaver/core-di';
|
||||
import { NotificationService } from '@cloudbeaver/core-events';
|
||||
import { AiEnginesResource } from '@cloudbeaver/plugin-ai';
|
||||
|
||||
import type { AIChatProfile } from '../../../AIChatProfilesResource.js';
|
||||
import type { AIChatConversationInfo } from '../AIChatConversationsResource.js';
|
||||
@@ -28,6 +29,8 @@ export const AIChatConversationProfile = observer<Props>(function AIChatConversa
|
||||
const notificationService = useService(NotificationService);
|
||||
const aiChatConversationsService = useService(AIChatConversationsService);
|
||||
|
||||
const aiEnginesResource = useResource(AIChatConversationProfile, AiEnginesResource, undefined);
|
||||
|
||||
async function selectProfile(profileId: string) {
|
||||
try {
|
||||
await aiChatConversationsService.updateConversationProfile(conversation.id, profileId);
|
||||
@@ -46,6 +49,7 @@ export const AIChatConversationProfile = observer<Props>(function AIChatConversa
|
||||
|
||||
{profiles.map(profile => {
|
||||
const isCurrent = conversation.profile === profile.id;
|
||||
const engine = aiEnginesResource.data.find(e => e.id === profile.engineId);
|
||||
|
||||
return (
|
||||
<MenuItemRadio
|
||||
@@ -62,6 +66,7 @@ export const AIChatConversationProfile = observer<Props>(function AIChatConversa
|
||||
>
|
||||
<div className="tw:flex tw:items-center tw:gap-2 tw:overflow-hidden">
|
||||
<RadioIndicator size="small" checked={isCurrent} />
|
||||
{engine?.icon && <IconOrImage className="tw:w-[14px]" icon={engine.icon} />}
|
||||
<span className="tw:truncate">{profile.name}</span>
|
||||
</div>
|
||||
</MenuItemRadio>
|
||||
|
||||
+5
@@ -92,6 +92,11 @@ export class AIChatConversationsResource extends CachedMapResource<string, AICha
|
||||
return this.get(conversation.id)!;
|
||||
}
|
||||
|
||||
async cancelConversation(conversationId: string): Promise<boolean> {
|
||||
const { result } = await this.graphQLService.sdk.cancelConversation({ conversationId });
|
||||
return result;
|
||||
}
|
||||
|
||||
protected async loader(originalKey: ResourceKey<string>): Promise<Map<string, AIChatConversationInfo>> {
|
||||
const conversationList: AIChatConversationInfo[] = [];
|
||||
|
||||
|
||||
@@ -13,10 +13,12 @@ import { ActionIconButton, AutoResizeTextarea, Form, s, useS, useTranslate } fro
|
||||
import { useService } from '@cloudbeaver/core-di';
|
||||
import { getOS, OperatingSystem } from '@cloudbeaver/core-utils';
|
||||
import { NotificationService } from '@cloudbeaver/core-events';
|
||||
import { Command } from '@dbeaver/ui-kit';
|
||||
|
||||
import { AIChatMessageService } from './AIChatMessageService.js';
|
||||
import { AIChatConversationsService } from '../AIChatConversation/AIChatConversationsService.js';
|
||||
import { AIChatContext } from '../AIChatContext.js';
|
||||
import { AIChatConversationsResource } from '../AIChatConversation/AIChatConversationsResource.js';
|
||||
import classes from './AIChatMessageForm.module.css';
|
||||
|
||||
interface Props {
|
||||
@@ -30,6 +32,7 @@ export const AIChatMessageForm = observer<PropsWithChildren<Props>>(function AIC
|
||||
const notificationService = useService(NotificationService);
|
||||
const aiChatMessageService = useService(AIChatMessageService);
|
||||
const aiChatConversationsService = useService(AIChatConversationsService);
|
||||
const aiChatConversationsResource = useService(AIChatConversationsResource);
|
||||
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
@@ -54,6 +57,16 @@ export const AIChatMessageForm = observer<PropsWithChildren<Props>>(function AIC
|
||||
}
|
||||
}
|
||||
|
||||
async function cancel() {
|
||||
if (currentConversationId) {
|
||||
try {
|
||||
await aiChatConversationsResource.cancelConversation(currentConversationId);
|
||||
} catch (exception: any) {
|
||||
notificationService.logException(exception, 'plugin_ai_chat_conversation_cancel_failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getPlaceholder() {
|
||||
const OS = getOS();
|
||||
const symbol = OS === OperatingSystem.macOS ? '⌘' : 'Ctrl';
|
||||
@@ -65,7 +78,12 @@ export const AIChatMessageForm = observer<PropsWithChildren<Props>>(function AIC
|
||||
|
||||
return (
|
||||
<div className={s(styles, { container: true })}>
|
||||
<Form className="tw:flex tw:items-end tw:gap-2" disableEnterSubmit={disabled} contents onSubmit={sendMessage}>
|
||||
<Form
|
||||
className="tw:flex tw:items-end tw:gap-2"
|
||||
disableEnterSubmit={disabled}
|
||||
contents
|
||||
onSubmit={aiChatConversationsService.processing ? cancel : sendMessage}
|
||||
>
|
||||
<div className={s(styles, { textareaContainer: true })}>
|
||||
<AutoResizeTextarea
|
||||
className={s(styles, { textarea: true })}
|
||||
@@ -74,7 +92,16 @@ export const AIChatMessageForm = observer<PropsWithChildren<Props>>(function AIC
|
||||
autoFocus
|
||||
onChange={v => setValue(v)}
|
||||
/>
|
||||
<ActionIconButton name="/icons/send.svg" disabled={disabled} img onClick={sendMessage} />
|
||||
{!aiChatConversationsService.processing ? (
|
||||
<ActionIconButton name="/icons/send.svg" disabled={disabled} img onClick={sendMessage} />
|
||||
) : (
|
||||
<Command
|
||||
className="tw:cursor-pointer tw:w-10 tw:h-10 tw:bg-[var(--theme-primary)] tw:rounded-md tw:flex tw:items-center tw:justify-center tw:focus:opacity-80 tw:hover:opacity-80 tw:transition-opacity"
|
||||
onClick={cancel}
|
||||
>
|
||||
<div className="tw:w-3 tw:h-3 tw:bg-[var(--theme-surface)] tw:rounded-xs" />
|
||||
</Command>
|
||||
)}
|
||||
</div>
|
||||
</Form>
|
||||
{children}
|
||||
|
||||
@@ -44,6 +44,8 @@ export default [
|
||||
['plugin_ai_chat_conversation_history_date_group_last_7_days', 'Last 7 days'],
|
||||
['plugin_ai_chat_conversation_history_date_group_over_week_ago', 'Over a week ago'],
|
||||
|
||||
['plugin_ai_chat_conversation_cancel_failed', 'Failed to cancel the conversation'],
|
||||
|
||||
['plugin_ai_chat_scope_change', 'Configure AI context'],
|
||||
['plugin_ai_chat_scope_change_fail', 'Failed to change context'],
|
||||
['plugin_ai_chat_profile_group', 'Active configuration'],
|
||||
|
||||
@@ -44,6 +44,8 @@ export default [
|
||||
['plugin_ai_chat_conversation_history_date_group_last_7_days', 'Last 7 days'],
|
||||
['plugin_ai_chat_conversation_history_date_group_over_week_ago', 'Over a week ago'],
|
||||
|
||||
['plugin_ai_chat_conversation_cancel_failed', 'Impossibile annullare la conversazione'],
|
||||
|
||||
['plugin_ai_chat_scope_change', 'Configure AI context'],
|
||||
['plugin_ai_chat_scope_change_fail', 'Failed to change context'],
|
||||
['plugin_ai_chat_profile_group', 'Active configuration'],
|
||||
|
||||
@@ -44,6 +44,8 @@ export default [
|
||||
['plugin_ai_chat_conversation_history_date_group_last_7_days', 'Последние 7 дней'],
|
||||
['plugin_ai_chat_conversation_history_date_group_over_week_ago', 'Более недели назад'],
|
||||
|
||||
['plugin_ai_chat_conversation_cancel_failed', 'Не удалось отменить разговор'],
|
||||
|
||||
['plugin_ai_chat_scope_change', 'Настроить AI контекст'],
|
||||
['plugin_ai_chat_scope_change_fail', 'Не удалось изменить контекст'],
|
||||
['plugin_ai_chat_profile_group', 'Активная конфигурация'],
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user