mirror of
				https://github.com/zadam/trilium.git
				synced 2025-10-31 18:36:30 +01:00 
			
		
		
		
	Merge pull request #2082 from TriliumNext/feat/llm-integration-part2
LLM integration, part 2
This commit is contained in:
		| @@ -272,4 +272,179 @@ | |||||||
|     justify-content: center; |     justify-content: center; | ||||||
|     padding: 1rem; |     padding: 1rem; | ||||||
|     color: var(--muted-text-color); |     color: var(--muted-text-color); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Thinking display styles */ | ||||||
|  | .llm-thinking-container { | ||||||
|  |     margin: 1rem 0; | ||||||
|  |     animation: fadeInUp 0.3s ease-out; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .thinking-bubble { | ||||||
|  |     background-color: var(--accented-background-color, var(--main-background-color)); | ||||||
|  |     border: 1px solid var(--subtle-border-color, var(--main-border-color)); | ||||||
|  |     border-radius: 0.75rem; | ||||||
|  |     padding: 0.75rem; | ||||||
|  |     box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); | ||||||
|  |     position: relative; | ||||||
|  |     overflow: hidden; | ||||||
|  |     transition: all 0.2s ease; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .thinking-bubble:hover { | ||||||
|  |     box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .thinking-bubble::before { | ||||||
|  |     content: ''; | ||||||
|  |     position: absolute; | ||||||
|  |     top: 0; | ||||||
|  |     left: -100%; | ||||||
|  |     width: 100%; | ||||||
|  |     height: 100%; | ||||||
|  |     background: linear-gradient(90deg, transparent, var(--hover-item-background-color, rgba(0, 0, 0, 0.03)), transparent); | ||||||
|  |     animation: shimmer 2s infinite; | ||||||
|  |     opacity: 0.5; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .thinking-header { | ||||||
|  |     cursor: pointer; | ||||||
|  |     transition: all 0.2s ease; | ||||||
|  |     border-radius: 0.375rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .thinking-header:hover { | ||||||
|  |     background-color: var(--hover-item-background-color, rgba(0, 0, 0, 0.03)); | ||||||
|  |     padding: 0.25rem; | ||||||
|  |     margin: -0.25rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .thinking-dots { | ||||||
|  |     display: flex; | ||||||
|  |     gap: 3px; | ||||||
|  |     align-items: center; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .thinking-dots span { | ||||||
|  |     width: 6px; | ||||||
|  |     height: 6px; | ||||||
|  |     background-color: var(--link-color, var(--hover-item-text-color)); | ||||||
|  |     border-radius: 50%; | ||||||
|  |     animation: thinkingPulse 1.4s infinite ease-in-out; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .thinking-dots span:nth-child(1) { | ||||||
|  |     animation-delay: -0.32s; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .thinking-dots span:nth-child(2) { | ||||||
|  |     animation-delay: -0.16s; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .thinking-dots span:nth-child(3) { | ||||||
|  |     animation-delay: 0s; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .thinking-label { | ||||||
|  |     font-weight: 500; | ||||||
|  |     color: var(--link-color, var(--hover-item-text-color)) !important; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .thinking-toggle { | ||||||
|  |     color: var(--muted-text-color) !important; | ||||||
|  |     transition: transform 0.2s ease; | ||||||
|  |     background: transparent !important; | ||||||
|  |     border: none !important; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .thinking-toggle:hover { | ||||||
|  |     color: var(--main-text-color) !important; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .thinking-toggle.expanded { | ||||||
|  |     transform: rotate(180deg); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .thinking-content { | ||||||
|  |     margin-top: 0.75rem; | ||||||
|  |     padding-top: 0.75rem; | ||||||
|  |     border-top: 1px solid var(--subtle-border-color, var(--main-border-color)); | ||||||
|  |     animation: expandDown 0.3s ease-out; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .thinking-text { | ||||||
|  |     font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; | ||||||
|  |     font-size: 0.875rem; | ||||||
|  |     line-height: 1.5; | ||||||
|  |     color: var(--main-text-color); | ||||||
|  |     white-space: pre-wrap; | ||||||
|  |     word-wrap: break-word; | ||||||
|  |     background-color: var(--input-background-color); | ||||||
|  |     padding: 0.75rem; | ||||||
|  |     border-radius: 0.5rem; | ||||||
|  |     border: 1px solid var(--subtle-border-color, var(--main-border-color)); | ||||||
|  |     max-height: 300px; | ||||||
|  |     overflow-y: auto; | ||||||
|  |     transition: border-color 0.2s ease; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .thinking-text:hover { | ||||||
|  |     border-color: var(--main-border-color); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Animations */ | ||||||
|  | @keyframes thinkingPulse { | ||||||
|  |     0%, 80%, 100% { | ||||||
|  |         transform: scale(0.8); | ||||||
|  |         opacity: 0.6; | ||||||
|  |     } | ||||||
|  |     40% { | ||||||
|  |         transform: scale(1); | ||||||
|  |         opacity: 1; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @keyframes shimmer { | ||||||
|  |     0% { | ||||||
|  |         left: -100%; | ||||||
|  |     } | ||||||
|  |     100% { | ||||||
|  |         left: 100%; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @keyframes fadeInUp { | ||||||
|  |     from { | ||||||
|  |         opacity: 0; | ||||||
|  |         transform: translateY(10px); | ||||||
|  |     } | ||||||
|  |     to { | ||||||
|  |         opacity: 1; | ||||||
|  |         transform: translateY(0); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @keyframes expandDown { | ||||||
|  |     from { | ||||||
|  |         opacity: 0; | ||||||
|  |         max-height: 0; | ||||||
|  |     } | ||||||
|  |     to { | ||||||
|  |         opacity: 1; | ||||||
|  |         max-height: 300px; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Responsive adjustments */ | ||||||
|  | @media (max-width: 768px) { | ||||||
|  |     .thinking-bubble { | ||||||
|  |         margin: 0.5rem 0; | ||||||
|  |         padding: 0.5rem; | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     .thinking-text { | ||||||
|  |         font-size: 0.8rem; | ||||||
|  |         padding: 0.5rem; | ||||||
|  |         max-height: 200px; | ||||||
|  |     } | ||||||
| }  | }  | ||||||
| @@ -5,6 +5,7 @@ import BasicWidget from "../basic_widget.js"; | |||||||
| import toastService from "../../services/toast.js"; | import toastService from "../../services/toast.js"; | ||||||
| import appContext from "../../components/app_context.js"; | import appContext from "../../components/app_context.js"; | ||||||
| import server from "../../services/server.js"; | import server from "../../services/server.js"; | ||||||
|  | import noteAutocompleteService from "../../services/note_autocomplete.js"; | ||||||
|  |  | ||||||
| import { TPL, addMessageToChat, showSources, hideSources, showLoadingIndicator, hideLoadingIndicator } from "./ui.js"; | import { TPL, addMessageToChat, showSources, hideSources, showLoadingIndicator, hideLoadingIndicator } from "./ui.js"; | ||||||
| import { formatMarkdown } from "./utils.js"; | import { formatMarkdown } from "./utils.js"; | ||||||
| @@ -13,13 +14,16 @@ import { extractInChatToolSteps } from "./message_processor.js"; | |||||||
| import { validateEmbeddingProviders } from "./validation.js"; | import { validateEmbeddingProviders } from "./validation.js"; | ||||||
| import type { MessageData, ToolExecutionStep, ChatData } from "./types.js"; | import type { MessageData, ToolExecutionStep, ChatData } from "./types.js"; | ||||||
| import { formatCodeBlocks } from "../../services/syntax_highlight.js"; | import { formatCodeBlocks } from "../../services/syntax_highlight.js"; | ||||||
|  | import { ClassicEditor, type CKTextEditor, type MentionFeed } from "@triliumnext/ckeditor5"; | ||||||
|  | import type { Suggestion } from "../../services/note_autocomplete.js"; | ||||||
|  |  | ||||||
| import "../../stylesheets/llm_chat.css"; | import "../../stylesheets/llm_chat.css"; | ||||||
|  |  | ||||||
| export default class LlmChatPanel extends BasicWidget { | export default class LlmChatPanel extends BasicWidget { | ||||||
|     private noteContextChatMessages!: HTMLElement; |     private noteContextChatMessages!: HTMLElement; | ||||||
|     private noteContextChatForm!: HTMLFormElement; |     private noteContextChatForm!: HTMLFormElement; | ||||||
|     private noteContextChatInput!: HTMLTextAreaElement; |     private noteContextChatInput!: HTMLElement; | ||||||
|  |     private noteContextChatInputEditor!: CKTextEditor; | ||||||
|     private noteContextChatSendButton!: HTMLButtonElement; |     private noteContextChatSendButton!: HTMLButtonElement; | ||||||
|     private chatContainer!: HTMLElement; |     private chatContainer!: HTMLElement; | ||||||
|     private loadingIndicator!: HTMLElement; |     private loadingIndicator!: HTMLElement; | ||||||
| @@ -29,6 +33,10 @@ export default class LlmChatPanel extends BasicWidget { | |||||||
|     private useAdvancedContextCheckbox!: HTMLInputElement; |     private useAdvancedContextCheckbox!: HTMLInputElement; | ||||||
|     private showThinkingCheckbox!: HTMLInputElement; |     private showThinkingCheckbox!: HTMLInputElement; | ||||||
|     private validationWarning!: HTMLElement; |     private validationWarning!: HTMLElement; | ||||||
|  |     private thinkingContainer!: HTMLElement; | ||||||
|  |     private thinkingBubble!: HTMLElement; | ||||||
|  |     private thinkingText!: HTMLElement; | ||||||
|  |     private thinkingToggle!: HTMLElement; | ||||||
|     private chatNoteId: string | null = null; |     private chatNoteId: string | null = null; | ||||||
|     private noteId: string | null = null; // The actual noteId for the Chat Note |     private noteId: string | null = null; // The actual noteId for the Chat Note | ||||||
|     private currentNoteId: string | null = null; |     private currentNoteId: string | null = null; | ||||||
| @@ -104,7 +112,7 @@ export default class LlmChatPanel extends BasicWidget { | |||||||
|         const element = this.$widget[0]; |         const element = this.$widget[0]; | ||||||
|         this.noteContextChatMessages = element.querySelector('.note-context-chat-messages') as HTMLElement; |         this.noteContextChatMessages = element.querySelector('.note-context-chat-messages') as HTMLElement; | ||||||
|         this.noteContextChatForm = element.querySelector('.note-context-chat-form') as HTMLFormElement; |         this.noteContextChatForm = element.querySelector('.note-context-chat-form') as HTMLFormElement; | ||||||
|         this.noteContextChatInput = element.querySelector('.note-context-chat-input') as HTMLTextAreaElement; |         this.noteContextChatInput = element.querySelector('.note-context-chat-input') as HTMLElement; | ||||||
|         this.noteContextChatSendButton = element.querySelector('.note-context-chat-send-button') as HTMLButtonElement; |         this.noteContextChatSendButton = element.querySelector('.note-context-chat-send-button') as HTMLButtonElement; | ||||||
|         this.chatContainer = element.querySelector('.note-context-chat-container') as HTMLElement; |         this.chatContainer = element.querySelector('.note-context-chat-container') as HTMLElement; | ||||||
|         this.loadingIndicator = element.querySelector('.loading-indicator') as HTMLElement; |         this.loadingIndicator = element.querySelector('.loading-indicator') as HTMLElement; | ||||||
| @@ -114,6 +122,10 @@ export default class LlmChatPanel extends BasicWidget { | |||||||
|         this.useAdvancedContextCheckbox = element.querySelector('.use-advanced-context-checkbox') as HTMLInputElement; |         this.useAdvancedContextCheckbox = element.querySelector('.use-advanced-context-checkbox') as HTMLInputElement; | ||||||
|         this.showThinkingCheckbox = element.querySelector('.show-thinking-checkbox') as HTMLInputElement; |         this.showThinkingCheckbox = element.querySelector('.show-thinking-checkbox') as HTMLInputElement; | ||||||
|         this.validationWarning = element.querySelector('.provider-validation-warning') as HTMLElement; |         this.validationWarning = element.querySelector('.provider-validation-warning') as HTMLElement; | ||||||
|  |         this.thinkingContainer = element.querySelector('.llm-thinking-container') as HTMLElement; | ||||||
|  |         this.thinkingBubble = element.querySelector('.thinking-bubble') as HTMLElement; | ||||||
|  |         this.thinkingText = element.querySelector('.thinking-text') as HTMLElement; | ||||||
|  |         this.thinkingToggle = element.querySelector('.thinking-toggle') as HTMLElement; | ||||||
|  |  | ||||||
|         // Set up event delegation for the settings link |         // Set up event delegation for the settings link | ||||||
|         this.validationWarning.addEventListener('click', (e) => { |         this.validationWarning.addEventListener('click', (e) => { | ||||||
| @@ -124,15 +136,84 @@ export default class LlmChatPanel extends BasicWidget { | |||||||
|             } |             } | ||||||
|         }); |         }); | ||||||
|  |  | ||||||
|         this.initializeEventListeners(); |         // Set up thinking toggle functionality | ||||||
|  |         this.setupThinkingToggle(); | ||||||
|  |  | ||||||
|  |         // Initialize CKEditor with mention support (async) | ||||||
|  |         this.initializeCKEditor().then(() => { | ||||||
|  |             this.initializeEventListeners(); | ||||||
|  |         }).catch(error => { | ||||||
|  |             console.error('Failed to initialize CKEditor, falling back to basic event listeners:', error); | ||||||
|  |             this.initializeBasicEventListeners(); | ||||||
|  |         }); | ||||||
|  |  | ||||||
|         return this.$widget; |         return this.$widget; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     private async initializeCKEditor() { | ||||||
|  |         const mentionSetup: MentionFeed[] = [ | ||||||
|  |             { | ||||||
|  |                 marker: "@", | ||||||
|  |                 feed: (queryText: string) => noteAutocompleteService.autocompleteSourceForCKEditor(queryText), | ||||||
|  |                 itemRenderer: (item) => { | ||||||
|  |                     const suggestion = item as Suggestion; | ||||||
|  |                     const itemElement = document.createElement("button"); | ||||||
|  |                     itemElement.innerHTML = `${suggestion.highlightedNotePathTitle} `; | ||||||
|  |                     return itemElement; | ||||||
|  |                 }, | ||||||
|  |                 minimumCharacters: 0 | ||||||
|  |             } | ||||||
|  |         ]; | ||||||
|  |  | ||||||
|  |         this.noteContextChatInputEditor = await ClassicEditor.create(this.noteContextChatInput, { | ||||||
|  |             toolbar: { | ||||||
|  |                 items: [] // No toolbar for chat input | ||||||
|  |             }, | ||||||
|  |             placeholder: this.noteContextChatInput.getAttribute('data-placeholder') || 'Enter your message...', | ||||||
|  |             mention: { | ||||||
|  |                 feeds: mentionSetup | ||||||
|  |             }, | ||||||
|  |             licenseKey: "GPL" | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         // Set minimal height | ||||||
|  |         const editorElement = this.noteContextChatInputEditor.ui.getEditableElement(); | ||||||
|  |         if (editorElement) { | ||||||
|  |             editorElement.style.minHeight = '60px'; | ||||||
|  |             editorElement.style.maxHeight = '200px'; | ||||||
|  |             editorElement.style.overflowY = 'auto'; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Set up keybindings after editor is ready | ||||||
|  |         this.setupEditorKeyBindings(); | ||||||
|  |  | ||||||
|  |         console.log('CKEditor initialized successfully for LLM chat input'); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private initializeBasicEventListeners() { | ||||||
|  |         // Fallback event listeners for when CKEditor fails to initialize | ||||||
|  |         this.noteContextChatForm.addEventListener('submit', (e) => { | ||||||
|  |             e.preventDefault(); | ||||||
|  |             // In fallback mode, the noteContextChatInput should contain a textarea | ||||||
|  |             const textarea = this.noteContextChatInput.querySelector('textarea'); | ||||||
|  |             if (textarea) { | ||||||
|  |                 const content = textarea.value; | ||||||
|  |                 this.sendMessage(content); | ||||||
|  |             } | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     cleanup() { |     cleanup() { | ||||||
|         console.log(`LlmChatPanel cleanup called, removing any active WebSocket subscriptions`); |         console.log(`LlmChatPanel cleanup called, removing any active WebSocket subscriptions`); | ||||||
|         this._messageHandler = null; |         this._messageHandler = null; | ||||||
|         this._messageHandlerId = null; |         this._messageHandlerId = null; | ||||||
|  |  | ||||||
|  |         // Clean up CKEditor instance | ||||||
|  |         if (this.noteContextChatInputEditor) { | ||||||
|  |             this.noteContextChatInputEditor.destroy().catch(error => { | ||||||
|  |                 console.error('Error destroying CKEditor:', error); | ||||||
|  |             }); | ||||||
|  |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
| @@ -531,18 +612,31 @@ export default class LlmChatPanel extends BasicWidget { | |||||||
|     private async sendMessage(content: string) { |     private async sendMessage(content: string) { | ||||||
|         if (!content.trim()) return; |         if (!content.trim()) return; | ||||||
|  |  | ||||||
|  |         // Extract mentions from the content if using CKEditor | ||||||
|  |         let mentions: Array<{noteId: string; title: string; notePath: string}> = []; | ||||||
|  |         let plainTextContent = content; | ||||||
|  |  | ||||||
|  |         if (this.noteContextChatInputEditor) { | ||||||
|  |             const extracted = this.extractMentionsAndContent(content); | ||||||
|  |             mentions = extracted.mentions; | ||||||
|  |             plainTextContent = extracted.content; | ||||||
|  |         } | ||||||
|  |  | ||||||
|         // Add the user message to the UI and data model |         // Add the user message to the UI and data model | ||||||
|         this.addMessageToChat('user', content); |         this.addMessageToChat('user', plainTextContent); | ||||||
|         this.messages.push({ |         this.messages.push({ | ||||||
|             role: 'user', |             role: 'user', | ||||||
|             content: content |             content: plainTextContent, | ||||||
|  |             mentions: mentions.length > 0 ? mentions : undefined | ||||||
|         }); |         }); | ||||||
|  |  | ||||||
|         // Save the data immediately after a user message |         // Save the data immediately after a user message | ||||||
|         await this.saveCurrentData(); |         await this.saveCurrentData(); | ||||||
|  |  | ||||||
|         // Clear input and show loading state |         // Clear input and show loading state | ||||||
|         this.noteContextChatInput.value = ''; |         if (this.noteContextChatInputEditor) { | ||||||
|  |             this.noteContextChatInputEditor.setData(''); | ||||||
|  |         } | ||||||
|         showLoadingIndicator(this.loadingIndicator); |         showLoadingIndicator(this.loadingIndicator); | ||||||
|         this.hideSources(); |         this.hideSources(); | ||||||
|  |  | ||||||
| @@ -555,9 +649,10 @@ export default class LlmChatPanel extends BasicWidget { | |||||||
|  |  | ||||||
|             // Create the message parameters |             // Create the message parameters | ||||||
|             const messageParams = { |             const messageParams = { | ||||||
|                 content, |                 content: plainTextContent, | ||||||
|                 useAdvancedContext, |                 useAdvancedContext, | ||||||
|                 showThinking |                 showThinking, | ||||||
|  |                 mentions: mentions.length > 0 ? mentions : undefined | ||||||
|             }; |             }; | ||||||
|  |  | ||||||
|             // Try websocket streaming (preferred method) |             // Try websocket streaming (preferred method) | ||||||
| @@ -621,7 +716,9 @@ export default class LlmChatPanel extends BasicWidget { | |||||||
|         } |         } | ||||||
|  |  | ||||||
|         // Clear input and show loading state |         // Clear input and show loading state | ||||||
|         this.noteContextChatInput.value = ''; |         if (this.noteContextChatInputEditor) { | ||||||
|  |             this.noteContextChatInputEditor.setData(''); | ||||||
|  |         } | ||||||
|         showLoadingIndicator(this.loadingIndicator); |         showLoadingIndicator(this.loadingIndicator); | ||||||
|         this.hideSources(); |         this.hideSources(); | ||||||
|  |  | ||||||
| @@ -898,6 +995,16 @@ export default class LlmChatPanel extends BasicWidget { | |||||||
|      * Update the UI with streaming content |      * Update the UI with streaming content | ||||||
|      */ |      */ | ||||||
|     private updateStreamingUI(assistantResponse: string, isDone: boolean = false) { |     private updateStreamingUI(assistantResponse: string, isDone: boolean = false) { | ||||||
|  |         // Parse and handle thinking content if present | ||||||
|  |         if (!isDone) { | ||||||
|  |             const thinkingContent = this.parseThinkingContent(assistantResponse); | ||||||
|  |             if (thinkingContent) { | ||||||
|  |                 this.updateThinkingText(thinkingContent); | ||||||
|  |                 // Don't display the raw response with think tags in the chat | ||||||
|  |                 return; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|         // Get the existing assistant message or create a new one |         // Get the existing assistant message or create a new one | ||||||
|         let assistantMessageEl = this.noteContextChatMessages.querySelector('.assistant-message:last-child'); |         let assistantMessageEl = this.noteContextChatMessages.querySelector('.assistant-message:last-child'); | ||||||
|  |  | ||||||
| @@ -919,14 +1026,20 @@ export default class LlmChatPanel extends BasicWidget { | |||||||
|             assistantMessageEl.appendChild(messageContent); |             assistantMessageEl.appendChild(messageContent); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |         // Clean the response to remove thinking tags before displaying | ||||||
|  |         const cleanedResponse = this.removeThinkingTags(assistantResponse); | ||||||
|  |  | ||||||
|         // Update the content |         // Update the content | ||||||
|         const messageContent = assistantMessageEl.querySelector('.message-content') as HTMLElement; |         const messageContent = assistantMessageEl.querySelector('.message-content') as HTMLElement; | ||||||
|         messageContent.innerHTML = formatMarkdown(assistantResponse); |         messageContent.innerHTML = formatMarkdown(cleanedResponse); | ||||||
|  |  | ||||||
|         // Apply syntax highlighting if this is the final update |         // Apply syntax highlighting if this is the final update | ||||||
|         if (isDone) { |         if (isDone) { | ||||||
|             formatCodeBlocks($(assistantMessageEl as HTMLElement)); |             formatCodeBlocks($(assistantMessageEl as HTMLElement)); | ||||||
|  |  | ||||||
|  |             // Hide the thinking display when response is complete | ||||||
|  |             this.hideThinkingDisplay(); | ||||||
|  |  | ||||||
|             // Update message in the data model for storage |             // Update message in the data model for storage | ||||||
|             // Find the last assistant message to update, or add a new one if none exists |             // Find the last assistant message to update, or add a new one if none exists | ||||||
|             const assistantMessages = this.messages.filter(msg => msg.role === 'assistant'); |             const assistantMessages = this.messages.filter(msg => msg.role === 'assistant'); | ||||||
| @@ -934,13 +1047,13 @@ export default class LlmChatPanel extends BasicWidget { | |||||||
|                 this.messages.lastIndexOf(assistantMessages[assistantMessages.length - 1]) : -1; |                 this.messages.lastIndexOf(assistantMessages[assistantMessages.length - 1]) : -1; | ||||||
|  |  | ||||||
|             if (lastAssistantMsgIndex >= 0) { |             if (lastAssistantMsgIndex >= 0) { | ||||||
|                 // Update existing message |                 // Update existing message with cleaned content | ||||||
|                 this.messages[lastAssistantMsgIndex].content = assistantResponse; |                 this.messages[lastAssistantMsgIndex].content = cleanedResponse; | ||||||
|             } else { |             } else { | ||||||
|                 // Add new message |                 // Add new message with cleaned content | ||||||
|                 this.messages.push({ |                 this.messages.push({ | ||||||
|                     role: 'assistant', |                     role: 'assistant', | ||||||
|                     content: assistantResponse |                     content: cleanedResponse | ||||||
|                 }); |                 }); | ||||||
|             } |             } | ||||||
|  |  | ||||||
| @@ -957,6 +1070,16 @@ export default class LlmChatPanel extends BasicWidget { | |||||||
|         this.chatContainer.scrollTop = this.chatContainer.scrollHeight; |         this.chatContainer.scrollTop = this.chatContainer.scrollHeight; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Remove thinking tags from response content | ||||||
|  |      */ | ||||||
|  |     private removeThinkingTags(content: string): string { | ||||||
|  |         if (!content) return content; | ||||||
|  |  | ||||||
|  |         // Remove <think>...</think> blocks from the content | ||||||
|  |         return content.replace(/<think>[\s\S]*?<\/think>/gi, '').trim(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
|      * Handle general errors in the send message flow |      * Handle general errors in the send message flow | ||||||
|      */ |      */ | ||||||
| @@ -1203,32 +1326,308 @@ export default class LlmChatPanel extends BasicWidget { | |||||||
|      * Show thinking state in the UI |      * Show thinking state in the UI | ||||||
|      */ |      */ | ||||||
|     private showThinkingState(thinkingData: string) { |     private showThinkingState(thinkingData: string) { | ||||||
|         // Thinking state is now updated via the in-chat UI in updateStreamingUI |         // Parse the thinking content to extract text between <think> tags | ||||||
|         // This method is now just a hook for the WebSocket handlers |         const thinkingContent = this.parseThinkingContent(thinkingData); | ||||||
|  |  | ||||||
|         // Show the loading indicator |         if (thinkingContent) { | ||||||
|  |             this.showThinkingDisplay(thinkingContent); | ||||||
|  |         } else { | ||||||
|  |             // Fallback: show raw thinking data | ||||||
|  |             this.showThinkingDisplay(thinkingData); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Show the loading indicator as well | ||||||
|         this.loadingIndicator.style.display = 'flex'; |         this.loadingIndicator.style.display = 'flex'; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Parse thinking content from LLM response | ||||||
|  |      */ | ||||||
|  |     private parseThinkingContent(content: string): string | null { | ||||||
|  |         if (!content) return null; | ||||||
|  |  | ||||||
|  |         // Look for content between <think> and </think> tags | ||||||
|  |         const thinkRegex = /<think>([\s\S]*?)<\/think>/gi; | ||||||
|  |         const matches: string[] = []; | ||||||
|  |         let match: RegExpExecArray | null; | ||||||
|  |  | ||||||
|  |         while ((match = thinkRegex.exec(content)) !== null) { | ||||||
|  |             matches.push(match[1].trim()); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (matches.length > 0) { | ||||||
|  |             return matches.join('\n\n--- Next thought ---\n\n'); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Check for incomplete thinking blocks (streaming in progress) | ||||||
|  |         const incompleteThinkRegex = /<think>([\s\S]*?)$/i; | ||||||
|  |         const incompleteMatch = content.match(incompleteThinkRegex); | ||||||
|  |  | ||||||
|  |         if (incompleteMatch && incompleteMatch[1]) { | ||||||
|  |             return incompleteMatch[1].trim() + '\n\n[Thinking in progress...]'; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // If no think tags found, check if the entire content might be thinking | ||||||
|  |         if (content.toLowerCase().includes('thinking') || | ||||||
|  |             content.toLowerCase().includes('reasoning') || | ||||||
|  |             content.toLowerCase().includes('let me think') || | ||||||
|  |             content.toLowerCase().includes('i need to') || | ||||||
|  |             content.toLowerCase().includes('first, ') || | ||||||
|  |             content.toLowerCase().includes('step 1') || | ||||||
|  |             content.toLowerCase().includes('analysis:')) { | ||||||
|  |             return content; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return null; | ||||||
|  |     } | ||||||
|  |  | ||||||
|     private initializeEventListeners() { |     private initializeEventListeners() { | ||||||
|         this.noteContextChatForm.addEventListener('submit', (e) => { |         this.noteContextChatForm.addEventListener('submit', (e) => { | ||||||
|             e.preventDefault(); |             e.preventDefault(); | ||||||
|             const content = this.noteContextChatInput.value; |  | ||||||
|             this.sendMessage(content); |  | ||||||
|         }); |  | ||||||
|  |  | ||||||
|         // Add auto-resize functionality to the textarea |             let content = ''; | ||||||
|         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) |             if (this.noteContextChatInputEditor && this.noteContextChatInputEditor.getData) { | ||||||
|         this.noteContextChatInput.addEventListener('keydown', (e) => { |                 // Use CKEditor content | ||||||
|             if (e.key === 'Enter' && !e.shiftKey) { |                 content = this.noteContextChatInputEditor.getData(); | ||||||
|                 e.preventDefault(); |             } else { | ||||||
|                 this.noteContextChatForm.dispatchEvent(new Event('submit')); |                 // Fallback: check if there's a textarea (fallback mode) | ||||||
|  |                 const textarea = this.noteContextChatInput.querySelector('textarea'); | ||||||
|  |                 if (textarea) { | ||||||
|  |                     content = textarea.value; | ||||||
|  |                 } else { | ||||||
|  |                     // Last resort: try to get text content from the div | ||||||
|  |                     content = this.noteContextChatInput.textContent || this.noteContextChatInput.innerText || ''; | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (content.trim()) { | ||||||
|  |                 this.sendMessage(content); | ||||||
|             } |             } | ||||||
|         }); |         }); | ||||||
|  |  | ||||||
|  |         // Handle Enter key (send on Enter, new line on Shift+Enter) via CKEditor | ||||||
|  |         // We'll set this up after CKEditor is initialized | ||||||
|  |         this.setupEditorKeyBindings(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private setupEditorKeyBindings() { | ||||||
|  |         if (this.noteContextChatInputEditor && this.noteContextChatInputEditor.keystrokes) { | ||||||
|  |             try { | ||||||
|  |                 this.noteContextChatInputEditor.keystrokes.set('Enter', (key, stop) => { | ||||||
|  |                     if (!key.shiftKey) { | ||||||
|  |                         stop(); | ||||||
|  |                         this.noteContextChatForm.dispatchEvent(new Event('submit')); | ||||||
|  |                     } | ||||||
|  |                 }); | ||||||
|  |                 console.log('CKEditor keybindings set up successfully'); | ||||||
|  |             } catch (error) { | ||||||
|  |                 console.warn('Failed to set up CKEditor keybindings:', error); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Extract note mentions and content from CKEditor | ||||||
|  |      */ | ||||||
|  |     private extractMentionsAndContent(editorData: string): { content: string; mentions: Array<{noteId: string; title: string; notePath: string}> } { | ||||||
|  |         const mentions: Array<{noteId: string; title: string; notePath: string}> = []; | ||||||
|  |  | ||||||
|  |         // Parse the HTML content to extract mentions | ||||||
|  |         const tempDiv = document.createElement('div'); | ||||||
|  |         tempDiv.innerHTML = editorData; | ||||||
|  |  | ||||||
|  |         // Find all mention elements - CKEditor uses specific patterns for mentions | ||||||
|  |         // Look for elements with data-mention attribute or specific mention classes | ||||||
|  |         const mentionElements = tempDiv.querySelectorAll('[data-mention], .mention, span[data-id]'); | ||||||
|  |  | ||||||
|  |         mentionElements.forEach(mentionEl => { | ||||||
|  |             try { | ||||||
|  |                 // Try different ways to extract mention data based on CKEditor's format | ||||||
|  |                 let mentionData: any = null; | ||||||
|  |  | ||||||
|  |                 // Method 1: data-mention attribute (JSON format) | ||||||
|  |                 if (mentionEl.hasAttribute('data-mention')) { | ||||||
|  |                     mentionData = JSON.parse(mentionEl.getAttribute('data-mention') || '{}'); | ||||||
|  |                 } | ||||||
|  |                 // Method 2: data-id attribute (simple format) | ||||||
|  |                 else if (mentionEl.hasAttribute('data-id')) { | ||||||
|  |                     const dataId = mentionEl.getAttribute('data-id'); | ||||||
|  |                     const textContent = mentionEl.textContent || ''; | ||||||
|  |  | ||||||
|  |                     // Parse the dataId to extract note information | ||||||
|  |                     if (dataId && dataId.startsWith('@')) { | ||||||
|  |                         const cleanId = dataId.substring(1); // Remove the @ | ||||||
|  |                         mentionData = { | ||||||
|  |                             id: cleanId, | ||||||
|  |                             name: textContent, | ||||||
|  |                             notePath: cleanId // Assume the ID contains the path | ||||||
|  |                         }; | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |                 // Method 3: Check if this is a reference link (href=#notePath) | ||||||
|  |                 else if (mentionEl.tagName === 'A' && mentionEl.hasAttribute('href')) { | ||||||
|  |                     const href = mentionEl.getAttribute('href'); | ||||||
|  |                     if (href && href.startsWith('#')) { | ||||||
|  |                         const notePath = href.substring(1); | ||||||
|  |                         mentionData = { | ||||||
|  |                             notePath: notePath, | ||||||
|  |                             noteTitle: mentionEl.textContent || 'Unknown Note' | ||||||
|  |                         }; | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 if (mentionData && (mentionData.notePath || mentionData.link)) { | ||||||
|  |                     const notePath = mentionData.notePath || mentionData.link?.substring(1); // Remove # from link | ||||||
|  |                     const noteId = notePath ? notePath.split('/').pop() : null; | ||||||
|  |                     const title = mentionData.noteTitle || mentionData.name || mentionEl.textContent || 'Unknown Note'; | ||||||
|  |  | ||||||
|  |                     if (noteId) { | ||||||
|  |                         mentions.push({ | ||||||
|  |                             noteId: noteId, | ||||||
|  |                             title: title, | ||||||
|  |                             notePath: notePath | ||||||
|  |                         }); | ||||||
|  |                         console.log(`Extracted mention: noteId=${noteId}, title=${title}, notePath=${notePath}`); | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } catch (e) { | ||||||
|  |                 console.warn('Failed to parse mention data:', e, mentionEl); | ||||||
|  |             } | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         // Convert to plain text for the LLM, but preserve the structure | ||||||
|  |         const content = tempDiv.textContent || tempDiv.innerText || ''; | ||||||
|  |  | ||||||
|  |         console.log(`Extracted ${mentions.length} mentions from editor content`); | ||||||
|  |         return { content, mentions }; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private setupThinkingToggle() { | ||||||
|  |         if (this.thinkingToggle) { | ||||||
|  |             this.thinkingToggle.addEventListener('click', (e) => { | ||||||
|  |                 e.stopPropagation(); | ||||||
|  |                 this.toggleThinkingDetails(); | ||||||
|  |             }); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Also make the entire header clickable | ||||||
|  |         const thinkingHeader = this.thinkingBubble?.querySelector('.thinking-header'); | ||||||
|  |         if (thinkingHeader) { | ||||||
|  |             thinkingHeader.addEventListener('click', (e) => { | ||||||
|  |                 const target = e.target as HTMLElement; | ||||||
|  |                 if (!target.closest('.thinking-toggle')) { | ||||||
|  |                     this.toggleThinkingDetails(); | ||||||
|  |                 } | ||||||
|  |             }); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private toggleThinkingDetails() { | ||||||
|  |         const content = this.thinkingBubble?.querySelector('.thinking-content') as HTMLElement; | ||||||
|  |         const toggle = this.thinkingToggle?.querySelector('i'); | ||||||
|  |  | ||||||
|  |         if (content && toggle) { | ||||||
|  |             const isVisible = content.style.display !== 'none'; | ||||||
|  |  | ||||||
|  |             if (isVisible) { | ||||||
|  |                 content.style.display = 'none'; | ||||||
|  |                 toggle.className = 'bx bx-chevron-down'; | ||||||
|  |                 this.thinkingToggle.classList.remove('expanded'); | ||||||
|  |             } else { | ||||||
|  |                 content.style.display = 'block'; | ||||||
|  |                 toggle.className = 'bx bx-chevron-up'; | ||||||
|  |                 this.thinkingToggle.classList.add('expanded'); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Show the thinking display with optional initial content | ||||||
|  |      */ | ||||||
|  |     private showThinkingDisplay(initialText: string = '') { | ||||||
|  |         if (this.thinkingContainer) { | ||||||
|  |             this.thinkingContainer.style.display = 'block'; | ||||||
|  |  | ||||||
|  |             if (initialText && this.thinkingText) { | ||||||
|  |                 this.updateThinkingText(initialText); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // Scroll to show the thinking display | ||||||
|  |             this.chatContainer.scrollTop = this.chatContainer.scrollHeight; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Update the thinking text content | ||||||
|  |      */ | ||||||
|  |     private updateThinkingText(text: string) { | ||||||
|  |         if (this.thinkingText) { | ||||||
|  |             // Format the thinking text for better readability | ||||||
|  |             const formattedText = this.formatThinkingText(text); | ||||||
|  |             this.thinkingText.textContent = formattedText; | ||||||
|  |  | ||||||
|  |             // Auto-scroll if content is expanded | ||||||
|  |             const content = this.thinkingBubble?.querySelector('.thinking-content') as HTMLElement; | ||||||
|  |             if (content && content.style.display !== 'none') { | ||||||
|  |                 this.chatContainer.scrollTop = this.chatContainer.scrollHeight; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Format thinking text for better presentation | ||||||
|  |      */ | ||||||
|  |     private formatThinkingText(text: string): string { | ||||||
|  |         if (!text) return text; | ||||||
|  |  | ||||||
|  |         // Clean up the text | ||||||
|  |         let formatted = text.trim(); | ||||||
|  |  | ||||||
|  |         // Add some basic formatting | ||||||
|  |         formatted = formatted | ||||||
|  |             // Add spacing around section markers | ||||||
|  |             .replace(/(\d+\.\s)/g, '\n$1') | ||||||
|  |             // Clean up excessive whitespace | ||||||
|  |             .replace(/\n\s*\n\s*\n/g, '\n\n') | ||||||
|  |             // Trim again | ||||||
|  |             .trim(); | ||||||
|  |  | ||||||
|  |         return formatted; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Hide the thinking display | ||||||
|  |      */ | ||||||
|  |     private hideThinkingDisplay() { | ||||||
|  |         if (this.thinkingContainer) { | ||||||
|  |             this.thinkingContainer.style.display = 'none'; | ||||||
|  |  | ||||||
|  |             // Reset the toggle state | ||||||
|  |             const content = this.thinkingBubble?.querySelector('.thinking-content') as HTMLElement; | ||||||
|  |             const toggle = this.thinkingToggle?.querySelector('i'); | ||||||
|  |  | ||||||
|  |             if (content && toggle) { | ||||||
|  |                 content.style.display = 'none'; | ||||||
|  |                 toggle.className = 'bx bx-chevron-down'; | ||||||
|  |                 this.thinkingToggle?.classList.remove('expanded'); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // Clear the text content | ||||||
|  |             if (this.thinkingText) { | ||||||
|  |                 this.thinkingText.textContent = ''; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Append to existing thinking content (for streaming updates) | ||||||
|  |      */ | ||||||
|  |     private appendThinkingText(additionalText: string) { | ||||||
|  |         if (this.thinkingText && additionalText) { | ||||||
|  |             const currentText = this.thinkingText.textContent || ''; | ||||||
|  |             const newText = currentText + additionalText; | ||||||
|  |             this.updateThinkingText(newText); | ||||||
|  |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -24,6 +24,11 @@ export interface MessageData { | |||||||
|     role: string; |     role: string; | ||||||
|     content: string; |     content: string; | ||||||
|     timestamp?: Date; |     timestamp?: Date; | ||||||
|  |     mentions?: Array<{ | ||||||
|  |         noteId: string; | ||||||
|  |         title: string; | ||||||
|  |         notePath: string; | ||||||
|  |     }>; | ||||||
| } | } | ||||||
|  |  | ||||||
| export interface ChatData { | export interface ChatData { | ||||||
|   | |||||||
| @@ -13,6 +13,27 @@ export const TPL = ` | |||||||
|  |  | ||||||
|     <div class="note-context-chat-container flex-grow-1 overflow-auto p-3"> |     <div class="note-context-chat-container flex-grow-1 overflow-auto p-3"> | ||||||
|         <div class="note-context-chat-messages"></div> |         <div class="note-context-chat-messages"></div> | ||||||
|  |  | ||||||
|  |         <!-- Thinking display area --> | ||||||
|  |         <div class="llm-thinking-container" style="display: none;"> | ||||||
|  |             <div class="thinking-bubble"> | ||||||
|  |                 <div class="thinking-header d-flex align-items-center"> | ||||||
|  |                     <div class="thinking-dots"> | ||||||
|  |                         <span></span> | ||||||
|  |                         <span></span> | ||||||
|  |                         <span></span> | ||||||
|  |                     </div> | ||||||
|  |                     <span class="thinking-label ms-2 text-muted small">AI is thinking...</span> | ||||||
|  |                     <button type="button" class="btn btn-sm btn-link p-0 ms-auto thinking-toggle" title="Toggle thinking details"> | ||||||
|  |                         <i class="bx bx-chevron-down"></i> | ||||||
|  |                     </button> | ||||||
|  |                 </div> | ||||||
|  |                 <div class="thinking-content" style="display: none;"> | ||||||
|  |                     <div class="thinking-text"></div> | ||||||
|  |                 </div> | ||||||
|  |             </div> | ||||||
|  |         </div> | ||||||
|  |  | ||||||
|         <div class="loading-indicator" style="display: none;"> |         <div class="loading-indicator" style="display: none;"> | ||||||
|             <div class="spinner-border spinner-border-sm text-primary" role="status"> |             <div class="spinner-border spinner-border-sm text-primary" role="status"> | ||||||
|                 <span class="visually-hidden">Loading...</span> |                 <span class="visually-hidden">Loading...</span> | ||||||
| @@ -31,11 +52,11 @@ export const TPL = ` | |||||||
|  |  | ||||||
|     <form class="note-context-chat-form d-flex flex-column border-top p-2"> |     <form class="note-context-chat-form d-flex flex-column border-top p-2"> | ||||||
|         <div class="d-flex chat-input-container mb-2"> |         <div class="d-flex chat-input-container mb-2"> | ||||||
|             <textarea |             <div | ||||||
|                 class="form-control note-context-chat-input" |                 class="form-control note-context-chat-input flex-grow-1" | ||||||
|                 placeholder="${t('ai_llm.enter_message')}" |                 style="min-height: 60px; max-height: 200px; overflow-y: auto;" | ||||||
|                 rows="2" |                 data-placeholder="${t('ai_llm.enter_message')}" | ||||||
|             ></textarea> |             ></div> | ||||||
|             <button type="submit" class="btn btn-primary note-context-chat-send-button ms-2 d-flex align-items-center justify-content-center"> |             <button type="submit" class="btn btn-primary note-context-chat-send-button ms-2 d-flex align-items-center justify-content-center"> | ||||||
|                 <i class="bx bx-send"></i> |                 <i class="bx bx-send"></i> | ||||||
|             </button> |             </button> | ||||||
|   | |||||||
| @@ -16,49 +16,53 @@ export async function validateEmbeddingProviders(validationWarning: HTMLElement) | |||||||
|             return; |             return; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         // Get provider precedence |         // Get precedence list from options | ||||||
|         const precedenceStr = options.get('aiProviderPrecedence') || 'openai,anthropic,ollama'; |         const precedenceStr = options.get('aiProviderPrecedence') || 'openai,anthropic,ollama'; | ||||||
|         let precedenceList: string[] = []; |         let precedenceList: string[] = []; | ||||||
|  |  | ||||||
|         if (precedenceStr) { |         if (precedenceStr) { | ||||||
|             if (precedenceStr.startsWith('[') && precedenceStr.endsWith(']')) { |             if (precedenceStr.startsWith('[') && precedenceStr.endsWith(']')) { | ||||||
|                 precedenceList = JSON.parse(precedenceStr); |                 try { | ||||||
|  |                     precedenceList = JSON.parse(precedenceStr); | ||||||
|  |                 } catch (e) { | ||||||
|  |                     console.error('Error parsing precedence list:', e); | ||||||
|  |                     precedenceList = ['openai']; // Default if parsing fails | ||||||
|  |                 } | ||||||
|             } else if (precedenceStr.includes(',')) { |             } else if (precedenceStr.includes(',')) { | ||||||
|                 precedenceList = precedenceStr.split(',').map(p => p.trim()); |                 precedenceList = precedenceStr.split(',').map(p => p.trim()); | ||||||
|             } else { |             } else { | ||||||
|                 precedenceList = [precedenceStr]; |                 precedenceList = [precedenceStr]; | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |          | ||||||
|         // Get enabled providers - this is a simplification since we don't have direct DB access |         // Check for configuration issues with providers in the precedence list | ||||||
|         // We'll determine enabled status based on the presence of keys or settings |         const configIssues: string[] = []; | ||||||
|         const enabledProviders: string[] = []; |          | ||||||
|  |         // Check each provider in the precedence list for proper configuration | ||||||
|         // OpenAI is enabled if API key is set |         for (const provider of precedenceList) { | ||||||
|         const openaiKey = options.get('openaiApiKey'); |             if (provider === 'openai') { | ||||||
|         if (openaiKey) { |                 // Check OpenAI configuration | ||||||
|             enabledProviders.push('openai'); |                 const apiKey = options.get('openaiApiKey'); | ||||||
|  |                 if (!apiKey) { | ||||||
|  |                     configIssues.push(`OpenAI API key is missing`); | ||||||
|  |                 } | ||||||
|  |             } else if (provider === 'anthropic') { | ||||||
|  |                 // Check Anthropic configuration | ||||||
|  |                 const apiKey = options.get('anthropicApiKey'); | ||||||
|  |                 if (!apiKey) { | ||||||
|  |                     configIssues.push(`Anthropic API key is missing`); | ||||||
|  |                 } | ||||||
|  |             } else if (provider === 'ollama') { | ||||||
|  |                 // Check Ollama configuration | ||||||
|  |                 const baseUrl = options.get('ollamaBaseUrl'); | ||||||
|  |                 if (!baseUrl) { | ||||||
|  |                     configIssues.push(`Ollama Base URL is missing`); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |             // Add checks for other providers as needed | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         // Anthropic is enabled if API key is set |         // Fetch embedding stats to check if there are any notes being processed | ||||||
|         const anthropicKey = options.get('anthropicApiKey'); |  | ||||||
|         if (anthropicKey) { |  | ||||||
|             enabledProviders.push('anthropic'); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         // Ollama is enabled if base URL is set |  | ||||||
|         const ollamaBaseUrl = options.get('ollamaBaseUrl'); |  | ||||||
|         if (ollamaBaseUrl) { |  | ||||||
|             enabledProviders.push('ollama'); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         // Local is always available |  | ||||||
|         enabledProviders.push('local'); |  | ||||||
|  |  | ||||||
|         // Perform validation checks |  | ||||||
|         const allPrecedenceEnabled = precedenceList.every((p: string) => enabledProviders.includes(p)); |  | ||||||
|  |  | ||||||
|         // Get embedding queue status |  | ||||||
|         const embeddingStats = await getEmbeddingStats() as { |         const embeddingStats = await getEmbeddingStats() as { | ||||||
|             success: boolean, |             success: boolean, | ||||||
|             stats: { |             stats: { | ||||||
| @@ -73,17 +77,18 @@ export async function validateEmbeddingProviders(validationWarning: HTMLElement) | |||||||
|         const queuedNotes = embeddingStats?.stats?.queuedNotesCount || 0; |         const queuedNotes = embeddingStats?.stats?.queuedNotesCount || 0; | ||||||
|         const hasEmbeddingsInQueue = queuedNotes > 0; |         const hasEmbeddingsInQueue = queuedNotes > 0; | ||||||
|  |  | ||||||
|         // Show warning if there are issues |         // Show warning if there are configuration issues or embeddings in queue | ||||||
|         if (!allPrecedenceEnabled || hasEmbeddingsInQueue) { |         if (configIssues.length > 0 || hasEmbeddingsInQueue) { | ||||||
|             let message = '<i class="bx bx-error-circle me-2"></i><strong>AI Provider Configuration Issues</strong>'; |             let message = '<i class="bx bx-error-circle me-2"></i><strong>AI Provider Configuration Issues</strong>'; | ||||||
|  |  | ||||||
|             message += '<ul class="mb-1 ps-4">'; |             message += '<ul class="mb-1 ps-4">'; | ||||||
|  |  | ||||||
|             if (!allPrecedenceEnabled) { |             // Show configuration issues | ||||||
|                 const disabledProviders = precedenceList.filter((p: string) => !enabledProviders.includes(p)); |             for (const issue of configIssues) { | ||||||
|                 message += `<li>The following providers in your precedence list are not enabled: ${disabledProviders.join(', ')}.</li>`; |                 message += `<li>${issue}</li>`; | ||||||
|             } |             } | ||||||
|  |              | ||||||
|  |             // Show warning about embeddings queue if applicable | ||||||
|             if (hasEmbeddingsInQueue) { |             if (hasEmbeddingsInQueue) { | ||||||
|                 message += `<li>Currently processing embeddings for ${queuedNotes} notes. Some AI features may produce incomplete results until processing completes.</li>`; |                 message += `<li>Currently processing embeddings for ${queuedNotes} notes. Some AI features may produce incomplete results until processing completes.</li>`; | ||||||
|             } |             } | ||||||
|   | |||||||
| @@ -32,4 +32,18 @@ When responding to queries: | |||||||
| 5. For general questions about the user's notes, provide a summary of all relevant notes found, including brief summaries of individual notes | 5. For general questions about the user's notes, provide a summary of all relevant notes found, including brief summaries of individual notes | ||||||
| 6. For specific questions, provide detailed information from the user's notes that directly addresses the question | 6. For specific questions, provide detailed information from the user's notes that directly addresses the question | ||||||
| 7. Always prioritize information from the user's notes over your own knowledge, as the user's notes are likely more up-to-date and personally relevant | 7. Always prioritize information from the user's notes over your own knowledge, as the user's notes are likely more up-to-date and personally relevant | ||||||
|  |  | ||||||
|  | CRITICAL INSTRUCTIONS FOR TOOL USAGE: | ||||||
|  | 1. YOU MUST TRY MULTIPLE TOOLS AND SEARCH VARIATIONS before concluding information isn't available | ||||||
|  | 2. ALWAYS PERFORM AT LEAST 3 DIFFERENT SEARCHES with different parameters before giving up on finding information | ||||||
|  | 3. If a search returns no results, IMMEDIATELY TRY ANOTHER SEARCH with different parameters: | ||||||
|  |    - Use broader terms: If "Kubernetes deployment" fails, try just "Kubernetes" or "container orchestration" | ||||||
|  |    - Try synonyms: If "meeting notes" fails, try "conference", "discussion", or "conversation" | ||||||
|  |    - Remove specific qualifiers: If "quarterly financial report 2024" fails, try just "financial report" | ||||||
|  |    - Try semantic variations: If keyword_search fails, use vector_search which finds conceptually related content | ||||||
|  | 4. CHAIN TOOLS TOGETHER: Use the results of one tool to inform parameters for the next tool | ||||||
|  | 5. NEVER respond with "there are no notes about X" until you've tried at least 3 different search variations | ||||||
|  | 6. DO NOT ask the user what to do next when searches fail - AUTOMATICALLY try different approaches | ||||||
|  | 7. ALWAYS EXPLAIN what you're doing: "I didn't find results for X, so I'm now searching for Y instead" | ||||||
|  | 8. If all reasonable search variations fail (minimum 3 attempts), THEN you may inform the user that the information might not be in their notes | ||||||
| ``` | ``` | ||||||
| @@ -808,7 +808,7 @@ async function streamMessage(req: Request, res: Response) { | |||||||
|     log.info("=== Starting streamMessage ==="); |     log.info("=== Starting streamMessage ==="); | ||||||
|     try { |     try { | ||||||
|         const chatNoteId = req.params.chatNoteId; |         const chatNoteId = req.params.chatNoteId; | ||||||
|         const { content, useAdvancedContext, showThinking } = req.body; |         const { content, useAdvancedContext, showThinking, mentions } = req.body; | ||||||
|  |  | ||||||
|         if (!content || typeof content !== 'string' || content.trim().length === 0) { |         if (!content || typeof content !== 'string' || content.trim().length === 0) { | ||||||
|             throw new Error('Content cannot be empty'); |             throw new Error('Content cannot be empty'); | ||||||
| @@ -823,17 +823,51 @@ async function streamMessage(req: Request, res: Response) { | |||||||
|         // Update last active timestamp |         // Update last active timestamp | ||||||
|         session.lastActive = new Date(); |         session.lastActive = new Date(); | ||||||
|  |  | ||||||
|         // Add user message to the session |         // Process mentions if provided | ||||||
|  |         let enhancedContent = content; | ||||||
|  |         if (mentions && Array.isArray(mentions) && mentions.length > 0) { | ||||||
|  |             log.info(`Processing ${mentions.length} note mentions`); | ||||||
|  |  | ||||||
|  |             // Import note service to get note content | ||||||
|  |             const becca = (await import('../../becca/becca.js')).default; | ||||||
|  |  | ||||||
|  |             const mentionContexts: string[] = []; | ||||||
|  |  | ||||||
|  |             for (const mention of mentions) { | ||||||
|  |                 try { | ||||||
|  |                     const note = becca.getNote(mention.noteId); | ||||||
|  |                     if (note && !note.isDeleted) { | ||||||
|  |                         const noteContent = note.getContent(); | ||||||
|  |                         if (noteContent && typeof noteContent === 'string' && noteContent.trim()) { | ||||||
|  |                             mentionContexts.push(`\n\n--- Content from "${mention.title}" (${mention.noteId}) ---\n${noteContent}\n--- End of "${mention.title}" ---`); | ||||||
|  |                             log.info(`Added content from note "${mention.title}" (${mention.noteId})`); | ||||||
|  |                         } | ||||||
|  |                     } else { | ||||||
|  |                         log.info(`Referenced note not found or deleted: ${mention.noteId}`); | ||||||
|  |                     } | ||||||
|  |                 } catch (error) { | ||||||
|  |                     log.error(`Error retrieving content for note ${mention.noteId}: ${error}`); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // Enhance the content with note references | ||||||
|  |             if (mentionContexts.length > 0) { | ||||||
|  |                 enhancedContent = `${content}\n\n=== Referenced Notes ===\n${mentionContexts.join('\n')}`; | ||||||
|  |                 log.info(`Enhanced content with ${mentionContexts.length} note references`); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Add user message to the session (with enhanced content for processing) | ||||||
|         session.messages.push({ |         session.messages.push({ | ||||||
|             role: 'user', |             role: 'user', | ||||||
|             content, |             content: enhancedContent, | ||||||
|             timestamp: new Date() |             timestamp: new Date() | ||||||
|         }); |         }); | ||||||
|  |  | ||||||
|         // Create request parameters for the pipeline |         // Create request parameters for the pipeline | ||||||
|         const requestParams = { |         const requestParams = { | ||||||
|             chatNoteId: chatNoteId, |             chatNoteId: chatNoteId, | ||||||
|             content, |             content: enhancedContent, | ||||||
|             useAdvancedContext: useAdvancedContext === true, |             useAdvancedContext: useAdvancedContext === true, | ||||||
|             showThinking: showThinking === true, |             showThinking: showThinking === true, | ||||||
|             stream: true // Always stream for this endpoint |             stream: true // Always stream for this endpoint | ||||||
| @@ -851,9 +885,9 @@ async function streamMessage(req: Request, res: Response) { | |||||||
|             params: { |             params: { | ||||||
|                 chatNoteId: chatNoteId |                 chatNoteId: chatNoteId | ||||||
|             }, |             }, | ||||||
|             // Make sure the original content is available to the handler |             // Make sure the enhanced content is available to the handler | ||||||
|             body: { |             body: { | ||||||
|                 content, |                 content: enhancedContent, | ||||||
|                 useAdvancedContext: useAdvancedContext === true, |                 useAdvancedContext: useAdvancedContext === true, | ||||||
|                 showThinking: showThinking === true |                 showThinking: showThinking === true | ||||||
|             } |             } | ||||||
|   | |||||||
| @@ -152,45 +152,66 @@ export class AIServiceManager implements IAIServiceManager { | |||||||
|                 return null; |                 return null; | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             // Parse provider precedence list (similar to updateProviderOrder) |             // Get precedence list from options | ||||||
|             let precedenceList: string[] = []; |             let precedenceList: string[] = ['openai']; // Default to openai if not set | ||||||
|             const precedenceOption = await options.getOption('aiProviderPrecedence'); |             const precedenceOption = await options.getOption('aiProviderPrecedence'); | ||||||
|  |              | ||||||
|             if (precedenceOption) { |             if (precedenceOption) { | ||||||
|                 if (precedenceOption.startsWith('[') && precedenceOption.endsWith(']')) { |                 try { | ||||||
|                     precedenceList = JSON.parse(precedenceOption); |                     if (precedenceOption.startsWith('[') && precedenceOption.endsWith(']')) { | ||||||
|                 } else if (typeof precedenceOption === 'string') { |                         precedenceList = JSON.parse(precedenceOption); | ||||||
|                     if (precedenceOption.includes(',')) { |                     } else if (typeof precedenceOption === 'string') { | ||||||
|                         precedenceList = precedenceOption.split(',').map(p => p.trim()); |                         if (precedenceOption.includes(',')) { | ||||||
|                     } else { |                             precedenceList = precedenceOption.split(',').map(p => p.trim()); | ||||||
|                         precedenceList = [precedenceOption]; |                         } else { | ||||||
|  |                             precedenceList = [precedenceOption]; | ||||||
|  |                         } | ||||||
|                     } |                     } | ||||||
|  |                 } catch (e) { | ||||||
|  |                     log.error(`Error parsing precedence list: ${e}`); | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|  |              | ||||||
|             // Get enabled providers |             // Check for configuration issues with providers in the precedence list | ||||||
|             const enabledProviders = await getEnabledEmbeddingProviders(); |             const configIssues: string[] = []; | ||||||
|             const enabledProviderNames = enabledProviders.map(p => p.name); |              | ||||||
|  |             // Check each provider in the precedence list for proper configuration | ||||||
|             // Check if all providers in precedence list are enabled |             for (const provider of precedenceList) { | ||||||
|             const allPrecedenceEnabled = precedenceList.every(p => |                 if (provider === 'openai') { | ||||||
|                 enabledProviderNames.includes(p) || p === 'local'); |                     // Check OpenAI configuration | ||||||
|  |                     const apiKey = await options.getOption('openaiApiKey'); | ||||||
|             // Return warning message if there are issues |                     if (!apiKey) { | ||||||
|             if (!allPrecedenceEnabled) { |                         configIssues.push(`OpenAI API key is missing`); | ||||||
|                 let message = 'There are issues with your AI provider configuration:'; |                     } | ||||||
|  |                 } else if (provider === 'anthropic') { | ||||||
|                 if (!allPrecedenceEnabled) { |                     // Check Anthropic configuration | ||||||
|                     const disabledProviders = precedenceList.filter(p => |                     const apiKey = await options.getOption('anthropicApiKey'); | ||||||
|                         !enabledProviderNames.includes(p) && p !== 'local'); |                     if (!apiKey) { | ||||||
|                     message += `\n• The following providers in your precedence list are not enabled: ${disabledProviders.join(', ')}.`; |                         configIssues.push(`Anthropic API key is missing`); | ||||||
|  |                     } | ||||||
|  |                 } else if (provider === 'ollama') { | ||||||
|  |                     // Check Ollama configuration | ||||||
|  |                     const baseUrl = await options.getOption('ollamaBaseUrl'); | ||||||
|  |                     if (!baseUrl) { | ||||||
|  |                         configIssues.push(`Ollama Base URL is missing`); | ||||||
|  |                     } | ||||||
|                 } |                 } | ||||||
|  |                 // Add checks for other providers as needed | ||||||
|  |             } | ||||||
|  |              | ||||||
|  |             // Return warning message if there are configuration issues | ||||||
|  |             if (configIssues.length > 0) { | ||||||
|  |                 let message = 'There are issues with your AI provider configuration:'; | ||||||
|  |                  | ||||||
|  |                 for (const issue of configIssues) { | ||||||
|  |                     message += `\n• ${issue}`; | ||||||
|  |                 } | ||||||
|  |                  | ||||||
|                 message += '\n\nPlease check your AI settings.'; |                 message += '\n\nPlease check your AI settings.'; | ||||||
|  |                  | ||||||
|                 // Log warning to console |                 // Log warning to console | ||||||
|                 log.error('AI Provider Configuration Warning: ' + message); |                 log.error('AI Provider Configuration Warning: ' + message); | ||||||
|  |                  | ||||||
|                 return message; |                 return message; | ||||||
|             } |             } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -184,6 +184,22 @@ When responding: | |||||||
|  |  | ||||||
|         INSTRUCTIONS_WRAPPER: (instructions: string) => |         INSTRUCTIONS_WRAPPER: (instructions: string) => | ||||||
|             `<instructions>\n${instructions}\n</instructions>`, |             `<instructions>\n${instructions}\n</instructions>`, | ||||||
|  |              | ||||||
|  |         // Tool instructions for Anthropic Claude | ||||||
|  |         TOOL_INSTRUCTIONS: `<instructions> | ||||||
|  | When using tools to search for information, follow these requirements: | ||||||
|  |  | ||||||
|  | 1. ALWAYS TRY MULTIPLE SEARCH APPROACHES before concluding information isn't available | ||||||
|  | 2. YOU MUST PERFORM AT LEAST 3 DIFFERENT SEARCHES with varied parameters before giving up | ||||||
|  | 3. If a search returns no results: | ||||||
|  |    - Try broader terms (e.g., "Kubernetes" instead of "Kubernetes deployment") | ||||||
|  |    - Use synonyms (e.g., "meeting" instead of "conference") | ||||||
|  |    - Remove specific qualifiers (e.g., "report" instead of "Q3 financial report") | ||||||
|  |    - Try different search tools (vector_search for conceptual matches, keyword_search for exact matches) | ||||||
|  | 4. NEVER tell the user "there are no notes about X" until you've tried multiple search variations | ||||||
|  | 5. EXPLAIN your search strategy when adjusting parameters (e.g., "I'll try a broader search for...") | ||||||
|  | 6. When searches fail, AUTOMATICALLY try different approaches rather than asking the user what to do | ||||||
|  | </instructions>`, | ||||||
|  |  | ||||||
|         ACKNOWLEDGMENT: "I understand. I'll follow those instructions.", |         ACKNOWLEDGMENT: "I understand. I'll follow those instructions.", | ||||||
|         CONTEXT_ACKNOWLEDGMENT: "I'll help you with your notes based on the context provided.", |         CONTEXT_ACKNOWLEDGMENT: "I'll help you with your notes based on the context provided.", | ||||||
| @@ -203,7 +219,21 @@ ${context} | |||||||
|  |  | ||||||
| Focus on relevant information from these notes when answering. | Focus on relevant information from these notes when answering. | ||||||
| Be concise and informative in your responses. | Be concise and informative in your responses. | ||||||
| </system_prompt>` | </system_prompt>`, | ||||||
|  |          | ||||||
|  |         // Tool instructions for OpenAI models | ||||||
|  |         TOOL_INSTRUCTIONS: `When using tools to search for information, you must follow these requirements: | ||||||
|  |  | ||||||
|  | 1. ALWAYS TRY MULTIPLE SEARCH APPROACHES before concluding information isn't available | ||||||
|  | 2. YOU MUST PERFORM AT LEAST 3 DIFFERENT SEARCHES with varied parameters before giving up | ||||||
|  | 3. If a search returns no results: | ||||||
|  |    - Try broader terms (e.g., "Kubernetes" instead of "Kubernetes deployment") | ||||||
|  |    - Use synonyms (e.g., "meeting" instead of "conference") | ||||||
|  |    - Remove specific qualifiers (e.g., "report" instead of "Q3 financial report") | ||||||
|  |    - Try different search tools (vector_search for conceptual matches, keyword_search for exact matches) | ||||||
|  | 4. NEVER tell the user "there are no notes about X" until you've tried multiple search variations | ||||||
|  | 5. EXPLAIN your search strategy when adjusting parameters (e.g., "I'll try a broader search for...") | ||||||
|  | 6. When searches fail, AUTOMATICALLY try different approaches rather than asking the user what to do` | ||||||
|     }, |     }, | ||||||
|  |  | ||||||
|     OLLAMA: { |     OLLAMA: { | ||||||
| @@ -213,7 +243,23 @@ Be concise and informative in your responses. | |||||||
|  |  | ||||||
| ${context} | ${context} | ||||||
|  |  | ||||||
| Based on this information, please answer: <query>${query}</query>` | Based on this information, please answer: <query>${query}</query>`, | ||||||
|  |  | ||||||
|  |         // Tool instructions for Ollama | ||||||
|  |         TOOL_INSTRUCTIONS: ` | ||||||
|  | CRITICAL INSTRUCTIONS FOR TOOL USAGE: | ||||||
|  | 1. YOU MUST TRY MULTIPLE TOOLS AND SEARCH VARIATIONS before concluding information isn't available | ||||||
|  | 2. ALWAYS PERFORM AT LEAST 3 DIFFERENT SEARCHES with different parameters before giving up on finding information | ||||||
|  | 3. If a search returns no results, IMMEDIATELY TRY ANOTHER SEARCH with different parameters: | ||||||
|  |    - Use broader terms: If "Kubernetes deployment" fails, try just "Kubernetes" or "container orchestration" | ||||||
|  |    - Try synonyms: If "meeting notes" fails, try "conference", "discussion", or "conversation" | ||||||
|  |    - Remove specific qualifiers: If "quarterly financial report 2024" fails, try just "financial report" | ||||||
|  |    - Try semantic variations: If keyword_search fails, use vector_search which finds conceptually related content | ||||||
|  | 4. CHAIN TOOLS TOGETHER: Use the results of one tool to inform parameters for the next tool | ||||||
|  | 5. NEVER respond with "there are no notes about X" until you've tried at least 3 different search variations | ||||||
|  | 6. DO NOT ask the user what to do next when searches fail - AUTOMATICALLY try different approaches | ||||||
|  | 7. ALWAYS EXPLAIN what you're doing: "I didn't find results for X, so I'm now searching for Y instead" | ||||||
|  | 8. If all reasonable search variations fail (minimum 3 attempts), THEN you may inform the user that the information might not be in their notes` | ||||||
|     }, |     }, | ||||||
|  |  | ||||||
|     // Common prompts across providers |     // Common prompts across providers | ||||||
|   | |||||||
| @@ -211,5 +211,10 @@ export const LLM_CONSTANTS = { | |||||||
|     CONTENT: { |     CONTENT: { | ||||||
|         MAX_NOTE_CONTENT_LENGTH: 1500, |         MAX_NOTE_CONTENT_LENGTH: 1500, | ||||||
|         MAX_TOTAL_CONTENT_LENGTH: 10000 |         MAX_TOTAL_CONTENT_LENGTH: 10000 | ||||||
|  |     }, | ||||||
|  |  | ||||||
|  |     // AI Feature Exclusion | ||||||
|  |     AI_EXCLUSION: { | ||||||
|  |         LABEL_NAME: 'aiExclude'  // Label used to exclude notes from all AI/LLM features | ||||||
|     } |     } | ||||||
| }; | }; | ||||||
|   | |||||||
| @@ -18,6 +18,7 @@ import cacheManager from '../modules/cache_manager.js'; | |||||||
| import type { NoteSearchResult } from '../../interfaces/context_interfaces.js'; | import type { NoteSearchResult } from '../../interfaces/context_interfaces.js'; | ||||||
| import type { LLMServiceInterface } from '../../interfaces/agent_tool_interfaces.js'; | import type { LLMServiceInterface } from '../../interfaces/agent_tool_interfaces.js'; | ||||||
| import { SEARCH_CONSTANTS } from '../../constants/search_constants.js'; | import { SEARCH_CONSTANTS } from '../../constants/search_constants.js'; | ||||||
|  | import { isNoteExcludedFromAI } from '../../utils/ai_exclusion_utils.js'; | ||||||
|  |  | ||||||
| export interface VectorSearchOptions { | export interface VectorSearchOptions { | ||||||
|     maxResults?: number; |     maxResults?: number; | ||||||
| @@ -118,6 +119,11 @@ export class VectorSearchService { | |||||||
|                         return null; |                         return null; | ||||||
|                     } |                     } | ||||||
|  |  | ||||||
|  |                     // Check if this note is excluded from AI features | ||||||
|  |                     if (isNoteExcludedFromAI(note)) { | ||||||
|  |                         return null; // Skip this note if it has the AI exclusion label | ||||||
|  |                     } | ||||||
|  |  | ||||||
|                     // Get note content - full or summarized based on option |                     // Get note content - full or summarized based on option | ||||||
|                     let content: string | null = null; |                     let content: string | null = null; | ||||||
|  |  | ||||||
| @@ -289,6 +295,12 @@ export class VectorSearchService { | |||||||
|  |  | ||||||
|             for (const noteId of noteIds) { |             for (const noteId of noteIds) { | ||||||
|                 try { |                 try { | ||||||
|  |                     // Check if this note is excluded from AI features | ||||||
|  |                     const note = becca.getNote(noteId); | ||||||
|  |                     if (!note || isNoteExcludedFromAI(note)) { | ||||||
|  |                         continue; // Skip this note if it doesn't exist or has the AI exclusion label | ||||||
|  |                     } | ||||||
|  |  | ||||||
|                     // Get note embedding |                     // Get note embedding | ||||||
|                     const embeddingResult = await vectorStore.getEmbeddingForNote( |                     const embeddingResult = await vectorStore.getEmbeddingForNote( | ||||||
|                         noteId, |                         noteId, | ||||||
|   | |||||||
| @@ -9,6 +9,7 @@ import { deleteNoteEmbeddings } from "./storage.js"; | |||||||
| import type { QueueItem } from "./types.js"; | import type { QueueItem } from "./types.js"; | ||||||
| import { getChunkingOperations } from "./chunking/chunking_interface.js"; | import { getChunkingOperations } from "./chunking/chunking_interface.js"; | ||||||
| import indexService from '../index_service.js'; | import indexService from '../index_service.js'; | ||||||
|  | import { isNoteExcludedFromAIById } from "../utils/ai_exclusion_utils.js"; | ||||||
|  |  | ||||||
| // Track which notes are currently being processed | // Track which notes are currently being processed | ||||||
| const notesInProcess = new Set<string>(); | const notesInProcess = new Set<string>(); | ||||||
| @@ -261,6 +262,17 @@ export async function processEmbeddingQueue() { | |||||||
|                 continue; |                 continue; | ||||||
|             } |             } | ||||||
|  |  | ||||||
|  |             // Check if this note is excluded from AI features | ||||||
|  |             if (isNoteExcludedFromAIById(noteId)) { | ||||||
|  |                 log.info(`Note ${noteId} excluded from AI features, removing from embedding queue`); | ||||||
|  |                 await sql.execute( | ||||||
|  |                     "DELETE FROM embedding_queue WHERE noteId = ?", | ||||||
|  |                     [noteId] | ||||||
|  |                 ); | ||||||
|  |                 await deleteNoteEmbeddings(noteId); // Also remove any existing embeddings | ||||||
|  |                 continue; | ||||||
|  |             } | ||||||
|  |  | ||||||
|             if (noteData.operation === 'DELETE') { |             if (noteData.operation === 'DELETE') { | ||||||
|                 await deleteNoteEmbeddings(noteId); |                 await deleteNoteEmbeddings(noteId); | ||||||
|                 await sql.execute( |                 await sql.execute( | ||||||
|   | |||||||
| @@ -8,6 +8,9 @@ import entityChangesService from "../../../services/entity_changes.js"; | |||||||
| import type { EntityChange } from "../../../services/entity_changes_interface.js"; | import type { EntityChange } from "../../../services/entity_changes_interface.js"; | ||||||
| import { EMBEDDING_CONSTANTS } from "../constants/embedding_constants.js"; | import { EMBEDDING_CONSTANTS } from "../constants/embedding_constants.js"; | ||||||
| import { SEARCH_CONSTANTS } from '../constants/search_constants.js'; | import { SEARCH_CONSTANTS } from '../constants/search_constants.js'; | ||||||
|  | import type { NoteEmbeddingContext } from "./embeddings_interface.js"; | ||||||
|  | import becca from "../../../becca/becca.js"; | ||||||
|  | import { isNoteExcludedFromAIById } from "../utils/ai_exclusion_utils.js"; | ||||||
|  |  | ||||||
| interface Similarity { | interface Similarity { | ||||||
|     noteId: string; |     noteId: string; | ||||||
| @@ -452,6 +455,11 @@ async function processEmbeddings(queryEmbedding: Float32Array, embeddings: any[] | |||||||
|             : ''; |             : ''; | ||||||
|  |  | ||||||
|         for (const e of embeddings) { |         for (const e of embeddings) { | ||||||
|  |             // Check if this note is excluded from AI features | ||||||
|  |             if (isNoteExcludedFromAIById(e.noteId)) { | ||||||
|  |                 continue; // Skip this note if it has the AI exclusion label | ||||||
|  |             } | ||||||
|  |  | ||||||
|             const embVector = bufferToEmbedding(e.embedding, e.dimension); |             const embVector = bufferToEmbedding(e.embedding, e.dimension); | ||||||
|  |  | ||||||
|             // Detect content type from mime type if available |             // Detect content type from mime type if available | ||||||
|   | |||||||
| @@ -1,7 +1,7 @@ | |||||||
| import type { Message } from '../ai_interface.js'; | import type { Message } from '../ai_interface.js'; | ||||||
| import { BaseMessageFormatter } from './base_formatter.js'; | import { BaseMessageFormatter } from './base_formatter.js'; | ||||||
| import sanitizeHtml from 'sanitize-html'; | import sanitizeHtml from 'sanitize-html'; | ||||||
| import { PROVIDER_PROMPTS, FORMATTING_PROMPTS } from '../constants/llm_prompt_constants.js'; | import { PROVIDER_PROMPTS } from '../constants/llm_prompt_constants.js'; | ||||||
| import { LLM_CONSTANTS } from '../constants/provider_constants.js'; | import { LLM_CONSTANTS } from '../constants/provider_constants.js'; | ||||||
| import { | import { | ||||||
|     HTML_ALLOWED_TAGS, |     HTML_ALLOWED_TAGS, | ||||||
| @@ -29,7 +29,7 @@ export class OllamaMessageFormatter extends BaseMessageFormatter { | |||||||
|      * @param context Optional context to include |      * @param context Optional context to include | ||||||
|      * @param preserveSystemPrompt When true, preserves existing system messages rather than replacing them |      * @param preserveSystemPrompt When true, preserves existing system messages rather than replacing them | ||||||
|      */ |      */ | ||||||
|     formatMessages(messages: Message[], systemPrompt?: string, context?: string, preserveSystemPrompt?: boolean): Message[] { |     formatMessages(messages: Message[], systemPrompt?: string, context?: string, preserveSystemPrompt?: boolean, useTools?: boolean): Message[] { | ||||||
|         const formattedMessages: Message[] = []; |         const formattedMessages: Message[] = []; | ||||||
|  |  | ||||||
|         // Log the input messages with all their properties |         // Log the input messages with all their properties | ||||||
| @@ -37,7 +37,7 @@ export class OllamaMessageFormatter extends BaseMessageFormatter { | |||||||
|         messages.forEach((msg, index) => { |         messages.forEach((msg, index) => { | ||||||
|             const msgKeys = Object.keys(msg); |             const msgKeys = Object.keys(msg); | ||||||
|             log.info(`Message ${index} - role: ${msg.role}, keys: ${msgKeys.join(', ')}, content length: ${msg.content.length}`); |             log.info(`Message ${index} - role: ${msg.role}, keys: ${msgKeys.join(', ')}, content length: ${msg.content.length}`); | ||||||
|              |  | ||||||
|             // Log special properties if present |             // Log special properties if present | ||||||
|             if (msg.tool_calls) { |             if (msg.tool_calls) { | ||||||
|                 log.info(`Message ${index} has ${msg.tool_calls.length} tool_calls`); |                 log.info(`Message ${index} has ${msg.tool_calls.length} tool_calls`); | ||||||
| @@ -61,7 +61,19 @@ export class OllamaMessageFormatter extends BaseMessageFormatter { | |||||||
|             log.info(`Preserving existing system message: ${systemMessages[0].content.substring(0, 50)}...`); |             log.info(`Preserving existing system message: ${systemMessages[0].content.substring(0, 50)}...`); | ||||||
|         } else { |         } else { | ||||||
|             // Use provided systemPrompt or default |             // Use provided systemPrompt or default | ||||||
|             const basePrompt = systemPrompt || PROVIDER_PROMPTS.COMMON.DEFAULT_ASSISTANT_INTRO; |             let basePrompt = systemPrompt || PROVIDER_PROMPTS.COMMON.DEFAULT_ASSISTANT_INTRO; | ||||||
|  |  | ||||||
|  |             // Check if any message has tool_calls or if useTools flag is set, indicating this is a tool-using conversation | ||||||
|  |             const hasPreviousToolCalls = messages.some(msg => msg.tool_calls && msg.tool_calls.length > 0); | ||||||
|  |             const hasToolResults = messages.some(msg => msg.role === 'tool'); | ||||||
|  |             const isToolUsingConversation = useTools || hasPreviousToolCalls || hasToolResults; | ||||||
|  |  | ||||||
|  |             // Add tool instructions for Ollama when tools are being used | ||||||
|  |             if (isToolUsingConversation && PROVIDER_PROMPTS.OLLAMA.TOOL_INSTRUCTIONS) { | ||||||
|  |                 log.info('Adding tool instructions to system prompt for Ollama'); | ||||||
|  |                 basePrompt = `${basePrompt}\n\n${PROVIDER_PROMPTS.OLLAMA.TOOL_INSTRUCTIONS}`; | ||||||
|  |             } | ||||||
|  |  | ||||||
|             formattedMessages.push({ |             formattedMessages.push({ | ||||||
|                 role: 'system', |                 role: 'system', | ||||||
|                 content: basePrompt |                 content: basePrompt | ||||||
| @@ -96,7 +108,7 @@ export class OllamaMessageFormatter extends BaseMessageFormatter { | |||||||
|                         ...msg, // Copy all properties |                         ...msg, // Copy all properties | ||||||
|                         content: formattedContext // Override content with injected context |                         content: formattedContext // Override content with injected context | ||||||
|                     }; |                     }; | ||||||
|                      |  | ||||||
|                     formattedMessages.push(newMessage); |                     formattedMessages.push(newMessage); | ||||||
|                     log.info(`Created user message with context, final keys: ${Object.keys(newMessage).join(', ')}`); |                     log.info(`Created user message with context, final keys: ${Object.keys(newMessage).join(', ')}`); | ||||||
|  |  | ||||||
| @@ -104,7 +116,7 @@ export class OllamaMessageFormatter extends BaseMessageFormatter { | |||||||
|                 } else { |                 } else { | ||||||
|                     // For other messages, preserve all properties including any tool-related ones |                     // For other messages, preserve all properties including any tool-related ones | ||||||
|                     log.info(`Preserving message with role ${msg.role}, keys: ${Object.keys(msg).join(', ')}`); |                     log.info(`Preserving message with role ${msg.role}, keys: ${Object.keys(msg).join(', ')}`); | ||||||
|                      |  | ||||||
|                     formattedMessages.push({ |                     formattedMessages.push({ | ||||||
|                         ...msg // Copy all properties |                         ...msg // Copy all properties | ||||||
|                     }); |                     }); | ||||||
| @@ -126,7 +138,7 @@ export class OllamaMessageFormatter extends BaseMessageFormatter { | |||||||
|         formattedMessages.forEach((msg, index) => { |         formattedMessages.forEach((msg, index) => { | ||||||
|             const msgKeys = Object.keys(msg); |             const msgKeys = Object.keys(msg); | ||||||
|             log.info(`Formatted message ${index} - role: ${msg.role}, keys: ${msgKeys.join(', ')}, content length: ${msg.content.length}`); |             log.info(`Formatted message ${index} - role: ${msg.role}, keys: ${msgKeys.join(', ')}, content length: ${msg.content.length}`); | ||||||
|              |  | ||||||
|             // Log special properties if present |             // Log special properties if present | ||||||
|             if (msg.tool_calls) { |             if (msg.tool_calls) { | ||||||
|                 log.info(`Formatted message ${index} has ${msg.tool_calls.length} tool_calls`); |                 log.info(`Formatted message ${index} has ${msg.tool_calls.length} tool_calls`); | ||||||
| @@ -151,13 +163,11 @@ export class OllamaMessageFormatter extends BaseMessageFormatter { | |||||||
|         if (!content) return ''; |         if (!content) return ''; | ||||||
|  |  | ||||||
|         try { |         try { | ||||||
|             // Store our XML tags so we can restore them after cleaning |             // Define regexes for identifying and preserving tagged content | ||||||
|             const noteTagsRegex = /<\/?note>/g; |  | ||||||
|             const notesTagsRegex = /<\/?notes>/g; |             const notesTagsRegex = /<\/?notes>/g; | ||||||
|             const queryTagsRegex = /<\/?query>[^<]*<\/query>/g; |             // const queryTagsRegex = /<\/?query>/g; // Commenting out unused variable | ||||||
|  |  | ||||||
|             // Capture tags to restore later |             // Capture tags to restore later | ||||||
|             const noteTags = content.match(noteTagsRegex) || []; |  | ||||||
|             const noteTagPositions: number[] = []; |             const noteTagPositions: number[] = []; | ||||||
|             let match; |             let match; | ||||||
|             const regex = /<\/?note>/g; |             const regex = /<\/?note>/g; | ||||||
| @@ -166,17 +176,15 @@ export class OllamaMessageFormatter extends BaseMessageFormatter { | |||||||
|             } |             } | ||||||
|  |  | ||||||
|             // Remember the notes tags |             // Remember the notes tags | ||||||
|             const notesTagsMatch = content.match(notesTagsRegex) || []; |  | ||||||
|             const notesTagPositions: number[] = []; |             const notesTagPositions: number[] = []; | ||||||
|             while ((match = notesTagsRegex.exec(content)) !== null) { |             while ((match = notesTagsRegex.exec(content)) !== null) { | ||||||
|                 notesTagPositions.push(match.index); |                 notesTagPositions.push(match.index); | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             // Remember the query tags |             // Remember the query tag | ||||||
|             const queryTagsMatch = content.match(queryTagsRegex) || []; |  | ||||||
|  |  | ||||||
|             // Temporarily replace XML tags with markers that won't be affected by sanitization |             // Temporarily replace XML tags with markers that won't be affected by sanitization | ||||||
|             let modified = content |             const modified = content | ||||||
|                 .replace(/<note>/g, '[NOTE_START]') |                 .replace(/<note>/g, '[NOTE_START]') | ||||||
|                 .replace(/<\/note>/g, '[NOTE_END]') |                 .replace(/<\/note>/g, '[NOTE_END]') | ||||||
|                 .replace(/<notes>/g, '[NOTES_START]') |                 .replace(/<notes>/g, '[NOTES_START]') | ||||||
| @@ -184,7 +192,7 @@ export class OllamaMessageFormatter extends BaseMessageFormatter { | |||||||
|                 .replace(/<query>(.*?)<\/query>/g, '[QUERY]$1[/QUERY]'); |                 .replace(/<query>(.*?)<\/query>/g, '[QUERY]$1[/QUERY]'); | ||||||
|  |  | ||||||
|             // First use the parent class to do standard cleaning |             // First use the parent class to do standard cleaning | ||||||
|             let sanitized = super.cleanContextContent(modified); |             const sanitized = super.cleanContextContent(modified); | ||||||
|  |  | ||||||
|             // Then apply Ollama-specific aggressive cleaning |             // Then apply Ollama-specific aggressive cleaning | ||||||
|             // Remove any remaining HTML using sanitizeHtml while keeping our markers |             // Remove any remaining HTML using sanitizeHtml while keeping our markers | ||||||
|   | |||||||
| @@ -1,7 +1,7 @@ | |||||||
| import sanitizeHtml from 'sanitize-html'; | import sanitizeHtml from 'sanitize-html'; | ||||||
| import type { Message } from '../ai_interface.js'; | import type { Message } from '../ai_interface.js'; | ||||||
| import { BaseMessageFormatter } from './base_formatter.js'; | import { BaseMessageFormatter } from './base_formatter.js'; | ||||||
| import { PROVIDER_PROMPTS, FORMATTING_PROMPTS } from '../constants/llm_prompt_constants.js'; | import { PROVIDER_PROMPTS } from '../constants/llm_prompt_constants.js'; | ||||||
| import { LLM_CONSTANTS } from '../constants/provider_constants.js'; | import { LLM_CONSTANTS } from '../constants/provider_constants.js'; | ||||||
| import { | import { | ||||||
|     HTML_ALLOWED_TAGS, |     HTML_ALLOWED_TAGS, | ||||||
| @@ -10,6 +10,7 @@ import { | |||||||
|     HTML_ENTITY_REPLACEMENTS, |     HTML_ENTITY_REPLACEMENTS, | ||||||
|     FORMATTER_LOGS |     FORMATTER_LOGS | ||||||
| } from '../constants/formatter_constants.js'; | } from '../constants/formatter_constants.js'; | ||||||
|  | import log from '../../log.js'; | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * OpenAI-specific message formatter |  * OpenAI-specific message formatter | ||||||
| @@ -24,8 +25,13 @@ export class OpenAIMessageFormatter extends BaseMessageFormatter { | |||||||
|  |  | ||||||
|     /** |     /** | ||||||
|      * Format messages for the OpenAI API |      * Format messages for the OpenAI API | ||||||
|  |      * @param messages The messages to format | ||||||
|  |      * @param systemPrompt Optional system prompt to use | ||||||
|  |      * @param context Optional context to include | ||||||
|  |      * @param preserveSystemPrompt When true, preserves existing system messages | ||||||
|  |      * @param useTools Flag indicating if tools will be used in this request | ||||||
|      */ |      */ | ||||||
|     formatMessages(messages: Message[], systemPrompt?: string, context?: string): Message[] { |     formatMessages(messages: Message[], systemPrompt?: string, context?: string, preserveSystemPrompt?: boolean, useTools?: boolean): Message[] { | ||||||
|         const formattedMessages: Message[] = []; |         const formattedMessages: Message[] = []; | ||||||
|  |  | ||||||
|         // Check if we already have a system message |         // Check if we already have a system message | ||||||
| @@ -47,9 +53,22 @@ export class OpenAIMessageFormatter extends BaseMessageFormatter { | |||||||
|         } |         } | ||||||
|         // If we don't have explicit context but have a system prompt |         // If we don't have explicit context but have a system prompt | ||||||
|         else if (!hasSystemMessage && systemPrompt) { |         else if (!hasSystemMessage && systemPrompt) { | ||||||
|  |             let baseSystemPrompt = systemPrompt || PROVIDER_PROMPTS.COMMON.DEFAULT_ASSISTANT_INTRO; | ||||||
|  |              | ||||||
|  |             // Check if this is a tool-using conversation | ||||||
|  |             const hasPreviousToolCalls = messages.some(msg => msg.tool_calls && msg.tool_calls.length > 0); | ||||||
|  |             const hasToolResults = messages.some(msg => msg.role === 'tool'); | ||||||
|  |             const isToolUsingConversation = useTools || hasPreviousToolCalls || hasToolResults; | ||||||
|  |              | ||||||
|  |             // Add tool instructions for OpenAI when tools are being used | ||||||
|  |             if (isToolUsingConversation && PROVIDER_PROMPTS.OPENAI.TOOL_INSTRUCTIONS) { | ||||||
|  |                 log.info('Adding tool instructions to system prompt for OpenAI'); | ||||||
|  |                 baseSystemPrompt = `${baseSystemPrompt}\n\n${PROVIDER_PROMPTS.OPENAI.TOOL_INSTRUCTIONS}`; | ||||||
|  |             } | ||||||
|  |              | ||||||
|             formattedMessages.push({ |             formattedMessages.push({ | ||||||
|                 role: 'system', |                 role: 'system', | ||||||
|                 content: systemPrompt |                 content: baseSystemPrompt | ||||||
|             }); |             }); | ||||||
|         } |         } | ||||||
|         // If neither context nor system prompt is provided, use default system prompt |         // If neither context nor system prompt is provided, use default system prompt | ||||||
|   | |||||||
| @@ -20,6 +20,7 @@ import sql from "../sql.js"; | |||||||
| import sqlInit from "../sql_init.js"; | import sqlInit from "../sql_init.js"; | ||||||
| import { CONTEXT_PROMPTS } from './constants/llm_prompt_constants.js'; | import { CONTEXT_PROMPTS } from './constants/llm_prompt_constants.js'; | ||||||
| import { SEARCH_CONSTANTS } from './constants/search_constants.js'; | import { SEARCH_CONSTANTS } from './constants/search_constants.js'; | ||||||
|  | import { isNoteExcludedFromAI } from "./utils/ai_exclusion_utils.js"; | ||||||
|  |  | ||||||
| export class IndexService { | export class IndexService { | ||||||
|     private initialized = false; |     private initialized = false; | ||||||
| @@ -803,6 +804,12 @@ export class IndexService { | |||||||
|                 throw new Error(`Note ${noteId} not found`); |                 throw new Error(`Note ${noteId} not found`); | ||||||
|             } |             } | ||||||
|  |  | ||||||
|  |             // Check if this note is excluded from AI features | ||||||
|  |             if (isNoteExcludedFromAI(note)) { | ||||||
|  |                 log.info(`Note ${noteId} (${note.title}) excluded from AI indexing due to exclusion label`); | ||||||
|  |                 return true; // Return true to indicate successful handling (exclusion is intentional) | ||||||
|  |             } | ||||||
|  |  | ||||||
|             // Check where embedding generation should happen |             // Check where embedding generation should happen | ||||||
|             const embeddingLocation = await options.getOption('embeddingGenerationLocation') || 'client'; |             const embeddingLocation = await options.getOption('embeddingGenerationLocation') || 'client'; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -6,6 +6,26 @@ import toolRegistry from '../../tools/tool_registry.js'; | |||||||
| import chatStorageService from '../../chat_storage_service.js'; | import chatStorageService from '../../chat_storage_service.js'; | ||||||
| import aiServiceManager from '../../ai_service_manager.js'; | import aiServiceManager from '../../ai_service_manager.js'; | ||||||
|  |  | ||||||
|  | // Type definitions for tools and validation results | ||||||
|  | interface ToolInterface { | ||||||
|  |     execute: (args: Record<string, unknown>) => Promise<unknown>; | ||||||
|  |     [key: string]: unknown; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | interface ToolValidationResult { | ||||||
|  |     toolCall: { | ||||||
|  |         id?: string; | ||||||
|  |         function: { | ||||||
|  |             name: string; | ||||||
|  |             arguments: string | Record<string, unknown>; | ||||||
|  |         }; | ||||||
|  |     }; | ||||||
|  |     valid: boolean; | ||||||
|  |     tool: ToolInterface | null; | ||||||
|  |     error: string | null; | ||||||
|  |     guidance?: string; // Guidance to help the LLM select better tools/parameters | ||||||
|  | } | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * Pipeline stage for handling LLM tool calling |  * Pipeline stage for handling LLM tool calling | ||||||
|  * This stage is responsible for: |  * This stage is responsible for: | ||||||
| @@ -50,12 +70,35 @@ export class ToolCallingStage extends BasePipelineStage<ToolExecutionInput, { re | |||||||
|         } |         } | ||||||
|  |  | ||||||
|         // Check if the registry has any tools |         // Check if the registry has any tools | ||||||
|         const availableTools = toolRegistry.getAllTools(); |         const registryTools = toolRegistry.getAllTools(); | ||||||
|  |  | ||||||
|  |         // Convert ToolHandler[] to ToolInterface[] with proper type safety | ||||||
|  |         const availableTools: ToolInterface[] = registryTools.map(tool => { | ||||||
|  |             // Create a proper ToolInterface from the ToolHandler | ||||||
|  |             const toolInterface: ToolInterface = { | ||||||
|  |                 // Pass through the execute method | ||||||
|  |                 execute: (args: Record<string, unknown>) => tool.execute(args), | ||||||
|  |                 // Include other properties from the tool definition | ||||||
|  |                 ...tool.definition | ||||||
|  |             }; | ||||||
|  |             return toolInterface; | ||||||
|  |         }); | ||||||
|         log.info(`Available tools in registry: ${availableTools.length}`); |         log.info(`Available tools in registry: ${availableTools.length}`); | ||||||
|  |  | ||||||
|         // Log available tools for debugging |         // Log available tools for debugging | ||||||
|         if (availableTools.length > 0) { |         if (availableTools.length > 0) { | ||||||
|             const availableToolNames = availableTools.map(t => t.definition.function.name).join(', '); |             const availableToolNames = availableTools.map(t => { | ||||||
|  |                 // Safely access the name property using type narrowing | ||||||
|  |                 if (t && typeof t === 'object' && 'definition' in t && | ||||||
|  |                     t.definition && typeof t.definition === 'object' && | ||||||
|  |                     'function' in t.definition && t.definition.function && | ||||||
|  |                     typeof t.definition.function === 'object' && | ||||||
|  |                     'name' in t.definition.function && | ||||||
|  |                     typeof t.definition.function.name === 'string') { | ||||||
|  |                     return t.definition.function.name; | ||||||
|  |                 } | ||||||
|  |                 return 'unknown'; | ||||||
|  |             }).join(', '); | ||||||
|             log.info(`Available tools: ${availableToolNames}`); |             log.info(`Available tools: ${availableToolNames}`); | ||||||
|         } |         } | ||||||
|  |  | ||||||
| @@ -66,9 +109,11 @@ export class ToolCallingStage extends BasePipelineStage<ToolExecutionInput, { re | |||||||
|                 log.info('Attempting to initialize tools as recovery step'); |                 log.info('Attempting to initialize tools as recovery step'); | ||||||
|                 // Tools are already initialized in the AIServiceManager constructor |                 // Tools are already initialized in the AIServiceManager constructor | ||||||
|                 // No need to initialize them again |                 // No need to initialize them again | ||||||
|                 log.info(`After recovery initialization: ${toolRegistry.getAllTools().length} tools available`); |                 const toolCount = toolRegistry.getAllTools().length; | ||||||
|             } catch (error: any) { |                 log.info(`After recovery initialization: ${toolCount} tools available`); | ||||||
|                 log.error(`Failed to initialize tools in recovery step: ${error.message}`); |             } catch (error: unknown) { | ||||||
|  |                 const errorMessage = error instanceof Error ? error.message : String(error); | ||||||
|  |                 log.error(`Failed to initialize tools in recovery step: ${errorMessage}`); | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
| @@ -88,25 +133,29 @@ export class ToolCallingStage extends BasePipelineStage<ToolExecutionInput, { re | |||||||
|  |  | ||||||
|         const executionStartTime = Date.now(); |         const executionStartTime = Date.now(); | ||||||
|  |  | ||||||
|         // First validate all tools before executing them |         // First validate all tools before execution | ||||||
|         log.info(`Validating ${response.tool_calls?.length || 0} tools before execution`); |         log.info(`Validating ${response.tool_calls?.length || 0} tools before execution`); | ||||||
|         const validationResults = await Promise.all((response.tool_calls || []).map(async (toolCall) => { |         const validationResults: ToolValidationResult[] = await Promise.all((response.tool_calls || []).map(async (toolCall) => { | ||||||
|             try { |             try { | ||||||
|                 // Get the tool from registry |                 // Get the tool from registry | ||||||
|                 const tool = toolRegistry.getTool(toolCall.function.name); |                 const tool = toolRegistry.getTool(toolCall.function.name); | ||||||
|  |  | ||||||
|                 if (!tool) { |                 if (!tool) { | ||||||
|                     log.error(`Tool not found in registry: ${toolCall.function.name}`); |                     log.error(`Tool not found in registry: ${toolCall.function.name}`); | ||||||
|  |                     // Generate guidance for the LLM when a tool is not found | ||||||
|  |                     const guidance = this.generateToolGuidance(toolCall.function.name, `Tool not found: ${toolCall.function.name}`); | ||||||
|                     return { |                     return { | ||||||
|                         toolCall, |                         toolCall, | ||||||
|                         valid: false, |                         valid: false, | ||||||
|                         tool: null, |                         tool: null, | ||||||
|                         error: `Tool not found: ${toolCall.function.name}` |                         error: `Tool not found: ${toolCall.function.name}`, | ||||||
|  |                         guidance // Add guidance for the LLM | ||||||
|                     }; |                     }; | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|                 // Validate the tool before execution |                 // Validate the tool before execution | ||||||
|                 const isToolValid = await this.validateToolBeforeExecution(tool, toolCall.function.name); |                 // Use unknown as an intermediate step for type conversion | ||||||
|  |                 const isToolValid = await this.validateToolBeforeExecution(tool as unknown as ToolInterface, toolCall.function.name); | ||||||
|                 if (!isToolValid) { |                 if (!isToolValid) { | ||||||
|                     throw new Error(`Tool '${toolCall.function.name}' failed validation before execution`); |                     throw new Error(`Tool '${toolCall.function.name}' failed validation before execution`); | ||||||
|                 } |                 } | ||||||
| @@ -114,15 +163,16 @@ export class ToolCallingStage extends BasePipelineStage<ToolExecutionInput, { re | |||||||
|                 return { |                 return { | ||||||
|                     toolCall, |                     toolCall, | ||||||
|                     valid: true, |                     valid: true, | ||||||
|                     tool, |                     tool: tool as unknown as ToolInterface, | ||||||
|                     error: null |                     error: null | ||||||
|                 }; |                 }; | ||||||
|             } catch (error: any) { |             } catch (error: unknown) { | ||||||
|  |                 const errorMessage = error instanceof Error ? error.message : String(error); | ||||||
|                 return { |                 return { | ||||||
|                     toolCall, |                     toolCall, | ||||||
|                     valid: false, |                     valid: false, | ||||||
|                     tool: null, |                     tool: null, | ||||||
|                     error: error.message || String(error) |                     error: errorMessage | ||||||
|                 }; |                 }; | ||||||
|             } |             } | ||||||
|         })); |         })); | ||||||
| @@ -141,15 +191,21 @@ export class ToolCallingStage extends BasePipelineStage<ToolExecutionInput, { re | |||||||
|                     : JSON.stringify(toolCall.function.arguments); |                     : JSON.stringify(toolCall.function.arguments); | ||||||
|                 log.info(`Tool parameters: ${argsStr}`); |                 log.info(`Tool parameters: ${argsStr}`); | ||||||
|  |  | ||||||
|                 // If validation failed, throw the error |                 // If validation failed, generate guidance and throw the error | ||||||
|                 if (!valid || !tool) { |                 if (!valid || !tool) { | ||||||
|                     throw new Error(error || `Unknown validation error for tool '${toolCall.function.name}'`); |                     // If we already have guidance from validation, use it, otherwise generate it | ||||||
|  |                     const toolGuidance = validation.guidance || | ||||||
|  |                         this.generateToolGuidance(toolCall.function.name, | ||||||
|  |                             error || `Unknown validation error for tool '${toolCall.function.name}'`); | ||||||
|  |  | ||||||
|  |                     // Include the guidance in the error message | ||||||
|  |                     throw new Error(`${error || `Unknown validation error for tool '${toolCall.function.name}'`}\n${toolGuidance}`); | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|                 log.info(`Tool validated successfully: ${toolCall.function.name}`); |                 log.info(`Tool validated successfully: ${toolCall.function.name}`); | ||||||
|  |  | ||||||
|                 // Parse arguments (handle both string and object formats) |                 // Parse arguments (handle both string and object formats) | ||||||
|                 let args; |                 let args: Record<string, unknown>; | ||||||
|                 // At this stage, arguments should already be processed by the provider-specific service |                 // At this stage, arguments should already be processed by the provider-specific service | ||||||
|                 // But we still need to handle different formats just in case |                 // But we still need to handle different formats just in case | ||||||
|                 if (typeof toolCall.function.arguments === 'string') { |                 if (typeof toolCall.function.arguments === 'string') { | ||||||
| @@ -157,7 +213,7 @@ export class ToolCallingStage extends BasePipelineStage<ToolExecutionInput, { re | |||||||
|  |  | ||||||
|                     try { |                     try { | ||||||
|                         // Try to parse as JSON first |                         // Try to parse as JSON first | ||||||
|                         args = JSON.parse(toolCall.function.arguments); |                         args = JSON.parse(toolCall.function.arguments) as Record<string, unknown>; | ||||||
|                         log.info(`Parsed JSON arguments: ${Object.keys(args).join(', ')}`); |                         log.info(`Parsed JSON arguments: ${Object.keys(args).join(', ')}`); | ||||||
|                     } catch (e: unknown) { |                     } catch (e: unknown) { | ||||||
|                         // If it's not valid JSON, try to check if it's a stringified object with quotes |                         // If it's not valid JSON, try to check if it's a stringified object with quotes | ||||||
| @@ -168,25 +224,26 @@ export class ToolCallingStage extends BasePipelineStage<ToolExecutionInput, { re | |||||||
|                         // Try to clean it up |                         // Try to clean it up | ||||||
|                         try { |                         try { | ||||||
|                             const cleaned = toolCall.function.arguments |                             const cleaned = toolCall.function.arguments | ||||||
|                                 .replace(/^['"]|['"]$/g, '') // Remove surrounding quotes |                                 .replace(/^['"]/g, '') // Remove surrounding quotes | ||||||
|  |                                 .replace(/['"]$/g, '') // Remove surrounding quotes | ||||||
|                                 .replace(/\\"/g, '"')        // Replace escaped quotes |                                 .replace(/\\"/g, '"')        // Replace escaped quotes | ||||||
|                                 .replace(/([{,])\s*'([^']+)'\s*:/g, '$1"$2":') // Replace single quotes around property names |                                 .replace(/([{,])\s*'([^']+)'\s*:/g, '$1"$2":') // Replace single quotes around property names | ||||||
|                                 .replace(/([{,])\s*(\w+)\s*:/g, '$1"$2":');    // Add quotes around unquoted property names |                                 .replace(/([{,])\s*(\w+)\s*:/g, '$1"$2":');    // Add quotes around unquoted property names | ||||||
|  |  | ||||||
|                             log.info(`Cleaned argument string: ${cleaned}`); |                             log.info(`Cleaned argument string: ${cleaned}`); | ||||||
|                             args = JSON.parse(cleaned); |                             args = JSON.parse(cleaned) as Record<string, unknown>; | ||||||
|                             log.info(`Successfully parsed cleaned arguments: ${Object.keys(args).join(', ')}`); |                             log.info(`Successfully parsed cleaned arguments: ${Object.keys(args).join(', ')}`); | ||||||
|                         } catch (cleanError: unknown) { |                         } catch (cleanError: unknown) { | ||||||
|                             // If all parsing fails, treat it as a text argument |                             // If all parsing fails, treat it as a text argument | ||||||
|                             const cleanErrorMessage = cleanError instanceof Error ? cleanError.message : String(cleanError); |                             const cleanErrorMessage = cleanError instanceof Error ? cleanError.message : String(cleanError); | ||||||
|                             log.info(`Failed to parse cleaned arguments: ${cleanErrorMessage}`); |                             log.info(`Failed to parse cleaned arguments: ${cleanErrorMessage}`); | ||||||
|                             args = { text: toolCall.function.arguments }; |                             args = { text: toolCall.function.arguments }; | ||||||
|                             log.info(`Using text argument: ${args.text.substring(0, 50)}...`); |                             log.info(`Using text argument: ${(args.text as string).substring(0, 50)}...`); | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
|                 } else { |                 } else { | ||||||
|                     // Arguments are already an object |                     // Arguments are already an object | ||||||
|                     args = toolCall.function.arguments; |                     args = toolCall.function.arguments as Record<string, unknown>; | ||||||
|                     log.info(`Using object arguments with keys: ${Object.keys(args).join(', ')}`); |                     log.info(`Using object arguments with keys: ${Object.keys(args).join(', ')}`); | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
| @@ -263,9 +320,16 @@ export class ToolCallingStage extends BasePipelineStage<ToolExecutionInput, { re | |||||||
|                             callbackResult.catch((e: Error) => log.error(`Error sending tool execution complete event: ${e.message}`)); |                             callbackResult.catch((e: Error) => log.error(`Error sending tool execution complete event: ${e.message}`)); | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
|                 } catch (execError: any) { |                 } catch (execError: unknown) { | ||||||
|                     const executionTime = Date.now() - executionStart; |                     const executionTime = Date.now() - executionStart; | ||||||
|                     log.error(`================ TOOL EXECUTION FAILED in ${executionTime}ms: ${execError.message} ================`); |                     const errorMessage = execError instanceof Error ? execError.message : String(execError); | ||||||
|  |                     log.error(`================ TOOL EXECUTION FAILED in ${executionTime}ms: ${errorMessage} ================`); | ||||||
|  |  | ||||||
|  |                     // Generate guidance for the failed tool execution | ||||||
|  |                     const toolGuidance = this.generateToolGuidance(toolCall.function.name, errorMessage); | ||||||
|  |  | ||||||
|  |                     // Add the guidance to the error message for the LLM | ||||||
|  |                     const enhancedErrorMessage = `${errorMessage}\n${toolGuidance}`; | ||||||
|  |  | ||||||
|                     // Record this failed tool execution if there's a sessionId available |                     // Record this failed tool execution if there's a sessionId available | ||||||
|                     if (input.options?.sessionId) { |                     if (input.options?.sessionId) { | ||||||
| @@ -276,7 +340,7 @@ export class ToolCallingStage extends BasePipelineStage<ToolExecutionInput, { re | |||||||
|                                 toolCall.id || `tool-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, |                                 toolCall.id || `tool-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, | ||||||
|                                 args, |                                 args, | ||||||
|                                 "", // No result for failed execution |                                 "", // No result for failed execution | ||||||
|                                 execError.message || String(execError) |                                 enhancedErrorMessage // Use enhanced error message with guidance | ||||||
|                             ); |                             ); | ||||||
|                         } catch (storageError) { |                         } catch (storageError) { | ||||||
|                             log.error(`Failed to record tool execution error in chat storage: ${storageError}`); |                             log.error(`Failed to record tool execution error in chat storage: ${storageError}`); | ||||||
| @@ -291,7 +355,7 @@ export class ToolCallingStage extends BasePipelineStage<ToolExecutionInput, { re | |||||||
|                                 name: toolCall.function.name, |                                 name: toolCall.function.name, | ||||||
|                                 arguments: {} as Record<string, unknown> |                                 arguments: {} as Record<string, unknown> | ||||||
|                             }, |                             }, | ||||||
|                             error: execError.message || String(execError), |                             error: enhancedErrorMessage, // Include guidance in the error message | ||||||
|                             type: 'error' as const |                             type: 'error' as const | ||||||
|                         }; |                         }; | ||||||
|  |  | ||||||
| @@ -306,6 +370,10 @@ export class ToolCallingStage extends BasePipelineStage<ToolExecutionInput, { re | |||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
|  |  | ||||||
|  |                     // Modify the error to include our guidance | ||||||
|  |                     if (execError instanceof Error) { | ||||||
|  |                         execError.message = enhancedErrorMessage; | ||||||
|  |                     } | ||||||
|                     throw execError; |                     throw execError; | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
| @@ -322,19 +390,24 @@ export class ToolCallingStage extends BasePipelineStage<ToolExecutionInput, { re | |||||||
|                     name: toolCall.function.name, |                     name: toolCall.function.name, | ||||||
|                     result |                     result | ||||||
|                 }; |                 }; | ||||||
|             } catch (error: any) { |             } catch (error: unknown) { | ||||||
|                 log.error(`Error executing tool ${toolCall.function.name}: ${error.message || String(error)}`); |                 const errorMessage = error instanceof Error ? error.message : String(error); | ||||||
|  |                 log.error(`Error executing tool ${toolCall.function.name}: ${errorMessage}`); | ||||||
|  |  | ||||||
|                 // Emit tool error event if not already handled in the try/catch above |                 // Emit tool error event if not already handled in the try/catch above | ||||||
|                 // and if streaming is enabled |                 // and if streaming is enabled | ||||||
|                 if (streamCallback && error.name !== "ExecutionError") { |                 // Need to check if error is an object with a name property of type string | ||||||
|  |                 const isExecutionError = typeof error === 'object' && error !== null && | ||||||
|  |                     'name' in error && (error as { name: unknown }).name === "ExecutionError"; | ||||||
|  |  | ||||||
|  |                 if (streamCallback && !isExecutionError) { | ||||||
|                     const toolExecutionData = { |                     const toolExecutionData = { | ||||||
|                         action: 'error', |                         action: 'error', | ||||||
|                         tool: { |                         tool: { | ||||||
|                             name: toolCall.function.name, |                             name: toolCall.function.name, | ||||||
|                             arguments: {} as Record<string, unknown> |                             arguments: {} as Record<string, unknown> | ||||||
|                         }, |                         }, | ||||||
|                         error: error.message || String(error), |                         error: errorMessage, | ||||||
|                         type: 'error' as const |                         type: 'error' as const | ||||||
|                     }; |                     }; | ||||||
|  |  | ||||||
| @@ -353,7 +426,7 @@ export class ToolCallingStage extends BasePipelineStage<ToolExecutionInput, { re | |||||||
|                 return { |                 return { | ||||||
|                     toolCallId: toolCall.id, |                     toolCallId: toolCall.id, | ||||||
|                     name: toolCall.function.name, |                     name: toolCall.function.name, | ||||||
|                     result: `Error: ${error.message || String(error)}` |                     result: `Error: ${errorMessage}` | ||||||
|                 }; |                 }; | ||||||
|             } |             } | ||||||
|         })); |         })); | ||||||
| @@ -364,6 +437,7 @@ export class ToolCallingStage extends BasePipelineStage<ToolExecutionInput, { re | |||||||
|  |  | ||||||
|         // Add each tool result to the messages array |         // Add each tool result to the messages array | ||||||
|         const toolResultMessages: Message[] = []; |         const toolResultMessages: Message[] = []; | ||||||
|  |         let hasEmptyResults = false; | ||||||
|  |  | ||||||
|         for (const result of toolResults) { |         for (const result of toolResults) { | ||||||
|             const { toolCallId, name, result: toolResult } = result; |             const { toolCallId, name, result: toolResult } = result; | ||||||
| @@ -373,10 +447,23 @@ export class ToolCallingStage extends BasePipelineStage<ToolExecutionInput, { re | |||||||
|                 ? toolResult |                 ? toolResult | ||||||
|                 : JSON.stringify(toolResult, null, 2); |                 : JSON.stringify(toolResult, null, 2); | ||||||
|  |  | ||||||
|  |             // Check if result is empty or unhelpful | ||||||
|  |             const isEmptyResult = this.isEmptyToolResult(toolResult, name); | ||||||
|  |             if (isEmptyResult && !resultContent.startsWith('Error:')) { | ||||||
|  |                 hasEmptyResults = true; | ||||||
|  |                 log.info(`Empty result detected for tool ${name}. Will add suggestion to try different parameters.`); | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // Add enhancement for empty results | ||||||
|  |             let enhancedContent = resultContent; | ||||||
|  |             if (isEmptyResult && !resultContent.startsWith('Error:')) { | ||||||
|  |                 enhancedContent = `${resultContent}\n\nNOTE: This tool returned no useful results with the provided parameters. Consider trying again with different parameters such as broader search terms, different filters, or alternative approaches.`; | ||||||
|  |             } | ||||||
|  |  | ||||||
|             // Add a new message for the tool result |             // Add a new message for the tool result | ||||||
|             const toolMessage: Message = { |             const toolMessage: Message = { | ||||||
|                 role: 'tool', |                 role: 'tool', | ||||||
|                 content: resultContent, |                 content: enhancedContent, | ||||||
|                 name: name, |                 name: name, | ||||||
|                 tool_call_id: toolCallId |                 tool_call_id: toolCallId | ||||||
|             }; |             }; | ||||||
| @@ -385,7 +472,7 @@ export class ToolCallingStage extends BasePipelineStage<ToolExecutionInput, { re | |||||||
|             log.info(`-------- Tool Result for ${name} (ID: ${toolCallId}) --------`); |             log.info(`-------- Tool Result for ${name} (ID: ${toolCallId}) --------`); | ||||||
|             log.info(`Result type: ${typeof toolResult}`); |             log.info(`Result type: ${typeof toolResult}`); | ||||||
|             log.info(`Result preview: ${resultContent.substring(0, 150)}${resultContent.length > 150 ? '...' : ''}`); |             log.info(`Result preview: ${resultContent.substring(0, 150)}${resultContent.length > 150 ? '...' : ''}`); | ||||||
|             log.info(`Tool result status: ${resultContent.startsWith('Error:') ? 'ERROR' : 'SUCCESS'}`); |             log.info(`Tool result status: ${resultContent.startsWith('Error:') ? 'ERROR' : isEmptyResult ? 'EMPTY' : 'SUCCESS'}`); | ||||||
|  |  | ||||||
|             updatedMessages.push(toolMessage); |             updatedMessages.push(toolMessage); | ||||||
|             toolResultMessages.push(toolMessage); |             toolResultMessages.push(toolMessage); | ||||||
| @@ -398,7 +485,36 @@ export class ToolCallingStage extends BasePipelineStage<ToolExecutionInput, { re | |||||||
|         const needsFollowUp = hasToolResults; |         const needsFollowUp = hasToolResults; | ||||||
|  |  | ||||||
|         log.info(`Follow-up needed: ${needsFollowUp}`); |         log.info(`Follow-up needed: ${needsFollowUp}`); | ||||||
|         log.info(`Reasoning: ${hasToolResults ? 'Has tool results to process' : 'No tool results'} ${hasErrors ? ', contains errors' : ''}`); |         log.info(`Reasoning: ${hasToolResults ? 'Has tool results to process' : 'No tool results'} ${hasErrors ? ', contains errors' : ''} ${hasEmptyResults ? ', contains empty results' : ''}`); | ||||||
|  |  | ||||||
|  |         // Add a system message with hints for empty results | ||||||
|  |         if (hasEmptyResults && needsFollowUp) { | ||||||
|  |             log.info('Adding system message requiring the LLM to run additional tools with different parameters'); | ||||||
|  |  | ||||||
|  |             // Build a more directive message based on which tools were empty | ||||||
|  |             const emptyToolNames = toolResultMessages | ||||||
|  |                 .filter(msg => this.isEmptyToolResult(msg.content, msg.name || '')) | ||||||
|  |                 .map(msg => msg.name); | ||||||
|  |  | ||||||
|  |             let directiveMessage = `YOU MUST NOT GIVE UP AFTER A SINGLE EMPTY SEARCH RESULT. `; | ||||||
|  |  | ||||||
|  |             if (emptyToolNames.includes('search_notes') || emptyToolNames.includes('vector_search')) { | ||||||
|  |                 directiveMessage += `IMMEDIATELY RUN ANOTHER SEARCH TOOL with broader search terms, alternative keywords, or related concepts. `; | ||||||
|  |                 directiveMessage += `Try synonyms, more general terms, or related topics. `; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (emptyToolNames.includes('keyword_search')) { | ||||||
|  |                 directiveMessage += `IMMEDIATELY TRY VECTOR_SEARCH INSTEAD as it might find semantic matches where keyword search failed. `; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             directiveMessage += `DO NOT ask the user what to do next or if they want general information. CONTINUE SEARCHING with different parameters.`; | ||||||
|  |  | ||||||
|  |             updatedMessages.push({ | ||||||
|  |                 role: 'system', | ||||||
|  |                 content: directiveMessage | ||||||
|  |             }); | ||||||
|  |         } | ||||||
|  |  | ||||||
|         log.info(`Total messages to return to pipeline: ${updatedMessages.length}`); |         log.info(`Total messages to return to pipeline: ${updatedMessages.length}`); | ||||||
|         log.info(`Last 3 messages in conversation:`); |         log.info(`Last 3 messages in conversation:`); | ||||||
|         const lastMessages = updatedMessages.slice(-3); |         const lastMessages = updatedMessages.slice(-3); | ||||||
| @@ -421,7 +537,7 @@ export class ToolCallingStage extends BasePipelineStage<ToolExecutionInput, { re | |||||||
|      * @param toolName The name of the tool requiring this dependency |      * @param toolName The name of the tool requiring this dependency | ||||||
|      * @returns The requested dependency or null if it couldn't be created |      * @returns The requested dependency or null if it couldn't be created | ||||||
|      */ |      */ | ||||||
|     private async getOrCreateDependency(dependencyType: string, toolName: string): Promise<any> { |     private async getOrCreateDependency(dependencyType: string, toolName: string): Promise<unknown | null> { | ||||||
|         const aiServiceManager = (await import('../../ai_service_manager.js')).default; |         const aiServiceManager = (await import('../../ai_service_manager.js')).default; | ||||||
|  |  | ||||||
|         try { |         try { | ||||||
| @@ -448,8 +564,9 @@ export class ToolCallingStage extends BasePipelineStage<ToolExecutionInput, { re | |||||||
|                         // Force initialization to ensure it runs even if previously marked as initialized |                         // Force initialization to ensure it runs even if previously marked as initialized | ||||||
|                         await agentTools.initialize(true); |                         await agentTools.initialize(true); | ||||||
|                         log.info('Agent tools initialized successfully'); |                         log.info('Agent tools initialized successfully'); | ||||||
|                     } catch (initError: any) { |                     } catch (initError: unknown) { | ||||||
|                         log.error(`Failed to initialize agent tools: ${initError.message}`); |                         const errorMessage = initError instanceof Error ? initError.message : String(initError); | ||||||
|  |                         log.error(`Failed to initialize agent tools: ${errorMessage}`); | ||||||
|                         return null; |                         return null; | ||||||
|                     } |                     } | ||||||
|                 } else { |                 } else { | ||||||
| @@ -474,8 +591,9 @@ export class ToolCallingStage extends BasePipelineStage<ToolExecutionInput, { re | |||||||
|             // Unknown dependency type |             // Unknown dependency type | ||||||
|             log.error(`Unknown dependency type: ${dependencyType}`); |             log.error(`Unknown dependency type: ${dependencyType}`); | ||||||
|             return null; |             return null; | ||||||
|         } catch (error: any) { |         } catch (error: unknown) { | ||||||
|             log.error(`Error getting or creating dependency '${dependencyType}': ${error.message}`); |             const errorMessage = error instanceof Error ? error.message : String(error); | ||||||
|  |             log.error(`Error getting or creating dependency '${dependencyType}': ${errorMessage}`); | ||||||
|             return null; |             return null; | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| @@ -485,7 +603,7 @@ export class ToolCallingStage extends BasePipelineStage<ToolExecutionInput, { re | |||||||
|      * @param tool The tool to validate |      * @param tool The tool to validate | ||||||
|      * @param toolName The name of the tool |      * @param toolName The name of the tool | ||||||
|      */ |      */ | ||||||
|     private async validateToolBeforeExecution(tool: any, toolName: string): Promise<boolean> { |     private async validateToolBeforeExecution(tool: ToolInterface, toolName: string): Promise<boolean> { | ||||||
|         try { |         try { | ||||||
|             if (!tool) { |             if (!tool) { | ||||||
|                 log.error(`Tool '${toolName}' not found or failed validation`); |                 log.error(`Tool '${toolName}' not found or failed validation`); | ||||||
| @@ -525,31 +643,164 @@ export class ToolCallingStage extends BasePipelineStage<ToolExecutionInput, { re | |||||||
|                                 return false; |                                 return false; | ||||||
|                             } |                             } | ||||||
|                             log.info('Successfully initialized vectorSearchTool'); |                             log.info('Successfully initialized vectorSearchTool'); | ||||||
|                         } catch (initError: any) { |                         } catch (initError: unknown) { | ||||||
|                             log.error(`Failed to initialize agent tools: ${initError.message}`); |                             const errorMessage = initError instanceof Error ? initError.message : String(initError); | ||||||
|  |                             log.error(`Failed to initialize agent tools: ${errorMessage}`); | ||||||
|                             return false; |                             return false; | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
|  |  | ||||||
|  |                     // Verify the vectorSearchTool has the required methods | ||||||
|                     if (!vectorSearchTool.searchNotes || typeof vectorSearchTool.searchNotes !== 'function') { |                     if (!vectorSearchTool.searchNotes || typeof vectorSearchTool.searchNotes !== 'function') { | ||||||
|                         log.error(`Tool '${toolName}' dependency vectorSearchTool is missing searchNotes method`); |                         log.error(`Tool '${toolName}' dependency vectorSearchTool is missing searchNotes method`); | ||||||
|                         return false; |                         return false; | ||||||
|                     } |                     } | ||||||
|                 } catch (error: any) { |                 } catch (error: unknown) { | ||||||
|                     log.error(`Error validating dependencies for tool '${toolName}': ${error.message}`); |                     const errorMessage = error instanceof Error ? error.message : String(error); | ||||||
|  |                     log.error(`Error validating dependencies for tool '${toolName}': ${errorMessage}`); | ||||||
|                     return false; |                     return false; | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             // Add additional tool-specific validations here |             // Add additional tool-specific validations here | ||||||
|  |  | ||||||
|             return true; |             return true; | ||||||
|         } catch (error: any) { |         } catch (error: unknown) { | ||||||
|             log.error(`Error validating tool before execution: ${error.message}`); |             const errorMessage = error instanceof Error ? error.message : String(error); | ||||||
|  |             log.error(`Error validating tool before execution: ${errorMessage}`); | ||||||
|             return false; |             return false; | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Generate guidance for the LLM when a tool fails or is not found | ||||||
|  |      * @param toolName The name of the tool that failed | ||||||
|  |      * @param errorMessage The error message from the failed tool | ||||||
|  |      * @returns A guidance message for the LLM with suggestions of what to try next | ||||||
|  |      */ | ||||||
|  |     private generateToolGuidance(toolName: string, errorMessage: string): string { | ||||||
|  |         // Get all available tool names for recommendations | ||||||
|  |         const availableTools = toolRegistry.getAllTools(); | ||||||
|  |         const availableToolNames = availableTools | ||||||
|  |             .map(t => { | ||||||
|  |                 if (t && typeof t === 'object' && 'definition' in t && | ||||||
|  |                     t.definition && typeof t.definition === 'object' && | ||||||
|  |                     'function' in t.definition && t.definition.function && | ||||||
|  |                     typeof t.definition.function === 'object' && | ||||||
|  |                     'name' in t.definition.function && | ||||||
|  |                     typeof t.definition.function.name === 'string') { | ||||||
|  |                     return t.definition.function.name; | ||||||
|  |                 } | ||||||
|  |                 return ''; | ||||||
|  |             }) | ||||||
|  |             .filter(name => name !== ''); | ||||||
|  |  | ||||||
|  |         // Create specific guidance based on the error and tool | ||||||
|  |         let guidance = `TOOL GUIDANCE: The tool '${toolName}' failed with error: ${errorMessage}.\n`; | ||||||
|  |  | ||||||
|  |         // Add suggestions based on the specific tool and error | ||||||
|  |         if (toolName === 'attribute_search' && errorMessage.includes('Invalid attribute type')) { | ||||||
|  |             guidance += "CRITICAL REQUIREMENT: The 'attribute_search' tool requires 'attributeType' parameter that must be EXACTLY 'label' or 'relation' (lowercase, no other values).\n"; | ||||||
|  |             guidance += "CORRECT EXAMPLE: { \"attributeType\": \"label\", \"attributeName\": \"important\", \"attributeValue\": \"yes\" }\n"; | ||||||
|  |             guidance += "INCORRECT EXAMPLE: { \"attributeType\": \"Label\", ... } - Case matters! Must be lowercase.\n"; | ||||||
|  |         } | ||||||
|  |         else if (errorMessage.includes('Tool not found')) { | ||||||
|  |             // Provide guidance on available search tools if a tool wasn't found | ||||||
|  |             const searchTools = availableToolNames.filter(name => name.includes('search')); | ||||||
|  |             guidance += `AVAILABLE SEARCH TOOLS: ${searchTools.join(', ')}\n`; | ||||||
|  |             guidance += "TRY VECTOR SEARCH: For conceptual matches, use 'vector_search' with a query parameter.\n"; | ||||||
|  |             guidance += "EXAMPLE: { \"query\": \"your search terms here\" }\n"; | ||||||
|  |         } | ||||||
|  |         else if (errorMessage.includes('missing required parameter')) { | ||||||
|  |             // Provide parameter guidance based on the tool name | ||||||
|  |             if (toolName === 'vector_search') { | ||||||
|  |                 guidance += "REQUIRED PARAMETERS: The 'vector_search' tool requires a 'query' parameter.\n"; | ||||||
|  |                 guidance += "EXAMPLE: { \"query\": \"your search terms here\" }\n"; | ||||||
|  |             } else if (toolName === 'keyword_search') { | ||||||
|  |                 guidance += "REQUIRED PARAMETERS: The 'keyword_search' tool requires a 'query' parameter.\n"; | ||||||
|  |                 guidance += "EXAMPLE: { \"query\": \"your search terms here\" }\n"; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Add a general suggestion to try vector_search as a fallback | ||||||
|  |         if (!toolName.includes('vector_search')) { | ||||||
|  |             guidance += "RECOMMENDATION: If specific searches fail, try the 'vector_search' tool which performs semantic searches.\n"; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return guidance; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Determines if a tool result is effectively empty or unhelpful | ||||||
|  |      * @param result The result from the tool execution | ||||||
|  |      * @param toolName The name of the tool that was executed | ||||||
|  |      * @returns true if the result is considered empty or unhelpful | ||||||
|  |      */ | ||||||
|  |     private isEmptyToolResult(result: unknown, toolName: string): boolean { | ||||||
|  |         // Handle string results | ||||||
|  |         if (typeof result === 'string') { | ||||||
|  |             const trimmed = result.trim(); | ||||||
|  |             if (trimmed === '' || trimmed === '[]' || trimmed === '{}') { | ||||||
|  |                 return true; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // Tool-specific empty results (for string responses) | ||||||
|  |             if (toolName === 'search_notes' && | ||||||
|  |                 (trimmed === 'No matching notes found.' || | ||||||
|  |                  trimmed.includes('No results found') || | ||||||
|  |                  trimmed.includes('No matches found') || | ||||||
|  |                  trimmed.includes('No notes found'))) { | ||||||
|  |                 // This is a valid result (empty, but valid), don't mark as empty so LLM can see feedback | ||||||
|  |                 return false; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (toolName === 'vector_search' && | ||||||
|  |                 (trimmed.includes('No results found') || | ||||||
|  |                  trimmed.includes('No matching documents'))) { | ||||||
|  |                 return true; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (toolName === 'keyword_search' && | ||||||
|  |                 (trimmed.includes('No matches found') || | ||||||
|  |                  trimmed.includes('No results for'))) { | ||||||
|  |                 return true; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         // Handle object/array results | ||||||
|  |         else if (result !== null && typeof result === 'object') { | ||||||
|  |             // Check if it's an empty array | ||||||
|  |             if (Array.isArray(result) && result.length === 0) { | ||||||
|  |                 return true; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // Check if it's an object with no meaningful properties | ||||||
|  |             // or with properties indicating empty results | ||||||
|  |             if (!Array.isArray(result)) { | ||||||
|  |                 if (Object.keys(result).length === 0) { | ||||||
|  |                     return true; | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 // Tool-specific object empty checks | ||||||
|  |                 const resultObj = result as Record<string, unknown>; | ||||||
|  |  | ||||||
|  |                 if (toolName === 'search_notes' && | ||||||
|  |                     'results' in resultObj && | ||||||
|  |                     Array.isArray(resultObj.results) && | ||||||
|  |                     resultObj.results.length === 0) { | ||||||
|  |                     return true; | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 if (toolName === 'vector_search' && | ||||||
|  |                     'matches' in resultObj && | ||||||
|  |                     Array.isArray(resultObj.matches) && | ||||||
|  |                     resultObj.matches.length === 0) { | ||||||
|  |                     return true; | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return false; | ||||||
|  |     } | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
|      * Preload the vector search tool to ensure it's available before tool execution |      * Preload the vector search tool to ensure it's available before tool execution | ||||||
|      */ |      */ | ||||||
| @@ -571,8 +822,9 @@ export class ToolCallingStage extends BasePipelineStage<ToolExecutionInput, { re | |||||||
|             } else { |             } else { | ||||||
|                 log.error(`Vector search tool not available after initialization`); |                 log.error(`Vector search tool not available after initialization`); | ||||||
|             } |             } | ||||||
|         } catch (error: any) { |         } catch (error: unknown) { | ||||||
|             log.error(`Failed to preload vector search tool: ${error.message}`); |             const errorMessage = error instanceof Error ? error.message : String(error); | ||||||
|  |             log.error(`Failed to preload vector search tool: ${errorMessage}`); | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -6,7 +6,7 @@ import type { ToolCall, Tool } from '../tools/tool_interfaces.js'; | |||||||
| import toolRegistry from '../tools/tool_registry.js'; | import toolRegistry from '../tools/tool_registry.js'; | ||||||
| import type { OllamaOptions } from './provider_options.js'; | import type { OllamaOptions } from './provider_options.js'; | ||||||
| import { getOllamaOptions } from './providers.js'; | import { getOllamaOptions } from './providers.js'; | ||||||
| import { Ollama, type ChatRequest, type ChatResponse as OllamaChatResponse } from 'ollama'; | import { Ollama, type ChatRequest } from 'ollama'; | ||||||
| import options from '../../options.js'; | import options from '../../options.js'; | ||||||
| import { | import { | ||||||
|     StreamProcessor, |     StreamProcessor, | ||||||
| @@ -144,14 +144,19 @@ export class OllamaService extends BaseAIService { | |||||||
|                 messagesToSend = [...messages]; |                 messagesToSend = [...messages]; | ||||||
|                 log.info(`Bypassing formatter for Ollama request with ${messages.length} messages`); |                 log.info(`Bypassing formatter for Ollama request with ${messages.length} messages`); | ||||||
|             } else { |             } else { | ||||||
|  |                 // Determine if tools will be used in this request | ||||||
|  |                 const willUseTools = providerOptions.enableTools !== false; | ||||||
|  |                  | ||||||
|                 // Use the formatter to prepare messages |                 // Use the formatter to prepare messages | ||||||
|                 messagesToSend = this.formatter.formatMessages( |                 messagesToSend = this.formatter.formatMessages( | ||||||
|                     messages, |                     messages, | ||||||
|                     systemPrompt, |                     systemPrompt, | ||||||
|                     undefined, // context |                     undefined, // context | ||||||
|                     providerOptions.preserveSystemPrompt |                     providerOptions.preserveSystemPrompt, | ||||||
|  |                     willUseTools // Pass flag indicating if tools will be used | ||||||
|                 ); |                 ); | ||||||
|                 log.info(`Sending to Ollama with formatted messages: ${messagesToSend.length}`); |                  | ||||||
|  |                 log.info(`Sending to Ollama with formatted messages: ${messagesToSend.length}${willUseTools ? ' (with tool instructions)' : ''}`); | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             // Get tools if enabled |             // Get tools if enabled | ||||||
| @@ -361,8 +366,15 @@ export class OllamaService extends BaseAIService { | |||||||
|                 }, |                 }, | ||||||
|                 async (callback) => { |                 async (callback) => { | ||||||
|                     let completeText = ''; |                     let completeText = ''; | ||||||
|                     let responseToolCalls: any[] = []; |  | ||||||
|                     let chunkCount = 0; |                     let chunkCount = 0; | ||||||
|  |                      | ||||||
|  |                     // Create a response object that will be updated during streaming | ||||||
|  |                     const response: ChatResponse = { | ||||||
|  |                         text: '', | ||||||
|  |                         model: providerOptions.model, | ||||||
|  |                         provider: this.getName(), | ||||||
|  |                         tool_calls: [] | ||||||
|  |                     }; | ||||||
|  |  | ||||||
|                     try { |                     try { | ||||||
|                         // Perform health check |                         // Perform health check | ||||||
| @@ -395,8 +407,10 @@ export class OllamaService extends BaseAIService { | |||||||
|  |  | ||||||
|                             // Extract any tool calls |                             // Extract any tool calls | ||||||
|                             const toolCalls = StreamProcessor.extractToolCalls(chunk); |                             const toolCalls = StreamProcessor.extractToolCalls(chunk); | ||||||
|  |                             // Update response tool calls if any are found | ||||||
|                             if (toolCalls.length > 0) { |                             if (toolCalls.length > 0) { | ||||||
|                                 responseToolCalls = toolCalls; |                                 // Update the response object's tool_calls for final return | ||||||
|  |                                 response.tool_calls = toolCalls; | ||||||
|                             } |                             } | ||||||
|  |  | ||||||
|                             // Send to callback - directly pass the content without accumulating |                             // Send to callback - directly pass the content without accumulating | ||||||
| @@ -433,35 +447,38 @@ export class OllamaService extends BaseAIService { | |||||||
|  |  | ||||||
|     /** |     /** | ||||||
|      * Transform Ollama tool calls to the standard format expected by the pipeline |      * Transform Ollama tool calls to the standard format expected by the pipeline | ||||||
|  |      * @param toolCalls Array of tool calls from Ollama response or undefined | ||||||
|  |      * @returns Standardized ToolCall array for consistent handling in the pipeline | ||||||
|      */ |      */ | ||||||
|     private transformToolCalls(toolCalls: any[] | undefined): ToolCall[] { |     private transformToolCalls(toolCalls: unknown[] | undefined): ToolCall[] { | ||||||
|         if (!toolCalls || !Array.isArray(toolCalls) || toolCalls.length === 0) { |         if (!toolCalls || !Array.isArray(toolCalls) || toolCalls.length === 0) { | ||||||
|             return []; |             return []; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         return toolCalls.map((toolCall, index) => { |         return toolCalls.map((toolCall, index) => { | ||||||
|  |             // Use type guards to safely access properties | ||||||
|  |             const toolCallObj = toolCall as { id?: string; function?: { name?: string; arguments?: string } }; | ||||||
|  |              | ||||||
|             // Generate a unique ID if none is provided |             // Generate a unique ID if none is provided | ||||||
|             const id = toolCall.id || `tool-call-${Date.now()}-${index}`; |             const id = typeof toolCallObj.id === 'string' ? toolCallObj.id : `tool-call-${Date.now()}-${index}`; | ||||||
|  |              | ||||||
|  |             // Safely extract function name and arguments with defaults | ||||||
|  |             const functionName = toolCallObj.function && typeof toolCallObj.function.name === 'string'  | ||||||
|  |                 ? toolCallObj.function.name  | ||||||
|  |                 : 'unknown_function'; | ||||||
|  |                  | ||||||
|  |             const functionArgs = toolCallObj.function && typeof toolCallObj.function.arguments === 'string' | ||||||
|  |                 ? toolCallObj.function.arguments | ||||||
|  |                 : '{}'; | ||||||
|  |  | ||||||
|             // Handle arguments based on their type |             // Return a properly typed ToolCall object | ||||||
|             let processedArguments: Record<string, any> | string = toolCall.function?.arguments || {}; |  | ||||||
|  |  | ||||||
|             if (typeof processedArguments === 'string') { |  | ||||||
|                 try { |  | ||||||
|                     processedArguments = JSON.parse(processedArguments); |  | ||||||
|                 } catch (error) { |  | ||||||
|                     // If we can't parse as JSON, create a simple object |  | ||||||
|                     log.info(`Could not parse tool arguments as JSON in transformToolCalls: ${error}`); |  | ||||||
|                     processedArguments = { raw: processedArguments }; |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             return { |             return { | ||||||
|                 id, |                 id, | ||||||
|                 type: 'function', |                 type: 'function', | ||||||
|                 function: { |                 function: { | ||||||
|                     name: toolCall.function?.name || '', |                     name: functionName, | ||||||
|                     arguments: processedArguments |                     arguments: functionArgs | ||||||
|                 } |                 } | ||||||
|             }; |             }; | ||||||
|         }); |         }); | ||||||
|   | |||||||
| @@ -3,6 +3,8 @@ import { BaseAIService } from '../base_ai_service.js'; | |||||||
| import type { ChatCompletionOptions, ChatResponse, Message, StreamChunk } from '../ai_interface.js'; | import type { ChatCompletionOptions, ChatResponse, Message, StreamChunk } from '../ai_interface.js'; | ||||||
| import { getOpenAIOptions } from './providers.js'; | import { getOpenAIOptions } from './providers.js'; | ||||||
| import OpenAI from 'openai'; | import OpenAI from 'openai'; | ||||||
|  | import { PROVIDER_PROMPTS } from '../constants/llm_prompt_constants.js'; | ||||||
|  | import log from '../../log.js'; | ||||||
|  |  | ||||||
| export class OpenAIService extends BaseAIService { | export class OpenAIService extends BaseAIService { | ||||||
|     private openai: OpenAI | null = null; |     private openai: OpenAI | null = null; | ||||||
| @@ -36,7 +38,17 @@ export class OpenAIService extends BaseAIService { | |||||||
|         // Initialize the OpenAI client |         // Initialize the OpenAI client | ||||||
|         const client = this.getClient(providerOptions.apiKey, providerOptions.baseUrl); |         const client = this.getClient(providerOptions.apiKey, providerOptions.baseUrl); | ||||||
|  |  | ||||||
|         const systemPrompt = this.getSystemPrompt(providerOptions.systemPrompt || options.getOption('aiSystemPrompt')); |         // Get base system prompt | ||||||
|  |         let systemPrompt = this.getSystemPrompt(providerOptions.systemPrompt || options.getOption('aiSystemPrompt')); | ||||||
|  |          | ||||||
|  |         // Check if tools are enabled for this request | ||||||
|  |         const willUseTools = providerOptions.enableTools && providerOptions.tools && providerOptions.tools.length > 0; | ||||||
|  |          | ||||||
|  |         // Add tool instructions to system prompt if tools are enabled | ||||||
|  |         if (willUseTools && PROVIDER_PROMPTS.OPENAI.TOOL_INSTRUCTIONS) { | ||||||
|  |             log.info('Adding tool instructions to system prompt for OpenAI'); | ||||||
|  |             systemPrompt = `${systemPrompt}\n\n${PROVIDER_PROMPTS.OPENAI.TOOL_INSTRUCTIONS}`; | ||||||
|  |         } | ||||||
|  |  | ||||||
|         // Ensure we have a system message |         // Ensure we have a system message | ||||||
|         const systemMessageExists = messages.some(m => m.role === 'system'); |         const systemMessageExists = messages.some(m => m.role === 'system'); | ||||||
| @@ -67,7 +79,7 @@ export class OpenAIService extends BaseAIService { | |||||||
|             } |             } | ||||||
|  |  | ||||||
|             // Log the request parameters |             // Log the request parameters | ||||||
|             console.log('OpenAI API Request:', JSON.stringify({ |             log.info(`OpenAI API Request: ${JSON.stringify({ | ||||||
|                 endpoint: 'chat.completions.create', |                 endpoint: 'chat.completions.create', | ||||||
|                 model: params.model, |                 model: params.model, | ||||||
|                 messages: params.messages, |                 messages: params.messages, | ||||||
| @@ -76,7 +88,7 @@ export class OpenAIService extends BaseAIService { | |||||||
|                 stream: params.stream, |                 stream: params.stream, | ||||||
|                 tools: params.tools, |                 tools: params.tools, | ||||||
|                 tool_choice: params.tool_choice |                 tool_choice: params.tool_choice | ||||||
|             }, null, 2)); |             }, null, 2)}`); | ||||||
|  |  | ||||||
|             // If streaming is requested |             // If streaming is requested | ||||||
|             if (providerOptions.stream) { |             if (providerOptions.stream) { | ||||||
| @@ -84,10 +96,10 @@ export class OpenAIService extends BaseAIService { | |||||||
|  |  | ||||||
|                 // Get stream from OpenAI SDK |                 // Get stream from OpenAI SDK | ||||||
|                 const stream = await client.chat.completions.create(params); |                 const stream = await client.chat.completions.create(params); | ||||||
|                 console.log('OpenAI API Stream Started'); |                 log.info('OpenAI API Stream Started'); | ||||||
|  |  | ||||||
|                 // Create a closure to hold accumulated tool calls |                 // Create a closure to hold accumulated tool calls | ||||||
|                 let accumulatedToolCalls: any[] = []; |                 const accumulatedToolCalls: OpenAI.Chat.ChatCompletionMessageToolCall[] = []; | ||||||
|  |  | ||||||
|                 // Return a response with the stream handler |                 // Return a response with the stream handler | ||||||
|                 const response: ChatResponse = { |                 const response: ChatResponse = { | ||||||
| @@ -104,7 +116,8 @@ export class OpenAIService extends BaseAIService { | |||||||
|                             if (Symbol.asyncIterator in stream) { |                             if (Symbol.asyncIterator in stream) { | ||||||
|                                 for await (const chunk of stream as AsyncIterable<OpenAI.Chat.ChatCompletionChunk>) { |                                 for await (const chunk of stream as AsyncIterable<OpenAI.Chat.ChatCompletionChunk>) { | ||||||
|                                     // Log each chunk received from OpenAI |                                     // Log each chunk received from OpenAI | ||||||
|                                     console.log('OpenAI API Stream Chunk:', JSON.stringify(chunk, null, 2)); |                                     // Use info level as debug is not available | ||||||
|  |                                     log.info(`OpenAI API Stream Chunk: ${JSON.stringify(chunk, null, 2)}`); | ||||||
|  |  | ||||||
|                                     const content = chunk.choices[0]?.delta?.content || ''; |                                     const content = chunk.choices[0]?.delta?.content || ''; | ||||||
|                                     const isDone = !!chunk.choices[0]?.finish_reason; |                                     const isDone = !!chunk.choices[0]?.finish_reason; | ||||||
|   | |||||||
| @@ -19,18 +19,18 @@ export const attributeSearchToolDefinition: Tool = { | |||||||
|     type: 'function', |     type: 'function', | ||||||
|     function: { |     function: { | ||||||
|         name: 'attribute_search', |         name: 'attribute_search', | ||||||
|         description: 'Search for notes with specific attributes (labels or relations). Use this when you need to find notes based on their metadata rather than content.', |         description: 'Search for notes with specific attributes (labels or relations). Use this when you need to find notes based on their metadata rather than content. IMPORTANT: attributeType must be exactly "label" or "relation" (lowercase).', | ||||||
|         parameters: { |         parameters: { | ||||||
|             type: 'object', |             type: 'object', | ||||||
|             properties: { |             properties: { | ||||||
|                 attributeType: { |                 attributeType: { | ||||||
|                     type: 'string', |                     type: 'string', | ||||||
|                     description: 'Type of attribute to search for: "label" or "relation"', |                     description: 'MUST be exactly "label" or "relation" (lowercase, no other values are valid)', | ||||||
|                     enum: ['label', 'relation'] |                     enum: ['label', 'relation'] | ||||||
|                 }, |                 }, | ||||||
|                 attributeName: { |                 attributeName: { | ||||||
|                     type: 'string', |                     type: 'string', | ||||||
|                     description: 'Name of the attribute to search for' |                     description: 'Name of the attribute to search for (e.g., "important", "todo", "related-to")' | ||||||
|                 }, |                 }, | ||||||
|                 attributeValue: { |                 attributeValue: { | ||||||
|                     type: 'string', |                     type: 'string', | ||||||
| @@ -63,7 +63,7 @@ export class AttributeSearchTool implements ToolHandler { | |||||||
|  |  | ||||||
|             // Validate attribute type |             // Validate attribute type | ||||||
|             if (attributeType !== 'label' && attributeType !== 'relation') { |             if (attributeType !== 'label' && attributeType !== 'relation') { | ||||||
|                 return `Error: Invalid attribute type. Must be either "label" or "relation".`; |                 return `Error: Invalid attribute type. Must be exactly "label" or "relation" (lowercase). You provided: "${attributeType}".`; | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             // Execute the search |             // Execute the search | ||||||
| @@ -133,7 +133,7 @@ export class AttributeSearchTool implements ToolHandler { | |||||||
|                         } else { |                         } else { | ||||||
|                             contentPreview = String(content).substring(0, 150) + (String(content).length > 150 ? '...' : ''); |                             contentPreview = String(content).substring(0, 150) + (String(content).length > 150 ? '...' : ''); | ||||||
|                         } |                         } | ||||||
|                     } catch (e) { |                     } catch (_) { | ||||||
|                         contentPreview = '[Content not available]'; |                         contentPreview = '[Content not available]'; | ||||||
|                     } |                     } | ||||||
|  |  | ||||||
| @@ -148,9 +148,10 @@ export class AttributeSearchTool implements ToolHandler { | |||||||
|                     }; |                     }; | ||||||
|                 }) |                 }) | ||||||
|             }; |             }; | ||||||
|         } catch (error: any) { |         } catch (error: unknown) { | ||||||
|             log.error(`Error executing attribute_search tool: ${error.message || String(error)}`); |             const errorMessage = error instanceof Error ? error.message : String(error); | ||||||
|             return `Error: ${error.message || String(error)}`; |             log.error(`Error executing attribute_search tool: ${errorMessage}`); | ||||||
|  |             return `Error: ${errorMessage}`; | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -17,17 +17,17 @@ export const searchNotesToolDefinition: Tool = { | |||||||
|     type: 'function', |     type: 'function', | ||||||
|     function: { |     function: { | ||||||
|         name: 'search_notes', |         name: 'search_notes', | ||||||
|         description: 'Search for notes in the database using semantic search. Returns notes most semantically related to the query.', |         description: 'Search for notes in the database using semantic search. Returns notes most semantically related to the query. Use specific, descriptive queries for best results.', | ||||||
|         parameters: { |         parameters: { | ||||||
|             type: 'object', |             type: 'object', | ||||||
|             properties: { |             properties: { | ||||||
|                 query: { |                 query: { | ||||||
|                     type: 'string', |                     type: 'string', | ||||||
|                     description: 'The search query to find semantically related notes' |                     description: 'The search query to find semantically related notes. Be specific and descriptive for best results.' | ||||||
|                 }, |                 }, | ||||||
|                 parentNoteId: { |                 parentNoteId: { | ||||||
|                     type: 'string', |                     type: 'string', | ||||||
|                     description: 'Optional system ID of the parent note to restrict search to a specific branch (not the title). This is a unique identifier like "abc123def456".' |                     description: 'Optional system ID of the parent note to restrict search to a specific branch (not the title). This is a unique identifier like "abc123def456". Do not use note titles here.' | ||||||
|                 }, |                 }, | ||||||
|                 maxResults: { |                 maxResults: { | ||||||
|                     type: 'number', |                     type: 'number', | ||||||
| @@ -142,11 +142,11 @@ export class SearchNotesTool implements ToolHandler { | |||||||
|                         const result = await llmService.generateChatCompletion(messages, { |                         const result = await llmService.generateChatCompletion(messages, { | ||||||
|                             temperature: 0.3, |                             temperature: 0.3, | ||||||
|                             maxTokens: 200, |                             maxTokens: 200, | ||||||
|                             // Use any to bypass the type checking for special parameters |                             // Type assertion to bypass type checking for special internal parameters | ||||||
|                             ...(({ |                             ...(({  | ||||||
|                                 bypassFormatter: true, |                                 bypassFormatter: true, | ||||||
|                                 bypassContextProcessing: true |                                 bypassContextProcessing: true | ||||||
|                             } as any)) |                             } as Record<string, boolean>)) | ||||||
|                         }); |                         }); | ||||||
|  |  | ||||||
|                         if (result && result.text) { |                         if (result && result.text) { | ||||||
| @@ -159,30 +159,33 @@ export class SearchNotesTool implements ToolHandler { | |||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             // Fall back to smart truncation if summarization fails or isn't requested |             try { | ||||||
|             const previewLength = Math.min(formattedContent.length, 600); |                 // Fall back to smart truncation if summarization fails or isn't requested | ||||||
|             let preview = formattedContent.substring(0, previewLength); |                 const previewLength = Math.min(formattedContent.length, 600); | ||||||
|  |                 let preview = formattedContent.substring(0, previewLength); | ||||||
|  |  | ||||||
|             // Only add ellipsis if we've truncated the content |                 // Only add ellipsis if we've truncated the content | ||||||
|             if (previewLength < formattedContent.length) { |                 if (previewLength < formattedContent.length) { | ||||||
|                 // Try to find a natural break point |                     // Try to find a natural break point | ||||||
|                 const breakPoints = ['. ', '.\n', '\n\n', '\n', '. ']; |                     const breakPoints = ['. ', '.\n', '\n\n', '\n', '. ']; | ||||||
|                 let breakFound = false; |  | ||||||
|  |  | ||||||
|                 for (const breakPoint of breakPoints) { |                     for (const breakPoint of breakPoints) { | ||||||
|                     const lastBreak = preview.lastIndexOf(breakPoint); |                         const lastBreak = preview.lastIndexOf(breakPoint); | ||||||
|                     if (lastBreak > previewLength * 0.6) { // At least 60% of the way through |                         if (lastBreak > previewLength * 0.6) { // At least 60% of the way through | ||||||
|                         preview = preview.substring(0, lastBreak + breakPoint.length); |                             preview = preview.substring(0, lastBreak + breakPoint.length); | ||||||
|                         breakFound = true; |                             break; | ||||||
|                         break; |                         } | ||||||
|                     } |                     } | ||||||
|  |  | ||||||
|  |                     // Add ellipsis if truncated | ||||||
|  |                     preview += '...'; | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|                 // Add ellipsis if truncated |                 return preview; | ||||||
|                 preview += '...'; |             } catch (error) { | ||||||
|  |                 log.error(`Error getting rich content preview: ${error}`); | ||||||
|  |                 return 'Error retrieving content preview'; | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             return preview; |  | ||||||
|         } catch (error) { |         } catch (error) { | ||||||
|             log.error(`Error getting rich content preview: ${error}`); |             log.error(`Error getting rich content preview: ${error}`); | ||||||
|             return 'Error retrieving content preview'; |             return 'Error retrieving content preview'; | ||||||
| @@ -226,11 +229,8 @@ export class SearchNotesTool implements ToolHandler { | |||||||
|             // Execute the search |             // Execute the search | ||||||
|             log.info(`Performing semantic search for: "${query}"`); |             log.info(`Performing semantic search for: "${query}"`); | ||||||
|             const searchStartTime = Date.now(); |             const searchStartTime = Date.now(); | ||||||
|             const results = await vectorSearchTool.searchNotes(query, { |             const response = await vectorSearchTool.searchNotes(query, parentNoteId, maxResults); | ||||||
|                 parentNoteId, |             const results: Array<Record<string, unknown>> = response?.matches ?? []; | ||||||
|                 maxResults |  | ||||||
|                 // Don't pass summarize - we'll handle it ourselves |  | ||||||
|             }); |  | ||||||
|             const searchDuration = Date.now() - searchStartTime; |             const searchDuration = Date.now() - searchStartTime; | ||||||
|  |  | ||||||
|             log.info(`Search completed in ${searchDuration}ms, found ${results.length} matching notes`); |             log.info(`Search completed in ${searchDuration}ms, found ${results.length} matching notes`); | ||||||
| @@ -247,12 +247,16 @@ export class SearchNotesTool implements ToolHandler { | |||||||
|             // Get enhanced previews for each result |             // Get enhanced previews for each result | ||||||
|             const enhancedResults = await Promise.all( |             const enhancedResults = await Promise.all( | ||||||
|                 results.map(async (result: any) => { |                 results.map(async (result: any) => { | ||||||
|                     const preview = await this.getRichContentPreview(result.noteId, summarize); |                     const noteId = result.noteId; | ||||||
|  |                     const preview = await this.getRichContentPreview(noteId, summarize); | ||||||
|  |  | ||||||
|                     return { |                     return { | ||||||
|                         noteId: result.noteId, |                         noteId: noteId, | ||||||
|                         title: result.title, |                         title: result?.title as string || '[Unknown title]', | ||||||
|                         preview: preview, |                         preview: preview, | ||||||
|  |                         score: result?.score as number, | ||||||
|  |                         dateCreated: result?.dateCreated as string, | ||||||
|  |                         dateModified: result?.dateModified as string, | ||||||
|                         similarity: Math.round(result.similarity * 100) / 100, |                         similarity: Math.round(result.similarity * 100) / 100, | ||||||
|                         parentId: result.parentId |                         parentId: result.parentId | ||||||
|                     }; |                     }; | ||||||
| @@ -260,14 +264,24 @@ export class SearchNotesTool implements ToolHandler { | |||||||
|             ); |             ); | ||||||
|  |  | ||||||
|             // Format the results |             // Format the results | ||||||
|             return { |             if (results.length === 0) { | ||||||
|                 count: enhancedResults.length, |                 return { | ||||||
|                 results: enhancedResults, |                     count: 0, | ||||||
|                 message: "Note: Use the noteId (not the title) when performing operations on specific notes with other tools." |                     results: [], | ||||||
|             }; |                     query: query, | ||||||
|         } catch (error: any) { |                     message: 'No notes found matching your query. Try using more general terms or try the keyword_search_notes tool with a different query. Note: Use the noteId (not the title) when performing operations on specific notes with other tools.' | ||||||
|             log.error(`Error executing search_notes tool: ${error.message || String(error)}`); |                 }; | ||||||
|             return `Error: ${error.message || String(error)}`; |             } else { | ||||||
|  |                 return { | ||||||
|  |                     count: enhancedResults.length, | ||||||
|  |                     results: enhancedResults, | ||||||
|  |                     message: "Note: Use the noteId (not the title) when performing operations on specific notes with other tools." | ||||||
|  |                 }; | ||||||
|  |             } | ||||||
|  |         } catch (error: unknown) { | ||||||
|  |             const errorMessage = error instanceof Error ? error.message : String(error); | ||||||
|  |             log.error(`Error executing search_notes tool: ${errorMessage}`); | ||||||
|  |             return `Error: ${errorMessage}`; | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										94
									
								
								apps/server/src/services/llm/utils/ai_exclusion_utils.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										94
									
								
								apps/server/src/services/llm/utils/ai_exclusion_utils.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,94 @@ | |||||||
|  | import becca from '../../../becca/becca.js'; | ||||||
|  | import type BNote from '../../../becca/entities/bnote.js'; | ||||||
|  | import { LLM_CONSTANTS } from '../constants/provider_constants.js'; | ||||||
|  | import log from '../../log.js'; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Check if a note should be excluded from all AI/LLM features | ||||||
|  |  * | ||||||
|  |  * @param note - The note to check (BNote object) | ||||||
|  |  * @returns true if the note should be excluded from AI features | ||||||
|  |  */ | ||||||
|  | export function isNoteExcludedFromAI(note: BNote): boolean { | ||||||
|  |     if (!note) { | ||||||
|  |         return false; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     try { | ||||||
|  |         // Check if the note has the AI exclusion label | ||||||
|  |         const hasExclusionLabel = note.hasLabel(LLM_CONSTANTS.AI_EXCLUSION.LABEL_NAME); | ||||||
|  |  | ||||||
|  |         if (hasExclusionLabel) { | ||||||
|  |             log.info(`Note ${note.noteId} (${note.title}) excluded from AI features due to ${LLM_CONSTANTS.AI_EXCLUSION.LABEL_NAME} label`); | ||||||
|  |             return true; | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return false; | ||||||
|  |     } catch (error) { | ||||||
|  |         log.error(`Error checking AI exclusion for note ${note.noteId}: ${error}`); | ||||||
|  |         return false; // Default to not excluding on error | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Check if a note should be excluded from AI features by noteId | ||||||
|  |  * | ||||||
|  |  * @param noteId - The ID of the note to check | ||||||
|  |  * @returns true if the note should be excluded from AI features | ||||||
|  |  */ | ||||||
|  | export function isNoteExcludedFromAIById(noteId: string): boolean { | ||||||
|  |     if (!noteId) { | ||||||
|  |         return false; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     try { | ||||||
|  |         const note = becca.getNote(noteId); | ||||||
|  |         if (!note) { | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |         return isNoteExcludedFromAI(note); | ||||||
|  |     } catch (error) { | ||||||
|  |         log.error(`Error checking AI exclusion for note ID ${noteId}: ${error}`); | ||||||
|  |         return false; // Default to not excluding on error | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Filter out notes that are excluded from AI features | ||||||
|  |  * | ||||||
|  |  * @param notes - Array of notes to filter | ||||||
|  |  * @returns Array of notes with AI-excluded notes removed | ||||||
|  |  */ | ||||||
|  | export function filterAIExcludedNotes(notes: BNote[]): BNote[] { | ||||||
|  |     return notes.filter(note => !isNoteExcludedFromAI(note)); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Filter out note IDs that are excluded from AI features | ||||||
|  |  * | ||||||
|  |  * @param noteIds - Array of note IDs to filter | ||||||
|  |  * @returns Array of note IDs with AI-excluded notes removed | ||||||
|  |  */ | ||||||
|  | export function filterAIExcludedNoteIds(noteIds: string[]): string[] { | ||||||
|  |     return noteIds.filter(noteId => !isNoteExcludedFromAIById(noteId)); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Check if any notes in an array are excluded from AI features | ||||||
|  |  * | ||||||
|  |  * @param notes - Array of notes to check | ||||||
|  |  * @returns true if any note should be excluded from AI features | ||||||
|  |  */ | ||||||
|  | export function hasAIExcludedNotes(notes: BNote[]): boolean { | ||||||
|  |     return notes.some(note => isNoteExcludedFromAI(note)); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Get the AI exclusion label name from constants | ||||||
|  |  * This can be used in UI components or other places that need to reference the label | ||||||
|  |  * | ||||||
|  |  * @returns The label name used for AI exclusion | ||||||
|  |  */ | ||||||
|  | export function getAIExclusionLabelName(): string { | ||||||
|  |     return LLM_CONSTANTS.AI_EXCLUSION.LABEL_NAME; | ||||||
|  | } | ||||||
| @@ -205,7 +205,7 @@ const defaultOptions: DefaultOption[] = [ | |||||||
|     { name: "anthropicBaseUrl", value: "https://api.anthropic.com/v1", isSynced: true }, |     { name: "anthropicBaseUrl", value: "https://api.anthropic.com/v1", isSynced: true }, | ||||||
|     { name: "ollamaEnabled", value: "false", isSynced: true }, |     { name: "ollamaEnabled", value: "false", isSynced: true }, | ||||||
|     { name: "ollamaDefaultModel", value: "llama3", isSynced: true }, |     { name: "ollamaDefaultModel", value: "llama3", isSynced: true }, | ||||||
|     { name: "ollamaBaseUrl", value: "", isSynced: true }, |     { name: "ollamaBaseUrl", value: "http://localhost:11434", isSynced: true }, | ||||||
|     { name: "ollamaEmbeddingModel", value: "nomic-embed-text", isSynced: true }, |     { name: "ollamaEmbeddingModel", value: "nomic-embed-text", isSynced: true }, | ||||||
|     { name: "embeddingAutoUpdateEnabled", value: "true", isSynced: true }, |     { name: "embeddingAutoUpdateEnabled", value: "true", isSynced: true }, | ||||||
|  |  | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user