diff --git a/src/public/app/desktop.ts b/src/public/app/desktop.ts
index 025132495..4d723ece2 100644
--- a/src/public/app/desktop.ts
+++ b/src/public/app/desktop.ts
@@ -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();
diff --git a/src/public/app/layouts/desktop_layout.js b/src/public/app/layouts/desktop_layout.js
index 1ece1c156..1355fab73 100644
--- a/src/public/app/layouts/desktop_layout.js
+++ b/src/public/app/layouts/desktop_layout.js
@@ -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"))
+ )
)
)
)
diff --git a/src/public/app/services/app_service.js b/src/public/app/services/app_service.js
new file mode 100644
index 000000000..b1325c23c
--- /dev/null
+++ b/src/public/app/services/app_service.js
@@ -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 $(` ${title}`);
+ };
+
+ rightPaneTabManager.addTabContext(chatTab);
+
+ // Add chat button to the global menu
+ const $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
+};
diff --git a/src/public/app/services/right_pane_tab_manager.js b/src/public/app/services/right_pane_tab_manager.js
new file mode 100644
index 000000000..c8df13b60
--- /dev/null
+++ b/src/public/app/services/right_pane_tab_manager.js
@@ -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 = $('
')
+ .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;
diff --git a/src/public/app/widgets/llm/chat_widget.js b/src/public/app/widgets/llm/chat_widget.js
new file mode 100644
index 000000000..15b4b695d
--- /dev/null
+++ b/src/public/app/widgets/llm/chat_widget.js
@@ -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 = `
+
+`;
+
+const MESSAGE_TPL = `
+
+`;
+
+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('●●●
');
+
+ 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 `${utils.escapeHtml(code)}
`;
+ });
+
+ // Convert inline code
+ formatted = formatted.replace(/`([^`]+)`/g, '$1
');
+
+ // Convert line breaks
+ formatted = formatted.replace(/\n/g, '
');
+
+ 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();
+ }
+ }
+}
diff --git a/src/public/app/widgets/right_panel_tabs.js b/src/public/app/widgets/right_panel_tabs.js
new file mode 100644
index 000000000..83660d3b1
--- /dev/null
+++ b/src/public/app/widgets/right_panel_tabs.js
@@ -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 $(`${title}`);
+ }
+
+ /**
+ * 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);
+ }
+ }
+}
diff --git a/src/public/app/widgets/tab_aware_widget.js b/src/public/app/widgets/tab_aware_widget.js
new file mode 100644
index 000000000..c6f8e4450
--- /dev/null
+++ b/src/public/app/widgets/tab_aware_widget.js
@@ -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() {}
+}
diff --git a/src/public/stylesheets/style.css b/src/public/stylesheets/style.css
index e4e54b0f1..b68239fa6 100644
--- a/src/public/stylesheets/style.css
+++ b/src/public/stylesheets/style.css
@@ -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;
}
\ No newline at end of file
diff --git a/src/services/llm/ai_interface.ts b/src/services/llm/ai_interface.ts
new file mode 100644
index 000000000..e94e60ba1
--- /dev/null
+++ b/src/services/llm/ai_interface.ts
@@ -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;
+
+ /**
+ * Check if the service can be used (API key is set, etc.)
+ */
+ isAvailable(): boolean;
+
+ /**
+ * Get the name of the service
+ */
+ getName(): string;
+}
diff --git a/src/services/llm/ai_service_manager.ts b/src/services/llm/ai_service_manager.ts
new file mode 100644
index 000000000..b49918c5d
--- /dev/null
+++ b/src/services/llm/ai_service_manager.ts
@@ -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 = {
+ 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 {
+ 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;
diff --git a/src/services/llm/anthropic_service.ts b/src/services/llm/anthropic_service.ts
new file mode 100644
index 000000000..c49865b8f
--- /dev/null
+++ b/src/services/llm/anthropic_service.ts
@@ -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 {
+ 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
+ };
+ }
+}
diff --git a/src/services/llm/base_ai_service.ts b/src/services/llm/base_ai_service.ts
new file mode 100644
index 000000000..080dd1fa5
--- /dev/null
+++ b/src/services/llm/base_ai_service.ts
@@ -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;
+
+ 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.";
+ }
+}
diff --git a/src/services/llm/chat_service.ts b/src/services/llm/chat_service.ts
new file mode 100644
index 000000000..fbb9419e4
--- /dev/null
+++ b/src/services/llm/chat_service.ts
@@ -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 = new Map();
+ private streamingCallbacks: Map void> = new Map();
+
+ /**
+ * Create a new chat session
+ */
+ async createSession(title?: string, initialMessages: Message[] = []): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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;
diff --git a/src/services/llm/chat_storage_service.ts b/src/services/llm/chat_storage_service.ts
new file mode 100644
index 000000000..552c7b6d9
--- /dev/null
+++ b/src/services/llm/chat_storage_service.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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;
diff --git a/src/services/llm/context_extractor.ts b/src/services/llm/context_extractor.ts
new file mode 100644
index 000000000..875b4307c
--- /dev/null
+++ b/src/services/llm/context_extractor.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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;
diff --git a/src/services/llm/ollama_service.ts b/src/services/llm/ollama_service.ts
new file mode 100644
index 000000000..682ac57fa
--- /dev/null
+++ b/src/services/llm/ollama_service.ts
@@ -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 {
+ 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;
+ }
+}
diff --git a/src/services/llm/openai_service.ts b/src/services/llm/openai_service.ts
new file mode 100644
index 000000000..3a664077d
--- /dev/null
+++ b/src/services/llm/openai_service.ts
@@ -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 {
+ 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;
+ }
+ }
+}
diff --git a/src/services/migration.ts b/src/services/migration.ts
index 17f1be40d..d47de4d81 100644
--- a/src/services/migration.ts
+++ b/src/services/migration.ts
@@ -98,7 +98,9 @@ async function prepareMigrations(currentDbVersion: number): Promise