UNPKG

@juspay/neurolink

Version:

Universal AI Development Platform with working MCP integration, multi-provider support, voice (TTS/STT/realtime), and professional CLI. 58+ external MCP servers discoverable, multimodal file processing, RAG pipelines. Build, test, and deploy AI applicatio

232 lines 9.11 kB
/** * Conversation Selector for Loop Mode * Handles discovery and selection of stored conversations from Redis */ import inquirer from "inquirer"; import chalk from "chalk"; import { createRedisClient, scanKeys, deserializeConversation, getNormalizedConfig, } from "../../lib/utils/redis.js"; import { logger } from "../../lib/utils/logger.js"; import { LOOP_CACHE_CONFIG, LOOP_DISPLAY_LIMITS, generateConversationTitle, truncateText, formatTimeAgo, getContentIcon, } from "../../lib/utils/loopUtils.js"; export class ConversationSelector { redisClient = null; redisConfig; conversationCache = null; cacheTimestamp = 0; constructor(redisConfig = {}) { this.redisConfig = getNormalizedConfig(redisConfig); } /** * Initialize Redis connection */ async initializeRedis() { if (!this.redisClient) { // Cast is necessary: createRedisClient returns the ioredis client type which // does not structurally match our CliRedisClient interface due to overloaded // method signatures. The runtime value is fully compatible. this.redisClient = (await createRedisClient(this.redisConfig)); } } /** * Get available conversations for a user */ async getAvailableConversations(userId) { // Check if cached conversations are still valid (within TTL) if (this.conversationCache && Date.now() - this.cacheTimestamp < LOOP_CACHE_CONFIG.TTL_MS) { logger.debug("Using cached conversation list"); return this.filterConversationsByUser(this.conversationCache, userId); } try { await this.initializeRedis(); if (!this.redisClient) { throw new Error("Redis client not available"); } const keys = await this.scanConversationKeys(); if (keys.length === 0) { logger.debug("No conversations found in Redis"); return []; } const summaries = await this.processConversationKeys(keys); const sortedSummaries = this.sortConversationsByDate(summaries); this.updateCache(sortedSummaries); return this.filterConversationsByUser(sortedSummaries, userId); } catch (error) { return this.handleRetrievalError(error); } } /** * Display conversation menu and get user selection */ async displayConversationMenu(userId) { try { const conversations = await this.getAvailableConversations(userId); if (conversations.length === 0) { logger.debug("No conversations available for selection"); return "NEW_CONVERSATION"; } const choices = this.createMenuChoices(conversations); return await this.showSelectionPrompt(choices); } catch (error) { return this.handleMenuError(error); } } /** * Check if there are any stored conversations */ async hasStoredConversations(userId) { try { const conversations = await this.getAvailableConversations(userId); return conversations.length > 0; } catch (error) { logger.debug("Failed to check for stored conversations:", error); return false; } } /** * Close Redis connection */ async close() { if (this.redisClient) { await this.redisClient.quit(); this.redisClient = null; } } async scanConversationKeys() { if (!this.redisClient) { throw new Error("Redis client not initialized"); } const pattern = `${this.redisConfig.keyPrefix}*`; const keys = await scanKeys(this.redisClient, pattern); logger.debug(`Found ${keys.length} conversation keys in Redis`); return keys; } async processConversationKeys(keys) { const summaries = []; for (const key of keys) { const summary = await this.processSingleConversationKey(key); if (summary) { summaries.push(summary); } } return summaries; } async processSingleConversationKey(key) { if (!this.redisClient) { logger.warn(`Redis client not available for key ${key}`); return null; } try { const conversationData = await this.redisClient.get(key); const conversation = deserializeConversation(conversationData); if (conversation && conversation.messages && conversation.messages.length > 0) { // Only include conversations with session IDs prefixed with "NL_" if (!conversation.sessionId?.startsWith("NL_")) { return null; } return this.createConversationSummary(conversation); } return null; } catch (error) { logger.warn(`Failed to process conversation key ${key}:`, error); return null; } } sortConversationsByDate(summaries) { return summaries.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); } updateCache(summaries) { this.conversationCache = summaries; this.cacheTimestamp = Date.now(); logger.debug(`Retrieved ${summaries.length} valid conversations`); } filterConversationsByUser(summaries, userId) { if (!userId) { return summaries; } return summaries.filter((summary) => summary.userId === userId); } /* * Create menu choices for inquirer prompt */ createMenuChoices(conversations) { const choices = [ { name: chalk.green("🆕 Start New Conversation"), value: "NEW_CONVERSATION", short: "New Conversation", }, // Cast is intentional: inquirer's Separator type is not exported cleanly // and does not overlap with MenuChoice, but inquirer accepts it at runtime. new inquirer.Separator(), ]; for (const conversation of conversations.slice(0, LOOP_DISPLAY_LIMITS.MAX_CONVERSATIONS)) { const choice = this.formatConversationChoice(conversation); choices.push(choice); } return choices; } async showSelectionPrompt(choices) { const answer = await inquirer.prompt([ { type: "select", name: "selectedConversation", message: "Select a conversation to continue:", choices, pageSize: LOOP_DISPLAY_LIMITS.PAGE_SIZE, }, ]); return answer.selectedConversation; } handleRetrievalError(error) { logger.error("Failed to retrieve conversations:", error); return []; } handleMenuError(error) { logger.error("Failed to display conversation menu:", error); return "NEW_CONVERSATION"; } createConversationSummary(conversation) { const messages = conversation.messages; const firstMessage = messages[0]; const lastMessage = messages[messages.length - 1]; return { sessionId: conversation.sessionId, id: conversation.id, title: conversation.title || generateConversationTitle(firstMessage.content), firstMessage: { content: truncateText(firstMessage.content, LOOP_DISPLAY_LIMITS.CONTENT_LENGTH), timestamp: firstMessage.timestamp || conversation.createdAt, }, lastMessage: { content: truncateText(lastMessage.content, LOOP_DISPLAY_LIMITS.CONTENT_LENGTH), timestamp: lastMessage.timestamp || conversation.updatedAt, }, messageCount: messages.length, userId: conversation.userId, duration: formatTimeAgo(conversation.updatedAt), createdAt: conversation.createdAt, updatedAt: conversation.updatedAt, }; } formatConversationChoice(summary) { const icon = getContentIcon(summary.firstMessage.content); const title = chalk.white(summary.title || "Untitled Conversation"); const duration = chalk.gray(`(${summary.duration})`); const details = chalk.gray(` └ ${summary.messageCount} message${summary.messageCount !== 1 ? "s" : ""} | ` + `Session: ${summary.sessionId.slice(0, LOOP_DISPLAY_LIMITS.SESSION_ID_DISPLAY)}... | ` + `Updated: ${new Date(summary.updatedAt).toLocaleString()}`); const name = `${icon} ${title} ${duration}\n${details}`; return { name, value: summary.sessionId, short: `${summary.title} (${summary.sessionId.slice(0, LOOP_DISPLAY_LIMITS.SESSION_ID_SHORT)}...)`, }; } } //# sourceMappingURL=conversationSelector.js.map