session start works

This commit is contained in:
aodulov
2025-10-10 12:48:06 +03:00
parent 556df015e8
commit 3c192b136c
51 changed files with 29002 additions and 46 deletions

24
backend/src/index.ts Normal file
View File

@@ -0,0 +1,24 @@
import express from 'express';
import http from 'http';
import { createWebSocketServer } from './ws';
import sessionsRouter from './routes/sessions';
import cors from 'cors';
const app = express();
const server = http.createServer(app);
// Middleware
app.use(express.json());
app.use(cors());
// API Routes
app.use('/', sessionsRouter);
// Create and attach WebSocket server
createWebSocketServer(server);
const PORT = process.env.PORT || 8000;
server.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
});

View File

@@ -0,0 +1,22 @@
import express from 'express';
import { v4 as uuidv4 } from 'uuid';
import { sessions, SessionState } from '../ws'; // Import sessions and SessionState from ws/index.ts
const router = express.Router();
router.post('/sessions', (req, res) => {
const sessionId = uuidv4();
sessions.set(sessionId, {
state: SessionState.SETUP,
topic: null,
expectedResponses: 0,
submittedCount: 0,
responses: new Map(),
clients: new Map(),
finalResult: null,
});
console.log(`New session created: ${sessionId}`);
res.status(201).json({ sessionId });
});
export default router;

View File

@@ -0,0 +1,51 @@
import { GoogleGenerativeAI, GenerativeModel } from '@google/generative-ai';
interface DesireSet {
wants: string[];
accepts: string[];
noGoes: string[];
}
export class LLMService {
private genAI: GoogleGenerativeAI;
private model: GenerativeModel;
constructor(apiKey: string) {
this.genAI = new GoogleGenerativeAI(apiKey);
this.model = this.genAI.getGenerativeModel({ model: "gemini-pro" });
}
async analyzeDesires(desireSets: DesireSet[]): Promise<Record<string, string>> {
const allDesires: string[] = [];
desireSets.forEach(set => {
allDesires.push(...set.wants, ...set.accepts, ...set.noGoes);
});
const uniqueDesires = Array.from(new Set(allDesires.filter(d => d.trim() !== '')));
if (uniqueDesires.length === 0) {
return {};
}
const prompt = `
You are an AI assistant that groups similar desires. Given a list of desires, identify semantically equivalent or very similar items and group them under a single, concise canonical name. Return the output as a JSON object where keys are the original desire strings and values are their canonical group names.
Example:
Input: ["go for a walk", "walking", "stroll in the park", "eat pizza", "pizza for dinner"]
Output: {"go for a walk": "Go for a walk", "walking": "Go for a walk", "stroll in the park": "Go for a walk", "eat pizza": "Eat pizza", "pizza for dinner": "Eat pizza"}
Here is the list of desires to group:
${JSON.stringify(uniqueDesires)}
`;
try {
const result = await this.model.generateContent(prompt);
const response = result.response;
const text = response.text();
return JSON.parse(text);
} catch (error) {
console.error("Error calling Gemini API:", error);
throw error;
}
}
}

254
backend/src/ws/index.ts Normal file
View File

