Skip to content

Commit a9bde62

Browse files
anatoly314claude
andcommitted
Add persistent AI connection indicator and WebSocket heartbeat
- Add persistent connection status badge in header (green/red indicator) - Implement WebSocket ping/pong heartbeat (30s interval) to prevent idle disconnections - Handle heartbeat messages in backend gateway with pong responses - Configure nginx WebSocket timeouts (5 minutes as safety net) - Enhance CLAUDE.md with debugging commands, MCP tools list, and architecture details Fixes constant reconnection issues caused by proxy timeout on idle connections. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent f86b015 commit a9bde62

File tree

6 files changed

+145
-15
lines changed

6 files changed

+145
-15
lines changed

CLAUDE.md

Lines changed: 81 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,16 @@ pnpm clean # Clean all build artifacts and node_modules
4545
pnpm backend:inspector # Launch MCP Inspector for testing tools (STDIO mode)
4646
```
4747

48+
**Backend-specific commands** (when working in apps/backend):
49+
50+
```bash
51+
npm run inspector:stdio # Run MCP inspector for STDIO mode
52+
npm run inspector:stdio:debug # Run STDIO inspector with debugger on port 9229
53+
npm run inspector:http # Run MCP inspector for HTTP mode
54+
npm run start:debug:stdio # STDIO mode with debugger
55+
npm run start:debug:http # HTTP mode with debugger
56+
```
57+
4858
### Docker
4959

5060
**Run from GHCR:**
@@ -128,9 +138,13 @@ This is a **pnpm workspaces + Turborepo** monorepo (converted from standalone ap
128138
- Frontend connects to backend via WebSocket at `/remote-control`
129139
- Enabled when `VITE_REMOTE_CONTROL_ENABLED=true` (automatically set in Docker builds)
130140
- WebSocket auto-detects URL from browser (supports both direct access and nginx proxy)
131-
- Commands are handled by contexts, responses sent back to backend
132-
- Automatic reconnection with exponential backoff (max 10 attempts, up to 30s delay)
133-
- Connection status displayed via Toast notifications
141+
- Auto-detection: `ws://localhost:5173/remote-control` (dev) or `ws://localhost:8080/remote-control` (Docker)
142+
- Protocol switches to `wss://` for HTTPS connections
143+
- Commands are handled by contexts (`DiagramContext`, `AreasContext`, `NotesContext`, etc.)
144+
- Automatic reconnection with exponential backoff (max 10 attempts, up to 30s delay with jitter)
145+
- Connection status displayed via Toast notifications ("AI Assistant connected/disconnected")
146+
- All commands processed through `useRemoteControl.js:152` switch statement
147+
- Command timeout: 30 seconds (configured in `DrawDBClientService:15`)
134148

135149
### Backend (apps/backend)
136150

@@ -217,17 +231,18 @@ Example: `apps/backend/src/mcp/primitives/essential/tools/add-table.tool.ts`
217231

218232
**Available Commands:**
219233

220-
- `getDiagram()` - Get full diagram state
234+
- `getDiagram()` - Get full diagram state (all entities)
221235
- `addTable()`, `updateTable()`, `deleteTable()` - Table operations
222236
- `addField()`, `updateField()`, `deleteField()` - Field operations
223237
- `addRelationship()`, `updateRelationship()`, `deleteRelationship()` - Relationship operations
224238
- `addArea()`, `updateArea()`, `deleteArea()` - Area (grouping) operations
225239
- `addNote()`, `updateNote()`, `deleteNote()` - Note operations
226240
- `addEnum()`, `updateEnum()`, `deleteEnum()` - PostgreSQL ENUM type operations
227241
- `addType()`, `updateType()`, `deleteType()` - PostgreSQL composite type operations
228-
- `getTables()`, `getTable()`, `getRelationships()`, `getAreas()`, `getNotes()`, `getEnums()`, `getTypes()` - Query operations
229-
- `setDatabase()` - Set database type (MySQL, PostgreSQL, SQLite, etc.)
230-
- `importDiagram()` - Import complete diagram JSON
242+
- `getTables()`, `getTable()`, `getRelationships()`, `getAreas()`, `getNotes()`, `getEnums()`, `getTypes()` - Query operations (read-only)
243+
- `setDatabase()` - Set database type (MySQL, PostgreSQL, SQLite, MSSQL, MariaDB)
244+
- `importDiagram()` - Import complete diagram JSON (replaces current diagram)
245+
- `exportDiagram()` - Export diagram to SQL, JSON, or other formats (available via MCP tool)
231246

