diff --git a/db/migrations/0229__ai_llm_options.sql b/db/migrations/0229__ai_llm_options.sql index 065373c48..dcbd42b8b 100644 --- a/db/migrations/0229__ai_llm_options.sql +++ b/db/migrations/0229__ai_llm_options.sql @@ -1,22 +1,26 @@ -- Add new options for AI/LLM integration -INSERT INTO options (name, value, isSynced) VALUES ('aiEnabled', 'false', 1); +INSERT INTO options (name, value, isSynced, utcDateModified) VALUES ('aiEnabled', 'false', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')); -- OpenAI settings -INSERT INTO options (name, value, isSynced) VALUES ('openaiApiKey', '', 1); -INSERT INTO options (name, value, isSynced) VALUES ('openaiDefaultModel', 'gpt-4o', 1); -INSERT INTO options (name, value, isSynced) VALUES ('openaiBaseUrl', 'https://api.openai.com/v1', 1); +INSERT INTO options (name, value, isSynced, utcDateModified) VALUES ('openaiApiKey', '', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')); +INSERT INTO options (name, value, isSynced, utcDateModified) VALUES ('openaiDefaultModel', 'gpt-4o', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')); +INSERT INTO options (name, value, isSynced, utcDateModified) VALUES ('openaiBaseUrl', 'https://api.openai.com/v1', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')); -- Anthropic settings -INSERT INTO options (name, value, isSynced) VALUES ('anthropicApiKey', '', 1); -INSERT INTO options (name, value, isSynced) VALUES ('anthropicDefaultModel', 'claude-3-opus-20240229', 1); -INSERT INTO options (name, value, isSynced) VALUES ('anthropicBaseUrl', 'https://api.anthropic.com/v1', 1); +INSERT INTO options (name, value, isSynced, utcDateModified) VALUES ('anthropicApiKey', '', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')); +INSERT INTO options (name, value, isSynced, utcDateModified) VALUES ('anthropicDefaultModel', 'claude-3-opus-20240229', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')); +INSERT INTO options (name, value, isSynced, utcDateModified) VALUES ('anthropicBaseUrl', 'https://api.anthropic.com/v1', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')); -- Ollama settings -INSERT INTO options (name, value, isSynced) VALUES ('ollamaEnabled', 'false', 1); -INSERT INTO options (name, value, isSynced) VALUES ('ollamaBaseUrl', 'http://localhost:11434', 1); -INSERT INTO options (name, value, isSynced) VALUES ('ollamaDefaultModel', 'llama3', 1); +INSERT INTO options (name, value, isSynced, utcDateModified) VALUES ('ollamaEnabled', 'false', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')); +INSERT INTO options (name, value, isSynced, utcDateModified) VALUES ('ollamaBaseUrl', 'http://localhost:11434', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')); +INSERT INTO options (name, value, isSynced, utcDateModified) VALUES ('ollamaDefaultModel', 'llama3', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')); +INSERT INTO options (name, value, isSynced, utcDateModified) VALUES ('ollamaEmbeddingModel', 'nomic-embed-text', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')); -- General AI settings -INSERT INTO options (name, value, isSynced) VALUES ('aiProviderPrecedence', 'openai,anthropic,ollama', 1); -INSERT INTO options (name, value, isSynced) VALUES ('aiTemperature', '0.7', 1); -INSERT INTO options (name, value, isSynced) VALUES ('aiSystemPrompt', '', 1); \ No newline at end of file +INSERT INTO options (name, value, isSynced, utcDateModified) VALUES ('aiProviderPrecedence', 'openai,anthropic,ollama', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')); +INSERT INTO options (name, value, isSynced, utcDateModified) VALUES ('aiTemperature', '0.7', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')); +INSERT INTO options (name, value, isSynced, utcDateModified) VALUES ('aiSystemPrompt', '', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')); + +-- Embedding settings +INSERT INTO options (name, value, isSynced, utcDateModified) VALUES ('embeddingsDefaultProvider', 'openai', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')); \ No newline at end of file diff --git a/src/public/app/components/app_context.ts b/src/public/app/components/app_context.ts index 8c62a5586..b3b8f0b2d 100644 --- a/src/public/app/components/app_context.ts +++ b/src/public/app/components/app_context.ts @@ -83,6 +83,7 @@ export type CommandMappings = { closeHlt: CommandData; showLaunchBarSubtree: CommandData; showRevisions: CommandData; + showLlmChat: CommandData; showOptions: CommandData & { section: string; }; diff --git a/src/public/app/components/note_context.ts b/src/public/app/components/note_context.ts index 0b4798cea..2562d22e3 100644 --- a/src/public/app/components/note_context.ts +++ b/src/public/app/components/note_context.ts @@ -369,6 +369,11 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded"> const { note, viewScope } = this; + // For llmChat viewMode, show a custom title + if (viewScope?.viewMode === "llmChat") { + return "Chat with Notes"; + } + const isNormalView = (viewScope?.viewMode === "default" || viewScope?.viewMode === "contextual-help"); let title = (isNormalView ? note.title : `${note.title}: ${viewScope?.viewMode}`); diff --git a/src/public/app/components/root_command_executor.ts b/src/public/app/components/root_command_executor.ts index eb46e3139..96e917d81 100644 --- a/src/public/app/components/root_command_executor.ts +++ b/src/public/app/components/root_command_executor.ts @@ -7,8 +7,12 @@ import protectedSessionService from "../services/protected_session.js"; import options from "../services/options.js"; import froca from "../services/froca.js"; import utils from "../services/utils.js"; +import LlmChatPanel from "../widgets/llm_chat_panel.js"; +import toastService from "../services/toast.js"; export default class RootCommandExecutor extends Component { + private llmChatPanel: any = null; + editReadOnlyNoteCommand() { const noteContext = appContext.tabManager.getActiveContext(); if (noteContext?.viewScope) { @@ -226,4 +230,23 @@ export default class RootCommandExecutor extends Component { appContext.tabManager.activateNoteContext(tab.ntxId); } } + + async showLlmChatCommand() { + console.log("showLlmChatCommand triggered"); + toastService.showMessage("Opening LLM Chat..."); + + try { + // We'll use the Note Map approach - open a known note ID that corresponds to the LLM chat panel + await appContext.tabManager.openTabWithNoteWithHoisting("_globalNoteMap", { + activate: true, + viewScope: { + viewMode: "llmChat" // We'll need to handle this custom view mode elsewhere + } + }); + } + catch (e) { + console.error("Error opening LLM Chat:", e); + toastService.showError("Failed to open LLM Chat: " + (e as Error).message); + } + } } diff --git a/src/public/app/widgets/containers/launcher.ts b/src/public/app/widgets/containers/launcher.ts index 86fbabb96..942bdb60b 100644 --- a/src/public/app/widgets/containers/launcher.ts +++ b/src/public/app/widgets/containers/launcher.ts @@ -123,6 +123,8 @@ export default class LauncherWidget extends BasicWidget { return new TodayLauncher(note); case "quickSearch": return new QuickSearchLauncherWidget(this.isHorizontalLayout); + case "llmChatLauncher": + return new ScriptLauncher(note); default: throw new Error(`Unrecognized builtin widget ${builtinWidget} for launcher ${note.noteId} "${note.title}"`); } diff --git a/src/public/app/widgets/llm_chat_panel.ts b/src/public/app/widgets/llm_chat_panel.ts new file mode 100644 index 000000000..84ab68f77 --- /dev/null +++ b/src/public/app/widgets/llm_chat_panel.ts @@ -0,0 +1,246 @@ +import BasicWidget from "./basic_widget.js"; +import toastService from "../services/toast.js"; +import server from "../services/server.js"; +import appContext from "../components/app_context.js"; +import utils from "../services/utils.js"; +import { t } from "../services/i18n.js"; + +interface ChatResponse { + id: string; + messages: Array<{role: string; content: string}>; + sources?: Array<{noteId: string; title: string}>; +} + +interface SessionResponse { + id: string; + title: string; +} + +export default class LlmChatPanel extends BasicWidget { + private noteContextChatMessages!: HTMLElement; + private noteContextChatForm!: HTMLFormElement; + private noteContextChatInput!: HTMLTextAreaElement; + private noteContextChatSendButton!: HTMLButtonElement; + private chatContainer!: HTMLElement; + private loadingIndicator!: HTMLElement; + private sourcesList!: HTMLElement; + private sessionId: string | null = null; + private currentNoteId: string | null = null; + + doRender() { + this.$widget = $(` +
+
+
+ +
+ + + +
+ + +
+
+ `); + + const element = this.$widget[0]; + this.noteContextChatMessages = element.querySelector('.note-context-chat-messages') as HTMLElement; + this.noteContextChatForm = element.querySelector('.note-context-chat-form') as HTMLFormElement; + this.noteContextChatInput = element.querySelector('.note-context-chat-input') as HTMLTextAreaElement; + this.noteContextChatSendButton = element.querySelector('.note-context-chat-send-button') as HTMLButtonElement; + this.chatContainer = element.querySelector('.note-context-chat-container') as HTMLElement; + this.loadingIndicator = element.querySelector('.loading-indicator') as HTMLElement; + this.sourcesList = element.querySelector('.sources-list') as HTMLElement; + + this.initializeEventListeners(); + + // Create a session when first loaded + this.createChatSession(); + + return this.$widget; + } + + async refresh() { + if (!this.isVisible()) { + return; + } + + // Get current note context if needed + this.currentNoteId = appContext.tabManager.getActiveContext()?.note?.noteId || null; + + if (!this.sessionId) { + // Create a new chat session + await this.createChatSession(); + } + } + + private async createChatSession() { + try { + const resp = await server.post('llm/sessions', { + title: 'Note Chat' + }); + + if (resp && resp.id) { + this.sessionId = resp.id; + } + } catch (error) { + console.error('Failed to create chat session:', error); + toastService.showError('Failed to create chat session'); + } + } + + private async sendMessage(content: string) { + if (!content.trim() || !this.sessionId) { + return; + } + + this.showLoadingIndicator(); + + try { + // Add user message to chat + this.addMessageToChat('user', content); + this.noteContextChatInput.value = ''; + + // Get AI settings + const useRAG = true; // Always use RAG for this widget + + // Send message to server + const response = await server.post('llm/sessions/' + this.sessionId + '/messages', { + sessionId: this.sessionId, + content: content, + options: { + useRAG: useRAG + } + }); + + // Get the assistant's message (last one) + if (response?.messages?.length) { + const messages = response.messages; + const lastMessage = messages[messages.length - 1]; + + if (lastMessage && lastMessage.role === 'assistant') { + this.addMessageToChat('assistant', lastMessage.content); + } + } + + // Display sources if available + if (response?.sources?.length) { + this.showSources(response.sources); + } else { + this.hideSources(); + } + + } catch (error) { + console.error('Failed to send message:', error); + toastService.showError('Failed to send message to AI'); + } finally { + this.hideLoadingIndicator(); + } + } + + private addMessageToChat(role: 'user' | 'assistant', content: string) { + const messageElement = document.createElement('div'); + messageElement.className = `chat-message ${role}-message mb-3`; + + const avatarElement = document.createElement('div'); + avatarElement.className = 'message-avatar'; + avatarElement.innerHTML = role === 'user' + ? '' + : ''; + + const contentElement = document.createElement('div'); + contentElement.className = 'message-content p-3'; + + // Use a simple markdown formatter if utils.formatMarkdown is not available + let formattedContent = content + .replace(/```([\s\S]*?)```/g, '
$1
') + .replace(/`([^`]+)`/g, '$1') + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/\*([^*]+)\*/g, '$1') + .replace(/\n/g, '
'); + + contentElement.innerHTML = formattedContent; + + messageElement.appendChild(avatarElement); + messageElement.appendChild(contentElement); + + this.noteContextChatMessages.appendChild(messageElement); + + // Scroll to bottom + this.chatContainer.scrollTop = this.chatContainer.scrollHeight; + } + + private showSources(sources: Array<{noteId: string, title: string}>) { + this.sourcesList.innerHTML = ''; + + sources.forEach(source => { + const sourceElement = document.createElement('div'); + sourceElement.className = 'source-item p-1'; + sourceElement.innerHTML = `${source.title}`; + + sourceElement.querySelector('.source-link')?.addEventListener('click', (e) => { + e.preventDefault(); + appContext.tabManager.openTabWithNoteWithHoisting(source.noteId); + }); + + this.sourcesList.appendChild(sourceElement); + }); + + const sourcesContainer = this.$widget[0].querySelector('.sources-container') as HTMLElement; + if (sourcesContainer) { + sourcesContainer.style.display = 'block'; + } + } + + private hideSources() { + const sourcesContainer = this.$widget[0].querySelector('.sources-container') as HTMLElement; + if (sourcesContainer) { + sourcesContainer.style.display = 'none'; + } + } + + private showLoadingIndicator() { + this.loadingIndicator.style.display = 'flex'; + } + + private hideLoadingIndicator() { + this.loadingIndicator.style.display = 'none'; + } + + private initializeEventListeners() { + this.noteContextChatForm.addEventListener('submit', (e) => { + e.preventDefault(); + const content = this.noteContextChatInput.value; + this.sendMessage(content); + }); + + // Add auto-resize functionality to the textarea + this.noteContextChatInput.addEventListener('input', () => { + this.noteContextChatInput.style.height = 'auto'; + this.noteContextChatInput.style.height = `${this.noteContextChatInput.scrollHeight}px`; + }); + + // Handle Enter key (send on Enter, new line on Shift+Enter) + this.noteContextChatInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + this.noteContextChatForm.dispatchEvent(new Event('submit')); + } + }); + } +} diff --git a/src/public/app/widgets/note_detail.ts b/src/public/app/widgets/note_detail.ts index 962daf1be..aca9f9f18 100644 --- a/src/public/app/widgets/note_detail.ts +++ b/src/public/app/widgets/note_detail.ts @@ -35,6 +35,7 @@ import GeoMapTypeWidget from "./type_widgets/geo_map.js"; import utils from "../services/utils.js"; import type { NoteType } from "../entities/fnote.js"; import type TypeWidget from "./type_widgets/type_widget.js"; +import LlmChatTypeWidget from "./type_widgets/llm_chat.js"; const TPL = `
@@ -72,7 +73,8 @@ const typeWidgetClasses = { attachmentDetail: AttachmentDetailTypeWidget, attachmentList: AttachmentListTypeWidget, mindMap: MindMapWidget, - geoMap: GeoMapTypeWidget + geoMap: GeoMapTypeWidget, + llmChat: LlmChatTypeWidget }; /** @@ -88,7 +90,8 @@ type ExtendedNoteType = | "editableCode" | "attachmentDetail" | "attachmentList" - | "protectedSession"; + | "protectedSession" + | "llmChat"; export default class NoteDetailWidget extends NoteContextAwareWidget { @@ -211,17 +214,19 @@ export default class NoteDetailWidget extends NoteContextAwareWidget { async getWidgetType(): Promise { const note = this.note; - if (!note) { return "empty"; } - let type: NoteType = note.type; + const type = note.type; let resultingType: ExtendedNoteType; const viewScope = this.noteContext?.viewScope; if (viewScope?.viewMode === "source") { resultingType = "readOnlyCode"; + } else if (viewScope?.viewMode === "llmChat") { + // Special handling for our LLM Chat view mode + resultingType = "llmChat"; // This will need to be added to the ExtendedNoteType } else if (viewScope && viewScope.viewMode === "attachments") { resultingType = viewScope.attachmentId ? "attachmentDetail" : "attachmentList"; } else if (type === "text" && (await this.noteContext?.isReadOnly())) { diff --git a/src/public/app/widgets/type_widgets/llm_chat.ts b/src/public/app/widgets/type_widgets/llm_chat.ts new file mode 100644 index 000000000..7d4b41faf --- /dev/null +++ b/src/public/app/widgets/type_widgets/llm_chat.ts @@ -0,0 +1,51 @@ +import TypeWidget from "./type_widget.js"; +import LlmChatPanel from "../llm_chat_panel.js"; +import { type EventData } from "../../components/app_context.js"; +import type FNote from "../../entities/fnote.js"; + +export default class LlmChatTypeWidget extends TypeWidget { + private llmChatPanel: LlmChatPanel; + private isInitialized: boolean = false; + + constructor() { + super(); + this.llmChatPanel = new LlmChatPanel(); + } + + static getType() { + return "llmChat"; + } + + doRender() { + this.$widget = $('
'); + this.$widget.append(this.llmChatPanel.render()); + + return this.$widget; + } + + async doRefresh(note: FNote | null | undefined) { + // Initialize only once + if (!this.isInitialized) { + console.log("Initializing LLM Chat Panel"); + await this.llmChatPanel.refresh(); + this.isInitialized = true; + } + } + + async entitiesReloadedEvent(data: EventData<"entitiesReloaded">) { + // We don't need to refresh on entities reloaded for the chat + } + + async activeContextChangedEvent(data: EventData<"activeContextChanged">) { + // Only refresh when this becomes active and we're not initialized yet + if (this.isActive() && !this.isInitialized) { + await this.llmChatPanel.refresh(); + this.isInitialized = true; + } + } + + // Handle data saving - we don't need to save anything + getData() { + return {}; + } +} diff --git a/src/public/app/widgets/type_widgets/options/ai_settings.ts b/src/public/app/widgets/type_widgets/options/ai_settings.ts index 3f408a7e5..1fc4b0cc7 100644 --- a/src/public/app/widgets/type_widgets/options/ai_settings.ts +++ b/src/public/app/widgets/type_widgets/options/ai_settings.ts @@ -159,6 +159,17 @@ export default class AiSettingsWidget extends OptionsWidget {
${t("ai_llm.embedding_configuration")}
+
+ + +
${t("ai_llm.embedding_default_provider_description")}
+
+