Fix MCP delete/update methods and add frontend sync functionality
- Fix MCP server update_element method parameter parsing issue - Add comprehensive delete operation debugging and validation - Implement frontend sync button with real-time status feedback - Add elements sync API endpoint (/api/elements/sync) for manual synchronization - Enhance WebSocket message handling with proper element validation - Add binding validation and cleanup for Excalidraw elements - Improve error handling and logging throughout the sync process - Add sync status tracking and user feedback in the UI
This commit is contained in:
@@ -231,6 +231,50 @@
|
||||
.loading-content div:first-child {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* Sync Controls Styles */
|
||||
.sync-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn-loading {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid #ffffff40;
|
||||
border-top: 2px solid #ffffff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.sync-status {
|
||||
font-size: 12px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.sync-success {
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.sync-error {
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.sync-time {
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+247
-9
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { Excalidraw, convertToExcalidrawElements } from '@excalidraw/excalidraw'
|
||||
import { Excalidraw, convertToExcalidrawElements, CaptureUpdateAction } from '@excalidraw/excalidraw'
|
||||
import '@excalidraw/excalidraw/index.css'
|
||||
|
||||
// Helper function to clean elements for Excalidraw
|
||||
@@ -13,10 +13,62 @@ const cleanElementForExcalidraw = (element) => {
|
||||
return cleanElement;
|
||||
}
|
||||
|
||||
// Helper function to validate and fix element binding data
|
||||
const validateAndFixBindings = (elements) => {
|
||||
const elementMap = new Map(elements.map(el => [el.id, el]));
|
||||
|
||||
return elements.map(element => {
|
||||
const fixedElement = { ...element };
|
||||
|
||||
// Validate and fix boundElements
|
||||
if (fixedElement.boundElements) {
|
||||
if (Array.isArray(fixedElement.boundElements)) {
|
||||
fixedElement.boundElements = fixedElement.boundElements.filter(binding => {
|
||||
// Ensure binding has required properties
|
||||
if (!binding || typeof binding !== 'object') return false;
|
||||
if (!binding.id || !binding.type) return false;
|
||||
|
||||
// Ensure the referenced element exists
|
||||
const referencedElement = elementMap.get(binding.id);
|
||||
if (!referencedElement) return false;
|
||||
|
||||
// Validate binding type
|
||||
if (!['text', 'arrow'].includes(binding.type)) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Remove boundElements if empty
|
||||
if (fixedElement.boundElements.length === 0) {
|
||||
fixedElement.boundElements = null;
|
||||
}
|
||||
} else {
|
||||
// Invalid boundElements format, set to null
|
||||
fixedElement.boundElements = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate and fix containerId
|
||||
if (fixedElement.containerId) {
|
||||
const containerElement = elementMap.get(fixedElement.containerId);
|
||||
if (!containerElement) {
|
||||
// Container doesn't exist, remove containerId
|
||||
fixedElement.containerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
return fixedElement;
|
||||
});
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [excalidrawAPI, setExcalidrawAPI] = useState(null)
|
||||
const [isConnected, setIsConnected] = useState(false)
|
||||
const websocketRef = useRef(null)
|
||||
|
||||
// Sync state management
|
||||
const [syncStatus, setSyncStatus] = useState('idle') // idle, syncing, success, error
|
||||
const [lastSyncTime, setLastSyncTime] = useState(null)
|
||||
|
||||
// WebSocket connection
|
||||
useEffect(() => {
|
||||
@@ -97,6 +149,38 @@ function App() {
|
||||
}
|
||||
}
|
||||
|
||||
// 调试函数:记录删除操作详情
|
||||
const debugDeleteOperation = (operation, data, additionalInfo = {}) => {
|
||||
const timestamp = new Date().toISOString()
|
||||
const debugInfo = {
|
||||
operation,
|
||||
timestamp,
|
||||
data,
|
||||
elementCount: excalidrawAPI?.getSceneElements()?.length || 0,
|
||||
...additionalInfo
|
||||
}
|
||||
|
||||
console.group(`🔍 DELETE DEBUG: ${operation}`)
|
||||
console.log('详细信息:', debugInfo)
|
||||
|
||||
if (operation === 'websocket_received' && data.type === 'element_deleted') {
|
||||
const currentElements = excalidrawAPI?.getSceneElements() || []
|
||||
const targetElement = currentElements.find(el => el.id === data.elementId)
|
||||
|
||||
console.log('目标元素存在:', !!targetElement)
|
||||
console.log('当前元素ID列表:', currentElements.map(el => el.id))
|
||||
console.log('要删除的ID:', data.elementId)
|
||||
console.log('ID类型匹配检查:', currentElements.map(el => ({
|
||||
id: el.id,
|
||||
type: typeof el.id,
|
||||
matches: el.id === data.elementId,
|
||||
strictEquals: el.id === data.elementId
|
||||
})))
|
||||
}
|
||||
|
||||
console.groupEnd()
|
||||
}
|
||||
|
||||
const handleWebSocketMessage = (data) => {
|
||||
if (!excalidrawAPI) {
|
||||
return
|
||||
@@ -104,13 +188,20 @@ function App() {
|
||||
|
||||
try {
|
||||
const currentElements = excalidrawAPI.getSceneElements()
|
||||
console.log('当前元素:', currentElements);
|
||||
|
||||
switch (data.type) {
|
||||
case 'initial_elements':
|
||||
if (data.elements && data.elements.length > 0) {
|
||||
const cleanedElements = data.elements.map(cleanElementForExcalidraw)
|
||||
const convertedElements = convertToExcalidrawElements(cleanedElements)
|
||||
excalidrawAPI.updateScene({ elements: convertedElements })
|
||||
const validatedElements = validateAndFixBindings(cleanedElements)
|
||||
const convertedElements = convertToExcalidrawElements(validatedElements)
|
||||
excalidrawAPI.updateScene({
|
||||
elements: convertedElements,
|
||||
captureUpdate: CaptureUpdateAction.NEVER
|
||||
})
|
||||
console.log('Loaded initial elements with validated bindings:', convertedElements)
|
||||
debugElementBindings(convertedElements)
|
||||
}
|
||||
break
|
||||
|
||||
@@ -118,7 +209,10 @@ function App() {
|
||||
const cleanedNewElement = cleanElementForExcalidraw(data.element)
|
||||
const newElement = convertToExcalidrawElements([cleanedNewElement])
|
||||
const updatedElementsAfterCreate = [...currentElements, ...newElement]
|
||||
excalidrawAPI.updateScene({ elements: updatedElementsAfterCreate })
|
||||
excalidrawAPI.updateScene({
|
||||
elements: updatedElementsAfterCreate,
|
||||
captureUpdate: CaptureUpdateAction.NEVER
|
||||
})
|
||||
break
|
||||
|
||||
case 'element_updated':
|
||||
@@ -127,19 +221,46 @@ function App() {
|
||||
const updatedElements = currentElements.map(el =>
|
||||
el.id === data.element.id ? convertedUpdatedElement : el
|
||||
)
|
||||
excalidrawAPI.updateScene({ elements: updatedElements })
|
||||
excalidrawAPI.updateScene({
|
||||
elements: updatedElements,
|
||||
captureUpdate: CaptureUpdateAction.NEVER
|
||||
})
|
||||
break
|
||||
|
||||
case 'element_deleted':
|
||||
debugDeleteOperation('websocket_received', data)
|
||||
const filteredElements = currentElements.filter(el => el.id !== data.elementId)
|
||||
excalidrawAPI.updateScene({ elements: filteredElements })
|
||||
debugDeleteOperation('after_filter', data, {
|
||||
originalCount: currentElements.length,
|
||||
filteredCount: filteredElements.length,
|
||||
actuallyRemoved: currentElements.length - filteredElements.length
|
||||
})
|
||||
excalidrawAPI.updateScene({
|
||||
elements: filteredElements,
|
||||
captureUpdate: CaptureUpdateAction.NEVER
|
||||
})
|
||||
debugDeleteOperation('after_update_scene', data, {
|
||||
finalElementCount: excalidrawAPI.getSceneElements().length
|
||||
})
|
||||
break
|
||||
|
||||
case 'elements_batch_created':
|
||||
const cleanedBatchElements = data.elements.map(cleanElementForExcalidraw)
|
||||
const batchElements = convertToExcalidrawElements(cleanedBatchElements)
|
||||
const updatedElementsAfterBatch = [...currentElements, ...batchElements]
|
||||
excalidrawAPI.updateScene({ elements: updatedElementsAfterBatch })
|
||||
excalidrawAPI.updateScene({
|
||||
elements: updatedElementsAfterBatch,
|
||||
captureUpdate: CaptureUpdateAction.NEVER
|
||||
})
|
||||
break
|
||||
|
||||
case 'elements_synced':
|
||||
console.log(`Sync confirmed by server: ${data.count} elements`)
|
||||
// Sync confirmation already handled by HTTP response
|
||||
break
|
||||
|
||||
case 'sync_status':
|
||||
console.log(`Server sync status: ${data.elementCount} elements`)
|
||||
break
|
||||
|
||||
default:
|
||||
@@ -150,6 +271,89 @@ function App() {
|
||||
}
|
||||
}
|
||||
|
||||
// Data format conversion for backend
|
||||
const convertToBackendFormat = (element) => {
|
||||
return {
|
||||
...element
|
||||
}
|
||||
}
|
||||
|
||||
// Format sync time display
|
||||
const formatSyncTime = (time) => {
|
||||
if (!time) return ''
|
||||
return time.toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// Main sync function
|
||||
const syncToBackend = async () => {
|
||||
if (!excalidrawAPI) {
|
||||
console.warn('Excalidraw API not available')
|
||||
return
|
||||
}
|
||||
|
||||
setSyncStatus('syncing')
|
||||
|
||||
try {
|
||||
// 1. Get current elements
|
||||
const currentElements = excalidrawAPI.getSceneElements()
|
||||
console.log(`Syncing ${currentElements.length} elements to backend`)
|
||||
|
||||
// 2. Filter out deleted elements
|
||||
const activeElements = currentElements.filter(el => !el.isDeleted)
|
||||
|
||||
// 3. Convert to backend format
|
||||
const backendElements = activeElements.map(convertToBackendFormat)
|
||||
|
||||
// 4. Send to backend
|
||||
const response = await fetch('/api/elements/sync', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
elements: backendElements,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json()
|
||||
setSyncStatus('success')
|
||||
setLastSyncTime(new Date())
|
||||
console.log(`Sync successful: ${result.count} elements synced`)
|
||||
|
||||
// Reset status after 2 seconds
|
||||
setTimeout(() => setSyncStatus('idle'), 2000)
|
||||
} else {
|
||||
const error = await response.json()
|
||||
setSyncStatus('error')
|
||||
console.error('Sync failed:', error.error)
|
||||
}
|
||||
} catch (error) {
|
||||
setSyncStatus('error')
|
||||
console.error('Sync error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Debug function to check element bindings
|
||||
const debugElementBindings = (elements) => {
|
||||
console.group('🔍 Element Binding Debug');
|
||||
elements.forEach(element => {
|
||||
if (element.boundElements || element.containerId) {
|
||||
console.log(`Element ${element.id} (${element.type}):`, {
|
||||
boundElements: element.boundElements,
|
||||
containerId: element.containerId,
|
||||
text: element.text || 'N/A'
|
||||
});
|
||||
}
|
||||
});
|
||||
console.groupEnd();
|
||||
}
|
||||
|
||||
const clearCanvas = async () => {
|
||||
if (excalidrawAPI) {
|
||||
try {
|
||||
@@ -165,11 +369,17 @@ function App() {
|
||||
}
|
||||
|
||||
// Clear the frontend canvas
|
||||
excalidrawAPI.updateScene({ elements: [] })
|
||||
excalidrawAPI.updateScene({
|
||||
elements: [],
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error clearing canvas:', error)
|
||||
// Still clear frontend even if backend fails
|
||||
excalidrawAPI.updateScene({ elements: [] })
|
||||
excalidrawAPI.updateScene({
|
||||
elements: [],
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -184,6 +394,34 @@ function App() {
|
||||
<div className={`status-dot ${isConnected ? 'status-connected' : 'status-disconnected'}`}></div>
|
||||
<span>{isConnected ? 'Connected' : 'Disconnected'}</span>
|
||||
</div>
|
||||
|
||||
{/* Sync Controls */}
|
||||
<div className="sync-controls">
|
||||
<button
|
||||
className={`btn-primary ${syncStatus === 'syncing' ? 'btn-loading' : ''}`}
|
||||
onClick={syncToBackend}
|
||||
disabled={syncStatus === 'syncing' || !excalidrawAPI}
|
||||
>
|
||||
{syncStatus === 'syncing' && <span className="spinner"></span>}
|
||||
{syncStatus === 'syncing' ? 'Syncing...' : 'Sync to Backend'}
|
||||
</button>
|
||||
|
||||
{/* Sync Status */}
|
||||
<div className="sync-status">
|
||||
{syncStatus === 'success' && (
|
||||
<span className="sync-success">✅ Synced</span>
|
||||
)}
|
||||
{syncStatus === 'error' && (
|
||||
<span className="sync-error">❌ Sync Failed</span>
|
||||
)}
|
||||
{lastSyncTime && syncStatus === 'idle' && (
|
||||
<span className="sync-time">
|
||||
Last sync: {formatSyncTime(lastSyncTime)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="btn-secondary" onClick={clearCanvas}>Clear Canvas</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -460,7 +460,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
}
|
||||
|
||||
case 'update_element': {
|
||||
const params = ElementSchema.partial().extend(ElementIdSchema).parse(args);
|
||||
const params = ElementIdSchema.merge(ElementSchema.partial()).parse(args);
|
||||
const { id, ...updates } = params;
|
||||
|
||||
if (!id) throw new Error('Element ID is required');
|
||||
|
||||
+108
-1
@@ -57,6 +57,13 @@ wss.on('connection', (ws) => {
|
||||
elements: Array.from(elements.values())
|
||||
}));
|
||||
|
||||
// Send sync status to new client
|
||||
ws.send(JSON.stringify({
|
||||
type: 'sync_status',
|
||||
elementCount: elements.size,
|
||||
timestamp: new Date().toISOString()
|
||||
}));
|
||||
|
||||
ws.on('close', () => {
|
||||
clients.delete(ws);
|
||||
logger.info('WebSocket connection closed');
|
||||
@@ -70,6 +77,7 @@ wss.on('connection', (ws) => {
|
||||
|
||||
// Schema validation
|
||||
const CreateElementSchema = z.object({
|
||||
id: z.string().optional(), // 允许传入ID,用于MCP同步
|
||||
type: z.enum(Object.values(EXCALIDRAW_ELEMENT_TYPES)),
|
||||
x: z.number(),
|
||||
y: z.number(),
|
||||
@@ -134,7 +142,8 @@ app.post('/api/elements', (req, res) => {
|
||||
const params = CreateElementSchema.parse(req.body);
|
||||
logger.info('Creating element via API', { type: params.type });
|
||||
|
||||
const id = generateId();
|
||||
// 优先使用传入的ID(用于MCP同步),否则生成新ID
|
||||
const id = params.id || generateId();
|
||||
const element = {
|
||||
id,
|
||||
...params,
|
||||
@@ -348,6 +357,90 @@ app.post('/api/elements/batch', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Sync elements from frontend (覆盖式同步)
|
||||
app.post('/api/elements/sync', (req, res) => {
|
||||
try {
|
||||
const { elements: frontendElements, timestamp } = req.body;
|
||||
|
||||
logger.info(`Sync request received: ${frontendElements.length} elements`, {
|
||||
timestamp,
|
||||
elementCount: frontendElements.length
|
||||
});
|
||||
|
||||
// 验证输入数据
|
||||
if (!Array.isArray(frontendElements)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Expected elements to be an array'
|
||||
});
|
||||
}
|
||||
|
||||
// 记录同步前的元素数量
|
||||
const beforeCount = elements.size;
|
||||
|
||||
// 1. 清空现有内存存储
|
||||
elements.clear();
|
||||
logger.info(`Cleared existing elements: ${beforeCount} elements removed`);
|
||||
|
||||
// 2. 批量写入新数据
|
||||
let successCount = 0;
|
||||
const processedElements = [];
|
||||
|
||||
frontendElements.forEach((element, index) => {
|
||||
try {
|
||||
// 确保元素有ID,如果没有则生成一个
|
||||
const elementId = element.id || generateId();
|
||||
|
||||
// 添加服务端元数据
|
||||
const processedElement = {
|
||||
...element,
|
||||
id: elementId,
|
||||
syncedAt: new Date().toISOString(),
|
||||
source: 'frontend_sync',
|
||||
syncTimestamp: timestamp,
|
||||
version: 1
|
||||
};
|
||||
|
||||
// 存储到内存
|
||||
elements.set(elementId, processedElement);
|
||||
processedElements.push(processedElement);
|
||||
successCount++;
|
||||
|
||||
} catch (elementError) {
|
||||
logger.warn(`Failed to process element ${index}:`, elementError);
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`Sync completed: ${successCount}/${frontendElements.length} elements synced`);
|
||||
|
||||
// 3. 广播同步事件给所有WebSocket客户端
|
||||
broadcast({
|
||||
type: 'elements_synced',
|
||||
count: successCount,
|
||||
timestamp: new Date().toISOString(),
|
||||
source: 'manual_sync'
|
||||
});
|
||||
|
||||
// 4. 返回同步结果
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Successfully synced ${successCount} elements`,
|
||||
count: successCount,
|
||||
syncedAt: new Date().toISOString(),
|
||||
beforeCount,
|
||||
afterCount: elements.size
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Sync error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message,
|
||||
details: 'Internal server error during sync operation'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Serve the frontend
|
||||
app.get('/', (req, res) => {
|
||||
const htmlFile = path.join(__dirname, '../dist/frontend/index.html');
|
||||
@@ -369,6 +462,20 @@ app.get('/health', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Sync status endpoint
|
||||
app.get('/api/sync/status', (req, res) => {
|
||||
res.json({
|
||||
success: true,
|
||||
elementCount: elements.size,
|
||||
timestamp: new Date().toISOString(),
|
||||
memoryUsage: {
|
||||
heapUsed: Math.round(process.memoryUsage().heapUsed / 1024 / 1024), // MB
|
||||
heapTotal: Math.round(process.memoryUsage().heapTotal / 1024 / 1024), // MB
|
||||
},
|
||||
websocketClients: clients.size
|
||||
});
|
||||
});
|
||||
|
||||
// Error handling middleware
|
||||
app.use((err, req, res, next) => {
|
||||
logger.error('Unhandled error:', err);
|
||||
|
||||
Reference in New Issue
Block a user