232247
See `apps/backend/src/drawdb/drawdb-client.service.ts` and `apps/gui/src/hooks/useRemoteControl.js` for full command list.
233248

@@ -292,6 +307,51 @@ node dist/main-http.js --port 3000 --host 127.0.0.1
292307

293308
## Development Tips
294309

310+
### Available MCP Tools
311+
312+
The backend exposes the following MCP tools (located in `apps/backend/src/mcp/primitives/essential/tools/`):
313+
314+
**Table Tools:**
315+
- `add-table` - Create new table with fields
316+
- `update-table` - Modify table properties (name, color, position, comment)
317+
- `delete-table` - Remove table from diagram
318+
- `get-table` - Retrieve single table by ID or name
319+
320+
**Field Tools:**
321+
- `add-field` - Add field to existing table
322+
- `update-field` - Modify field properties (type, constraints, etc.)
323+
- `delete-field` - Remove field from table
324+
325+
**Relationship Tools:**
326+
- `add-relationship` - Create relationship between tables
327+
- `update-relationship` - Modify relationship properties
328+
- `delete-relationship` - Remove relationship
329+
330+
**Area Tools:**
331+
- `add-area` - Create grouping area for tables
332+
- `update-area` - Modify area properties (name, size, color, position)
333+
- `delete-area` - Remove area
334+
335+
**Note Tools:**
336+
- `add-note` - Create text note with Lexical editor content
337+
- `update-note` - Modify note content or properties
338+
- `delete-note` - Remove note
339+
340+
**PostgreSQL Type Tools:**
341+
- `add-enum` - Create ENUM type with values
342+
- `update-enum` - Modify ENUM values
343+
- `delete-enum` - Remove ENUM type
344+
- `add-type` - Create composite type with fields
345+
- `update-type` - Modify composite type
346+
- `delete-type` - Remove composite type
347+
348+
**Diagram Tools:**
349+
- `get-diagram` - Retrieve entire diagram state (all entities)
350+
- `import-diagram` - Replace diagram with JSON data
351+
- `export-diagram` - Export to SQL, JSON, or other formats
352+
353+
Each tool validates input using Zod schemas and communicates with the frontend via WebSocket.
354+
295355
### Adding a New MCP Tool
296356

297357
1. Create `apps/backend/src/mcp/primitives/essential/tools/your-tool.tool.ts`
@@ -305,7 +365,13 @@ node dist/main-http.js --port 3000 --host 127.0.0.1
305365
1. Start backend: `pnpm backend:dev`
306366
2. Start frontend: `pnpm gui:dev`
307367
3. Open browser console, check for "DrawDB client connected" in backend logs
308-
4. Use MCP Inspector to test tools: `pnpm backend:inspector` (requires separate STDIO mode)
368+
4. Use MCP Inspector to test tools: `pnpm backend:inspector` (STDIO mode)
369+
370+
**For debugging with breakpoints:**
371+
372+
1. Run `npm run inspector:stdio:debug` in `apps/backend` directory
373+
2. Attach your IDE debugger to port 9229
374+
3. Inspector pauses at startup waiting for debugger attachment
309375

310376
### Debugging Docker Build
311377

