Skip to content

Commit a9079cc

Browse files
committed
feat: implement real-time collaborative workspace with WebRTC voice, Yjs-backed code editor, and socket-based synchronization
1 parent 98b1399 commit a9079cc

3 files changed

Lines changed: 227 additions & 5 deletions

File tree

client/src/pages/CodeEditor.jsx

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ export default function CodeEditor() {
121121
setCode(BOILERPLATES[lang]);
122122
};
123123

124-
// Intercept Tab key for insertion of 4 spaces
124+
// Intercept Tab key and Ctrl+Space for AI Autocomplete
125125
const handleKeyDown = (e) => {
126126
if (e.key === 'Tab') {
127127
e.preventDefault();
@@ -136,6 +136,9 @@ export default function CodeEditor() {
136136
textareaRef.current.selectionStart = textareaRef.current.selectionEnd = start + 4;
137137
}
138138
}, 0);
139+
} else if (e.key === ' ' && e.ctrlKey) {
140+
e.preventDefault();
141+
triggerInlineCompletion();
139142
}
140143
};
141144

@@ -298,6 +301,59 @@ Provide instructions and modified code structure to achieve this request.`;
298301
}
299302
};
300303

304+
// Hotkey triggered AI Autocomplete continuation
305+
const triggerInlineCompletion = async () => {
306+
if (!aiIntellisense || !textareaRef.current) return;
307+
308+
const cursor = textareaRef.current.selectionStart;
309+
const textBefore = code.substring(0, cursor);
310+
const textAfter = code.substring(cursor);
311+
312+
setTerminalOutput(prev => [
313+
...prev,
314+
{ type: 'info', text: 'AI Copilot: Suggesting code completion...' }
315+
]);
316+
317+
const prompt = `You are an inline code auto-complete assistant.
318+
Given the code prefix and suffix, return ONLY the direct next few lines of code to continue the logic. Do not write explanations or wrap the code in markdown blocks.
319+
320+
Code Prefix:
321+
${textBefore}
322+
323+
Code Suffix:
324+
${textAfter}
325+
326+
Completion:`;
327+
328+
try {
329+
const completion = await callLlm(prompt);
330+
// Clean markdown blocks if LLM wrapped it
331+
let cleanCompletion = completion.trim();
332+
if (cleanCompletion.startsWith('```')) {
333+
cleanCompletion = cleanCompletion.replace(/```[a-z]*\n/, '').replace(/```$/, '');
334+
}
335+
336+
const newVal = textBefore + cleanCompletion + textAfter;
337+
setCode(newVal);
338+
339+
setTimeout(() => {
340+
if (textareaRef.current) {
341+
textareaRef.current.selectionStart = textareaRef.current.selectionEnd = cursor + cleanCompletion.length;
342+
}
343+
}, 0);
344+
345+
setTerminalOutput(prev => [
346+
...prev,
347+
{ type: 'system', text: 'AI Copilot: Inserted completion successfully.' }
348+
]);
349+
} catch (err) {
350+
setTerminalOutput(prev => [
351+
...prev,
352+
{ type: 'stderr', text: `AI Copilot Error: ${err.message}` }
353+
]);
354+
}
355+
};
356+
301357
// Apply Suggestion from AI Panel to Editor
302358
const applyAiSuggestion = () => {
303359
// Extract code block from AI Response
@@ -518,7 +574,7 @@ Provide instructions and modified code structure to achieve this request.`;
518574
onKeyDown={handleKeyDown}
519575
onScroll={handleScroll}
520576
className={`flex-1 py-4 px-4 bg-transparent outline-none border-none resize-none font-mono focus:ring-0 ${styles.textarea} overflow-y-auto`}
521-
placeholder="Write your code here..."
577+
placeholder="Write your code here... Press [Ctrl + Space] for AI Autocomplete"
522578
spellCheck={false}
523579
/>
524580
</div>

client/src/pages/CollaborativeWorkspace.jsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,11 @@ export default function CollaborativeWorkspace() {
224224
setChatMessages((prev) => [...prev, msg]);
225225
});
226226

227+
// Delete Chat Message
228+
newSocket.on('delete-message', (msgId) => {
229+
setChatMessages((prev) => prev.filter((m) => m.id !== msgId));
230+
});
231+
227232
// Yjs Update Handling
228233
newSocket.on('yjs-update', (updateArray) => {
229234
try {

server/sockets/collaborativeWorkspace.js

Lines changed: 164 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,15 @@ function initCollaborativeWorkspace(io) {
4141
canvasHistory: [],
4242
cleanupTimer: null
4343
};
44+
45+
// Add virtual AI partner to room users list
46+
workspace.users.set('forgeai', {
47+
socketId: 'forgeai',
48+
username: 'ForgeAI (AI Partner)',
49+
avatarColor: '#8b5cf6',
50+
cursor: null
51+
});
52+
4453
workspaces.set(roomId, workspace);
4554
}
4655

@@ -50,7 +59,9 @@ function initCollaborativeWorkspace(io) {
5059
console.log(`Cancelled cleanup timer for room: ${roomId}`);
5160
}
5261

53-
if (workspace.users.size >= MAX_USERS_PER_ROOM) {
62+
// Count human users (exclude 'forgeai')
63+
const humanCount = Array.from(workspace.users.values()).filter(u => u.socketId !== 'forgeai').length;
64+
if (humanCount >= MAX_USERS_PER_ROOM) {
5465
socket.emit('room-error', 'Room is full (Maximum 5 users).');
5566
return;
5667
}
@@ -145,6 +156,11 @@ function initCollaborativeWorkspace(io) {
145156
}
146157

147158
io.to(roomId).emit('new-message', messageObj);
159+
160+
// Trigger ForgeAI if message targets the agent
161+
if (text && text.trim().toLowerCase().startsWith('@forgeai')) {
162+
handleForgeAIMessage(roomId, io, text);
163+
}
148164
});
149165

150166
// Live shared AI assistant stream
@@ -341,8 +357,9 @@ Provide a concise, expert answer. If correcting code, explain the bug briefly an
341357
workspace.users.delete(socket.id);
342358
io.to(roomId).emit('user-left', socket.id);
343359

344-
// If room is empty, trigger cleanup grace period
345-
if (workspace.users.size === 0) {
360+
// If room is empty of human users, trigger cleanup grace period
361+
const humanCount = Array.from(workspace.users.values()).filter(u => u.socketId !== 'forgeai').length;
362+
if (humanCount === 0) {
346363
workspace.cleanupTimer = setTimeout(() => {
347364
workspaces.delete(roomId);
348365
console.log(`🧹 Room ${roomId} has been deleted from memory (empty and inactive).`);
@@ -352,4 +369,148 @@ Provide a concise, expert answer. If correcting code, explain the bug briefly an
352369
});
353370
}
354371

372+
// Helper for parsing JSON safely from LLM response
373+
function parseJsonResponse(text) {
374+
let cleanText = text.trim();
375+
if (cleanText.startsWith('```json')) {
376+
cleanText = cleanText.substring(7);
377+
} else if (cleanText.startsWith('```')) {
378+
cleanText = cleanText.substring(3);
379+
}
380+
if (cleanText.endsWith('```')) {
381+
cleanText = cleanText.substring(0, cleanText.length - 3);
382+
}
383+
cleanText = cleanText.trim();
384+
385+
try {
386+
return JSON.parse(cleanText);
387+
} catch (e) {
388+
const firstBrace = cleanText.indexOf('{');
389+
const lastBrace = cleanText.lastIndexOf('}');
390+
if (firstBrace !== -1 && lastBrace !== -1) {
391+
const jsonSubstring = cleanText.substring(firstBrace, lastBrace + 1);
392+
return JSON.parse(jsonSubstring);
393+
}
394+
throw e;
395+
}
396+
}
397+
398+
// Background handler for ForgeAI interaction
399+
async function handleForgeAIMessage(roomId, io, text) {
400+
const workspace = workspaces.get(roomId);
401+
if (!workspace) return;
402+
403+
const promptText = text.replace(/^@forgeai/i, '').trim();
404+
if (!promptText) return;
405+
406+
// 1. Emit a temporary typing message
407+
const aiTempMsgId = `msg-forgeai-loading-${Date.now()}`;
408+
const tempMessageObj = {
409+
id: aiTempMsgId,
410+
sender: 'ForgeAI (AI Partner)',
411+
avatarColor: '#8b5cf6',
412+
text: '🤖 ForgeAI is typing...',
413+
timestamp: new Date().toISOString()
414+
};
415+
io.to(roomId).emit('new-message', tempMessageObj);
416+
417+
try {
418+
const geminiApiKey = process.env.GEMINI_API_KEY;
419+
if (!geminiApiKey) {
420+
throw new Error("Gemini API key is not configured on the server.");
421+
}
422+
423+
const currentCode = workspace.yDoc ? workspace.yDoc.getText('monaco').toString() : '';
424+
425+
const systemPrompt = `You are "ForgeAI", an autonomous AI programming partner participating in a collaborative room.
426+
You are helping developers with code. You can either just chat/explain, or edit the shared code document directly.
427+
If they ask you to write, modify, refactor, add, fix, or optimize code, you should perform an "edit". Otherwise, perform a "chat".
428+
429+
Current Code in the Editor:
430+
${currentCode}
431+
432+
User request: "${promptText}"
433+
434+
You must respond with ONLY a valid JSON object. Do not include markdown code block backticks around the JSON.
435+
Schema:
436+
{
437+
"action": "chat" | "edit",
438+
"message": "Your conversational response/explanation to the developers in the chat.",
439+
"code": "If action is 'edit', the COMPLETE modified code content. If action is 'chat', leave this empty."
440+
}
441+
`;
442+
443+
const genAI = new GoogleGenerativeAI(geminiApiKey);
444+
const model = genAI.getGenerativeModel({ model: 'gemini-2.5-flash' });
445+
const result = await model.generateContent(systemPrompt);
446+
const responseText = result.response.text();
447+
448+
// Parse response
449+
let parsed;
450+
try {
451+
parsed = parseJsonResponse(responseText);
452+
} catch (err) {
453+
console.error("Failed to parse ForgeAI response:", responseText, err);
454+
parsed = {
455+
action: "chat",
456+
message: parsed?.message || responseText,
457+
code: ""
458+
};
459+
}
460+
461+
// Delete temporary loading message
462+
io.to(roomId).emit('delete-message', aiTempMsgId);
463+
464+
// Send final message to chat
465+
const finalMsgObj = {
466+
id: `msg-forgeai-${Date.now()}`,
467+
sender: 'ForgeAI (AI Partner)',
468+
avatarColor: '#8b5cf6',
469+
text: parsed.message || 'I have completed your request.',
470+
timestamp: new Date().toISOString()
471+
};
472+
workspace.messages.push(finalMsgObj);
473+
if (workspace.messages.length > MAX_MESSAGE_HISTORY) {
474+
workspace.messages.shift();
475+
}
476+
io.to(roomId).emit('new-message', finalMsgObj);
477+
478+
// If edit action, apply changes to Yjs doc
479+
if (parsed.action === 'edit' && parsed.code) {
480+
const textType = workspace.yDoc.getText('monaco');
481+
workspace.yDoc.transact(() => {
482+
textType.delete(0, textType.length);
483+
textType.insert(0, parsed.code);
484+
});
485+
486+
const update = Y.encodeStateAsUpdate(workspace.yDoc);
487+
io.to(roomId).emit('yjs-update', Array.from(update));
488+
489+
// Emit a system notification in the chat
490+
const sysMsg = {
491+
id: `msg-sys-${Date.now()}`,
492+
sender: 'System',
493+
avatarColor: '#94a3b8',
494+
text: `⚙️ ForgeAI has updated the active code buffer.`,
495+
timestamp: new Date().toISOString()
496+
};
497+
workspace.messages.push(sysMsg);
498+
if (workspace.messages.length > MAX_MESSAGE_HISTORY) {
499+
workspace.messages.shift();
500+
}
501+
io.to(roomId).emit('new-message', sysMsg);
502+
}
503+
} catch (error) {
504+
console.error("ForgeAI Agent Error:", error);
505+
io.to(roomId).emit('delete-message', aiTempMsgId);
506+
io.to(roomId).emit('new-message', {
507+
id: `msg-forgeai-err-${Date.now()}`,
508+
sender: 'ForgeAI (AI Partner)',
509+
avatarColor: '#8b5cf6',
510+
text: `⚠️ Sorry, I encountered an error: ${error.message}`,
511+
timestamp: new Date().toISOString()
512+
});
513+
}
514+
}
515+
355516
module.exports = initCollaborativeWorkspace;

0 commit comments

Comments
 (0)