@@ -0,0 +1,254 @@
import { WebSocketServer, WebSocket } from 'ws';
import { LLMService } from '../services/LLMService';
import { v4 as uuidv4 } from 'uuid';
// Types from the frontend
interface SemanticDesire {
title: string;
rawInputs: string[];
}
interface Decision {
goTos: SemanticDesire[];
alsoGoods: SemanticDesire[];
considerables: SemanticDesire[];
noGoes: SemanticDesire[];
}
// Define the SessionState enum
export enum SessionState {
SETUP = 'SETUP',
GATHERING = 'GATHERING',
HARMONIZING = 'HARMONIZING',
FINAL = 'FINAL',
}
// A map to hold session data, including clients and the latest state
interface SessionData {
state: SessionState; // Current phase of the session
topic: string | null; // The topic of the session
expectedResponses: number; // The number set by the first user in State A.
submittedCount: number; // The current count of submitted responses.
responses: Map<string, any>; // Stores the submitted desire objects. Map<ClientID, ResponseData>
clients: Map<string, WebSocket>; // Maps the persistent Client ID to their active WebSocket connection object.
finalResult: any | null; // The result returned by the LLM.
}
export const sessions = new Map<string, SessionData>();
// Initialize LLM Service (API key from environment)
const llmService = new LLMService(process.env.GEMINI_API_KEY || '');
export const broadcastToSession = (sessionId: string, message: any, excludeClientId: string | null = null) => {
const sessionData = sessions.get(sessionId);
if (sessionData) {
sessionData.clients.forEach((client, clientId) => {
if (clientId !== excludeClientId && client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(message));
}
});
}
};
export const createWebSocketServer = (server: any) => {
const wss = new WebSocketServer({ server });
wss.on('connection', (ws, req) => {
const url = new URL(req.url || '', `http://${req.headers.host}`);
const sessionId = url.pathname.split('/').pop();
if (!sessionId) {
ws.close(1008, 'Invalid session ID');
return;
}
if (!sessions.has(sessionId)) {
sessions.set(sessionId, {
state: SessionState.SETUP,
topic: null,
expectedResponses: 0,
submittedCount: 0,
responses: new Map<string, any>(),
clients: new Map<string, WebSocket>(),
finalResult: null,
});
}
const sessionData = sessions.get(sessionId)!;
console.log(`Client connecting to session: ${sessionId}`);
ws.on('message', async (message) => {
const parsedMessage = JSON.parse(message.toString());
const { type, clientId, payload } = parsedMessage;
if (!clientId) {
console.error(`Received message without clientId in session ${sessionId}. Type: ${type}`);
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: 'clientId is required' } }));
return;
}
if (!sessionData.clients.has(clientId)) {
sessionData.clients.set(clientId, ws);
console.log(`Client ${clientId} registered for session: ${sessionId}. Total clients: ${sessionData.clients.size}`);
ws.send(JSON.stringify({ type: 'STATE_UPDATE', payload: { session: sessionData } }));
}
console.log(`Received message from ${clientId} in session ${sessionId}:`, type);
switch (type) {
case 'REGISTER_CLIENT':
break;
case 'SETUP_SESSION':
if (sessionData.state === SessionState.SETUP) {
const { expectedResponses, topic } = payload;
if (typeof expectedResponses !== 'number' || expectedResponses <= 0) {
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: 'Invalid expectedResponses' } }));
return;
}
sessionData.expectedResponses = expectedResponses;
sessionData.topic = topic || 'Untitled Session';
sessionData.state = SessionState.GATHERING;
broadcastToSession(sessionId, { type: 'STATE_UPDATE', payload: { session: sessionData } });
console.log(`Session ${sessionId} moved to GATHERING with topic "${sessionData.topic}" and ${expectedResponses} expected responses.`);
} else {
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: `Session is not in SETUP state. Current state: ${sessionData.state}` } }));
}
break;
case 'SUBMIT_RESPONSE':
if (sessionData.state === SessionState.GATHERING) {
if (sessionData.responses.has(clientId)) {
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: 'You have already submitted a response for this session.' } }));
return;
}
sessionData.responses.set(clientId, payload.response);
sessionData.submittedCount++;
console.log(`Client ${clientId} submitted response. Submitted count: ${sessionData.submittedCount}/${sessionData.expectedResponses}`);
if (sessionData.submittedCount === sessionData.expectedResponses) {
sessionData.state = SessionState.HARMONIZING;
broadcastToSession(sessionId, { type: 'STATE_UPDATE', payload: { session: sessionData } });
console.log(`Session ${sessionId} moved to HARMONIZING. Triggering LLM analysis.`);
try {
const allDesires = Array.from(sessionData.responses.values());
const canonicalMap = await llmService.analyzeDesires(allDesires);
const semanticDesiresMap = new Map<string, SemanticDesire>();
for (const originalDesire in canonicalMap) {
const canonicalName = canonicalMap[originalDesire];
if (!semanticDesiresMap.has(canonicalName)) {
semanticDesiresMap.set(canonicalName, { title: canonicalName, rawInputs: [] });
}
semanticDesiresMap.get(canonicalName)?.rawInputs.push(originalDesire);
}
const decision: Decision = {
goTos: [],
alsoGoods: [],
considerables: [],
noGoes: [],
};
const participantIds = Array.from(sessionData.responses.keys());
semanticDesiresMap.forEach(semanticDesire => {
let isNoGo = false;
let allWant = true;
let atLeastOneWant = false;
let allAcceptOrWant = true;
for (const pId of participantIds) {
const participantDesireSet = sessionData.responses.get(pId);
if (!participantDesireSet) continue;
const participantWants = new Set(participantDesireSet.wants.map((d: string) => canonicalMap[d] || d));
const participantAccepts = new Set(participantDesireSet.accepts.map((d: string) => canonicalMap[d] || d));
const participantNoGoes = new Set(participantDesireSet.noGoes.map((d: string) => canonicalMap[d] || d));
const canonicalTitle = semanticDesire.title;
if (participantNoGoes.has(canonicalTitle)) {
isNoGo = true;
break;
}
if (!participantWants.has(canonicalTitle)) {
allWant = false;
}
if (participantWants.has(canonicalTitle)) {
atLeastOneWant = true;
}
if (!participantWants.has(canonicalTitle) && !participantAccepts.has(canonicalTitle)) {
allAcceptOrWant = false;
}
}
if (isNoGo) {
decision.noGoes.push(semanticDesire);
} else if (allWant) {
decision.goTos.push(semanticDesire);
} else if (atLeastOneWant && allAcceptOrWant) {
decision.alsoGoods.push(semanticDesire);
} else if (atLeastOneWant || !allAcceptOrWant) {
decision.considerables.push(semanticDesire);
}
});
sessionData.finalResult = decision;
sessionData.state = SessionState.FINAL;
broadcastToSession(sessionId, { type: 'STATE_UPDATE', payload: { session: sessionData } });
console.log(`Analysis complete for session ${sessionId}. Result:`, decision);
} catch (error) {
console.error(`Error during analysis for session ${sessionId}:`, error);
sessionData.state = SessionState.GATHERING;
broadcastToSession(sessionId, { type: 'STATE_UPDATE', payload: { session: sessionData } });
}
} else {
broadcastToSession(sessionId, { type: 'STATE_UPDATE', payload: { session: sessionData } });
}
} else {
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: `Session is not in GATHERING state. Current state: ${sessionData.state}` } }));
}
break;
default:
console.warn(`Unknown message type: ${type} from client ${clientId} in session ${sessionId}`);
ws.send(JSON.stringify({ type: 'ERROR', payload: { message: `Unknown message type: ${type}` } }));
break;
}
});
ws.on('close', () => {
let disconnectedClientId: string | null = null;
for (const [clientId, clientWs] of sessionData.clients.entries()) {
if (clientWs === ws) {
disconnectedClientId = clientId;
break;
}
}
if (disconnectedClientId) {
sessionData.clients.delete(disconnectedClientId);
console.log(`Client ${disconnectedClientId} disconnected from session: ${sessionId}. Remaining clients: ${sessionData.clients.size}`);
} else {
console.log(`An unregistered client disconnected from session: ${sessionId}.`);
}
if (sessionData.clients.size === 0) {
sessions.delete(sessionId);
console.log(`Session ${sessionId} closed and state cleared.`);
}
});
ws.on('error', (error) => {
console.error(`WebSocket error in session ${sessionId}:`, error);
});
});
return wss;
};