mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-24 02:39:19 -05:00
feat: introduce auto routing (#122)
This commit is contained in:
BIN
frontend/favicon.ico
Normal file → Executable file
BIN
frontend/favicon.ico
Normal file → Executable file
Binary file not shown.
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 5.1 KiB |
@@ -14,14 +14,14 @@ const DeleteDialog = ({ isOpen, onClose, onConfirm, serverName, isGroup = false
|
|||||||
if (!isOpen) return null
|
if (!isOpen) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-30 z-50 flex items-center justify-center p-4">
|
<div className="fixed inset-0 bg-black/50 bg-opacity-30 z-50 flex items-center justify-center p-4">
|
||||||
<div className="bg-white rounded-lg shadow-lg max-w-md w-full">
|
<div className="bg-white rounded-lg shadow-lg max-w-md w-full">
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-3">
|
<h3 className="text-lg font-medium text-gray-900 mb-3">
|
||||||
{isGroup ? t('groups.confirmDelete') : t('server.confirmDelete')}
|
{isGroup ? t('groups.confirmDelete') : t('server.confirmDelete')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-500 mb-6">
|
<p className="text-gray-500 mb-6">
|
||||||
{isGroup
|
{isGroup
|
||||||
? t('groups.deleteWarning', { name: serverName })
|
? t('groups.deleteWarning', { name: serverName })
|
||||||
: t('server.deleteWarning', { name: serverName })}
|
: t('server.deleteWarning', { name: serverName })}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { createContext, useContext, useState, ReactNode } from 'react';
|
import React, { createContext, useContext, useState, ReactNode, useCallback } from 'react';
|
||||||
import Toast, { ToastType } from '@/components/ui/Toast';
|
import Toast, { ToastType } from '@/components/ui/Toast';
|
||||||
|
|
||||||
interface ToastContextProps {
|
interface ToastContextProps {
|
||||||
@@ -32,18 +32,18 @@ export const ToastProvider: React.FC<ToastProviderProps> = ({ children }) => {
|
|||||||
duration: 3000,
|
duration: 3000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const showToast = (message: string, type: ToastType = 'info', duration: number = 3000) => {
|
const showToast = useCallback((message: string, type: ToastType = 'info', duration: number = 3000) => {
|
||||||
setToast({
|
setToast({
|
||||||
message,
|
message,
|
||||||
type,
|
type,
|
||||||
visible: true,
|
visible: true,
|
||||||
duration,
|
duration,
|
||||||
});
|
});
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const hideToast = () => {
|
const hideToast = useCallback(() => {
|
||||||
setToast((prev) => ({ ...prev, visible: false }));
|
setToast((prev) => ({ ...prev, visible: false }));
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToastContext.Provider value={{ showToast }}>
|
<ToastContext.Provider value={{ showToast }}>
|
||||||
|
|||||||
@@ -16,10 +16,19 @@ interface InstallConfig {
|
|||||||
npmRegistry: string;
|
npmRegistry: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SmartRoutingConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
dbUrl: string;
|
||||||
|
openaiApiBaseUrl: string;
|
||||||
|
openaiApiKey: string;
|
||||||
|
openaiApiEmbeddingModel: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface SystemSettings {
|
interface SystemSettings {
|
||||||
systemConfig?: {
|
systemConfig?: {
|
||||||
routing?: RoutingConfig;
|
routing?: RoutingConfig;
|
||||||
install?: InstallConfig;
|
install?: InstallConfig;
|
||||||
|
smartRouting?: SmartRoutingConfig;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +56,14 @@ export const useSettingsData = () => {
|
|||||||
npmRegistry: '',
|
npmRegistry: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [smartRoutingConfig, setSmartRoutingConfig] = useState<SmartRoutingConfig>({
|
||||||
|
enabled: false,
|
||||||
|
dbUrl: '',
|
||||||
|
openaiApiBaseUrl: '',
|
||||||
|
openaiApiKey: '',
|
||||||
|
openaiApiEmbeddingModel: '',
|
||||||
|
});
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [refreshKey, setRefreshKey] = useState(0);
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
@@ -89,14 +106,25 @@ export const useSettingsData = () => {
|
|||||||
npmRegistry: data.data.systemConfig.install.npmRegistry || '',
|
npmRegistry: data.data.systemConfig.install.npmRegistry || '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (data.success && data.data?.systemConfig?.smartRouting) {
|
||||||
|
setSmartRoutingConfig({
|
||||||
|
enabled: data.data.systemConfig.smartRouting.enabled ?? false,
|
||||||
|
dbUrl: data.data.systemConfig.smartRouting.dbUrl || '',
|
||||||
|
openaiApiBaseUrl: data.data.systemConfig.smartRouting.openaiApiBaseUrl || '',
|
||||||
|
openaiApiKey: data.data.systemConfig.smartRouting.openaiApiKey || '',
|
||||||
|
openaiApiEmbeddingModel:
|
||||||
|
data.data.systemConfig.smartRouting.openaiApiEmbeddingModel || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch settings:', error);
|
console.error('Failed to fetch settings:', error);
|
||||||
setError(error instanceof Error ? error.message : 'Failed to fetch settings');
|
setError(error instanceof Error ? error.message : 'Failed to fetch settings');
|
||||||
|
// 使用一个稳定的 showToast 引用,避免将其加入依赖数组
|
||||||
showToast(t('errors.failedToFetchSettings'));
|
showToast(t('errors.failedToFetchSettings'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [t, showToast]);
|
}, [t]); // 移除 showToast 依赖
|
||||||
|
|
||||||
// Update routing configuration
|
// Update routing configuration
|
||||||
const updateRoutingConfig = async <T extends keyof RoutingConfig>(
|
const updateRoutingConfig = async <T extends keyof RoutingConfig>(
|
||||||
@@ -195,6 +223,107 @@ export const useSettingsData = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Update smart routing configuration
|
||||||
|
const updateSmartRoutingConfig = async <T extends keyof SmartRoutingConfig>(
|
||||||
|
key: T,
|
||||||
|
value: SmartRoutingConfig[T],
|
||||||
|
) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('mcphub_token');
|
||||||
|
const response = await fetch('/api/system-config', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-auth-token': token || '',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
smartRouting: {
|
||||||
|
[key]: value,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.message || `HTTP error! Status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
setSmartRoutingConfig({
|
||||||
|
...smartRoutingConfig,
|
||||||
|
[key]: value,
|
||||||
|
});
|
||||||
|
showToast(t('settings.systemConfigUpdated'));
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
showToast(data.message || t('errors.failedToUpdateSmartRoutingConfig'));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update smart routing config:', error);
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : 'Failed to update smart routing config';
|
||||||
|
setError(errorMessage);
|
||||||
|
showToast(errorMessage);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update multiple smart routing configuration fields at once
|
||||||
|
const updateSmartRoutingConfigBatch = async (updates: Partial<SmartRoutingConfig>) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('mcphub_token');
|
||||||
|
const response = await fetch('/api/system-config', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-auth-token': token || '',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
smartRouting: updates,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.message || `HTTP error! Status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
setSmartRoutingConfig({
|
||||||
|
...smartRoutingConfig,
|
||||||
|
...updates,
|
||||||
|
});
|
||||||
|
showToast(t('settings.systemConfigUpdated'));
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
showToast(data.message || t('errors.failedToUpdateSmartRoutingConfig'));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update smart routing config:', error);
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : 'Failed to update smart routing config';
|
||||||
|
setError(errorMessage);
|
||||||
|
showToast(errorMessage);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Fetch settings when the component mounts or refreshKey changes
|
// Fetch settings when the component mounts or refreshKey changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSettings();
|
fetchSettings();
|
||||||
@@ -213,6 +342,7 @@ export const useSettingsData = () => {
|
|||||||
tempRoutingConfig,
|
tempRoutingConfig,
|
||||||
setTempRoutingConfig,
|
setTempRoutingConfig,
|
||||||
installConfig,
|
installConfig,
|
||||||
|
smartRoutingConfig,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
setError,
|
setError,
|
||||||
@@ -220,5 +350,7 @@ export const useSettingsData = () => {
|
|||||||
fetchSettings,
|
fetchSettings,
|
||||||
updateRoutingConfig,
|
updateRoutingConfig,
|
||||||
updateInstallConfig,
|
updateInstallConfig,
|
||||||
|
updateSmartRoutingConfig,
|
||||||
|
updateSmartRoutingConfigBatch,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -125,7 +125,8 @@
|
|||||||
"initialStartup": "The server might be starting up. Please wait a moment as this process can take some time on first launch...",
|
"initialStartup": "The server might be starting up. Please wait a moment as this process can take some time on first launch...",
|
||||||
"serverInstall": "Failed to install server",
|
"serverInstall": "Failed to install server",
|
||||||
"failedToFetchSettings": "Failed to fetch settings",
|
"failedToFetchSettings": "Failed to fetch settings",
|
||||||
"failedToUpdateRouteConfig": "Failed to update route configuration"
|
"failedToUpdateRouteConfig": "Failed to update route configuration",
|
||||||
|
"failedToUpdateSmartRoutingConfig": "Failed to update smart routing configuration"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"processing": "Processing...",
|
"processing": "Processing...",
|
||||||
@@ -170,8 +171,9 @@
|
|||||||
"account": "Account Settings",
|
"account": "Account Settings",
|
||||||
"password": "Change Password",
|
"password": "Change Password",
|
||||||
"appearance": "Appearance",
|
"appearance": "Appearance",
|
||||||
"routeConfig": "Security Configuration",
|
"routeConfig": "Security",
|
||||||
"installConfig": "Installation Configuration"
|
"installConfig": "Installation",
|
||||||
|
"smartRouting": "Smart Routing"
|
||||||
},
|
},
|
||||||
"market": {
|
"market": {
|
||||||
"title": "Server Market - (Data from mcpm.sh)"
|
"title": "Server Market - (Data from mcpm.sh)"
|
||||||
@@ -277,7 +279,20 @@
|
|||||||
"npmRegistry": "NPM Registry URL",
|
"npmRegistry": "NPM Registry URL",
|
||||||
"npmRegistryDescription": "Set npm_config_registry environment variable for NPM package installation",
|
"npmRegistryDescription": "Set npm_config_registry environment variable for NPM package installation",
|
||||||
"npmRegistryPlaceholder": "e.g. https://registry.npmjs.org/",
|
"npmRegistryPlaceholder": "e.g. https://registry.npmjs.org/",
|
||||||
"installConfig": "Installation Configuration",
|
"installConfig": "Installation",
|
||||||
"systemConfigUpdated": "System configuration updated successfully"
|
"systemConfigUpdated": "System configuration updated successfully",
|
||||||
|
"enableSmartRouting": "Enable Smart Routing",
|
||||||
|
"enableSmartRoutingDescription": "Enable smart routing feature to search the most suitable tool based on input (using $smart group name)",
|
||||||
|
"dbUrl": "PostgreSQL URL (with pgvector support)",
|
||||||
|
"dbUrlPlaceholder": "e.g. postgresql://user:password@localhost:5432/dbname",
|
||||||
|
"openaiApiBaseUrl": "OpenAI API Base URL",
|
||||||
|
"openaiApiBaseUrlPlaceholder": "https://api.openai.com/v1",
|
||||||
|
"openaiApiKey": "OpenAI API Key",
|
||||||
|
"openaiApiKeyPlaceholder": "Enter OpenAI API key",
|
||||||
|
"openaiApiEmbeddingModel": "OpenAI Embedding Model",
|
||||||
|
"openaiApiEmbeddingModelPlaceholder": "text-embedding-3-small",
|
||||||
|
"smartRoutingConfigUpdated": "Smart routing configuration updated successfully",
|
||||||
|
"smartRoutingRequiredFields": "Database URL and OpenAI API Key are required to enable smart routing",
|
||||||
|
"smartRoutingValidationError": "Please fill in the required fields before enabling Smart Routing: {{fields}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,7 +126,8 @@
|
|||||||
"serverInstall": "安装服务器失败",
|
"serverInstall": "安装服务器失败",
|
||||||
"failedToFetchSettings": "获取设置失败",
|
"failedToFetchSettings": "获取设置失败",
|
||||||
"failedToUpdateSystemConfig": "更新系统配置失败",
|
"failedToUpdateSystemConfig": "更新系统配置失败",
|
||||||
"failedToUpdateRouteConfig": "更新路由配置失败"
|
"failedToUpdateRouteConfig": "更新路由配置失败",
|
||||||
|
"failedToUpdateSmartRoutingConfig": "更新智能路由配置失败"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"processing": "处理中...",
|
"processing": "处理中...",
|
||||||
@@ -169,7 +170,8 @@
|
|||||||
"password": "修改密码",
|
"password": "修改密码",
|
||||||
"appearance": "外观",
|
"appearance": "外观",
|
||||||
"routeConfig": "安全配置",
|
"routeConfig": "安全配置",
|
||||||
"installConfig": "安装配置"
|
"installConfig": "安装",
|
||||||
|
"smartRouting": "智能路由"
|
||||||
},
|
},
|
||||||
"groups": {
|
"groups": {
|
||||||
"title": "分组管理"
|
"title": "分组管理"
|
||||||
@@ -279,6 +281,20 @@
|
|||||||
"npmRegistryDescription": "设置 npm_config_registry 环境变量,用于 NPM 包安装",
|
"npmRegistryDescription": "设置 npm_config_registry 环境变量,用于 NPM 包安装",
|
||||||
"npmRegistryPlaceholder": "例如: https://registry.npmmirror.com/",
|
"npmRegistryPlaceholder": "例如: https://registry.npmmirror.com/",
|
||||||
"installConfig": "安装配置",
|
"installConfig": "安装配置",
|
||||||
"systemConfigUpdated": "系统配置更新成功"
|
"systemConfigUpdated": "系统配置更新成功",
|
||||||
|
"enableSmartRouting": "启用智能路由",
|
||||||
|
"enableSmartRoutingDescription": "开启智能路由功能,根据输入自动搜索最合适的工具",
|
||||||
|
"dbUrl": "PostgreSQL 连接地址(支持 pgvector)",
|
||||||
|
"dbUrlPlaceholder": "例如: postgresql://user:password@localhost:5432/dbname",
|
||||||
|
"openaiApiBaseUrl": "OpenAI API 基础地址",
|
||||||
|
"openaiApiBaseUrlPlaceholder": "https://api.openai.com/v1",
|
||||||
|
"openaiApiKey": "OpenAI API 密钥",
|
||||||
|
"openaiApiKeyDescription": "用于访问 OpenAI API 的密钥",
|
||||||
|
"openaiApiKeyPlaceholder": "请输入 OpenAI API 密钥",
|
||||||
|
"openaiApiEmbeddingModel": "OpenAI 嵌入模型",
|
||||||
|
"openaiApiEmbeddingModelPlaceholder": "text-embedding-3-small",
|
||||||
|
"smartRoutingConfigUpdated": "智能路由配置更新成功",
|
||||||
|
"smartRoutingRequiredFields": "启用智能路由需要填写数据库连接地址和 OpenAI API 密钥",
|
||||||
|
"smartRoutingValidationError": "启用智能路由前请先填写必要字段:{{fields}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -26,14 +26,29 @@ const SettingsPage: React.FC = () => {
|
|||||||
npmRegistry: '',
|
npmRegistry: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [tempSmartRoutingConfig, setTempSmartRoutingConfig] = useState<{
|
||||||
|
dbUrl: string;
|
||||||
|
openaiApiBaseUrl: string;
|
||||||
|
openaiApiKey: string;
|
||||||
|
openaiApiEmbeddingModel: string;
|
||||||
|
}>({
|
||||||
|
dbUrl: '',
|
||||||
|
openaiApiBaseUrl: '',
|
||||||
|
openaiApiKey: '',
|
||||||
|
openaiApiEmbeddingModel: '',
|
||||||
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
routingConfig,
|
routingConfig,
|
||||||
tempRoutingConfig,
|
tempRoutingConfig,
|
||||||
setTempRoutingConfig,
|
setTempRoutingConfig,
|
||||||
installConfig: savedInstallConfig,
|
installConfig: savedInstallConfig,
|
||||||
|
smartRoutingConfig,
|
||||||
loading,
|
loading,
|
||||||
updateRoutingConfig,
|
updateRoutingConfig,
|
||||||
updateInstallConfig
|
updateInstallConfig,
|
||||||
|
updateSmartRoutingConfig,
|
||||||
|
updateSmartRoutingConfigBatch
|
||||||
} = useSettingsData();
|
} = useSettingsData();
|
||||||
|
|
||||||
// Update local installConfig when savedInstallConfig changes
|
// Update local installConfig when savedInstallConfig changes
|
||||||
@@ -43,13 +58,26 @@ const SettingsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [savedInstallConfig]);
|
}, [savedInstallConfig]);
|
||||||
|
|
||||||
|
// Update local tempSmartRoutingConfig when smartRoutingConfig changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (smartRoutingConfig) {
|
||||||
|
setTempSmartRoutingConfig({
|
||||||
|
dbUrl: smartRoutingConfig.dbUrl || '',
|
||||||
|
openaiApiBaseUrl: smartRoutingConfig.openaiApiBaseUrl || '',
|
||||||
|
openaiApiKey: smartRoutingConfig.openaiApiKey || '',
|
||||||
|
openaiApiEmbeddingModel: smartRoutingConfig.openaiApiEmbeddingModel || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [smartRoutingConfig]);
|
||||||
|
|
||||||
const [sectionsVisible, setSectionsVisible] = useState({
|
const [sectionsVisible, setSectionsVisible] = useState({
|
||||||
routingConfig: false,
|
routingConfig: false,
|
||||||
installConfig: false,
|
installConfig: false,
|
||||||
|
smartRoutingConfig: false,
|
||||||
password: false
|
password: false
|
||||||
});
|
});
|
||||||
|
|
||||||
const toggleSection = (section: 'routingConfig' | 'installConfig' | 'password') => {
|
const toggleSection = (section: 'routingConfig' | 'installConfig' | 'smartRoutingConfig' | 'password') => {
|
||||||
setSectionsVisible(prev => ({
|
setSectionsVisible(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[section]: !prev[section]
|
[section]: !prev[section]
|
||||||
@@ -91,6 +119,59 @@ const SettingsPage: React.FC = () => {
|
|||||||
await updateInstallConfig(key, installConfig[key]);
|
await updateInstallConfig(key, installConfig[key]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSmartRoutingConfigChange = (key: 'dbUrl' | 'openaiApiBaseUrl' | 'openaiApiKey' | 'openaiApiEmbeddingModel', value: string) => {
|
||||||
|
setTempSmartRoutingConfig({
|
||||||
|
...tempSmartRoutingConfig,
|
||||||
|
[key]: value
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveSmartRoutingConfig = async (key: 'dbUrl' | 'openaiApiBaseUrl' | 'openaiApiKey' | 'openaiApiEmbeddingModel') => {
|
||||||
|
await updateSmartRoutingConfig(key, tempSmartRoutingConfig[key]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSmartRoutingEnabledChange = async (value: boolean) => {
|
||||||
|
// If enabling Smart Routing, validate required fields and save any unsaved changes
|
||||||
|
if (value) {
|
||||||
|
const currentDbUrl = tempSmartRoutingConfig.dbUrl || smartRoutingConfig.dbUrl;
|
||||||
|
const currentOpenaiApiKey = tempSmartRoutingConfig.openaiApiKey || smartRoutingConfig.openaiApiKey;
|
||||||
|
|
||||||
|
if (!currentDbUrl || !currentOpenaiApiKey) {
|
||||||
|
const missingFields = [];
|
||||||
|
if (!currentDbUrl) missingFields.push(t('settings.dbUrl'));
|
||||||
|
if (!currentOpenaiApiKey) missingFields.push(t('settings.openaiApiKey'));
|
||||||
|
|
||||||
|
showToast(t('settings.smartRoutingValidationError', {
|
||||||
|
fields: missingFields.join(', ')
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare updates object with unsaved changes and enabled status
|
||||||
|
const updates: any = { enabled: value };
|
||||||
|
|
||||||
|
// Check for unsaved changes and include them in the batch update
|
||||||
|
if (tempSmartRoutingConfig.dbUrl !== smartRoutingConfig.dbUrl) {
|
||||||
|
updates.dbUrl = tempSmartRoutingConfig.dbUrl;
|
||||||
|
}
|
||||||
|
if (tempSmartRoutingConfig.openaiApiBaseUrl !== smartRoutingConfig.openaiApiBaseUrl) {
|
||||||
|
updates.openaiApiBaseUrl = tempSmartRoutingConfig.openaiApiBaseUrl;
|
||||||
|
}
|
||||||
|
if (tempSmartRoutingConfig.openaiApiKey !== smartRoutingConfig.openaiApiKey) {
|
||||||
|
updates.openaiApiKey = tempSmartRoutingConfig.openaiApiKey;
|
||||||
|
}
|
||||||
|
if (tempSmartRoutingConfig.openaiApiEmbeddingModel !== smartRoutingConfig.openaiApiEmbeddingModel) {
|
||||||
|
updates.openaiApiEmbeddingModel = tempSmartRoutingConfig.openaiApiEmbeddingModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save all changes in a single batch update
|
||||||
|
await updateSmartRoutingConfigBatch(updates);
|
||||||
|
} else {
|
||||||
|
// If disabling, just update the enabled status
|
||||||
|
await updateSmartRoutingConfig('enabled', value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handlePasswordChangeSuccess = () => {
|
const handlePasswordChangeSuccess = () => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
navigate('/');
|
navigate('/');
|
||||||
@@ -133,6 +214,131 @@ const SettingsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Smart Routing Configuration Settings */}
|
||||||
|
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6">
|
||||||
|
<div
|
||||||
|
className="flex justify-between items-center cursor-pointer"
|
||||||
|
onClick={() => toggleSection('smartRoutingConfig')}
|
||||||
|
>
|
||||||
|
<h2 className="font-semibold text-gray-800">{t('pages.settings.smartRouting')}</h2>
|
||||||
|
<span className="text-gray-500">
|
||||||
|
{sectionsVisible.smartRoutingConfig ? '▼' : '►'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sectionsVisible.smartRoutingConfig && (
|
||||||
|
<div className="space-y-4 mt-4">
|
||||||
|
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-gray-700">{t('settings.enableSmartRouting')}</h3>
|
||||||
|
<p className="text-sm text-gray-500">{t('settings.enableSmartRoutingDescription')}</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
disabled={loading}
|
||||||
|
checked={smartRoutingConfig.enabled}
|
||||||
|
onCheckedChange={(checked) => handleSmartRoutingEnabledChange(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 bg-gray-50 rounded-md">
|
||||||
|
<div className="mb-2">
|
||||||
|
<h3 className="font-medium text-gray-700">
|
||||||
|
<span className="text-red-500 px-1">*</span>{t('settings.dbUrl')}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={tempSmartRoutingConfig.dbUrl}
|
||||||
|
onChange={(e) => handleSmartRoutingConfigChange('dbUrl', e.target.value)}
|
||||||
|
placeholder={t('settings.dbUrlPlaceholder')}
|
||||||
|
className="flex-1 mt-1 block w-full py-2 px-3 border rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm border-gray-300"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => saveSmartRoutingConfig('dbUrl')}
|
||||||
|
disabled={loading}
|
||||||
|
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('common.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 bg-gray-50 rounded-md">
|
||||||
|
<div className="mb-2">
|
||||||
|
<h3 className="font-medium text-gray-700">
|
||||||
|
<span className="text-red-500 px-1">*</span>{t('settings.openaiApiKey')}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={tempSmartRoutingConfig.openaiApiKey}
|
||||||
|
onChange={(e) => handleSmartRoutingConfigChange('openaiApiKey', e.target.value)}
|
||||||
|
placeholder={t('settings.openaiApiKeyPlaceholder')}
|
||||||
|
className="flex-1 mt-1 block w-full py-2 px-3 border rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm border-gray-300"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => saveSmartRoutingConfig('openaiApiKey')}
|
||||||
|
disabled={loading}
|
||||||
|
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('common.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 bg-gray-50 rounded-md">
|
||||||
|
<div className="mb-2">
|
||||||
|
<h3 className="font-medium text-gray-700">{t('settings.openaiApiBaseUrl')}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={tempSmartRoutingConfig.openaiApiBaseUrl}
|
||||||
|
onChange={(e) => handleSmartRoutingConfigChange('openaiApiBaseUrl', e.target.value)}
|
||||||
|
placeholder={t('settings.openaiApiBaseUrlPlaceholder')}
|
||||||
|
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => saveSmartRoutingConfig('openaiApiBaseUrl')}
|
||||||
|
disabled={loading}
|
||||||
|
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('common.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 bg-gray-50 rounded-md">
|
||||||
|
<div className="mb-2">
|
||||||
|
<h3 className="font-medium text-gray-700">{t('settings.openaiApiEmbeddingModel')}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={tempSmartRoutingConfig.openaiApiEmbeddingModel}
|
||||||
|
onChange={(e) => handleSmartRoutingConfigChange('openaiApiEmbeddingModel', e.target.value)}
|
||||||
|
placeholder={t('settings.openaiApiEmbeddingModelPlaceholder')}
|
||||||
|
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => saveSmartRoutingConfig('openaiApiEmbeddingModel')}
|
||||||
|
disabled={loading}
|
||||||
|
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('common.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Route Configuration Settings */}
|
{/* Route Configuration Settings */}
|
||||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6">
|
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6">
|
||||||
<div
|
<div
|
||||||
@@ -296,7 +502,7 @@ const SettingsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div >
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
10
package.json
10
package.json
@@ -42,11 +42,19 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.11.1",
|
"@modelcontextprotocol/sdk": "^1.11.1",
|
||||||
|
"@types/pg": "^8.15.2",
|
||||||
"bcryptjs": "^3.0.2",
|
"bcryptjs": "^3.0.2",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
|
"dotenv-expand": "^12.0.2",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"express-validator": "^7.2.1",
|
"express-validator": "^7.2.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"openai": "^4.103.0",
|
||||||
|
"pg": "^8.16.0",
|
||||||
|
"pgvector": "^0.2.1",
|
||||||
|
"postgres": "^3.4.7",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"typeorm": "^0.3.24",
|
||||||
"uuid": "^11.1.0"
|
"uuid": "^11.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -96,4 +104,4 @@
|
|||||||
"node": "^18.0.0 || >=20.0.0"
|
"node": "^18.0.0 || >=20.0.0"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.11.0+sha256.a69e9cb077da419d47d18f1dd52e207245b29cac6e076acedbeb8be3b1a67bd7"
|
"packageManager": "pnpm@10.11.0+sha256.a69e9cb077da419d47d18f1dd52e207245b29cac6e076acedbeb8be3b1a67bd7"
|
||||||
}
|
}
|
||||||
640
pnpm-lock.yaml
generated
640
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ import {
|
|||||||
toggleServerStatus,
|
toggleServerStatus,
|
||||||
} from '../services/mcpService.js';
|
} from '../services/mcpService.js';
|
||||||
import { loadSettings, saveSettings } from '../config/index.js';
|
import { loadSettings, saveSettings } from '../config/index.js';
|
||||||
|
import { syncAllServerToolsEmbeddings } from '../services/vectorSearchService.js';
|
||||||
|
|
||||||
export const getAllServers = (_: Request, res: Response): void => {
|
export const getAllServers = (_: Request, res: Response): void => {
|
||||||
try {
|
try {
|
||||||
@@ -283,7 +284,7 @@ export const toggleServer = async (req: Request, res: Response): Promise<void> =
|
|||||||
|
|
||||||
export const updateSystemConfig = (req: Request, res: Response): void => {
|
export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||||
try {
|
try {
|
||||||
const { routing, install } = req.body;
|
const { routing, install, smartRouting } = req.body;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(!routing ||
|
(!routing ||
|
||||||
@@ -292,7 +293,13 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
|||||||
typeof routing.enableBearerAuth !== 'boolean' &&
|
typeof routing.enableBearerAuth !== 'boolean' &&
|
||||||
typeof routing.bearerAuthKey !== 'string')) &&
|
typeof routing.bearerAuthKey !== 'string')) &&
|
||||||
(!install ||
|
(!install ||
|
||||||
(typeof install.pythonIndexUrl !== 'string' && typeof install.npmRegistry !== 'string'))
|
(typeof install.pythonIndexUrl !== 'string' && typeof install.npmRegistry !== 'string')) &&
|
||||||
|
(!smartRouting ||
|
||||||
|
(typeof smartRouting.enabled !== 'boolean' &&
|
||||||
|
typeof smartRouting.dbUrl !== 'string' &&
|
||||||
|
typeof smartRouting.openaiApiBaseUrl !== 'string' &&
|
||||||
|
typeof smartRouting.openaiApiKey !== 'string' &&
|
||||||
|
typeof smartRouting.openaiApiEmbeddingModel !== 'string'))
|
||||||
) {
|
) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -314,6 +321,13 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
|||||||
pythonIndexUrl: '',
|
pythonIndexUrl: '',
|
||||||
npmRegistry: '',
|
npmRegistry: '',
|
||||||
},
|
},
|
||||||
|
smartRouting: {
|
||||||
|
enabled: false,
|
||||||
|
dbUrl: '',
|
||||||
|
openaiApiBaseUrl: '',
|
||||||
|
openaiApiKey: '',
|
||||||
|
openaiApiEmbeddingModel: '',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,6 +347,16 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!settings.systemConfig.smartRouting) {
|
||||||
|
settings.systemConfig.smartRouting = {
|
||||||
|
enabled: false,
|
||||||
|
dbUrl: '',
|
||||||
|
openaiApiBaseUrl: '',
|
||||||
|
openaiApiKey: '',
|
||||||
|
openaiApiEmbeddingModel: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (routing) {
|
if (routing) {
|
||||||
if (typeof routing.enableGlobalRoute === 'boolean') {
|
if (typeof routing.enableGlobalRoute === 'boolean') {
|
||||||
settings.systemConfig.routing.enableGlobalRoute = routing.enableGlobalRoute;
|
settings.systemConfig.routing.enableGlobalRoute = routing.enableGlobalRoute;
|
||||||
@@ -360,12 +384,77 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track smartRouting state and configuration changes
|
||||||
|
const wasSmartRoutingEnabled = settings.systemConfig.smartRouting.enabled || false;
|
||||||
|
const previousSmartRoutingConfig = { ...settings.systemConfig.smartRouting };
|
||||||
|
let needsSync = false;
|
||||||
|
|
||||||
|
if (smartRouting) {
|
||||||
|
if (typeof smartRouting.enabled === 'boolean') {
|
||||||
|
// If enabling Smart Routing, validate required fields
|
||||||
|
if (smartRouting.enabled) {
|
||||||
|
const currentDbUrl = smartRouting.dbUrl || settings.systemConfig.smartRouting.dbUrl;
|
||||||
|
const currentOpenaiApiKey =
|
||||||
|
smartRouting.openaiApiKey || settings.systemConfig.smartRouting.openaiApiKey;
|
||||||
|
|
||||||
|
if (!currentDbUrl || !currentOpenaiApiKey) {
|
||||||
|
const missingFields = [];
|
||||||
|
if (!currentDbUrl) missingFields.push('Database URL');
|
||||||
|
if (!currentOpenaiApiKey) missingFields.push('OpenAI API Key');
|
||||||
|
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: `Smart Routing requires the following fields: ${missingFields.join(', ')}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
settings.systemConfig.smartRouting.enabled = smartRouting.enabled;
|
||||||
|
}
|
||||||
|
if (typeof smartRouting.dbUrl === 'string') {
|
||||||
|
settings.systemConfig.smartRouting.dbUrl = smartRouting.dbUrl;
|
||||||
|
}
|
||||||
|
if (typeof smartRouting.openaiApiBaseUrl === 'string') {
|
||||||
|
settings.systemConfig.smartRouting.openaiApiBaseUrl = smartRouting.openaiApiBaseUrl;
|
||||||
|
}
|
||||||
|
if (typeof smartRouting.openaiApiKey === 'string') {
|
||||||
|
settings.systemConfig.smartRouting.openaiApiKey = smartRouting.openaiApiKey;
|
||||||
|
}
|
||||||
|
if (typeof smartRouting.openaiApiEmbeddingModel === 'string') {
|
||||||
|
settings.systemConfig.smartRouting.openaiApiEmbeddingModel =
|
||||||
|
smartRouting.openaiApiEmbeddingModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we need to sync embeddings
|
||||||
|
const isNowEnabled = settings.systemConfig.smartRouting.enabled || false;
|
||||||
|
const hasConfigChanged =
|
||||||
|
previousSmartRoutingConfig.dbUrl !== settings.systemConfig.smartRouting.dbUrl ||
|
||||||
|
previousSmartRoutingConfig.openaiApiBaseUrl !==
|
||||||
|
settings.systemConfig.smartRouting.openaiApiBaseUrl ||
|
||||||
|
previousSmartRoutingConfig.openaiApiKey !==
|
||||||
|
settings.systemConfig.smartRouting.openaiApiKey ||
|
||||||
|
previousSmartRoutingConfig.openaiApiEmbeddingModel !==
|
||||||
|
settings.systemConfig.smartRouting.openaiApiEmbeddingModel;
|
||||||
|
|
||||||
|
// Sync if: first time enabling OR smart routing is enabled and any config changed
|
||||||
|
needsSync = (!wasSmartRoutingEnabled && isNowEnabled) || (isNowEnabled && hasConfigChanged);
|
||||||
|
}
|
||||||
|
|
||||||
if (saveSettings(settings)) {
|
if (saveSettings(settings)) {
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: settings.systemConfig,
|
data: settings.systemConfig,
|
||||||
message: 'System configuration updated successfully',
|
message: 'System configuration updated successfully',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// If smart routing configuration changed, sync all existing server tools
|
||||||
|
if (needsSync) {
|
||||||
|
console.log('SmartRouting configuration changed - syncing all existing server tools...');
|
||||||
|
// Run sync asynchronously to avoid blocking the response
|
||||||
|
syncAllServerToolsEmbeddings().catch((error) => {
|
||||||
|
console.error('Failed to sync server tools embeddings:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
|
|||||||
318
src/db/connection.ts
Normal file
318
src/db/connection.ts
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
import 'reflect-metadata'; // Ensure reflect-metadata is imported here too
|
||||||
|
import { DataSource, DataSourceOptions } from 'typeorm';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
import dotenvExpand from 'dotenv-expand';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import entities from './entities/index.js';
|
||||||
|
import { registerPostgresVectorType } from './types/postgresVectorType.js';
|
||||||
|
import { VectorEmbeddingSubscriber } from './subscribers/VectorEmbeddingSubscriber.js';
|
||||||
|
import { loadSettings } from '../config/index.js';
|
||||||
|
|
||||||
|
// Get database URL from smart routing config or fallback to environment variable
|
||||||
|
const getDatabaseUrl = (): string => {
|
||||||
|
try {
|
||||||
|
const settings = loadSettings();
|
||||||
|
const smartRouting = settings.systemConfig?.smartRouting;
|
||||||
|
|
||||||
|
// Use smart routing dbUrl if smart routing is enabled and dbUrl is configured
|
||||||
|
if (smartRouting?.enabled && smartRouting?.dbUrl) {
|
||||||
|
console.log('Using smart routing database URL');
|
||||||
|
return smartRouting.dbUrl;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
'Failed to load settings for smart routing database URL, falling back to environment variable:',
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Default database configuration
|
||||||
|
const defaultConfig: DataSourceOptions = {
|
||||||
|
type: 'postgres',
|
||||||
|
url: getDatabaseUrl(),
|
||||||
|
synchronize: true,
|
||||||
|
entities: entities,
|
||||||
|
subscribers: [VectorEmbeddingSubscriber],
|
||||||
|
};
|
||||||
|
|
||||||
|
// AppDataSource is the TypeORM data source
|
||||||
|
let AppDataSource = new DataSource(defaultConfig);
|
||||||
|
|
||||||
|
// Function to create a new DataSource with updated configuration
|
||||||
|
export const updateDataSourceConfig = (): DataSource => {
|
||||||
|
const newConfig: DataSourceOptions = {
|
||||||
|
...defaultConfig,
|
||||||
|
url: getDatabaseUrl(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// If the configuration has changed, we need to create a new DataSource
|
||||||
|
const currentUrl = (AppDataSource.options as any).url;
|
||||||
|
if (currentUrl !== newConfig.url) {
|
||||||
|
console.log('Database URL configuration changed, updating DataSource...');
|
||||||
|
AppDataSource = new DataSource(newConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
return AppDataSource;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get the current AppDataSource instance
|
||||||
|
export const getAppDataSource = (): DataSource => {
|
||||||
|
return AppDataSource;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reconnect database with updated configuration
|
||||||
|
export const reconnectDatabase = async (): Promise<DataSource> => {
|
||||||
|
try {
|
||||||
|
// Close existing connection if it exists
|
||||||
|
if (AppDataSource.isInitialized) {
|
||||||
|
console.log('Closing existing database connection...');
|
||||||
|
await AppDataSource.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update configuration and reconnect
|
||||||
|
AppDataSource = updateDataSourceConfig();
|
||||||
|
return await initializeDatabase();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during database reconnection:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize database connection
|
||||||
|
export const initializeDatabase = async (): Promise<DataSource> => {
|
||||||
|
try {
|
||||||
|
// Update configuration before initializing
|
||||||
|
AppDataSource = updateDataSourceConfig();
|
||||||
|
|
||||||
|
if (!AppDataSource.isInitialized) {
|
||||||
|
console.log('Initializing database connection...');
|
||||||
|
await AppDataSource.initialize();
|
||||||
|
|
||||||
|
// Register the vector type with TypeORM
|
||||||
|
registerPostgresVectorType(AppDataSource);
|
||||||
|
|
||||||
|
// Create pgvector extension if it doesn't exist
|
||||||
|
await AppDataSource.query('CREATE EXTENSION IF NOT EXISTS vector;').catch((err) => {
|
||||||
|
console.warn('Failed to create vector extension:', err.message);
|
||||||
|
console.warn('Vector functionality may not be available.');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up vector column and index with a more direct approach
|
||||||
|
try {
|
||||||
|
// First, create the extension
|
||||||
|
await AppDataSource.query(`CREATE EXTENSION IF NOT EXISTS vector;`);
|
||||||
|
|
||||||
|
// Check if table exists first
|
||||||
|
const tableExists = await AppDataSource.query(`
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'vector_embeddings'
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (tableExists[0].exists) {
|
||||||
|
// Add pgvector support via raw SQL commands
|
||||||
|
console.log('Configuring vector support for embeddings table...');
|
||||||
|
|
||||||
|
// Step 1: Drop any existing index on the column
|
||||||
|
try {
|
||||||
|
await AppDataSource.query(`DROP INDEX IF EXISTS idx_vector_embeddings_embedding;`);
|
||||||
|
} catch (dropError: any) {
|
||||||
|
console.warn('Note: Could not drop existing index:', dropError.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Alter column type to vector (if it's not already)
|
||||||
|
try {
|
||||||
|
// Check column type first
|
||||||
|
const columnType = await AppDataSource.query(`
|
||||||
|
SELECT data_type FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'vector_embeddings'
|
||||||
|
AND column_name = 'embedding';
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (columnType.length > 0 && columnType[0].data_type !== 'vector') {
|
||||||
|
await AppDataSource.query(`
|
||||||
|
ALTER TABLE vector_embeddings
|
||||||
|
ALTER COLUMN embedding TYPE vector USING embedding::vector;
|
||||||
|
`);
|
||||||
|
console.log('Vector embedding column type updated successfully.');
|
||||||
|
}
|
||||||
|
} catch (alterError: any) {
|
||||||
|
console.warn('Could not alter embedding column type:', alterError.message);
|
||||||
|
console.warn('Will try to recreate the table later.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Try to create appropriate indices
|
||||||
|
try {
|
||||||
|
// First, let's check if there are any records to determine the dimensions
|
||||||
|
const records = await AppDataSource.query(`
|
||||||
|
SELECT dimensions FROM vector_embeddings LIMIT 1;
|
||||||
|
`);
|
||||||
|
|
||||||
|
let dimensions = 1536; // Default to common OpenAI embedding size
|
||||||
|
if (records && records.length > 0 && records[0].dimensions) {
|
||||||
|
dimensions = records[0].dimensions;
|
||||||
|
console.log(`Found vector dimension from existing data: ${dimensions}`);
|
||||||
|
} else {
|
||||||
|
console.log(`Using default vector dimension: ${dimensions} (no existing data found)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the vector dimensions explicitly only if table has data
|
||||||
|
if (records && records.length > 0) {
|
||||||
|
await AppDataSource.query(`
|
||||||
|
ALTER TABLE vector_embeddings
|
||||||
|
ALTER COLUMN embedding TYPE vector(${dimensions});
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Now try to create the index
|
||||||
|
await AppDataSource.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_vector_embeddings_embedding
|
||||||
|
ON vector_embeddings USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
|
||||||
|
`);
|
||||||
|
console.log('Created IVFFlat index for vector similarity search.');
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
'No existing vector data found, skipping index creation - will be handled by vector service.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (indexError: any) {
|
||||||
|
console.warn('IVFFlat index creation failed:', indexError.message);
|
||||||
|
console.warn('Trying alternative index type...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try HNSW index instead
|
||||||
|
await AppDataSource.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_vector_embeddings_embedding
|
||||||
|
ON vector_embeddings USING hnsw (embedding vector_cosine_ops);
|
||||||
|
`);
|
||||||
|
console.log('Created HNSW index for vector similarity search.');
|
||||||
|
} catch (hnswError: any) {
|
||||||
|
// Final fallback to simpler index type
|
||||||
|
console.warn('HNSW index creation failed too. Using simple L2 distance index.');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create a basic GIN index as last resort
|
||||||
|
await AppDataSource.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_vector_embeddings_embedding
|
||||||
|
ON vector_embeddings USING gin (embedding);
|
||||||
|
`);
|
||||||
|
console.log('Created GIN index for basic vector lookups.');
|
||||||
|
} catch (ginError: any) {
|
||||||
|
console.warn('All index creation attempts failed:', ginError.message);
|
||||||
|
console.warn('Vector search will be slower without an optimized index.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
'Vector embeddings table does not exist yet - will configure after schema sync.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.warn('Could not set up vector column/index:', error.message);
|
||||||
|
console.warn('Will attempt again after schema synchronization.');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Database connection established successfully.');
|
||||||
|
|
||||||
|
// Run one final setup check after schema synchronization is done
|
||||||
|
if (defaultConfig.synchronize) {
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
console.log('Running final vector configuration check...');
|
||||||
|
|
||||||
|
// Try setup again with the same code from above
|
||||||
|
const tableExists = await AppDataSource.query(`
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'vector_embeddings'
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (tableExists[0].exists) {
|
||||||
|
console.log('Vector embeddings table found, checking configuration...');
|
||||||
|
|
||||||
|
// Get the dimension size first
|
||||||
|
try {
|
||||||
|
// Try to get dimensions from an existing record
|
||||||
|
const records = await AppDataSource.query(`
|
||||||
|
SELECT dimensions FROM vector_embeddings LIMIT 1;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Only proceed if we have existing data, otherwise let vector service handle it
|
||||||
|
if (records && records.length > 0 && records[0].dimensions) {
|
||||||
|
const dimensions = records[0].dimensions;
|
||||||
|
console.log(`Found vector dimension from database: ${dimensions}`);
|
||||||
|
|
||||||
|
// Ensure column type is vector with explicit dimensions
|
||||||
|
await AppDataSource.query(`
|
||||||
|
ALTER TABLE vector_embeddings
|
||||||
|
ALTER COLUMN embedding TYPE vector(${dimensions});
|
||||||
|
`);
|
||||||
|
console.log('Vector embedding column type updated in final check.');
|
||||||
|
|
||||||
|
// One more attempt at creating the index with dimensions
|
||||||
|
try {
|
||||||
|
// Drop existing index if any
|
||||||
|
await AppDataSource.query(`
|
||||||
|
DROP INDEX IF EXISTS idx_vector_embeddings_embedding;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Create new index with proper dimensions
|
||||||
|
await AppDataSource.query(`
|
||||||
|
CREATE INDEX idx_vector_embeddings_embedding
|
||||||
|
ON vector_embeddings USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
|
||||||
|
`);
|
||||||
|
console.log('Created IVFFlat index in final check.');
|
||||||
|
} catch (indexError: any) {
|
||||||
|
console.warn(
|
||||||
|
'Final index creation attempt did not succeed:',
|
||||||
|
indexError.message,
|
||||||
|
);
|
||||||
|
console.warn('Using basic lookup without vector index.');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
'No existing vector data found, vector dimensions will be configured by vector service.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (setupError: any) {
|
||||||
|
console.warn('Vector setup in final check failed:', setupError.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.warn('Post-initialization vector setup failed:', error.message);
|
||||||
|
}
|
||||||
|
}, 3000); // Give synchronize some time to complete
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return AppDataSource;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during database initialization:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get database connection status
|
||||||
|
export const isDatabaseConnected = (): boolean => {
|
||||||
|
return AppDataSource.isInitialized;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close database connection
|
||||||
|
export const closeDatabase = async (): Promise<void> => {
|
||||||
|
if (AppDataSource.isInitialized) {
|
||||||
|
await AppDataSource.destroy();
|
||||||
|
console.log('Database connection closed.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Export AppDataSource for backward compatibility
|
||||||
|
export { AppDataSource };
|
||||||
|
|
||||||
|
export default getAppDataSource;
|
||||||
46
src/db/entities/VectorEmbedding.ts
Normal file
46
src/db/entities/VectorEmbedding.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity({ name: 'vector_embeddings' })
|
||||||
|
export class VectorEmbedding {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar' })
|
||||||
|
content_type: string; // 'market_server', 'tool', 'documentation', etc.
|
||||||
|
|
||||||
|
@Column({ type: 'varchar' })
|
||||||
|
content_id: string; // Reference ID to the original content
|
||||||
|
|
||||||
|
@Column('text')
|
||||||
|
text_content: string; // The text that was embedded
|
||||||
|
|
||||||
|
@Column('simple-json')
|
||||||
|
metadata: Record<string, any>; // Additional metadata about the embedding
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'float',
|
||||||
|
array: true,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
embedding: number[]; // The vector embedding - will be converted to vector type after table creation
|
||||||
|
|
||||||
|
@Column({ type: 'int' })
|
||||||
|
dimensions: number; // Dimensionality of the embedding vector
|
||||||
|
|
||||||
|
@Column({ type: 'varchar' })
|
||||||
|
model: string; // Model used to create the embedding
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp' })
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VectorEmbedding;
|
||||||
7
src/db/entities/index.ts
Normal file
7
src/db/entities/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { VectorEmbedding } from './VectorEmbedding.js';
|
||||||
|
|
||||||
|
// Export all entities
|
||||||
|
export default [VectorEmbedding];
|
||||||
|
|
||||||
|
// Export individual entities for direct use
|
||||||
|
export { VectorEmbedding };
|
||||||
33
src/db/index.ts
Normal file
33
src/db/index.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { initializeDatabase, closeDatabase, isDatabaseConnected } from './connection.js';
|
||||||
|
import * as repositories from './repositories/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the database module
|
||||||
|
*/
|
||||||
|
export async function initializeDbModule(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
// Connect to the database
|
||||||
|
await initializeDatabase();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize database module:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the repository factory for a database entity type
|
||||||
|
* @param entityType The type of entity to get a repository for
|
||||||
|
*/
|
||||||
|
export function getRepositoryFactory(entityType: 'vectorEmbeddings') {
|
||||||
|
// Return the appropriate repository based on entity type
|
||||||
|
switch (entityType) {
|
||||||
|
case 'vectorEmbeddings':
|
||||||
|
return () => new repositories.VectorEmbeddingRepository();
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown entity type: ${entityType}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export everything from the database module
|
||||||
|
export { initializeDatabase, closeDatabase, isDatabaseConnected, repositories };
|
||||||
69
src/db/repositories/BaseRepository.ts
Normal file
69
src/db/repositories/BaseRepository.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { Repository, EntityTarget, ObjectLiteral } from 'typeorm';
|
||||||
|
import { getAppDataSource } from '../connection.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base repository class with common CRUD operations
|
||||||
|
*/
|
||||||
|
export class BaseRepository<T extends ObjectLiteral> {
|
||||||
|
protected readonly repository: Repository<T>;
|
||||||
|
|
||||||
|
constructor(entityClass: EntityTarget<T>) {
|
||||||
|
this.repository = getAppDataSource().getRepository(entityClass);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get repository access
|
||||||
|
*/
|
||||||
|
getRepository(): Repository<T> {
|
||||||
|
return this.repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all entities
|
||||||
|
*/
|
||||||
|
async findAll(): Promise<T[]> {
|
||||||
|
return this.repository.find();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find entity by ID
|
||||||
|
* @param id Entity ID
|
||||||
|
*/
|
||||||
|
async findById(id: string | number): Promise<T | null> {
|
||||||
|
return this.repository.findOneBy({ id } as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save or update an entity
|
||||||
|
* @param entity Entity to save
|
||||||
|
*/
|
||||||
|
async save(entity: Partial<T>): Promise<T> {
|
||||||
|
return this.repository.save(entity as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save multiple entities
|
||||||
|
* @param entities Array of entities to save
|
||||||
|
*/
|
||||||
|
async saveMany(entities: Partial<T>[]): Promise<T[]> {
|
||||||
|
return this.repository.save(entities as any[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an entity by ID
|
||||||
|
* @param id Entity ID
|
||||||
|
*/
|
||||||
|
async delete(id: string | number): Promise<boolean> {
|
||||||
|
const result = await this.repository.delete(id);
|
||||||
|
return result.affected !== null && result.affected !== undefined && result.affected > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count total entities
|
||||||
|
*/
|
||||||
|
async count(): Promise<number> {
|
||||||
|
return this.repository.count();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BaseRepository;
|
||||||
219
src/db/repositories/VectorEmbeddingRepository.ts
Normal file
219
src/db/repositories/VectorEmbeddingRepository.ts
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
import { VectorEmbedding } from '../entities/VectorEmbedding.js';
|
||||||
|
import BaseRepository from './BaseRepository.js';
|
||||||
|
import { getAppDataSource } from '../connection.js';
|
||||||
|
|
||||||
|
export class VectorEmbeddingRepository extends BaseRepository<VectorEmbedding> {
|
||||||
|
constructor() {
|
||||||
|
super(VectorEmbedding);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find by content type and ID
|
||||||
|
* @param contentType Content type
|
||||||
|
* @param contentId Content ID
|
||||||
|
*/
|
||||||
|
async findByContentIdentity(
|
||||||
|
contentType: string,
|
||||||
|
contentId: string,
|
||||||
|
): Promise<VectorEmbedding | null> {
|
||||||
|
return this.repository.findOneBy({
|
||||||
|
content_type: contentType,
|
||||||
|
content_id: contentId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create or update an embedding for content
|
||||||
|
* @param contentType Content type
|
||||||
|
* @param contentId Content ID
|
||||||
|
* @param textContent Text content to embed
|
||||||
|
* @param embedding Vector embedding
|
||||||
|
* @param metadata Additional metadata
|
||||||
|
* @param model Model used to create the embedding
|
||||||
|
*/
|
||||||
|
async saveEmbedding(
|
||||||
|
contentType: string,
|
||||||
|
contentId: string,
|
||||||
|
textContent: string,
|
||||||
|
embedding: number[],
|
||||||
|
metadata: Record<string, any> = {},
|
||||||
|
model = 'default',
|
||||||
|
): Promise<VectorEmbedding> {
|
||||||
|
// Check if embedding exists
|
||||||
|
let vectorEmbedding = await this.findByContentIdentity(contentType, contentId);
|
||||||
|
|
||||||
|
if (!vectorEmbedding) {
|
||||||
|
vectorEmbedding = new VectorEmbedding();
|
||||||
|
vectorEmbedding.content_type = contentType;
|
||||||
|
vectorEmbedding.content_id = contentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update properties
|
||||||
|
vectorEmbedding.text_content = textContent;
|
||||||
|
vectorEmbedding.embedding = embedding;
|
||||||
|
vectorEmbedding.dimensions = embedding.length;
|
||||||
|
vectorEmbedding.metadata = metadata;
|
||||||
|
vectorEmbedding.model = model;
|
||||||
|
|
||||||
|
// For raw SQL operations where our subscriber might not be called
|
||||||
|
// Ensure the embedding is properly formatted for postgres
|
||||||
|
const rawEmbedding = this.formatEmbeddingForPgVector(embedding);
|
||||||
|
if (rawEmbedding) {
|
||||||
|
(vectorEmbedding as any).embedding = rawEmbedding;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.save(vectorEmbedding);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for similar embeddings using cosine similarity
|
||||||
|
* @param embedding Vector embedding to search against
|
||||||
|
* @param limit Maximum number of results (default: 10)
|
||||||
|
* @param threshold Similarity threshold (default: 0.7)
|
||||||
|
* @param contentTypes Optional content types to filter by
|
||||||
|
*/
|
||||||
|
async searchSimilar(
|
||||||
|
embedding: number[],
|
||||||
|
limit = 10,
|
||||||
|
threshold = 0.7,
|
||||||
|
contentTypes?: string[],
|
||||||
|
): Promise<Array<{ embedding: VectorEmbedding; similarity: number }>> {
|
||||||
|
try {
|
||||||
|
// Try using vector similarity operator first
|
||||||
|
try {
|
||||||
|
// Build query with vector operators
|
||||||
|
let query = getAppDataSource()
|
||||||
|
.createQueryBuilder()
|
||||||
|
.select('vector_embedding.*')
|
||||||
|
.addSelect(`1 - (vector_embedding.embedding <=> :embedding) AS similarity`)
|
||||||
|
.from(VectorEmbedding, 'vector_embedding')
|
||||||
|
.where(`1 - (vector_embedding.embedding <=> :embedding) > :threshold`)
|
||||||
|
.orderBy('similarity', 'DESC')
|
||||||
|
.limit(limit)
|
||||||
|
.setParameter(
|
||||||
|
'embedding',
|
||||||
|
Array.isArray(embedding) ? `[${embedding.join(',')}]` : embedding,
|
||||||
|
)
|
||||||
|
.setParameter('threshold', threshold);
|
||||||
|
|
||||||
|
// Add content type filter if provided
|
||||||
|
if (contentTypes && contentTypes.length > 0) {
|
||||||
|
query = query
|
||||||
|
.andWhere('vector_embedding.content_type IN (:...contentTypes)')
|
||||||
|
.setParameter('contentTypes', contentTypes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute query
|
||||||
|
const results = await query.getRawMany();
|
||||||
|
|
||||||
|
// Return results if successful
|
||||||
|
return results.map((row) => ({
|
||||||
|
embedding: this.mapRawToEntity(row),
|
||||||
|
similarity: parseFloat(row.similarity),
|
||||||
|
}));
|
||||||
|
} catch (vectorError) {
|
||||||
|
console.warn(
|
||||||
|
'Vector similarity search failed, falling back to basic filtering:',
|
||||||
|
vectorError,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fallback to just getting the records by content type
|
||||||
|
let query = this.repository.createQueryBuilder('vector_embedding');
|
||||||
|
|
||||||
|
// Add content type filter if provided
|
||||||
|
if (contentTypes && contentTypes.length > 0) {
|
||||||
|
query = query
|
||||||
|
.where('vector_embedding.content_type IN (:...contentTypes)')
|
||||||
|
.setParameter('contentTypes', contentTypes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit results
|
||||||
|
query = query.take(limit);
|
||||||
|
|
||||||
|
// Execute query
|
||||||
|
const results = await query.getMany();
|
||||||
|
|
||||||
|
// Return results with a placeholder similarity
|
||||||
|
return results.map((entity) => ({
|
||||||
|
embedding: entity,
|
||||||
|
similarity: 0.5, // Placeholder similarity
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during vector search:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search by text using vector similarity
|
||||||
|
* @param text Text to search for
|
||||||
|
* @param getEmbeddingFunc Function to convert text to embedding
|
||||||
|
* @param limit Maximum number of results
|
||||||
|
* @param threshold Similarity threshold
|
||||||
|
* @param contentTypes Optional content types to filter by
|
||||||
|
*/
|
||||||
|
async searchByText(
|
||||||
|
text: string,
|
||||||
|
getEmbeddingFunc: (text: string) => Promise<number[]>,
|
||||||
|
limit = 10,
|
||||||
|
threshold = 0.7,
|
||||||
|
contentTypes?: string[],
|
||||||
|
): Promise<Array<{ embedding: VectorEmbedding; similarity: number }>> {
|
||||||
|
try {
|
||||||
|
// Get embedding for the search text
|
||||||
|
const embedding = await getEmbeddingFunc(text);
|
||||||
|
|
||||||
|
// Search by embedding
|
||||||
|
return this.searchSimilar(embedding, limit, threshold, contentTypes);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error searching by text:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map raw database result to entity
|
||||||
|
* @param raw Raw database result
|
||||||
|
*/
|
||||||
|
private mapRawToEntity(raw: any): VectorEmbedding {
|
||||||
|
const entity = new VectorEmbedding();
|
||||||
|
entity.id = raw.id;
|
||||||
|
entity.content_type = raw.content_type;
|
||||||
|
entity.content_id = raw.content_id;
|
||||||
|
entity.text_content = raw.text_content;
|
||||||
|
entity.metadata = raw.metadata;
|
||||||
|
entity.embedding = raw.embedding;
|
||||||
|
entity.dimensions = raw.dimensions;
|
||||||
|
entity.model = raw.model;
|
||||||
|
entity.createdAt = raw.created_at;
|
||||||
|
entity.updatedAt = raw.updated_at;
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format embedding array for pgvector
|
||||||
|
* @param embedding Array of embedding values
|
||||||
|
* @returns Properly formatted vector string for pgvector
|
||||||
|
*/
|
||||||
|
private formatEmbeddingForPgVector(embedding: number[] | string): string | null {
|
||||||
|
if (!embedding) return null;
|
||||||
|
|
||||||
|
// If it's already a string and starts with '[', assume it's formatted
|
||||||
|
if (typeof embedding === 'string') {
|
||||||
|
if (embedding.startsWith('[') && embedding.endsWith(']')) {
|
||||||
|
return embedding;
|
||||||
|
}
|
||||||
|
return `[${embedding}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format array as proper pgvector string
|
||||||
|
if (Array.isArray(embedding)) {
|
||||||
|
return `[${embedding.join(',')}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VectorEmbeddingRepository;
|
||||||
4
src/db/repositories/index.ts
Normal file
4
src/db/repositories/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import VectorEmbeddingRepository from './VectorEmbeddingRepository.js';
|
||||||
|
|
||||||
|
// Export all repositories
|
||||||
|
export { VectorEmbeddingRepository };
|
||||||
53
src/db/subscribers/VectorEmbeddingSubscriber.ts
Normal file
53
src/db/subscribers/VectorEmbeddingSubscriber.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { EntitySubscriberInterface, EventSubscriber, InsertEvent, UpdateEvent } from 'typeorm';
|
||||||
|
import { VectorEmbedding } from '../entities/VectorEmbedding.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A subscriber to format vector embeddings before saving to database
|
||||||
|
*/
|
||||||
|
@EventSubscriber()
|
||||||
|
export class VectorEmbeddingSubscriber implements EntitySubscriberInterface<VectorEmbedding> {
|
||||||
|
/**
|
||||||
|
* Indicates that this subscriber only listens to VectorEmbedding events
|
||||||
|
*/
|
||||||
|
listenTo() {
|
||||||
|
return VectorEmbedding;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called before entity insertion
|
||||||
|
*/
|
||||||
|
beforeInsert(event: InsertEvent<VectorEmbedding>) {
|
||||||
|
this.formatEmbedding(event.entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called before entity update
|
||||||
|
*/
|
||||||
|
beforeUpdate(event: UpdateEvent<VectorEmbedding>) {
|
||||||
|
if (event.entity && event.entity.embedding) {
|
||||||
|
this.formatEmbedding(event.entity as VectorEmbedding);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format embedding as a proper vector string
|
||||||
|
*/
|
||||||
|
private formatEmbedding(entity: VectorEmbedding | undefined) {
|
||||||
|
if (!entity || !entity.embedding || !Array.isArray(entity.embedding)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the embedding is already a string, don't process it
|
||||||
|
if (typeof entity.embedding === 'string') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format array as proper pgvector string
|
||||||
|
// Ensure the string starts with '[' and ends with ']' as required by pgvector
|
||||||
|
const vectorString = `[${entity.embedding.join(',')}]`;
|
||||||
|
|
||||||
|
// Store the string directly (TypeORM will handle conversion)
|
||||||
|
// We need to use 'as any' because the type is declared as number[] but we're setting a string
|
||||||
|
(entity as any).embedding = vectorString;
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/db/types/postgresVectorType.ts
Normal file
38
src/db/types/postgresVectorType.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register the PostgreSQL vector type with TypeORM
|
||||||
|
* @param dataSource TypeORM data source
|
||||||
|
*/
|
||||||
|
export function registerPostgresVectorType(dataSource: DataSource): void {
|
||||||
|
// Skip if not postgres
|
||||||
|
if (dataSource.driver.options.type !== 'postgres') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the postgres driver
|
||||||
|
const pgDriver = dataSource.driver;
|
||||||
|
|
||||||
|
// Add 'vector' to the list of supported column types
|
||||||
|
if (pgDriver.supportedDataTypes) {
|
||||||
|
pgDriver.supportedDataTypes.push('vector' as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override the normalization for the vector type
|
||||||
|
if ((pgDriver as any).dataTypeDefaults) {
|
||||||
|
(pgDriver as any).dataTypeDefaults['vector'] = {
|
||||||
|
type: 'vector',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override the column type resolver to prevent it from converting vector to other types
|
||||||
|
const originalColumnTypeResolver = (pgDriver as any).columnTypeResolver;
|
||||||
|
if (originalColumnTypeResolver) {
|
||||||
|
(pgDriver as any).columnTypeResolver = (column: any) => {
|
||||||
|
if (column.type === 'vector') {
|
||||||
|
return 'vector';
|
||||||
|
}
|
||||||
|
return originalColumnTypeResolver(column);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
import AppServer from './server.js';
|
import AppServer from './server.js';
|
||||||
|
|
||||||
const appServer = new AppServer();
|
const appServer = new AppServer();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { loadSettings, saveSettings, expandEnvVars } from '../config/index.js';
|
|||||||
import config from '../config/index.js';
|
import config from '../config/index.js';
|
||||||
import { getGroup } from './sseService.js';
|
import { getGroup } from './sseService.js';
|
||||||
import { getServersInGroup } from './groupService.js';
|
import { getServersInGroup } from './groupService.js';
|
||||||
|
import { saveToolsAsVectorEmbeddings, searchToolsByVector } from './vectorSearchService.js';
|
||||||
|
|
||||||
const servers: { [sessionId: string]: Server } = {};
|
const servers: { [sessionId: string]: Server } = {};
|
||||||
|
|
||||||
@@ -99,14 +100,21 @@ export const initializeClientsFromSettings = (isInit: boolean): ServerInfo[] =>
|
|||||||
|
|
||||||
// Add UV_DEFAULT_INDEX from settings if available (for Python packages)
|
// Add UV_DEFAULT_INDEX from settings if available (for Python packages)
|
||||||
const settings = loadSettings(); // Add UV_DEFAULT_INDEX from settings if available (for Python packages)
|
const settings = loadSettings(); // Add UV_DEFAULT_INDEX from settings if available (for Python packages)
|
||||||
if (settings.systemConfig?.install?.pythonIndexUrl && conf.command === 'uvx') {
|
if (
|
||||||
|
settings.systemConfig?.install?.pythonIndexUrl &&
|
||||||
|
(conf.command === 'uvx' || conf.command === 'uv' || conf.command === 'python')
|
||||||
|
) {
|
||||||
env['UV_DEFAULT_INDEX'] = settings.systemConfig.install.pythonIndexUrl;
|
env['UV_DEFAULT_INDEX'] = settings.systemConfig.install.pythonIndexUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add npm_config_registry from settings if available (for NPM packages)
|
// Add npm_config_registry from settings if available (for NPM packages)
|
||||||
if (
|
if (
|
||||||
settings.systemConfig?.install?.npmRegistry &&
|
settings.systemConfig?.install?.npmRegistry &&
|
||||||
(conf.command === 'npm' || conf.command === 'npx')
|
(conf.command === 'npm' ||
|
||||||
|
conf.command === 'npx' ||
|
||||||
|
conf.command === 'pnpm' ||
|
||||||
|
conf.command === 'yarn' ||
|
||||||
|
conf.command === 'node')
|
||||||
) {
|
) {
|
||||||
env['npm_config_registry'] = settings.systemConfig.install.npmRegistry;
|
env['npm_config_registry'] = settings.systemConfig.install.npmRegistry;
|
||||||
}
|
}
|
||||||
@@ -168,6 +176,22 @@ export const initializeClientsFromSettings = (isInit: boolean): ServerInfo[] =>
|
|||||||
}));
|
}));
|
||||||
serverInfo.status = 'connected';
|
serverInfo.status = 'connected';
|
||||||
serverInfo.error = null;
|
serverInfo.error = null;
|
||||||
|
|
||||||
|
// Save tools as vector embeddings for search (only when smart routing is enabled)
|
||||||
|
if (serverInfo.tools.length > 0) {
|
||||||
|
try {
|
||||||
|
const settings = loadSettings();
|
||||||
|
const smartRoutingEnabled = settings.systemConfig?.smartRouting?.enabled || false;
|
||||||
|
if (smartRoutingEnabled) {
|
||||||
|
console.log(
|
||||||
|
`Smart routing enabled - saving vector embeddings for server ${name}`,
|
||||||
|
);
|
||||||
|
saveToolsAsVectorEmbeddings(name, serverInfo.tools);
|
||||||
|
}
|
||||||
|
} catch (vectorError) {
|
||||||
|
console.warn(`Failed to save vector embeddings for server ${name}:`, vectorError);
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error(
|
console.error(
|
||||||
@@ -258,7 +282,6 @@ export const addServer = async (
|
|||||||
return { success: false, message: 'Failed to save settings' };
|
return { success: false, message: 'Failed to save settings' };
|
||||||
}
|
}
|
||||||
|
|
||||||
registerAllTools(false);
|
|
||||||
return { success: true, message: 'Server added successfully' };
|
return { success: true, message: 'Server added successfully' };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to add server: ${name}`, error);
|
console.error(`Failed to add server: ${name}`, error);
|
||||||
@@ -369,6 +392,74 @@ const handleListToolsRequest = async (_: any, extra: any) => {
|
|||||||
const sessionId = extra.sessionId || '';
|
const sessionId = extra.sessionId || '';
|
||||||
const group = getGroup(sessionId);
|
const group = getGroup(sessionId);
|
||||||
console.log(`Handling ListToolsRequest for group: ${group}`);
|
console.log(`Handling ListToolsRequest for group: ${group}`);
|
||||||
|
|
||||||
|
// Special handling for $smart group to return special tools
|
||||||
|
if (group === '$smart') {
|
||||||
|
return {
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
name: 'search_tools',
|
||||||
|
description: (() => {
|
||||||
|
// Get info about available servers
|
||||||
|
const availableServers = serverInfos.filter(
|
||||||
|
(server) => server.status === 'connected' && server.enabled !== false,
|
||||||
|
);
|
||||||
|
// Create simple server information with only server names
|
||||||
|
const serversList = availableServers
|
||||||
|
.map((server) => {
|
||||||
|
return `${server.name}`;
|
||||||
|
})
|
||||||
|
.join(', ');
|
||||||
|
return `STEP 1 of 2: Use this tool FIRST to discover and search for relevant tools across all available servers. This tool and call_tool work together as a two-step process: 1) search_tools to find what you need, 2) call_tool to execute it.
|
||||||
|
|
||||||
|
For optimal results, use specific queries matching your exact needs. Call this tool multiple times with different queries for different parts of complex tasks. Example queries: "image generation tools", "code review tools", "data analysis", "translation capabilities", etc. Results are sorted by relevance using vector similarity.
|
||||||
|
|
||||||
|
After finding relevant tools, you MUST use the call_tool to actually execute them. The search_tools only finds tools - it doesn't execute them.
|
||||||
|
|
||||||
|
Available servers: ${serversList}`;
|
||||||
|
})(),
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
query: {
|
||||||
|
type: 'string',
|
||||||
|
description:
|
||||||
|
'The search query to find relevant tools. Be specific and descriptive about the task you want to accomplish.',
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
type: 'integer',
|
||||||
|
description:
|
||||||
|
'Maximum number of results to return. Use higher values (20-30) for broad searches and lower values (5-10) for specific searches.',
|
||||||
|
default: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['query'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'call_tool',
|
||||||
|
description:
|
||||||
|
"STEP 2 of 2: Use this tool AFTER search_tools to actually execute/invoke any tool you found. This is the execution step - search_tools finds tools, call_tool runs them.\n\nWorkflow: search_tools → examine results → call_tool with the chosen tool name and required arguments.\n\nIMPORTANT: Always check the tool's inputSchema from search_tools results before invoking to ensure you provide the correct arguments. The search results will show you exactly what parameters each tool expects.",
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
toolName: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'The exact name of the tool to invoke (from search_tools results)',
|
||||||
|
},
|
||||||
|
arguments: {
|
||||||
|
type: 'object',
|
||||||
|
description:
|
||||||
|
'The arguments to pass to the tool based on its inputSchema (optional if tool requires no arguments)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['toolName'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const allServerInfos = serverInfos.filter((serverInfo) => {
|
const allServerInfos = serverInfos.filter((serverInfo) => {
|
||||||
if (serverInfo.enabled === false) return false;
|
if (serverInfo.enabled === false) return false;
|
||||||
if (!group) return true;
|
if (!group) return true;
|
||||||
@@ -392,6 +483,143 @@ const handleListToolsRequest = async (_: any, extra: any) => {
|
|||||||
const handleCallToolRequest = async (request: any, extra: any) => {
|
const handleCallToolRequest = async (request: any, extra: any) => {
|
||||||
console.log(`Handling CallToolRequest for tool: ${request.params.name}`);
|
console.log(`Handling CallToolRequest for tool: ${request.params.name}`);
|
||||||
try {
|
try {
|
||||||
|
// Special handling for agent group tools
|
||||||
|
if (request.params.name === 'search_tools') {
|
||||||
|
const { query, limit = 10 } = request.params.arguments || {};
|
||||||
|
|
||||||
|
if (!query || typeof query !== 'string') {
|
||||||
|
throw new Error('Query parameter is required and must be a string');
|
||||||
|
}
|
||||||
|
|
||||||
|
const limitNum = Math.min(Math.max(parseInt(String(limit)) || 10, 1), 100);
|
||||||
|
|
||||||
|
// Dynamically adjust threshold based on query characteristics
|
||||||
|
let thresholdNum = 0.3; // Default threshold
|
||||||
|
|
||||||
|
// For more general queries, use a lower threshold to get more diverse results
|
||||||
|
if (query.length < 10 || query.split(' ').length <= 2) {
|
||||||
|
thresholdNum = 0.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For very specific queries, use a higher threshold for more precise results
|
||||||
|
if (query.length > 30 || query.includes('specific') || query.includes('exact')) {
|
||||||
|
thresholdNum = 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Using similarity threshold: ${thresholdNum} for query: "${query}"`);
|
||||||
|
const servers = undefined; // No server filtering
|
||||||
|
|
||||||
|
const searchResults = await searchToolsByVector(query, limitNum, thresholdNum, servers);
|
||||||
|
console.log(`Search results: ${JSON.stringify(searchResults)}`);
|
||||||
|
// Find actual tool information from serverInfos by serverName and toolName
|
||||||
|
const tools = searchResults.map((result) => {
|
||||||
|
// Find the server in serverInfos
|
||||||
|
const server = serverInfos.find(
|
||||||
|
(serverInfo) =>
|
||||||
|
serverInfo.name === result.serverName &&
|
||||||
|
serverInfo.status === 'connected' &&
|
||||||
|
serverInfo.enabled !== false,
|
||||||
|
);
|
||||||
|
if (server && server.tools && server.tools.length > 0) {
|
||||||
|
// Find the tool in server.tools
|
||||||
|
const actualTool = server.tools.find((tool) => tool.name === result.toolName);
|
||||||
|
if (actualTool) {
|
||||||
|
// Return the actual tool info from serverInfos
|
||||||
|
return actualTool;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to search result if server or tool not found
|
||||||
|
return {
|
||||||
|
name: result.toolName,
|
||||||
|
description: result.description || '',
|
||||||
|
inputSchema: result.inputSchema || {},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add usage guidance to the response
|
||||||
|
const response = {
|
||||||
|
tools,
|
||||||
|
metadata: {
|
||||||
|
query: query,
|
||||||
|
threshold: thresholdNum,
|
||||||
|
totalResults: tools.length,
|
||||||
|
guideline:
|
||||||
|
tools.length > 0
|
||||||
|
? "Found relevant tools. If these tools don't match exactly what you need, try another search with more specific keywords."
|
||||||
|
: 'No tools found. Try broadening your search or using different keywords.',
|
||||||
|
nextSteps:
|
||||||
|
tools.length > 0
|
||||||
|
? 'To use a tool, call call_tool with the toolName and required arguments.'
|
||||||
|
: 'Consider searching for related capabilities or more general terms.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Return in the same format as handleListToolsRequest
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: JSON.stringify(response),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special handling for call_tool
|
||||||
|
if (request.params.name === 'call_tool') {
|
||||||
|
const { toolName, arguments: toolArgs = {} } = request.params.arguments || {};
|
||||||
|
|
||||||
|
if (!toolName) {
|
||||||
|
throw new Error('toolName parameter is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// arguments parameter is now optional
|
||||||
|
|
||||||
|
let targetServerInfo: ServerInfo | undefined;
|
||||||
|
|
||||||
|
// Find the first server that has this tool
|
||||||
|
targetServerInfo = serverInfos.find(
|
||||||
|
(serverInfo) =>
|
||||||
|
serverInfo.status === 'connected' &&
|
||||||
|
serverInfo.enabled !== false &&
|
||||||
|
serverInfo.tools.some((tool) => tool.name === toolName),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!targetServerInfo) {
|
||||||
|
throw new Error(`No available servers found with tool: ${toolName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the tool exists on the server
|
||||||
|
const toolExists = targetServerInfo.tools.some((tool) => tool.name === toolName);
|
||||||
|
if (!toolExists) {
|
||||||
|
throw new Error(`Tool '${toolName}' not found on server '${targetServerInfo.name}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call the tool on the target server
|
||||||
|
const client = targetServerInfo.client;
|
||||||
|
if (!client) {
|
||||||
|
throw new Error(`Client not found for server: ${targetServerInfo.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use toolArgs if it has properties, otherwise fallback to request.params.arguments
|
||||||
|
const finalArgs =
|
||||||
|
toolArgs && Object.keys(toolArgs).length > 0 ? toolArgs : request.params.arguments || {};
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Invoking tool '${toolName}' on server '${targetServerInfo.name}' with arguments: ${JSON.stringify(finalArgs)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await client.callTool({
|
||||||
|
name: toolName,
|
||||||
|
arguments: finalArgs,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Tool invocation result: ${JSON.stringify(result)}`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular tool handling
|
||||||
const serverInfo = getServerByTool(request.params.name);
|
const serverInfo = getServerByTool(request.params.name);
|
||||||
if (!serverInfo) {
|
if (!serverInfo) {
|
||||||
throw new Error(`Server not found: ${request.params.name}`);
|
throw new Error(`Server not found: ${request.params.name}`);
|
||||||
|
|||||||
706
src/services/vectorSearchService.ts
Normal file
706
src/services/vectorSearchService.ts
Normal file
@@ -0,0 +1,706 @@
|
|||||||
|
import { getRepositoryFactory } from '../db/index.js';
|
||||||
|
import { VectorEmbeddingRepository } from '../db/repositories/index.js';
|
||||||
|
import { ToolInfo } from '../types/index.js';
|
||||||
|
import { getAppDataSource, initializeDatabase } from '../db/connection.js';
|
||||||
|
import { loadSettings } from '../config/index.js';
|
||||||
|
import OpenAI from 'openai';
|
||||||
|
|
||||||
|
// Get OpenAI configuration from smartRouting settings or fallback to environment variables
|
||||||
|
const getOpenAIConfig = () => {
|
||||||
|
try {
|
||||||
|
const settings = loadSettings();
|
||||||
|
const smartRouting = settings.systemConfig?.smartRouting;
|
||||||
|
|
||||||
|
return {
|
||||||
|
apiKey: smartRouting?.openaiApiKey || process.env.OPENAI_API_KEY,
|
||||||
|
baseURL:
|
||||||
|
smartRouting?.openaiApiBaseUrl ||
|
||||||
|
process.env.OPENAI_API_BASE_URL ||
|
||||||
|
'https://api.openai.com/v1',
|
||||||
|
embeddingModel:
|
||||||
|
smartRouting?.openaiApiEmbeddingModel ||
|
||||||
|
process.env.OPENAI_API_EMBEDDING_MODEL ||
|
||||||
|
'text-embedding-3-small',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
'Failed to load smartRouting settings, falling back to environment variables:',
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
apiKey: '',
|
||||||
|
baseURL: 'https://api.openai.com/v1',
|
||||||
|
embeddingModel: 'text-embedding-3-small',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Environment variables for embedding configuration
|
||||||
|
const EMBEDDING_ENV = {
|
||||||
|
// The embedding model to use - default to OpenAI but allow BAAI/BGE models
|
||||||
|
MODEL: process.env.EMBEDDING_MODEL || getOpenAIConfig().embeddingModel,
|
||||||
|
// Detect if using a BGE model from the environment variable
|
||||||
|
IS_BGE_MODEL: !!(process.env.EMBEDDING_MODEL && process.env.EMBEDDING_MODEL.includes('bge')),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Constants for embedding models
|
||||||
|
const EMBEDDING_DIMENSIONS = 1536; // OpenAI's text-embedding-3-small outputs 1536 dimensions
|
||||||
|
const BGE_DIMENSIONS = 1024; // BAAI/bge-m3 outputs 1024 dimensions
|
||||||
|
const FALLBACK_DIMENSIONS = 100; // Fallback implementation uses 100 dimensions
|
||||||
|
|
||||||
|
// Get dimensions for a model
|
||||||
|
const getDimensionsForModel = (model: string): number => {
|
||||||
|
if (model.includes('bge-m3')) {
|
||||||
|
return BGE_DIMENSIONS;
|
||||||
|
} else if (model.includes('text-embedding-3')) {
|
||||||
|
return EMBEDDING_DIMENSIONS;
|
||||||
|
} else if (model === 'fallback' || model === 'simple-hash') {
|
||||||
|
return FALLBACK_DIMENSIONS;
|
||||||
|
}
|
||||||
|
// Default to OpenAI dimensions
|
||||||
|
return EMBEDDING_DIMENSIONS;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize the OpenAI client with smartRouting configuration
|
||||||
|
const getOpenAIClient = () => {
|
||||||
|
const config = getOpenAIConfig();
|
||||||
|
return new OpenAI({
|
||||||
|
apiKey: config.apiKey, // Get API key from smartRouting settings or environment variables
|
||||||
|
baseURL: config.baseURL, // Get base URL from smartRouting settings or fallback to default
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate text embedding using OpenAI's embedding model
|
||||||
|
*
|
||||||
|
* NOTE: embeddings are 1536 dimensions by default.
|
||||||
|
* If you previously used the fallback implementation (100 dimensions),
|
||||||
|
* you may need to rebuild your vector database indices after switching.
|
||||||
|
*
|
||||||
|
* @param text Text to generate embeddings for
|
||||||
|
* @returns Promise with vector embedding as number array
|
||||||
|
*/
|
||||||
|
async function generateEmbedding(text: string): Promise<number[]> {
|
||||||
|
try {
|
||||||
|
const config = getOpenAIConfig();
|
||||||
|
const openai = getOpenAIClient();
|
||||||
|
|
||||||
|
// Check if API key is configured
|
||||||
|
if (!openai.apiKey) {
|
||||||
|
console.warn('OpenAI API key is not configured. Using fallback embedding method.');
|
||||||
|
return generateFallbackEmbedding(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate text if it's too long (OpenAI has token limits)
|
||||||
|
const truncatedText = text.length > 8000 ? text.substring(0, 8000) : text;
|
||||||
|
|
||||||
|
// Call OpenAI's embeddings API
|
||||||
|
const response = await openai.embeddings.create({
|
||||||
|
model: config.embeddingModel, // Modern model with better performance
|
||||||
|
input: truncatedText,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return the embedding
|
||||||
|
return response.data[0].embedding;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating embedding:', error);
|
||||||
|
console.warn('Falling back to simple embedding method');
|
||||||
|
return generateFallbackEmbedding(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback embedding function using a simple approach when OpenAI API is unavailable
|
||||||
|
* @param text Text to generate embeddings for
|
||||||
|
* @returns Vector embedding as number array
|
||||||
|
*/
|
||||||
|
function generateFallbackEmbedding(text: string): number[] {
|
||||||
|
const words = text.toLowerCase().split(/\s+/);
|
||||||
|
const vocabulary = [
|
||||||
|
'search',
|
||||||
|
'find',
|
||||||
|
'get',
|
||||||
|
'fetch',
|
||||||
|
'retrieve',
|
||||||
|
'query',
|
||||||
|
'map',
|
||||||
|
'location',
|
||||||
|
'weather',
|
||||||
|
'file',
|
||||||
|
'directory',
|
||||||
|
'email',
|
||||||
|
'message',
|
||||||
|
'send',
|
||||||
|
'create',
|
||||||
|
'update',
|
||||||
|
'delete',
|
||||||
|
'browser',
|
||||||
|
'web',
|
||||||
|
'page',
|
||||||
|
'click',
|
||||||
|
'navigate',
|
||||||
|
'screenshot',
|
||||||
|
'automation',
|
||||||
|
'database',
|
||||||
|
'table',
|
||||||
|
'record',
|
||||||
|
'insert',
|
||||||
|
'select',
|
||||||
|
'schema',
|
||||||
|
'data',
|
||||||
|
'image',
|
||||||
|
'photo',
|
||||||
|
'video',
|
||||||
|
'media',
|
||||||
|
'upload',
|
||||||
|
'download',
|
||||||
|
'convert',
|
||||||
|
'text',
|
||||||
|
'document',
|
||||||
|
'pdf',
|
||||||
|
'excel',
|
||||||
|
'word',
|
||||||
|
'format',
|
||||||
|
'parse',
|
||||||
|
'api',
|
||||||
|
'rest',
|
||||||
|
'http',
|
||||||
|
'request',
|
||||||
|
'response',
|
||||||
|
'json',
|
||||||
|
'xml',
|
||||||
|
'time',
|
||||||
|
'date',
|
||||||
|
'calendar',
|
||||||
|
'schedule',
|
||||||
|
'reminder',
|
||||||
|
'clock',
|
||||||
|
'math',
|
||||||
|
'calculate',
|
||||||
|
'number',
|
||||||
|
'sum',
|
||||||
|
'average',
|
||||||
|
'statistics',
|
||||||
|
'user',
|
||||||
|
'account',
|
||||||
|
'login',
|
||||||
|
'auth',
|
||||||
|
'permission',
|
||||||
|
'role',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Create vector with fallback dimensions
|
||||||
|
const vector = new Array(FALLBACK_DIMENSIONS).fill(0);
|
||||||
|
|
||||||
|
words.forEach((word) => {
|
||||||
|
const index = vocabulary.indexOf(word);
|
||||||
|
if (index >= 0 && index < vector.length) {
|
||||||
|
vector[index] += 1;
|
||||||
|
}
|
||||||
|
// Add some randomness based on word hash
|
||||||
|
const hash = word.split('').reduce((a, b) => a + b.charCodeAt(0), 0);
|
||||||
|
vector[hash % vector.length] += 0.1;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Normalize the vector
|
||||||
|
const magnitude = Math.sqrt(vector.reduce((sum, val) => sum + val * val, 0));
|
||||||
|
if (magnitude > 0) {
|
||||||
|
return vector.map((val) => val / magnitude);
|
||||||
|
}
|
||||||
|
|
||||||
|
return vector;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save tool information as vector embeddings
|
||||||
|
* @param serverName Server name
|
||||||
|
* @param tools Array of tools to save
|
||||||
|
*/
|
||||||
|
export const saveToolsAsVectorEmbeddings = async (
|
||||||
|
serverName: string,
|
||||||
|
tools: ToolInfo[],
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const config = getOpenAIConfig();
|
||||||
|
const vectorRepository = getRepositoryFactory(
|
||||||
|
'vectorEmbeddings',
|
||||||
|
)() as VectorEmbeddingRepository;
|
||||||
|
|
||||||
|
for (const tool of tools) {
|
||||||
|
// Create searchable text from tool information
|
||||||
|
const searchableText = [
|
||||||
|
tool.name,
|
||||||
|
tool.description,
|
||||||
|
// Include input schema properties if available
|
||||||
|
...(tool.inputSchema && typeof tool.inputSchema === 'object'
|
||||||
|
? Object.keys(tool.inputSchema).filter((key) => key !== 'type' && key !== 'properties')
|
||||||
|
: []),
|
||||||
|
// Include schema property names if available
|
||||||
|
...(tool.inputSchema &&
|
||||||
|
tool.inputSchema.properties &&
|
||||||
|
typeof tool.inputSchema.properties === 'object'
|
||||||
|
? Object.keys(tool.inputSchema.properties)
|
||||||
|
: []),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Generate embedding
|
||||||
|
const embedding = await generateEmbedding(searchableText);
|
||||||
|
|
||||||
|
// Check database compatibility before saving
|
||||||
|
await checkDatabaseVectorDimensions(embedding.length);
|
||||||
|
|
||||||
|
// Save embedding
|
||||||
|
await vectorRepository.saveEmbedding(
|
||||||
|
'tool',
|
||||||
|
`${serverName}:${tool.name}`,
|
||||||
|
searchableText,
|
||||||
|
embedding,
|
||||||
|
{
|
||||||
|
serverName,
|
||||||
|
toolName: tool.name,
|
||||||
|
description: tool.description,
|
||||||
|
inputSchema: tool.inputSchema,
|
||||||
|
},
|
||||||
|
config.embeddingModel, // Store the model used for this embedding
|
||||||
|
);
|
||||||
|
} catch (toolError) {
|
||||||
|
console.error(`Error processing tool ${tool.name} for server ${serverName}:`, toolError);
|
||||||
|
// Continue with the next tool rather than failing the whole batch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Saved ${tools.length} tool embeddings for server: ${serverName}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error saving tool embeddings for server ${serverName}:`, error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for tools using vector similarity
|
||||||
|
* @param query Search query text
|
||||||
|
* @param limit Maximum number of results to return
|
||||||
|
* @param threshold Similarity threshold (0-1)
|
||||||
|
* @param serverNames Optional array of server names to filter by
|
||||||
|
*/
|
||||||
|
export const searchToolsByVector = async (
|
||||||
|
query: string,
|
||||||
|
limit: number = 10,
|
||||||
|
threshold: number = 0.7,
|
||||||
|
serverNames?: string[],
|
||||||
|
): Promise<
|
||||||
|
Array<{
|
||||||
|
serverName: string;
|
||||||
|
toolName: string;
|
||||||
|
description: string;
|
||||||
|
inputSchema: any;
|
||||||
|
similarity: number;
|
||||||
|
searchableText: string;
|
||||||
|
}>
|
||||||
|
> => {
|
||||||
|
try {
|
||||||
|
const vectorRepository = getRepositoryFactory(
|
||||||
|
'vectorEmbeddings',
|
||||||
|
)() as VectorEmbeddingRepository;
|
||||||
|
|
||||||
|
// Search by text using vector similarity
|
||||||
|
const results = await vectorRepository.searchByText(
|
||||||
|
query,
|
||||||
|
generateEmbedding,
|
||||||
|
limit,
|
||||||
|
threshold,
|
||||||
|
['tool'],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Filter by server names if provided
|
||||||
|
let filteredResults = results;
|
||||||
|
if (serverNames && serverNames.length > 0) {
|
||||||
|
filteredResults = results.filter((result) => {
|
||||||
|
if (typeof result.embedding.metadata === 'string') {
|
||||||
|
try {
|
||||||
|
const parsedMetadata = JSON.parse(result.embedding.metadata);
|
||||||
|
return serverNames.includes(parsedMetadata.serverName);
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform results to a more useful format
|
||||||
|
return filteredResults.map((result) => {
|
||||||
|
// Check if we have metadata as a string that needs to be parsed
|
||||||
|
if (result.embedding?.metadata && typeof result.embedding.metadata === 'string') {
|
||||||
|
try {
|
||||||
|
// Parse the metadata string as JSON
|
||||||
|
const parsedMetadata = JSON.parse(result.embedding.metadata);
|
||||||
|
|
||||||
|
if (parsedMetadata.serverName && parsedMetadata.toolName) {
|
||||||
|
// We have properly structured metadata
|
||||||
|
return {
|
||||||
|
serverName: parsedMetadata.serverName,
|
||||||
|
toolName: parsedMetadata.toolName,
|
||||||
|
description: parsedMetadata.description || '',
|
||||||
|
inputSchema: parsedMetadata.inputSchema || {},
|
||||||
|
similarity: result.similarity,
|
||||||
|
searchableText: result.embedding.text_content,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing metadata string:', error);
|
||||||
|
// Fall through to the extraction logic below
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract tool info from text_content if metadata is not available or parsing failed
|
||||||
|
const textContent = result.embedding?.text_content || '';
|
||||||
|
|
||||||
|
// Extract toolName (first word of text_content)
|
||||||
|
const toolNameMatch = textContent.match(/^(\S+)/);
|
||||||
|
const toolName = toolNameMatch ? toolNameMatch[1] : '';
|
||||||
|
|
||||||
|
// Extract serverName from toolName if it follows the pattern "serverName_toolPart"
|
||||||
|
const serverNameMatch = toolName.match(/^([^_]+)_/);
|
||||||
|
const serverName = serverNameMatch ? serverNameMatch[1] : 'unknown';
|
||||||
|
|
||||||
|
// Extract description (everything after the first word)
|
||||||
|
const description = textContent.replace(/^\S+\s*/, '').trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
serverName,
|
||||||
|
toolName,
|
||||||
|
description,
|
||||||
|
inputSchema: {},
|
||||||
|
similarity: result.similarity,
|
||||||
|
searchableText: textContent,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error searching tools by vector:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all available tools in vector database
|
||||||
|
* @param serverNames Optional array of server names to filter by
|
||||||
|
*/
|
||||||
|
export const getAllVectorizedTools = async (
|
||||||
|
serverNames?: string[],
|
||||||
|
): Promise<
|
||||||
|
Array<{
|
||||||
|
serverName: string;
|
||||||
|
toolName: string;
|
||||||
|
description: string;
|
||||||
|
inputSchema: any;
|
||||||
|
}>
|
||||||
|
> => {
|
||||||
|
try {
|
||||||
|
const config = getOpenAIConfig();
|
||||||
|
const vectorRepository = getRepositoryFactory(
|
||||||
|
'vectorEmbeddings',
|
||||||
|
)() as VectorEmbeddingRepository;
|
||||||
|
|
||||||
|
// Try to determine what dimension our database is using
|
||||||
|
let dimensionsToUse = getDimensionsForModel(config.embeddingModel); // Default based on the model selected
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await getAppDataSource().query(`
|
||||||
|
SELECT atttypmod as dimensions
|
||||||
|
FROM pg_attribute
|
||||||
|
WHERE attrelid = 'vector_embeddings'::regclass
|
||||||
|
AND attname = 'embedding'
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (result && result.length > 0 && result[0].dimensions) {
|
||||||
|
const rawValue = result[0].dimensions;
|
||||||
|
|
||||||
|
if (rawValue === -1) {
|
||||||
|
// No type modifier specified
|
||||||
|
dimensionsToUse = getDimensionsForModel(config.embeddingModel);
|
||||||
|
} else {
|
||||||
|
// For this version of pgvector, atttypmod stores the dimension value directly
|
||||||
|
dimensionsToUse = rawValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.warn('Could not determine vector dimensions from database:', error?.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all tool embeddings
|
||||||
|
const results = await vectorRepository.searchSimilar(
|
||||||
|
new Array(dimensionsToUse).fill(0), // Zero vector with dimensions matching the database
|
||||||
|
1000, // Large limit
|
||||||
|
-1, // No threshold (get all)
|
||||||
|
['tool'],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Filter by server names if provided
|
||||||
|
let filteredResults = results;
|
||||||
|
if (serverNames && serverNames.length > 0) {
|
||||||
|
filteredResults = results.filter((result) => {
|
||||||
|
if (typeof result.embedding.metadata === 'string') {
|
||||||
|
try {
|
||||||
|
const parsedMetadata = JSON.parse(result.embedding.metadata);
|
||||||
|
return serverNames.includes(parsedMetadata.serverName);
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform results
|
||||||
|
return filteredResults.map((result) => {
|
||||||
|
if (typeof result.embedding.metadata === 'string') {
|
||||||
|
try {
|
||||||
|
const parsedMetadata = JSON.parse(result.embedding.metadata);
|
||||||
|
return {
|
||||||
|
serverName: parsedMetadata.serverName,
|
||||||
|
toolName: parsedMetadata.toolName,
|
||||||
|
description: parsedMetadata.description,
|
||||||
|
inputSchema: parsedMetadata.inputSchema,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing metadata string:', error);
|
||||||
|
return {
|
||||||
|
serverName: 'unknown',
|
||||||
|
toolName: 'unknown',
|
||||||
|
description: '',
|
||||||
|
inputSchema: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
serverName: 'unknown',
|
||||||
|
toolName: 'unknown',
|
||||||
|
description: '',
|
||||||
|
inputSchema: {},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting all vectorized tools:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove tool embeddings for a server
|
||||||
|
* @param serverName Server name
|
||||||
|
*/
|
||||||
|
export const removeServerToolEmbeddings = async (serverName: string): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const vectorRepository = getRepositoryFactory(
|
||||||
|
'vectorEmbeddings',
|
||||||
|
)() as VectorEmbeddingRepository;
|
||||||
|
|
||||||
|
// Note: This would require adding a delete method to VectorEmbeddingRepository
|
||||||
|
// For now, we'll log that this functionality needs to be implemented
|
||||||
|
console.log(`TODO: Remove tool embeddings for server: ${serverName}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error removing tool embeddings for server ${serverName}:`, error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync all server tools embeddings when smart routing is first enabled
|
||||||
|
* This function will scan all currently connected servers and save their tools as vector embeddings
|
||||||
|
*/
|
||||||
|
export const syncAllServerToolsEmbeddings = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
console.log('Starting synchronization of all server tools embeddings...');
|
||||||
|
|
||||||
|
// Import getServersInfo to get all server information
|
||||||
|
const { getServersInfo } = await import('./mcpService.js');
|
||||||
|
|
||||||
|
const servers = getServersInfo();
|
||||||
|
let totalToolsSynced = 0;
|
||||||
|
let serversSynced = 0;
|
||||||
|
|
||||||
|
for (const server of servers) {
|
||||||
|
if (server.status === 'connected' && server.tools && server.tools.length > 0) {
|
||||||
|
try {
|
||||||
|
console.log(`Syncing tools for server: ${server.name} (${server.tools.length} tools)`);
|
||||||
|
await saveToolsAsVectorEmbeddings(server.name, server.tools);
|
||||||
|
totalToolsSynced += server.tools.length;
|
||||||
|
serversSynced++;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to sync tools for server ${server.name}:`, error);
|
||||||
|
}
|
||||||
|
} else if (server.status === 'connected' && (!server.tools || server.tools.length === 0)) {
|
||||||
|
console.log(`Server ${server.name} is connected but has no tools to sync`);
|
||||||
|
} else {
|
||||||
|
console.log(`Skipping server ${server.name} (status: ${server.status})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Smart routing tools sync completed: synced ${totalToolsSynced} tools from ${serversSynced} servers`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during smart routing tools synchronization:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check database vector dimensions and ensure compatibility
|
||||||
|
* @param dimensionsNeeded The number of dimensions required
|
||||||
|
* @returns Promise that resolves when check is complete
|
||||||
|
*/
|
||||||
|
async function checkDatabaseVectorDimensions(dimensionsNeeded: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
// First check if database is initialized
|
||||||
|
if (!getAppDataSource().isInitialized) {
|
||||||
|
console.info('Database not initialized, initializing...');
|
||||||
|
await initializeDatabase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check current vector dimension in the database
|
||||||
|
// First try to get vector type info directly
|
||||||
|
let vectorTypeInfo;
|
||||||
|
try {
|
||||||
|
vectorTypeInfo = await getAppDataSource().query(`
|
||||||
|
SELECT
|
||||||
|
atttypmod,
|
||||||
|
format_type(atttypid, atttypmod) as formatted_type
|
||||||
|
FROM pg_attribute
|
||||||
|
WHERE attrelid = 'vector_embeddings'::regclass
|
||||||
|
AND attname = 'embedding'
|
||||||
|
`);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Could not get vector type info, falling back to atttypmod query');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to original query
|
||||||
|
const result = await getAppDataSource().query(`
|
||||||
|
SELECT atttypmod as dimensions
|
||||||
|
FROM pg_attribute
|
||||||
|
WHERE attrelid = 'vector_embeddings'::regclass
|
||||||
|
AND attname = 'embedding'
|
||||||
|
`);
|
||||||
|
|
||||||
|
let currentDimensions = 0;
|
||||||
|
|
||||||
|
// Parse dimensions from result
|
||||||
|
if (result && result.length > 0 && result[0].dimensions) {
|
||||||
|
if (vectorTypeInfo && vectorTypeInfo.length > 0) {
|
||||||
|
// Try to extract dimensions from formatted type like "vector(1024)"
|
||||||
|
const match = vectorTypeInfo[0].formatted_type?.match(/vector\((\d+)\)/);
|
||||||
|
if (match) {
|
||||||
|
currentDimensions = parseInt(match[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we couldn't extract from formatted type, use the atttypmod value directly
|
||||||
|
if (currentDimensions === 0) {
|
||||||
|
const rawValue = result[0].dimensions;
|
||||||
|
|
||||||
|
if (rawValue === -1) {
|
||||||
|
// No type modifier specified
|
||||||
|
currentDimensions = 0;
|
||||||
|
} else {
|
||||||
|
// For this version of pgvector, atttypmod stores the dimension value directly
|
||||||
|
currentDimensions = rawValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check the dimensions stored in actual records for validation
|
||||||
|
try {
|
||||||
|
const recordCheck = await getAppDataSource().query(`
|
||||||
|
SELECT dimensions, model, COUNT(*) as count
|
||||||
|
FROM vector_embeddings
|
||||||
|
GROUP BY dimensions, model
|
||||||
|
ORDER BY count DESC
|
||||||
|
LIMIT 5
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (recordCheck && recordCheck.length > 0) {
|
||||||
|
// If we couldn't determine dimensions from schema, use the most common dimension from records
|
||||||
|
if (currentDimensions === 0 && recordCheck[0].dimensions) {
|
||||||
|
currentDimensions = recordCheck[0].dimensions;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Could not check dimensions from actual records:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no dimensions are set or they don't match what we need, handle the mismatch
|
||||||
|
if (currentDimensions === 0 || currentDimensions !== dimensionsNeeded) {
|
||||||
|
console.log(
|
||||||
|
`Vector dimensions mismatch: database=${currentDimensions}, needed=${dimensionsNeeded}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (currentDimensions === 0) {
|
||||||
|
console.log('Setting up vector dimensions for the first time...');
|
||||||
|
} else {
|
||||||
|
console.log('Dimension mismatch detected. Clearing existing incompatible vector data...');
|
||||||
|
|
||||||
|
// Clear all existing vector embeddings with mismatched dimensions
|
||||||
|
await clearMismatchedVectorData(dimensionsNeeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop any existing indices first
|
||||||
|
await getAppDataSource().query(`DROP INDEX IF EXISTS idx_vector_embeddings_embedding;`);
|
||||||
|
|
||||||
|
// Alter the column type with the new dimensions
|
||||||
|
await getAppDataSource().query(`
|
||||||
|
ALTER TABLE vector_embeddings
|
||||||
|
ALTER COLUMN embedding TYPE vector(${dimensionsNeeded});
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Create a new index with better error handling
|
||||||
|
try {
|
||||||
|
await getAppDataSource().query(`
|
||||||
|
CREATE INDEX idx_vector_embeddings_embedding
|
||||||
|
ON vector_embeddings USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
|
||||||
|
`);
|
||||||
|
} catch (indexError: any) {
|
||||||
|
// If the index already exists (code 42P07) or there's a duplicate key constraint (code 23505),
|
||||||
|
// it's not a critical error as the index is already there
|
||||||
|
if (indexError.code === '42P07' || indexError.code === '23505') {
|
||||||
|
console.log('Index already exists, continuing...');
|
||||||
|
} else {
|
||||||
|
console.warn('Warning: Failed to create index, but continuing:', indexError.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Successfully configured vector dimensions to ${dimensionsNeeded}`);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error checking/updating vector dimensions:', error);
|
||||||
|
throw new Error(`Vector dimension check failed: ${error?.message || 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear vector embeddings with mismatched dimensions
|
||||||
|
* @param expectedDimensions The expected dimensions
|
||||||
|
* @returns Promise that resolves when cleanup is complete
|
||||||
|
*/
|
||||||
|
async function clearMismatchedVectorData(expectedDimensions: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
console.log(
|
||||||
|
`Clearing vector embeddings with dimensions different from ${expectedDimensions}...`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete all embeddings that don't match the expected dimensions
|
||||||
|
await getAppDataSource().query(
|
||||||
|
`
|
||||||
|
DELETE FROM vector_embeddings
|
||||||
|
WHERE dimensions != $1
|
||||||
|
`,
|
||||||
|
[expectedDimensions],
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('Successfully cleared mismatched vector embeddings');
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error clearing mismatched vector data:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -90,6 +90,13 @@ export interface McpSettings {
|
|||||||
pythonIndexUrl?: string; // Python package repository URL (UV_DEFAULT_INDEX)
|
pythonIndexUrl?: string; // Python package repository URL (UV_DEFAULT_INDEX)
|
||||||
npmRegistry?: string; // NPM registry URL (npm_config_registry)
|
npmRegistry?: string; // NPM registry URL (npm_config_registry)
|
||||||
};
|
};
|
||||||
|
smartRouting?: {
|
||||||
|
enabled?: boolean; // Controls whether smart routing is enabled
|
||||||
|
dbUrl?: string; // Database URL for smart routing
|
||||||
|
openaiApiBaseUrl?: string; // OpenAI API base URL
|
||||||
|
openaiApiKey?: string; // OpenAI API key
|
||||||
|
openaiApiEmbeddingModel?: string; // OpenAI API embedding model
|
||||||
|
};
|
||||||
// Add other system configuration sections here in the future
|
// Add other system configuration sections here in the future
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,10 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"sourceMap": true
|
"sourceMap": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"strictPropertyInitialization": false
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "**/*.test.ts", "dist"]
|
"exclude": ["node_modules", "**/*.test.ts", "dist"]
|
||||||
|
|||||||
Reference in New Issue
Block a user