@@ -335,12 +401,14 @@ function MyComponent() {
335401
### Common Gotchas
336402

337403
- **WebSocket connection fails**: Check that `VITE_REMOTE_CONTROL_ENABLED=true` and backend is running
338-
- **MCP tools timeout**: Default timeout is 30s (configured in `DrawDBClientService:16`)
404+
- **MCP tools timeout**: Default timeout is 30s (configured in `DrawDBClientService:15`)
339405
- **Docker nginx issues**: Nginx runs as non-root user `nodejs:nodejs`, requires proper permissions
340406
- **Build failures**: Run `pnpm clean` then `pnpm install` to reset
341407
- **Turborepo cache issues**: Delete `.turbo` directory to clear cache
342-
- **Area/Note deletion**: Areas and notes use numeric array indices internally but are looked up by `name`/`title` for MCP operations
343-
- **Field IDs**: All entities (tables, fields, relationships) use `nanoid()` for ID generation
408+
- **Area/Note lookup**: Areas and notes use numeric IDs internally but MCP tools look them up by `name`/`title`. When deleting, the ID is converted to integer for array lookup (`parseInt(params.id, 10)`)
409+
- **Entity IDs**: All entities (tables, fields, relationships, enums, types) use `nanoid()` for unique ID generation (imported from `nanoid` package)
410+
- **Remote control enabled check**: Frontend checks `import.meta.env.VITE_REMOTE_CONTROL_ENABLED` to enable WebSocket connection (see `useRemoteControl.js:34`)
411+
- **Command ID format**: Backend generates unique command IDs using `cmd_${timestamp}_${random}` pattern for request/response matching
344412

345413
## CI/CD and Deployment
346414

@@ -363,9 +431,9 @@ function MyComponent() {
363431

364432
### Version Management
365433

366-
- Backend version: `apps/backend/package.json` (currently 1.1.2)
434+
- Backend version: `apps/backend/package.json` (currently 1.1.4)
367435
- GUI version: `apps/gui/package.json` (currently 1.0.0)
368-
- Root package: `package.json` (workspace root)
436+
- Root package: `package.json` (workspace root, 1.0.0)
369437

370438
## Git Workflow
371439

apps/backend/src/drawdb/drawdb-client.service.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,17 @@ export class DrawDBClientService {
2424
// Setup message handler
2525
ws.on('message', (data: string) => {
2626
try {
27-
const response: DrawDBResponse = JSON.parse(data);
27+
const message = JSON.parse(data);
28+
29+
// Handle ping/pong heartbeat
30+
if (message.type === 'ping') {
31+
this.logger.debug('Received ping, sending pong');
32+
ws.send(JSON.stringify({ type: 'pong' }));
33+
return;
34+
}
35+
36+
// Handle normal command responses
37+
const response: DrawDBResponse = message;
2838
this.handleResponse(response);
2939
} catch (error) {
3040
this.logger.error('Failed to parse message from DrawDB client:', error);

apps/gui/src/components/EditorHeader/ControlPanel.jsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ export default function ControlPanel({
9292
title,
9393
setTitle,
9494
lastSaved,
95+
aiAssistantConnected,
9596
}) {
9697
const [modal, setModal] = useState(MODAL.NONE);
9798
const [sidesheet, setSidesheet] = useState(SIDESHEET.NONE);
@@ -2040,6 +2041,21 @@ export default function ControlPanel({
20402041
{getState()}
20412042
</Tag>
20422043
)}
2044+
{aiAssistantConnected !== null && (
2045+
<Tooltip content={aiAssistantConnected ? "AI Assistant connected and ready" : "AI Assistant disconnected"} position="bottom">
2046+
<Tag
2047+
size="small"
2048+
color={aiAssistantConnected ? "green" : "red"}
2049+
prefixIcon={
2050+
<span style={{ fontSize: '10px' }}>
2051+
{aiAssistantConnected ? '●' : '○'}
2052+
</span>
2053+
}
2054+
>
2055+
{aiAssistantConnected ? "AI Connected" : "AI Disconnected"}
2056+
</Tag>
2057+
</Tooltip>
2058+
)}
20432059
</div>
20442060
</div>
20452061
</div>

apps/gui/src/components/Workspace.jsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ export default function WorkSpace() {
7272

7373
// Enable remote control if VITE_REMOTE_CONTROL_ENABLED is set
7474
const remoteControlEnabled = import.meta.env.VITE_REMOTE_CONTROL_ENABLED === "true";
75-
useRemoteControl(remoteControlEnabled);
75+
const { isConnected: aiAssistantConnected } = useRemoteControl(remoteControlEnabled);
7676
const handleResize = (e) => {
7777
if (!resize) return;
7878
const w = isRtl(i18n.language) ? window.innerWidth - e.clientX : e.clientX;
@@ -436,6 +436,7 @@ export default function WorkSpace() {
436436
setTitle={setTitle}
437437
lastSaved={lastSaved}
438438
setLastSaved={setLastSaved}
439+
aiAssistantConnected={remoteControlEnabled ? aiAssistantConnected : null}
439440
/>
440441
</IdContext.Provider>
441442
<div

apps/gui/src/hooks/useRemoteControl.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export function useRemoteControl(enabled = false) {
1515
const wsRef = useRef(null);
1616
const reconnectTimeoutRef = useRef(null);
1717
const reconnectAttemptsRef = useRef(0);
18+
const pingIntervalRef = useRef(null);
1819
const [isConnected, setIsConnected] = useState(false);
1920

2021
const diagram = useContext(DiagramContext);
@@ -41,6 +42,10 @@ export function useRemoteControl(enabled = false) {
4142
clearTimeout(reconnectTimeoutRef.current);
4243
reconnectTimeoutRef.current = null;
4344
}
45+
if (pingIntervalRef.current) {
46+
clearInterval(pingIntervalRef.current);
47+
pingIntervalRef.current = null;
48+
}
4449
setIsConnected(false);
4550
return;
4651
}
@@ -77,11 +82,26 @@ export function useRemoteControl(enabled = false) {
7782
} else {
7883
Toast.success("AI Assistant connected");
7984
}
85+
86+
// Start heartbeat ping every 30 seconds to keep connection alive
87+
pingIntervalRef.current = setInterval(() => {
88+
if (wsRef.current?.readyState === WebSocket.OPEN) {
89+
wsRef.current.send(JSON.stringify({ type: "ping" }));
90+
console.log("[RemoteControl] Sent ping");
91+
}
92+
}, 30000); // 30 seconds
8093
};
8194

8295
ws.onmessage = (event) => {
8396
try {
8497
const message = JSON.parse(event.data);
98+
99+
// Handle pong response
100+
if (message.type === "pong") {
101+
console.log("[RemoteControl] Received pong");
102+
return;
103+
}
104+
85105
handleCommand(message);
86106
} catch (error) {
87107
console.error("[RemoteControl] Failed to parse message:", error);
@@ -98,6 +118,12 @@ export function useRemoteControl(enabled = false) {
98118
setIsConnected(false);
99119
wsRef.current = null;
100120

121+
// Clear ping interval
122+
if (pingIntervalRef.current) {
123+
clearInterval(pingIntervalRef.current);
124+
pingIntervalRef.current = null;
125+
}
126+
101127
// Only attempt to reconnect if enabled and haven't exceeded max attempts
102128
if (enabled && reconnectAttemptsRef.current < maxReconnectAttempts) {
103129
reconnectAttemptsRef.current++;
@@ -139,6 +165,10 @@ export function useRemoteControl(enabled = false) {
139165
clearTimeout(reconnectTimeoutRef.current);
140166
reconnectTimeoutRef.current = null;
141167
}
168+
if (pingIntervalRef.current) {
169+
clearInterval(pingIntervalRef.current);
170+
pingIntervalRef.current = null;
171+
}
142172
setIsConnected(false);
143173
};
144174
}, [enabled]);

docker/nginx.conf

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,5 +23,10 @@ server {
2323
proxy_set_header Upgrade $http_upgrade;
2424
proxy_set_header Connection "upgrade";
2525
proxy_set_header Host $host;
26+
27+
# WebSocket timeout configuration
28+
# Set to 5 minutes as safety net (ping interval is 30s)
29+
proxy_read_timeout 300s;
30+
proxy_send_timeout 300s;
2631
}
2732
}

0 commit comments

Comments
 (0)