Merge branch 'master' of github.com:Mintplex-Labs/anything-llm

This commit is contained in:
Timothy Carambat
2026-01-29 16:43:28 -08:00
6 changed files with 420 additions and 75 deletions

View File

@@ -1,7 +1,9 @@
import PostgreSQLLogo from "./icons/postgresql.png";
import MySQLLogo from "./icons/mysql.png";
import MSSQLLogo from "./icons/mssql.png";
import { X } from "@phosphor-icons/react";
import { PencilSimple, X } from "@phosphor-icons/react";
import { useModal } from "@/hooks/useModal";
import EditSQLConnection from "./SQLConnectionModal";
export const DB_LOGOS = {
postgresql: PostgreSQLLogo,
@@ -9,8 +11,16 @@ export const DB_LOGOS = {
"sql-server": MSSQLLogo,
};
export default function DBConnection({ connection, onRemove }) {
export default function DBConnection({
connection,
onRemove,
onUpdate,
setHasChanges,
connections = [],
}) {
const { database_id, engine } = connection;
const { isOpen, openModal, closeModal } = useModal();
function removeConfirmation() {
if (
!window.confirm(
@@ -33,14 +43,33 @@ export default function DBConnection({ connection, onRemove }) {
<div className="text-sm font-semibold text-white">{database_id}</div>
<div className="mt-1 text-xs text-description">{engine}</div>
</div>
<button
type="button"
onClick={removeConfirmation}
className="border-none text-white hover:text-red-500"
>
<X size={24} />
</button>
<div className="flex gap-x-2">
<button
type="button"
data-tooltip-id="edit-sql-connection-tooltip"
className="border-none text-theme-text-secondary hover:text-theme-text-primary transition-colors duration-200 p-1 rounded"
onClick={openModal}
>
<PencilSimple size={18} />
</button>
<button
type="button"
data-tooltip-id="delete-sql-connection-tooltip"
onClick={removeConfirmation}
className="border-none text-theme-text-secondary hover:text-red-500"
>
<X size={18} />
</button>
</div>
</div>
<EditSQLConnection
isOpen={isOpen}
closeModal={closeModal}
existingConnection={connection}
onSubmit={onUpdate}
setHasChanges={setHasChanges}
connections={connections}
/>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { createPortal } from "react-dom";
import ModalWrapper from "@/components/ModalWrapper";
import { WarningOctagon, X } from "@phosphor-icons/react";
@@ -7,6 +7,33 @@ import System from "@/models/system";
import showToast from "@/utils/toast";
import Toggle from "@/components/lib/Toggle";
/**
* Converts a string to a URL-friendly slug format.
* Matches backend slugify behavior for consistent database_id generation.
* @param {string} str - The string to slugify
* @returns {string} - The slugified string (lowercase, hyphens, no special chars)
*/
function slugify(str) {
return str
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, "") // Remove special characters
.replace(/[\s_]+/g, "-") // Replace spaces and underscores with hyphens
.replace(/^-+|-+$/g, ""); // Remove leading/trailing hyphens
}
/**
* Assembles a database connection string based on the engine type and configuration.
* @param {Object} params - Connection parameters
* @param {string} params.engine - The database engine ('postgresql', 'mysql', or 'sql-server')
* @param {string} [params.username=""] - Database username
* @param {string} [params.password=""] - Database password
* @param {string} [params.host=""] - Database host/endpoint
* @param {string} [params.port=""] - Database port
* @param {string} [params.database=""] - Database name
* @param {boolean} [params.encrypt=false] - Enable encryption (SQL Server only)
* @returns {string|null} - The assembled connection string, error message if fields missing, or null if engine invalid
*/
function assembleConnectionString({
engine,
username = "",
@@ -32,6 +59,7 @@ function assembleConnectionString({
const DEFAULT_ENGINE = "postgresql";
const DEFAULT_CONFIG = {
name: "",
username: null,
password: null,
host: null,
@@ -41,15 +69,59 @@ const DEFAULT_CONFIG = {
encrypt: false,
};
export default function NewSQLConnection({
/**
* Modal component for creating or editing SQL database connections.
* Supports PostgreSQL, MySQL, and SQL Server with connection validation.
* Handles duplicate connection name detection and connection string assembly.
*
* @param {Object} props - Component props
* @param {boolean} props.isOpen - Whether the modal is currently open
* @param {Function} props.closeModal - Callback to close the modal
* @param {Function} props.onSubmit - Callback when connection is successfully validated and saved
* @param {Function} props.setHasChanges - Callback to mark that changes have been made
* @param {Object|null} [props.existingConnection=null] - Existing connection data for edit mode (contains database_id, engine, username, password, host, port, database, scheme, encrypt)
* @param {Array} [props.connections=[]] - List of all existing connections for duplicate detection
* @returns {React.ReactPortal|null} - Portal containing the modal UI, or null if not open
*/
export default function SQLConnectionModal({
isOpen,
closeModal,
onSubmit,
setHasChanges,
existingConnection = null, // { database_id, engine } for edit mode
connections = [], // List of all existing connections for duplicate detection
}) {
const isEditMode = !!existingConnection;
const [engine, setEngine] = useState(DEFAULT_ENGINE);
const [config, setConfig] = useState(DEFAULT_CONFIG);
const [isValidating, setIsValidating] = useState(false);
// Sync state when modal opens - useState initial values only run once on mount,
// so we need this effect to update state when the modal is reopened
useEffect(() => {
if (!isOpen) return;
if (existingConnection) {
setEngine(existingConnection.engine);
setConfig({
name: existingConnection.database_id,
username: existingConnection.username,
password: existingConnection.password,
host: existingConnection.host,
port: existingConnection.port,
database: existingConnection.database,
scheme: existingConnection.scheme,
encrypt: existingConnection?.encrypt,
});
} else {
setEngine(DEFAULT_ENGINE);
setConfig(DEFAULT_CONFIG);
}
}, [isOpen, existingConnection]);
// Track original database ID to send to server for updating if in edit mode
const originalDatabaseId = isEditMode ? existingConnection.database_id : null;
if (!isOpen) return null;
function handleClose() {
@@ -61,6 +133,7 @@ export default function NewSQLConnection({
function onFormChange(e) {
const form = new FormData(e.target.form);
setConfig({
name: form.get("name").trim(),
username: form.get("username").trim(),
password: form.get("password"),
host: form.get("host").trim(),
@@ -70,14 +143,61 @@ export default function NewSQLConnection({
});
}
/**
* Checks if a connection name (slugified) already exists in the connections list.
* For edit mode, excludes the original connection being edited.
* @param {string} slugifiedName - The slugified name to check
* @returns {boolean} - True if duplicate exists, false otherwise
*/
function isDuplicateConnectionName(slugifiedName) {
// Get active connections (not marked for removal)
const activeConnections = connections.filter(
(conn) => conn.action !== "remove"
);
// Check for duplicates, excluding the original connection in edit mode
return activeConnections.some((conn) => {
// In edit mode, skip the original connection being edited
if (isEditMode && conn.database_id === originalDatabaseId) {
return false;
}
return conn.database_id === slugifiedName;
});
}
/**
* Handles form submission for both creating new connections and updating existing ones.
* Process:
* 1. Slugify the connection name to match backend behavior
* 2. Check for duplicate names (prevents frontend from sending invalid updates)
* 3. Validate the connection string by attempting to connect to the database
* 4. If valid, submit with appropriate action ("add" or "update")
*
* For updates: Includes originalDatabaseId so backend can find and replace the old connection
* For new connections: Just includes the new connection data
*/
async function handleUpdate(e) {
e.preventDefault();
e.stopPropagation();
const form = new FormData(e.target);
const connectionString = assembleConnectionString({ engine, ...config });
// Slugify the database_id immediately to match backend behavior
const slugifiedDatabaseId = slugify(form.get("name"));
// Check for duplicate connection names before validation
if (isDuplicateConnectionName(slugifiedDatabaseId)) {
showToast(
`A connection with the name "${slugifiedDatabaseId}" already exists. Please choose a different name.`,
"error",
{ clear: true }
);
return;
}
setIsValidating(true);
try {
// Validate that we can actually connect to this database
const { success, error } = await System.validateSQLConnection(
engine,
connectionString
@@ -93,11 +213,30 @@ export default function NewSQLConnection({
return;
}
onSubmit({
const connectionData = {
engine,
database_id: form.get("name"),
database_id: slugifiedDatabaseId,
connectionString,
});
};
if (isEditMode) {
// EDIT MODE: Send update action with originalDatabaseId
// This tells the backend to find the connection with originalDatabaseId
// and replace it with the new connection data
onSubmit({
...connectionData,
action: "update",
originalDatabaseId: originalDatabaseId,
});
} else {
// CREATE MODE: Send add action
// Backend will check for duplicates and add if unique
onSubmit({
...connectionData,
action: "add",
});
}
setHasChanges(true);
handleClose();
} catch (error) {
@@ -123,7 +262,7 @@ export default function NewSQLConnection({
<div className="relative p-6 border-b rounded-t border-theme-modal-border">
<div className="w-full flex gap-x-2 items-center">
<h3 className="text-xl font-semibold text-white overflow-hidden overflow-ellipsis whitespace-nowrap">
New SQL Connection
{isEditMode ? "Edit SQL Connection" : "New SQL Connection"}
</h3>
</div>
<button
@@ -142,8 +281,9 @@ export default function NewSQLConnection({
<div className="px-7 py-6">
<div className="space-y-6 max-h-[60vh] overflow-y-auto pr-2">
<p className="text-sm text-white/60">
Add the connection information for your database below and it
will be available for future SQL agent calls.
{isEditMode
? "Update the connection information for your database below."
: "Add the connection information for your database below and it will be available for future SQL agent calls."}
</p>
<div className="flex flex-col w-full">
<div className="border border-red-800 bg-zinc-800 light:bg-red-200/50 p-4 rounded-lg flex items-center gap-x-2 text-sm text-red-400 light:text-red-500">
@@ -191,6 +331,7 @@ export default function NewSQLConnection({
required={true}
autoComplete="off"
spellCheck={false}
defaultValue={config.name || ""}
/>
</div>
@@ -207,6 +348,7 @@ export default function NewSQLConnection({
required={true}
autoComplete="off"
spellCheck={false}
defaultValue={config.username || ""}
/>
</div>
<div className="flex flex-col">
@@ -214,13 +356,14 @@ export default function NewSQLConnection({
Database user password
</label>
<input
type="text"
type="password"
name="password"
className="border-none bg-theme-settings-input-bg w-full text-white placeholder:text-theme-settings-input-placeholder text-sm rounded-lg focus:outline-primary-button active:outline-primary-button outline-none block w-full p-2.5"
placeholder="password123"
required={true}
autoComplete="off"
spellCheck={false}
defaultValue={config.password || ""}
/>
</div>
</div>
@@ -238,6 +381,7 @@ export default function NewSQLConnection({
required={true}
autoComplete="off"
spellCheck={false}
defaultValue={config.host || ""}
/>
</div>
<div>
@@ -252,6 +396,7 @@ export default function NewSQLConnection({
required={false}
autoComplete="off"
spellCheck={false}
defaultValue={config.port || ""}
/>
</div>
</div>
@@ -268,6 +413,7 @@ export default function NewSQLConnection({
required={true}
autoComplete="off"
spellCheck={false}
defaultValue={config.database || ""}
/>
</div>
@@ -284,6 +430,7 @@ export default function NewSQLConnection({
required={false}
autoComplete="off"
spellCheck={false}
defaultValue={config.schema || ""}
/>
</div>
)}
@@ -328,6 +475,16 @@ export default function NewSQLConnection({
);
}
/**
* Database engine selection button component.
* Displays a database logo and handles selection state.
*
* @param {Object} props - Component props
* @param {string} props.provider - The database provider identifier ('postgresql', 'mysql', 'sql-server')
* @param {boolean} props.active - Whether this engine is currently selected
* @param {Function} props.onClick - Callback when the engine is clicked
* @returns {JSX.Element} - Button element with database logo
*/
function DBEngine({ provider, active, onClick }) {
return (
<button

View File

@@ -1,11 +1,12 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useState, useRef } from "react";
import DBConnection from "./DBConnection";
import { Plus, Database } from "@phosphor-icons/react";
import NewSQLConnection from "./NewConnectionModal";
import { Plus, Database, CircleNotch } from "@phosphor-icons/react";
import NewSQLConnection from "./SQLConnectionModal";
import { useModal } from "@/hooks/useModal";
import SQLAgentImage from "@/media/agents/sql-agent.png";
import Admin from "@/models/admin";
import Toggle from "@/components/lib/Toggle";
import { Tooltip } from "react-tooltip";
export default function AgentSQLConnectorSelection({
skill,
@@ -13,15 +14,40 @@ export default function AgentSQLConnectorSelection({
toggleSkill,
enabled = false,
setHasChanges,
hasChanges = false,
}) {
const { isOpen, openModal, closeModal } = useModal();
const [connections, setConnections] = useState([]);
const [loading, setLoading] = useState(true);
const prevHasChanges = useRef(hasChanges);
// Load connections on mount
useEffect(() => {
setLoading(true);
Admin.systemPreferencesByFields(["agent_sql_connections"])
.then((res) => setConnections(res?.settings?.agent_sql_connections ?? []))
.catch(() => setConnections([]));
.catch(() => setConnections([]))
.finally(() => setLoading(false));
}, []);
// Refresh connections from backend when save completes (hasChanges: true -> false)
// This ensures we get clean data without stale action properties
useEffect(() => {
if (prevHasChanges.current === true && hasChanges === false) {
Admin.systemPreferencesByFields(["agent_sql_connections"])
.then((res) =>
setConnections(res?.settings?.agent_sql_connections ?? [])
)
.catch(() => {});
}
prevHasChanges.current = hasChanges;
}, [hasChanges]);
/**
* Marks a connection for removal by adding action: "remove".
* The connection stays in the array (for undo capability) until saved.
* @param {string} databaseId - The database_id of the connection to remove
*/
function handleRemoveConnection(databaseId) {
setHasChanges(true);
setConnections((prev) =>
@@ -33,6 +59,38 @@ export default function AgentSQLConnectorSelection({
);
}
/**
* Updates an existing connection by replacing it in the local state.
* This removes the old connection (by originalDatabaseId) and adds the updated version.
*
* Note: The old connection is removed from local state immediately, but the backend
* handles the actual update logic when saved. See mergeConnections in server/models/systemSettings.js
*
* @param {Object} updatedConnection - The updated connection data
* @param {string} updatedConnection.originalDatabaseId - The original database_id before the update
* @param {string} updatedConnection.database_id - The new database_id
* @param {string} updatedConnection.action - Should be "update"
*/
function handleUpdateConnection(updatedConnection) {
setHasChanges(true);
setConnections((prev) =>
prev.map((conn) =>
conn.database_id === updatedConnection.originalDatabaseId
? updatedConnection
: conn
)
);
}
/**
* Adds a new connection to the local state with action: "add".
* The backend will validate and deduplicate when saved.
* @param {Object} newConnection - The new connection data with action: "add"
*/
function handleAddConnection(newConnection) {
setHasChanges(true);
setConnections((prev) => [...prev, newConnection]);
}
return (
<>
<div className="p-2">
@@ -84,15 +142,27 @@ export default function AgentSQLConnectorSelection({
Your database connections
</p>
<div className="flex flex-col gap-y-3">
{connections
.filter((connection) => connection.action !== "remove")
.map((connection) => (
<DBConnection
key={connection.database_id}
connection={connection}
onRemove={handleRemoveConnection}
{loading ? (
<div className="flex items-center justify-center py-4">
<CircleNotch
size={24}
className="animate-spin text-theme-text-primary"
/>
))}
</div>
) : (
connections
.filter((connection) => connection.action !== "remove")
.map((connection) => (
<DBConnection
key={connection.database_id}
connection={connection}
onRemove={handleRemoveConnection}
onUpdate={handleUpdateConnection}
setHasChanges={setHasChanges}
connections={connections}
/>
))
)}
<button
type="button"
onClick={openModal}
@@ -121,9 +191,32 @@ export default function AgentSQLConnectorSelection({
isOpen={isOpen}
closeModal={closeModal}
setHasChanges={setHasChanges}
onSubmit={(newDb) =>
setConnections((prev) => [...prev, { action: "add", ...newDb }])
}
onSubmit={handleAddConnection}
connections={connections}
/>
<Tooltip
id="edit-sql-connection-tooltip"
content="Edit SQL connection"
place="top"
delayShow={300}
className="tooltip !text-xs !opacity-100"
style={{
maxWidth: "250px",
whiteSpace: "normal",
wordWrap: "break-word",
}}
/>
<Tooltip
id="delete-sql-connection-tooltip"
content="Delete SQL connection"
place="top"
delayShow={300}
className="tooltip !text-xs !opacity-100"
style={{
maxWidth: "250px",
whiteSpace: "normal",
wordWrap: "break-word",
}}
/>
</>
);

View File

@@ -400,6 +400,7 @@ export default function AdminAgents() {
configurableSkills[selectedSkill]?.skill
)}
setHasChanges={setHasChanges}
hasChanges={hasChanges}
{...configurableSkills[selectedSkill]}
/>
)}
@@ -591,6 +592,7 @@ export default function AdminAgents() {
configurableSkills[selectedSkill]?.skill
)}
setHasChanges={setHasChanges}
hasChanges={hasChanges}
{...configurableSkills[selectedSkill]}
/>
)}

View File

@@ -376,7 +376,7 @@ function adminEndpoints(app) {
break;
case "agent_sql_connections":
requestedSettings[label] =
await SystemSettings.brief.agent_sql_connections();
await SystemSettings.agent_sql_connections();
break;
case "default_agent_skills":
requestedSettings[label] = safeJsonParse(setting?.value, []);

View File

@@ -10,6 +10,9 @@ const { MetaGenerator } = require("../utils/boot/MetaGenerator");
const { PGVector } = require("../utils/vectorDbProviders/pgvector");
const { NativeEmbedder } = require("../utils/EmbeddingEngines/native");
const { getBaseLLMProviderModel } = require("../utils/helpers");
const {
ConnectionStringParser,
} = require("../utils/agents/aibitat/plugins/sql-agent/SQLConnectors/utils");
function isNullOrNaN(value) {
if (value === null) return true;
@@ -681,18 +684,31 @@ const SystemSettings = {
};
},
// For special retrieval of a key setting that does not expose any credential information
brief: {
agent_sql_connections: async function () {
const setting = await SystemSettings.get({
label: "agent_sql_connections",
});
if (!setting) return [];
return safeJsonParse(setting.value, []).map((dbConfig) => {
const { connectionString, ...rest } = dbConfig;
return rest;
});
},
agent_sql_connections: async function () {
const setting = await SystemSettings.get({
label: "agent_sql_connections",
});
if (!setting) return [];
const connections = safeJsonParse(setting.value, []).map((conn) => {
let scheme = conn.engine;
if (scheme === "sql-server") scheme = "mssql";
if (scheme === "postgresql") scheme = "postgres";
const parser = new ConnectionStringParser({ scheme });
const parsed = parser.parse(conn.connectionString);
return {
...conn,
username: parsed.username,
password: parsed.password,
host: parsed.hosts?.[0]?.host,
port: parsed.hosts?.[0]?.port,
database: parsed.endpoint,
scheme: parsed.scheme,
};
});
return connections;
},
getFeatureFlags: async function () {
return {
@@ -742,42 +758,90 @@ const SystemSettings = {
},
};
/**
* Merges SQL connection updates from the frontend with existing backend connections.
* Processes three types of actions: "remove", "update", and "add".
*
* @param {Array<Object>} existingConnections - Current connections stored in the database
* @param {Array<Object>} updates - Connection updates from frontend, each with an action property
* @returns {Array<Object>} - The merged connections array
*/
function mergeConnections(existingConnections = [], updates = []) {
let updatedConnections = [...existingConnections];
const existingDbIds = existingConnections.map((conn) => conn.database_id);
// First remove all 'action:remove' candidates from existing connections.
const toRemove = updates
.filter((conn) => conn.action === "remove")
.map((conn) => conn.database_id);
updatedConnections = updatedConnections.filter(
(conn) => !toRemove.includes(conn.database_id)
const connectionsMap = new Map(
existingConnections.map((conn) => [conn.database_id, conn])
);
// Next add all 'action:add' candidates into the updatedConnections; We DO NOT validate the connection strings.
// but we do validate their database_id is unique.
updates
.filter((conn) => conn.action === "add")
.forEach((update) => {
if (!update.connectionString) return; // invalid connection string
for (const update of updates) {
const {
action,
database_id,
originalDatabaseId,
connectionString,
engine,
} = update;
// Remap name to be unique to entire set.
if (existingDbIds.includes(update.database_id)) {
update.database_id = slugify(
`${update.database_id}-${v4().slice(0, 4)}`
);
} else {
update.database_id = slugify(update.database_id);
switch (action) {
case "remove": {
connectionsMap.delete(database_id);
break;
}
case "update": {
if (!connectionString) continue;
const newId = slugify(database_id);
// Verify original connection exists
if (!connectionsMap.has(originalDatabaseId)) {
console.warn(
`[mergeConnections] Update skipped: Original connection "${originalDatabaseId}" not found`
);
break;
}
// Check for name conflict (excluding the one being updated)
if (newId !== originalDatabaseId && connectionsMap.has(newId)) {
console.warn(
`[mergeConnections] Update skipped: New name "${newId}" conflicts with existing connection`
);
break;
}
// Remove old and add updated connection
connectionsMap.delete(originalDatabaseId);
connectionsMap.set(newId, {
engine,
database_id: newId,
connectionString,
});
break;
}
updatedConnections.push({
engine: update.engine,
database_id: update.database_id,
connectionString: update.connectionString,
});
});
case "add": {
if (!connectionString) continue;
const slugifiedId = slugify(database_id);
return updatedConnections;
// Skip if already exists
if (connectionsMap.has(slugifiedId)) {
console.warn(
`[mergeConnections] Add skipped: Connection "${slugifiedId}" already exists`
);
break;
}
connectionsMap.set(slugifiedId, {
engine,
database_id: slugifiedId,
connectionString,
});
break;
}
default: {
throw new Error("SQL connection update contains an invalid action.");
}
}
}
return Array.from(connectionsMap.values());
}
module.exports.SystemSettings = SystemSettings;