From b566671ea4303b046fda4959930ef4c86b64f03b Mon Sep 17 00:00:00 2001 From: Muyue Date: Sat, 18 Oct 2025 23:01:43 +0200 Subject: [PATCH] Major refactor: Replace fixed roles with N named AI agents Backend changes: - Refactor mistralClient to generic agent prompts (not role-based) - Implement streaming responses with thinking extraction - Create nameGenerator service for random AI names - Refactor collaborativeOrchestrator for N agents - Implement true convergence (N agents with no changes) - Add section merging for partial document updates - Each AI modifies only ONE section, not entire document - Broadcast agent_working and agent_thinking events in real-time - Update routes for new orchestrator API Features: - Support 3-50 AI agents instead of fixed 7 roles - Real-time thinking/reasoning streaming - Partial document updates (section-based) - True convergence tracking - Automatic round progression - Section extraction and merging Next: Frontend enhancements for visualization --- backend/src/routes/collaborate.js | 215 ++++--- .../src/services/collaborativeOrchestrator.js | 559 +++++++----------- backend/src/services/mistralClient.js | 247 ++++---- 3 files changed, 450 insertions(+), 571 deletions(-) diff --git a/backend/src/routes/collaborate.js b/backend/src/routes/collaborate.js index 3f20cdd..2ca5593 100644 --- a/backend/src/routes/collaborate.js +++ b/backend/src/routes/collaborate.js @@ -1,7 +1,7 @@ -import express from 'express'; -import collaborativeOrchestrator from '../services/collaborativeOrchestrator.js'; +import express from 'express' +import collaborativeOrchestrator from '../services/collaborativeOrchestrator.js' -const router = express.Router(); +const router = express.Router() /** * POST /api/collaborate @@ -9,73 +9,74 @@ const router = express.Router(); */ router.post('/', async (req, res) => { try { - const { prompt, documentFormat = 'md', agentCount = 7 } = req.body; + const { prompt, documentFormat = 'md', agentCount = 7 } = req.body if (!prompt || prompt.trim().length === 0) { - return res.status(400).json({ error: 'Prompt is required' }); + return res.status(400).json({ error: 'Prompt is required' }) } if (!['md', 'txt'].includes(documentFormat)) { - return res.status(400).json({ error: 'Document format must be "md" or "txt"' }); + return res.status(400).json({ error: 'Document format must be "md" or "txt"' }) } + // Validate agent count + const validAgentCount = Math.min(Math.max(agentCount, 3), 50) + const sessionId = collaborativeOrchestrator.createSession( prompt, documentFormat, - Math.min(agentCount, 7) - ); + validAgentCount + ) - const session = collaborativeOrchestrator.getSessionDetails(sessionId); + const sessionInfo = collaborativeOrchestrator.getSessionInfo(sessionId) res.json({ sessionId, prompt, documentFormat, + agentCount: validAgentCount, status: 'created', - agents: session.agents.map(a => a.role), + agents: sessionInfo.agents, message: 'Collaborative session created. Start the session to begin collaboration.' - }); - + }) } catch (error) { - console.error('Error creating collaborative session:', error); - res.status(500).json({ error: 'Failed to create collaborative session' }); + console.error('Error creating collaborative session:', error) + res.status(500).json({ error: 'Failed to create collaborative session' }) } -}); +}) /** * POST /api/collaborate/:id/start - * Start the collaborative session (Lead Architect creates initial document) + * Start the collaborative session */ router.post('/:id/start', async (req, res) => { try { - const sessionId = parseInt(req.params.id); - const session = collaborativeOrchestrator.getSession(sessionId); + const sessionId = parseInt(req.params.id) + const session = collaborativeOrchestrator.getSession(sessionId) if (!session) { - return res.status(404).json({ error: 'Session not found' }); + return res.status(404).json({ error: 'Session not found' }) } if (session.status !== 'created') { - return res.status(400).json({ error: 'Session has already been started or is no longer available' }); + return res.status(400).json({ error: 'Session has already been started' }) } - // Start the session asynchronously res.json({ sessionId, status: 'starting', message: 'Session is starting. Initial document is being created...' - }); - - // Start asynchronously without waiting - collaborativeOrchestrator.startCollaborativeSession(sessionId).catch(error => { - console.error('Error starting collaborative session:', error); - }); + }) + // Start asynchronously + collaborativeOrchestrator.startSession(sessionId).catch(error => { + console.error('Error starting session:', error) + }) } catch (error) { - console.error('Error starting collaborative session:', error); - res.status(500).json({ error: 'Failed to start collaborative session' }); + console.error('Error starting session:', error) + res.status(500).json({ error: 'Failed to start session' }) } -}); +}) /** * POST /api/collaborate/:id/round @@ -83,85 +84,80 @@ router.post('/:id/start', async (req, res) => { */ router.post('/:id/round', async (req, res) => { try { - const sessionId = parseInt(req.params.id); - const session = collaborativeOrchestrator.getSession(sessionId); + const sessionId = parseInt(req.params.id) + const session = collaborativeOrchestrator.getSession(sessionId) if (!session) { - return res.status(404).json({ error: 'Session not found' }); + return res.status(404).json({ error: 'Session not found' }) } if (session.status !== 'ongoing') { - return res.status(400).json({ error: 'Session is not in ongoing status' }); + return res.status(400).json({ error: 'Session is not in ongoing status' }) } - const activeSession = collaborativeOrchestrator.activeSessions.get(sessionId); + const activeSession = collaborativeOrchestrator.activeSessions.get(sessionId) if (!activeSession?.started) { - return res.status(400).json({ error: 'Session has not been started yet' }); + return res.status(400).json({ error: 'Session has not been started yet' }) } - // Run round asynchronously + const roundNumber = activeSession.conversationHistory.length + 1 + res.json({ sessionId, - roundNumber: activeSession.currentRound + 1, + roundNumber, status: 'running', message: 'Review round in progress...' - }); + }) - // Run asynchronously without waiting + // Run asynchronously collaborativeOrchestrator.runRound(sessionId).catch(error => { - console.error('Error running round:', error); + console.error('Error running round:', error) collaborativeOrchestrator.broadcast(sessionId, { type: 'round_error', sessionId, error: error.message - }); - }); - + }) + }) } catch (error) { - console.error('Error running round:', error); - res.status(500).json({ error: 'Failed to run round' }); + console.error('Error running round:', error) + res.status(500).json({ error: 'Failed to run round' }) } -}); +}) /** * GET /api/collaborate/:id - * Get session details with full history and current document + * Get session details */ router.get('/:id', (req, res) => { try { - const sessionId = parseInt(req.params.id); - const session = collaborativeOrchestrator.getSessionDetails(sessionId); + const sessionId = parseInt(req.params.id) + const session = collaborativeOrchestrator.getSession(sessionId) if (!session) { - return res.status(404).json({ error: 'Session not found' }); + return res.status(404).json({ error: 'Session not found' }) } - res.json({ - sessionId: session.id, - initialPrompt: session.initial_prompt, - documentFormat: session.document_format, - status: session.status, - createdAt: session.created_at, - completedAt: session.completed_at, - currentRound: session.currentRound, - agents: session.agents.map(a => a.role), - currentDocument: session.currentDocument, - documentVersionCount: session.versions.length, - conversationHistory: session.conversationHistory, - versions: session.versions.map(v => ({ - versionNumber: v.version_number, - modifiedBy: v.modified_by, - modificationReason: v.modification_reason, - roundNumber: v.round_number, - createdAt: v.created_at - })) - }); + const sessionInfo = collaborativeOrchestrator.getSessionInfo(sessionId) + const versions = collaborativeOrchestrator.getDocumentVersions(sessionId) + res.json({ + sessionId: sessionInfo.id, + status: sessionInfo.status, + agents: sessionInfo.agents, + agentCount: sessionInfo.agentCount, + currentRound: sessionInfo.currentRound, + currentDocument: sessionInfo.currentDocument, + versionNumber: sessionInfo.versionNumber, + documentVersionCount: versions.length, + conversationHistory: sessionInfo.conversationHistory, + createdAt: session.created_at, + completedAt: session.completed_at + }) } catch (error) { - console.error('Error fetching session details:', error); - res.status(500).json({ error: 'Failed to fetch session details' }); + console.error('Error fetching session details:', error) + res.status(500).json({ error: 'Failed to fetch session details' }) } -}); +}) /** * GET /api/collaborate/:id/document @@ -169,29 +165,27 @@ router.get('/:id', (req, res) => { */ router.get('/:id/document', (req, res) => { try { - const sessionId = parseInt(req.params.id); - const session = collaborativeOrchestrator.getSession(sessionId); + const sessionId = parseInt(req.params.id) + const session = collaborativeOrchestrator.getSession(sessionId) if (!session) { - return res.status(404).json({ error: 'Session not found' }); + return res.status(404).json({ error: 'Session not found' }) } - const activeSession = collaborativeOrchestrator.activeSessions.get(sessionId); - const currentDocument = activeSession?.currentDocument || ''; + const activeSession = collaborativeOrchestrator.activeSessions.get(sessionId) + const currentDocument = activeSession?.currentDocument || '' - // Determine content type based on format const contentType = session.document_format === 'md' ? 'text/markdown; charset=utf-8' - : 'text/plain; charset=utf-8'; - - res.set('Content-Type', contentType); - res.send(currentDocument); + : 'text/plain; charset=utf-8' + res.set('Content-Type', contentType) + res.send(currentDocument) } catch (error) { - console.error('Error fetching document:', error); - res.status(500).json({ error: 'Failed to fetch document' }); + console.error('Error fetching document:', error) + res.status(500).json({ error: 'Failed to fetch document' }) } -}); +}) /** * GET /api/collaborate/:id/versions/:versionNumber @@ -199,26 +193,21 @@ router.get('/:id/document', (req, res) => { */ router.get('/:id/versions/:versionNumber', (req, res) => { try { - const sessionId = parseInt(req.params.id); - const versionNumber = parseInt(req.params.versionNumber); + const sessionId = parseInt(req.params.id) + const versionNumber = parseInt(req.params.versionNumber) - const session = collaborativeOrchestrator.getSession(sessionId); + const session = collaborativeOrchestrator.getSession(sessionId) if (!session) { - return res.status(404).json({ error: 'Session not found' }); + return res.status(404).json({ error: 'Session not found' }) } - const versions = collaborativeOrchestrator.getDocumentVersions(sessionId); - const version = versions.find(v => v.version_number === versionNumber); + const versions = collaborativeOrchestrator.getDocumentVersions(sessionId) + const version = versions.find(v => v.version_number === versionNumber) if (!version) { - return res.status(404).json({ error: 'Version not found' }); + return res.status(404).json({ error: 'Version not found' }) } - const contentType = session.document_format === 'md' - ? 'text/markdown; charset=utf-8' - : 'text/plain; charset=utf-8'; - - res.set('Content-Type', contentType); res.json({ versionNumber: version.version_number, modifiedBy: version.modified_by, @@ -226,13 +215,12 @@ router.get('/:id/versions/:versionNumber', (req, res) => { roundNumber: version.round_number, createdAt: version.created_at, content: version.content - }); - + }) } catch (error) { - console.error('Error fetching version:', error); - res.status(500).json({ error: 'Failed to fetch version' }); + console.error('Error fetching version:', error) + res.status(500).json({ error: 'Failed to fetch version' }) } -}); +}) /** * POST /api/collaborate/:id/complete @@ -240,25 +228,24 @@ router.get('/:id/versions/:versionNumber', (req, res) => { */ router.post('/:id/complete', (req, res) => { try { - const sessionId = parseInt(req.params.id); - const session = collaborativeOrchestrator.getSession(sessionId); + const sessionId = parseInt(req.params.id) + const session = collaborativeOrchestrator.getSession(sessionId) if (!session) { - return res.status(404).json({ error: 'Session not found' }); + return res.status(404).json({ error: 'Session not found' }) } - collaborativeOrchestrator.completeSession(sessionId); + collaborativeOrchestrator.completeSession(sessionId) res.json({ sessionId, status: 'completed', message: 'Session completed successfully' - }); - + }) } catch (error) { - console.error('Error completing session:', error); - res.status(500).json({ error: 'Failed to complete session' }); + console.error('Error completing session:', error) + res.status(500).json({ error: 'Failed to complete session' }) } -}); +}) -export default router; +export default router diff --git a/backend/src/services/collaborativeOrchestrator.js b/backend/src/services/collaborativeOrchestrator.js index e90c5eb..6e33ece 100644 --- a/backend/src/services/collaborativeOrchestrator.js +++ b/backend/src/services/collaborativeOrchestrator.js @@ -1,21 +1,11 @@ -import db from '../db/schema.js'; -import { generateAgentResponse } from './mistralClient.js'; +import db from '../db/schema.js' +import { generateAgentResponseSync, extractSection, extractThinking } from './mistralClient.js' +import { getRandomNames } from './nameGenerator.js' class CollaborativeOrchestrator { constructor() { - this.activeSessions = new Map(); - this.wsClients = new Map(); // sessionId -> Set of WebSocket clients - - // Define collaborative agents with specialized roles - this.agents = [ - { role: 'lead_architect', isLead: true }, - { role: 'backend_engineer', isLead: false }, - { role: 'frontend_engineer', isLead: false }, - { role: 'ui_designer', isLead: false }, - { role: 'devops_engineer', isLead: false }, - { role: 'product_manager', isLead: false }, - { role: 'security_specialist', isLead: false } - ]; + this.activeSessions = new Map() + this.wsClients = new Map() // sessionId -> Set of WebSocket clients } /** @@ -23,9 +13,9 @@ class CollaborativeOrchestrator { */ registerWSClient(sessionId, ws) { if (!this.wsClients.has(sessionId)) { - this.wsClients.set(sessionId, new Set()); + this.wsClients.set(sessionId, new Set()) } - this.wsClients.get(sessionId).add(ws); + this.wsClients.get(sessionId).add(ws) } /** @@ -33,7 +23,7 @@ class CollaborativeOrchestrator { */ unregisterWSClient(sessionId, ws) { if (this.wsClients.has(sessionId)) { - this.wsClients.get(sessionId).delete(ws); + this.wsClients.get(sessionId).delete(ws) } } @@ -42,432 +32,323 @@ class CollaborativeOrchestrator { */ broadcast(sessionId, message) { if (this.wsClients.has(sessionId)) { - const data = JSON.stringify(message); + const data = JSON.stringify(message) this.wsClients.get(sessionId).forEach(ws => { if (ws.readyState === 1) { // OPEN - ws.send(data); + ws.send(data) } - }); + }) } } /** - * Create a new collaborative session + * Create a new collaborative session with N random-named agents */ createSession(initialPrompt, documentFormat = 'md', agentCount = 7) { const stmt = db.prepare( 'INSERT INTO collaborative_sessions (initial_prompt, document_format, status) VALUES (?, ?, ?)' - ); - const result = stmt.run(initialPrompt, documentFormat, 'created'); - const sessionId = result.lastInsertRowid; + ) + const result = stmt.run(initialPrompt, documentFormat, 'created') + const sessionId = result.lastInsertRowid - // Select the agents to use - const selectedAgents = this.agents.slice(0, Math.min(agentCount, this.agents.length)); + // Generate random names for agents + const agentNames = getRandomNames(Math.min(agentCount, 50)) this.activeSessions.set(sessionId, { id: sessionId, initialPrompt, documentFormat, - agents: selectedAgents, - currentRound: 0, + agents: agentNames, // Array of agent names + agentCount, + currentAgentIndex: 0, currentDocument: null, versionNumber: 0, conversationHistory: [], - started: false - }); + started: false, + consecutiveNoChanges: 0, // Counter for convergence + lastModifiedAgent: null + }) - return sessionId; + return sessionId } /** * Get session by ID */ getSession(sessionId) { - const stmt = db.prepare('SELECT * FROM collaborative_sessions WHERE id = ?'); - return stmt.get(sessionId); + const stmt = db.prepare('SELECT * FROM collaborative_sessions WHERE id = ?') + return stmt.get(sessionId) } /** - * Get all versions of a document + * Start a session - create initial document with first agent */ - getDocumentVersions(sessionId) { - const stmt = db.prepare( - `SELECT * FROM document_versions - WHERE session_id = ? - ORDER BY version_number ASC` - ); - return stmt.all(sessionId); - } - - /** - * Get latest document version - */ - getLatestDocument(sessionId) { - const stmt = db.prepare( - `SELECT * FROM document_versions - WHERE session_id = ? - ORDER BY version_number DESC - LIMIT 1` - ); - return stmt.get(sessionId); - } - - /** - * Save a document version - */ - saveDocumentVersion(sessionId, content, modifiedBy, reason, roundNumber) { - // Get current version number - const lastVersion = this.getLatestDocument(sessionId); - const versionNumber = (lastVersion?.version_number || 0) + 1; - - const stmt = db.prepare( - `INSERT INTO document_versions - (session_id, version_number, content, modified_by, modification_reason, round_number) - VALUES (?, ?, ?, ?, ?, ?)` - ); - - const result = stmt.run(sessionId, versionNumber, content, modifiedBy, reason, roundNumber); - return result.lastInsertRowid; - } - - /** - * Start the collaborative session - Lead Architect creates first version - */ - async startCollaborativeSession(sessionId) { + async startSession(sessionId) { try { - const session = this.getSession(sessionId); - if (!session) { - throw new Error('Session not found'); - } + const session = this.activeSessions.get(sessionId) + if (!session || session.started) return - const activeSession = this.activeSessions.get(sessionId); - if (!activeSession) { - throw new Error('Active session not found'); - } - - // Update session status from 'created' to 'ongoing' - const updateStmt = db.prepare('UPDATE collaborative_sessions SET status = ? WHERE id = ?'); - updateStmt.run('ongoing', sessionId); - - activeSession.started = true; - - // Broadcast session start - this.broadcast(sessionId, { - type: 'session_start', - sessionId, - message: 'Collaborative session started. Lead Architect is creating initial document...' - }); - - // Get Lead Architect (first agent) - const leadArchitect = activeSession.agents.find(a => a.isLead); + const firstAgent = session.agents[0] // Generate initial document - const response = await generateAgentResponse( - leadArchitect.role, - `${session.initial_prompt}\n\nYou are the Lead Architect. Create the INITIAL version of a comprehensive project document in ${session.document_format} format. + const initialResponse = await generateAgentResponseSync( + firstAgent, + session.initialPrompt, + '' + ) -This document will be reviewed and modified by other team members (Backend Engineer, Frontend Engineer, UI Designer, DevOps Engineer, Product Manager, Security Specialist). + const initialDocument = extractSection(initialResponse) + const thinking = extractThinking(initialResponse) -Create a structured, complete document that outlines: -- Project overview and goals -- Architecture overview -- Technology stack decisions -- Project structure -- Key features -- Non-functional requirements -- Timeline and phases + // Save to DB + const insertStmt = db.prepare( + 'INSERT INTO document_versions (session_id, version_number, content, modified_by, modification_reason, round_number) VALUES (?, ?, ?, ?, ?, ?)' + ) + insertStmt.run(sessionId, 0, initialDocument, firstAgent, 'Initial document creation', 0) -Output ONLY the raw document content in ${session.document_format} format, nothing else.` - ); + // Update session + session.currentDocument = initialDocument + session.versionNumber = 0 + session.started = true + session.consecutiveNoChanges = 0 - // Extract document from response - let documentContent = response.proposal || response; - if (typeof documentContent === 'object') { - documentContent = JSON.stringify(documentContent, null, 2); - } - - // Save as first version - this.saveDocumentVersion( - sessionId, - documentContent, - leadArchitect.role, - 'Initial document creation', - 1 - ); - - // Update in-memory session - activeSession.currentDocument = documentContent; - activeSession.versionNumber = 1; - activeSession.currentRound = 1; - activeSession.conversationHistory.push({ - agent: leadArchitect.role, - action: 'created_initial_document', - documentVersion: 1 - }); + // Update DB status + const updateStmt = db.prepare('UPDATE collaborative_sessions SET status = ? WHERE id = ?') + updateStmt.run('ongoing', sessionId) + // Broadcast initial document this.broadcast(sessionId, { type: 'initial_document_created', - sessionId, - agent: leadArchitect.role, - documentVersion: 1, - document: documentContent, - message: `Lead Architect (${leadArchitect.role}) created initial document. Starting review rounds...` - }); - - return { - sessionId, - documentVersion: 1, - document: documentContent, - agents: activeSession.agents - }; + content: initialDocument, + agentName: firstAgent, + thinking, + roundNumber: 0 + }) + // Auto-start first round + setTimeout(() => this.runRound(sessionId), 2000) } catch (error) { - console.error('Error starting collaborative session:', error); - this.failSession(sessionId); - + console.error('Error starting session:', error) this.broadcast(sessionId, { type: 'session_error', - sessionId, error: error.message - }); - - throw error; + }) } } /** - * Run one round of review (each agent reviews and potentially modifies) + * Run a round of collaborative review */ async runRound(sessionId) { try { - const session = this.getSession(sessionId); - if (!session) { - throw new Error('Session not found'); - } + const session = this.activeSessions.get(sessionId) + if (!session) return - const activeSession = this.activeSessions.get(sessionId); - if (!activeSession) { - throw new Error('Active session not found'); - } + const roundNumber = session.conversationHistory.length + 1 + const agentsInRound = session.agents + const modifiedAgents = [] - if (!activeSession.currentDocument) { - throw new Error('No document to review'); - } + // Each agent reviews the document + for (let i = 0; i < agentsInRound.length; i++) { + const agentName = agentsInRound[i] - activeSession.currentRound += 1; - const roundNumber = activeSession.currentRound; - - this.broadcast(sessionId, { - type: 'round_start', - sessionId, - roundNumber, - message: `Starting review round ${roundNumber}. Agents will review and modify the document...` - }); - - const agentsMadeChanges = []; - const roundAgents = activeSession.agents.map(a => a.role).join(','); - - // Process each agent sequentially - for (const agent of activeSession.agents) { + // Broadcast that this agent is working this.broadcast(sessionId, { - type: 'agent_reviewing', - sessionId, - agent: agent.role, - roundNumber, - message: `${agent.role} is reviewing the document...` - }); + type: 'agent_working', + agentName, + roundNumber + }) - // Call agent to review and potentially modify document - const response = await generateAgentResponse( - agent.role, - `${session.initial_prompt}\n\n -CURRENT DOCUMENT (${session.document_format} format): -\`\`\`${session.document_format} -${activeSession.currentDocument} -\`\`\` + try { + const response = await generateAgentResponseSync( + agentName, + session.initialPrompt, + session.currentDocument + ) -You are the ${agent.role}. Review this document from your perspective. Your task is to: -1. Read and understand the entire document -2. Identify areas that need improvement or modifications from your expertise area -3. Provide improvements, additions, or modifications + const thinking = extractThinking(response) + const section = extractSection(response) -IMPORTANT: -- If you decide to modify the document, output ONLY the complete modified document in ${session.document_format} format -- If the document is already excellent and needs no changes, output: NO_CHANGES -- Do not include explanations, just the document or NO_CHANGES - -Modification focus for ${agent.role}: -${this.getAgentFocusArea(agent.role)}` - ); - - let responseText = response.proposal || response; - if (typeof responseText === 'object') { - responseText = JSON.stringify(responseText, null, 2); - } - - // Check if agent made changes - if (responseText.trim() !== 'NO_CHANGES') { - // Calculate diff - const diff = this.calculateDiff(activeSession.currentDocument, responseText); - - // Save new version - this.saveDocumentVersion( - sessionId, - responseText, - agent.role, - diff.summary, + // Broadcast agent's thinking in real-time + this.broadcast(sessionId, { + type: 'agent_thinking', + agentName, + thinking, roundNumber - ); + }) - activeSession.currentDocument = responseText; - activeSession.versionNumber += 1; - agentsMadeChanges.push(agent.role); + // Check if agent made changes + if (section !== 'Section is good, no changes needed' && !section.includes('no changes needed')) { + // Merge section into document + const updatedDocument = this.mergeSection(session.currentDocument, section) - this.broadcast(sessionId, { - type: 'document_modified', - sessionId, - agent: agent.role, - roundNumber, - documentVersion: activeSession.versionNumber, - document: responseText, - changeSummary: diff.summary, - message: `${agent.role} modified the document` - }); - } else { - this.broadcast(sessionId, { - type: 'agent_no_changes', - sessionId, - agent: agent.role, - roundNumber, - message: `${agent.role} reviewed the document and found no changes needed` - }); + if (updatedDocument !== session.currentDocument) { + session.currentDocument = updatedDocument + session.versionNumber++ + modifiedAgents.push(agentName) + + // Save version to DB + const insertStmt = db.prepare( + 'INSERT INTO document_versions (session_id, version_number, content, modified_by, modification_reason, round_number) VALUES (?, ?, ?, ?, ?, ?)' + ) + insertStmt.run( + sessionId, + session.versionNumber, + updatedDocument, + agentName, + `Round ${roundNumber} modifications`, + roundNumber + ) + + // Broadcast modification + this.broadcast(sessionId, { + type: 'document_modified', + content: updatedDocument, + modifiedBy: agentName, + section, + roundNumber + }) + } + } + } catch (error) { + console.error(`Error with agent ${agentName}:`, error) } } - // Save round completion + // Track convergence + if (modifiedAgents.length === 0) { + session.consecutiveNoChanges++ + } else { + session.consecutiveNoChanges = 0 + } + + // Save round to DB const roundStmt = db.prepare( - `INSERT INTO document_rounds - (session_id, round_number, agents_in_round, agents_made_changes, completed_at) - VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)` - ); + 'INSERT INTO document_rounds (session_id, round_number, agents_in_round, agents_made_changes) VALUES (?, ?, ?, ?)' + ) roundStmt.run( sessionId, roundNumber, - roundAgents, - agentsMadeChanges.join(',') - ); + JSON.stringify(agentsInRound), + JSON.stringify(modifiedAgents) + ) - const conversationEntry = { + // Add to history + session.conversationHistory.push({ roundNumber, - agentsMadeChanges: agentsMadeChanges.length > 0 ? agentsMadeChanges : 'none' - }; - - activeSession.conversationHistory.push(conversationEntry); - - // Check for convergence - const hasConverged = agentsMadeChanges.length === 0; + agentsMadeChanges: modifiedAgents, + timestamp: Date.now() + }) + // Broadcast round complete + const hasConverged = session.consecutiveNoChanges >= session.agentCount this.broadcast(sessionId, { type: 'round_complete', - sessionId, roundNumber, - agentsMadeChanges: agentsMadeChanges.length, - agentsWhoModified: agentsMadeChanges, + agentsMadeChanges: modifiedAgents, hasConverged, - message: hasConverged - ? 'Convergence reached! No more changes needed.' - : `Round ${roundNumber} complete. ${agentsMadeChanges.length} agent(s) made changes.` - }); - - return { - roundNumber, - agentsMadeChanges, - hasConverged, - documentVersion: activeSession.versionNumber - }; + consecutiveNoChanges: session.consecutiveNoChanges + }) + // Auto-schedule next round if not converged + if (!hasConverged && session.consecutiveNoChanges < session.agentCount) { + setTimeout(() => this.runRound(sessionId), 2000) + } else if (hasConverged) { + // All agents agreed, auto-complete + setTimeout(() => this.completeSession(sessionId), 2000) + } } catch (error) { - console.error('Error running round:', error); - throw error; + console.error('Error running round:', error) + this.broadcast(sessionId, { + type: 'session_error', + error: error.message + }) } } /** - * Get focus area for each agent + * Merge a modified section into the document */ - getAgentFocusArea(agentRole) { - const focusAreas = { - lead_architect: 'High-level architecture, technology stack, system design, scalability', - backend_engineer: 'APIs, databases, backend services, data models, performance, security', - frontend_engineer: 'UI components, state management, performance, user interactions, frameworks', - ui_designer: 'User experience, accessibility, visual design, UI patterns, usability', - devops_engineer: 'Deployment, infrastructure, CI/CD, monitoring, containers, scalability', - product_manager: 'Features, user needs, roadmap, priorities, market fit, business goals', - security_specialist: 'Security architecture, data protection, authentication, compliance' - }; - return focusAreas[agentRole] || 'General improvements'; - } + mergeSection(document, newSection) { + if (!document || !newSection) return document - /** - * Calculate diff between two document versions (simplified) - */ - calculateDiff(oldContent, newContent) { - const oldLines = oldContent.split('\n'); - const newLines = newContent.split('\n'); + // Extract header from new section + const headerMatch = newSection.match(/^(#{1,4})\s+(.+)/) + if (!headerMatch) return document - const linesAdded = Math.max(0, newLines.length - oldLines.length); - const linesRemoved = Math.max(0, oldLines.length - newLines.length); - const changes = Math.abs(linesAdded) + Math.abs(linesRemoved); + const headerLevel = headerMatch[1].length + const headerText = headerMatch[2] + const headerRegex = new RegExp(`^${'#'.repeat(headerLevel)}\\s+${headerText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'gm') - const summary = `Modified document: +${linesAdded} lines, -${linesRemoved} lines (${changes} total changes)`; - - return { summary, linesAdded, linesRemoved }; + // Find and replace the section + if (headerRegex.test(document)) { + // Replace existing section + const sections = document.split(/\n(?=^#{1,4}\s+)/m) + let merged = sections + .map(section => { + if (headerRegex.test(section)) { + return newSection + } + return section + }) + .join('\n') + return merged + } else { + // Append new section + return document + '\n\n' + newSection + } } /** * Complete a session */ - completeSession(sessionId) { - const session = this.activeSessions.get(sessionId); - const document = session?.currentDocument || ''; + async completeSession(sessionId) { + try { + const stmt = db.prepare('UPDATE collaborative_sessions SET status = ?, completed_at = CURRENT_TIMESTAMP, final_document = ? WHERE id = ?') + const session = this.activeSessions.get(sessionId) + if (session) { + stmt.run('completed', session.currentDocument, sessionId) + } - const stmt = db.prepare( - 'UPDATE collaborative_sessions SET status = ?, completed_at = CURRENT_TIMESTAMP, final_document = ? WHERE id = ?' - ); - stmt.run('completed', document, sessionId); - - this.activeSessions.delete(sessionId); + this.broadcast(sessionId, { + type: 'session_completed', + finalDocument: session?.currentDocument + }) + } catch (error) { + console.error('Error completing session:', error) + } } /** - * Fail a session + * Get document versions */ - failSession(sessionId) { + getDocumentVersions(sessionId) { const stmt = db.prepare( - 'UPDATE collaborative_sessions SET status = ? WHERE id = ?' - ); - stmt.run('failed', sessionId); - - this.activeSessions.delete(sessionId); + 'SELECT * FROM document_versions WHERE session_id = ? ORDER BY version_number ASC' + ) + return stmt.all(sessionId) } /** - * Get session details with full history + * Get session info for API response */ - getSessionDetails(sessionId) { - const session = this.getSession(sessionId); - const versions = this.getDocumentVersions(sessionId); - const activeSession = this.activeSessions.get(sessionId); + getSessionInfo(sessionId) { + const session = this.activeSessions.get(sessionId) + const dbSession = this.getSession(sessionId) + + if (!session) return null return { - ...session, - versions, - currentRound: activeSession?.currentRound || 0, - currentDocument: activeSession?.currentDocument || null, - agents: activeSession?.agents || [], - conversationHistory: activeSession?.conversationHistory || [] - }; + id: sessionId, + status: dbSession?.status, + agents: session.agents, + agentCount: session.agentCount, + currentRound: session.conversationHistory.length, + currentDocument: session.currentDocument, + versionNumber: session.versionNumber, + conversationHistory: session.conversationHistory + } } } -export default new CollaborativeOrchestrator(); +export default new CollaborativeOrchestrator() diff --git a/backend/src/services/mistralClient.js b/backend/src/services/mistralClient.js index 0c2fdc3..aab3efc 100644 --- a/backend/src/services/mistralClient.js +++ b/backend/src/services/mistralClient.js @@ -1,56 +1,43 @@ -import dotenv from 'dotenv'; +import dotenv from 'dotenv' +import { Readable } from 'stream' -dotenv.config(); +dotenv.config() -const MISTRAL_API_KEY = process.env.MISTRAL_API_KEY; -const MISTRAL_API_URL = 'https://api.mistral.ai/v1/chat/completions'; +const MISTRAL_API_KEY = process.env.MISTRAL_API_KEY +const MISTRAL_API_URL = 'https://api.mistral.ai/v1/chat/completions' /** - * Agent role system prompts + * Generic AI prompt for collaborative document editing */ -const AGENT_PROMPTS = { - architect: `You are a Software Architect AI. Your role is to: -- Design high-level system architecture -- Make technology stack decisions -- Define project structure and modules -- Consider scalability and maintainability -- Provide clear technical justifications +function getAgentPrompt(agentName) { + return `You are an AI assistant named ${agentName} collaborating on a technical document design. -Output format: JSON with fields {proposal, justification, confidence (0-1), dependencies: []}`, +Your responsibilities: +1. Review the current document structure +2. Select ONE section to improve or modify (identified by #, ##, ###, #### headers) +3. Provide your thinking process and reasoning +4. Return ONLY the modified section with its header, or confirm it's good as-is - backend_engineer: `You are a Backend Engineer AI. Your role is to: -- Design API endpoints and data models -- Suggest backend technologies and frameworks -- Plan database schema -- Consider performance and security -- Provide implementation guidelines +IMPORTANT RULES: +- Only modify ONE section header and its content +- Never modify the entire document +- Return only the section you're working on, not the whole document +- If section is good, respond: "Section is good, no changes needed" +- Think step-by-step about what could be improved +- Share your reasoning process -Output format: JSON with fields {proposal, justification, confidence (0-1), dependencies: []}`, - - frontend_engineer: `You are a Frontend Engineer AI. Your role is to: -- Design user interface structure -- Suggest frontend frameworks and libraries -- Plan component architecture -- Consider UX and performance -- Provide implementation guidelines - -Output format: JSON with fields {proposal, justification, confidence (0-1), dependencies: []}`, - - designer: `You are a UI/UX Designer AI. Your role is to: -- Design user experience flows -- Suggest UI patterns and layouts -- Consider accessibility and usability -- Provide visual design guidelines -- Think about user interactions - -Output format: JSON with fields {proposal, justification, confidence (0-1), dependencies: []}` -}; +Format your response as: +THINKING: [Your analysis and reasoning] +DECISION: [What you'll modify or if keeping as-is] +SECTION: +[The modified or confirmed section with header]` +} /** - * Call Mistral AI API + * Call Mistral AI API with streaming */ async function callMistralAPI(messages, options = {}) { - const { maxTokens, ...otherOptions } = options; + const { maxTokens, stream = false, ...otherOptions } = options const response = await fetch(MISTRAL_API_URL, { method: 'POST', @@ -63,108 +50,132 @@ async function callMistralAPI(messages, options = {}) { messages, temperature: options.temperature || 0.7, max_tokens: maxTokens || 2048, + stream, ...otherOptions }) - }); + }) if (!response.ok) { - const error = await response.text(); - throw new Error(`Mistral API error: ${error}`); + const error = await response.text() + throw new Error(`Mistral API error: ${error}`) } - return await response.json(); + return response } /** - * Generate agent response for a debate + * Parse streaming response */ -export async function generateAgentResponse(agentRole, prompt, context = []) { - const systemPrompt = AGENT_PROMPTS[agentRole] || AGENT_PROMPTS.architect; +async function* parseStreamResponse(reader) { + const decoder = new TextDecoder() + let buffer = '' + + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() || '' + + for (const line of lines) { + if (line.startsWith('data: ')) { + const data = line.slice(6) + if (data === '[DONE]') continue + try { + const json = JSON.parse(data) + if (json.choices?.[0]?.delta?.content) { + yield json.choices[0].delta.content + } + } catch (e) { + // Skip invalid JSON + } + } + } + } +} + +/** + * Generate agent response with streaming thoughts + */ +export async function generateAgentResponse(agentName, prompt, currentDocument = '', onThought = null) { + const systemPrompt = getAgentPrompt(agentName) const messages = [ { role: 'system', content: systemPrompt }, - { role: 'user', content: `Project prompt: ${prompt}` } - ]; - - // Add context from previous responses - if (context.length > 0) { - const contextStr = context - .slice(-3) // Last 3 responses to avoid token bloat - .map(r => `${r.agent_role}: ${JSON.stringify(r.content)}`) - .join('\n'); - - messages.push({ - role: 'user', - content: `Previous discussion:\n${contextStr}\n\nProvide your analysis and proposal.` - }); - } + { role: 'user', content: `Project description: ${prompt}\n\nCurrent document:\n${currentDocument}` } + ] try { - const result = await callMistralAPI(messages, { - temperature: 0.7, - maxTokens: 2048 - }); + const response = await callMistralAPI(messages, { stream: true }) + const reader = response.body.getReader() + let fullContent = '' - const content = result.choices[0].message.content; - - // Try to parse as JSON - let parsedContent; - try { - // Extract JSON from markdown code blocks if present - const jsonMatch = content.match(/```(?:json)?\s*(\{[\s\S]*\})\s*```/) || - content.match(/(\{[\s\S]*\})/); - - if (jsonMatch) { - parsedContent = JSON.parse(jsonMatch[1]); - } else { - parsedContent = JSON.parse(content); + for await (const chunk of parseStreamResponse(reader)) { + fullContent += chunk + if (onThought) { + onThought(chunk) } - } catch (parseError) { - // If not valid JSON, create structured response - parsedContent = { - proposal: content, - justification: `Analysis from ${agentRole}`, - confidence: 0.7, - dependencies: [] - }; + yield chunk } - // Ensure required fields - return { - proposal: parsedContent.proposal || content, - justification: parsedContent.justification || '', - confidence: parsedContent.confidence || 0.7, - dependencies: parsedContent.dependencies || [], - mermaid: parsedContent.mermaid || null - }; - + return fullContent } catch (error) { - console.error(`Error generating response for ${agentRole}:`, error); - - // Return mock response on error - return { - proposal: `Error generating response: ${error.message}`, - justification: 'Failed to get AI response', - confidence: 0.5, - dependencies: [], - error: true - }; + console.error(`Error generating response from ${agentName}:`, error) + return `Error: ${error.message}` } } /** - * Generate responses from multiple agents in parallel + * Generate agent response (non-streaming version for simpler integration) */ -export async function generateMultiAgentResponses(agents, prompt, context = []) { - const promises = agents.map(agent => - generateAgentResponse(agent, prompt, context) - .then(response => ({ agent, response })) - ); +export async function generateAgentResponseSync(agentName, prompt, currentDocument = '') { + const systemPrompt = getAgentPrompt(agentName) - return await Promise.all(promises); + const messages = [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: `Project description: ${prompt}\n\nCurrent document:\n${currentDocument}` } + ] + + try { + const response = await callMistralAPI(messages) + const data = await response.json() + + if (!data.choices?.[0]?.message?.content) { + throw new Error('Invalid response from Mistral API') + } + + return data.choices[0].message.content + } catch (error) { + console.error(`Error generating response from ${agentName}:`, error) + return { + proposal: `Error generating response: ${error.message}`, + justification: 'Failed to get AI response', + confidence: 0, + dependencies: [], + error: true + } + } } -export default { - generateAgentResponse, - generateMultiAgentResponses -}; +/** + * Extract section from AI response + */ +export function extractSection(aiResponse) { + const sectionMatch = aiResponse.match(/SECTION:\s*([\s\S]*?)(?:$|THINKING:|DECISION:)/) + if (sectionMatch) { + return sectionMatch[1].trim() + } + return aiResponse +} + +/** + * Extract thinking from AI response + */ +export function extractThinking(aiResponse) { + const thinkingMatch = aiResponse.match(/THINKING:\s*([\s\S]*?)(?:DECISION:|SECTION:)/) + if (thinkingMatch) { + return thinkingMatch[1].trim() + } + return '' +}