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:
songmd
2025-08-06 19:55:25 +08:00
parent 1831a621ac
commit 60c8af9ca8
4 changed files with 400 additions and 11 deletions
+44
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);