UNPKG

contaigents

Version:

Modular AI Content Ecosystem with Audio Generation

413 lines (412 loc) • 20.7 kB
import { LLMFactory } from "./llm/LLMFactory.js"; import { ToolManager } from "./tools/ToolManager.js"; import { PromptTemplates } from "./prompts/PromptTemplates.js"; import { DebugLogger } from "./DebugLogger.js"; import { ConversationMessageBuilder, ToolResponseHandler } from "./conversation/MessageBuilder.js"; import { ImageContextService } from "./ImageContextService.js"; import { MemoryManager } from "./MemoryManager.js"; import { StateManager } from "./stateManager.js"; export class ChatService { constructor(baseDir, debugEnabled = false, debugFile) { this.sessions = new Map(); this.baseDir = baseDir || process.cwd(); this.toolManager = new ToolManager(this.baseDir); this.debugLogger = new DebugLogger(debugEnabled, debugFile); this.imageContextService = new ImageContextService(this.baseDir); this.memoryManager = new MemoryManager(this.toolManager); this.stateManager = new StateManager(this.baseDir); } getDebugLogger() { return this.debugLogger; } /** * Get memory manager for configuration and statistics */ getMemoryManager() { return this.memoryManager; } /** * Create a new chat session */ async createSession(options) { const sessionId = this.generateSessionId(); // Detect story project state const hasStoryProject = await this.stateManager.hasStoryProject(); let storyProjectState; if (hasStoryProject) { const parsed = await this.stateManager.readState(); if (parsed) { storyProjectState = parsed.state; console.log(`šŸ“– Story project detected: ${storyProjectState.workflow_state}`); } } const session = { id: sessionId, messages: [], createdAt: Date.now(), updatedAt: Date.now(), systemPrompt: options?.systemPrompt || PromptTemplates.getDefaultSystemPrompt(), hasStoryProject, storyProjectState }; // Update system prompt with state context using helper method this.updateSystemPromptWithState(session); // Add system message with the updated prompt if (session.systemPrompt) { session.messages.push({ id: this.generateMessageId(), role: 'system', content: session.systemPrompt, timestamp: Date.now() }); } this.sessions.set(sessionId, session); return session; } /** * Get an existing chat session */ getSession(sessionId) { return this.sessions.get(sessionId); } /** * Restore a session from persistence */ restoreSession(session) { this.sessions.set(session.id, session); } /** * Send a message and get a response */ async sendMessage(sessionId, userMessage, options) { const session = this.sessions.get(sessionId); if (!session) { throw new Error(`Chat session ${sessionId} not found`); } // Refresh story project state before processing message // This ensures we have the latest state in case it changed externally await this.refreshStoryProjectState(sessionId); // Process images if present in the message let processedImages = []; if (this.imageContextService.hasImageReferences(userMessage)) { console.log('šŸ–¼ļø Detecting images in user message...'); const imageReferences = this.imageContextService.extractImageReferences(userMessage); if (imageReferences.length > 0) { console.log(`šŸ“ø Found ${imageReferences.length} image reference(s): ${imageReferences.join(', ')}`); processedImages = await this.imageContextService.processImages(imageReferences); if (processedImages.length > 0) { console.log(`āœ… Successfully processed ${processedImages.length} image(s) for LLM context`); console.log(`šŸ“Š ${this.imageContextService.createImageSummary(processedImages)}`); } } } // Add user message to session with images const userMsg = { id: this.generateMessageId(), role: 'user', content: userMessage, timestamp: Date.now(), ...(processedImages.length > 0 && { images: processedImages }) }; session.messages.push(userMsg); try { // Get LLM provider const llm = await this.getLLMProvider(options?.provider); // Execute prompt with conversation context const promptOptions = { temperature: options?.temperature ?? 0.7, maxTokens: options?.maxTokens ?? 10000 // Increased from 2000 to handle complex tool calls }; // Tool call loop with turn tracking const maxTurns = 10; // Maximum number of tool call iterations let currentTurn = 0; // Build messages using hybrid approach const actualProviderName = this.getActualProviderName(llm); const messageData = await ConversationMessageBuilder.buildForProvider(session, userMessage, actualProviderName, this.toolManager); let currentResponse; // Use appropriate execution method based on provider capabilities if (llm.supportsConversation() && Array.isArray(messageData)) { // Log conversation messages for debugging await this.debugLogger.logPrompt(JSON.stringify(messageData, null, 2), promptOptions, sessionId, actualProviderName, options?.model); currentResponse = await llm.executeConversation(messageData, promptOptions); } else { // Fallback to prompt string for simple providers const promptString = typeof messageData === 'string' ? messageData : JSON.stringify(messageData); await this.debugLogger.logPrompt(promptString, promptOptions, sessionId, actualProviderName, options?.model); currentResponse = await llm.executePrompt(promptString, promptOptions); } // Log the initial response await this.debugLogger.logResponse(currentResponse.content, undefined, // PromptResponse doesn't have usage field sessionId, actualProviderName, options?.model); while (currentTurn < maxTurns) { currentTurn++; console.log(`šŸ”„ Turn ${currentTurn}/${maxTurns}`); // Parse tool calls from current response const toolCalls = this.toolManager.parseToolCalls(currentResponse.content); if (toolCalls.length === 0) { // No tool calls - this is the final response console.log(`āœ… Final response (no tool calls) on turn ${currentTurn}`); const finalMsg = { id: this.generateMessageId(), role: 'assistant', content: currentResponse.content, timestamp: Date.now() }; // Trim conversation history and cleanup old tool responses await this.memoryManager.trimConversationHistory(session); this.memoryManager.cleanupToolResponses(session, currentTurn); session.messages.push(finalMsg); session.updatedAt = Date.now(); return finalMsg; } // Check if this is end-of-turn memory only const assistantMsg = { id: this.generateMessageId(), role: 'assistant', content: currentResponse.content, timestamp: Date.now() }; if (await this.memoryManager.processEndOfTurnMemory(session, toolCalls, assistantMsg)) { // End-of-turn memory was processed, return without additional LLM turn console.log(`āœ… End-of-turn memory processed on turn ${currentTurn}`); // Trim conversation history await this.memoryManager.trimConversationHistory(session); this.memoryManager.cleanupToolResponses(session, currentTurn); return assistantMsg; } // Process tool calls console.log(`šŸ”§ Processing ${toolCalls.length} tool calls on turn ${currentTurn}...`); // Log each tool call for (const toolCall of toolCalls) { await this.debugLogger.logToolCall(toolCall.tool_name, toolCall.parameters, sessionId); } // Add the assistant message with tool calls to session const assistantMsgWithTools = { id: this.generateMessageId(), role: 'assistant', content: currentResponse.content, timestamp: Date.now() }; session.messages.push(assistantMsgWithTools); // Execute tool calls and collect results const toolResponses = []; for (const toolCall of toolCalls) { console.log(` šŸ› ļø Executing tool: ${toolCall.tool_name} with parameters:`, toolCall.parameters); const toolResult = await this.toolManager.executeTool(toolCall); // Generate unique ID if not provided const responseId = toolCall.id || `${toolCall.tool_name}_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`; const toolResponse = { id: responseId, tool_call: toolCall, result: toolResult }; toolResponses.push(toolResponse); // Log tool response await this.debugLogger.logToolResponse(toolCall.tool_name, toolResult.success, toolResult.success ? toolResult.message : undefined, toolResult.success ? undefined : toolResult.error, sessionId); if (toolResult.success) { console.log(` āœ… Tool execution successful: ${toolResult.message}`); } else { console.log(` āŒ Tool execution failed: ${toolResult.error}`); } } // Store tool responses in memory automatically await this.memoryManager.processToolResponses(session, toolResponses); // Integrate tool responses using the new handler await ToolResponseHandler.integrateToolResponses(session, toolResponses, actualProviderName, this.toolManager); // Refresh story project state after tool executions // This is critical for state-aware tools like cinematic_workflow await this.refreshStoryProjectState(sessionId); // Mark tool responses with current turn for lifecycle management const toolResponseMessages = session.messages.filter(m => m.isToolResponse && !m.toolResponseTurn); toolResponseMessages.forEach(msg => { msg.toolResponseTurn = currentTurn; }); // Generate next response with tool results console.log(`šŸ¤– Generating follow-up response with tool results (turn ${currentTurn})...`); try { // Build follow-up messages using hybrid approach const followUpMessageData = await ConversationMessageBuilder.buildForProvider(session, '', // No new user message, just continuing conversation actualProviderName, this.toolManager); // Use appropriate execution method based on provider capabilities if (llm.supportsConversation() && Array.isArray(followUpMessageData)) { // Log conversation messages for debugging await this.debugLogger.logPrompt(JSON.stringify(followUpMessageData, null, 2), promptOptions, sessionId, actualProviderName, options?.model); currentResponse = await llm.executeConversation(followUpMessageData, promptOptions); } else { // Fallback to prompt string for simple providers const promptString = typeof followUpMessageData === 'string' ? followUpMessageData : JSON.stringify(followUpMessageData); await this.debugLogger.logPrompt(promptString, promptOptions, sessionId, actualProviderName, options?.model); currentResponse = await llm.executePrompt(promptString, promptOptions); } // Log follow-up response await this.debugLogger.logResponse(currentResponse.content, undefined, // PromptResponse doesn't have usage field sessionId, actualProviderName, options?.model); } catch (error) { console.error(`āŒ Error generating follow-up response on turn ${currentTurn}:`, error); // Log the error await this.debugLogger.logError(`Error generating follow-up response on turn ${currentTurn}: ${error.message}`, error.stack, sessionId); const errorContent = PromptTemplates.getErrorHandlingPrompt(error.message); const errorMsg = { id: this.generateMessageId(), role: 'assistant', content: errorContent, timestamp: Date.now() }; session.messages.push(errorMsg); session.updatedAt = Date.now(); return errorMsg; } } // Max turns reached - return current response with warning console.log(`āš ļø Maximum turns (${maxTurns}) reached. Returning current response.`); const maxTurnsMsg = { id: this.generateMessageId(), role: 'assistant', content: `${currentResponse.content}\n\nāš ļø *Note: Maximum tool call iterations (${maxTurns}) reached. Some operations may be incomplete.*`, timestamp: Date.now() }; session.messages.push(maxTurnsMsg); session.updatedAt = Date.now(); return maxTurnsMsg; } catch (error) { console.error('Error in chat service:', error); throw new Error(`Failed to generate response: ${error.message}`); } } /** * Get all messages from a session (filtered for UI display) */ getMessages(sessionId) { const session = this.sessions.get(sessionId); return session ? session.messages.filter(msg => msg.role !== 'system' && !msg.metadata?.internal && !msg.metadata?.isToolResponse) : []; } /** * Get all messages from a session including tool responses (for internal use) */ getAllMessages(sessionId) { const session = this.sessions.get(sessionId); return session ? session.messages : []; } /** * Clear a chat session */ clearSession(sessionId) { return this.sessions.delete(sessionId); } /** * List all active sessions */ listSessions() { return Array.from(this.sessions.values()); } async getLLMProvider(providerName) { if (providerName) { const provider = LLMFactory.getProvider(providerName); // Ensure the provider is configured if (!(await provider.isConfigured())) { throw new Error(`${providerName} provider is not configured. Please configure it first.`); } return provider; } // Try to get a configured provider const configuredProvider = await LLMFactory.getConfiguredProvider(); if (configuredProvider) { return configuredProvider; } throw new Error('No LLM provider configured. Please configure a provider first.'); } /** * Refresh story project state for a session * Call this after tool executions that might have changed the state * Also updates the system prompt with latest state context */ async refreshStoryProjectState(sessionId) { const session = this.sessions.get(sessionId); if (!session) { return; } const hasStoryProject = await this.stateManager.hasStoryProject(); const previousState = session.storyProjectState?.workflow_state; session.hasStoryProject = hasStoryProject; if (hasStoryProject) { const parsed = await this.stateManager.readState(); if (parsed) { session.storyProjectState = parsed.state; // Log state changes for visibility if (previousState && previousState !== parsed.state.workflow_state) { console.log(`šŸ”„ Story project state changed: ${previousState} → ${parsed.state.workflow_state}`); } } } else { session.storyProjectState = undefined; } // Update system prompt with latest state context // This ensures the LLM always has current state information this.updateSystemPromptWithState(session); } /** * Update session's system prompt with current state context * Called after state refresh to keep LLM informed of current state */ updateSystemPromptWithState(session) { // Get base system prompt (without state context) const basePrompt = PromptTemplates.getDefaultSystemPrompt(); // If story project exists, append state context if (session.hasStoryProject && session.storyProjectState) { const state = session.storyProjectState; let stateContext = `\n\n## Current Story Project Context\n\n`; stateContext += `**Story Project Detected**: A story_outline.md file exists in the current directory.\n\n`; stateContext += `**Current Workflow State**: ${state.workflow_state}\n`; const constraints = this.stateManager.getStateConstraints(state.workflow_state); stateContext += `**Allowed Operations**:\n`; if (constraints.canEditOutline) stateContext += `- āœ… Edit outline\n`; if (constraints.canGenerateScreenplay) stateContext += `- āœ… Create/update screenplay\n`; if (constraints.canGenerateSketches) stateContext += `- āœ… Generate sketches\n`; stateContext += `\n**User Preferences**:\n`; stateContext += `- Auto-generate sketches: ${state.auto_generate_sketches ? 'enabled' : 'disabled'}\n`; stateContext += `- Sketch style: ${state.sketch_style_preference}\n`; stateContext += `\n**IMPORTANT**: Respect the current state constraints. Operations not listed above will be blocked by the tools.\n`; session.systemPrompt = basePrompt + stateContext; } else { // No story project, use base prompt session.systemPrompt = basePrompt; } } /** * Get state manager for direct state operations */ getStateManager() { return this.stateManager; } getActualProviderName(llmProvider) { // Get the provider name from the LLM provider instance const config = llmProvider.getConfig(); // Check if the provider has a name in its config if (config && config.name) { return config.name; } // Fallback: try to determine from the constructor name const constructorName = llmProvider.constructor.name; if (constructorName.endsWith('Provider')) { return constructorName.replace('Provider', ''); } // Last resort: return a default return 'OpenAI'; // Default to OpenAI as it's most common } generateSessionId() { return `chat_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; } generateMessageId() { return `msg_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; } }