mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-23 18:29:21 -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
|
||||
|
||||
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="p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-3">
|
||||
{isGroup ? t('groups.confirmDelete') : t('server.confirmDelete')}
|
||||
</h3>
|
||||
<p className="text-gray-500 mb-6">
|
||||
{isGroup
|
||||
{isGroup
|
||||
? t('groups.deleteWarning', { name: serverName })
|
||||
: t('server.deleteWarning', { name: serverName })}
|
||||
</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';
|
||||
|
||||
interface ToastContextProps {
|
||||
@@ -32,18 +32,18 @@ export const ToastProvider: React.FC<ToastProviderProps> = ({ children }) => {
|
||||
duration: 3000,
|
||||
});
|
||||
|
||||
const showToast = (message: string, type: ToastType = 'info', duration: number = 3000) => {
|
||||
const showToast = useCallback((message: string, type: ToastType = 'info', duration: number = 3000) => {
|
||||
setToast({
|
||||
message,
|
||||
type,
|
||||
visible: true,
|
||||
duration,
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
const hideToast = () => {
|
||||
const hideToast = useCallback(() => {
|
||||
setToast((prev) => ({ ...prev, visible: false }));
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ showToast }}>
|
||||
|
||||
@@ -16,10 +16,19 @@ interface InstallConfig {
|
||||
npmRegistry: string;
|
||||
}
|
||||
|
||||
interface SmartRoutingConfig {
|
||||
enabled: boolean;
|
||||
dbUrl: string;
|
||||
openaiApiBaseUrl: string;
|
||||
openaiApiKey: string;
|
||||
openaiApiEmbeddingModel: string;
|
||||
}
|
||||
|
||||
interface SystemSettings {
|
||||
systemConfig?: {
|
||||
routing?: RoutingConfig;
|
||||
install?: InstallConfig;
|
||||
smartRouting?: SmartRoutingConfig;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -47,6 +56,14 @@ export const useSettingsData = () => {
|
||||
npmRegistry: '',
|
||||
});
|
||||
|
||||
const [smartRoutingConfig, setSmartRoutingConfig] = useState<SmartRoutingConfig>({
|
||||
enabled: false,
|
||||
dbUrl: '',
|
||||
openaiApiBaseUrl: '',
|
||||
openaiApiKey: '',
|
||||
openaiApiEmbeddingModel: '',
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
@@ -89,14 +106,25 @@ export const useSettingsData = () => {
|
||||
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) {
|
||||
console.error('Failed to fetch settings:', error);
|
||||
setError(error instanceof Error ? error.message : 'Failed to fetch settings');
|
||||
// 使用一个稳定的 showToast 引用,避免将其加入依赖数组
|
||||
showToast(t('errors.failedToFetchSettings'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t, showToast]);
|
||||
}, [t]); // 移除 showToast 依赖
|
||||
|
||||
// Update routing configuration
|
||||
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
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
@@ -213,6 +342,7 @@ export const useSettingsData = () => {
|
||||
tempRoutingConfig,
|
||||
setTempRoutingConfig,
|
||||
installConfig,
|
||||
smartRoutingConfig,
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
@@ -220,5 +350,7 @@ export const useSettingsData = () => {
|
||||
fetchSettings,
|
||||
updateRoutingConfig,
|
||||
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...",
|
||||
"serverInstall": "Failed to install server",
|
||||
"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": {
|
||||
"processing": "Processing...",
|
||||
@@ -170,8 +171,9 @@
|
||||
"account": "Account Settings",
|
||||
"password": "Change Password",
|
||||
"appearance": "Appearance",
|
||||
"routeConfig": "Security Configuration",
|
||||
"installConfig": "Installation Configuration"
|
||||
"routeConfig": "Security",
|
||||
"installConfig": "Installation",
|
||||
"smartRouting": "Smart Routing"
|
||||
},
|
||||
"market": {
|
||||
"title": "Server Market - (Data from mcpm.sh)"
|
||||
@@ -277,7 +279,20 @@
|
||||
"npmRegistry": "NPM Registry URL",
|
||||
"npmRegistryDescription": "Set npm_config_registry environment variable for NPM package installation",
|
||||
"npmRegistryPlaceholder": "e.g. https://registry.npmjs.org/",
|
||||
"installConfig": "Installation Configuration",
|
||||
"systemConfigUpdated": "System configuration updated successfully"
|
||||
"installConfig": "Installation",
|
||||
"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": "安装服务器失败",
|
||||
"failedToFetchSettings": "获取设置失败",
|
||||
"failedToUpdateSystemConfig": "更新系统配置失败",
|
||||
"failedToUpdateRouteConfig": "更新路由配置失败"
|
||||
"failedToUpdateRouteConfig": "更新路由配置失败",
|
||||
"failedToUpdateSmartRoutingConfig": "更新智能路由配置失败"
|
||||
},
|
||||
"common": {
|
||||
"processing": "处理中...",
|
||||
@@ -169,7 +170,8 @@
|
||||
"password": "修改密码",
|
||||
"appearance": "外观",
|
||||
"routeConfig": "安全配置",
|
||||
"installConfig": "安装配置"
|
||||
"installConfig": "安装",
|
||||
"smartRouting": "智能路由"
|
||||
},
|
||||
"groups": {
|
||||
"title": "分组管理"
|
||||
@@ -279,6 +281,20 @@
|
||||
"npmRegistryDescription": "设置 npm_config_registry 环境变量,用于 NPM 包安装",
|
||||
"npmRegistryPlaceholder": "例如: https://registry.npmmirror.com/",
|
||||
"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: '',
|
||||
});
|
||||
|
||||
const [tempSmartRoutingConfig, setTempSmartRoutingConfig] = useState<{
|
||||
dbUrl: string;
|
||||
openaiApiBaseUrl: string;
|
||||
openaiApiKey: string;
|
||||
openaiApiEmbeddingModel: string;
|
||||
}>({
|
||||
dbUrl: '',
|
||||
openaiApiBaseUrl: '',
|
||||
openaiApiKey: '',
|
||||
openaiApiEmbeddingModel: '',
|
||||
});
|
||||
|
||||
const {
|
||||
routingConfig,
|
||||
tempRoutingConfig,
|
||||
setTempRoutingConfig,
|
||||
installConfig: savedInstallConfig,
|
||||
smartRoutingConfig,
|
||||
loading,
|
||||
updateRoutingConfig,
|
||||
updateInstallConfig
|
||||
updateInstallConfig,
|
||||
updateSmartRoutingConfig,
|
||||
updateSmartRoutingConfigBatch
|
||||
} = useSettingsData();
|
||||
|
||||
// Update local installConfig when savedInstallConfig changes
|
||||
@@ -43,13 +58,26 @@ const SettingsPage: React.FC = () => {
|
||||
}
|
||||
}, [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({
|
||||
routingConfig: false,
|
||||
installConfig: false,
|
||||
smartRoutingConfig: false,
|
||||
password: false
|
||||
});
|
||||
|
||||
const toggleSection = (section: 'routingConfig' | 'installConfig' | 'password') => {
|
||||
const toggleSection = (section: 'routingConfig' | 'installConfig' | 'smartRoutingConfig' | 'password') => {
|
||||
setSectionsVisible(prev => ({
|
||||
...prev,
|
||||
[section]: !prev[section]
|
||||
@@ -91,6 +119,59 @@ const SettingsPage: React.FC = () => {
|
||||
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 = () => {
|
||||
setTimeout(() => {
|
||||
navigate('/');
|
||||
@@ -133,6 +214,131 @@ const SettingsPage: React.FC = () => {
|
||||
</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 */}
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6">
|
||||
<div
|
||||
@@ -296,7 +502,7 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div >
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
10
package.json
10
package.json
@@ -42,11 +42,19 @@
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.11.1",
|
||||
"@types/pg": "^8.15.2",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"dotenv": "^16.3.1",
|
||||
"dotenv-expand": "^12.0.2",
|
||||
"express": "^4.21.2",
|
||||
"express-validator": "^7.2.1",
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -96,4 +104,4 @@
|
||||
"node": "^18.0.0 || >=20.0.0"
|
||||
},
|
||||
"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,
|
||||
} from '../services/mcpService.js';
|
||||
import { loadSettings, saveSettings } from '../config/index.js';
|
||||
import { syncAllServerToolsEmbeddings } from '../services/vectorSearchService.js';
|
||||
|
||||
export const getAllServers = (_: Request, res: Response): void => {
|
||||
try {
|
||||
@@ -283,7 +284,7 @@ export const toggleServer = async (req: Request, res: Response): Promise<void> =
|
||||
|
||||
export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { routing, install } = req.body;
|
||||
const { routing, install, smartRouting } = req.body;
|
||||
|
||||
if (
|
||||
(!routing ||
|
||||
@@ -292,7 +293,13 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
typeof routing.enableBearerAuth !== 'boolean' &&
|
||||
typeof routing.bearerAuthKey !== 'string')) &&
|
||||
(!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({
|
||||
success: false,
|
||||
@@ -314,6 +321,13 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
pythonIndexUrl: '',
|
||||
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 (typeof routing.enableGlobalRoute === 'boolean') {
|
||||
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)) {
|
||||
res.json({
|
||||
success: true,
|
||||
data: settings.systemConfig,
|
||||
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 {
|
||||
res.status(500).json({
|
||||
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';
|
||||
|
||||
const appServer = new AppServer();
|
||||
|
||||
@@ -9,6 +9,7 @@ import { loadSettings, saveSettings, expandEnvVars } from '../config/index.js';
|
||||
import config from '../config/index.js';
|
||||
import { getGroup } from './sseService.js';
|
||||
import { getServersInGroup } from './groupService.js';
|
||||
import { saveToolsAsVectorEmbeddings, searchToolsByVector } from './vectorSearchService.js';
|
||||
|
||||
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)
|
||||
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;
|
||||
}
|
||||
|
||||
// Add npm_config_registry from settings if available (for NPM packages)
|
||||
if (
|
||||
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;
|
||||
}
|
||||
@@ -168,6 +176,22 @@ export const initializeClientsFromSettings = (isInit: boolean): ServerInfo[] =>
|
||||
}));
|
||||
serverInfo.status = 'connected';
|
||||
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) => {
|
||||
console.error(
|
||||
@@ -258,7 +282,6 @@ export const addServer = async (
|
||||
return { success: false, message: 'Failed to save settings' };
|
||||
}
|
||||
|
||||
registerAllTools(false);
|
||||
return { success: true, message: 'Server added successfully' };
|
||||
} catch (error) {
|
||||
console.error(`Failed to add server: ${name}`, error);
|
||||
@@ -369,6 +392,74 @@ const handleListToolsRequest = async (_: any, extra: any) => {
|
||||
const sessionId = extra.sessionId || '';
|
||||
const group = getGroup(sessionId);
|
||||
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) => {
|
||||
if (serverInfo.enabled === false) return false;
|
||||
if (!group) return true;
|
||||
@@ -392,6 +483,143 @@ const handleListToolsRequest = async (_: any, extra: any) => {
|
||||
const handleCallToolRequest = async (request: any, extra: any) => {
|
||||
console.log(`Handling CallToolRequest for tool: ${request.params.name}`);
|
||||
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);
|
||||
if (!serverInfo) {
|
||||
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)
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,7 +10,10 @@
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"sourceMap": true
|
||||
"sourceMap": true,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"strictPropertyInitialization": false
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "**/*.test.ts", "dist"]
|
||||
|
||||
Reference in New Issue
Block a user