mirror of https://github.com/TriliumNext/Notes
I'm 100% going to have to destroy this commit later
parent
733fdcf8ba
commit
adaac46fbf
@ -1,22 +1,26 @@
|
||||
-- Add new options for AI/LLM integration
|
||||
INSERT INTO options (name, value, isSynced) VALUES ('aiEnabled', 'false', 1);
|
||||
INSERT INTO options (name, value, isSynced, utcDateModified) VALUES ('aiEnabled', 'false', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
|
||||
|
||||
-- OpenAI settings
|
||||
INSERT INTO options (name, value, isSynced) VALUES ('openaiApiKey', '', 1);
|
||||
INSERT INTO options (name, value, isSynced) VALUES ('openaiDefaultModel', 'gpt-4o', 1);
|
||||
INSERT INTO options (name, value, isSynced) VALUES ('openaiBaseUrl', 'https://api.openai.com/v1', 1);
|
||||
INSERT INTO options (name, value, isSynced, utcDateModified) VALUES ('openaiApiKey', '', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
|
||||
INSERT INTO options (name, value, isSynced, utcDateModified) VALUES ('openaiDefaultModel', 'gpt-4o', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
|
||||
INSERT INTO options (name, value, isSynced, utcDateModified) VALUES ('openaiBaseUrl', 'https://api.openai.com/v1', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
|
||||
|
||||
-- Anthropic settings
|
||||
INSERT INTO options (name, value, isSynced) VALUES ('anthropicApiKey', '', 1);
|
||||
INSERT INTO options (name, value, isSynced) VALUES ('anthropicDefaultModel', 'claude-3-opus-20240229', 1);
|
||||
INSERT INTO options (name, value, isSynced) VALUES ('anthropicBaseUrl', 'https://api.anthropic.com/v1', 1);
|
||||
INSERT INTO options (name, value, isSynced, utcDateModified) VALUES ('anthropicApiKey', '', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
|
||||
INSERT INTO options (name, value, isSynced, utcDateModified) VALUES ('anthropicDefaultModel', 'claude-3-opus-20240229', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
|
||||
INSERT INTO options (name, value, isSynced, utcDateModified) VALUES ('anthropicBaseUrl', 'https://api.anthropic.com/v1', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
|
||||
|
||||
-- Ollama settings
|
||||
INSERT INTO options (name, value, isSynced) VALUES ('ollamaEnabled', 'false', 1);
|
||||
INSERT INTO options (name, value, isSynced) VALUES ('ollamaBaseUrl', 'http://localhost:11434', 1);
|
||||
INSERT INTO options (name, value, isSynced) VALUES ('ollamaDefaultModel', 'llama3', 1);
|
||||
INSERT INTO options (name, value, isSynced, utcDateModified) VALUES ('ollamaEnabled', 'false', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
|
||||
INSERT INTO options (name, value, isSynced, utcDateModified) VALUES ('ollamaBaseUrl', 'http://localhost:11434', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
|
||||
INSERT INTO options (name, value, isSynced, utcDateModified) VALUES ('ollamaDefaultModel', 'llama3', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
|
||||
INSERT INTO options (name, value, isSynced, utcDateModified) VALUES ('ollamaEmbeddingModel', 'nomic-embed-text', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
|
||||
|
||||
-- General AI settings
|
||||
INSERT INTO options (name, value, isSynced) VALUES ('aiProviderPrecedence', 'openai,anthropic,ollama', 1);
|
||||
INSERT INTO options (name, value, isSynced) VALUES ('aiTemperature', '0.7', 1);
|
||||
INSERT INTO options (name, value, isSynced) VALUES ('aiSystemPrompt', '', 1);
|
||||
INSERT INTO options (name, value, isSynced, utcDateModified) VALUES ('aiProviderPrecedence', 'openai,anthropic,ollama', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
|
||||
INSERT INTO options (name, value, isSynced, utcDateModified) VALUES ('aiTemperature', '0.7', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
|
||||
INSERT INTO options (name, value, isSynced, utcDateModified) VALUES ('aiSystemPrompt', '', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
|
||||
|
||||
-- Embedding settings
|
||||
INSERT INTO options (name, value, isSynced, utcDateModified) VALUES ('embeddingsDefaultProvider', 'openai', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
|
||||
@ -0,0 +1,246 @@
|
||||
import BasicWidget from "./basic_widget.js";
|
||||
import toastService from "../services/toast.js";
|
||||
import server from "../services/server.js";
|
||||
import appContext from "../components/app_context.js";
|
||||
import utils from "../services/utils.js";
|
||||
import { t } from "../services/i18n.js";
|
||||
|
||||
interface ChatResponse {
|
||||
id: string;
|
||||
messages: Array<{role: string; content: string}>;
|
||||
sources?: Array<{noteId: string; title: string}>;
|
||||
}
|
||||
|
||||
interface SessionResponse {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export default class LlmChatPanel extends BasicWidget {
|
||||
private noteContextChatMessages!: HTMLElement;
|
||||
private noteContextChatForm!: HTMLFormElement;
|
||||
private noteContextChatInput!: HTMLTextAreaElement;
|
||||
private noteContextChatSendButton!: HTMLButtonElement;
|
||||
private chatContainer!: HTMLElement;
|
||||
private loadingIndicator!: HTMLElement;
|
||||
private sourcesList!: HTMLElement;
|
||||
private sessionId: string | null = null;
|
||||
private currentNoteId: string | null = null;
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(`
|
||||
<div class="note-context-chat h-100 w-100 d-flex flex-column">
|
||||
<div class="note-context-chat-container flex-grow-1 overflow-auto p-3">
|
||||
<div class="note-context-chat-messages"></div>
|
||||
<div class="loading-indicator" style="display: none;">
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<span class="ms-2">${t('common.processing')}...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sources-container p-2 border-top" style="display: none;">
|
||||
<h6 class="m-0 p-1">${t('ai.sources')}</h6>
|
||||
<div class="sources-list"></div>
|
||||
</div>
|
||||
|
||||
<form class="note-context-chat-form d-flex border-top p-2">
|
||||
<textarea
|
||||
class="form-control note-context-chat-input"
|
||||
placeholder="${t('ai.enter_message')}"
|
||||
rows="3"
|
||||
></textarea>
|
||||
<button type="submit" class="btn btn-primary note-context-chat-send-button ms-2">
|
||||
<i class="bx bx-send"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
`);
|
||||
|
||||
const element = this.$widget[0];
|
||||
this.noteContextChatMessages = element.querySelector('.note-context-chat-messages') as HTMLElement;
|
||||
this.noteContextChatForm = element.querySelector('.note-context-chat-form') as HTMLFormElement;
|
||||
this.noteContextChatInput = element.querySelector('.note-context-chat-input') as HTMLTextAreaElement;
|
||||
this.noteContextChatSendButton = element.querySelector('.note-context-chat-send-button') as HTMLButtonElement;
|
||||
this.chatContainer = element.querySelector('.note-context-chat-container') as HTMLElement;
|
||||
this.loadingIndicator = element.querySelector('.loading-indicator') as HTMLElement;
|
||||
this.sourcesList = element.querySelector('.sources-list') as HTMLElement;
|
||||
|
||||
this.initializeEventListeners();
|
||||
|
||||
// Create a session when first loaded
|
||||
this.createChatSession();
|
||||
|
||||
return this.$widget;
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
if (!this.isVisible()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current note context if needed
|
||||
this.currentNoteId = appContext.tabManager.getActiveContext()?.note?.noteId || null;
|
||||
|
||||
if (!this.sessionId) {
|
||||
// Create a new chat session
|
||||
await this.createChatSession();
|
||||
}
|
||||
}
|
||||
|
||||
private async createChatSession() {
|
||||
try {
|
||||
const resp = await server.post<SessionResponse>('llm/sessions', {
|
||||
title: 'Note Chat'
|
||||
});
|
||||
|
||||
if (resp && resp.id) {
|
||||
this.sessionId = resp.id;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create chat session:', error);
|
||||
toastService.showError('Failed to create chat session');
|
||||
}
|
||||
}
|
||||
|
||||
private async sendMessage(content: string) {
|
||||
if (!content.trim() || !this.sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.showLoadingIndicator();
|
||||
|
||||
try {
|
||||
// Add user message to chat
|
||||
this.addMessageToChat('user', content);
|
||||
this.noteContextChatInput.value = '';
|
||||
|
||||
// Get AI settings
|
||||
const useRAG = true; // Always use RAG for this widget
|
||||
|
||||
// Send message to server
|
||||
const response = await server.post<ChatResponse>('llm/sessions/' + this.sessionId + '/messages', {
|
||||
sessionId: this.sessionId,
|
||||
content: content,
|
||||
options: {
|
||||
useRAG: useRAG
|
||||
}
|
||||
});
|
||||
|
||||
// Get the assistant's message (last one)
|
||||
if (response?.messages?.length) {
|
||||
const messages = response.messages;
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
|
||||
if (lastMessage && lastMessage.role === 'assistant') {
|
||||
this.addMessageToChat('assistant', lastMessage.content);
|
||||
}
|
||||
}
|
||||
|
||||
// Display sources if available
|
||||
if (response?.sources?.length) {
|
||||
this.showSources(response.sources);
|
||||
} else {
|
||||
this.hideSources();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to send message:', error);
|
||||
toastService.showError('Failed to send message to AI');
|
||||
} finally {
|
||||
this.hideLoadingIndicator();
|
||||
}
|
||||
}
|
||||
|
||||
private addMessageToChat(role: 'user' | 'assistant', content: string) {
|
||||
const messageElement = document.createElement('div');
|
||||
messageElement.className = `chat-message ${role}-message mb-3`;
|
||||
|
||||
const avatarElement = document.createElement('div');
|
||||
avatarElement.className = 'message-avatar';
|
||||
avatarElement.innerHTML = role === 'user'
|
||||
? '<i class="bx bx-user"></i>'
|
||||
: '<i class="bx bx-bot"></i>';
|
||||
|
||||
const contentElement = document.createElement('div');
|
||||
contentElement.className = 'message-content p-3';
|
||||
|
||||
// Use a simple markdown formatter if utils.formatMarkdown is not available
|
||||
let formattedContent = content
|
||||
.replace(/```([\s\S]*?)```/g, '<pre><code>$1</code></pre>')
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
|
||||
.replace(/\n/g, '<br>');
|
||||
|
||||
contentElement.innerHTML = formattedContent;
|
||||
|
||||
messageElement.appendChild(avatarElement);
|
||||
messageElement.appendChild(contentElement);
|
||||
|
||||
this.noteContextChatMessages.appendChild(messageElement);
|
||||
|
||||
// Scroll to bottom
|
||||
this.chatContainer.scrollTop = this.chatContainer.scrollHeight;
|
||||
}
|
||||
|
||||
private showSources(sources: Array<{noteId: string, title: string}>) {
|
||||
this.sourcesList.innerHTML = '';
|
||||
|
||||
sources.forEach(source => {
|
||||
const sourceElement = document.createElement('div');
|
||||
sourceElement.className = 'source-item p-1';
|
||||
sourceElement.innerHTML = `<a href="#" data-note-id="${source.noteId}" class="source-link">${source.title}</a>`;
|
||||
|
||||
sourceElement.querySelector('.source-link')?.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
appContext.tabManager.openTabWithNoteWithHoisting(source.noteId);
|
||||
});
|
||||
|
||||
this.sourcesList.appendChild(sourceElement);
|
||||
});
|
||||
|
||||
const sourcesContainer = this.$widget[0].querySelector('.sources-container') as HTMLElement;
|
||||
if (sourcesContainer) {
|
||||
sourcesContainer.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
private hideSources() {
|
||||
const sourcesContainer = this.$widget[0].querySelector('.sources-container') as HTMLElement;
|
||||
if (sourcesContainer) {
|
||||
sourcesContainer.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
private showLoadingIndicator() {
|
||||
this.loadingIndicator.style.display = 'flex';
|
||||
}
|
||||
|
||||
private hideLoadingIndicator() {
|
||||
this.loadingIndicator.style.display = 'none';
|
||||
}
|
||||
|
||||
private initializeEventListeners() {
|
||||
this.noteContextChatForm.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
const content = this.noteContextChatInput.value;
|
||||
this.sendMessage(content);
|
||||
});
|
||||
|
||||
// Add auto-resize functionality to the textarea
|
||||
this.noteContextChatInput.addEventListener('input', () => {
|
||||
this.noteContextChatInput.style.height = 'auto';
|
||||
this.noteContextChatInput.style.height = `${this.noteContextChatInput.scrollHeight}px`;
|
||||
});
|
||||
|
||||
// Handle Enter key (send on Enter, new line on Shift+Enter)
|
||||
this.noteContextChatInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
this.noteContextChatForm.dispatchEvent(new Event('submit'));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
import TypeWidget from "./type_widget.js";
|
||||
import LlmChatPanel from "../llm_chat_panel.js";
|
||||
import { type EventData } from "../../components/app_context.js";
|
||||
import type FNote from "../../entities/fnote.js";
|
||||
|
||||
export default class LlmChatTypeWidget extends TypeWidget {
|
||||
private llmChatPanel: LlmChatPanel;
|
||||
private isInitialized: boolean = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.llmChatPanel = new LlmChatPanel();
|
||||
}
|
||||
|
||||
static getType() {
|
||||
return "llmChat";
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $('<div class="llm-chat-widget-container" style="height: 100%;"></div>');
|
||||
this.$widget.append(this.llmChatPanel.render());
|
||||
|
||||
return this.$widget;
|
||||
}
|
||||
|
||||
async doRefresh(note: FNote | null | undefined) {
|
||||
// Initialize only once
|
||||
if (!this.isInitialized) {
|
||||
console.log("Initializing LLM Chat Panel");
|
||||
await this.llmChatPanel.refresh();
|
||||
this.isInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
async entitiesReloadedEvent(data: EventData<"entitiesReloaded">) {
|
||||
// We don't need to refresh on entities reloaded for the chat
|
||||
}
|
||||
|
||||
async activeContextChangedEvent(data: EventData<"activeContextChanged">) {
|
||||
// Only refresh when this becomes active and we're not initialized yet
|
||||
if (this.isActive() && !this.isInitialized) {
|
||||
await this.llmChatPanel.refresh();
|
||||
this.isInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle data saving - we don't need to save anything
|
||||
getData() {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,122 @@
|
||||
/* LLM Chat Launcher Widget Styles */
|
||||
.note-context-chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.note-context-chat-container {
|
||||
flex-grow: 1;
|
||||
overflow-y: auto;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.chat-message {
|
||||
display: flex;
|
||||
margin-bottom: 15px;
|
||||
max-width: 85%;
|
||||
}
|
||||
|
||||
.chat-message.user-message {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.chat-message.assistant-message {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.message-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.user-message .message-avatar {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.assistant-message .message-avatar {
|
||||
background-color: var(--secondary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
background-color: var(--more-accented-background-color);
|
||||
border-radius: 12px;
|
||||
padding: 10px 15px;
|
||||
max-width: calc(100% - 40px);
|
||||
}
|
||||
|
||||
.user-message .message-content {
|
||||
background-color: var(--accented-background-color);
|
||||
}
|
||||
|
||||
.message-content pre {
|
||||
background-color: var(--code-background-color);
|
||||
border-radius: 5px;
|
||||
padding: 10px;
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.message-content code {
|
||||
background-color: var(--code-background-color);
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.loading-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 10px 0;
|
||||
color: var(--muted-text-color);
|
||||
}
|
||||
|
||||
.sources-container {
|
||||
background-color: var(--accented-background-color);
|
||||
border-top: 1px solid var(--main-border-color);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.sources-list {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.source-item {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.source-link {
|
||||
color: var(--link-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.source-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.note-context-chat-form {
|
||||
display: flex;
|
||||
background-color: var(--main-background-color);
|
||||
border-top: 1px solid var(--main-border-color);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.note-context-chat-input {
|
||||
resize: vertical;
|
||||
min-height: 44px;
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.chat-message {
|
||||
max-width: 95%;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,721 @@
|
||||
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";
|
||||
|
||||
// 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'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find relevant notes using vector embeddings
|
||||
*/
|
||||
async function findRelevantNotes(query: string, contextNoteId: string | null = null, limit = 5): Promise<NoteSource[]> {
|
||||
try {
|
||||
// Only proceed if database is initialized
|
||||
if (!isDatabaseInitialized()) {
|
||||
log.info('Database not initialized, skipping vector search');
|
||||
return [{
|
||||
noteId: "root",
|
||||
title: "Database not initialized yet",
|
||||
content: "Please wait for database initialization to complete."
|
||||
}];
|
||||
}
|
||||
|
||||
// Get the default embedding provider
|
||||
let providerId;
|
||||
try {
|
||||
// @ts-ignore - embeddingsDefaultProvider exists but might not be in the TypeScript definitions
|
||||
providerId = await options.getOption('embeddingsDefaultProvider') || 'openai';
|
||||
} catch (error) {
|
||||
log.info('Could not get default embedding provider, using mock data');
|
||||
return [{
|
||||
noteId: "root",
|
||||
title: "Embeddings not configured",
|
||||
content: "Embedding provider not available"
|
||||
}];
|
||||
}
|
||||
|
||||
const provider = providerManager.getEmbeddingProvider(providerId);
|
||||
|
||||
if (!provider) {
|
||||
log.info(`Embedding provider ${providerId} not found, using mock data`);
|
||||
return [{
|
||||
noteId: "root",
|
||||
title: "Embeddings not available",
|
||||
content: "No embedding provider available"
|
||||
}];
|
||||
}
|
||||
|
||||
// Generate embedding for the query
|
||||
const embedding = await provider.generateEmbeddings(query);
|
||||
|
||||
// Find similar notes
|
||||
const modelId = 'default'; // Use default model for the provider
|
||||
const similarNotes = await vectorStore.findSimilarNotes(
|
||||
embedding, providerId, modelId, limit, 0.6 // Lower threshold to find more results
|
||||
);
|
||||
|
||||
// If a context note was provided, check if we should include its children
|
||||
if (contextNoteId) {
|
||||
const contextNote = becca.getNote(contextNoteId);
|
||||
if (contextNote) {
|
||||
const childNotes = contextNote.getChildNotes();
|
||||
if (childNotes.length > 0) {
|
||||
// Add relevant children that weren't already included
|
||||
const childIds = new Set(childNotes.map(note => note.noteId));
|
||||
const existingIds = new Set(similarNotes.map(note => note.noteId));
|
||||
|
||||
// Find children that aren't already in the similar notes
|
||||
const missingChildIds = Array.from(childIds).filter(id => !existingIds.has(id));
|
||||
|
||||
// Add up to 3 children that weren't already included
|
||||
for (const noteId of missingChildIds.slice(0, 3)) {
|
||||
similarNotes.push({
|
||||
noteId,
|
||||
similarity: 0.75 // Fixed similarity score for context children
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get note content for context
|
||||
return await Promise.all(similarNotes.map(async ({ noteId, similarity }) => {
|
||||
const note = becca.getNote(noteId);
|
||||
if (!note) {
|
||||
return {
|
||||
noteId,
|
||||
title: "Unknown Note",
|
||||
similarity
|
||||
};
|
||||
}
|
||||
|
||||
// Get note content
|
||||
let content = '';
|
||||
try {
|
||||
// @ts-ignore - Content can be string or Buffer
|
||||
const noteContent = await note.getContent();
|
||||
content = typeof noteContent === 'string' ? noteContent : noteContent.toString('utf8');
|
||||
|
||||
// Truncate content if it's too long (for performance)
|
||||
if (content.length > 2000) {
|
||||
content = content.substring(0, 2000) + "...";
|
||||
}
|
||||
} catch (e) {
|
||||
log.error(`Error getting content for note ${noteId}: ${e}`);
|
||||
}
|
||||
|
||||
// Get a branch ID for navigation
|
||||
let branchId;
|
||||
try {
|
||||
const branches = note.getBranches();
|
||||
if (branches.length > 0) {
|
||||
branchId = branches[0].branchId;
|
||||
}
|
||||
} catch (e) {
|
||||
log.error(`Error getting branch for note ${noteId}: ${e}`);
|
||||
}
|
||||
|
||||
return {
|
||||
noteId,
|
||||
title: note.title,
|
||||
content,
|
||||
similarity,
|
||||
branchId
|
||||
};
|
||||
}));
|
||||
} catch (error) {
|
||||
log.error(`Error finding relevant notes: ${error}`);
|
||||
// Return empty array on error
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a context string from relevant 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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to an LLM chat session and get a response
|
||||
*/
|
||||
async function sendMessage(req: Request, res: Response) {
|
||||
try {
|
||||
const { sessionId, content, temperature, maxTokens, provider, model } = req.body;
|
||||
|
||||
console.log("Received message request:", {
|
||||
sessionId,
|
||||
contentLength: content ? content.length : 0,
|
||||
contentPreview: content ? content.substring(0, 50) + (content.length > 50 ? '...' : '') : 'undefined',
|
||||
temperature,
|
||||
maxTokens,
|
||||
provider,
|
||||
model
|
||||
});
|
||||
|
||||
if (!sessionId) {
|
||||
throw new Error('Session ID is required');
|
||||
}
|
||||
|
||||
if (!content || typeof content !== 'string' || content.trim().length === 0) {
|
||||
throw new Error('Content cannot be empty');
|
||||
}
|
||||
|
||||
// Check if streaming is requested
|
||||
const wantsStream = (req.headers as any)['accept']?.includes('text/event-stream');
|
||||
|
||||
// If client wants streaming, set up SSE response
|
||||
if (wantsStream) {
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
|
||||
// Get chat session
|
||||
let session = sessions.get(sessionId);
|
||||
if (!session) {
|
||||
const newSession = await createSession(req, res);
|
||||
if (!newSession) {
|
||||
throw new Error('Failed to create session');
|
||||
}
|
||||
// Add required properties to match ChatSession interface
|
||||
session = {
|
||||
...newSession,
|
||||
messages: [],
|
||||
lastActive: new Date(),
|
||||
metadata: {}
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
}
|
||||
|
||||
// Add user message to session
|
||||
const userMessage: ChatMessage = {
|
||||
role: 'user',
|
||||
content: content,
|
||||
timestamp: new Date()
|
||||
};
|
||||
console.log("Created user message:", {
|
||||
role: userMessage.role,
|
||||
contentLength: userMessage.content?.length || 0,
|
||||
contentPreview: userMessage.content?.substring(0, 50) + (userMessage.content?.length > 50 ? '...' : '') || 'undefined'
|
||||
});
|
||||
session.messages.push(userMessage);
|
||||
|
||||
// Get context for query
|
||||
const sources = await findRelevantNotes(content, session.noteContext || null);
|
||||
|
||||
// Format messages for AI with proper type casting
|
||||
const aiMessages: Message[] = [
|
||||
{ role: 'system', content: 'You are a helpful assistant for Trilium Notes. When providing answers, use only the context provided in the notes. If the information is not in the notes, say so.' },
|
||||
{ role: 'user', content: buildContextFromNotes(sources, content) }
|
||||
];
|
||||
|
||||
// Ensure we're not sending empty content
|
||||
console.log("Final message content length:", aiMessages[1].content.length);
|
||||
console.log("Final message content preview:", aiMessages[1].content.substring(0, 100));
|
||||
|
||||
try {
|
||||
// Send initial SSE message with session info
|
||||
const sourcesForResponse = sources.map(({ noteId, title, similarity, branchId }) => ({
|
||||
noteId,
|
||||
title,
|
||||
similarity: similarity ? Math.round(similarity * 100) / 100 : undefined,
|
||||
branchId
|
||||
}));
|
||||
|
||||
res.write(`data: ${JSON.stringify({
|
||||
type: 'init',
|
||||
session: {
|
||||
id: sessionId,
|
||||
messages: session.messages.slice(0, -1), // Don't include the new message yet
|
||||
sources: sourcesForResponse
|
||||
}
|
||||
})}\n\n`);
|
||||
|
||||
// Get AI response with streaming enabled
|
||||
const aiResponse = await aiServiceManagerModule.default.generateChatCompletion(aiMessages, {
|
||||
temperature,
|
||||
maxTokens,
|
||||
model: provider ? `${provider}:${model}` : model,
|
||||
stream: true
|
||||
});
|
||||
|
||||
if (aiResponse.stream) {
|
||||
// Create an empty assistant message
|
||||
const assistantMessage: ChatMessage = {
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: new Date()
|
||||
};
|
||||
session.messages.push(assistantMessage);
|
||||
|
||||
// Stream the response chunks
|
||||
await aiResponse.stream(async (chunk) => {
|
||||
if (chunk.text) {
|
||||
// Update the message content
|
||||
assistantMessage.content += chunk.text;
|
||||
|
||||
// Send chunk to client
|
||||
res.write(`data: ${JSON.stringify({
|
||||
type: 'chunk',
|
||||
text: chunk.text,
|
||||
done: chunk.done
|
||||
})}\n\n`);
|
||||
}
|
||||
|
||||
if (chunk.done) {
|
||||
// Send final message with complete response
|
||||
res.write(`data: ${JSON.stringify({
|
||||
type: 'done',
|
||||
session: {
|
||||
id: sessionId,
|
||||
messages: session.messages,
|
||||
sources: sourcesForResponse
|
||||
}
|
||||
})}\n\n`);
|
||||
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
return; // Early return for streaming
|
||||
} else {
|
||||
// Fallback for non-streaming response
|
||||
const assistantMessage: ChatMessage = {
|
||||
role: 'assistant',
|
||||
content: aiResponse.text,
|
||||
timestamp: new Date()
|
||||
};
|
||||
session.messages.push(assistantMessage);
|
||||
|
||||
// Send complete response
|
||||
res.write(`data: ${JSON.stringify({
|
||||
type: 'done',
|
||||
session: {
|
||||
id: sessionId,
|
||||
messages: session.messages,
|
||||
sources: sourcesForResponse
|
||||
}
|
||||
})}\n\n`);
|
||||
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Send error in streaming format
|
||||
res.write(`data: ${JSON.stringify({
|
||||
type: 'error',
|
||||
error: `AI service error: ${error.message}`
|
||||
})}\n\n`);
|
||||
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Non-streaming API continues with normal JSON response...
|
||||
|
||||
// Get chat session
|
||||
let session = sessions.get(sessionId);
|
||||
if (!session) {
|
||||
const newSession = await createSession(req, res);
|
||||
if (!newSession) {
|
||||
throw new Error('Failed to create session');
|
||||
}
|
||||
// Add required properties to match ChatSession interface
|
||||
session = {
|
||||
...newSession,
|
||||
messages: [],
|
||||
lastActive: new Date(),
|
||||
metadata: {}
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
}
|
||||
|
||||
// Add user message to session
|
||||
const userMessage: ChatMessage = {
|
||||
role: 'user',
|
||||
content: content,
|
||||
timestamp: new Date()
|
||||
};
|
||||
console.log("Created user message:", {
|
||||
role: userMessage.role,
|
||||
contentLength: userMessage.content?.length || 0,
|
||||
contentPreview: userMessage.content?.substring(0, 50) + (userMessage.content?.length > 50 ? '...' : '') || 'undefined'
|
||||
});
|
||||
session.messages.push(userMessage);
|
||||
|
||||
// Get context for query
|
||||
const sources = await findRelevantNotes(content, session.noteContext || null);
|
||||
|
||||
// Format messages for AI with proper type casting
|
||||
const aiMessages: Message[] = [
|
||||
{ role: 'system', content: 'You are a helpful assistant for Trilium Notes. When providing answers, use only the context provided in the notes. If the information is not in the notes, say so.' },
|
||||
{ role: 'user', content: buildContextFromNotes(sources, content) }
|
||||
];
|
||||
|
||||
// Ensure we're not sending empty content
|
||||
console.log("Final message content length:", aiMessages[1].content.length);
|
||||
console.log("Final message content preview:", aiMessages[1].content.substring(0, 100));
|
||||
|
||||
try {
|
||||
// Get AI response using the safe accessor methods
|
||||
const aiResponse = await aiServiceManagerModule.default.generateChatCompletion(aiMessages, {
|
||||
temperature,
|
||||
maxTokens,
|
||||
model: provider ? `${provider}:${model}` : model,
|
||||
stream: false
|
||||
});
|
||||
|
||||
// Add assistant message to session
|
||||
const assistantMessage: ChatMessage = {
|
||||
role: 'assistant',
|
||||
content: aiResponse.text,
|
||||
timestamp: new Date()
|
||||
};
|
||||
session.messages.push(assistantMessage);
|
||||
|
||||
// Format sources for the response (without content to reduce payload size)
|
||||
const sourcesForResponse = sources.map(({ noteId, title, similarity, branchId }) => ({
|
||||
noteId,
|
||||
title,
|
||||
similarity: similarity ? Math.round(similarity * 100) / 100 : undefined,
|
||||
branchId
|
||||
}));
|
||||
|
||||
return {
|
||||
id: sessionId,
|
||||
messages: session.messages,
|
||||
sources: sourcesForResponse,
|
||||
provider: aiResponse.provider,
|
||||
model: aiResponse.model
|
||||
};
|
||||
} catch (error: any) {
|
||||
log.error(`AI service error: ${error.message}`);
|
||||
throw new Error(`AI service error: ${error.message}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
log.error(`Error sending message: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
createSession,
|
||||
getSession,
|
||||
updateSession,
|
||||
listSessions,
|
||||
deleteSession,
|
||||
sendMessage
|
||||
};
|
||||
Loading…
Reference in New Issue