hey look, it doesn't crash again

This commit is contained in:
perf3ct 2025-03-02 19:39:10 -08:00
parent e09e15ad05
commit f2a6f92732
No known key found for this signature in database
GPG Key ID: 569C4EEC436F5232
18 changed files with 1939 additions and 4 deletions

View File

@ -11,6 +11,7 @@ import options from "./services/options.js";
import type ElectronRemote from "@electron/remote";
import type Electron from "electron";
import "../stylesheets/bootstrap.scss";
import rightPaneTabManager from "./services/right_pane_tab_manager.js";
await appContext.earlyInit();
@ -27,6 +28,16 @@ bundleService.getWidgetBundlesByParent().then(async (widgetBundles) => {
});
console.error("Critical error occured", e);
});
// Initialize right pane tab manager after layout is loaded
setTimeout(() => {
const $tabContainer = $("#right-pane-tab-container");
const $contentContainer = $("#right-pane-content-container");
if ($tabContainer.length && $contentContainer.length) {
rightPaneTabManager.init($tabContainer, $contentContainer);
}
}, 1000);
});
glob.setupGlobs();

View File

@ -88,6 +88,7 @@ import utils from "../services/utils.js";
import GeoMapButtons from "../widgets/floating_buttons/geo_map_button.js";
import ContextualHelpButton from "../widgets/floating_buttons/help_button.js";
import CloseZenButton from "../widgets/close_zen_button.js";
import rightPaneTabManager from "../services/right_pane_tab_manager.js";
export default class DesktopLayout {
constructor(customWidgets) {
@ -97,6 +98,16 @@ export default class DesktopLayout {
getRootWidget(appContext) {
appContext.noteTreeWidget = new NoteTreeWidget();
// Initialize the right pane tab manager after widget render
setTimeout(() => {
const $tabContainer = $("#right-pane-tab-container");
const $contentContainer = $("#right-pane-content-container");
if ($tabContainer.length && $contentContainer.length) {
rightPaneTabManager.init($tabContainer, $contentContainer);
}
}, 500);
const launcherPaneIsHorizontal = options.get("layoutOrientation") === "horizontal";
const launcherPane = this.#buildLauncherPane(launcherPaneIsHorizontal);
const isElectron = utils.isElectron();
@ -234,9 +245,24 @@ export default class DesktopLayout {
)
.child(
new RightPaneContainer()
.child(new TocWidget())
.child(new HighlightsListWidget())
.child(...this.customWidgets.get("right-pane"))
.id("right-pane-container")
.child(
new FlexContainer("row")
.id("right-pane-tab-container")
.css("height", "40px")
.css("padding", "5px 10px")
.css("border-bottom", "1px solid var(--main-border-color)")
.css("background-color", "var(--accented-background-color)")
)
.child(
new FlexContainer("column")
.id("right-pane-content-container")
.css("flex-grow", "1")
.css("overflow", "hidden")
.child(new TocWidget())
.child(new HighlightsListWidget())
.child(...this.customWidgets.get("right-pane"))
)
)
)
)

View File

@ -0,0 +1,63 @@
import options from "../../../services/options.js";
import ChatWidget from "../widgets/llm/chat_widget.js";
import TabContext from "../widgets/right_panel_tabs.js";
import rightPaneTabManager from "./right_pane_tab_manager.js";
import keyboardActionsService from "./keyboard_actions.js";
function initComponents() {
// ... existing code ...
addChatTab();
// ... existing code ...
}
function addChatTab() {
if (!options.getOptionBool('llmEnabled')) {
return;
}
const chatWidget = new ChatWidget();
// Add chat widget to the right pane
const chatTab = new TabContext("AI Chat", chatWidget);
chatTab.renderTitle = title => {
return $(`<span class="tab-title"><span class="bx bx-bot"></span> ${title}</span>`);
};
rightPaneTabManager.addTabContext(chatTab);
// Add chat button to the global menu
const $button = $('<button class="button-widget global-menu-button" title="Open AI Chat (Ctrl+Shift+C)"><span class="bx bx-chat"></span></button>');
$button.on('click', () => {
chatTab.activate();
chatWidget.refresh();
});
$button.insertBefore($('.global-menu-button:first')); // Add to the beginning of global menu
// Add keyboard shortcut
keyboardActionsService.setupActionsForScope('window', {
'openAiChat': {
'enabled': true,
'title': 'Open AI Chat',
'clickNote': true,
'shortcutKeys': {
'keyCode': 'C',
'ctrlKey': true,
'shiftKey': true
},
'handler': () => {
chatTab.activate();
chatWidget.refresh();
}
}
});
}
// Export the functions to make them available to other modules
export default {
initComponents,
addChatTab
};

View File

@ -0,0 +1,127 @@
/**
* Manager for tabs in the right pane
*/
class RightPaneTabManager {
constructor() {
this.tabs = [];
this.activeTab = null;
this.$tabContainer = null;
this.$contentContainer = null;
this.initialized = false;
}
/**
* Initialize the tab manager with container elements
*/
init($tabContainer, $contentContainer) {
this.$tabContainer = $tabContainer;
this.$contentContainer = $contentContainer;
this.initialized = true;
this.renderTabs();
}
/**
* Add a new tab context
*/
addTabContext(tabContext) {
this.tabs.push(tabContext);
if (this.initialized) {
this.renderTabs();
// If this is the first tab, activate it
if (this.tabs.length === 1) {
this.activateTab(tabContext);
}
}
}
/**
* Render all tabs
*/
renderTabs() {
if (!this.initialized) return;
this.$tabContainer.empty();
for (const tab of this.tabs) {
const $tab = $('<div class="right-pane-tab"></div>')
.attr('data-tab-id', this.tabs.indexOf(tab))
.append(tab.renderTitle(tab.title))
.toggleClass('active', tab === this.activeTab)
.on('click', () => {
this.activateTab(tab);
});
this.$tabContainer.append($tab);
}
}
/**
* Activate a specific tab
*/
activateTab(tabContext) {
if (this.activeTab === tabContext) return;
// Deactivate current tab
if (this.activeTab) {
this.activeTab.deactivate();
}
// Activate new tab
this.activeTab = tabContext;
tabContext.activate();
// Update UI
if (this.initialized) {
this.renderTabs();
this.renderContent();
}
}
/**
* Render the content of the active tab
*/
renderContent() {
if (!this.initialized || !this.activeTab) return;
this.$contentContainer.empty();
const widget = this.activeTab.widget;
if (widget) {
if (typeof widget.render === 'function') {
const $renderedWidget = widget.render();
this.$contentContainer.append($renderedWidget);
} else if (widget instanceof jQuery) {
this.$contentContainer.append(widget);
} else if (widget.nodeType) {
this.$contentContainer.append($(widget));
}
}
}
/**
* Remove a tab
*/
removeTab(tabContext) {
const index = this.tabs.indexOf(tabContext);
if (index !== -1) {
this.tabs.splice(index, 1);
if (this.activeTab === tabContext) {
this.activeTab = this.tabs.length > 0 ? this.tabs[0] : null;
if (this.activeTab) {
this.activeTab.activate();
}
}
if (this.initialized) {
this.renderTabs();
this.renderContent();
}
}
}
}
const rightPaneTabManager = new RightPaneTabManager();
export default rightPaneTabManager;

View File

@ -0,0 +1,356 @@
import TabAwareWidget from "../tab_aware_widget.js";
import chatService from "../../../../services/llm/chat_service.js";
import options from "../../services/options.js";
import utils from "../../services/utils.js";
const TPL = `
<div class="chat-widget">
<div class="chat-header">
<div class="chat-title"></div>
<div class="chat-actions">
<button class="btn btn-sm chat-new-btn" title="New Chat">
<span class="bx bx-plus"></span>
</button>
<button class="btn btn-sm chat-options-btn" title="Chat Options">
<span class="bx bx-cog"></span>
</button>
</div>
</div>
<div class="chat-messages"></div>
<div class="chat-controls">
<div class="chat-input-container">
<textarea class="chat-input form-control" placeholder="Type your message here..." rows="2"></textarea>
</div>
<div class="chat-buttons">
<button class="btn btn-primary btn-sm chat-send-btn" title="Send Message">
<span class="bx bx-send"></span>
</button>
<button class="btn btn-outline-secondary btn-sm chat-add-context-btn" title="Add current note as context">
<span class="bx bx-link"></span>
</button>
</div>
</div>
</div>
`;
const MESSAGE_TPL = `
<div class="chat-message">
<div class="chat-message-avatar">
<span class="bx"></span>
</div>
<div class="chat-message-content"></div>
</div>
`;
export default class ChatWidget extends TabAwareWidget {
constructor() {
super();
this.activeSessionId = null;
this.$widget = $(TPL);
this.$title = this.$widget.find('.chat-title');
this.$messagesContainer = this.$widget.find('.chat-messages');
this.$input = this.$widget.find('.chat-input');
this.$sendBtn = this.$widget.find('.chat-send-btn');
this.$newChatBtn = this.$widget.find('.chat-new-btn');
this.$optionsBtn = this.$widget.find('.chat-options-btn');
this.$addContextBtn = this.$widget.find('.chat-add-context-btn');
this.initialized = false;
this.isActiveTab = false;
}
isEnabled() {
return options.getOptionBool('llmEnabled');
}
doRender() {
this.$widget.on('click', '.chat-send-btn', async () => {
if (!this.activeSessionId) return;
const message = this.$input.val().trim();
if (!message) return;
this.$input.val('');
this.$input.prop('disabled', true);
this.$sendBtn.prop('disabled', true);
await this.sendMessage(message);
this.$input.prop('disabled', false);
this.$sendBtn.prop('disabled', false);
this.$input.focus();
});
this.$input.on('keydown', async e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
this.$sendBtn.click();
}
});
this.$newChatBtn.on('click', async () => {
await this.startNewChat();
});
this.$addContextBtn.on('click', async () => {
if (!this.activeSessionId || !this.noteId) return;
this.$input.prop('disabled', true);
this.$sendBtn.prop('disabled', true);
this.$addContextBtn.prop('disabled', true);
await this.addNoteContext();
this.$input.prop('disabled', false);
this.$sendBtn.prop('disabled', false);
this.$addContextBtn.prop('disabled', false);
});
this.$optionsBtn.on('click', () => {
this.triggerEvent('openOptions');
});
return this.$widget;
}
async refresh() {
if (!this.isEnabled()) {
this.toggleVisibility(false);
return;
}
this.toggleVisibility(true);
if (!this.initialized) {
await this.initialize();
}
if (this.activeSessionId) {
await this.loadSession(this.activeSessionId);
} else {
await this.startNewChat();
}
}
toggleVisibility(show) {
this.$widget.toggleClass('d-none', !show);
}
async initialize() {
// Load last or create new chat session
const sessions = await chatService.getAllSessions();
if (sessions.length > 0) {
// Use the most recent session
this.activeSessionId = sessions[0].id;
await this.loadSession(this.activeSessionId);
} else {
await this.startNewChat();
}
this.initialized = true;
}
async loadSession(sessionId) {
try {
const session = await chatService.getOrCreateSession(sessionId);
this.activeSessionId = session.id;
// Update title
this.$title.text(session.title || 'New Chat');
// Clear and reload messages
this.$messagesContainer.empty();
for (const message of session.messages) {
this.addMessageToUI(message);
}
// Scroll to bottom
this.scrollToBottom();
} catch (error) {
console.error('Failed to load chat session:', error);
await this.startNewChat();
}
}
async startNewChat() {
try {
const session = await chatService.createSession();
this.activeSessionId = session.id;
// Update title
this.$title.text(session.title || 'New Chat');
// Clear messages
this.$messagesContainer.empty();
// Add welcome message
const welcomeMessage = {
role: 'assistant',
content: 'Hello! How can I assist you today?'
};
this.addMessageToUI(welcomeMessage);
// Focus input
this.$input.focus();
} catch (error) {
console.error('Failed to create new chat session:', error);
}
}
async sendMessage(content) {
if (!this.activeSessionId) return;
// Add user message to UI immediately
const userMessage = { role: 'user', content };
this.addMessageToUI(userMessage);
// Prepare for streaming response
const $assistantMessage = this.createEmptyAssistantMessage();
// Send to service with streaming callback
try {
await chatService.sendMessage(
this.activeSessionId,
content,
null,
(content, isDone) => {
// Update the message content as it streams
$assistantMessage.find('.chat-message-content').html(this.formatMessageContent(content));
this.scrollToBottom();
if (isDone) {
// Update session title if it changed
chatService.getOrCreateSession(this.activeSessionId).then(session => {
this.$title.text(session.title);
});
}
}
);
} catch (error) {
console.error('Error sending message:', error);
// Show error in UI if not already shown by streaming
$assistantMessage.find('.chat-message-content').html(
this.formatMessageContent(`Error: Failed to generate response. ${error.message || 'Please check AI settings and try again.'}`)
);
}
this.scrollToBottom();
}
async addNoteContext() {
if (!this.activeSessionId || !this.noteId) return;
try {
// Show loading message
const $loadingMessage = this.createEmptyAssistantMessage();
$loadingMessage.find('.chat-message-content').html('Loading note context...');
await chatService.addNoteContext(this.activeSessionId, this.noteId);
// Remove loading message
$loadingMessage.remove();
// Reload the session to show updated messages
await this.loadSession(this.activeSessionId);
} catch (error) {
console.error('Error adding note context:', error);
}
}
addMessageToUI(message) {
const $message = $(MESSAGE_TPL);
// Set avatar icon based on role
if (message.role === 'user') {
$message.addClass('chat-message-user');
$message.find('.chat-message-avatar .bx').addClass('bx-user');
} else {
$message.addClass('chat-message-assistant');
$message.find('.chat-message-avatar .bx').addClass('bx-bot');
}
// Set content
$message.find('.chat-message-content').html(this.formatMessageContent(message.content));
// Add to container
this.$messagesContainer.append($message);
// Scroll to bottom
this.scrollToBottom();
return $message;
}
createEmptyAssistantMessage() {
const $message = $(MESSAGE_TPL);
$message.addClass('chat-message-assistant');
$message.find('.chat-message-avatar .bx').addClass('bx-bot');
$message.find('.chat-message-content').html('<div class="chat-loading">●●●</div>');
this.$messagesContainer.append($message);
this.scrollToBottom();
return $message;
}
formatMessageContent(content) {
if (!content) return '';
// Escape HTML
let formatted = utils.escapeHtml(content);
// Convert markdown-style code blocks to HTML
formatted = formatted.replace(/```(\w+)?\n([\s\S]+?)\n```/g, (match, language, code) => {
return `<pre class="code${language ? ' language-' + language : ''}"><code>${utils.escapeHtml(code)}</code></pre>`;
});
// Convert inline code
formatted = formatted.replace(/`([^`]+)`/g, '<code>$1</code>');
// Convert line breaks
formatted = formatted.replace(/\n/g, '<br>');
return formatted;
}
scrollToBottom() {
this.$messagesContainer.scrollTop(this.$messagesContainer[0].scrollHeight);
}
/**
* @param {string} noteId
*/
async noteSwitched(noteId) {
this.noteId = noteId;
if (this.isActiveTab) {
// Only refresh if we're the active tab
await this.refresh();
}
}
/**
* @param {boolean} active
*/
activeTabChanged(active) {
this.isActiveTab = active;
if (active) {
this.refresh();
}
}
entitiesReloaded() {
if (this.isActiveTab) {
this.refresh();
}
}
}

View File

@ -0,0 +1,49 @@
/**
* TabContext represents a tab in the right pane that can contain any widget
*/
export default class TabContext {
/**
* @param {string} title - Tab title
* @param {object} widget - Widget to display in the tab
*/
constructor(title, widget) {
this.title = title;
this.widget = widget;
this.active = false;
}
/**
* Custom renderer for the tab title
* @param {string} title
* @returns {JQuery}
*/
renderTitle(title) {
return $(`<span class="tab-title">${title}</span>`);
}
/**
* Activate this tab
*/
activate() {
this.active = true;
if (this.widget && typeof this.widget.activeTabChanged === 'function') {
this.widget.activeTabChanged(true);
}
if (typeof rightPaneTabManager.activateTab === 'function') {
rightPaneTabManager.activateTab(this);
}
}
/**
* Deactivate this tab
*/
deactivate() {
this.active = false;
if (this.widget && typeof this.widget.activeTabChanged === 'function') {
this.widget.activeTabChanged(false);
}
}
}

View File

@ -0,0 +1,53 @@
import BasicWidget from "./basic_widget.js";
/**
* Base class for widgets that need to track the active tab/note
*/
export default class TabAwareWidget extends BasicWidget {
constructor() {
super();
this.noteId = null;
this.noteType = null;
this.notePath = null;
this.isActiveTab = false;
}
/**
* Called when the active note is switched
*
* @param {string} noteId
* @param {string|null} noteType
* @param {string|null} notePath
*/
async noteSwitched(noteId, noteType, notePath) {
this.noteId = noteId;
this.noteType = noteType;
this.notePath = notePath;
}
/**
* Called when the widget's tab becomes active or inactive
*
* @param {boolean} active
*/
activeTabChanged(active) {
this.isActiveTab = active;
}
/**
* Called when entities (notes, attributes, etc.) are reloaded
*/
entitiesReloaded() {}
/**
* Check if this widget is enabled
*/
isEnabled() {
return true;
}
/**
* Refresh widget with current data
*/
async refresh() {}
}

View File

@ -1695,4 +1695,185 @@ footer.file-footer {
footer.file-footer button {
margin: 5px;
}
/* AI Chat Widget Styles */
.chat-widget {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 15px;
border-bottom: 1px solid var(--main-border-color);
background-color: var(--accented-background-color);
}
.chat-title {
font-weight: bold;
flex-grow: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-actions {
display: flex;
gap: 5px;
}
.chat-messages {
flex-grow: 1;
overflow-y: auto;
padding: 15px;
display: flex;
flex-direction: column;
gap: 15px;
}
.chat-message {
display: flex;
gap: 10px;
max-width: 85%;
}
.chat-message-user {
align-self: flex-end;
flex-direction: row-reverse;
}
.chat-message-assistant {
align-self: flex-start;
}
.chat-message-avatar {
flex-shrink: 0;
width: 30px;
height: 30px;
border-radius: 50%;
background-color: var(--accented-background-color);
display: flex;
align-items: center;
justify-content: center;
}
.chat-message-user .chat-message-avatar {
background-color: var(--primary-color);
color: white;
}
.chat-message-assistant .chat-message-avatar {
background-color: var(--muted-text-color);
color: white;
}
.chat-message-content {
flex-grow: 1;
padding: 10px 15px;
border-radius: 12px;
background-color: var(--accented-background-color);
overflow-wrap: break-word;
word-break: break-word;
}
.chat-message-user .chat-message-content {
background-color: var(--primary-color);
color: white;
}
.chat-message-content pre {
background-color: var(--main-background-color);
border-radius: 5px;
padding: 10px;
overflow-x: auto;
margin: 10px 0;
}
.chat-message-user .chat-message-content pre {
background-color: rgba(255, 255, 255, 0.2);
}
.chat-message-content code {
font-family: monospace;
background-color: var(--main-background-color);
padding: 2px 4px;
border-radius: 3px;
}
.chat-message-user .chat-message-content code {
background-color: rgba(255, 255, 255, 0.2);
}
.chat-controls {
display: flex;
flex-direction: column;
padding: 15px;
gap: 10px;
border-top: 1px solid var(--main-border-color);
}
.chat-input-container {
position: relative;
}
.chat-input {
width: 100%;
resize: none;
padding-right: 40px;
}
.chat-buttons {
display: flex;
justify-content: space-between;
}
.chat-loading {
animation: chat-loading 1s infinite;
letter-spacing: 3px;
}
@keyframes chat-loading {
0% { opacity: 0.3; }
50% { opacity: 1; }
100% { opacity: 0.3; }
}
/* Right Pane Tab Styles */
#right-pane-tab-container {
display: flex;
gap: 10px;
}
.right-pane-tab {
padding: 5px 10px;
cursor: pointer;
border-radius: 5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: background-color 0.2s ease;
}
.right-pane-tab:hover {
background-color: var(--hover-item-background-color);
}
.right-pane-tab.active {
background-color: var(--primary-color);
color: white;
}
.right-pane-tab .tab-title {
display: flex;
align-items: center;
gap: 5px;
}
.right-pane-tab .tab-title .bx {
font-size: 1.1em;
}

View File

@ -0,0 +1,39 @@
export interface Message {
role: 'user' | 'assistant' | 'system';
content: string;
}
export interface ChatCompletionOptions {
model?: string;
temperature?: number;
maxTokens?: number;
systemPrompt?: string;
}
export interface ChatResponse {
text: string;
model: string;
provider: string;
usage?: {
promptTokens?: number;
completionTokens?: number;
totalTokens?: number;
};
}
export interface AIService {
/**
* Generate a chat completion response
*/
generateChatCompletion(messages: Message[], options?: ChatCompletionOptions): Promise<ChatResponse>;
/**
* Check if the service can be used (API key is set, etc.)
*/
isAvailable(): boolean;
/**
* Get the name of the service
*/
getName(): string;
}

View File

@ -0,0 +1,125 @@
import options from '../options.js';
import type { AIService, ChatCompletionOptions, ChatResponse, Message } from './ai_interface.js';
import { OpenAIService } from './openai_service.js';
import { AnthropicService } from './anthropic_service.js';
import { OllamaService } from './ollama_service.js';
type ServiceProviders = 'openai' | 'anthropic' | 'ollama';
export class AIServiceManager {
private services: Record<ServiceProviders, AIService> = {
openai: new OpenAIService(),
anthropic: new AnthropicService(),
ollama: new OllamaService()
};
private providerOrder: ServiceProviders[] = [];
constructor() {
this.updateProviderOrder();
}
/**
* Update the provider precedence order from saved options
*/
updateProviderOrder() {
// Default precedence: openai, anthropic, ollama
const defaultOrder: ServiceProviders[] = ['openai', 'anthropic', 'ollama'];
// Get custom order from options
const customOrder = options.getOption('aiProviderPrecedence');
if (customOrder) {
try {
const parsed = JSON.parse(customOrder);
// Validate that all providers are valid
if (Array.isArray(parsed) &&
parsed.every(p => Object.keys(this.services).includes(p))) {
this.providerOrder = parsed as ServiceProviders[];
} else {
console.warn('Invalid AI provider precedence format, using defaults');
this.providerOrder = defaultOrder;
}
} catch (e) {
console.error('Failed to parse AI provider precedence:', e);
this.providerOrder = defaultOrder;
}
} else {
this.providerOrder = defaultOrder;
}
}
/**
* Check if any AI service is available
*/
isAnyServiceAvailable(): boolean {
return Object.values(this.services).some(service => service.isAvailable());
}
/**
* Get list of available providers
*/
getAvailableProviders(): ServiceProviders[] {
return Object.entries(this.services)
.filter(([_, service]) => service.isAvailable())
.map(([key, _]) => key as ServiceProviders);
}
/**
* Generate a chat completion response using the first available AI service
* based on the configured precedence order
*/
async generateChatCompletion(messages: Message[], options: ChatCompletionOptions = {}): Promise<ChatResponse> {
if (!messages || messages.length === 0) {
throw new Error('No messages provided for chat completion');
}
this.updateProviderOrder();
// Try providers in order of preference
const availableProviders = this.getAvailableProviders();
if (availableProviders.length === 0) {
throw new Error('No AI providers are available. Please check your AI settings.');
}
// Sort available providers by precedence
const sortedProviders = this.providerOrder
.filter(provider => availableProviders.includes(provider));
// If a specific provider is requested and available, use it
if (options.model && options.model.includes(':')) {
const [providerName, modelName] = options.model.split(':');
if (availableProviders.includes(providerName as ServiceProviders)) {
try {
const modifiedOptions = { ...options, model: modelName };
return await this.services[providerName as ServiceProviders].generateChatCompletion(messages, modifiedOptions);
} catch (error) {
console.error(`Error with specified provider ${providerName}:`, error);
// If the specified provider fails, continue with the fallback providers
}
}
}
// Try each provider in order until one succeeds
let lastError: Error | null = null;
for (const provider of sortedProviders) {
try {
return await this.services[provider].generateChatCompletion(messages, options);
} catch (error) {
console.error(`Error with provider ${provider}:`, error);
lastError = error as Error;
// Continue to the next provider
}
}
// If we get here, all providers failed
throw new Error(`All AI providers failed: ${lastError?.message || 'Unknown error'}`);
}
}
// Singleton instance
const aiServiceManager = new AIServiceManager();
export default aiServiceManager;

View File

@ -0,0 +1,94 @@
import options from '../options.js';
import { BaseAIService } from './base_ai_service.js';
import type { ChatCompletionOptions, ChatResponse, Message } from './ai_interface.js';
export class AnthropicService extends BaseAIService {
constructor() {
super('Anthropic');
}
isAvailable(): boolean {
return super.isAvailable() && !!options.getOption('anthropicApiKey');
}
async generateChatCompletion(messages: Message[], opts: ChatCompletionOptions = {}): Promise<ChatResponse> {
if (!this.isAvailable()) {
throw new Error('Anthropic service is not available. Check API key and AI settings.');
}
const apiKey = options.getOption('anthropicApiKey');
const baseUrl = options.getOption('anthropicBaseUrl') || 'https://api.anthropic.com';
const model = opts.model || options.getOption('anthropicDefaultModel') || 'claude-3-haiku-20240307';
const temperature = opts.temperature !== undefined
? opts.temperature
: parseFloat(options.getOption('aiTemperature') || '0.7');
const systemPrompt = this.getSystemPrompt(opts.systemPrompt || options.getOption('aiSystemPrompt'));
// Format for Anthropic's API
const formattedMessages = this.formatMessages(messages, systemPrompt);
try {
const endpoint = `${baseUrl.replace(/\/+$/, '')}/v1/messages`;
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01'
},
body: JSON.stringify({
model,
messages: formattedMessages.messages,
system: formattedMessages.system,
temperature,
max_tokens: opts.maxTokens || 4000,
})
});
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`Anthropic API error: ${response.status} ${response.statusText} - ${errorBody}`);
}
const data = await response.json();
return {
text: data.content[0].text,
model: data.model,
provider: this.getName(),
usage: {
// Anthropic doesn't provide token usage in the same format as OpenAI
// but we can still estimate based on input/output length
totalTokens: data.usage?.input_tokens + data.usage?.output_tokens
}
};
} catch (error) {
console.error('Anthropic service error:', error);
throw error;
}
}
private formatMessages(messages: Message[], systemPrompt: string): { messages: any[], system: string } {
// Extract system messages
const systemMessages = messages.filter(m => m.role === 'system');
const nonSystemMessages = messages.filter(m => m.role !== 'system');
// Combine all system messages with our default
const combinedSystemPrompt = [systemPrompt]
.concat(systemMessages.map(m => m.content))
.join('\n\n');
// Format remaining messages for Anthropic's API
const formattedMessages = nonSystemMessages.map(m => ({
role: m.role,
content: m.content
}));
return {
messages: formattedMessages,
system: combinedSystemPrompt
};
}
}

View File

@ -0,0 +1,29 @@
import options from '../options.js';
import type { AIService, ChatCompletionOptions, ChatResponse, Message } from './ai_interface.js';
export abstract class BaseAIService implements AIService {
protected name: string;
constructor(name: string) {
this.name = name;
}
abstract generateChatCompletion(messages: Message[], options?: ChatCompletionOptions): Promise<ChatResponse>;
isAvailable(): boolean {
return options.getOption('aiEnabled') === 'true'; // Base check if AI is enabled globally
}
getName(): string {
return this.name;
}
protected getSystemPrompt(customPrompt?: string): string {
// Default system prompt if none is provided
return customPrompt ||
"You are a helpful assistant embedded in the Trilium Notes application. " +
"You can help users with their notes, answer questions, and provide information. " +
"Keep your responses concise and helpful. " +
"You're currently chatting with the user about their notes.";
}
}

View File

@ -0,0 +1,222 @@
import type { Message, ChatCompletionOptions } from './ai_interface.js';
import aiServiceManager from './ai_service_manager.js';
import chatStorageService from './chat_storage_service.js';
import contextExtractor from './context_extractor.js';
export interface ChatSession {
id: string;
title: string;
messages: Message[];
isStreaming?: boolean;
options?: ChatCompletionOptions;
}
/**
* Service for managing chat interactions and history
*/
export class ChatService {
private activeSessions: Map<string, ChatSession> = new Map();
private streamingCallbacks: Map<string, (content: string, isDone: boolean) => void> = new Map();
/**
* Create a new chat session
*/
async createSession(title?: string, initialMessages: Message[] = []): Promise<ChatSession> {
const chat = await chatStorageService.createChat(title || 'New Chat', initialMessages);
const session: ChatSession = {
id: chat.id,
title: chat.title,
messages: chat.messages,
isStreaming: false
};
this.activeSessions.set(chat.id, session);
return session;
}
/**
* Get an existing session or create a new one
*/
async getOrCreateSession(sessionId?: string): Promise<ChatSession> {
if (sessionId) {
const existingSession = this.activeSessions.get(sessionId);
if (existingSession) {
return existingSession;
}
const chat = await chatStorageService.getChat(sessionId);
if (chat) {
const session: ChatSession = {
id: chat.id,
title: chat.title,
messages: chat.messages,
isStreaming: false
};
this.activeSessions.set(chat.id, session);
return session;
}
}
return this.createSession();
}
/**
* Send a message in a chat session and get the AI response
*/
async sendMessage(
sessionId: string,
content: string,
options?: ChatCompletionOptions,
streamCallback?: (content: string, isDone: boolean) => void
): Promise<ChatSession> {
const session = await this.getOrCreateSession(sessionId);
// Add user message
const userMessage: Message = {
role: 'user',
content
};
session.messages.push(userMessage);
session.isStreaming = true;
// Set up streaming if callback provided
if (streamCallback) {
this.streamingCallbacks.set(session.id, streamCallback);
}
try {
// Immediately save the user message
await chatStorageService.updateChat(session.id, session.messages);
// Generate AI response
const response = await aiServiceManager.generateChatCompletion(
session.messages,
options || session.options
);
// Add assistant message
const assistantMessage: Message = {
role: 'assistant',
content: response.text
};
session.messages.push(assistantMessage);
session.isStreaming = false;
// Save the complete conversation
await chatStorageService.updateChat(session.id, session.messages);
// If first message, update the title based on content
if (session.messages.length <= 2 && !session.title) {
// Extract a title from the conversation
const title = this.generateTitleFromMessages(session.messages);
session.title = title;
await chatStorageService.updateChat(session.id, session.messages, title);
}
// Notify streaming is complete
if (streamCallback) {
streamCallback(response.text, true);
this.streamingCallbacks.delete(session.id);
}
return session;
} catch (error: any) {
session.isStreaming = false;
console.error('Error in AI chat:', error);
// Add error message so user knows something went wrong
const errorMessage: Message = {
role: 'assistant',
content: `Error: Failed to generate response. ${error.message || 'Please check AI settings and try again.'}`
};
session.messages.push(errorMessage);
// Save the conversation with error
await chatStorageService.updateChat(session.id, session.messages);
// Notify streaming is complete with error
if (streamCallback) {
streamCallback(errorMessage.content, true);
this.streamingCallbacks.delete(session.id);
}
return session;
}
}
/**
* Add context from the current note to the chat
*/
async addNoteContext(sessionId: string, noteId: string): Promise<ChatSession> {
const session = await this.getOrCreateSession(sessionId);
const context = await contextExtractor.getFullContext(noteId);
const contextMessage: Message = {
role: 'user',
content: `Here is the content of the note I want to discuss:\n\n${context}\n\nPlease help me with this information.`
};
session.messages.push(contextMessage);
await chatStorageService.updateChat(session.id, session.messages);
return session;
}
/**
* Get all user's chat sessions
*/
async getAllSessions(): Promise<ChatSession[]> {
const chats = await chatStorageService.getAllChats();
return chats.map(chat => ({
id: chat.id,
title: chat.title,
messages: chat.messages,
isStreaming: this.activeSessions.get(chat.id)?.isStreaming || false
}));
}
/**
* Delete a chat session
*/
async deleteSession(sessionId: string): Promise<boolean> {
this.activeSessions.delete(sessionId);
this.streamingCallbacks.delete(sessionId);
return chatStorageService.deleteChat(sessionId);
}
/**
* Generate a title from the first messages in a conversation
*/
private generateTitleFromMessages(messages: Message[]): string {
if (messages.length < 2) {
return 'New Chat';
}
// Get the first user message
const firstUserMessage = messages.find(m => m.role === 'user');
if (!firstUserMessage) {
return 'New Chat';
}
// Extract first line or first few words
const firstLine = firstUserMessage.content.split('\n')[0].trim();
if (firstLine.length <= 30) {
return firstLine;
}
// Take first 30 chars if too long
return firstLine.substring(0, 27) + '...';
}
}
// Singleton instance
const chatService = new ChatService();
export default chatService;

View File

@ -0,0 +1,217 @@
import notes from '../notes.js';
import sql from '../sql.js';
import attributes from '../attributes.js';
import type { Message } from './ai_interface.js';
interface StoredChat {
id: string;
title: string;
messages: Message[];
noteId?: string;
createdAt: Date;
updatedAt: Date;
}
/**
* Service for storing and retrieving chat histories
* Chats are stored as a special type of note
*/
export class ChatStorageService {
private static readonly CHAT_LABEL = 'triliumChat';
private static readonly CHAT_ROOT_LABEL = 'triliumChatRoot';
private static readonly CHAT_TYPE = 'code';
private static readonly CHAT_MIME = 'application/json';
/**
* Get or create the root note for all chats
*/
async getOrCreateChatRoot(): Promise<string> {
const existingRoot = await sql.getRow<{noteId: string}>(
`SELECT noteId FROM attributes WHERE name = ? AND value = ?`,
['label', ChatStorageService.CHAT_ROOT_LABEL]
);
if (existingRoot) {
return existingRoot.noteId;
}
// Create root note for chats
const { note } = notes.createNewNote({
parentNoteId: 'root',
title: 'AI Chats',
type: 'text',
content: 'This note contains your saved AI chat conversations.'
});
attributes.createLabel(
note.noteId,
ChatStorageService.CHAT_ROOT_LABEL,
''
);
return note.noteId;
}
/**
* Create a new chat
*/
async createChat(title: string, messages: Message[] = []): Promise<StoredChat> {
const rootNoteId = await this.getOrCreateChatRoot();
const now = new Date();
const { note } = notes.createNewNote({
parentNoteId: rootNoteId,
title: title || 'New Chat ' + now.toLocaleString(),
type: ChatStorageService.CHAT_TYPE,
mime: ChatStorageService.CHAT_MIME,
content: JSON.stringify({
messages,
createdAt: now,
updatedAt: now
}, null, 2)
});
attributes.createLabel(
note.noteId,
ChatStorageService.CHAT_LABEL,
''
);
return {
id: note.noteId,
title: title || 'New Chat ' + now.toLocaleString(),
messages,
noteId: note.noteId,
createdAt: now,
updatedAt: now
};
}
/**
* Get all chats
*/
async getAllChats(): Promise<StoredChat[]> {
const chats = await sql.getRows<{noteId: string, title: string, dateCreated: string, dateModified: string, content: string}>(
`SELECT notes.noteId, notes.title, notes.dateCreated, notes.dateModified, note_contents.content
FROM notes
JOIN note_contents ON notes.noteId = note_contents.noteId
JOIN attributes ON notes.noteId = attributes.noteId
WHERE attributes.name = ? AND attributes.value = ?
ORDER BY notes.dateModified DESC`,
['label', ChatStorageService.CHAT_LABEL]
);
return chats.map(chat => {
let messages: Message[] = [];
try {
const content = JSON.parse(chat.content);
messages = content.messages || [];
} catch (e) {
console.error('Failed to parse chat content:', e);
}
return {
id: chat.noteId,
title: chat.title,
messages,
noteId: chat.noteId,
createdAt: new Date(chat.dateCreated),
updatedAt: new Date(chat.dateModified)
};
});
}
/**
* Get a specific chat
*/
async getChat(chatId: string): Promise<StoredChat | null> {
const chat = await sql.getRow<{noteId: string, title: string, dateCreated: string, dateModified: string, content: string}>(
`SELECT notes.noteId, notes.title, notes.dateCreated, notes.dateModified, note_contents.content
FROM notes
JOIN note_contents ON notes.noteId = note_contents.noteId
WHERE notes.noteId = ?`,
[chatId]
);
if (!chat) {
return null;
}
let messages: Message[] = [];
try {
const content = JSON.parse(chat.content);
messages = content.messages || [];
} catch (e) {
console.error('Failed to parse chat content:', e);
}
return {
id: chat.noteId,
title: chat.title,
messages,
noteId: chat.noteId,
createdAt: new Date(chat.dateCreated),
updatedAt: new Date(chat.dateModified)
};
}
/**
* Update messages in a chat
*/
async updateChat(chatId: string, messages: Message[], title?: string): Promise<StoredChat | null> {
const chat = await this.getChat(chatId);
if (!chat) {
return null;
}
const now = new Date();
// Update content directly using SQL since we don't have a method for this in the notes service
await sql.execute(
`UPDATE note_contents SET content = ? WHERE noteId = ?`,
[JSON.stringify({
messages,
createdAt: chat.createdAt,
updatedAt: now
}, null, 2), chatId]
);
// Update title if provided
if (title && title !== chat.title) {
await sql.execute(
`UPDATE notes SET title = ? WHERE noteId = ?`,
[title, chatId]
);
}
return {
...chat,
title: title || chat.title,
messages,
updatedAt: now
};
}
/**
* Delete a chat
*/
async deleteChat(chatId: string): Promise<boolean> {
try {
// Mark note as deleted using SQL since we don't have deleteNote in the exports
await sql.execute(
`UPDATE notes SET isDeleted = 1 WHERE noteId = ?`,
[chatId]
);
return true;
} catch (e) {
console.error('Failed to delete chat:', e);
return false;
}
}
}
// Singleton instance
const chatStorageService = new ChatStorageService();
export default chatStorageService;

View File

@ -0,0 +1,182 @@
import sql from '../sql.js';
import sanitizeHtml from 'sanitize-html';
/**
* Utility class for extracting context from notes to provide to AI models
*/
export class ContextExtractor {
/**
* Get the content of a note
*/
async getNoteContent(noteId: string): Promise<string | null> {
const note = await sql.getRow<{content: string, type: string, mime: string, title: string}>(
`SELECT note_contents.content, notes.type, notes.mime, notes.title
FROM notes
JOIN note_contents ON notes.noteId = note_contents.noteId
WHERE notes.noteId = ?`,
[noteId]
);
if (!note) {
return null;
}
return this.formatNoteContent(note.content, note.type, note.mime, note.title);
}
/**
* Get a set of parent notes to provide hierarchical context
*/
async getParentContext(noteId: string, maxDepth = 3): Promise<string> {
const parents = await this.getParentNotes(noteId, maxDepth);
if (!parents.length) return '';
let context = 'Here is the hierarchical context for the current note:\n\n';
for (const parent of parents) {
context += `- ${parent.title}\n`;
}
return context + '\n';
}
/**
* Get child notes to provide additional context
*/
async getChildContext(noteId: string, maxChildren = 5): Promise<string> {
const children = await sql.getRows<{noteId: string, title: string}>(
`SELECT noteId, title FROM notes
WHERE parentNoteId = ? AND isDeleted = 0
LIMIT ?`,
[noteId, maxChildren]
);
if (!children.length) return '';
let context = 'The current note has these child notes:\n\n';
for (const child of children) {
context += `- ${child.title}\n`;
}
return context + '\n';
}
/**
* Get notes linked to this note
*/
async getLinkedNotesContext(noteId: string, maxLinks = 5): Promise<string> {
const linkedNotes = await sql.getRows<{title: string}>(
`SELECT title FROM notes
WHERE noteId IN (
SELECT value FROM attributes
WHERE noteId = ? AND type = 'relation'
LIMIT ?
)`,
[noteId, maxLinks]
);
if (!linkedNotes.length) return '';
let context = 'This note has relationships with these notes:\n\n';
for (const linked of linkedNotes) {
context += `- ${linked.title}\n`;
}
return context + '\n';
}
/**
* Format the content of a note based on its type
*/
private formatNoteContent(content: string, type: string, mime: string, title: string): string {
let formattedContent = `# ${title}\n\n`;
switch (type) {
case 'text':
// Remove HTML formatting for text notes
formattedContent += this.sanitizeHtml(content);
break;
case 'code':
// Format code notes with code blocks
formattedContent += '```\n' + content + '\n```';
break;
default:
// For other notes, just use the content as is
formattedContent += this.sanitizeHtml(content);
}
return formattedContent;
}
/**
* Sanitize HTML content to plain text
*/
private sanitizeHtml(html: string): string {
return sanitizeHtml(html, {
allowedTags: [],
allowedAttributes: {},
textFilter: (text) => {
// Replace multiple newlines with a single one
return text.replace(/\n\s*\n/g, '\n\n');
}
});
}
/**
* Get parent notes in the hierarchy
*/
private async getParentNotes(noteId: string, maxDepth: number): Promise<{noteId: string, title: string}[]> {
const parentNotes: {noteId: string, title: string}[] = [];
let currentNoteId = noteId;
for (let i = 0; i < maxDepth; i++) {
const parent = await sql.getRow<{parentNoteId: string, title: string}>(
`SELECT branches.parentNoteId, notes.title
FROM branches
JOIN notes ON branches.parentNoteId = notes.noteId
WHERE branches.noteId = ? AND branches.isDeleted = 0 LIMIT 1`,
[currentNoteId]
);
if (!parent || parent.parentNoteId === 'root') {
break;
}
parentNotes.unshift({
noteId: parent.parentNoteId,
title: parent.title
});
currentNoteId = parent.parentNoteId;
}
return parentNotes;
}
/**
* Get the full context for a note, including parent hierarchy, content, and children
*/
async getFullContext(noteId: string): Promise<string> {
const noteContent = await this.getNoteContent(noteId);
if (!noteContent) {
return 'Note not found';
}
const parentContext = await this.getParentContext(noteId);
const childContext = await this.getChildContext(noteId);
const linkedContext = await this.getLinkedNotesContext(noteId);
return [
parentContext,
noteContent,
childContext,
linkedContext
].filter(Boolean).join('\n\n');
}
}
// Singleton instance
const contextExtractor = new ContextExtractor();
export default contextExtractor;

