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