feat: introduce auto routing (#122)

This commit is contained in:
samanhappy
2025-05-25 21:09:47 +08:00
committed by GitHub
parent 27b7e766af
commit 9e5c2b5525
24 changed files with 2864 additions and 26 deletions

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

View File

@@ -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>

View File

@@ -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 }}>

View File

@@ -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,
};
};

View File

@@ -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}}"
}
}

View File

@@ -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}}"
}
}

View File

@@ -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 >
);
};

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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
View 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;

View 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
View 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
View 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 };

View 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;

View 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;

View File

@@ -0,0 +1,4 @@
import VectorEmbeddingRepository from './VectorEmbeddingRepository.js';
// Export all repositories
export { VectorEmbeddingRepository };

View 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;
}
}

View 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);
};
}
}

View File

@@ -1,3 +1,4 @@
import 'reflect-metadata';
import AppServer from './server.js';
const appServer = new AppServer();

View File

@@ -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}`);

View 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;
}
}

View File

@@ -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
};
}

View File

@@ -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"]