View File

@ -0,0 +1,86 @@
import options from '../options.js';
import { BaseAIService } from './base_ai_service.js';
import type { ChatCompletionOptions, ChatResponse, Message } from './ai_interface.js';
export class OllamaService extends BaseAIService {
constructor() {
super('Ollama');
}
isAvailable(): boolean {
return super.isAvailable() &&
options.getOption('ollamaEnabled') === 'true' &&
!!options.getOption('ollamaBaseUrl');
}
async generateChatCompletion(messages: Message[], opts: ChatCompletionOptions = {}): Promise<ChatResponse> {
if (!this.isAvailable()) {
throw new Error('Ollama service is not available. Check Ollama settings.');
}
const baseUrl = options.getOption('ollamaBaseUrl') || 'http://localhost:11434';
const model = opts.model || options.getOption('ollamaDefaultModel') || 'llama2';
const temperature = opts.temperature !== undefined
? opts.temperature
: parseFloat(options.getOption('aiTemperature') || '0.7');
const systemPrompt = this.getSystemPrompt(opts.systemPrompt || options.getOption('aiSystemPrompt'));
// Format messages for Ollama
const formattedMessages = this.formatMessages(messages, systemPrompt);
try {
const endpoint = `${baseUrl.replace(/\/+$/, '')}/api/chat`;
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
model,
messages: formattedMessages,
options: {
temperature,
}
})
});
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`Ollama API error: ${response.status} ${response.statusText} - ${errorBody}`);
}
const data = await response.json();
return {
text: data.message?.content || "No response from Ollama",
model: data.model || model,
provider: this.getName(),
usage: {
// Ollama doesn't provide token usage in the same format
totalTokens: data.eval_count || data.prompt_eval_count || 0
}
};
} catch (error) {
console.error('Ollama service error:', error);
throw error;
}
}
private formatMessages(messages: Message[], systemPrompt: string): any[] {
// Add system message if it doesn't exist
const hasSystemMessage = messages.some(m => m.role === 'system');
let resultMessages = [...messages];
if (!hasSystemMessage && systemPrompt) {
resultMessages.unshift({
role: 'system',
content: systemPrompt
});
}
// Ollama uses the same format as OpenAI for messages
return resultMessages;
}
}

