--- id: "ET-LLM-001" title: "Arquitectura del Sistema de Chat" type: "Technical Specification" status: "Done" priority: "Alta" epic: "OQI-007" project: "trading-platform" version: "1.0.0" created_date: "2025-12-05" updated_date: "2026-01-04" --- # ET-LLM-001: Arquitectura del Sistema de Chat **Épica:** OQI-007 - LLM Strategy Agent **Versión:** 1.0 **Fecha:** 2025-12-05 **Estado:** Planificado **Prioridad:** P0 - Crítico --- ## Resumen Esta especificación define la arquitectura técnica del sistema de chat conversacional con el agente LLM, incluyendo backend, frontend, streaming y persistencia. --- ## Arquitectura General ``` ┌─────────────────────────────────────────────────────────────────────────┐ │ FRONTEND │ │ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────────────┐ │ │ │ ChatWindow │ │ MessageList │ │ MessageInput │ │ │ │ Component │ │ Component │ │ Component │ │ │ └────────┬────────┘ └────────┬────────┘ └───────────┬─────────────┘ │ │ └────────────────────┴───────────────────────┘ │ │ │ │ │ ┌─────────────────────────────┴─────────────────────────────────────┐ │ │ │ WebSocket Client │ │ │ │ (Socket.IO / native WS) │ │ │ └─────────────────────────────┬─────────────────────────────────────┘ │ └────────────────────────────────┼────────────────────────────────────────┘ │ WSS ┌────────────────────────────────┼────────────────────────────────────────┐ │ BACKEND │ │ ┌─────────────────────────────┴─────────────────────────────────────┐ │ │ │ WebSocket Gateway │ │ │ │ (NestJS Gateway / Express WS) │ │ │ └────────┬────────────────────┬────────────────────┬────────────────┘ │ │ │ │ │ │ │ ┌────────▼────────┐ ┌────────▼────────┐ ┌───────▼─────────┐ │ │ │ ChatService │ │ AgentService │ │ ToolsService │ │ │ │ │ │ │ │ │ │ │ │ - createConv() │ │ - processMsg() │ │ - get_price() │ │ │ │ - getHistory() │ │ - buildCtx() │ │ - create_order()│ │ │ │ - saveMessage() │ │ - streamResp() │ │ - get_ml() │ │ │ └────────┬────────┘ └────────┬────────┘ └───────┬─────────┘ │ │ │ │ │ │ │ ┌────────▼────────────────────▼────────────────────▼────────────────┐ │ │ │ LLM Provider Adapter │ │ │ │ (OpenAI / Claude / Local Model) │ │ │ └───────────────────────────────────────────────────────────────────┘ │ └──────────────────────────────────────────────────────────────────────────┘ │ ┌────────────────────────────────┼────────────────────────────────────────┐ │ STORAGE │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ │ PostgreSQL │ │ Redis │ │ S3/Blob │ │ │ │ │ │ │ │ │ │ │ │ conversations│ │ session cache│ │ attachments │ │ │ │ messages │ │ rate limits │ │ exports │ │ │ │ user_memory │ │ active WS │ │ │ │ │ └──────────────┘ └──────────────┘ └──────────────┘ │ └──────────────────────────────────────────────────────────────────────────┘ ``` --- ## Componentes Backend ### 1. WebSocket Gateway ```typescript // src/modules/copilot/gateways/chat.gateway.ts import { WebSocketGateway, WebSocketServer, SubscribeMessage, OnGatewayConnection, OnGatewayDisconnect, ConnectedSocket, MessageBody, } from '@nestjs/websockets'; import { Server, Socket } from 'socket.io'; @WebSocketGateway({ namespace: '/copilot', cors: { origin: process.env.FRONTEND_URL, credentials: true, }, }) export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect { @WebSocketServer() server: Server; constructor( private readonly chatService: ChatService, private readonly agentService: AgentService, private readonly authService: AuthService, ) {} async handleConnection(client: Socket) { try { const token = client.handshake.auth.token; const user = await this.authService.validateToken(token); client.data.user = user; client.join(`user:${user.id}`); // Cargar conversación activa si existe const activeConv = await this.chatService.getActiveConversation(user.id); if (activeConv) { client.emit('conversation:restored', activeConv); } } catch (error) { client.disconnect(); } } handleDisconnect(client: Socket) { // Cleanup } @SubscribeMessage('message:send') async handleMessage( @ConnectedSocket() client: Socket, @MessageBody() data: { conversationId: string; content: string }, ) { const user = client.data.user; // Rate limiting check const canSend = await this.chatService.checkRateLimit(user.id, user.plan); if (!canSend) { client.emit('error', { code: 'RATE_LIMIT', message: 'Daily limit reached' }); return; } // Save user message const userMessage = await this.chatService.saveMessage({ conversationId: data.conversationId, role: 'user', content: data.content, }); client.emit('message:saved', userMessage); client.emit('agent:thinking', { status: 'processing' }); // Process with agent (streaming) const stream = await this.agentService.processMessage({ userId: user.id, conversationId: data.conversationId, message: data.content, userPlan: user.plan, }); let fullResponse = ''; for await (const chunk of stream) { fullResponse += chunk.content; client.emit('agent:stream', { chunk: chunk.content, toolCalls: chunk.toolCalls, }); } // Save complete response const agentMessage = await this.chatService.saveMessage({ conversationId: data.conversationId, role: 'assistant', content: fullResponse, }); client.emit('agent:complete', agentMessage); } @SubscribeMessage('message:cancel') async handleCancel( @ConnectedSocket() client: Socket, @MessageBody() data: { conversationId: string }, ) { await this.agentService.cancelGeneration(data.conversationId); client.emit('agent:cancelled'); } } ``` ### 2. Chat Service ```typescript // src/modules/copilot/services/chat.service.ts @Injectable() export class ChatService { constructor( @InjectRepository(Conversation) private conversationRepo: Repository, @InjectRepository(Message) private messageRepo: Repository, private readonly redis: RedisService, ) {} async createConversation(userId: string, title?: string): Promise { const conversation = this.conversationRepo.create({ userId, title: title || `Conversación ${new Date().toLocaleDateString()}`, status: 'active', }); return this.conversationRepo.save(conversation); } async getConversations(userId: string, limit = 20): Promise { return this.conversationRepo.find({ where: { userId, deletedAt: null }, order: { updatedAt: 'DESC' }, take: limit, }); } async getConversationMessages( conversationId: string, limit = 50, offset = 0, ): Promise { return this.messageRepo.find({ where: { conversationId }, order: { createdAt: 'ASC' }, take: limit, skip: offset, }); } async saveMessage(data: CreateMessageDto): Promise { const message = this.messageRepo.create(data); const saved = await this.messageRepo.save(message); // Update conversation timestamp await this.conversationRepo.update( { id: data.conversationId }, { updatedAt: new Date() }, ); return saved; } async checkRateLimit(userId: string, plan: string): Promise { const limits = { free: 10, pro: 100, premium: -1, // unlimited }; if (plan === 'premium') return true; const key = `rate:chat:${userId}:${new Date().toISOString().split('T')[0]}`; const count = await this.redis.incr(key); if (count === 1) { await this.redis.expire(key, 86400); // 24 hours } return count <= limits[plan]; } async deleteConversation(conversationId: string, userId: string): Promise { await this.conversationRepo.update( { id: conversationId, userId }, { deletedAt: new Date() }, ); } async renameConversation( conversationId: string, userId: string, title: string, ): Promise { await this.conversationRepo.update( { id: conversationId, userId }, { title }, ); return this.conversationRepo.findOne({ where: { id: conversationId } }); } } ``` ### 3. Agent Service ```typescript // src/modules/copilot/services/agent.service.ts @Injectable() export class AgentService { private activeGenerations: Map = new Map(); constructor( private readonly llmProvider: LLMProviderService, private readonly contextService: ContextService, private readonly toolsService: ToolsService, ) {} async *processMessage(params: ProcessMessageParams): AsyncGenerator { const { userId, conversationId, message, userPlan } = params; const abortController = new AbortController(); this.activeGenerations.set(conversationId, abortController); try { // Build context const context = await this.contextService.buildContext({ userId, conversationId, currentMessage: message, userPlan, }); // Define available tools based on plan const tools = this.toolsService.getAvailableTools(userPlan); // Create LLM request const stream = await this.llmProvider.createChatCompletion({ model: this.getModelForPlan(userPlan), messages: [ { role: 'system', content: context.systemPrompt }, ...context.conversationHistory, { role: 'user', content: message }, ], tools, stream: true, signal: abortController.signal, }); // Process stream for await (const chunk of stream) { if (abortController.signal.aborted) { break; } // Handle tool calls if (chunk.toolCalls) { for (const toolCall of chunk.toolCalls) { const result = await this.toolsService.execute( toolCall.name, toolCall.arguments, userId, ); yield { type: 'tool_result', content: '', toolCalls: [{ ...toolCall, result }], }; } } // Yield text content if (chunk.content) { yield { type: 'text', content: chunk.content, toolCalls: null, }; } } } finally { this.activeGenerations.delete(conversationId); } } async cancelGeneration(conversationId: string): Promise { const controller = this.activeGenerations.get(conversationId); if (controller) { controller.abort(); } } private getModelForPlan(plan: string): string { const models = { free: 'gpt-4o-mini', pro: 'gpt-4o', premium: 'claude-3-5-sonnet', }; return models[plan] || models.free; } } ``` --- ## Componentes Frontend ### 1. Chat Store (Zustand) ```typescript // src/stores/chat.store.ts import { create } from 'zustand'; import { io, Socket } from 'socket.io-client'; interface ChatState { socket: Socket | null; connected: boolean; conversations: Conversation[]; activeConversation: Conversation | null; messages: Message[]; isStreaming: boolean; streamingContent: string; // Actions connect: (token: string) => void; disconnect: () => void; sendMessage: (content: string) => void; cancelGeneration: () => void; createConversation: () => void; selectConversation: (id: string) => void; deleteConversation: (id: string) => void; } export const useChatStore = create((set, get) => ({ socket: null, connected: false, conversations: [], activeConversation: null, messages: [], isStreaming: false, streamingContent: '', connect: (token: string) => { const socket = io(`${API_URL}/copilot`, { auth: { token }, transports: ['websocket'], }); socket.on('connect', () => { set({ connected: true }); }); socket.on('disconnect', () => { set({ connected: false }); }); socket.on('conversation:restored', (conversation) => { set({ activeConversation: conversation }); }); socket.on('message:saved', (message) => { set((state) => ({ messages: [...state.messages, message], })); }); socket.on('agent:thinking', () => { set({ isStreaming: true, streamingContent: '' }); }); socket.on('agent:stream', ({ chunk }) => { set((state) => ({ streamingContent: state.streamingContent + chunk, })); }); socket.on('agent:complete', (message) => { set((state) => ({ messages: [...state.messages, message], isStreaming: false, streamingContent: '', })); }); socket.on('agent:cancelled', () => { set({ isStreaming: false }); }); set({ socket }); }, disconnect: () => { get().socket?.disconnect(); set({ socket: null, connected: false }); }, sendMessage: (content: string) => { const { socket, activeConversation } = get(); if (!socket || !activeConversation) return; socket.emit('message:send', { conversationId: activeConversation.id, content, }); }, cancelGeneration: () => { const { socket, activeConversation } = get(); if (!socket || !activeConversation) return; socket.emit('message:cancel', { conversationId: activeConversation.id, }); }, // ... other actions })); ``` ### 2. Chat Component ```tsx // src/modules/copilot/components/ChatWindow.tsx import { useEffect, useRef } from 'react'; import { useChatStore } from '@/stores/chat.store'; import { MessageList } from './MessageList'; import { MessageInput } from './MessageInput'; import { ConversationSidebar } from './ConversationSidebar'; export function ChatWindow() { const { connected, connect, messages, isStreaming, streamingContent, sendMessage, cancelGeneration, } = useChatStore(); const messagesEndRef = useRef(null); const { token } = useAuth(); useEffect(() => { if (token && !connected) { connect(token); } return () => { useChatStore.getState().disconnect(); }; }, [token]); useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages, streamingContent]); return (
{isStreaming && (
)}
); } ``` --- ## Modelos de Datos ### Conversation Entity ```typescript // src/modules/copilot/entities/conversation.entity.ts @Entity('conversations') export class Conversation { @PrimaryGeneratedColumn('uuid') id: string; @Column({ name: 'user_id' }) userId: string; @ManyToOne(() => User) @JoinColumn({ name: 'user_id' }) user: User; @Column({ length: 255 }) title: string; @Column({ type: 'enum', enum: ['active', 'archived'], default: 'active', }) status: string; @Column({ type: 'jsonb', nullable: true }) metadata: Record; @Column({ name: 'messages_count', default: 0 }) messagesCount: number; @CreateDateColumn({ name: 'created_at' }) createdAt: Date; @UpdateDateColumn({ name: 'updated_at' }) updatedAt: Date; @DeleteDateColumn({ name: 'deleted_at' }) deletedAt: Date; @OneToMany(() => Message, (message) => message.conversation) messages: Message[]; } ``` ### Message Entity ```typescript // src/modules/copilot/entities/message.entity.ts @Entity('messages') export class Message { @PrimaryGeneratedColumn('uuid') id: string; @Column({ name: 'conversation_id' }) conversationId: string; @ManyToOne(() => Conversation, (conv) => conv.messages) @JoinColumn({ name: 'conversation_id' }) conversation: Conversation; @Column({ type: 'enum', enum: ['user', 'assistant', 'system'], }) role: string; @Column({ type: 'text' }) content: string; @Column({ type: 'jsonb', nullable: true, name: 'tool_calls' }) toolCalls: ToolCall[]; @Column({ type: 'jsonb', nullable: true }) metadata: Record; @Column({ type: 'smallint', nullable: true, name: 'feedback_rating' }) feedbackRating: number; @Column({ type: 'text', nullable: true, name: 'feedback_comment' }) feedbackComment: string; @Column({ name: 'tokens_input', nullable: true }) tokensInput: number; @Column({ name: 'tokens_output', nullable: true }) tokensOutput: number; @CreateDateColumn({ name: 'created_at' }) createdAt: Date; } interface ToolCall { id: string; name: string; arguments: Record; result?: any; } ``` --- ## API REST Endpoints | Method | Endpoint | Descripción | |--------|----------|-------------| | GET | `/api/copilot/conversations` | Listar conversaciones | | POST | `/api/copilot/conversations` | Crear conversación | | GET | `/api/copilot/conversations/:id` | Obtener conversación | | PATCH | `/api/copilot/conversations/:id` | Renombrar conversación | | DELETE | `/api/copilot/conversations/:id` | Eliminar conversación | | GET | `/api/copilot/conversations/:id/messages` | Obtener mensajes | | POST | `/api/copilot/messages/:id/feedback` | Dar feedback a mensaje | --- ## WebSocket Events ### Client → Server | Event | Payload | Descripción | |-------|---------|-------------| | `message:send` | `{ conversationId, content }` | Enviar mensaje | | `message:cancel` | `{ conversationId }` | Cancelar generación | | `conversation:create` | `{ title? }` | Crear conversación | | `typing:start` | `{ conversationId }` | Usuario escribiendo | ### Server → Client | Event | Payload | Descripción | |-------|---------|-------------| | `message:saved` | `Message` | Mensaje guardado | | `agent:thinking` | `{ status }` | Agente procesando | | `agent:stream` | `{ chunk, toolCalls? }` | Chunk de respuesta | | `agent:complete` | `Message` | Respuesta completa | | `agent:cancelled` | `{}` | Generación cancelada | | `error` | `{ code, message }` | Error | | `conversation:restored` | `Conversation` | Conversación restaurada | --- ## Dependencias ### NPM Packages Backend - `@nestjs/websockets` - `@nestjs/platform-socket.io` - `socket.io` - `openai` / `@anthropic-ai/sdk` ### NPM Packages Frontend - `socket.io-client` - `zustand` - `react-markdown` - `remark-gfm` --- ## Notas de Implementación 1. **Reconexión automática:** Socket.IO maneja reconexión, pero debemos restaurar estado 2. **Rate limiting:** Implementar tanto en WS como en Redis para precisión 3. **Streaming:** Usar Server-Sent Events como fallback si WS falla 4. **Tokens:** Trackear uso de tokens para billing y límites 5. **Seguridad:** Validar token JWT en cada conexión WS --- ## Referencias - [RF-LLM-001: Interfaz de Chat](../requerimientos/RF-LLM-001-chat-interface.md) - [ET-LLM-006: Gestión de Memoria](./ET-LLM-006-gestion-memoria.md) --- *Especificación técnica - Sistema NEXUS* *Trading Platform*