Notes/src/routes/api/llm.ts

690 lines
22 KiB
TypeScript
Raw Normal View History

import type { Request, Response } from "express";
import log from "../../services/log.js";
import options from "../../services/options.js";
// @ts-ignore
import { v4 as uuidv4 } from 'uuid';
import becca from "../../becca/becca.js";
import vectorStore from "../../services/llm/embeddings/vector_store.js";
import providerManager from "../../services/llm/embeddings/providers.js";
import type { Message, ChatCompletionOptions } from "../../services/llm/ai_interface.js";
// Import this way to prevent immediate instantiation
import * as aiServiceManagerModule from "../../services/llm/ai_service_manager.js";
2025-03-10 03:34:48 +00:00
import triliumContextService from "../../services/llm/trilium_context_service.js";
import sql from "../../services/sql.js";
// Define basic interfaces
interface ChatMessage {
role: 'user' | 'assistant' | 'system';
content: string;
timestamp?: Date;
}
interface ChatSession {
id: string;
title: string;
messages: ChatMessage[];
createdAt: Date;
lastActive: Date;
noteContext?: string; // Optional noteId that provides context
metadata: Record<string, any>;
}
interface NoteSource {
noteId: string;
title: string;
content?: string;
similarity?: number;
branchId?: string;
}
interface SessionOptions {
title?: string;
systemPrompt?: string;
temperature?: number;
maxTokens?: number;
model?: string;
provider?: string;
contextNoteId?: string;
}
// In-memory storage for sessions
// In a production app, this should be stored in a database
const sessions = new Map<string, ChatSession>();
// Flag to track if cleanup timer has been initialized
let cleanupInitialized = false;
/**
* Initialize the cleanup timer if not already running
* Only call this after database is initialized
*/
function initializeCleanupTimer() {
if (cleanupInitialized) {
return;
}
// Utility function to clean sessions older than 12 hours
function cleanupOldSessions() {
const twelveHoursAgo = new Date(Date.now() - 12 * 60 * 60 * 1000);
for (const [sessionId, session] of sessions.entries()) {
if (session.lastActive < twelveHoursAgo) {
sessions.delete(sessionId);
}
}
}
// Run cleanup every hour
setInterval(cleanupOldSessions, 60 * 60 * 1000);
cleanupInitialized = true;
}
/**
* Check if the database is initialized
*/
function isDatabaseInitialized(): boolean {
try {
options.getOption('initialized');
return true;
} catch (error) {
return false;
}
}
/**
* Get the AI service manager in a way that doesn't crash at startup
*/
function safelyUseAIManager(): boolean {
// Only use AI manager if database is initialized
if (!isDatabaseInitialized()) {
return false;
}
// Try to access the manager - will create instance only if needed
try {
return aiServiceManagerModule.default.isAnyServiceAvailable();
} catch (error) {
log.error(`Error accessing AI service manager: ${error}`);
return false;
}
}
/**
* Create a new LLM chat session
*/
async function createSession(req: Request, res: Response) {
try {
// Initialize cleanup if not already done
initializeCleanupTimer();
const options: SessionOptions = req.body || {};
const title = options.title || 'Chat Session';
const sessionId = uuidv4();
const now = new Date();
// Initial system message if provided
const messages: ChatMessage[] = [];
if (options.systemPrompt) {
messages.push({
role: 'system',
content: options.systemPrompt,
timestamp: now
});
}
// Store session info
sessions.set(sessionId, {
id: sessionId,
title,
messages,
createdAt: now,
lastActive: now,
noteContext: options.contextNoteId,
metadata: {
temperature: options.temperature,
maxTokens: options.maxTokens,
model: options.model,
provider: options.provider
}
});
return {
id: sessionId,
title,
createdAt: now
};
} catch (error: any) {
log.error(`Error creating LLM session: ${error.message || 'Unknown error'}`);
throw new Error(`Failed to create LLM session: ${error.message || 'Unknown error'}`);
}
}
/**
* Get session details
*/
async function getSession(req: Request, res: Response) {
try {
const { sessionId } = req.params;
// Check if session exists
const session = sessions.get(sessionId);
if (!session) {
throw new Error(`Session with ID ${sessionId} not found`);
}
// Return session without internal metadata
return {
id: session.id,
title: session.title,
createdAt: session.createdAt,
lastActive: session.lastActive,
messages: session.messages,
noteContext: session.noteContext
};
} catch (error: any) {
log.error(`Error getting LLM session: ${error.message || 'Unknown error'}`);
throw new Error(`Failed to get session: ${error.message || 'Unknown error'}`);
}
}
/**
* Update session properties
*/
async function updateSession(req: Request, res: Response) {
try {
const { sessionId } = req.params;
const updates = req.body || {};
// Check if session exists
const session = sessions.get(sessionId);
if (!session) {
throw new Error(`Session with ID ${sessionId} not found`);
}
// Update allowed fields
if (updates.title) {
session.title = updates.title;
}
if (updates.noteContext) {
session.noteContext = updates.noteContext;
}
// Update metadata
if (updates.temperature !== undefined) {
session.metadata.temperature = updates.temperature;
}
if (updates.maxTokens !== undefined) {
session.metadata.maxTokens = updates.maxTokens;
}
if (updates.model) {
session.metadata.model = updates.model;
}
if (updates.provider) {
session.metadata.provider = updates.provider;
}
// Update timestamp
session.lastActive = new Date();
return {
id: session.id,
title: session.title,
updatedAt: session.lastActive
};
} catch (error: any) {
log.error(`Error updating LLM session: ${error.message || 'Unknown error'}`);
throw new Error(`Failed to update session: ${error.message || 'Unknown error'}`);
}
}
/**
* List active sessions
*/
async function listSessions(req: Request, res: Response) {
try {
const sessionList = Array.from(sessions.values()).map(session => ({
id: session.id,
title: session.title,
createdAt: session.createdAt,
lastActive: session.lastActive,
messageCount: session.messages.length
}));
// Sort by last activity (most recent first)
sessionList.sort((a, b) => b.lastActive.getTime() - a.lastActive.getTime());
return {
sessions: sessionList
};
} catch (error: any) {
log.error(`Error listing LLM sessions: ${error.message || 'Unknown error'}`);
throw new Error(`Failed to list sessions: ${error.message || 'Unknown error'}`);
}
}
/**
* Delete a session
*/
async function deleteSession(req: Request, res: Response) {
try {
const { sessionId } = req.params;
// Check if session exists
if (!sessions.has(sessionId)) {
throw new Error(`Session with ID ${sessionId} not found`);
}
// Delete session
sessions.delete(sessionId);
return {
success: true,
message: `Session ${sessionId} deleted successfully`
};
} catch (error: any) {
log.error(`Error deleting LLM session: ${error.message || 'Unknown error'}`);
throw new Error(`Failed to delete session: ${error.message || 'Unknown error'}`);
}
}
/**
2025-03-10 03:34:48 +00:00
* Find relevant notes based on search query
*/
2025-03-10 03:34:48 +00:00
async function findRelevantNotes(content: string, contextNoteId: string | null = null, limit = 5): Promise<NoteSource[]> {
try {
2025-03-10 03:34:48 +00:00
// If database is not initialized, we can't do this
if (!isDatabaseInitialized()) {
2025-03-10 03:34:48 +00:00
return [];
}
2025-03-10 03:34:48 +00:00
// Check if embeddings are available
const enabledProviders = await providerManager.getEnabledEmbeddingProviders();
if (enabledProviders.length === 0) {
log.info("No embedding providers available, can't find relevant notes");
return [];
}
2025-03-10 03:34:48 +00:00
// If content is too short, don't bother
if (content.length < 3) {
return [];
}
2025-03-10 03:34:48 +00:00
// Get the embedding for the query
const provider = enabledProviders[0];
const embedding = await provider.generateEmbeddings(content);
2025-03-10 03:34:48 +00:00
let results;
if (contextNoteId) {
2025-03-10 03:34:48 +00:00
// For branch context, get notes specifically from that branch
// TODO: This is a simplified implementation - we need to
// properly get all notes in the subtree starting from contextNoteId
// For now, just get direct children of the context note
const contextNote = becca.notes[contextNoteId];
if (!contextNote) {
return [];
}
const childBranches = await sql.getRows(`
SELECT branches.* FROM branches
WHERE branches.parentNoteId = ?
AND branches.isDeleted = 0
`, [contextNoteId]);
const childNoteIds = childBranches.map((branch: any) => branch.noteId);
// Include the context note itself
childNoteIds.push(contextNoteId);
// Find similar notes in this context
results = [];
for (const noteId of childNoteIds) {
const noteEmbedding = await vectorStore.getEmbeddingForNote(
noteId,
provider.name,
provider.getConfig().model
);
if (noteEmbedding) {
const similarity = vectorStore.cosineSimilarity(
embedding,
noteEmbedding.embedding
);
if (similarity > 0.65) {
results.push({
noteId,
2025-03-10 03:34:48 +00:00
similarity
});
}
}
}
2025-03-10 03:34:48 +00:00
// Sort by similarity
results.sort((a, b) => b.similarity - a.similarity);
results = results.slice(0, limit);
} else {
// General search across all notes
results = await vectorStore.findSimilarNotes(
embedding,
provider.name,
provider.getConfig().model,
limit
);
}
2025-03-10 03:34:48 +00:00
// Format the results
const sources: NoteSource[] = [];
2025-03-10 03:34:48 +00:00
for (const result of results) {
const note = becca.notes[result.noteId];
if (!note) continue;
2025-03-10 03:34:48 +00:00
let noteContent: string | undefined = undefined;
if (note.type === 'text') {
const content = note.getContent();
// Handle both string and Buffer types
noteContent = typeof content === 'string' ? content :
content instanceof Buffer ? content.toString('utf8') : undefined;
}
2025-03-10 03:34:48 +00:00
sources.push({
noteId: result.noteId,
title: note.title,
2025-03-10 03:34:48 +00:00
content: noteContent,
similarity: result.similarity,
branchId: note.getBranches()[0]?.branchId
});
}
return sources;
} catch (error: any) {
log.error(`Error finding relevant notes: ${error.message}`);
return [];
}
}
/**
2025-03-10 03:34:48 +00:00
* Build context from notes
*/
function buildContextFromNotes(sources: NoteSource[], query: string): string {
console.log("Building context from notes with query:", query);
console.log("Sources length:", sources ? sources.length : 0);
// If no sources are available, just return the query without additional context
if (!sources || sources.length === 0) {
console.log("No sources available, using just the query");
return query || '';
}
const noteContexts = sources
.filter(source => source.content) // Only include sources with content
.map((source, index) => {
// Format each note as a section in the context
return `[NOTE ${index + 1}: ${source.title}]\n${source.content || 'No content available'}`;
})
.join('\n\n');
if (!noteContexts) {
console.log("After filtering, no valid note contexts remain - using just the query");
return query || '';
}
// Build a complete context prompt
return `I'll provide you with relevant notes from my knowledge base to help answer the question. Please use this information when responding:
${noteContexts}
Now, based on the above notes, please answer: ${query}`;
}
/**
2025-03-10 03:34:48 +00:00
* Send a message to the AI
*/
async function sendMessage(req: Request, res: Response) {
try {
2025-03-10 03:34:48 +00:00
// Extract the content from the request body
const { content, sessionId, useAdvancedContext = false } = req.body || {};
2025-03-10 03:34:48 +00:00
// Validate the content
if (!content || typeof content !== 'string' || content.trim().length === 0) {
throw new Error('Content cannot be empty');
}
2025-03-10 03:34:48 +00:00
// Get or create the session
let session: ChatSession;
if (sessionId && sessions.has(sessionId)) {
session = sessions.get(sessionId)!;
session.lastActive = new Date();
} else {
const result = await createSession(req, res);
if (!result?.id) {
throw new Error('Failed to create a new session');
}
2025-03-10 03:34:48 +00:00
session = sessions.get(result.id)!;
}
2025-03-10 03:34:48 +00:00
// Check if AI services are available
if (!safelyUseAIManager()) {
throw new Error('AI services are not available');
}
2025-03-10 03:34:48 +00:00
// Get the AI service manager
const aiServiceManager = aiServiceManagerModule.default.getInstance();
// Get the default service - just use the first available one
const availableProviders = aiServiceManager.getAvailableProviders();
let service = null;
if (availableProviders.length > 0) {
// Use the first available provider
const providerName = availableProviders[0];
// We know the manager has a 'services' property from our code inspection,
// but TypeScript doesn't know that from the interface.
// This is a workaround to access it
service = (aiServiceManager as any).services[providerName];
}
2025-03-10 03:34:48 +00:00
if (!service) {
throw new Error('No AI service is available');
}
// Create user message
const userMessage: Message = {
role: 'user',
content
};
// Add message to session
session.messages.push({
role: 'user',
content,
timestamp: new Date()
});
// Log a preview of the message
log.info(`Processing LLM message: "${content.substring(0, 50)}${content.length > 50 ? '...' : ''}"`);
// Information to return to the client
let aiResponse = '';
let sourceNotes: NoteSource[] = [];
// If Advanced Context is enabled, we use the improved method
if (useAdvancedContext) {
// Use the Trilium-specific approach
const contextNoteId = session.noteContext || null;
const results = await triliumContextService.processQuery(content, service, contextNoteId);
// Get the generated context
const context = results.context;
sourceNotes = results.notes;
// Add system message with the context
const contextMessage: Message = {
role: 'system',
content: context
};
// Format all messages for the AI
const aiMessages: Message[] = [
2025-03-10 03:34:48 +00:00
contextMessage,
...session.messages.slice(-10).map(msg => ({
role: msg.role,
content: msg.content
}))
];
2025-03-10 03:34:48 +00:00
// Configure chat options from session metadata
const chatOptions: ChatCompletionOptions = {
temperature: session.metadata.temperature || 0.7,
maxTokens: session.metadata.maxTokens,
model: session.metadata.model
// 'provider' property has been removed as it's not in the ChatCompletionOptions type
};
2025-03-10 03:34:48 +00:00
// Get streaming response if requested
const acceptHeader = req.get('Accept');
if (acceptHeader && acceptHeader.includes('text/event-stream')) {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
let messageContent = '';
// Stream the response
await service.sendChatCompletion(
aiMessages,
chatOptions,
(chunk: string) => {
messageContent += chunk;
res.write(`data: ${JSON.stringify({ content: chunk })}\n\n`);
}
2025-03-10 03:34:48 +00:00
);
2025-03-10 03:34:48 +00:00
// Close the stream
res.write('data: [DONE]\n\n');
res.end();
2025-03-10 03:34:48 +00:00
// Store the full response
aiResponse = messageContent;
} else {
// Non-streaming approach
aiResponse = await service.sendChatCompletion(aiMessages, chatOptions);
}
2025-03-10 03:34:48 +00:00
} else {
// Original approach - find relevant notes through direct embedding comparison
const relevantNotes = await findRelevantNotes(
content,
session.noteContext || null,
5
);
2025-03-10 03:34:48 +00:00
sourceNotes = relevantNotes;
2025-03-10 03:34:48 +00:00
// Build context from relevant notes
const context = buildContextFromNotes(relevantNotes, content);
// Add system message with the context
const contextMessage: Message = {
role: 'system',
content: context
};
2025-03-10 03:34:48 +00:00
// Format all messages for the AI
const aiMessages: Message[] = [
contextMessage,
...session.messages.slice(-10).map(msg => ({
role: msg.role,
content: msg.content
}))
];
2025-03-10 03:34:48 +00:00
// Configure chat options from session metadata
const chatOptions: ChatCompletionOptions = {
temperature: session.metadata.temperature || 0.7,
maxTokens: session.metadata.maxTokens,
model: session.metadata.model
// 'provider' property has been removed as it's not in the ChatCompletionOptions type
};
2025-03-10 03:34:48 +00:00
// Get streaming response if requested
const acceptHeader = req.get('Accept');
if (acceptHeader && acceptHeader.includes('text/event-stream')) {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
let messageContent = '';
// Stream the response
await service.sendChatCompletion(
aiMessages,
chatOptions,
(chunk: string) => {
messageContent += chunk;
res.write(`data: ${JSON.stringify({ content: chunk })}\n\n`);
}
);
// Close the stream
res.write('data: [DONE]\n\n');
res.end();
2025-03-10 03:34:48 +00:00
// Store the full response
aiResponse = messageContent;
} else {
// Non-streaming approach
aiResponse = await service.sendChatCompletion(aiMessages, chatOptions);
}
}
// Only store the assistant's message if we're not streaming (otherwise we already did)
const acceptHeader = req.get('Accept');
if (!acceptHeader || !acceptHeader.includes('text/event-stream')) {
// Store the assistant's response in the session
session.messages.push({
role: 'assistant',
content: aiResponse,
timestamp: new Date()
});
// Return the response
return {
2025-03-10 03:34:48 +00:00
content: aiResponse,
sources: sourceNotes.map(note => ({
noteId: note.noteId,
title: note.title,
similarity: note.similarity,
branchId: note.branchId
}))
};
2025-03-10 03:34:48 +00:00
} else {
// For streaming responses, we've already sent the data
// But we still need to add the message to the session
session.messages.push({
role: 'assistant',
content: aiResponse,
timestamp: new Date()
});
}
} catch (error: any) {
2025-03-10 03:34:48 +00:00
log.error(`Error sending message to LLM: ${error.message}`);
throw new Error(`Failed to send message: ${error.message}`);
}
}
export default {
createSession,
getSession,
updateSession,
listSessions,
deleteSession,
sendMessage
};