View File

@ -0,0 +1,73 @@
import options from '../options.js';
import { BaseAIService } from './base_ai_service.js';
import type { ChatCompletionOptions, ChatResponse, Message } from './ai_interface.js';
export class OpenAIService extends BaseAIService {
constructor() {
super('OpenAI');
}
isAvailable(): boolean {
return super.isAvailable() && !!options.getOption('openaiApiKey');
}
async generateChatCompletion(messages: Message[], opts: ChatCompletionOptions = {}): Promise<ChatResponse> {
if (!this.isAvailable()) {
throw new Error('OpenAI service is not available. Check API key and AI settings.');
}
const apiKey = options.getOption('openaiApiKey');
const baseUrl = options.getOption('openaiBaseUrl') || 'https://api.openai.com';
const model = opts.model || options.getOption('openaiDefaultModel') || 'gpt-3.5-turbo';
const temperature = opts.temperature !== undefined
? opts.temperature
: parseFloat(options.getOption('aiTemperature') || '0.7');
const systemPrompt = this.getSystemPrompt(opts.systemPrompt || options.getOption('aiSystemPrompt'));
// Ensure we have a system message
const systemMessageExists = messages.some(m => m.role === 'system');
const messagesWithSystem = systemMessageExists
? messages
: [{ role: 'system', content: systemPrompt }, ...messages];
try {
const endpoint = `${baseUrl.replace(/\/+$/, '')}/v1/chat/completions`;
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
body: JSON.stringify({
model,
messages: messagesWithSystem,
temperature,
max_tokens: opts.maxTokens,
})
});
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`OpenAI API error: ${response.status} ${response.statusText} - ${errorBody}`);
}
const data = await response.json();
return {
text: data.choices[0].message.content,
model: data.model,
provider: this.getName(),
usage: {
promptTokens: data.usage?.prompt_tokens,
completionTokens: data.usage?.completion_tokens,
totalTokens: data.usage?.total_tokens
}
};
} catch (error) {
console.error('OpenAI service error:', error);
throw error;
}
}
}

View File

@ -98,7 +98,9 @@ async function prepareMigrations(currentDbVersion: number): Promise<MigrationInf
if (type === "js" || type === "ts") {
// Due to ESM imports, the migration file needs to be imported asynchronously and thus cannot be loaded at migration time (since migration is not asynchronous).
// As such we have to preload the ESM.
migration.module = (await import(`file://${resourceDir.MIGRATIONS_DIR}/${file}`)).default;
// Going back to the original approach but making it webpack-compatible
const importPath = `../../db/migrations/${file}`;
migration.module = (await import(importPath)).default;
}
migrations.push(migration);