import type { Request, Response } from "express"; import log from "../../services/log.js"; import options from "../../services/options.js"; // Import the index service for knowledge base management import indexService from "../../services/llm/index_service.js"; import restChatService from "../../services/llm/rest_chat_service.js"; import chatStorageService from '../../services/llm/chat_storage_service.js'; // Define basic interfaces interface ChatMessage { role: 'user' | 'assistant' | 'system'; content: string; timestamp?: Date; } /** * @swagger * /api/llm/sessions: * post: * summary: Create a new LLM chat session * operationId: llm-create-session * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * title: * type: string * description: Title for the chat session * systemPrompt: * type: string * description: System message to set the behavior of the assistant * temperature: * type: number * description: Temperature parameter for the LLM (0.0-1.0) * maxTokens: * type: integer * description: Maximum tokens to generate in responses * model: * type: string * description: Specific model to use (depends on provider) * provider: * type: string * description: LLM provider to use (e.g., 'openai', 'anthropic', 'ollama') * contextNoteId: * type: string * description: Note ID to use as context for the session * responses: * '200': * description: Successfully created session * content: * application/json: * schema: * type: object * properties: * sessionId: * type: string * title: * type: string * createdAt: * type: string * format: date-time * security: * - session: [] * tags: ["llm"] */ async function createSession(req: Request, res: Response) { return restChatService.createSession(req, res); } /** * @swagger * /api/llm/sessions/{sessionId}: * get: * summary: Retrieve a specific chat session * operationId: llm-get-session * parameters: * - name: sessionId * in: path * required: true * schema: * type: string * responses: * '200': * description: Chat session details * content: * application/json: * schema: * type: object * properties: * id: * type: string * title: * type: string * messages: * type: array * items: * type: object * properties: * role: * type: string * enum: [user, assistant, system] * content: * type: string * timestamp: * type: string * format: date-time * createdAt: * type: string * format: date-time * lastActive: * type: string * format: date-time * '404': * description: Session not found * security: * - session: [] * tags: ["llm"] */ async function getSession(req: Request, res: Response) { return restChatService.getSession(req, res); } /** * @swagger * /api/llm/chat/{chatNoteId}: * patch: * summary: Update a chat's settings * operationId: llm-update-chat * parameters: * - name: chatNoteId * in: path * required: true * schema: * type: string * description: The ID of the chat note (formerly sessionId) * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * title: * type: string * description: Updated title for the session * systemPrompt: * type: string * description: Updated system prompt * temperature: * type: number * description: Updated temperature setting * maxTokens: * type: integer * description: Updated maximum tokens setting * model: * type: string * description: Updated model selection * provider: * type: string * description: Updated provider selection * contextNoteId: * type: string * description: Updated note ID for context * responses: * '200': * description: Session successfully updated * content: * application/json: * schema: * type: object * properties: * id: * type: string * title: * type: string * updatedAt: * type: string * format: date-time * '404': * description: Session not found * security: * - session: [] * tags: ["llm"] */ async function updateSession(req: Request, res: Response) { // Get the chat using chatStorageService directly const chatNoteId = req.params.chatNoteId; const updates = req.body; try { // Get the chat const chat = await chatStorageService.getChat(chatNoteId); if (!chat) { throw new Error(`Chat with ID ${chatNoteId} not found`); } // Update title if provided if (updates.title) { await chatStorageService.updateChat(chatNoteId, chat.messages, updates.title); } // Return the updated chat return { id: chatNoteId, title: updates.title || chat.title, updatedAt: new Date() }; } catch (error) { log.error(`Error updating chat: ${error}`); throw new Error(`Failed to update chat: ${error}`); } } /** * @swagger * /api/llm/sessions: * get: * summary: List all chat sessions * operationId: llm-list-sessions * responses: * '200': * description: List of chat sessions * content: * application/json: * schema: * type: array * items: * type: object * properties: * id: * type: string * title: * type: string * createdAt: * type: string * format: date-time * lastActive: * type: string * format: date-time * messageCount: * type: integer * security: * - session: [] * tags: ["llm"] */ async function listSessions(req: Request, res: Response) { // Get all sessions using chatStorageService directly try { const chats = await chatStorageService.getAllChats(); // Format the response return { sessions: chats.map(chat => ({ id: chat.id, title: chat.title, createdAt: chat.createdAt || new Date(), lastActive: chat.updatedAt || new Date(), messageCount: chat.messages.length })) }; } catch (error) { log.error(`Error listing sessions: ${error}`); throw new Error(`Failed to list sessions: ${error}`); } } /** * @swagger * /api/llm/sessions/{sessionId}: * delete: * summary: Delete a chat session * operationId: llm-delete-session * parameters: * - name: sessionId * in: path * required: true * schema: * type: string * responses: * '200': * description: Session successfully deleted * '404': * description: Session not found * security: * - session: [] * tags: ["llm"] */ async function deleteSession(req: Request, res: Response) { return restChatService.deleteSession(req, res); } /** * @swagger * /api/llm/chat/{chatNoteId}/messages: * post: * summary: Send a message to an LLM and get a response * operationId: llm-send-message * parameters: * - name: chatNoteId * in: path * required: true * schema: * type: string * description: The ID of the chat note (formerly sessionId) * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * message: * type: string * description: The user message to send to the LLM * options: * type: object * description: Optional parameters for this specific message * properties: * temperature: * type: number * maxTokens: * type: integer * model: * type: string * provider: * type: string * includeContext: * type: boolean * description: Whether to include relevant notes as context * useNoteContext: * type: boolean * description: Whether to use the session's context note * responses: * '200': * description: LLM response * content: * application/json: * schema: * type: object * properties: * response: * type: string * sources: * type: array * items: * type: object * properties: * noteId: * type: string * title: * type: string * similarity: * type: number * sessionId: * type: string * '404': * description: Session not found * '500': * description: Error processing request * security: * - session: [] * tags: ["llm"] */ async function sendMessage(req: Request, res: Response) { return restChatService.handleSendMessage(req, res); } /** * @swagger * /api/llm/indexes/stats: * get: * summary: Get stats about the LLM knowledge base indexing status * operationId: llm-index-stats * responses: * '200': * description: Index stats successfully retrieved * security: * - session: [] * tags: ["llm"] */ async function getIndexStats(req: Request, res: Response) { try { // Check if AI is enabled const aiEnabled = await options.getOptionBool('aiEnabled'); if (!aiEnabled) { return { success: false, message: "AI features are disabled" }; } // Return indexing stats const stats = await indexService.getIndexingStats(); return { success: true, ...stats }; } catch (error: any) { log.error(`Error getting index stats: ${error.message || 'Unknown error'}`); throw new Error(`Failed to get index stats: ${error.message || 'Unknown error'}`); } } /** * @swagger * /api/llm/indexes: * post: * summary: Start or continue indexing the knowledge base * operationId: llm-start-indexing * requestBody: * required: false * content: * application/json: * schema: * type: object * properties: * force: * type: boolean * description: Whether to force reindexing of all notes * responses: * '200': * description: Indexing started successfully * security: * - session: [] * tags: ["llm"] */ async function startIndexing(req: Request, res: Response) { try { // Check if AI is enabled const aiEnabled = await options.getOptionBool('aiEnabled'); if (!aiEnabled) { return { success: false, message: "AI features are disabled" }; } const { force = false } = req.body; // Start indexing await indexService.startFullIndexing(force); return { success: true, message: "Indexing started" }; } catch (error: any) { log.error(`Error starting indexing: ${error.message || 'Unknown error'}`); throw new Error(`Failed to start indexing: ${error.message || 'Unknown error'}`); } } /** * @swagger * /api/llm/indexes/failed: * get: * summary: Get list of notes that failed to index * operationId: llm-failed-indexes * parameters: * - name: limit * in: query * required: false * schema: * type: integer * default: 100 * responses: * '200': * description: Failed indexes successfully retrieved * security: * - session: [] * tags: ["llm"] */ async function getFailedIndexes(req: Request, res: Response) { try { // Check if AI is enabled const aiEnabled = await options.getOptionBool('aiEnabled'); if (!aiEnabled) { return { success: false, message: "AI features are disabled" }; } const limit = parseInt(req.query.limit as string || "100", 10); // Get failed indexes const failed = await indexService.getFailedIndexes(limit); return { success: true, failed }; } catch (error: any) { log.error(`Error getting failed indexes: ${error.message || 'Unknown error'}`); throw new Error(`Failed to get failed indexes: ${error.message || 'Unknown error'}`); } } /** * @swagger * /api/llm/indexes/notes/{noteId}: * put: * summary: Retry indexing a specific note that previously failed * operationId: llm-retry-index * parameters: * - name: noteId * in: path * required: true * schema: * type: string * responses: * '200': * description: Index retry successfully initiated * security: * - session: [] * tags: ["llm"] */ async function retryFailedIndex(req: Request, res: Response) { try { // Check if AI is enabled const aiEnabled = await options.getOptionBool('aiEnabled'); if (!aiEnabled) { return { success: false, message: "AI features are disabled" }; } const { noteId } = req.params; // Retry indexing the note const result = await indexService.retryFailedNote(noteId); return { success: true, message: result ? "Note queued for indexing" : "Failed to queue note for indexing" }; } catch (error: any) { log.error(`Error retrying failed index: ${error.message || 'Unknown error'}`); throw new Error(`Failed to retry index: ${error.message || 'Unknown error'}`); } } /** * @swagger * /api/llm/indexes/failed: * put: * summary: Retry indexing all failed notes * operationId: llm-retry-all-indexes * responses: * '200': * description: Retry of all failed indexes successfully initiated * security: * - session: [] * tags: ["llm"] */ async function retryAllFailedIndexes(req: Request, res: Response) { try { // Check if AI is enabled const aiEnabled = await options.getOptionBool('aiEnabled'); if (!aiEnabled) { return { success: false, message: "AI features are disabled" }; } // Retry all failed notes const count = await indexService.retryAllFailedNotes(); return { success: true, message: `${count} notes queued for reprocessing` }; } catch (error: any) { log.error(`Error retrying all failed indexes: ${error.message || 'Unknown error'}`); throw new Error(`Failed to retry all indexes: ${error.message || 'Unknown error'}`); } } /** * @swagger * /api/llm/indexes/notes/similar: * get: * summary: Find notes similar to a query string * operationId: llm-find-similar-notes * parameters: * - name: query * in: query * required: true * schema: * type: string * - name: contextNoteId * in: query * required: false * schema: * type: string * - name: limit * in: query * required: false * schema: * type: integer * default: 5 * responses: * '200': * description: Similar notes found successfully * security: * - session: [] * tags: ["llm"] */ async function findSimilarNotes(req: Request, res: Response) { try { // Check if AI is enabled const aiEnabled = await options.getOptionBool('aiEnabled'); if (!aiEnabled) { return { success: false, message: "AI features are disabled" }; } const query = req.query.query as string; const contextNoteId = req.query.contextNoteId as string | undefined; const limit = parseInt(req.query.limit as string || "5", 10); if (!query) { return { success: false, message: "Query is required" }; } // Find similar notes const similar = await indexService.findSimilarNotes(query, contextNoteId, limit); return { success: true, similar }; } catch (error: any) { log.error(`Error finding similar notes: ${error.message || 'Unknown error'}`); throw new Error(`Failed to find similar notes: ${error.message || 'Unknown error'}`); } } /** * @swagger * /api/llm/indexes/context: * get: * summary: Generate context for an LLM query based on the knowledge base * operationId: llm-generate-context * parameters: * - name: query * in: query * required: true * schema: * type: string * - name: contextNoteId * in: query * required: false * schema: * type: string * - name: depth * in: query * required: false * schema: * type: integer * default: 2 * responses: * '200': * description: Context generated successfully * security: * - session: [] * tags: ["llm"] */ async function generateQueryContext(req: Request, res: Response) { try { // Check if AI is enabled const aiEnabled = await options.getOptionBool('aiEnabled'); if (!aiEnabled) { return { success: false, message: "AI features are disabled" }; } const query = req.query.query as string; const contextNoteId = req.query.contextNoteId as string | undefined; const depth = parseInt(req.query.depth as string || "2", 10); if (!query) { return { success: false, message: "Query is required" }; } // Generate context const context = await indexService.generateQueryContext(query, contextNoteId, depth); return { success: true, context }; } catch (error: any) { log.error(`Error generating query context: ${error.message || 'Unknown error'}`); throw new Error(`Failed to generate query context: ${error.message || 'Unknown error'}`); } } /** * @swagger * /api/llm/indexes/notes/{noteId}: * post: * summary: Index a specific note for LLM knowledge base * operationId: llm-index-note * parameters: * - name: noteId * in: path * required: true * schema: * type: string * responses: * '200': * description: Note indexed successfully * security: * - session: [] * tags: ["llm"] */ async function indexNote(req: Request, res: Response) { try { // Check if AI is enabled const aiEnabled = await options.getOptionBool('aiEnabled'); if (!aiEnabled) { return { success: false, message: "AI features are disabled" }; } const { noteId } = req.params; if (!noteId) { return { success: false, message: "Note ID is required" }; } // Index the note const result = await indexService.generateNoteIndex(noteId); return { success: true, message: result ? "Note indexed successfully" : "Failed to index note" }; } catch (error: any) { log.error(`Error indexing note: ${error.message || 'Unknown error'}`); throw new Error(`Failed to index note: ${error.message || 'Unknown error'}`); } } /** * @swagger * /api/llm/chat/{chatNoteId}/messages/stream: * post: * summary: Stream a message to an LLM via WebSocket * operationId: llm-stream-message * parameters: * - name: chatNoteId * in: path * required: true * schema: * type: string * description: The ID of the chat note to stream messages to (formerly sessionId) * requestBody: * required: true * content: * application/json: * schema: * type: object * properties: * content: * type: string * description: The user message to send to the LLM * useAdvancedContext: * type: boolean * description: Whether to use advanced context extraction * showThinking: * type: boolean * description: Whether to show thinking process in the response * responses: * '200': * description: Streaming started successfully * '404': * description: Session not found * '500': * description: Error processing request * security: * - session: [] * tags: ["llm"] */ async function streamMessage(req: Request, res: Response) { log.info("=== Starting streamMessage ==="); try { const chatNoteId = req.params.chatNoteId; const { content, useAdvancedContext, showThinking, mentions } = req.body; if (!content || typeof content !== 'string' || content.trim().length === 0) { return res.status(400).json({ success: false, error: 'Content cannot be empty' }); } // IMPORTANT: Immediately send a success response to the initial POST request // The client is waiting for this to confirm streaming has been initiated res.status(200).json({ success: true, message: 'Streaming initiated successfully' }); log.info(`Sent immediate success response for streaming setup`); // Create a new response object for streaming through WebSocket only // We won't use HTTP streaming since we've already sent the HTTP response // Get or create chat directly from storage (simplified approach) let chat = await chatStorageService.getChat(chatNoteId); if (!chat) { // Create a new chat if it doesn't exist chat = await chatStorageService.createChat('New Chat'); log.info(`Created new chat with ID: ${chat.id} for stream request`); } // Add the user message to the chat immediately chat.messages.push({ role: 'user', content }); // Save the chat to ensure the user message is recorded await chatStorageService.updateChat(chat.id, chat.messages, chat.title); // Process mentions if provided let enhancedContent = content; if (mentions && Array.isArray(mentions) && mentions.length > 0) { log.info(`Processing ${mentions.length} note mentions`); // Import note service to get note content const becca = (await import('../../becca/becca.js')).default; const mentionContexts: string[] = []; for (const mention of mentions) { try { const note = becca.getNote(mention.noteId); if (note && !note.isDeleted) { const noteContent = note.getContent(); if (noteContent && typeof noteContent === 'string' && noteContent.trim()) { mentionContexts.push(`\n\n--- Content from "${mention.title}" (${mention.noteId}) ---\n${noteContent}\n--- End of "${mention.title}" ---`); log.info(`Added content from note "${mention.title}" (${mention.noteId})`); } } else { log.info(`Referenced note not found or deleted: ${mention.noteId}`); } } catch (error) { log.error(`Error retrieving content for note ${mention.noteId}: ${error}`); } } // Enhance the content with note references if (mentionContexts.length > 0) { enhancedContent = `${content}\n\n=== Referenced Notes ===\n${mentionContexts.join('\n')}`; log.info(`Enhanced content with ${mentionContexts.length} note references`); } } // Import the WebSocket service to send immediate feedback const wsService = (await import('../../services/ws.js')).default; // Let the client know streaming has started wsService.sendMessageToAllClients({ type: 'llm-stream', chatNoteId: chatNoteId, thinking: showThinking ? 'Initializing streaming LLM response...' : undefined }); // Instead of trying to reimplement the streaming logic ourselves, // delegate to restChatService but set up the correct protocol: // 1. We've already sent a success response to the initial POST // 2. Now we'll have restChatService process the actual streaming through WebSocket try { // Import the WebSocket service for sending messages const wsService = (await import('../../services/ws.js')).default; // Create a simple pass-through response object that won't write to the HTTP response // but will allow restChatService to send WebSocket messages const dummyResponse = { writableEnded: false, // Implement methods that would normally be used by restChatService write: (_chunk: string) => { // Silent no-op - we're only using WebSocket return true; }, end: (_chunk?: string) => { // Log when streaming is complete via WebSocket log.info(`[${chatNoteId}] Completed HTTP response handling during WebSocket streaming`); return dummyResponse; }, setHeader: (name: string, _value: string) => { // Only log for content-type to reduce noise if (name.toLowerCase() === 'content-type') { log.info(`[${chatNoteId}] Setting up streaming for WebSocket only`); } return dummyResponse; } }; // Process the streaming now through WebSocket only try { log.info(`[${chatNoteId}] Processing LLM streaming through WebSocket after successful initiation at ${new Date().toISOString()}`); // Call restChatService with our enhanced request and dummy response // The important part is setting method to GET to indicate streaming mode await restChatService.handleSendMessage({ ...req, method: 'GET', // Indicate streaming mode query: { ...req.query, stream: 'true' // Add the required stream parameter }, body: { content: enhancedContent, useAdvancedContext: useAdvancedContext === true, showThinking: showThinking === true }, params: { chatNoteId } } as unknown as Request, dummyResponse as unknown as Response); log.info(`[${chatNoteId}] WebSocket streaming completed at ${new Date().toISOString()}`); } catch (streamError) { log.error(`[${chatNoteId}] Error during WebSocket streaming: ${streamError}`); // Send error message through WebSocket wsService.sendMessageToAllClients({ type: 'llm-stream', chatNoteId: chatNoteId, error: `Error during streaming: ${streamError}`, done: true }); } } catch (error) { log.error(`Error during streaming: ${error}`); // Send error to client via WebSocket wsService.sendMessageToAllClients({ type: 'llm-stream', chatNoteId: chatNoteId, error: `Error processing message: ${error}`, done: true }); } } catch (error: any) { log.error(`Error starting message stream: ${error.message}`); log.error(`Error starting message stream, can't communicate via WebSocket: ${error.message}`); } } export default { // Chat session management createSession, getSession, updateSession, listSessions, deleteSession, sendMessage, streamMessage, // Knowledge base index management getIndexStats, startIndexing, getFailedIndexes, retryFailedIndex, retryAllFailedIndexes, findSimilarNotes, generateQueryContext, indexNote };