Compare commits

..

4 Commits

45 changed files with 2119 additions and 568 deletions

2
.gitignore vendored
View File

@@ -24,3 +24,5 @@ yarn-error.log*
.vscode/
*.log
coverage/
data/

View File

@@ -57,7 +57,7 @@ Create a `mcp_settings.json` file to customize your server settings:
**Recommended**: Mount your custom config:
```bash
docker run -p 3000:3000 -v $(pwd)/mcp_settings.json:/app/mcp_settings.json samanhappy/mcphub
docker run -p 3000:3000 -v ./mcp_settings.json:/app/mcp_settings.json -v ./data:/app/data samanhappy/mcphub
```
or run with default settings:

View File

@@ -57,7 +57,7 @@ MCPHub 通过将多个 MCPModel Context Protocol服务器组织为灵活
**推荐**:挂载自定义配置:
```bash
docker run -p 3000:3000 -v $(pwd)/mcp_settings.json:/app/mcp_settings.json samanhappy/mcphub
docker run -p 3000:3000 -v ./mcp_settings.json:/app/mcp_settings.json -v ./data:/app/data samanhappy/mcphub
```
或使用默认配置运行:

View File

@@ -1,13 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MCP Hub Dashboard</title>
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
</head>
<body class="bg-gray-100">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -50,7 +50,7 @@ const AddGroupForm = ({ onAdd, onCancel }: AddGroupFormProps) => {
}
const result = await createGroup(formData.name, formData.description, formData.servers)
if (!result) {
setError(t('groups.createError'))
setIsSubmitting(false)
@@ -69,7 +69,7 @@ const AddGroupForm = ({ onAdd, onCancel }: AddGroupFormProps) => {
<div className="bg-white rounded-lg shadow-lg max-w-md w-full">
<div className="p-6">
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t('groups.addNew')}</h2>
{error && (
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded">
{error}
@@ -87,7 +87,7 @@ const AddGroupForm = ({ onAdd, onCancel }: AddGroupFormProps) => {
name="name"
value={formData.name}
onChange={handleChange}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
placeholder={t('groups.namePlaceholder')}
required
/>
@@ -109,14 +109,14 @@ const AddGroupForm = ({ onAdd, onCancel }: AddGroupFormProps) => {
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-gray-600 hover:text-gray-800"
className="px-4 py-2 text-gray-600 hover:text-gray-800 btn-secondary"
disabled={isSubmitting}
>
{t('common.cancel')}
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50 btn-primary"
disabled={isSubmitting}
>
{isSubmitting ? t('common.submitting') : t('common.create')}

View File

@@ -1,7 +1,8 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import ServerForm from './ServerForm'
import { getApiUrl } from '../utils/runtime';
import { getApiUrl } from '../utils/runtime'
import { detectVariables } from '../utils/variableDetection'
interface AddServerFormProps {
onAdd: () => void
@@ -11,13 +12,26 @@ const AddServerForm = ({ onAdd }: AddServerFormProps) => {
const { t } = useTranslation()
const [modalVisible, setModalVisible] = useState(false)
const [error, setError] = useState<string | null>(null)
const [confirmationVisible, setConfirmationVisible] = useState(false)
const [pendingPayload, setPendingPayload] = useState<any>(null)
const [detectedVariables, setDetectedVariables] = useState<string[]>([])
const toggleModal = () => {
setModalVisible(!modalVisible)
setError(null) // Clear any previous errors when toggling modal
setConfirmationVisible(false) // Close confirmation dialog
setPendingPayload(null) // Clear pending payload
}
const handleSubmit = async (payload: any) => {
const handleConfirmSubmit = async () => {
if (pendingPayload) {
await submitServer(pendingPayload)
setConfirmationVisible(false)
setPendingPayload(null)
}
}
const submitServer = async (payload: any) => {
try {
setError(null)
const token = localStorage.getItem('mcphub_token');
@@ -65,11 +79,31 @@ const AddServerForm = ({ onAdd }: AddServerFormProps) => {
}
}
const handleSubmit = async (payload: any) => {
try {
// Check for variables in the payload
const variables = detectVariables(payload)
if (variables.length > 0) {
// Show confirmation dialog
setDetectedVariables(variables)
setPendingPayload(payload)
setConfirmationVisible(true)
} else {
// Submit directly if no variables found
await submitServer(payload)
}
} catch (err) {
console.error('Error processing server submission:', err)
setError(t('errors.serverAdd'))
}
}
return (
<div>
<button
onClick={toggleModal}
className="w-full bg-blue-100 text-blue-800 rounded hover:bg-blue-200 py-2 px-4 flex items-center justify-center"
className="w-full bg-blue-100 text-blue-800 rounded hover:bg-blue-200 py-2 px-4 flex items-center justify-center btn-primary"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-2" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clipRule="evenodd" />
@@ -87,6 +121,60 @@ const AddServerForm = ({ onAdd }: AddServerFormProps) => {
/>
</div>
)}
{confirmationVisible && (
<div className="fixed inset-0 bg-black/50 z-[60] flex items-center justify-center p-4">
<div className="bg-white rounded-lg p-6 w-full max-w-md">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
{t('server.confirmVariables')}
</h3>
<p className="text-gray-600 mb-4">
{t('server.variablesDetected')}
</p>
<div className="bg-yellow-50 border border-yellow-200 rounded p-3 mb-4">
<div className="flex items-start">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h4 className="text-sm font-medium text-yellow-800">
{t('server.detectedVariables')}:
</h4>
<ul className="mt-1 text-sm text-yellow-700">
{detectedVariables.map((variable, index) => (
<li key={index} className="font-mono">
${`{${variable}}`}
</li>
))}
</ul>
</div>
</div>
</div>
<p className="text-gray-600 text-sm mb-6">
{t('server.confirmVariablesMessage')}
</p>
<div className="flex justify-end space-x-3">
<button
onClick={() => {
setConfirmationVisible(false)
setPendingPayload(null)
}}
className="px-4 py-2 text-gray-600 border border-gray-300 rounded hover:bg-gray-50 btn-secondary"
>
{t('common.cancel')}
</button>
<button
onClick={handleConfirmSubmit}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 btn-primary"
>
{t('server.confirmAndAdd')}
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -31,17 +31,17 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
// Validate passwords match
if (formData.newPassword !== confirmPassword) {
setError(t('auth.passwordsNotMatch'));
return;
}
setIsLoading(true);
try {
const response = await changePassword(formData);
if (response.success) {
setSuccess(true);
if (onSuccess) {
@@ -60,7 +60,7 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
return (
<div className="p-6 bg-white rounded-lg shadow-md">
<h2 className="text-xl font-bold mb-4">{t('auth.changePassword')}</h2>
{success ? (
<div className="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">
{t('auth.changePasswordSuccess')}
@@ -72,7 +72,7 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
{error}
</div>
)}
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="currentPassword">
{t('auth.currentPassword')}
@@ -81,13 +81,13 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
type="password"
id="currentPassword"
name="currentPassword"
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500 form-input"
value={formData.currentPassword}
onChange={handleChange}
required
/>
</div>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="newPassword">
{t('auth.newPassword')}
@@ -96,14 +96,14 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
type="password"
id="newPassword"
name="newPassword"
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500 form-input"
value={formData.newPassword}
onChange={handleChange}
required
minLength={6}
/>
</div>
<div className="mb-6">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="confirmPassword">
{t('auth.confirmPassword')}
@@ -112,14 +112,14 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
type="password"
id="confirmPassword"
name="confirmPassword"
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500 form-input"
value={confirmPassword}
onChange={handleChange}
required
minLength={6}
/>
</div>
<div className="flex justify-end space-x-2">
{onCancel && (
<button
@@ -134,7 +134,7 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
<button
type="submit"
disabled={isLoading}
className="py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
className="py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 btn-primary"
>
{isLoading ? (
<span className="flex items-center">

View File

@@ -0,0 +1,413 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { getApiUrl } from '@/utils/runtime';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
interface DxtUploadFormProps {
onSuccess: (serverConfig: any) => void;
onCancel: () => void;
}
interface DxtUploadResponse {
success: boolean;
data?: {
manifest: any;
extractDir: string;
};
message?: string;
}
const DxtUploadForm: React.FC<DxtUploadFormProps> = ({ onSuccess, onCancel }) => {
const { t } = useTranslation();
const [isDragging, setIsDragging] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [showServerForm, setShowServerForm] = useState(false);
const [manifestData, setManifestData] = useState<any>(null);
const [extractDir, setExtractDir] = useState<string>('');
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [pendingServerName, setPendingServerName] = useState<string>('');
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const files = e.dataTransfer.files;
if (files.length > 0) {
const file = files[0];
if (file.name.endsWith('.dxt')) {
setSelectedFile(file);
setError(null);
} else {
setError(t('dxt.invalidFileType'));
}
}
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
const file = files[0];
if (file.name.endsWith('.dxt')) {
setSelectedFile(file);
setError(null);
} else {
setError(t('dxt.invalidFileType'));
}
}
};
const handleUpload = async () => {
if (!selectedFile) {
setError(t('dxt.noFileSelected'));
return;
}
setIsUploading(true);
setError(null);
try {
const formData = new FormData();
formData.append('dxtFile', selectedFile);
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/dxt/upload'), {
method: 'POST',
headers: {
'x-auth-token': token || '',
},
body: formData,
});
const result: DxtUploadResponse = await response.json();
if (!response.ok) {
throw new Error(result.message || `HTTP error! Status: ${response.status}`);
}
if (result.success && result.data) {
setManifestData(result.data.manifest);
setExtractDir(result.data.extractDir);
setShowServerForm(true);
} else {
throw new Error(result.message || t('dxt.uploadFailed'));
}
} catch (err) {
console.error('DXT upload error:', err);
setError(err instanceof Error ? err.message : t('dxt.uploadFailed'));
} finally {
setIsUploading(false);
}
};
const handleInstallServer = async (serverName: string, forceOverride: boolean = false) => {
setIsUploading(true);
setError(null);
try {
// Convert DXT manifest to MCPHub stdio server configuration
const serverConfig = convertDxtToMcpConfig(manifestData, extractDir, serverName);
const token = localStorage.getItem('mcphub_token');
// First, check if server exists
if (!forceOverride) {
const checkResponse = await fetch(getApiUrl('/servers'), {
method: 'GET',
headers: {
'x-auth-token': token || '',
},
});
if (checkResponse.ok) {
const checkResult = await checkResponse.json();
const existingServer = checkResult.data?.find((server: any) => server.name === serverName);
if (existingServer) {
// Server exists, show confirmation dialog
setPendingServerName(serverName);
setShowConfirmDialog(true);
setIsUploading(false);
return;
}
}
}
// Install or override the server
const method = forceOverride ? 'PUT' : 'POST';
const url = forceOverride ? getApiUrl(`/servers/${encodeURIComponent(serverName)}`) : getApiUrl('/servers');
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || '',
},
body: JSON.stringify({
name: serverName,
config: serverConfig,
}),
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.message || `HTTP error! Status: ${response.status}`);
}
if (result.success) {
onSuccess(serverConfig);
} else {
throw new Error(result.message || t('dxt.installFailed'));
}
} catch (err) {
console.error('DXT install error:', err);
setError(err instanceof Error ? err.message : t('dxt.installFailed'));
setIsUploading(false);
}
};
const handleConfirmOverride = () => {
setShowConfirmDialog(false);
if (pendingServerName) {
handleInstallServer(pendingServerName, true);
}
};
const handleCancelOverride = () => {
setShowConfirmDialog(false);
setPendingServerName('');
setIsUploading(false);
};
const convertDxtToMcpConfig = (manifest: any, extractPath: string, _serverName: string) => {
const mcpConfig = manifest.server?.mcp_config || {};
// Convert DXT manifest to MCPHub stdio configuration
const config: any = {
type: 'stdio',
command: mcpConfig.command || 'node',
args: (mcpConfig.args || []).map((arg: string) =>
arg.replace('${__dirname}', extractPath)
),
};
// Add environment variables if they exist
if (mcpConfig.env && Object.keys(mcpConfig.env).length > 0) {
config.env = { ...mcpConfig.env };
// Replace ${__dirname} in environment variables
Object.keys(config.env).forEach(key => {
if (typeof config.env[key] === 'string') {
config.env[key] = config.env[key].replace('${__dirname}', extractPath);
}
});
}
return config;
};
if (showServerForm && manifestData) {
return (
<>
<ConfirmDialog
isOpen={showConfirmDialog}
onClose={handleCancelOverride}
onConfirm={handleConfirmOverride}
title={t('dxt.serverExistsTitle')}
message={t('dxt.serverExistsConfirm', { serverName: pendingServerName })}
confirmText={t('dxt.override')}
cancelText={t('common.cancel')}
variant="warning"
/>
<div className={`fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 ${showConfirmDialog ? 'opacity-50 pointer-events-none' : ''}`}>
<div className="bg-white shadow rounded-lg p-6 w-full max-w-2xl max-h-screen overflow-y-auto">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-semibold text-gray-900">{t('dxt.installServer')}</h2>
<button onClick={onCancel} className="text-gray-500 hover:text-gray-700">
</button>
</div>
{error && (
<div className="mb-4 bg-red-50 border-l-4 border-red-500 p-4 rounded">
<p className="text-red-700">{error}</p>
</div>
)}
<div className="space-y-6">
{/* Extension Info */}
<div className="bg-gray-50 p-4 rounded-lg">
<h3 className="font-medium text-gray-900 mb-2">{t('dxt.extensionInfo')}</h3>
<div className="space-y-2 text-sm">
<div><strong>{t('dxt.name')}:</strong> {manifestData.display_name || manifestData.name}</div>
<div><strong>{t('dxt.version')}:</strong> {manifestData.version}</div>
<div><strong>{t('dxt.description')}:</strong> {manifestData.description}</div>
{manifestData.author && (
<div><strong>{t('dxt.author')}:</strong> {manifestData.author.name}</div>
)}
{manifestData.tools && manifestData.tools.length > 0 && (
<div>
<strong>{t('dxt.tools')}:</strong>
<ul className="list-disc list-inside ml-4">
{manifestData.tools.map((tool: any, index: number) => (
<li key={index}>{tool.name} - {tool.description}</li>
))}
</ul>
</div>
)}
</div>
</div>
{/* Server Configuration */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{t('dxt.serverName')}
</label>
<input
type="text"
id="serverName"
defaultValue={manifestData.name}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 form-input"
placeholder={t('dxt.serverNamePlaceholder')}
/>
</div>
{/* Action Buttons */}
<div className="flex justify-end space-x-4">
<button
onClick={onCancel}
disabled={isUploading}
className="px-4 py-2 text-gray-700 bg-gray-200 rounded hover:bg-gray-300 disabled:opacity-50 btn-secondary"
>
{t('common.cancel')}
</button>
<button
onClick={() => {
const nameInput = document.getElementById('serverName') as HTMLInputElement;
const serverName = nameInput?.value.trim() || manifestData.name;
handleInstallServer(serverName);
}}
disabled={isUploading}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 flex items-center btn-primary"
>
{isUploading ? (
<>
<svg className="animate-spin h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{t('dxt.installing')}
</>
) : (
t('dxt.install')
)}
</button>
</div>
</div>
</div>
</div>
</>
);
}
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white shadow rounded-lg p-6 w-full max-w-lg">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-semibold text-gray-900">{t('dxt.uploadTitle')}</h2>
<button onClick={onCancel} className="text-gray-500 hover:text-gray-700">
</button>
</div>
{error && (
<div className="mb-4 bg-red-50 border-l-4 border-red-500 p-4 rounded">
<p className="text-red-700">{error}</p>
</div>
)}
{/* File Drop Zone */}
<div
className={`relative border-2 border-dashed rounded-lg p-8 text-center transition-colors ${isDragging
? 'border-blue-500 bg-blue-50'
: selectedFile
? 'border-gray-500 '
: 'border-gray-300 hover:border-gray-400'
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{selectedFile ? (
<div className="space-y-2">
<svg className="mx-auto h-12 w-12 text-green-200" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-sm text-gray-900 font-medium">{selectedFile.name}</p>
<p className="text-xs text-gray-500">{(selectedFile.size / 1024 / 1024).toFixed(2)} MB</p>
</div>
) : (
<div className="space-y-2">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
<div>
<p className="text-sm text-gray-900">{t('dxt.dropFileHere')}</p>
<p className="text-xs text-gray-500">{t('dxt.orClickToSelect')}</p>
</div>
</div>
)}
<input
type="file"
accept=".dxt"
onChange={handleFileSelect}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
</div>
<div className="mt-6 flex justify-end space-x-4">
<button
onClick={onCancel}
disabled={isUploading}
className="px-4 py-2 text-gray-700 bg-gray-200 rounded hover:bg-gray-300 disabled:opacity-50 btn-secondary"
>
{t('common.cancel')}
</button>
<button
onClick={handleUpload}
disabled={!selectedFile || isUploading}
className="px-4 py-2 text-white rounded hover:bg-blue-700 disabled:opacity-50 flex items-center btn-primary"
>
{isUploading ? (
<>
<svg className="animate-spin h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{t('dxt.uploading')}
</>
) : (
t('dxt.upload')
)}
</button>
</div>
</div>
</div>
);
};
export default DxtUploadForm;

View File

@@ -38,18 +38,6 @@ const EditGroupForm = ({ group, onEdit, onCancel }: EditGroupFormProps) => {
}))
}
const handleServerToggle = (serverName: string) => {
setFormData(prev => {
const isSelected = prev.servers.includes(serverName)
return {
...prev,
servers: isSelected
? prev.servers.filter(name => name !== serverName)
: [...prev.servers, serverName]
}
})
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsSubmitting(true)
@@ -67,7 +55,7 @@ const EditGroupForm = ({ group, onEdit, onCancel }: EditGroupFormProps) => {
description: formData.description,
servers: formData.servers
})
if (!result) {
setError(t('groups.updateError'))
setIsSubmitting(false)
@@ -86,7 +74,7 @@ const EditGroupForm = ({ group, onEdit, onCancel }: EditGroupFormProps) => {
<div className="bg-white rounded-lg shadow-lg max-w-md w-full">
<div className="p-6">
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t('groups.edit')}</h2>
{error && (
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded">
{error}
@@ -104,7 +92,7 @@ const EditGroupForm = ({ group, onEdit, onCancel }: EditGroupFormProps) => {
name="name"
value={formData.name}
onChange={handleChange}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
placeholder={t('groups.namePlaceholder')}
required
/>
@@ -126,14 +114,14 @@ const EditGroupForm = ({ group, onEdit, onCancel }: EditGroupFormProps) => {
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-gray-600 hover:text-gray-800"
className="px-4 py-2 text-gray-600 hover:text-gray-800 btn-secondary"
disabled={isSubmitting}
>
{t('common.cancel')}
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50 btn-primary"
disabled={isSubmitting}
>
{isSubmitting ? t('common.submitting') : t('common.save')}

View File

@@ -68,7 +68,7 @@ const GroupCard = ({
const groupServers = servers.filter(server => group.servers.includes(server.name))
return (
<div className="bg-white shadow rounded-lg p-6">
<div className="bg-white shadow rounded-lg p-6 ">
<div className="flex justify-between items-center mb-4">
<div>
<div className="flex items-center">
@@ -89,7 +89,7 @@ const GroupCard = ({
)}
</div>
<div className="flex items-center space-x-3">
<div className="bg-blue-50 text-blue-700 px-3 py-1 rounded-full text-sm">
<div className="bg-blue-50 text-blue-700 px-3 py-1 rounded-full text-sm btn-secondary">
{t('groups.serverCount', { count: group.servers.length })}
</div>
<button
@@ -121,7 +121,7 @@ const GroupCard = ({
>
<span className="font-medium text-gray-700 text-sm">{server.name}</span>
<span className={`ml-2 inline-block h-2 w-2 rounded-full ${server.status === 'connected' ? 'bg-green-500' :
server.status === 'connecting' ? 'bg-yellow-500' : 'bg-red-500'
server.status === 'connecting' ? 'bg-yellow-500' : 'bg-red-500'
}`}></span>
</div>
))}

View File

@@ -48,25 +48,26 @@ const LogViewer: React.FC<LogViewerProps> = ({ logs, isLoading = false, error =
// Get badge color based on log type
const getLogTypeColor = (type: string) => {
switch (type) {
case 'error': return 'bg-red-400';
case 'warn': return 'bg-yellow-400';
case 'debug': return 'bg-purple-400';
default: return 'bg-blue-400';
case 'error': return 'bg-red-400/80 text-white';
case 'warn': return 'bg-yellow-400/80 text-gray-900';
case 'debug': return 'bg-purple-400/80 text-white';
case 'info': return 'bg-blue-400/80 text-white';
default: return 'bg-blue-400/80 text-white';
}
};
// Get badge color based on log source
const getSourceColor = (source: string) => {
switch (source) {
case 'main': return 'bg-green-400';
case 'child': return 'bg-orange-400';
default: return 'bg-gray-400';
case 'main': return 'bg-green-400/80 text-white';
case 'child': return 'bg-orange-400/80 text-white';
default: return 'bg-gray-400/80 text-white';
}
};
return (
<div className="flex flex-col h-full">
<div className="bg-card p-3 rounded-t-md border-b flex flex-wrap items-center justify-between gap-2">
<div className="bg-card p-3 rounded-t-md flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold text-sm">{t('logs.filters')}:</span>
@@ -74,14 +75,14 @@ const LogViewer: React.FC<LogViewerProps> = ({ logs, isLoading = false, error =
<input
type="text"
placeholder={t('logs.search')}
className="px-2 py-1 text-sm border rounded"
className="shadow appearance-none border border-gray-200 rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
{/* Log type filters */}
<div className="flex gap-1 items-center">
{(['info', 'error', 'warn', 'debug'] as const).map(type => (
{(['debug', 'info', 'error', 'warn'] as const).map(type => (
<Badge
key={type}
variant={typeFilter.includes(type) ? 'default' : 'outline'}
@@ -134,6 +135,7 @@ const LogViewer: React.FC<LogViewerProps> = ({ logs, isLoading = false, error =
variant="outline"
size="sm"
onClick={onClear}
className='btn-secondary'
disabled={isLoading || logs.length === 0}
>
{t('logs.clearLogs')}
@@ -164,7 +166,7 @@ const LogViewer: React.FC<LogViewerProps> = ({ logs, isLoading = false, error =
filteredLogs.map((log, index) => (
<div
key={`${log.timestamp}-${index}`}
className={`py-1 border-b border-gray-100 dark:border-gray-800 ${log.type === 'error' ? 'text-red-500' :
className={`py-1 ${log.type === 'error' ? 'text-red-500' :
log.type === 'warn' ? 'text-yellow-500' : ''
}`}
>

View File

@@ -15,31 +15,31 @@ const MarketServerCard: React.FC<MarketServerCardProps> = ({ server, onClick })
if (!server.tags || server.tags.length === 0) {
return { tagsToShow: [], hasMore: false, moreCount: 0 };
}
// Estimate available width in the card (in characters)
const estimatedAvailableWidth = 28; // Estimated number of characters that can fit in one line
// Calculate the character space needed for tags and plus sign (including # and spacing)
const calculateTagWidth = (tag: string) => tag.length + 3; // +3 for # and spacing
// Loop to determine the maximum number of tags that can be displayed
let totalWidth = 0;
let i = 0;
// First, sort tags by length to prioritize displaying shorter tags
const sortedTags = [...server.tags].sort((a, b) => a.length - b.length);
// Calculate how many tags can fit
for (i = 0; i < sortedTags.length; i++) {
const tagWidth = calculateTagWidth(sortedTags[i]);
// If this tag would make the total width exceed available width, stop adding
if (totalWidth + tagWidth > estimatedAvailableWidth) {
break;
}
totalWidth += tagWidth;
// If this is the last tag but there's still space, no need to show "more"
if (i === sortedTags.length - 1) {
return {
@@ -49,16 +49,16 @@ const MarketServerCard: React.FC<MarketServerCardProps> = ({ server, onClick })
};
}
}
// If there's not enough space to display any tags, show at least one
if (i === 0 && sortedTags.length > 0) {
i = 1;
}
// Calculate space needed for the "more" tag
const moreCount = sortedTags.length - i;
const moreTagWidth = 3 + String(moreCount).length + t('market.moreTags').length;
// If there's enough remaining space to display the "more" tag
if (totalWidth + moreTagWidth <= estimatedAvailableWidth || i < 1) {
return {
@@ -67,7 +67,7 @@ const MarketServerCard: React.FC<MarketServerCardProps> = ({ server, onClick })
moreCount
};
}
// If there's not enough space for even the "more" tag, reduce one tag to make room
return {
tagsToShow: sortedTags.slice(0, Math.max(1, i - 1)),
@@ -79,27 +79,27 @@ const MarketServerCard: React.FC<MarketServerCardProps> = ({ server, onClick })
const { tagsToShow, hasMore, moreCount } = getTagsToDisplay();
return (
<div
className="bg-white rounded-lg shadow-md p-5 hover:shadow-lg transition-shadow cursor-pointer flex flex-col h-full"
<div
className="bg-white rounded-lg shadow-md p-5 hover:shadow-lg transition-all duration-200 cursor-pointer flex flex-col h-full page-card"
onClick={() => onClick(server)}
>
<div className="flex justify-between items-start mb-3">
<h3 className="text-lg font-semibold text-gray-900 line-clamp-1 mr-2">{server.display_name}</h3>
{server.is_official && (
<span className="bg-blue-100 text-blue-800 text-xs font-medium px-2.5 py-0.5 rounded flex-shrink-0">
<span className="text-xs font-medium px-2.5 py-0.5 rounded flex-shrink-0 label-primary">
{t('market.official')}
</span>
)}
</div>
<p className="text-gray-600 text-sm mb-4 line-clamp-2 min-h-[40px]">{server.description}</p>
{/* Categories */}
<div className="flex flex-wrap gap-1 mb-2 min-h-[28px]">
{server.categories?.length > 0 ? (
server.categories.map((category, index) => (
<span
<span
key={index}
className="bg-gray-100 text-gray-800 text-xs px-2 py-1 rounded whitespace-nowrap"
className="bg-gray-100 text-gray-800 text-xs px-2 py-1.5 rounded whitespace-nowrap"
>
{category}
</span>
@@ -108,15 +108,15 @@ const MarketServerCard: React.FC<MarketServerCardProps> = ({ server, onClick })
<span className="text-xs text-gray-400 py-1">-</span>
)}
</div>
{/* Tags */}
<div className="relative mb-3 min-h-[28px] overflow-x-auto">
{server.tags?.length > 0 ? (
<div className="flex gap-1 items-center whitespace-nowrap">
{tagsToShow.map((tag, index) => (
<span
<span
key={index}
className="bg-green-50 text-green-700 text-xs px-2 py-1 rounded flex-shrink-0"
className="bg-green-50 text-green-700 text-xs px-2 py-1 rounded flex-shrink-0 label-secondary"
>
#{tag}
</span>
@@ -131,8 +131,8 @@ const MarketServerCard: React.FC<MarketServerCardProps> = ({ server, onClick })
<span className="text-xs text-gray-400 py-1">-</span>
)}
</div>
<div className="flex justify-between items-center mt-auto pt-2 text-xs text-gray-500 border-t border-gray-100">
<div className="flex justify-between items-center mt-auto pt-2 text-xs text-gray-500">
<div className="overflow-hidden">
<span className="whitespace-nowrap">{t('market.by')} </span>
<span className="font-medium whitespace-nowrap overflow-hidden text-ellipsis max-w-[120px] inline-block align-bottom">

View File

@@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { MarketServer, MarketServerInstallation } from '@/types';
import ServerForm from './ServerForm';
import { detectVariables } from '../utils/variableDetection';
import { ServerConfig } from '@/types';
@@ -23,6 +24,9 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
const { t } = useTranslation();
const [modalVisible, setModalVisible] = useState(false);
const [error, setError] = useState<string | null>(null);
const [confirmationVisible, setConfirmationVisible] = useState(false);
const [pendingPayload, setPendingPayload] = useState<any>(null);
const [detectedVariables, setDetectedVariables] = useState<string[]>([]);
// Helper function to determine button state
const getButtonProps = () => {
@@ -40,7 +44,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
};
} else {
return {
className: "bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded text-sm font-medium text-white",
className: "bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded text-sm font-medium text-white btn-primary",
disabled: false,
text: t('market.install')
};
@@ -50,6 +54,27 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
const toggleModal = () => {
setModalVisible(!modalVisible);
setError(null); // Clear any previous errors when toggling modal
setConfirmationVisible(false);
setPendingPayload(null);
};
const handleConfirmInstall = async () => {
if (pendingPayload) {
await proceedWithInstall(pendingPayload);
setConfirmationVisible(false);
setPendingPayload(null);
}
};
const proceedWithInstall = async (payload: any) => {
try {
setError(null);
onInstall(server, payload.config);
setModalVisible(false);
} catch (err) {
console.error('Error installing server:', err);
setError(t('errors.serverInstall'));
}
};
const handleInstall = () => {
@@ -72,24 +97,32 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
} else if (server.installations.default) {
return server.installations.default;
}
// If none of the preferred types are available, get the first available installation type
const installTypes = Object.keys(server.installations);
if (installTypes.length > 0) {
return server.installations[installTypes[0]];
}
return undefined;
};
const handleSubmit = async (payload: any) => {
try {
setError(null);
// Pass the server object and the payload (includes env changes) for installation
onInstall(server, payload.config);
setModalVisible(false);
// Check for variables in the payload
const variables = detectVariables(payload);
if (variables.length > 0) {
// Show confirmation dialog
setDetectedVariables(variables);
setPendingPayload(payload);
setConfirmationVisible(true);
} else {
// Install directly if no variables found
await proceedWithInstall(payload);
}
} catch (err) {
console.error('Error installing server:', err);
console.error('Error processing server installation:', err);
setError(t('errors.serverInstall'));
}
};
@@ -114,15 +147,15 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
<div className="flex justify-between items-start mb-4">
<div>
<h2 className="text-2xl font-bold text-gray-900 flex items-center flex-wrap">
{server.display_name}
{server.display_name}
<span className="text-sm font-normal text-gray-500 ml-2">({server.name})</span>
<span className="text-sm font-normal text-gray-600 ml-4">
{t('market.author')}: {server.author.name} {t('market.license')}: {server.license}
{t('market.author')}: {server.author.name} {t('market.license')}: {server.license}
<a
href={server.repository.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline ml-1"
className="text-blue-500 hover:underline ml-1"
>
{t('market.repository')}
</a>
@@ -132,7 +165,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
<div className="flex items-center">
{server.is_official && (
<span className="bg-blue-100 text-blue-800 text-sm font-medium px-4 py-2 rounded mr-2 flex items-center">
<span className="bg-blue-100 text-blue-800 text-sm font-normal px-4 py-2 rounded mr-2 flex items-center label-primary">
{t('market.official')}
</span>
)}
@@ -169,7 +202,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
<h3 className="text-lg font-semibold mb-3">{t('market.arguments')}</h3>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap">
{t('market.argumentName')}
@@ -198,7 +231,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
{arg.required ? (
<span className="text-green-600"></span>
) : (
<span className="text-red-600"></span>
<span className="text-gray-600"></span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
@@ -228,7 +261,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
element.classList.toggle('hidden');
}
}}
className="text-sm text-blue-600 hover:underline focus:outline-none ml-2"
className="text-sm text-blue-500 font-normal hover:underline focus:outline-none ml-2"
>
{t('market.viewSchema')}
</button>
@@ -281,17 +314,71 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
initialData={{
name: server.name,
status: 'disconnected',
config: preferredInstallation
config: preferredInstallation
? {
command: preferredInstallation.command || '',
args: preferredInstallation.args || [],
env: preferredInstallation.env || {}
}
command: preferredInstallation.command || '',
args: preferredInstallation.args || [],
env: preferredInstallation.env || {}
}
: undefined
}}
/>
</div>
)}
{confirmationVisible && (
<div className="fixed inset-0 bg-black/50 z-[60] flex items-center justify-center p-4">
<div className="bg-white rounded-lg p-6 w-full max-w-md">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
{t('server.confirmVariables')}
</h3>
<p className="text-gray-600 mb-4">
{t('server.variablesDetected')}
</p>
<div className="bg-yellow-50 border border-yellow-200 rounded p-3 mb-4">
<div className="flex items-start">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h4 className="text-sm font-medium text-yellow-800">
{t('server.detectedVariables')}:
</h4>
<ul className="mt-1 text-sm text-yellow-700">
{detectedVariables.map((variable, index) => (
<li key={index} className="font-mono">
${`{${variable}}`}
</li>
))}
</ul>
</div>
</div>
</div>
<p className="text-gray-600 text-sm mb-6">
{t('market.confirmVariablesMessage')}
</p>
<div className="flex justify-end space-x-3">
<button
onClick={() => {
setConfirmationVisible(false)
setPendingPayload(null)
}}
className="px-4 py-2 text-gray-600 border border-gray-300 rounded hover:bg-gray-50 btn-secondary"
>
{t('common.cancel')}
</button>
<button
onClick={handleConfirmInstall}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 btn-primary"
>
{t('market.confirmAndInstall')}
</button>
</div>
</div>
</div>
)}
</div>
);
};

View File

@@ -128,7 +128,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
return (
<>
<div className={`bg-white shadow rounded-lg p-6 mb-6 ${server.enabled === false ? 'opacity-60' : ''}`}>
<div className={`bg-white shadow rounded-lg p-6 mb-6 page-card transition-all duration-200 ${server.enabled === false ? 'opacity-60' : ''}`}>
<div
className="flex justify-between items-center cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
@@ -138,7 +138,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
<StatusBadge status={server.status} />
{/* Tool count display */}
<div className="flex items-center px-2 py-1 bg-blue-50 text-blue-700 rounded-full text-sm">
<div className="flex items-center px-2 py-1 bg-blue-50 text-blue-700 rounded-full text-sm btn-primary">
<svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clipRule="evenodd" />
</svg>
@@ -174,7 +174,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
<h4 className="text-sm font-medium text-red-600">{t('server.errorDetails')}</h4>
<button
onClick={copyToClipboard}
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
className="p-1 text-gray-400 hover:text-gray-600 transition-colors btn-secondary"
title={t('common.copy')}
>
{copied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
@@ -201,7 +201,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
<div className="flex space-x-2">
<button
onClick={handleEdit}
className="px-3 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm"
className="px-3 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary"
>
{t('server.edit')}
</button>
@@ -211,8 +211,8 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
className={`px-3 py-1 text-sm rounded transition-colors ${isToggling
? 'bg-gray-200 text-gray-500'
: server.enabled !== false
? 'bg-green-100 text-green-800 hover:bg-green-200'
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
? 'bg-green-100 text-green-800 hover:bg-green-200 btn-secondary'
: 'bg-gray-100 text-gray-800 hover:bg-gray-200 btn-primary'
}`}
disabled={isToggling}
>
@@ -226,11 +226,11 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
</div>
<button
onClick={handleRemove}
className="px-3 py-1 bg-red-100 text-red-800 rounded hover:bg-red-200 text-sm"
className="px-3 py-1 bg-red-100 text-red-800 rounded hover:bg-red-200 text-sm btn-danger"
>
{t('server.delete')}
</button>
<button className="text-gray-400 hover:text-gray-600">
<button className="text-gray-400 hover:text-gray-600 btn-secondary">
{isExpanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
</button>
</div>

View File

@@ -286,7 +286,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
id="name"
value={formData.name}
onChange={handleInputChange}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
placeholder="e.g.: time-mcp"
required
disabled={isEdit}
@@ -403,7 +403,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
...prev,
openapi: { ...prev.openapi!, url: e.target.value }
}))}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
placeholder="e.g.: https://api.example.com/openapi.json"
required={serverType === 'openapi' && formData.openapi?.inputMode === 'url'}
/>
@@ -462,7 +462,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
url: prev.openapi?.url || ''
}
}))}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
>
<option value="none">{t('server.openapi.securityNone')}</option>
<option value="apiKey">{t('server.openapi.securityApiKey')}</option>
@@ -474,7 +474,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
{/* API Key Configuration */}
{formData.openapi?.securityType === 'apiKey' && (
<div className="mb-4 p-4 border rounded bg-gray-50">
<div className="mb-4 p-4 border border-gray-200 rounded bg-gray-50">
<h4 className="text-sm font-medium text-gray-700 mb-3">{t('server.openapi.apiKeyConfig')}</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
@@ -486,7 +486,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
...prev,
openapi: { ...prev.openapi, apiKeyName: e.target.value, url: prev.openapi?.url || '' }
}))}
className="w-full border rounded px-2 py-1 text-sm"
className="w-full border rounded px-2 py-1 text-sm form-input focus:outline-none"
placeholder="Authorization"
/>
</div>
@@ -498,7 +498,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
...prev,
openapi: { ...prev.openapi, apiKeyIn: e.target.value as any, url: prev.openapi?.url || '' }
}))}
className="w-full border rounded px-2 py-1 text-sm"
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
>
<option value="header">Header</option>
<option value="query">Query</option>
@@ -514,7 +514,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
...prev,
openapi: { ...prev.openapi, apiKeyValue: e.target.value, url: prev.openapi?.url || '' }
}))}
className="w-full border rounded px-2 py-1 text-sm"
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
placeholder="your-api-key"
/>
</div>
@@ -524,7 +524,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
{/* HTTP Authentication Configuration */}
{formData.openapi?.securityType === 'http' && (
<div className="mb-4 p-4 border rounded bg-gray-50">
<div className="mb-4 p-4 border border-gray-200 rounded bg-gray-50">
<h4 className="text-sm font-medium text-gray-700 mb-3">{t('server.openapi.httpAuthConfig')}</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
@@ -535,7 +535,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
...prev,
openapi: { ...prev.openapi, httpScheme: e.target.value as any, url: prev.openapi?.url || '' }
}))}
className="w-full border rounded px-2 py-1 text-sm"
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
>
<option value="basic">Basic</option>
<option value="bearer">Bearer</option>
@@ -551,7 +551,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
...prev,
openapi: { ...prev.openapi, httpCredentials: e.target.value, url: prev.openapi?.url || '' }
}))}
className="w-full border rounded px-2 py-1 text-sm"
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
placeholder={formData.openapi?.httpScheme === 'basic' ? 'base64-encoded-credentials' : 'bearer-token'}
/>
</div>
@@ -561,7 +561,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
{/* OAuth2 Configuration */}
{formData.openapi?.securityType === 'oauth2' && (
<div className="mb-4 p-4 border rounded bg-gray-50">
<div className="mb-4 p-4 border border-gray-200 rounded bg-gray-50">
<h4 className="text-sm font-medium text-gray-700 mb-3">{t('server.openapi.oauth2Config')}</h4>
<div className="grid grid-cols-1 gap-3">
<div>
@@ -573,7 +573,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
...prev,
openapi: { ...prev.openapi, oauth2Token: e.target.value, url: prev.openapi?.url || '' }
}))}
className="w-full border rounded px-2 py-1 text-sm"
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
placeholder="access-token"
/>
</div>
@@ -583,7 +583,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
{/* OpenID Connect Configuration */}
{formData.openapi?.securityType === 'openIdConnect' && (
<div className="mb-4 p-4 border rounded bg-gray-50">
<div className="mb-4 p-4 border border-gray-200 rounded bg-gray-50">
<h4 className="text-sm font-medium text-gray-700 mb-3">{t('server.openapi.openIdConnectConfig')}</h4>
<div className="grid grid-cols-1 gap-3">
<div>
@@ -595,7 +595,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
...prev,
openapi: { ...prev.openapi, openIdConnectUrl: e.target.value, url: prev.openapi?.url || '' }
}))}
className="w-full border rounded px-2 py-1 text-sm"
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
placeholder="https://example.com/.well-known/openid_configuration"
/>
</div>
@@ -608,7 +608,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
...prev,
openapi: { ...prev.openapi, openIdConnectToken: e.target.value, url: prev.openapi?.url || '' }
}))}
className="w-full border rounded px-2 py-1 text-sm"
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
placeholder="id-token"
/>
</div>
@@ -624,7 +624,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
<button
type="button"
onClick={addHeaderVar}
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center"
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center btn-primary"
>
+ {t('server.add')}
</button>
@@ -636,7 +636,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
type="text"
value={headerVar.key}
onChange={(e) => handleHeaderVarChange(index, 'key', e.target.value)}
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2"
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2 form-input"
placeholder="Authorization"
/>
<span className="flex items-center">:</span>
@@ -644,14 +644,14 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
type="text"
value={headerVar.value}
onChange={(e) => handleHeaderVarChange(index, 'value', e.target.value)}
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2"
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2 form-input"
placeholder="Bearer token..."
/>
</div>
<button
type="button"
onClick={() => removeHeaderVar(index)}
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[56px] ml-2"
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[56px] ml-2 btn-danger"
>
- {t('server.remove')}
</button>
@@ -671,7 +671,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
id="url"
value={formData.url}
onChange={handleInputChange}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
placeholder={serverType === 'streamable-http' ? "e.g.: http://localhost:3000/mcp" : "e.g.: http://localhost:3000/sse"}
required={serverType === 'sse' || serverType === 'streamable-http'}
/>
@@ -685,7 +685,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
<button
type="button"
onClick={addHeaderVar}
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center"
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center btn-primary"
>
+ {t('server.add')}
</button>
@@ -697,7 +697,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
type="text"
value={headerVar.key}
onChange={(e) => handleHeaderVarChange(index, 'key', e.target.value)}
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2"
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2 form-input"
placeholder="Authorization"
/>
<span className="flex items-center">:</span>
@@ -705,14 +705,14 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
type="text"
value={headerVar.value}
onChange={(e) => handleHeaderVarChange(index, 'value', e.target.value)}
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2"
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2 form-input"
placeholder="Bearer token..."
/>
</div>
<button
type="button"
onClick={() => removeHeaderVar(index)}
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[56px] ml-2"
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[56px] ml-2 btn-danger"
>
- {t('server.remove')}
</button>
@@ -732,7 +732,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
id="command"
value={formData.command}
onChange={handleInputChange}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
placeholder="e.g.: npx"
required={serverType === 'stdio'}
/>
@@ -747,7 +747,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
id="arguments"
value={formData.arguments}
onChange={(e) => handleArgsChange(e.target.value)}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
placeholder="e.g.: -y time-mcp"
required={serverType === 'stdio'}
/>
@@ -761,7 +761,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
<button
type="button"
onClick={addEnvVar}
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center"
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center btn-primary"
>
+ {t('server.add')}
</button>
@@ -773,7 +773,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
type="text"
value={envVar.key}
onChange={(e) => handleEnvVarChange(index, 'key', e.target.value)}
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2"
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2 form-input"
placeholder={t('server.key')}
/>
<span className="flex items-center">:</span>
@@ -781,14 +781,14 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
type="text"
value={envVar.value}
onChange={(e) => handleEnvVarChange(index, 'value', e.target.value)}
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2"
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2 form-input"
placeholder={t('server.value')}
/>
</div>
<button
type="button"
onClick={() => removeEnvVar(index)}
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[56px] ml-2"
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[56px] ml-2 btn-danger"
>
- {t('server.remove')}
</button>
@@ -802,7 +802,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
{serverType !== 'openapi' && (
<div className="mb-4">
<div
className="flex items-center justify-between cursor-pointer bg-gray-50 hover:bg-gray-100 p-3 rounded border"
className="flex items-center justify-between cursor-pointer bg-gray-50 hover:bg-gray-100 p-3 rounded border border-gray-200"
onClick={() => setIsRequestOptionsExpanded(!isRequestOptionsExpanded)}
>
<label className="text-gray-700 text-sm font-bold">
@@ -814,7 +814,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
</div>
{isRequestOptionsExpanded && (
<div className="border rounded-b p-4 bg-gray-50 border-t-0">
<div className="border border-gray-200 rounded-b p-4 bg-gray-50 border-t-0">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-gray-600 text-sm font-medium mb-1" htmlFor="timeout">
@@ -825,7 +825,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
id="timeout"
value={formData.options?.timeout || 60000}
onChange={(e) => handleOptionsChange('timeout', parseInt(e.target.value) || 60000)}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
placeholder="30000"
min="1000"
max="300000"
@@ -842,7 +842,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
id="maxTotalTimeout"
value={formData.options?.maxTotalTimeout || ''}
onChange={(e) => handleOptionsChange('maxTotalTimeout', e.target.value ? parseInt(e.target.value) : undefined)}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
placeholder="Optional"
min="1000"
/>
@@ -873,13 +873,13 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
<button
type="button"
onClick={onCancel}
className="bg-gray-300 hover:bg-gray-400 text-gray-800 font-medium py-2 px-4 rounded mr-2"
className="bg-gray-300 hover:bg-gray-400 text-gray-800 font-medium py-2 px-4 rounded mr-2 btn-secondary"
>
{t('server.cancel')}
</button>
<button
type="submit"
className="bg-blue-500 hover:bg-blue-600 text-white font-medium py-2 px-4 rounded"
className="bg-blue-500 hover:bg-blue-600 text-white font-medium py-2 px-4 rounded btn-primary"
>
{isEdit ? t('server.save') : t('server.add')}
</button>

View File

@@ -1,6 +1,5 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '@/contexts/AuthContext';
import ThemeSwitch from '@/components/ui/ThemeSwitch';
import GitHubIcon from '@/components/icons/GitHubIcon';
import SponsorIcon from '@/components/icons/SponsorIcon';
@@ -15,13 +14,12 @@ interface HeaderProps {
const Header: React.FC<HeaderProps> = ({ onToggleSidebar }) => {
const { t, i18n } = useTranslation();
const { auth } = useAuth();
const [sponsorDialogOpen, setSponsorDialogOpen] = useState(false);
const [wechatDialogOpen, setWechatDialogOpen] = useState(false);
return (
<header className="bg-white dark:bg-gray-800 shadow-sm z-10">
<div className="flex justify-between items-center px-4 py-3">
<div className="flex justify-between items-center px-3 py-3">
<div className="flex items-center">
{/* 侧边栏切换按钮 */}
<button

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { NavLink, useLocation } from 'react-router-dom';
import { NavLink } from 'react-router-dom';
import UserProfileMenu from '@/components/ui/UserProfileMenu';
interface SidebarProps {
@@ -15,11 +15,10 @@ interface MenuItem {
const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
const { t } = useTranslation();
const location = useLocation();
// Application version from package.json (accessed via Vite environment variables)
const appVersion = import.meta.env.PACKAGE_VERSION as string;
// Menu item configuration
const menuItems: MenuItem[] = [
{
@@ -71,10 +70,9 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
];
return (
<aside
className={`bg-white dark:bg-gray-800 shadow-sm transition-all duration-300 ease-in-out flex flex-col h-full relative ${
collapsed ? 'w-16' : 'w-64'
}`}
<aside
className={`bg-white dark:bg-gray-800 shadow-sm transition-all duration-300 ease-in-out flex flex-col h-full relative ${collapsed ? 'w-16' : 'w-64'
}`}
>
{/* Scrollable navigation area */}
<div className="overflow-y-auto flex-grow">
@@ -83,12 +81,11 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) =>
`flex items-center px-3 py-2 rounded-md transition-colors ${
isActive
? 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`
className={({ isActive }) =>
`flex items-center px-2.5 py-2 rounded-lg transition-colors duration-200
${isActive
? 'bg-blue-50 text-blue-700'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-100'}`
}
end={item.path === '/'}
>
@@ -98,7 +95,7 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
))}
</nav>
</div>
{/* User profile menu fixed at the bottom */}
<div className="p-3 bg-white dark:bg-gray-800">
<UserProfileMenu collapsed={collapsed} version={appVersion} />

View File

@@ -93,7 +93,7 @@ const AboutDialog: React.FC<AboutDialogProps> = ({ isOpen, onClose, version }) =
<button
onClick={checkForUpdates}
disabled={isChecking}
className={`mt-4 inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium
className={`mt-4 inline-flex items-center px-4 py-2 border border-gray-200 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium btn-secondary
${isChecking
? 'text-gray-400 dark:text-gray-500 bg-gray-100 dark:bg-gray-800'
: 'text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600'

View File

@@ -1,6 +1,5 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { ServerStatus } from '@/types';
import { cn } from '../../utils/cn';
type BadgeVariant = 'default' | 'secondary' | 'outline' | 'destructive';
@@ -19,11 +18,11 @@ const badgeVariants = {
destructive: 'bg-red-500 text-white hover:bg-red-600',
};
export function Badge({
children,
variant = 'default',
className,
onClick
export function Badge({
children,
variant = 'default',
className,
onClick
}: BadgeProps) {
return (
<span
@@ -43,11 +42,11 @@ export function Badge({
// For backward compatibility with existing code
export const StatusBadge = ({ status }: { status: 'connected' | 'disconnected' | 'connecting' }) => {
const { t } = useTranslation();
const colors = {
connecting: 'bg-yellow-100 text-yellow-800',
connected: 'bg-green-100 text-green-800',
disconnected: 'bg-red-100 text-red-800',
connecting: 'status-badge-connecting',
connected: 'status-badge-online',
disconnected: 'status-badge-offline',
};
// Map status to translation keys

View File

@@ -0,0 +1,142 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
interface ConfirmDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title?: string;
message: string;
confirmText?: string;
cancelText?: string;
variant?: 'danger' | 'warning' | 'info';
}
const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText,
cancelText,
variant = 'warning'
}) => {
const { t } = useTranslation();
if (!isOpen) return null;
const getVariantStyles = () => {
switch (variant) {
case 'danger':
return {
icon: (
<svg className="w-6 h-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
),
confirmClass: 'bg-red-600 hover:bg-red-700 text-white',
};
case 'warning':
return {
icon: (
<svg className="w-6 h-6 text-yellow-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
),
confirmClass: 'bg-yellow-600 hover:bg-yellow-700 text-white',
};
case 'info':
return {
icon: (
<svg className="w-6 h-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
confirmClass: 'bg-blue-600 hover:bg-blue-700 text-white',
};
default:
return {
icon: null,
confirmClass: 'bg-blue-600 hover:bg-blue-700 text-white',
};
}
};
const { icon, confirmClass } = getVariantStyles();
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
} else if (e.key === 'Enter') {
onConfirm();
}
};
return (
<div
className="fixed inset-0 bg-black/50 z-[100] flex items-center justify-center p-4"
onClick={handleBackdropClick}
onKeyDown={handleKeyDown}
tabIndex={-1}
>
<div
className="bg-white rounded-lg shadow-xl max-w-md w-full transform transition-all duration-200 ease-out"
role="dialog"
aria-modal="true"
aria-labelledby="confirm-dialog-title"
aria-describedby="confirm-dialog-message"
>
<div className="p-6">
<div className="flex items-start space-x-3">
{icon && (
<div className="flex-shrink-0">
{icon}
</div>
)}
<div className="flex-1">
{title && (
<h3
id="confirm-dialog-title"
className="text-lg font-medium text-gray-900 mb-2"
>
{title}
</h3>
)}
<p
id="confirm-dialog-message"
className="text-gray-600 leading-relaxed"
>
{message}
</p>
</div>
</div>
<div className="flex justify-end space-x-3 mt-6">
<button
onClick={onClose}
className="px-4 py-2 text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-md transition-colors duration-150 btn-secondary"
autoFocus
>
{cancelText || t('common.cancel')}
</button>
<button
onClick={onConfirm}
className={`px-4 py-2 rounded-md transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-offset-2 ${confirmClass} ${variant === 'danger' ? 'btn-danger' : variant === 'warning' ? 'btn-warning' : 'btn-primary'}`}
>
{confirmText || t('common.confirm')}
</button>
</div>
</div>
</div>
</div>
);
};
export default ConfirmDialog;

View File

@@ -28,13 +28,13 @@ const DeleteDialog = ({ isOpen, onClose, onConfirm, serverName, isGroup = false
<div className="flex justify-end space-x-3">
<button
onClick={onClose}
className="px-4 py-2 text-gray-600 hover:text-gray-800"
className="px-4 py-2 text-gray-600 hover:text-gray-800 btn-secondary"
>
{t('common.cancel')}
</button>
<button
onClick={onConfirm}
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600 btn-danger"
>
{t('common.delete')}
</button>

View File

@@ -18,9 +18,10 @@ interface DynamicFormProps {
onCancel: () => void;
loading?: boolean;
storageKey?: string; // Optional key for localStorage persistence
title?: string; // Optional title to display instead of default parameters title
}
const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, loading = false, storageKey }) => {
const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, loading = false, storageKey, title }) => {
const { t } = useTranslation();
const [formValues, setFormValues] = useState<Record<string, any>>({});
const [errors, setErrors] = useState<Record<string, string>>({});
@@ -284,7 +285,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
type="text"
value={value || ''}
onChange={(e) => onChange(e.target.value)}
className="w-full border rounded-md px-2 py-1 text-sm border-gray-300 focus:outline-none focus:ring-1 focus:ring-blue-500"
className="w-full border rounded-md px-2 py-1 text-sm border-gray-300 focus:outline-none focus:ring-1 focus:ring-blue-500 form-input"
placeholder={schema.description || t('tool.enterKey', { key })}
/>
);
@@ -301,7 +302,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
const val = e.target.value === '' ? '' : schema.type === 'integer' ? parseInt(e.target.value) : parseFloat(e.target.value);
onChange(val);
}}
className="w-full border rounded-md px-2 py-1 text-sm border-gray-300 focus:outline-none focus:ring-1 focus:ring-blue-500"
className="w-full border rounded-md px-2 py-1 text-sm border-gray-300 focus:outline-none focus:ring-1 focus:ring-blue-500 form-input"
/>
);
}
@@ -323,7 +324,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
type="text"
value={value || ''}
onChange={(e) => onChange(e.target.value)}
className="w-full border rounded-md px-2 py-1 text-sm border-gray-300 focus:outline-none focus:ring-1 focus:ring-blue-500"
className="w-full border rounded-md px-2 py-1 text-sm border-gray-300 focus:outline-none focus:ring-1 focus:ring-blue-500 form-input"
placeholder={schema.description || t('tool.enterKey', { key })}
/>
);
@@ -340,7 +341,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<div key={fullPath} className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{(path ? getNestedValue(jsonSchema, path)?.required?.includes(key) : jsonSchema.required?.includes(key)) && <span className="text-red-500 ml-1">*</span>}
{(path ? getNestedValue(jsonSchema, path)?.required?.includes(key) : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
</label>
{propSchema.description && (
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
@@ -358,7 +359,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
newArray.splice(index, 1);
handleInputChange(fullPath, newArray);
}}
className="text-red-500 hover:text-red-700 text-sm"
className="text-status-red hover:text-red-700 text-sm"
>
{t('common.remove')}
</button>
@@ -387,7 +388,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<div key={objKey}>
<label className="block text-xs font-medium text-gray-600 mb-1">
{objKey}
{propSchema.items?.required?.includes(objKey) && <span className="text-red-500 ml-1">*</span>}
{propSchema.items?.required?.includes(objKey) && <span className="text-status-red ml-1">*</span>}
</label>
{renderObjectField(objKey, objSchema as JsonSchema, item, (newValue) => {
const newArray = [...arrayValue];
@@ -406,7 +407,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
newArray[index] = e.target.value;
handleInputChange(fullPath, newArray);
}}
className="w-full border rounded-md px-3 py-2 border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full border rounded-md px-3 py-2 border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 form-input"
placeholder={t('tool.enterValue', { type: propSchema.items?.type || 'value' })}
/>
)}
@@ -425,7 +426,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
</button>
</div>
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
</div>
);
} // Handle object type
@@ -436,7 +437,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<div key={fullPath} className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{(path ? getNestedValue(jsonSchema, path)?.required?.includes(key) : jsonSchema.required?.includes(key)) && <span className="text-red-500 ml-1">*</span>}
{(path ? getNestedValue(jsonSchema, path)?.required?.includes(key) : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
</label>
{propSchema.description && (
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
@@ -448,7 +449,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
))}
</div>
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
</div>
);
} else {
@@ -457,7 +458,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<div key={fullPath} className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{(path ? getNestedValue(jsonSchema, path)?.required?.includes(key) : jsonSchema.required?.includes(key)) && <span className="text-red-500 ml-1">*</span>}
{(path ? getNestedValue(jsonSchema, path)?.required?.includes(key) : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
<span className="text-xs text-gray-500 ml-1">(JSON object)</span>
</label>
{propSchema.description && (
@@ -478,7 +479,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
className={`w-full border rounded-md px-3 py-2 font-mono text-sm ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500`}
rows={4}
/>
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
</div>
);
}
@@ -488,7 +489,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<div key={fullPath} className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-red-500 ml-1">*</span>}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
</label>
{propSchema.description && (
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
@@ -505,7 +506,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
</option>
))}
</select>
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
</div>
);
} else {
@@ -513,7 +514,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<div key={fullPath} className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-red-500 ml-1">*</span>}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
</label>
{propSchema.description && (
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
@@ -522,9 +523,9 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
type="text"
value={value || ''}
onChange={(e) => handleInputChange(fullPath, e.target.value)}
className={`w-full border rounded-md px-3 py-2 ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500`}
className={`w-full border rounded-md px-3 py-2 ${error ? 'border-red' : 'border-gray-200'} focus:outline-none form-input`}
/>
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
</div>
);
}
@@ -533,7 +534,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<div key={fullPath} className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-red-500 ml-1">*</span>}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
</label>
{propSchema.description && (
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
@@ -546,9 +547,9 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
const val = e.target.value === '' ? '' : propSchema.type === 'integer' ? parseInt(e.target.value) : parseFloat(e.target.value);
handleInputChange(fullPath, val);
}}
className={`w-full border rounded-md px-3 py-2 ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500`}
className={`w-full border rounded-md px-3 py-2 form-input ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500`}
/>
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
</div>
);
}
@@ -565,13 +566,13 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
/>
<label className="ml-2 block text-sm text-gray-700">
{key}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-red-500 ml-1">*</span>}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
</label>
</div>
{propSchema.description && (
<p className="text-xs text-gray-500 mt-1">{propSchema.description}</p>
)}
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
</div>
);
} // For other types, show as text input with description
@@ -579,7 +580,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<div key={fullPath} className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-red-500 ml-1">*</span>}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
<span className="text-xs text-gray-500 ml-1">({propSchema.type})</span>
</label>
{propSchema.description && (
@@ -590,9 +591,9 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
value={value || ''}
onChange={(e) => handleInputChange(fullPath, e.target.value)}
placeholder={t('tool.enterValue', { type: propSchema.type })}
className={`w-full border rounded-md px-3 py-2 ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500`}
className={`w-full border rounded-md px-3 py-2 ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500 form-input`}
/>
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
</div>
);
};
@@ -624,15 +625,15 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
return (
<div className="space-y-4">
{/* Mode Toggle */}
<div className="flex justify-between items-center border-b pb-3">
<h3 className="text-lg font-medium text-gray-900">{t('tool.parameters')}</h3>
<div className="flex justify-between items-center pb-3">
<h6 className="text-md font-medium text-gray-900">{title}</h6>
<div className="flex space-x-2">
<button
type="button"
onClick={switchToFormMode}
className={`px-3 py-1 text-sm rounded-md transition-colors ${!isJsonMode
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
? 'bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary'
: 'text-sm text-gray-600 bg-gray-200 rounded hover:bg-gray-300 btn-secondary'
}`}
>
{t('tool.formMode')}
@@ -641,8 +642,8 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
type="button"
onClick={switchToJsonMode}
className={`px-3 py-1 text-sm rounded-md transition-colors ${isJsonMode
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
? 'px-4 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary'
: 'text-sm text-gray-600 bg-gray-200 rounded hover:bg-gray-300 btn-secondary'
}`}
>
{t('tool.jsonMode')}
@@ -661,17 +662,17 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
value={jsonText}
onChange={(e) => handleJsonTextChange(e.target.value)}
placeholder={`{\n "key": "value"\n}`}
className={`w-full h-64 border rounded-md px-3 py-2 font-mono text-sm resize-y ${jsonError ? 'border-red-500' : 'border-gray-300'
className={`w-full h-64 border rounded-md px-3 py-2 font-mono text-sm resize-y form-input ${jsonError ? 'border-red-500' : 'border-gray-300'
} focus:outline-none focus:ring-2 focus:ring-blue-500`}
/>
{jsonError && <p className="text-red-500 text-xs mt-1">{jsonError}</p>}
{jsonError && <p className="text-status-red text-xs mt-1">{jsonError}</p>}
</div>
<div className="flex justify-end space-x-2 pt-4">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-sm text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200"
className="px-4 py-1 text-sm text-gray-600 bg-gray-200 rounded hover:bg-gray-300 btn-secondary"
>
{t('tool.cancel')}
</button>
@@ -685,7 +686,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
}
}}
disabled={loading || !!jsonError}
className="px-4 py-2 text-sm text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:opacity-50"
className="px-4 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary"
>
{loading ? t('tool.running') : t('tool.runTool')}
</button>
@@ -702,14 +703,14 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-sm text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200"
className="px-4 py-1 text-sm text-gray-600 bg-gray-200 rounded hover:bg-gray-300 btn-secondary"
>
{t('tool.cancel')}
</button>
<button
type="submit"
disabled={loading}
className="px-4 py-2 text-sm text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:opacity-50"
className="px-4 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary"
>
{loading ? t('tool.running') : t('tool.runTool')}
</button>

View File

@@ -6,34 +6,33 @@ interface PaginationProps {
onPageChange: (page: number) => void;
}
const Pagination: React.FC<PaginationProps> = ({
currentPage,
totalPages,
onPageChange
const Pagination: React.FC<PaginationProps> = ({
currentPage,
totalPages,
onPageChange
}) => {
// Generate page buttons
const getPageButtons = () => {
const buttons = [];
const maxDisplayedPages = 5; // Maximum number of page buttons to display
// Always display first page
buttons.push(
<button
key="first"
onClick={() => onPageChange(1)}
className={`px-3 py-1 mx-1 rounded ${
currentPage === 1
? 'bg-blue-500 text-white'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
}`}
className={`px-3 py-1 mx-1 rounded ${currentPage === 1
? 'bg-blue-500 text-white btn-primary'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
}`}
>
1
</button>
);
// Start range
let startPage = Math.max(2, currentPage - Math.floor(maxDisplayedPages / 2));
const startPage = Math.max(2, currentPage - Math.floor(maxDisplayedPages / 2));
// If we're showing ellipsis after first page
if (startPage > 2) {
buttons.push(
@@ -42,24 +41,23 @@ const Pagination: React.FC<PaginationProps> = ({
</span>
);
}
// Middle pages
for (let i = startPage; i <= Math.min(totalPages - 1, startPage + maxDisplayedPages - 3); i++) {
buttons.push(
<button
key={i}
onClick={() => onPageChange(i)}
className={`px-3 py-1 mx-1 rounded ${
currentPage === i
? 'bg-blue-500 text-white'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
}`}
className={`px-3 py-1 mx-1 rounded ${currentPage === i
? 'bg-blue-500 text-white btn-primary'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
}`}
>
{i}
</button>
);
}
// If we're showing ellipsis before last page
if (startPage + maxDisplayedPages - 3 < totalPages - 1) {
buttons.push(
@@ -68,24 +66,23 @@ const Pagination: React.FC<PaginationProps> = ({
</span>
);
}
// Always display last page if there's more than one page
if (totalPages > 1) {
buttons.push(
<button
key="last"
onClick={() => onPageChange(totalPages)}
className={`px-3 py-1 mx-1 rounded ${
currentPage === totalPages
? 'bg-blue-500 text-white'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
}`}
className={`px-3 py-1 mx-1 rounded ${currentPage === totalPages
? 'bg-blue-500 text-white btn-primary'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
}`}
>
{totalPages}
</button>
);
}
return buttons;
};
@@ -99,25 +96,23 @@ const Pagination: React.FC<PaginationProps> = ({
<button
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
className={`px-3 py-1 rounded mr-2 ${
currentPage === 1
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
}`}
className={`px-3 py-1 rounded mr-2 ${currentPage === 1
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
}`}
>
&laquo; Prev
</button>
<div className="flex">{getPageButtons()}</div>
<button
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
className={`px-3 py-1 rounded ml-2 ${
currentPage === totalPages
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
}`}
className={`px-3 py-1 rounded ml-2 ${currentPage === totalPages
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
}`}
>
Next &raquo;
</button>

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useTheme } from '@/contexts/ThemeContext';
import { Sun, Moon, Monitor } from 'lucide-react';
import { Sun, Moon } from 'lucide-react';
const ThemeSwitch: React.FC = () => {
const { t } = useTranslation();
@@ -9,7 +9,7 @@ const ThemeSwitch: React.FC = () => {
return (
<div className="flex items-center space-x-2">
<div className="flex bg-gray-200 dark:bg-gray-700 rounded-lg p-1">
<div className="flex bg-gray-100 dark:bg-gray-700 rounded-lg p-1">
<button
onClick={() => setTheme('light')}
className={`flex items-center justify-center rounded-md p-1.5 ${theme === 'light'

View File

@@ -9,7 +9,6 @@ interface ToggleGroupItemProps {
}
export const ToggleGroupItem: React.FC<ToggleGroupItemProps> = ({
value,
isSelected,
onClick,
children
@@ -21,8 +20,8 @@ export const ToggleGroupItem: React.FC<ToggleGroupItemProps> = ({
aria-checked={isSelected}
className={cn(
"flex w-full items-center justify-between p-2 rounded transition-colors cursor-pointer",
isSelected
? "bg-blue-50 text-blue-700 hover:bg-blue-100 border-l-4 border-blue-500"
isSelected
? "bg-blue-50 text-blue-700 hover:bg-blue-100 border-l-4 border-blue-500"
: "hover:bg-gray-50 text-gray-700"
)}
onClick={onClick}
@@ -72,7 +71,7 @@ export const ToggleGroup: React.FC<ToggleGroupProps> = ({
<label className="block text-gray-700 text-sm font-bold mb-2">
{label}
</label>
<div className="border rounded shadow max-h-60 overflow-y-auto">
<div className="border border-gray-200 rounded shadow max-h-60 overflow-y-auto">
{options.length === 0 ? (
<p className="text-gray-500 text-sm p-3">{noOptionsText}</p>
) : (
@@ -118,7 +117,7 @@ export const Switch: React.FC<SwitchProps> = ({
disabled={disabled}
className={cn(
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500",
checked ? "bg-blue-600" : "bg-gray-200",
checked ? "bg-blue-200" : "bg-gray-100",
disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"
)}
onClick={() => !disabled && onCheckedChange(!checked)}

View File

@@ -130,7 +130,7 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
}
return (
<div className="bg-white border border-gray-300 shadow rounded-lg p-4 mb-4">
<div className="bg-white border border-gray-200 shadow rounded-lg p-4 mb-4">
<div
className="flex justify-between items-center cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
@@ -144,7 +144,7 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
<input
ref={descriptionInputRef}
type="text"
className="px-2 py-1 border border-blue-300 rounded bg-white text-sm"
className="px-2 py-1 border border-blue-300 rounded bg-white text-sm focus:outline-none form-input"
value={customDescription}
onChange={handleDescriptionChange}
onKeyDown={handleDescriptionKeyDown}
@@ -155,7 +155,7 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
}}
/>
<button
className="ml-2 p-1 text-green-600 hover:text-green-800"
className="ml-2 p-1 text-green-600 hover:text-green-800 cursor-pointer transition-colors"
onClick={(e) => {
e.stopPropagation()
handleDescriptionSave()
@@ -168,7 +168,7 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
<>
<span ref={descriptionTextRef}>{customDescription || t('tool.noDescription')}</span>
<button
className="ml-2 p-1 text-gray-500 hover:text-blue-600 transition-colors"
className="ml-2 p-1 text-gray-500 hover:text-blue-600 cursor-pointer transition-colors"
onClick={(e) => {
e.stopPropagation()
handleDescriptionEdit()
@@ -198,7 +198,7 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
setIsExpanded(true) // Ensure card is expanded when showing run form
setShowRunForm(true)
}}
className="flex items-center space-x-1 px-3 py-1 text-sm text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-md transition-colors"
className="flex items-center space-x-1 px-3 py-1 text-sm text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-md transition-colors btn-primary"
disabled={isRunning || !tool.enabled}
>
{isRunning ? (
@@ -228,14 +228,14 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
{/* Run Form */}
{showRunForm && (
<div className="border border-gray-300 rounded-lg p-4 bg-blue-50">
<h4 className="text-sm font-medium text-gray-900 mb-3">{t('tool.runToolWithName', { name: tool.name.replace(server + '-', '') })}</h4>
<div className="border border-gray-300 rounded-lg p-4">
<DynamicForm
schema={tool.inputSchema || { type: 'object' }}
onSubmit={handleRunTool}
onCancel={handleCancelRun}
loading={isRunning}
storageKey={getStorageKey()}
title={t('tool.runToolWithName', { name: tool.name.replace(server + '-', '') })}
/>
{/* Tool Result */}
{result && (

View File

@@ -65,7 +65,6 @@ const ToolResult: React.FC<ToolResultProps> = ({ result, onClose }) => {
// For other structured content, try to parse as JSON
try {
const jsonString = typeof item === 'string' ? item : JSON.stringify(item, null, 2);
const parsed = typeof item === 'string' ? JSON.parse(item) : item;
return (
@@ -97,9 +96,9 @@ const ToolResult: React.FC<ToolResultProps> = ({ result, onClose }) => {
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
{result.success ? (
<CheckCircle size={20} className="text-green-500" />
<CheckCircle size={20} className="text-status-green" />
) : (
<XCircle size={20} className="text-red-500" />
<XCircle size={20} className="text-status-red" />
)}
<div>
<h4 className="text-sm font-medium text-gray-900">

View File

@@ -73,7 +73,7 @@ const UserProfileMenu: React.FC<UserProfileMenuProps> = ({ collapsed, version })
}`}
>
<div className="flex-shrink-0 relative">
<div className="w-7 h-7 flex items-center justify-center rounded-full border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700">
<div className="w-5 h-5 flex items-center justify-center rounded-full border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700">
<User className="h-4 w-4 text-gray-700 dark:text-gray-300" />
</div>
{showNewVersionInfo && (
@@ -90,7 +90,7 @@ const UserProfileMenu: React.FC<UserProfileMenuProps> = ({ collapsed, version })
</button>
{isOpen && (
<div className="absolute top-0 transform -translate-y-full left-0 w-48 bg-white dark:bg-gray-800 shadow-lg rounded-md py-1 z-50">
<div className="absolute top-0 transform -translate-y-full left-0 w-full min-w-max bg-white border border-gray-200 dark:bg-gray-800 py-1 z-50">
<button
onClick={handleSettingsClick}
className="flex items-center w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"

View File

@@ -1,11 +1,22 @@
/* Use project's custom Tailwind import */
@import "tailwindcss";
@import 'tailwindcss';
/* Add some custom styles to verify CSS is working correctly */
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
font-family:
'Inter',
'PingFang SC',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
'Roboto',
'Oxygen',
'Ubuntu',
'Cantarell',
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@@ -13,7 +24,7 @@ body {
/* Dark mode override styles - these will apply when dark class is on html element */
.dark body {
background-color: #111827;
background-color: #1f2a37;
color: #e5e7eb;
}
@@ -37,30 +48,432 @@ body {
color: #d1d5db !important;
}
.dark .text-gray-500 {
/* .dark .text-gray-500 {
color: #9ca3af !important;
}
} */
.dark .border-gray-300 {
border-color: #4b5563 !important;
border-color: #2f3b4c !important;
}
.dark .border-gray-200 {
border-color: #2f3b4c !important;
}
.dark .divide-gray-200 > :not([hidden]) ~ :not([hidden]) {
border-color: #2f3b4c !important;
}
.dark .bg-gray-100 {
background-color: #374151 !important;
}
/* Specific hover effects for dark mode */
.dark .hover\:bg-gray-100:hover {
background-color: rgba(110, 127, 156, 0.15) !important;
}
.dark .hover\:text-gray-900:hover {
color: rgb(190, 188, 185) !important;
}
.dark .bg-gray-50 {
background-color: #1f2937 !important;
}
.dark .text-blue-700 {
color: white !important;
}
.dark .bg-blue-50 {
background-color: #4b5563 !important;
}
.dark .bg-blue-200 {
background-color: #576476 !important;
}
.dark .shadow {
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.4), 0 1px 2px 0 rgba(0, 0, 0, 0.24) !important;
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.15),
0 2px 6px rgba(0, 0, 0, 0.1) !important;
}
.bg-custom-blue {
background-color: #4a90e2;
background-color: #4a90e2;
}
.text-custom-white {
color: #ffffff;
}
}
.status-badge-online {
background-color: white !important;
color: rgba(129, 199, 132, 0.9) !important;
border: 1px solid #a6d7b7;
}
/* Enhanced status badge styles for dark theme */
.dark .status-badge-online {
background-color: rgba(76, 175, 80, 0.15) !important;
color: rgba(129, 199, 132, 0.9) !important;
border: 1px solid rgba(76, 175, 80, 0.3);
}
.status-badge-offline {
background-color: white !important;
color: rgba(107, 114, 128, 0.9) !important;
border: 1px solid #d1d5db;
}
.dark .status-badge-offline {
background-color: rgba(107, 114, 128, 0.15) !important;
color: rgba(156, 163, 175, 0.9) !important;
border: 1px solid rgba(107, 114, 128, 0.3);
}
.status-badge-connecting {
background-color: white !important;
color: rgba(255, 213, 79, 0.9) !important;
border: 1px solid #ffd57f;
}
.dark .status-badge-connecting {
background-color: rgba(255, 193, 7, 0.15) !important;
color: rgba(255, 213, 79, 0.9) !important;
border: 1px solid rgba(255, 193, 7, 0.3);
}
/* Enhanced status icons for dark theme */
.dark .status-icon-blue {
background-color: rgba(59, 130, 246, 0.15) !important;
color: rgba(96, 165, 250, 0.9) !important;
}
.dark .status-icon-green {
background-color: rgba(76, 175, 80, 0.15) !important;
color: rgba(129, 199, 132, 0.9) !important;
}
.dark .status-icon-red {
background-color: rgba(244, 67, 54, 0.15) !important;
color: rgba(239, 154, 154, 0.9) !important;
}
.dark .status-icon-yellow {
background-color: rgba(255, 193, 7, 0.15) !important;
color: rgba(255, 213, 79, 0.9) !important;
}
/* Enhanced card hover effects */
.dashboard-card {
transition: all 0.3s ease;
border-radius: 12px;
}
.dashboard-card:hover {
transform: translateY(-2px);
box-shadow:
0 8px 25px rgba(0, 0, 0, 0.2),
0 4px 12px rgba(0, 0, 0, 0.15) !important;
}
/* Icon container hover effects */
.icon-container {
transition: all 0.3s ease;
}
.icon-container:hover {
transform: scale(1.05);
filter: brightness(1.1);
}
/* Progress bar enhancements */
.progress-bar-online {
background: linear-gradient(90deg, rgba(76, 175, 80, 0.8), rgba(129, 199, 132, 0.6));
}
.progress-bar-offline {
background: linear-gradient(90deg, rgba(244, 67, 54, 0.8), rgba(239, 154, 154, 0.6));
}
.progress-bar-connecting {
background: linear-gradient(90deg, rgba(255, 193, 7, 0.8), rgba(255, 213, 79, 0.6));
}
/* Table enhancements for dark theme */
.dark .table-container {
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.dark thead {
background-color: #252d3a !important;
}
.dark tbody tr {
border-bottom: 1px solid #2f3b4c;
}
tbody tr:hover {
background-color: var(--color-gray-100) !important;
transition: background-color 0.2s ease;
}
.dark tbody tr:hover {
background-color: rgba(55, 65, 81, 0.5) !important;
transition: background-color 0.2s ease;
}
/* Error box enhancements for dark theme */
.dark .error-box {
background-color: rgba(244, 67, 54, 0.1) !important;
border-color: rgba(244, 67, 54, 0.3) !important;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(244, 67, 54, 0.1);
}
.dark .error-box h3 {
color: rgba(239, 154, 154, 0.9) !important;
}
.dark .error-box p {
color: #d1d5db !important;
}
/* Loading container enhancements */
.loading-container {
border-radius: 12px;
backdrop-filter: blur(10px);
}
.dark .loading-container {
background-color: rgba(31, 41, 55, 0.8) !important;
border: 1px solid #2f3b4c;
}
.label-primary {
background-color: var(--color-blue-50) !important;
color: var(--color-blue-500) !important;
}
.dark .label-primary {
background-color: rgba(59, 130, 246, 0.15) !important;
color: rgba(96, 165, 250, 0.9) !important;
}
.label-secondary {
background-color: var(--color-green-50) !important;
color: var(--color-green-500) !important;
}
.dark .label-secondary {
background-color: rgba(76, 175, 80, 0.15) !important;
color: rgba(129, 199, 132, 0.9) !important;
}
.btn-primary {
background-color: var(--color-blue-100) !important;
color: var(--color-blue-800) !important;
border: none;
border-radius: 8px;
transition: all 0.3s ease;
}
.btn-primary:hover {
background-color: var(--color-blue-200) !important;
color: var(--color-blue-800) !important;
}
/* Enhanced button styles for dark theme */
.dark .btn-primary {
background-color: rgba(59, 130, 246, 0.15) !important;
color: rgba(96, 165, 250, 0.9) !important;
border: 1px solid rgba(59, 130, 246, 0.3);
transition: all 0.3s ease;
}
.dark .btn-primary:hover {
background-color: rgba(59, 130, 246, 0.25) !important;
color: rgba(96, 165, 250, 1) !important;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2);
}
.btn-secondary {
background-color: #f9fafb !important;
color: #374151 !important;
border: 1px solid #d1d5db !important;
border-radius: 8px;
font-size: 0.875rem;
}
.btn-secondary:hover {
background-color: #e5e7eb !important;
color: #374151 !important;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.dark .btn-secondary {
background-color: rgba(107, 114, 128, 0.15) !important;
color: rgba(156, 163, 175, 0.9) !important;
border: 1px solid rgba(107, 114, 128, 0.3) !important;
transition: all 0.3s ease;
}
.dark .btn-secondary:hover {
background-color: rgba(107, 114, 128, 0.25) !important;
color: rgba(156, 163, 175, 1) !important;
transform: translateY(-1px);
}
.btn-warning {
background-color: var(--color-yellow-100) !important;
color: var(--color-yellow-800) !important;
border: none;
border-radius: 8px;
transition: all 0.3s ease;
}
.btn-warning:hover {
background-color: var(--color-yellow-200) !important;
color: var(--color-yellow-800) !important;
}
.dark .btn-warning {
background-color: rgba(234, 179, 8, 0.15) !important;
color: rgba(250, 204, 21, 0.9) !important;
border: 1px solid rgba(234, 179, 8, 0.3);
transition: all 0.3s ease;
}
.dark .btn-warning:hover {
background-color: rgba(234, 179, 8, 0.25) !important;
color: rgba(250, 204, 21, 1) !important;
transform: translateY(-1px);
}
.btn-danger {
background-color: var(--color-red-100) !important;
color: var(--color-red-800) !important;
border: none;
border-radius: 8px;
transition: all 0.3s ease;
}
.btn-danger:hover {
background-color: var(--color-red-200) !important;
color: var(--color-red-800) !important;
}
.dark .btn-danger {
background-color: rgba(244, 67, 54, 0.15) !important;
color: rgba(239, 154, 154, 0.9) !important;
border: 1px solid rgba(244, 67, 54, 0.3);
transition: all 0.3s ease;
}
.dark .btn-danger:hover {
background-color: rgba(244, 67, 54, 0.25) !important;
color: rgba(239, 154, 154, 1) !important;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(244, 67, 54, 0.2);
}
.form-input {
background-color: #f9fafb !important;
border-color: #d1d5db !important;
color: #374151 !important;
border-radius: 8px;
transition: all 0.3s ease;
}
.form-input:focus {
border-color: rgba(184, 193, 207, 0.5);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
/* Form input enhancements for dark theme */
.dark .form-input {
background-color: #1f2937 !important;
border-color: #2f3b4c !important;
color: #e5e7eb !important;
border-radius: 8px;
}
.dark .form-input:focus {
border-color: rgba(59, 130, 246, 0.5) !important;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1) !important;
}
.dark .form-input::placeholder {
color: #9ca3af !important;
}
/* Card spacing and layout improvements */
.page-card {
border-radius: 12px;
transition: all 0.3s ease;
}
.page-card:hover {
transform: translateY(-1px);
}
.dark .page-card {
background-color: #1f2937 !important;
border: 1px solid #2f3b4c;
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.15),
0 2px 6px rgba(0, 0, 0, 0.1);
}
/* Custom text color to match status-icon-red */
.text-status-red {
color: #991b1b; /* Tailwind red-800 for light mode */
}
.dark .text-status-red {
color: rgba(239, 154, 154, 0.9) !important;
}
.border-red {
border-color: #937d7d; /* Tailwind red-800 for light mode */
}
.dark .border-red {
border-color: rgba(188, 161, 161, 0.9) !important;
}
.dark .text-status-green {
color: rgba(129, 199, 132, 0.9) !important;
}
/* Empty state styling */
.dark .empty-state {
background-color: #1f2937 !important;
border: 1px solid #2f3b4c;
border-radius: 12px;
text-align: center;
padding: 3rem 2rem;
}
.dark .empty-state p {
color: #9ca3af !important;
}
/* Login page enhancements for dark theme */
.dark .login-container {
background-color: #1f2a37 !important;
}
.dark .login-card {
background-color: #1f2937 !important;
border: 1px solid #2f3b4c;
box-shadow:
0 8px 25px rgba(0, 0, 0, 0.2),
0 4px 12px rgba(0, 0, 0, 0.15);
border-radius: 12px;
}

View File

@@ -117,6 +117,11 @@
"argumentsPlaceholder": "Enter arguments",
"errorDetails": "Error Details",
"viewErrorDetails": "View error details",
"confirmVariables": "Confirm Variable Configuration",
"variablesDetected": "Variables detected in configuration. Please confirm these variables are properly configured:",
"detectedVariables": "Detected Variables",
"confirmVariablesMessage": "Please ensure these variables are properly defined in your runtime environment. Continue adding server?",
"confirmAndAdd": "Confirm and Add",
"openapi": {
"inputMode": "Input Mode",
"inputModeUrl": "Specification URL",
@@ -174,7 +179,8 @@
"copy": "Copy",
"copySuccess": "Copied to clipboard",
"copyFailed": "Copy failed",
"close": "Close"
"close": "Close",
"confirm": "Confirm"
},
"nav": {
"dashboard": "Dashboard",
@@ -296,7 +302,9 @@
"tagFilterError": "Error filtering servers by tag",
"noInstallationMethod": "No installation method available for this server",
"showing": "Showing {{from}}-{{to}} of {{total}} servers",
"perPage": "Per page"
"perPage": "Per page",
"confirmVariablesMessage": "Please ensure these variables are properly defined in your runtime environment. Continue installing server?",
"confirmAndInstall": "Confirm and Install"
},
"tool": {
"run": "Run",
@@ -366,5 +374,30 @@
"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}}"
},
"dxt": {
"upload": "Upload",
"uploadTitle": "Upload DXT Extension",
"dropFileHere": "Drop your .dxt file here",
"orClickToSelect": "or click to select from your computer",
"invalidFileType": "Please select a valid .dxt file",
"noFileSelected": "Please select a .dxt file to upload",
"uploading": "Uploading...",
"uploadFailed": "Failed to upload DXT file",
"installServer": "Install MCP Server from DXT",
"extensionInfo": "Extension Information",
"name": "Name",
"version": "Version",
"description": "Description",
"author": "Author",
"tools": "Tools",
"serverName": "Server Name",
"serverNamePlaceholder": "Enter a name for this server",
"install": "Install",
"installing": "Installing...",
"installFailed": "Failed to install server from DXT",
"serverExistsTitle": "Server Already Exists",
"serverExistsConfirm": "Server '{{serverName}}' already exists. Do you want to override it with the new version?",
"override": "Override"
}
}

View File

@@ -117,6 +117,11 @@
"argumentsPlaceholder": "请输入参数",
"errorDetails": "错误详情",
"viewErrorDetails": "查看错误详情",
"confirmVariables": "确认变量配置",
"variablesDetected": "检测到配置中包含变量,请确认这些变量是否已正确配置:",
"detectedVariables": "检测到的变量",
"confirmVariablesMessage": "请确保这些变量在运行环境中已正确定义。是否继续添加服务器?",
"confirmAndAdd": "确认并添加",
"openapi": {
"inputMode": "输入模式",
"inputModeUrl": "规范 URL",
@@ -175,7 +180,8 @@
"copy": "复制",
"copySuccess": "已复制到剪贴板",
"copyFailed": "复制失败",
"close": "关闭"
"close": "关闭",
"confirm": "确认"
},
"nav": {
"dashboard": "仪表盘",
@@ -297,12 +303,14 @@
"tagFilterError": "按标签筛选服务器失败",
"noInstallationMethod": "该服务器没有可用的安装方法",
"showing": "显示 {{from}}-{{to}}/{{total}} 个服务器",
"perPage": "每页显示"
"perPage": "每页显示",
"confirmVariablesMessage": "请确保这些变量在运行环境中已正确定义。是否继续安装服务器?",
"confirmAndInstall": "确认并安装"
},
"tool": {
"run": "运行",
"running": "运行中...",
"runTool": "运行工具",
"runTool": "运行",
"cancel": "取消",
"noDescription": "无描述信息",
"inputSchema": "输入模式:",
@@ -368,5 +376,30 @@
"smartRoutingConfigUpdated": "智能路由配置更新成功",
"smartRoutingRequiredFields": "启用智能路由需要填写数据库连接地址和 OpenAI API 密钥",
"smartRoutingValidationError": "启用智能路由前请先填写必要字段:{{fields}}"
},
"dxt": {
"upload": "上传",
"uploadTitle": "上传 DXT 扩展",
"dropFileHere": "将 .dxt 文件拖拽到此处",
"orClickToSelect": "或点击从计算机选择",
"invalidFileType": "请选择有效的 .dxt 文件",
"noFileSelected": "请选择要上传的 .dxt 文件",
"uploading": "上传中...",
"uploadFailed": "上传 DXT 文件失败",
"installServer": "从 DXT 安装 MCP 服务器",
"extensionInfo": "扩展信息",
"name": "名称",
"version": "版本",
"description": "描述",
"author": "作者",
"tools": "工具",
"serverName": "服务器名称",
"serverNamePlaceholder": "为此服务器输入名称",
"install": "安装",
"installing": "安装中...",
"installFailed": "从 DXT 安装服务器失败",
"serverExistsTitle": "服务器已存在",
"serverExistsConfirm": "服务器 '{{serverName}}' 已存在。是否要用新版本覆盖它?",
"override": "覆盖"
}
}

View File

@@ -1,7 +1,6 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useServerData } from '@/hooks/useServerData';
import { ServerStatus } from '@/types';
const DashboardPage: React.FC = () => {
const { t } = useTranslation();
@@ -22,26 +21,20 @@ const DashboardPage: React.FC = () => {
connecting: 'status.connecting'
}
// Calculate percentage for each status (for dashboard display)
const getStatusPercentage = (status: ServerStatus) => {
if (servers.length === 0) return 0;
return Math.round((servers.filter(server => server.status === status).length / servers.length) * 100);
};
return (
<div>
<h1 className="text-2xl font-bold text-gray-900 mb-8">{t('pages.dashboard.title')}</h1>
{error && (
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4 rounded shadow-sm">
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4 rounded shadow-sm error-box">
<div className="flex items-center justify-between">
<div>
<h3 className="text-red-600 text-lg font-medium">{t('app.error')}</h3>
<h3 className="text-status-red text-lg font-medium">{t('app.error')}</h3>
<p className="text-gray-600 mt-1">{error}</p>
</div>
<button
onClick={() => setError(null)}
className="ml-4 text-gray-500 hover:text-gray-700"
className="ml-4 text-gray-500 hover:text-gray-700 transition-colors duration-200"
aria-label={t('app.closeButton')}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
@@ -52,8 +45,8 @@ const DashboardPage: React.FC = () => {
</div>
)}
{isLoading ? (
<div className="bg-white shadow rounded-lg p-6 flex items-center justify-center">
{isLoading && (
<div className="bg-white shadow rounded-lg p-6 flex items-center justify-center loading-container">
<div className="flex flex-col items-center">
<svg className="animate-spin h-10 w-10 text-blue-500 mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
@@ -62,12 +55,14 @@ const DashboardPage: React.FC = () => {
<p className="text-gray-600">{t('app.loading')}</p>
</div>
</div>
) : (
)}
{!isLoading && (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
{/* Total servers */}
<div className="bg-white rounded-lg shadow p-6">
<div className="bg-white rounded-lg shadow p-6 dashboard-card">
<div className="flex items-center">
<div className="p-3 rounded-full bg-blue-100 text-blue-800">
<div className="p-3 rounded-full bg-blue-100 text-blue-800 icon-container status-icon-blue">
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
</svg>
@@ -80,9 +75,9 @@ const DashboardPage: React.FC = () => {
</div>
{/* Online servers */}
<div className="bg-white rounded-lg shadow p-6">
<div className="bg-white rounded-lg shadow p-6 dashboard-card">
<div className="flex items-center">
<div className="p-3 rounded-full bg-green-100 text-green-800">
<div className="p-3 rounded-full bg-green-100 text-green-800 icon-container status-icon-green">
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
@@ -92,18 +87,12 @@ const DashboardPage: React.FC = () => {
<p className="text-3xl font-bold text-gray-900">{serverStats.online}</p>
</div>
</div>
<div className="mt-4 h-2 bg-gray-200 rounded-full">
<div
className="h-full bg-green-500 rounded-full"
style={{ width: `${getStatusPercentage('connected')}%` }}
></div>
</div>
</div>
{/* Offline servers */}
<div className="bg-white rounded-lg shadow p-6">
<div className="bg-white rounded-lg shadow p-6 dashboard-card">
<div className="flex items-center">
<div className="p-3 rounded-full bg-red-100 text-red-800">
<div className="p-3 rounded-full bg-red-100 text-red-800 icon-container status-icon-red">
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
@@ -113,18 +102,12 @@ const DashboardPage: React.FC = () => {
<p className="text-3xl font-bold text-gray-900">{serverStats.offline}</p>
</div>
</div>
<div className="mt-4 h-2 bg-gray-200 rounded-full">
<div
className="h-full bg-red-500 rounded-full"
style={{ width: `${getStatusPercentage('disconnected')}%` }}
></div>
</div>
</div>
{/* Connecting servers */}
<div className="bg-white rounded-lg shadow p-6">
<div className="bg-white rounded-lg shadow p-6 dashboard-card">
<div className="flex items-center">
<div className="p-3 rounded-full bg-yellow-100 text-yellow-800">
<div className="p-3 rounded-full bg-yellow-100 text-yellow-800 icon-container status-icon-yellow">
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
@@ -134,12 +117,7 @@ const DashboardPage: React.FC = () => {
<p className="text-3xl font-bold text-gray-900">{serverStats.connecting}</p>
</div>
</div>
<div className="mt-4 h-2 bg-gray-200 rounded-full">
<div
className="h-full bg-yellow-500 rounded-full"
style={{ width: `${getStatusPercentage('connecting')}%` }}
></div>
</div>
</div>
</div>
)}
@@ -148,20 +126,20 @@ const DashboardPage: React.FC = () => {
{servers.length > 0 && !isLoading && (
<div className="mt-8">
<h2 className="text-xl font-semibold text-gray-900 mb-4">{t('pages.dashboard.recentServers')}</h2>
<div className="bg-white shadow rounded-lg overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<div className="bg-white shadow rounded-lg overflow-hidden table-container">
<table className="min-w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('server.name')}
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('server.status')}
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('server.tools')}
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('server.enabled')}
</th>
</tr>
@@ -173,11 +151,11 @@ const DashboardPage: React.FC = () => {
{server.name}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${server.status === 'connected'
? 'bg-green-100 text-green-800'
: server.status === 'disconnected'
? 'bg-red-100 text-red-800'
: 'bg-yellow-100 text-yellow-800'
<span className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${server.status === 'connected'
? 'status-badge-online'
: server.status === 'disconnected'
? 'status-badge-offline'
: 'status-badge-connecting'
}`}>
{t(statusTranslations[server.status] || server.status)}
</span>
@@ -189,7 +167,7 @@ const DashboardPage: React.FC = () => {
{server.enabled !== false ? (
<span className="text-green-600"></span>
) : (
<span className="text-red-600"></span>
<span className="text-status-red"></span>
)}
</td>
</tr>

View File

@@ -9,16 +9,16 @@ import GroupCard from '@/components/GroupCard';
const GroupsPage: React.FC = () => {
const { t } = useTranslation();
const {
groups,
loading: groupsLoading,
error: groupError,
const {
groups,
loading: groupsLoading,
error: groupError,
setError: setGroupError,
deleteGroup,
triggerRefresh
} = useGroupData();
const { servers } = useServerData();
const [editingGroup, setEditingGroup] = useState<Group | null>(null);
const [showAddForm, setShowAddForm] = useState(false);
@@ -54,7 +54,7 @@ const GroupsPage: React.FC = () => {
<div className="flex space-x-4">
<button
onClick={handleAddGroup}
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center"
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-2" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 3a1 1 0 00-1 1v5H4a1 1 0 100 2h5v5a1 1 0 102 0v-5h5a1 1 0 100-2h-5V4a1 1 0 00-1-1z" clipRule="evenodd" />
@@ -65,13 +65,13 @@ const GroupsPage: React.FC = () => {
</div>
{groupError && (
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6">
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6 error-box rounded-lg">
<p>{groupError}</p>
</div>
)}
{groupsLoading ? (
<div className="bg-white shadow rounded-lg p-6">
<div className="bg-white shadow rounded-lg p-6 loading-container">
<div className="flex flex-col items-center justify-center">
<svg className="animate-spin h-10 w-10 text-blue-500 mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
@@ -81,7 +81,7 @@ const GroupsPage: React.FC = () => {
</div>
</div>
) : groups.length === 0 ? (
<div className="bg-white shadow rounded-lg p-6">
<div className="bg-white shadow rounded-lg p-6 empty-state">
<p className="text-gray-600">{t('groups.noGroups')}</p>
</div>
) : (

View File

@@ -26,7 +26,7 @@ const LoginPage: React.FC = () => {
}
const success = await login(username, password);
if (success) {
navigate('/');
} else {
@@ -40,18 +40,18 @@ const LoginPage: React.FC = () => {
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8">
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8 login-container">
<div className="absolute top-4 right-4">
<ThemeSwitch />
</div>
<div className="max-w-md w-full space-y-8">
<div className="max-w-md w-full space-y-8 login-card p-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white">
{t('auth.loginTitle')}
</h2>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="rounded-md shadow-sm -space-y-px">
<div className="rounded-md -space-y-px">
<div>
<label htmlFor="username" className="sr-only">
{t('auth.username')}
@@ -62,7 +62,7 @@ const LoginPage: React.FC = () => {
type="text"
autoComplete="username"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-200 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm transition-all duration-200 form-input"
placeholder={t('auth.username')}
value={username}
onChange={(e) => setUsername(e.target.value)}
@@ -78,7 +78,7 @@ const LoginPage: React.FC = () => {
type="password"
autoComplete="current-password"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm login-input transition-all duration-200 form-input"
placeholder={t('auth.password')}
value={password}
onChange={(e) => setPassword(e.target.value)}
@@ -87,14 +87,14 @@ const LoginPage: React.FC = () => {
</div>
{error && (
<div className="text-red-500 dark:text-red-400 text-sm text-center">{error}</div>
<div className="text-red-500 dark:text-red-400 text-sm text-center error-box p-2 rounded">{error}</div>
)}
<div>
<button
type="submit"
disabled={loading}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 login-button transition-all duration-200 btn-primary"
>
{loading ? t('auth.loggingIn') : t('auth.login')}
</button>

View File

@@ -11,9 +11,9 @@ const LogsPage: React.FC = () => {
return (
<div className="container mx-auto p-4">
<div className="flex justify-between items-center mb-4">
<h1 className="text-2xl font-bold">{t('pages.logs.title')}</h1>
<h1 className="text-2xl font-bold text-gray-900">{t('pages.logs.title')}</h1>
</div>
<div className="bg-card rounded-md shadow-sm">
<div className="bg-card rounded-md shadow-sm border border-gray-200 page-card">
<LogViewer
logs={logs}
isLoading={loading}

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useParams, useLocation } from 'react-router-dom';
import { useNavigate, useParams } from 'react-router-dom';
import { MarketServer, ServerConfig } from '@/types';
import { useMarketData } from '@/hooks/useMarketData';
import { useToast } from '@/contexts/ToastContext';
@@ -11,15 +11,13 @@ import Pagination from '@/components/ui/Pagination';
const MarketPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const { serverName } = useParams<{ serverName?: string }>();
const { showToast } = useToast();
const {
servers,
allServers,
categories,
tags,
loading,
error,
setError,
@@ -42,7 +40,6 @@ const MarketPage: React.FC = () => {
const [selectedServer, setSelectedServer] = useState<MarketServer | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [installing, setInstalling] = useState(false);
const [showTags, setShowTags] = useState(false);
// Load server details if a server name is in the URL
useEffect(() => {
@@ -59,7 +56,7 @@ const MarketPage: React.FC = () => {
setSelectedServer(null);
}
};
loadServerDetails();
}, [serverName, fetchServerByName, navigate]);
@@ -72,10 +69,6 @@ const MarketPage: React.FC = () => {
filterByCategory(category);
};
const handleTagClick = (tag: string) => {
filterByTag(tag);
};
const handleClearFilters = () => {
setSearchQuery('');
filterByCategory('');
@@ -115,10 +108,6 @@ const MarketPage: React.FC = () => {
changeServersPerPage(newValue);
};
const toggleTagsVisibility = () => {
setShowTags(!showTags);
};
// Render detailed view if a server is selected
if (selectedServer) {
return (
@@ -144,12 +133,12 @@ const MarketPage: React.FC = () => {
</div>
{error && (
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6">
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6 error-box rounded-lg">
<div className="flex items-center justify-between">
<p>{error}</p>
<button
onClick={() => setError(null)}
className="text-red-700 hover:text-red-900"
className="text-red-700 hover:text-red-900 transition-colors duration-200"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4.293 4.293a1 1 011.414 0L10 8.586l4.293-4.293a1 1 01.414 1.414L11.414 10l4.293 4.293a1 1 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 01-1.414-1.414L8.586 10 4.293 5.707a1 1 010-1.414z" clipRule="evenodd" />
@@ -160,7 +149,7 @@ const MarketPage: React.FC = () => {
)}
{/* Search bar at the top */}
<div className="bg-white shadow rounded-lg p-6 mb-6">
<div className="bg-white shadow rounded-lg p-6 mb-6 page-card">
<form onSubmit={handleSearch} className="flex space-x-4 mb-0">
<div className="flex-grow">
<input
@@ -168,12 +157,12 @@ const MarketPage: React.FC = () => {
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('market.searchPlaceholder')}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
className="shadow appearance-none border border-gray-200 rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
/>
</div>
<button
type="submit"
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded"
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200"
>
{t('market.search')}
</button>
@@ -181,7 +170,7 @@ const MarketPage: React.FC = () => {
<button
type="button"
onClick={handleClearFilters}
className="border border-gray-300 text-gray-700 font-medium py-2 px-4 rounded hover:bg-gray-50"
className="border border-gray-300 text-gray-700 font-medium py-2 px-4 rounded hover:bg-gray-50 btn-secondary transition-all duration-200"
>
{t('market.clearFilters')}
</button>
@@ -192,14 +181,14 @@ const MarketPage: React.FC = () => {
<div className="flex flex-col md:flex-row gap-6">
{/* Left sidebar for filters (without search) */}
<div className="md:w-48 flex-shrink-0">
<div className="bg-white shadow rounded-lg p-4 mb-6 sticky top-4">
<div className="bg-white shadow rounded-lg p-4 mb-6 sticky top-4 page-card">
{/* Categories */}
{categories.length > 0 ? (
<div className="mb-6">
<div className="flex justify-between items-center mb-3">
<h3 className="font-medium text-gray-900">{t('market.categories')}</h3>
{selectedCategory && (
<span className="text-xs text-blue-600 cursor-pointer hover:underline" onClick={() => filterByCategory('')}>
<span className="text-xs text-blue-600 cursor-pointer hover:underline transition-colors duration-200" onClick={() => filterByCategory('')}>
{t('market.clearCategoryFilter')}
</span>
)}
@@ -209,9 +198,9 @@ const MarketPage: React.FC = () => {
<button
key={category}
onClick={() => handleCategoryClick(category)}
className={`px-3 py-2 rounded text-sm text-left ${selectedCategory === category
? 'bg-blue-100 text-blue-800 font-medium'
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
className={`px-3 py-2 rounded text-sm text-left transition-all duration-200 ${selectedCategory === category
? 'bg-blue-100 text-blue-800 font-medium btn-primary'
: 'bg-gray-100 text-gray-800 hover:bg-gray-200 btn-secondary'
}`}
>
{category}
@@ -224,7 +213,7 @@ const MarketPage: React.FC = () => {
<div className="mb-3">
<h3 className="font-medium text-gray-900">{t('market.categories')}</h3>
</div>
<div className="flex flex-col gap-2 items-center py-4">
<div className="flex flex-col gap-2 items-center py-4 loading-container">
<svg className="animate-spin h-6 w-6 text-blue-500 mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
@@ -333,7 +322,7 @@ const MarketPage: React.FC = () => {
id="perPage"
value={serversPerPage}
onChange={handleChangeItemsPerPage}
className="border rounded p-1 text-sm"
className="border rounded p-1 text-sm btn-secondary outline-none"
>
<option value="6">6</option>
<option value="9">9</option>

View File

@@ -6,6 +6,7 @@ import ServerCard from '@/components/ServerCard';
import AddServerForm from '@/components/AddServerForm';
import EditServerForm from '@/components/EditServerForm';
import { useServerData } from '@/hooks/useServerData';
import DxtUploadForm from '@/components/DxtUploadForm';
const ServersPage: React.FC = () => {
const { t } = useTranslation();
@@ -23,6 +24,7 @@ const ServersPage: React.FC = () => {
} = useServerData();
const [editingServer, setEditingServer] = useState<Server | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
const [showDxtUpload, setShowDxtUpload] = useState(false);
const handleEditClick = async (server: Server) => {
const fullServerData = await handleServerEdit(server);
@@ -47,6 +49,12 @@ const ServersPage: React.FC = () => {
}
};
const handleDxtUploadSuccess = (_serverConfig: any) => {
// Close upload dialog and refresh servers
setShowDxtUpload(false);
triggerRefresh();
};
return (
<div>
<div className="flex justify-between items-center mb-8">
@@ -54,7 +62,7 @@ const ServersPage: React.FC = () => {
<div className="flex space-x-4">
<button
onClick={() => navigate('/market')}
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center"
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-2" viewBox="0 0 20 20" fill="currentColor">
<path d="M3 1a1 1 0 000 2h1.22l.305 1.222a.997.997 0 00.01.042l1.358 5.43-.893.892C3.74 11.846 4.632 14 6.414 14H15a1 1 0 000-2H6.414l1-1H14a1 1 0 00.894-.553l3-6A1 1 0 0017 3H6.28l-.31-1.243A1 1 0 005 1H3z" />
@@ -62,10 +70,19 @@ const ServersPage: React.FC = () => {
{t('nav.market')}
</button>
<AddServerForm onAdd={handleServerAdd} />
<button
onClick={() => setShowDxtUpload(true)}
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-2" viewBox="0 0 20 20" fill="currentColor">
<path d="M5.5 13a3.5 3.5 0 01-.369-6.98 4 4 0 117.753-1.977A4.5 4.5 0 1113.5 13H11V9.413l1.293 1.293a1 1 0 001.414-1.414l-3-3a1 1 0 00-1.414 0l-3 3a1 1 0 001.414 1.414L9 9.413V13H5.5z" />
</svg>
{t('dxt.upload')}
</button>
<button
onClick={handleRefresh}
disabled={isRefreshing}
className={`px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center ${isRefreshing ? 'opacity-70 cursor-not-allowed' : ''}`}
className={`px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200 ${isRefreshing ? 'opacity-70 cursor-not-allowed' : ''}`}
>
{isRefreshing ? (
<svg className="animate-spin h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
@@ -83,7 +100,7 @@ const ServersPage: React.FC = () => {
</div>
{error && (
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4 rounded shadow-sm">
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4 rounded shadow-sm error-box">
<div className="flex items-center justify-between">
<div>
<h3 className="text-red-600 text-lg font-medium">{t('app.error')}</h3>
@@ -91,7 +108,7 @@ const ServersPage: React.FC = () => {
</div>
<button
onClick={() => setError(null)}
className="ml-4 text-gray-500 hover:text-gray-700"
className="ml-4 text-gray-500 hover:text-gray-700 transition-colors duration-200 btn-secondary"
aria-label={t('app.closeButton')}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
@@ -103,7 +120,7 @@ const ServersPage: React.FC = () => {
)}
{isLoading ? (
<div className="bg-white shadow rounded-lg p-6 flex items-center justify-center">
<div className="bg-white shadow rounded-lg p-6 flex items-center justify-center loading-container">
<div className="flex flex-col items-center">
<svg className="animate-spin h-10 w-10 text-blue-500 mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
@@ -113,7 +130,7 @@ const ServersPage: React.FC = () => {
</div>
</div>
) : servers.length === 0 ? (
<div className="bg-white shadow rounded-lg p-6">
<div className="bg-white shadow rounded-lg p-6 empty-state">
<p className="text-gray-600">{t('app.noServers')}</p>
</div>
) : (
@@ -138,6 +155,13 @@ const ServersPage: React.FC = () => {
onCancel={() => setEditingServer(null)}
/>
)}
{showDxtUpload && (
<DxtUploadForm
onSuccess={handleDxtUploadSuccess}
onCancel={() => setShowDxtUpload(false)}
/>
)}
</div>
);
};

View File

@@ -203,23 +203,23 @@ const SettingsPage: React.FC = () => {
<h1 className="text-2xl font-bold text-gray-900 mb-8">{t('pages.settings.title')}</h1>
{/* Language Settings */}
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6">
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 page-card">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-gray-800">{t('pages.settings.language')}</h2>
<div className="flex space-x-3">
<button
className={`px-3 py-1.5 rounded-md transition-colors text-sm ${currentLanguage.startsWith('en')
? 'bg-blue-500 text-white'
: 'bg-blue-100 text-blue-800 hover:bg-blue-200'
className={`px-3 py-1.5 rounded-md transition-all duration-200 text-sm ${currentLanguage.startsWith('en')
? 'bg-blue-500 text-white btn-primary'
: 'bg-blue-100 text-blue-800 hover:bg-blue-200 btn-secondary'
}`}
onClick={() => handleLanguageChange('en')}
>
English
</button>
<button
className={`px-3 py-1.5 rounded-md transition-colors text-sm ${currentLanguage.startsWith('zh')
? 'bg-blue-500 text-white'
: 'bg-blue-100 text-blue-800 hover:bg-blue-200'
className={`px-3 py-1.5 rounded-md transition-all duration-200 text-sm ${currentLanguage.startsWith('zh')
? 'bg-blue-500 text-white btn-primary'
: 'bg-blue-100 text-blue-800 hover:bg-blue-200 btn-secondary'
}`}
onClick={() => handleLanguageChange('zh')}
>
@@ -230,13 +230,13 @@ const SettingsPage: React.FC = () => {
</div>
{/* Smart Routing Configuration Settings */}
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6">
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 page-card">
<div
className="flex justify-between items-center cursor-pointer"
className="flex justify-between items-center cursor-pointer transition-colors duration-200 hover:text-blue-600"
onClick={() => toggleSection('smartRoutingConfig')}
>
<h2 className="font-semibold text-gray-800">{t('pages.settings.smartRouting')}</h2>
<span className="text-gray-500">
<span className="text-gray-500 transition-transform duration-200">
{sectionsVisible.smartRoutingConfig ? '▼' : '►'}
</span>
</div>
@@ -267,13 +267,13 @@ const SettingsPage: React.FC = () => {
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"
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 form-input"
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"
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 btn-primary"
>
{t('common.save')}
</button>
@@ -298,7 +298,7 @@ const SettingsPage: React.FC = () => {
<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"
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 btn-primary"
>
{t('common.save')}
</button>
@@ -315,13 +315,13 @@ const SettingsPage: React.FC = () => {
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"
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 form-input"
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"
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 btn-primary"
>
{t('common.save')}
</button>
@@ -338,13 +338,13 @@ const SettingsPage: React.FC = () => {
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"
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 form-input"
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"
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 btn-primary"
>
{t('common.save')}
</button>
@@ -392,13 +392,13 @@ const SettingsPage: React.FC = () => {
value={tempRoutingConfig.bearerAuthKey}
onChange={(e) => handleBearerAuthKeyChange(e.target.value)}
placeholder={t('settings.bearerAuthKeyPlaceholder')}
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"
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 form-input"
disabled={loading || !routingConfig.enableBearerAuth}
/>
<button
onClick={saveBearerAuthKey}
disabled={loading || !routingConfig.enableBearerAuth}
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"
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 btn-primary"
>
{t('common.save')}
</button>
@@ -471,13 +471,13 @@ const SettingsPage: React.FC = () => {
value={installConfig.pythonIndexUrl}
onChange={(e) => handleInstallConfigChange('pythonIndexUrl', e.target.value)}
placeholder={t('settings.pythonIndexUrlPlaceholder')}
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"
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 form-input"
disabled={loading}
/>
<button
onClick={() => saveInstallConfig('pythonIndexUrl')}
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"
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 btn-primary"
>
{t('common.save')}
</button>
@@ -495,13 +495,13 @@ const SettingsPage: React.FC = () => {
value={installConfig.npmRegistry}
onChange={(e) => handleInstallConfigChange('npmRegistry', e.target.value)}
placeholder={t('settings.npmRegistryPlaceholder')}
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"
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 form-input"
disabled={loading}
/>
<button
onClick={() => saveInstallConfig('npmRegistry')}
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"
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 btn-primary"
>
{t('common.save')}
</button>

View File

@@ -0,0 +1,27 @@
// Utility function to detect ${} variables in server configurations
export const detectVariables = (payload: any): string[] => {
const variables = new Set<string>();
const variableRegex = /\$\{([^}]+)\}/g;
const checkString = (str: string) => {
let match;
while ((match = variableRegex.exec(str)) !== null) {
variables.add(match[1]);
}
};
const checkObject = (obj: any, path: string = '') => {
if (typeof obj === 'string') {
checkString(obj);
} else if (Array.isArray(obj)) {
obj.forEach((item, index) => checkObject(item, `${path}[${index}]`));
} else if (obj && typeof obj === 'object') {
Object.entries(obj).forEach(([key, value]) => {
checkObject(value, path ? `${path}.${key}` : key);
});
}
};
checkObject(payload);
return Array.from(variables);
};

View File

@@ -47,7 +47,10 @@
"dependencies": {
"@apidevtools/swagger-parser": "^11.0.1",
"@modelcontextprotocol/sdk": "^1.12.1",
"@types/adm-zip": "^0.5.7",
"@types/multer": "^1.4.13",
"@types/pg": "^8.15.2",
"adm-zip": "^0.5.16",
"axios": "^1.10.0",
"bcryptjs": "^3.0.2",
"dotenv": "^16.3.1",
@@ -55,6 +58,7 @@
"express": "^4.21.2",
"express-validator": "^7.2.1",
"jsonwebtoken": "^9.0.2",
"multer": "^2.0.1",
"openai": "^4.103.0",
"openapi-types": "^12.1.3",
"pg": "^8.16.0",

111
pnpm-lock.yaml generated
View File

@@ -14,9 +14,18 @@ importers:
'@modelcontextprotocol/sdk':
specifier: ^1.12.1
version: 1.12.1
'@types/adm-zip':
specifier: ^0.5.7
version: 0.5.7
'@types/multer':
specifier: ^1.4.13
version: 1.4.13
'@types/pg':
specifier: ^8.15.2
version: 8.15.4
adm-zip:
specifier: ^0.5.16
version: 0.5.16
axios:
specifier: ^1.10.0
version: 1.10.0
@@ -38,6 +47,9 @@ importers:
jsonwebtoken:
specifier: ^9.0.2
version: 9.0.2
multer:
specifier: ^2.0.1
version: 2.0.1
openai:
specifier: ^4.103.0
version: 4.104.0(zod@3.25.48)
@@ -616,85 +628,72 @@ packages:
resolution: {integrity: sha512-IVfGJa7gjChDET1dK9SekxFFdflarnUB8PwW8aGwEoF3oAsSDuNUTYS+SKDOyOJxQyDC1aPFMuRYLoDInyV9Ew==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.1.0':
resolution: {integrity: sha512-s8BAd0lwUIvYCJyRdFqvsj+BJIpDBSxs6ivrOPm/R7piTs5UIwY5OjXrP2bqXC9/moGsyRa37eYWYCOGVXxVrA==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.1.0':
resolution: {integrity: sha512-tiXxFZFbhnkWE2LA8oQj7KYR+bWBkiV2nilRldT7bqoEZ4HiDOcePr9wVDAZPi/Id5fT1oY9iGnDq20cwUz8lQ==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.1.0':
resolution: {integrity: sha512-xukSwvhguw7COyzvmjydRb3x/09+21HykyapcZchiCUkTThEQEOMtBj9UhkaBRLuBrgLFzQ2wbxdeCCJW/jgJA==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.1.0':
resolution: {integrity: sha512-yRj2+reB8iMg9W5sULM3S74jVS7zqSzHG3Ol/twnAAkAhnGQnpjj6e4ayUz7V+FpKypwgs82xbRdYtchTTUB+Q==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.1.0':
resolution: {integrity: sha512-jYZdG+whg0MDK+q2COKbYidaqW/WTz0cc1E+tMAusiDygrM4ypmSCjOJPmFTvHHJ8j/6cAGyeDWZOsK06tP33w==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.1.0':
resolution: {integrity: sha512-wK7SBdwrAiycjXdkPnGCPLjYb9lD4l6Ze2gSdAGVZrEL05AOUJESWU2lhlC+Ffn5/G+VKuSm6zzbQSzFX/P65A==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.34.2':
resolution: {integrity: sha512-D8n8wgWmPDakc83LORcfJepdOSN6MvWNzzz2ux0MnIbOqdieRZwVYY32zxVx+IFUT8er5KPcyU3XXsn+GzG/0Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.2':
resolution: {integrity: sha512-0DZzkvuEOqQUP9mo2kjjKNok5AmnOr1jB2XYjkaoNRwpAYMDzRmAqUIa1nRi58S2WswqSfPOWLNOr0FDT3H5RQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.2':
resolution: {integrity: sha512-EGZ1xwhBI7dNISwxjChqBGELCWMGDvmxZXKjQRuqMrakhO8QoMgqCrdjnAqJq/CScxfRn+Bb7suXBElKQpPDiw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.2':
resolution: {integrity: sha512-sD7J+h5nFLMMmOXYH4DD9UtSNBD05tWSSdWAcEyzqW8Cn5UxXvsHAxmxSesYUsTOBmUnjtxghKDl15EvfqLFbQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.2':
resolution: {integrity: sha512-NEE2vQ6wcxYav1/A22OOxoSOGiKnNmDzCYFOZ949xFmrWZOVII1Bp3NqVVpvj+3UeHMFyN5eP/V5hzViQ5CZNA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.2':
resolution: {integrity: sha512-DOYMrDm5E6/8bm/yQLCWyuDJwUnlevR8xtF8bs+gjZ7cyUNYXiSf/E8Kp0Ss5xasIaXSHzb888V1BE4i1hFhAA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.34.2':
resolution: {integrity: sha512-/VI4mdlJ9zkaq53MbIG6rZY+QRN3MLbR6usYlgITEzi4Rpx5S6LFKsycOQjkOGmqTNmkIdLjEvooFKwww6OpdQ==}
@@ -870,28 +869,24 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@next/swc-linux-arm64-musl@15.3.3':
resolution: {integrity: sha512-h6Y1fLU4RWAp1HPNJWDYBQ+e3G7sLckyBXhmH9ajn8l/RSMnhbuPBV/fXmy3muMcVwoJdHL+UtzRzs0nXOf9SA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@next/swc-linux-x64-gnu@15.3.3':
resolution: {integrity: sha512-jJ8HRiF3N8Zw6hGlytCj5BiHyG/K+fnTKVDEKvUCyiQ/0r5tgwO7OgaRiOjjRoIx2vwLR+Rz8hQoPrnmFbJdfw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@next/swc-linux-x64-musl@15.3.3':
resolution: {integrity: sha512-HrUcTr4N+RgiiGn3jjeT6Oo208UT/7BuTr7K0mdKRBtTbT4v9zJqCDKO97DUqqoBK1qyzP1RwvrWTvU6EPh/Cw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@next/swc-win32-arm64-msvc@15.3.3':
resolution: {integrity: sha512-SxorONgi6K7ZUysMtRF3mIeHC5aA3IQLmKFQzU0OuhuUYwpOBc1ypaLJLP5Bf3M9k53KUUUj4vTPwzGvl/NwlQ==}
@@ -1105,67 +1100,56 @@ packages:
resolution: {integrity: sha512-ehSKrewwsESPt1TgSE/na9nIhWCosfGSFqv7vwEtjyAqZcvbGIg4JAcV7ZEh2tfj/IlfBeZjgOXm35iOOjadcg==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.40.1':
resolution: {integrity: sha512-m39iO/aaurh5FVIu/F4/Zsl8xppd76S4qoID8E+dSRQvTyZTOI2gVk3T4oqzfq1PtcvOfAVlwLMK3KRQMaR8lg==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.40.1':
resolution: {integrity: sha512-Y+GHnGaku4aVLSgrT0uWe2o2Rq8te9hi+MwqGF9r9ORgXhmHK5Q71N757u0F8yU1OIwUIFy6YiJtKjtyktk5hg==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.40.1':
resolution: {integrity: sha512-jEwjn3jCA+tQGswK3aEWcD09/7M5wGwc6+flhva7dsQNRZZTe30vkalgIzV4tjkopsTS9Jd7Y1Bsj6a4lzz8gQ==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loongarch64-gnu@4.40.1':
resolution: {integrity: sha512-ySyWikVhNzv+BV/IDCsrraOAZ3UaC8SZB67FZlqVwXwnFhPihOso9rPOxzZbjp81suB1O2Topw+6Ug3JNegejQ==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-powerpc64le-gnu@4.40.1':
resolution: {integrity: sha512-BvvA64QxZlh7WZWqDPPdt0GH4bznuL6uOO1pmgPnnv86rpUpc8ZxgZwcEgXvo02GRIZX1hQ0j0pAnhwkhwPqWg==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.40.1':
resolution: {integrity: sha512-EQSP+8+1VuSulm9RKSMKitTav89fKbHymTf25n5+Yr6gAPZxYWpj3DzAsQqoaHAk9YX2lwEyAf9S4W8F4l3VBQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.40.1':
resolution: {integrity: sha512-n/vQ4xRZXKuIpqukkMXZt9RWdl+2zgGNx7Uda8NtmLJ06NL8jiHxUawbwC+hdSq1rrw/9CghCpEONor+l1e2gA==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.40.1':
resolution: {integrity: sha512-h8d28xzYb98fMQKUz0w2fMc1XuGzLLjdyxVIbhbil4ELfk5/orZlSTpF/xdI9C8K0I8lCkq+1En2RJsawZekkg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.40.1':
resolution: {integrity: sha512-XiK5z70PEFEFqcNj3/zRSz/qX4bp4QIraTy9QjwJAb/Z8GM7kVUsD0Uk8maIPeTyPCP03ChdI+VVmJriKYbRHQ==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.40.1':
resolution: {integrity: sha512-2BRORitq5rQ4Da9blVovzNCMaUlyKrzMSvkVR0D4qPuOy/+pMCrh1d7o01RATwVy+6Fa1WBw+da7QPeLWU/1mQ==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-win32-arm64-msvc@4.40.1':
resolution: {integrity: sha512-b2bcNm9Kbde03H+q+Jjw9tSfhYkzrDUf2d5MAd1bOJuVplXvFhWz7tRtWvD8/ORZi7qSCy0idW6tf2HgxSXQSg==}
@@ -1248,28 +1232,24 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.1.8':
resolution: {integrity: sha512-O6b8QesPbJCRshsNApsOIpzKt3ztG35gfX9tEf4arD7mwNinsoCKxkj8TgEE0YRjmjtO3r9FlJnT/ENd9EVefQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.1.8':
resolution: {integrity: sha512-32iEXX/pXwikshNOGnERAFwFSfiltmijMIAbUhnNyjFr3tmWmMJWQKU2vNcFX0DACSXJ3ZWcSkzNbaKTdngH6g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.1.8':
resolution: {integrity: sha512-s+VSSD+TfZeMEsCaFaHTaY5YNj3Dri8rST09gMvYQKwPphacRG7wbuQ5ZJMIJXN/puxPcg/nU+ucvWguPpvBDg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.1.8':
resolution: {integrity: sha512-CXBPVFkpDjM67sS1psWohZ6g/2/cd+cq56vPxK4JeawelxwK4YECgl9Y9TjkE2qfF+9/s1tHHJqrC4SS6cVvSg==}
@@ -1319,6 +1299,9 @@ packages:
'@tsconfig/node16@1.0.4':
resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==}
'@types/adm-zip@0.5.7':
resolution: {integrity: sha512-DNEs/QvmyRLurdQPChqq0Md4zGvPwHerAJYWk9l2jCbD1VPpnzRJorOdiq4zsw09NFbYnhfsoEhWtxIzXpn2yw==}
'@types/babel__core@7.20.5':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
@@ -1386,6 +1369,9 @@ packages:
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
'@types/multer@1.4.13':
resolution: {integrity: sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw==}
'@types/node-fetch@2.6.12':
resolution: {integrity: sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==}
@@ -1538,6 +1524,10 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
adm-zip@0.5.16:
resolution: {integrity: sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==}
engines: {node: '>=12.0'}
agentkeepalive@4.6.0:
resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==}
engines: {node: '>= 8.0.0'}
@@ -1592,6 +1582,9 @@ packages:
resolution: {integrity: sha512-biN3PwB2gUtjaYy/isrU3aNWI5w+fAfvHkSvCKeQGxhmYpwKFUxudR3Yya+KqVRHBmEDYh+/lTozYCFbmzX4nA==}
engines: {node: '>= 6.0.0'}
append-field@1.0.0:
resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==}
arg@4.1.3:
resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==}
@@ -1842,6 +1835,10 @@ packages:
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
concat-stream@2.0.0:
resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==}
engines: {'0': node >= 6.0}
concurrently@9.1.2:
resolution: {integrity: sha512-H9MWcoPsYddwbOGM6difjVwVZHl63nwMEwDJG/L7VGtuaJhb12h2caPG2tVPWs7emuYix252iGfqOyrz1GczTQ==}
engines: {node: '>=18'}
@@ -2820,28 +2817,24 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.30.1:
resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.30.1:
resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.30.1:
resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.30.1:
resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==}
@@ -3022,6 +3015,10 @@ packages:
resolution: {integrity: sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==}
engines: {node: '>= 18'}
mkdirp@0.5.6:
resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==}
hasBin: true
mkdirp@1.0.4:
resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==}
engines: {node: '>=10'}
@@ -3038,6 +3035,10 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
multer@2.0.1:
resolution: {integrity: sha512-Ug8bXeTIUlxurg8xLTEskKShvcKDZALo1THEX5E41pYCD2sCVub5/kIRIGqWNoqV6szyLyQKV6mD4QUrWE5GCQ==}
engines: {node: '>= 10.16.0'}
nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -3877,6 +3878,9 @@ packages:
resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==}
engines: {node: '>= 0.6'}
typedarray@0.0.6:
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
typeorm@0.3.24:
resolution: {integrity: sha512-4IrHG7A0tY8l5gEGXfW56VOMfUVWEkWlH/h5wmcyZ+V8oCiLj7iTPp0lEjMEZVrxEkGSdP9ErgTKHKXQApl/oA==}
engines: {node: '>=16.13.0'}
@@ -5156,6 +5160,10 @@ snapshots:
'@tsconfig/node16@1.0.4': {}
'@types/adm-zip@0.5.7':
dependencies:
'@types/node': 22.15.29
'@types/babel__core@7.20.5':
dependencies:
'@babel/parser': 7.27.4
@@ -5242,6 +5250,10 @@ snapshots:
'@types/ms@2.1.0': {}
'@types/multer@1.4.13':
dependencies:
'@types/express': 4.17.22
'@types/node-fetch@2.6.12':
dependencies:
'@types/node': 22.15.29
@@ -5436,6 +5448,8 @@ snapshots:
acorn@8.14.1: {}
adm-zip@0.5.16: {}
agentkeepalive@4.6.0:
dependencies:
humanize-ms: 1.2.1
@@ -5483,6 +5497,8 @@ snapshots:
app-root-path@3.1.0: {}
append-field@1.0.0: {}
arg@4.1.3: {}
argparse@1.0.10:
@@ -5774,6 +5790,13 @@ snapshots:
concat-map@0.0.1: {}
concat-stream@2.0.0:
dependencies:
buffer-from: 1.1.2
inherits: 2.0.4
readable-stream: 3.6.2
typedarray: 0.0.6
concurrently@9.1.2:
dependencies:
chalk: 4.1.2
@@ -7176,6 +7199,10 @@ snapshots:
dependencies:
minipass: 7.1.2
mkdirp@0.5.6:
dependencies:
minimist: 1.2.8
mkdirp@1.0.4: {}
mkdirp@3.0.1: {}
@@ -7184,6 +7211,16 @@ snapshots:
ms@2.1.3: {}
multer@2.0.1:
dependencies:
append-field: 1.0.0
busboy: 1.6.0
concat-stream: 2.0.0
mkdirp: 0.5.6
object-assign: 4.1.1
type-is: 1.6.18
xtend: 4.0.2
nanoid@3.3.11: {}
natural-compare@1.4.0: {}
@@ -8030,6 +8067,8 @@ snapshots:
media-typer: 1.1.0
mime-types: 3.0.1
typedarray@0.0.6: {}
typeorm@0.3.24(pg@8.16.0)(reflect-metadata@0.2.2)(ts-node@10.9.2(@types/node@22.15.29)(typescript@5.8.3)):
dependencies:
'@sqltools/formatter': 1.2.5

View File

@@ -0,0 +1,156 @@
import { Request, Response } from 'express';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
import AdmZip from 'adm-zip';
import { ApiResponse } from '../types/index.js';
// Get the directory name in ESM
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Configure multer for file uploads
const storage = multer.diskStorage({
destination: (req, file, cb) => {
const uploadDir = path.join(__dirname, '../../data/uploads/dxt');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
cb(null, uploadDir);
},
filename: (req, file, cb) => {
const timestamp = Date.now();
const originalName = path.parse(file.originalname).name;
cb(null, `${originalName}-${timestamp}.dxt`);
},
});
const upload = multer({
storage,
fileFilter: (req, file, cb) => {
if (file.originalname.endsWith('.dxt')) {
cb(null, true);
} else {
cb(new Error('Only .dxt files are allowed'));
}
},
limits: {
fileSize: 100 * 1024 * 1024, // 100MB limit
},
});
export const uploadMiddleware = upload.single('dxtFile');
// Clean up old DXT server files when installing a new version
const cleanupOldDxtServer = (serverName: string): void => {
try {
const uploadDir = path.join(__dirname, '../../data/uploads/dxt');
const serverPattern = `server-${serverName}`;
if (fs.existsSync(uploadDir)) {
const files = fs.readdirSync(uploadDir);
files.forEach((file) => {
if (file.startsWith(serverPattern)) {
const filePath = path.join(uploadDir, file);
if (fs.statSync(filePath).isDirectory()) {
fs.rmSync(filePath, { recursive: true, force: true });
console.log(`Cleaned up old DXT server directory: ${filePath}`);
}
}
});
}
} catch (error) {
console.warn('Failed to cleanup old DXT server files:', error);
// Don't fail the installation if cleanup fails
}
};
export const uploadDxtFile = async (req: Request, res: Response): Promise<void> => {
try {
if (!req.file) {
res.status(400).json({
success: false,
message: 'No DXT file uploaded',
});
return;
}
const dxtFilePath = req.file.path;
const timestamp = Date.now();
const tempExtractDir = path.join(path.dirname(dxtFilePath), `temp-extracted-${timestamp}`);
try {
// Extract the DXT file (which is a ZIP archive) to a temporary directory first
const zip = new AdmZip(dxtFilePath);
zip.extractAllTo(tempExtractDir, true);
// Read and validate the manifest.json
const manifestPath = path.join(tempExtractDir, 'manifest.json');
if (!fs.existsSync(manifestPath)) {
throw new Error('manifest.json not found in DXT file');
}
const manifestContent = fs.readFileSync(manifestPath, 'utf-8');
const manifest = JSON.parse(manifestContent);
// Validate required fields in manifest
if (!manifest.dxt_version) {
throw new Error('Invalid manifest: missing dxt_version');
}
if (!manifest.name) {
throw new Error('Invalid manifest: missing name');
}
if (!manifest.version) {
throw new Error('Invalid manifest: missing version');
}
if (!manifest.server) {
throw new Error('Invalid manifest: missing server configuration');
}
// Use server name as the final extract directory for automatic version management
const finalExtractDir = path.join(path.dirname(dxtFilePath), `server-${manifest.name}`);
// Clean up any existing version of this server
cleanupOldDxtServer(manifest.name);
// Move the temporary directory to the final location
fs.renameSync(tempExtractDir, finalExtractDir);
console.log(`DXT server extracted to: ${finalExtractDir}`);
// Clean up the uploaded DXT file
fs.unlinkSync(dxtFilePath);
const response: ApiResponse = {
success: true,
data: {
manifest,
extractDir: finalExtractDir,
},
};
res.json(response);
} catch (extractError) {
// Clean up files on error
if (fs.existsSync(dxtFilePath)) {
fs.unlinkSync(dxtFilePath);
}
if (fs.existsSync(tempExtractDir)) {
fs.rmSync(tempExtractDir, { recursive: true, force: true });
}
throw extractError;
}
} catch (error) {
console.error('DXT upload error:', error);
let message = 'Failed to process DXT file';
if (error instanceof Error) {
message = error.message;
}
res.status(500).json({
success: false,
message,
});
}
};

View File

@@ -3,8 +3,8 @@ import { ApiResponse, AddServerRequest } from '../types/index.js';
import {
getServersInfo,
addServer,
addOrUpdateServer,
removeServer,
updateMcpServer,
notifyToolChanged,
syncToolEmbedding,
toggleServerStatus,
@@ -264,7 +264,7 @@ export const updateServer = async (req: Request, res: Response): Promise<void> =
config.keepAliveInterval = 60000; // Default 60 seconds for SSE servers
}
const result = await updateMcpServer(name, config);
const result = await addOrUpdateServer(name, config, true); // Allow override for updates
if (result.success) {
notifyToolChanged();
res.json({

View File

@@ -36,6 +36,7 @@ import { login, register, getCurrentUser, changePassword } from '../controllers/
import { getAllLogs, clearLogs, streamLogs } from '../controllers/logController.js';
import { getRuntimeConfig, getPublicConfig } from '../controllers/configController.js';
import { callTool } from '../controllers/toolController.js';
import { uploadDxtFile, uploadMiddleware } from '../controllers/dxtController.js';
import { auth } from '../middlewares/auth.js';
const router = express.Router();
@@ -67,6 +68,9 @@ export const initRoutes = (app: express.Application): void => {
// Tool management routes
router.post('/tools/call/:server', callTool);
// DXT upload routes
router.post('/dxt/upload', uploadMiddleware, uploadDxtFile);
// Market routes
router.get('/market/servers', getAllMarketServers);
router.get('/market/servers/search', searchMarketServersByQuery);

View File

@@ -101,6 +101,187 @@ export const syncToolEmbedding = async (serverName: string, toolName: string) =>
// Store all server information
let serverInfos: ServerInfo[] = [];
// Helper function to create transport based on server configuration
const createTransportFromConfig = (name: string, conf: ServerConfig): any => {
let transport;
if (conf.type === 'streamable-http') {
const options: any = {};
if (conf.headers && Object.keys(conf.headers).length > 0) {
options.requestInit = {
headers: conf.headers,
};
}
transport = new StreamableHTTPClientTransport(new URL(conf.url || ''), options);
} else if (conf.url) {
// SSE transport
const options: any = {};
if (conf.headers && Object.keys(conf.headers).length > 0) {
options.eventSourceInit = {
headers: conf.headers,
};
options.requestInit = {
headers: conf.headers,
};
}
transport = new SSEClientTransport(new URL(conf.url), options);
} else if (conf.command && conf.args) {
// Stdio transport
const env: Record<string, string> = {
...(process.env as Record<string, string>),
...replaceEnvVars(conf.env || {}),
};
env['PATH'] = expandEnvVars(process.env.PATH as string) || '';
const settings = loadSettings();
// Add UV_DEFAULT_INDEX and npm_config_registry if needed
if (
settings.systemConfig?.install?.pythonIndexUrl &&
(conf.command === 'uvx' || conf.command === 'uv' || conf.command === 'python')
) {
env['UV_DEFAULT_INDEX'] = settings.systemConfig.install.pythonIndexUrl;
}
if (
settings.systemConfig?.install?.npmRegistry &&
(conf.command === 'npm' ||
conf.command === 'npx' ||
conf.command === 'pnpm' ||
conf.command === 'yarn' ||
conf.command === 'node')
) {
env['npm_config_registry'] = settings.systemConfig.install.npmRegistry;
}
transport = new StdioClientTransport({
command: conf.command,
args: conf.args,
env: env,
stderr: 'pipe',
});
transport.stderr?.on('data', (data) => {
console.log(`[${name}] [child] ${data}`);
});
} else {
throw new Error(`Unable to create transport for server: ${name}`);
}
return transport;
};
// Helper function to handle client.callTool with reconnection logic
const callToolWithReconnect = async (
serverInfo: ServerInfo,
toolParams: any,
options?: any,
maxRetries: number = 1,
): Promise<any> => {
if (!serverInfo.client) {
throw new Error(`Client not found for server: ${serverInfo.name}`);
}
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const result = await serverInfo.client.callTool(toolParams, undefined, options || {});
return result;
} catch (error: any) {
// Check if error message starts with "Error POSTing to endpoint (HTTP 40"
const isHttp40xError = error?.message?.startsWith?.('Error POSTing to endpoint (HTTP 40');
// Only retry for StreamableHTTPClientTransport
const isStreamableHttp = serverInfo.transport instanceof StreamableHTTPClientTransport;
if (isHttp40xError && attempt < maxRetries && serverInfo.transport && isStreamableHttp) {
console.warn(
`HTTP 40x error detected for StreamableHTTP server ${serverInfo.name}, attempting reconnection (attempt ${attempt + 1}/${maxRetries + 1})`,
);
try {
// Close existing connection
if (serverInfo.keepAliveIntervalId) {
clearInterval(serverInfo.keepAliveIntervalId);
serverInfo.keepAliveIntervalId = undefined;
}
serverInfo.client.close();
serverInfo.transport.close();
// Get server configuration to recreate transport
const settings = loadSettings();
const conf = settings.mcpServers[serverInfo.name];
if (!conf) {
throw new Error(`Server configuration not found for: ${serverInfo.name}`);
}
// Recreate transport using helper function
const newTransport = createTransportFromConfig(serverInfo.name, conf);
// Create new client
const client = new Client(
{
name: `mcp-client-${serverInfo.name}`,
version: '1.0.0',
},
{
capabilities: {
prompts: {},
resources: {},
tools: {},
},
},
);
// Reconnect with new transport
await client.connect(newTransport, serverInfo.options || {});
// Update server info with new client and transport
serverInfo.client = client;
serverInfo.transport = newTransport;
serverInfo.status = 'connected';
// Reload tools list after reconnection
try {
const tools = await client.listTools({}, serverInfo.options || {});
serverInfo.tools = tools.tools.map((tool) => ({
name: `${serverInfo.name}-${tool.name}`,
description: tool.description || '',
inputSchema: tool.inputSchema || {},
}));
// Save tools as vector embeddings for search
saveToolsAsVectorEmbeddings(serverInfo.name, serverInfo.tools);
} catch (listToolsError) {
console.warn(
`Failed to reload tools after reconnection for server ${serverInfo.name}:`,
listToolsError,
);
// Continue anyway, as the connection might still work for the current tool
}
console.log(`Successfully reconnected to server: ${serverInfo.name}`);
// Continue to next attempt
continue;
} catch (reconnectError) {
console.error(`Failed to reconnect to server ${serverInfo.name}:`, reconnectError);
serverInfo.status = 'disconnected';
serverInfo.error = `Failed to reconnect: ${reconnectError}`;
// If this was the last attempt, throw the original error
if (attempt === maxRetries) {
throw error;
}
}
} else {
// Not an HTTP 40x error or no more retries, throw the original error
throw error;
}
}
}
// This should not be reached, but just in case
throw new Error('Unexpected error in callToolWithReconnect');
};
// Initialize MCP server clients
export const initializeClientsFromSettings = async (isInit: boolean): Promise<ServerInfo[]> => {
const settings = loadSettings();
@@ -154,21 +335,21 @@ export const initializeClientsFromSettings = async (isInit: boolean): Promise<Se
continue;
}
// Create server info first and keep reference to it
const serverInfo: ServerInfo = {
name,
status: 'connecting',
error: null,
tools: [],
createTime: Date.now(),
enabled: conf.enabled === undefined ? true : conf.enabled,
};
serverInfos.push(serverInfo);
try {
// Create OpenAPI client instance
openApiClient = new OpenAPIClient(conf);
// Add server with connecting status first
const serverInfo: ServerInfo = {
name,
status: 'connecting',
error: null,
tools: [],
createTime: Date.now(),
enabled: conf.enabled === undefined ? true : conf.enabled,
};
serverInfos.push(serverInfo);
console.log(`Initializing OpenAPI server: ${name}...`);
// Perform async initialization
@@ -197,91 +378,13 @@ export const initializeClientsFromSettings = async (isInit: boolean): Promise<Se
} catch (error) {
console.error(`Failed to initialize OpenAPI server ${name}:`, error);
// Find and update the server info if it was already added
const existingServerIndex = serverInfos.findIndex((s) => s.name === name);
if (existingServerIndex !== -1) {
serverInfos[existingServerIndex].status = 'disconnected';
serverInfos[existingServerIndex].error = `Failed to initialize OpenAPI server: ${error}`;
} else {
// Add new server info with error status
serverInfos.push({
name,
status: 'disconnected',
error: `Failed to initialize OpenAPI server: ${error}`,
tools: [],
createTime: Date.now(),
});
}
// Update the already pushed server info with error status
serverInfo.status = 'disconnected';
serverInfo.error = `Failed to initialize OpenAPI server: ${error}`;
continue;
}
} else if (conf.type === 'streamable-http') {
const options: any = {};
if (conf.headers && Object.keys(conf.headers).length > 0) {
options.requestInit = {
headers: conf.headers,
};
}
transport = new StreamableHTTPClientTransport(new URL(conf.url || ''), options);
} else if (conf.url) {
// Default to SSE only when 'conf.type' is not specified and 'conf.url' is available
const options: any = {};
if (conf.headers && Object.keys(conf.headers).length > 0) {
options.eventSourceInit = {
headers: conf.headers,
};
options.requestInit = {
headers: conf.headers,
};
}
transport = new SSEClientTransport(new URL(conf.url), options);
} else if (conf.command && conf.args) {
// If type is stdio or if command and args are provided without type
const env: Record<string, string> = {
...(process.env as Record<string, string>), // Inherit all environment variables from parent process
...replaceEnvVars(conf.env || {}), // Override with configured env vars
};
env['PATH'] = expandEnvVars(process.env.PATH as string) || '';
// 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' || 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 === 'pnpm' ||
conf.command === 'yarn' ||
conf.command === 'node')
) {
env['npm_config_registry'] = settings.systemConfig.install.npmRegistry;
}
transport = new StdioClientTransport({
command: conf.command,
args: conf.args,
env: env,
stderr: 'pipe',
});
transport.stderr?.on('data', (data) => {
console.log(`[${name}] [child] ${data}`);
});
} else {
console.warn(`Skipping server '${name}': missing required configuration`);
serverInfos.push({
name,
status: 'disconnected',
error: 'Missing required configuration',
tools: [],
createTime: Date.now(),
});
continue;
transport = createTransportFromConfig(name, conf);
}
const client = new Client(
@@ -312,6 +415,19 @@ export const initializeClientsFromSettings = async (isInit: boolean): Promise<Se
maxTotalTimeout: serverRequestOptions.maxTotalTimeout,
};
// Create server info first and keep reference to it
const serverInfo: ServerInfo = {
name,
status: 'connecting',
error: null,
tools: [],
client,
transport,
options: requestOptions,
createTime: Date.now(),
};
serverInfos.push(serverInfo);
client
.connect(transport, initRequestOptions || requestOptions)
.then(() => {
@@ -320,11 +436,6 @@ export const initializeClientsFromSettings = async (isInit: boolean): Promise<Se
.listTools({}, initRequestOptions || requestOptions)
.then((tools) => {
console.log(`Successfully listed ${tools.tools.length} tools for server: ${name}`);
const serverInfo = getServerByName(name);
if (!serverInfo) {
console.warn(`Server info not found for server: ${name}`);
return;
}
serverInfo.tools = tools.tools.map((tool) => ({
name: `${name}-${tool.name}`,
@@ -344,33 +455,17 @@ export const initializeClientsFromSettings = async (isInit: boolean): Promise<Se
console.error(
`Failed to list tools for server ${name} by error: ${error} with stack: ${error.stack}`,
);
const serverInfo = getServerByName(name);
if (serverInfo) {
serverInfo.status = 'disconnected';
serverInfo.error = `Failed to list tools: ${error.stack} `;
}
serverInfo.status = 'disconnected';
serverInfo.error = `Failed to list tools: ${error.stack} `;
});
})
.catch((error) => {
console.error(
`Failed to connect client for server ${name} by error: ${error} with stack: ${error.stack}`,
);
const serverInfo = getServerByName(name);
if (serverInfo) {
serverInfo.status = 'disconnected';
serverInfo.error = `Failed to connect: ${error.stack} `;
}
serverInfo.status = 'disconnected';
serverInfo.error = `Failed to connect: ${error.stack} `;
});
serverInfos.push({
name,
status: 'connecting',
error: null,
tools: [],
client,
transport,
options: requestOptions,
createTime: Date.now(),
});
console.log(`Initialized client for server: ${name}`);
}
@@ -513,6 +608,42 @@ export const updateMcpServer = async (
}
};
// Add or update server (supports overriding existing servers for DXT)
export const addOrUpdateServer = async (
name: string,
config: ServerConfig,
allowOverride: boolean = false,
): Promise<{ success: boolean; message?: string }> => {
try {
const settings = loadSettings();
const exists = !!settings.mcpServers[name];
if (exists && !allowOverride) {
return { success: false, message: 'Server name already exists' };
}
// If overriding and this is a DXT server (stdio type with file paths),
// we might want to clean up old files in the future
if (exists && config.type === 'stdio') {
// Close existing server connections
closeServer(name);
// Remove from server infos
serverInfos = serverInfos.filter((serverInfo) => serverInfo.name !== name);
}
settings.mcpServers[name] = config;
if (!saveSettings(settings)) {
return { success: false, message: 'Failed to save settings' };
}
const action = exists ? 'updated' : 'added';
return { success: true, message: `Server ${action} successfully` };
} catch (error) {
console.error(`Failed to add/update server: ${name}`, error);
return { success: false, message: 'Failed to add/update server' };
}
};
// Close server client and transport
function closeServer(name: string) {
const serverInfo = serverInfos.find((serverInfo) => serverInfo.name === name);
@@ -866,12 +997,12 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
toolName = toolName.startsWith(`${targetServerInfo.name}-`)
? toolName.replace(`${targetServerInfo.name}-`, '')
: toolName;
const result = await client.callTool(
const result = await callToolWithReconnect(
targetServerInfo,
{
name: toolName,
arguments: finalArgs,
},
undefined,
targetServerInfo.options || {},
);
@@ -921,7 +1052,11 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
request.params.name = request.params.name.startsWith(`${serverInfo.name}-`)
? request.params.name.replace(`${serverInfo.name}-`, '')
: request.params.name;
const result = await client.callTool(request.params, undefined, serverInfo.options || {});
const result = await callToolWithReconnect(
serverInfo,
request.params,
serverInfo.options || {},
);
console.log(`Tool call result: ${JSON.stringify(result)}`);
return result;
} catch (error) {