mirror of
https://github.com/TriliumNext/Notes.git
synced 2025-08-10 10:22:29 +08:00
hey look, it doesn't crash again
This commit is contained in:
parent
e09e15ad05
commit
f2a6f92732
@ -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();
|
||||
|
@ -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"))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
63
src/public/app/services/app_service.js
Normal file
63
src/public/app/services/app_service.js
Normal 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
|
||||
};
|
127
src/public/app/services/right_pane_tab_manager.js
Normal file
127
src/public/app/services/right_pane_tab_manager.js
Normal 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;
|
356
src/public/app/widgets/llm/chat_widget.js
Normal file
356
src/public/app/widgets/llm/chat_widget.js
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
49
src/public/app/widgets/right_panel_tabs.js
Normal file
49
src/public/app/widgets/right_panel_tabs.js
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
53
src/public/app/widgets/tab_aware_widget.js
Normal file
53
src/public/app/widgets/tab_aware_widget.js
Normal 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() {}
|
||||
}
|
@ -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;
|
||||
}
|
39
src/services/llm/ai_interface.ts
Normal file
39
src/services/llm/ai_interface.ts
Normal 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;
|
||||
}
|
125
src/services/llm/ai_service_manager.ts
Normal file
125
src/services/llm/ai_service_manager.ts
Normal 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;
|
94
src/services/llm/anthropic_service.ts
Normal file
94
src/services/llm/anthropic_service.ts
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
29
src/services/llm/base_ai_service.ts
Normal file
29
src/services/llm/base_ai_service.ts
Normal 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.";
|
||||
}
|
||||
}
|
222
src/services/llm/chat_service.ts
Normal file
222
src/services/llm/chat_service.ts
Normal 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;
|
217
src/services/llm/chat_storage_service.ts
Normal file
217
src/services/llm/chat_storage_service.ts
Normal 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;
|
182
src/services/llm/context_extractor.ts
Normal file
182
src/services/llm/context_extractor.ts
Normal 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;
|
86
src/services/llm/ollama_service.ts
Normal file
86
src/services/llm/ollama_service.ts
Normal 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;
|
||||
}
|
||||
}
|
73
src/services/llm/openai_service.ts
Normal file
73
src/services/llm/openai_service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
|
Loading…
x
Reference in New Issue
Block a user