b7b3a185a0
Implement `ensureFolderExists` in `ToolExecutor` to create parent directories when creating, moving, or renaming notes. Update `ChatView` to properly persist agent mode and derive session titles from the first user message. Fix error handling in tool execution to return structured failures instead of null, and include failure details in follow-up messages.
2015 lines
66 KiB
TypeScript
Executable File
2015 lines
66 KiB
TypeScript
Executable File
import { ItemView, Notice, WorkspaceLeaf } from 'obsidian';
|
|
import {
|
|
ALL_AGENT_MODES,
|
|
getAgentModeLabel,
|
|
getSystemPromptForMode,
|
|
filterToolsForMode,
|
|
modeRequiresPreview,
|
|
} from './agent-modes';
|
|
import { OllamaClient } from './ollama-client';
|
|
import { VaultIndexer } from './vault-indexer';
|
|
import { VaultVectorStore } from './vault-vector-store';
|
|
import { ToolExecutor } from './tool-executor';
|
|
import { ActionPreviewBuilder, isWriteTool } from './action-preview-builder';
|
|
import { WorkflowEngine } from './workflow-engine';
|
|
import { NoteContextBuilder } from './note-context-builder';
|
|
import {
|
|
AgentMode,
|
|
PluginSettings,
|
|
OllamaMessage,
|
|
OllamaTool,
|
|
OllamaToolCall,
|
|
ChatMessage,
|
|
ProposedAction,
|
|
ToolResult,
|
|
ChatSession,
|
|
} from './types';
|
|
import { ConversationStateManager } from './conversation-state';
|
|
import { ErrorHandler } from './error-handler';
|
|
import { StructuredMemoryManager } from './structured-memory';
|
|
import { TelemetryManager } from './tool-telemetry';
|
|
import { ChatHistoryManager } from './chat-history';
|
|
import { Logger, LogEntry } from './utils';
|
|
|
|
export const VIEW_TYPE_OLLAMA_CHAT = 'ollama-chat-view';
|
|
|
|
export class ChatView extends ItemView {
|
|
// Getters for testing
|
|
getSendButtonClickHandler() {
|
|
return this.sendButtonClickHandler;
|
|
}
|
|
|
|
getInputKeyDownHandler() {
|
|
return this.inputKeyDownHandler;
|
|
}
|
|
|
|
getNewChatButtonClickHandler() {
|
|
return this.newChatButtonClickHandler;
|
|
}
|
|
|
|
getHistorySelectEl() {
|
|
return this.historySelectEl;
|
|
}
|
|
|
|
constructor(
|
|
leaf: WorkspaceLeaf,
|
|
settings: PluginSettings,
|
|
vectorStore?: VaultVectorStore,
|
|
structuredMemoryManager?: StructuredMemoryManager,
|
|
telemetryManager?: TelemetryManager,
|
|
chatHistoryManager?: ChatHistoryManager,
|
|
onModelChange?: (model: string) => void
|
|
) {
|
|
super(leaf);
|
|
this.messages = [];
|
|
this.lastMessageEl = null;
|
|
this.newChatButton = null;
|
|
this.sendButton = null;
|
|
this.inputEl = null;
|
|
this.chatContainer = null;
|
|
this.sendButtonClickHandler = null;
|
|
this.inputKeyDownHandler = null;
|
|
this.newChatButtonClickHandler = null;
|
|
this.sendButtonClickWrapper = null;
|
|
this.inputKeyDownWrapper = null;
|
|
this.newChatButtonClickWrapper = null;
|
|
this.listenersAttached = false;
|
|
this.settings = settings;
|
|
this.currentAgentMode = settings.agentMode ?? 'ask';
|
|
this.ollamaClient = this.createOllamaClient(settings.chatModel ?? settings.model, settings);
|
|
this.agentOllamaClient =
|
|
(settings.agentModel ?? settings.model) === (settings.chatModel ?? settings.model)
|
|
? this.ollamaClient
|
|
: this.createOllamaClient(settings.agentModel ?? settings.model, settings);
|
|
this.vaultIndexer = new VaultIndexer(this.app.vault, undefined, vectorStore);
|
|
this.vaultIndexer.setApp(this.app);
|
|
this.toolExecutor = new ToolExecutor(
|
|
this.app.vault,
|
|
this.app,
|
|
telemetryManager,
|
|
this.vaultIndexer
|
|
);
|
|
this.actionPreviewBuilder = new ActionPreviewBuilder(this.app.vault, this.app);
|
|
this.noteContextBuilder = new NoteContextBuilder(this.app.vault, this.app, this.vaultIndexer);
|
|
this.conversationStateManager = new ConversationStateManager(
|
|
getSystemPromptForMode(this.currentAgentMode)
|
|
);
|
|
this.structuredMemoryManager = structuredMemoryManager;
|
|
this.telemetryManager = telemetryManager;
|
|
this.chatHistoryManager = chatHistoryManager;
|
|
this.onModelChange = onModelChange;
|
|
this.workflowEngine = new WorkflowEngine(
|
|
this.app.vault,
|
|
this.app,
|
|
settings.ollamaUrl,
|
|
settings.agentModel ?? settings.model,
|
|
{ cacheConfig: settings.cacheConfig }
|
|
);
|
|
|
|
// Subscribe to plugin logs
|
|
this.removeLogListener = Logger.addListener((entry) => {
|
|
if (this.logsVisible && this.logsContainer) {
|
|
this.appendLogEntry(entry);
|
|
}
|
|
});
|
|
|
|
// Restore active session if available
|
|
this.restoreActiveSession();
|
|
}
|
|
|
|
updateSettings(newSettings: PluginSettings) {
|
|
this.settings = newSettings;
|
|
this.currentAgentMode = newSettings.agentMode ?? 'ask';
|
|
if (this.modeSelectorEl) {
|
|
this.modeSelectorEl.value = this.currentAgentMode;
|
|
}
|
|
this.ollamaClient = this.createOllamaClient(
|
|
newSettings.chatModel ?? newSettings.model,
|
|
newSettings
|
|
);
|
|
this.agentOllamaClient =
|
|
(newSettings.agentModel ?? newSettings.model) === (newSettings.chatModel ?? newSettings.model)
|
|
? this.ollamaClient
|
|
: this.createOllamaClient(newSettings.agentModel ?? newSettings.model, newSettings);
|
|
// Refresh model dropdown so it reflects the newly saved model
|
|
void this.populateModelDropdown();
|
|
this.workflowEngine = new WorkflowEngine(
|
|
this.app.vault,
|
|
this.app,
|
|
newSettings.ollamaUrl,
|
|
newSettings.agentModel ?? newSettings.model,
|
|
{ cacheConfig: newSettings.cacheConfig }
|
|
);
|
|
this.conversationStateManager.setSystemPrompt(getSystemPromptForMode(this.currentAgentMode));
|
|
void this.initializeClientCaches().catch(() => {
|
|
new Notice(
|
|
'Semantic cache unavailable: could not connect to ChromaDB. Check the ChromaDB URL in settings.'
|
|
);
|
|
});
|
|
}
|
|
|
|
setVectorStore(vectorStore: VaultVectorStore | undefined) {
|
|
this.vectorStore = vectorStore;
|
|
this.vaultIndexer.setVectorStore(vectorStore);
|
|
}
|
|
|
|
async clearCache(): Promise<void> {
|
|
await this.ollamaClient.clearCache();
|
|
if (this.agentOllamaClient !== this.ollamaClient) {
|
|
await this.agentOllamaClient.clearCache();
|
|
}
|
|
}
|
|
|
|
getViewType(): string {
|
|
return 'ollama-chat-view';
|
|
}
|
|
|
|
getDisplayText(): string {
|
|
return 'Ollama Chat';
|
|
}
|
|
|
|
getIcon(): string {
|
|
return 'bot';
|
|
}
|
|
|
|
async onOpen(): Promise<void> {
|
|
try {
|
|
await this.initializeClientCaches();
|
|
} catch {
|
|
new Notice(
|
|
'Semantic cache unavailable: could not connect to ChromaDB. Check the ChromaDB URL in settings.'
|
|
);
|
|
}
|
|
this.render();
|
|
this.removeEventListeners(); // Clean up any existing listeners before reattaching
|
|
this.setupEventListeners();
|
|
}
|
|
|
|
onSettingsChange(newSettings: PluginSettings): void {
|
|
this.updateSettings(newSettings);
|
|
}
|
|
|
|
async onClose(): Promise<void> {
|
|
this.saveCurrentSession();
|
|
this.ollamaClient.cancelStream();
|
|
this.removeEventListeners();
|
|
this.cleanupStreamingResources();
|
|
if (this.removeLogListener) {
|
|
this.removeLogListener();
|
|
this.removeLogListener = null;
|
|
}
|
|
if (this.logsContainer && this.logsContainer.parentElement) {
|
|
this.logsContainer.parentElement.removeChild(this.logsContainer);
|
|
}
|
|
this.lastMessageEl = null;
|
|
this.sendButton = null;
|
|
this.stopButton = null;
|
|
this.inputEl = null;
|
|
this.chatContainer = null;
|
|
this.showLogsButton = null;
|
|
this.logsContainer = null;
|
|
this.modelSelectorEl = null;
|
|
this.historySelectEl = null;
|
|
this.historyDeleteButton = null;
|
|
return Promise.resolve();
|
|
}
|
|
|
|
cleanupStreamingResources(): void {
|
|
// Only cleanup if there's still an active streaming message
|
|
const streamingMessage = this.messages.find((msg) => msg.isStreaming);
|
|
if (streamingMessage && this.lastMessageEl && this.lastMessageEl.parentElement) {
|
|
this.lastMessageEl.parentElement.removeChild(this.lastMessageEl);
|
|
this.lastMessageEl = null;
|
|
}
|
|
}
|
|
|
|
render(): void {
|
|
const container =
|
|
this.chatContainer || this.contentEl.createEl('div', { cls: 'ollama-chat-container' });
|
|
this.chatContainer = container;
|
|
const inputContainer =
|
|
this.contentEl.querySelector('.ollama-input-container') ||
|
|
this.contentEl.createEl('div', { cls: 'ollama-input-container' });
|
|
const newChatContainer =
|
|
this.contentEl.querySelector('.ollama-new-chat-container') ||
|
|
this.contentEl.createEl('div', { cls: 'ollama-new-chat-container' });
|
|
|
|
const messagesSnapshot = [...this.messages];
|
|
const nonStreamingMessages = messagesSnapshot.filter((msg) => !msg.isStreaming);
|
|
const existingMessages = container.querySelectorAll('.ollama-message');
|
|
|
|
// Remove messages that are no longer in the array
|
|
for (const el of Array.from(existingMessages)) {
|
|
const id = el.getAttribute('data-msg-id');
|
|
if (!id || !nonStreamingMessages.some((m) => m.id === id)) {
|
|
el.remove();
|
|
}
|
|
}
|
|
|
|
// Render non-streaming messages
|
|
for (const msg of nonStreamingMessages) {
|
|
const existingEl = container.querySelector(`.ollama-message[data-msg-id="${msg.id}"]`);
|
|
if (existingEl) {
|
|
const contentEl = existingEl.querySelector('.ollama-message-content');
|
|
if (contentEl) {
|
|
if (msg.isThinking) {
|
|
contentEl.innerHTML = '<span class="ollama-thinking-indicator">Thinking…</span>';
|
|
} else {
|
|
contentEl.textContent = msg.content;
|
|
}
|
|
}
|
|
} else {
|
|
const messageEl = container.createEl('div', {
|
|
cls: `ollama-message ollama-message-${msg.role}`,
|
|
});
|
|
messageEl.setAttribute('data-msg-id', msg.id);
|
|
|
|
const headerEl = messageEl.createEl('div', { cls: 'ollama-message-header' });
|
|
headerEl.createEl('span', { cls: 'ollama-message-role', text: msg.role });
|
|
|
|
const contentEl = messageEl.createEl('div', { cls: 'ollama-message-content' });
|
|
if (msg.isThinking) {
|
|
contentEl.innerHTML = '<span class="ollama-thinking-indicator">Thinking…</span>';
|
|
} else {
|
|
contentEl.textContent = msg.content;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Re-attach streaming message if it exists
|
|
const streamingMessage = messagesSnapshot.find((msg) => msg.isStreaming);
|
|
if (streamingMessage && this.lastMessageEl) {
|
|
const existingStreamingEl = container.querySelector(
|
|
`.ollama-message[data-msg-id="${streamingMessage.id}"]`
|
|
);
|
|
if (!existingStreamingEl) {
|
|
container.appendChild(this.lastMessageEl);
|
|
}
|
|
}
|
|
|
|
// Setup model selector
|
|
if (!this.modelSelectorEl) {
|
|
this.modelSelectorEl = newChatContainer.createEl('select', {
|
|
cls: 'ollama-model-selector',
|
|
});
|
|
this.modelSelectorEl.addEventListener('change', () => {
|
|
const selectedModel = this.modelSelectorEl!.value;
|
|
this.ollamaClient.setModel(selectedModel);
|
|
if (this.agentOllamaClient !== this.ollamaClient) {
|
|
this.agentOllamaClient.setModel(selectedModel);
|
|
}
|
|
this.onModelChange?.(selectedModel);
|
|
});
|
|
}
|
|
this.populateModelDropdown();
|
|
newChatContainer.appendChild(this.modelSelectorEl);
|
|
|
|
// Setup mode selector
|
|
if (!this.modeSelectorEl) {
|
|
this.modeSelectorEl = newChatContainer.createEl('select', {
|
|
cls: 'ollama-mode-selector',
|
|
});
|
|
for (const mode of ALL_AGENT_MODES) {
|
|
const option = this.modeSelectorEl.createEl('option', {
|
|
text: getAgentModeLabel(mode),
|
|
attr: { value: mode },
|
|
});
|
|
if (mode === this.currentAgentMode) {
|
|
option.setAttribute('selected', 'selected');
|
|
}
|
|
}
|
|
this.modeSelectorEl.addEventListener('change', () => {
|
|
this.setAgentMode(this.modeSelectorEl!.value as AgentMode);
|
|
});
|
|
} else {
|
|
newChatContainer.appendChild(this.modeSelectorEl);
|
|
}
|
|
|
|
// Setup chat history selector
|
|
if (!this.historySelectEl) {
|
|
this.historySelectEl = newChatContainer.createEl('select', {
|
|
cls: 'ollama-history-selector',
|
|
});
|
|
this.historySelectEl.addEventListener('change', () => {
|
|
const selectedId = this.historySelectEl!.value;
|
|
if (selectedId === '__new__') {
|
|
this.clearConversation();
|
|
} else if (selectedId) {
|
|
const session = this.chatHistoryManager?.getSession(selectedId);
|
|
if (session) {
|
|
this.chatHistoryManager?.setActiveSessionId(selectedId);
|
|
this.loadSession(session);
|
|
this.render();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
this.populateHistoryDropdown();
|
|
newChatContainer.appendChild(this.historySelectEl);
|
|
|
|
// Setup new chat button
|
|
if (!this.newChatButton) {
|
|
this.newChatButton = newChatContainer.createEl('button', {
|
|
cls: 'ollama-new-chat-button',
|
|
text: 'New Chat',
|
|
});
|
|
} else {
|
|
newChatContainer.appendChild(this.newChatButton);
|
|
}
|
|
|
|
// Setup delete history button
|
|
if (!this.historyDeleteButton) {
|
|
this.historyDeleteButton = newChatContainer.createEl('button', {
|
|
cls: 'ollama-history-delete-button',
|
|
text: 'Delete',
|
|
});
|
|
this.historyDeleteButton.addEventListener('click', () => {
|
|
const selectedId = this.historySelectEl?.value;
|
|
if (selectedId && selectedId !== '__new__') {
|
|
const deleted = this.chatHistoryManager?.deleteSession(selectedId);
|
|
if (deleted) {
|
|
new Notice('Chat deleted.');
|
|
this.clearConversation();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
newChatContainer.appendChild(this.historyDeleteButton);
|
|
|
|
// Setup show-logs toggle button
|
|
if (!this.showLogsButton) {
|
|
this.showLogsButton = newChatContainer.createEl('button', {
|
|
cls: 'ollama-show-logs-button',
|
|
text: this.logsVisible ? 'Hide Logs' : 'Show Logs',
|
|
});
|
|
} else {
|
|
this.showLogsButton.textContent = this.logsVisible ? 'Hide Logs' : 'Show Logs';
|
|
newChatContainer.appendChild(this.showLogsButton);
|
|
}
|
|
|
|
// Setup / toggle logs container
|
|
if (this.logsVisible) {
|
|
if (!this.logsContainer) {
|
|
this.logsContainer = this.contentEl.createEl('div', { cls: 'ollama-logs-container' });
|
|
// Populate with existing history
|
|
for (const entry of Logger.getHistory()) {
|
|
this.renderLogEntry(entry, this.logsContainer);
|
|
}
|
|
}
|
|
this.contentEl.insertBefore(this.logsContainer, inputContainer);
|
|
this.scrollLogsToBottom();
|
|
} else if (this.logsContainer && this.logsContainer.parentElement) {
|
|
this.logsContainer.parentElement.removeChild(this.logsContainer);
|
|
}
|
|
|
|
// Setup input area
|
|
if (!this.inputEl) {
|
|
this.inputEl = inputContainer.createEl('textarea', {
|
|
cls: 'ollama-input',
|
|
attr: { placeholder: 'Type your message...', rows: '3' },
|
|
});
|
|
} else {
|
|
inputContainer.appendChild(this.inputEl);
|
|
}
|
|
|
|
// Setup activity indicator
|
|
if (!this.activityIndicatorEl) {
|
|
this.activityIndicatorEl = inputContainer.createEl('div', {
|
|
cls: 'ollama-activity-indicator',
|
|
});
|
|
this.activityIndicatorEl.createEl('span', { cls: 'ollama-activity-dot' });
|
|
this.activityIndicatorEl.createEl('span', {
|
|
cls: 'ollama-activity-text',
|
|
text: '',
|
|
});
|
|
this.hideActivityIndicator();
|
|
} else {
|
|
inputContainer.appendChild(this.activityIndicatorEl);
|
|
}
|
|
|
|
// Setup send button
|
|
if (!this.sendButton) {
|
|
this.sendButton = inputContainer.createEl('button', {
|
|
cls: 'ollama-send-button',
|
|
text: 'Send',
|
|
});
|
|
} else {
|
|
inputContainer.appendChild(this.sendButton);
|
|
}
|
|
|
|
// Setup stop button
|
|
if (!this.stopButton) {
|
|
this.stopButton = inputContainer.createEl('button', {
|
|
cls: 'ollama-stop-button',
|
|
text: 'Stop',
|
|
});
|
|
this.stopButton.addEventListener('click', () => {
|
|
this.cancelCurrentOperation();
|
|
});
|
|
}
|
|
inputContainer.appendChild(this.stopButton);
|
|
this.stopButton.style.display = 'none';
|
|
|
|
// Append containers to contentEl
|
|
if (this.contentEl.addClass) {
|
|
this.contentEl.addClass('ollama-chat-view-content');
|
|
} else {
|
|
this.contentEl.classList?.add('ollama-chat-view-content');
|
|
}
|
|
this.contentEl.appendChild(newChatContainer);
|
|
this.contentEl.appendChild(container);
|
|
this.contentEl.appendChild(inputContainer);
|
|
|
|
// Auto-scroll chat to bottom if enabled
|
|
if (this.shouldAutoScroll && container) {
|
|
container.scrollTop = container.scrollHeight;
|
|
}
|
|
|
|
// Focus input on open
|
|
this.inputEl.focus();
|
|
}
|
|
|
|
setupEventListeners(): void {
|
|
if (this.listenersAttached) {
|
|
return;
|
|
}
|
|
|
|
this.sendButtonClickHandler = () => {
|
|
void this.handleUserInput(this.inputEl?.value);
|
|
};
|
|
|
|
this.inputKeyDownHandler = (event: KeyboardEvent) => {
|
|
if (event.key === 'Enter' && !event.shiftKey) {
|
|
event.preventDefault();
|
|
void this.handleUserInput(this.inputEl?.value);
|
|
}
|
|
};
|
|
|
|
this.newChatButtonClickHandler = () => {
|
|
this.clearConversation();
|
|
};
|
|
|
|
this.showLogsClickHandler = () => {
|
|
this.logsVisible = !this.logsVisible;
|
|
this.render();
|
|
};
|
|
|
|
if (this.sendButton && this.sendButtonClickHandler) {
|
|
this.sendButton.addEventListener('click', this.sendButtonClickHandler);
|
|
}
|
|
|
|
if (this.inputEl && this.inputKeyDownHandler) {
|
|
this.inputEl.addEventListener('keydown', this.inputKeyDownHandler);
|
|
}
|
|
|
|
if (this.newChatButton && this.newChatButtonClickHandler) {
|
|
this.newChatButton.addEventListener('click', this.newChatButtonClickHandler);
|
|
}
|
|
|
|
if (this.showLogsButton && this.showLogsClickHandler) {
|
|
this.showLogsButton.addEventListener('click', this.showLogsClickHandler);
|
|
}
|
|
|
|
// Chat container scroll listener for auto-scroll toggle
|
|
if (this.chatContainer) {
|
|
this.chatScrollHandler = () => {
|
|
if (!this.chatContainer) return;
|
|
const { scrollTop, scrollHeight, clientHeight } = this.chatContainer;
|
|
const isNearBottom = scrollHeight - scrollTop - clientHeight < 50;
|
|
this.shouldAutoScroll = isNearBottom;
|
|
};
|
|
this.chatContainer.addEventListener('scroll', this.chatScrollHandler);
|
|
}
|
|
|
|
this.listenersAttached = true;
|
|
}
|
|
|
|
removeEventListeners(): void {
|
|
if (!this.listenersAttached) {
|
|
return;
|
|
}
|
|
|
|
if (this.sendButton && this.sendButtonClickHandler) {
|
|
this.sendButton.removeEventListener('click', this.sendButtonClickHandler);
|
|
}
|
|
|
|
if (this.inputEl && this.inputKeyDownHandler) {
|
|
this.inputEl.removeEventListener('keydown', this.inputKeyDownHandler);
|
|
}
|
|
|
|
if (this.newChatButton && this.newChatButtonClickHandler) {
|
|
this.newChatButton.removeEventListener('click', this.newChatButtonClickHandler);
|
|
}
|
|
|
|
if (this.showLogsButton && this.showLogsClickHandler) {
|
|
this.showLogsButton.removeEventListener('click', this.showLogsClickHandler);
|
|
}
|
|
|
|
if (this.chatContainer && this.chatScrollHandler) {
|
|
this.chatContainer.removeEventListener('scroll', this.chatScrollHandler);
|
|
}
|
|
|
|
this.listenersAttached = false;
|
|
}
|
|
|
|
getAgentMode(): AgentMode {
|
|
return this.currentAgentMode;
|
|
}
|
|
|
|
setAgentMode(mode: AgentMode): void {
|
|
this.currentAgentMode = mode;
|
|
if (this.modeSelectorEl) {
|
|
this.modeSelectorEl.value = mode;
|
|
}
|
|
this.conversationStateManager.setSystemPrompt(getSystemPromptForMode(mode));
|
|
const activeId = this.chatHistoryManager?.getActiveSessionId();
|
|
if (activeId) {
|
|
this.chatHistoryManager?.updateSession(activeId, { agentMode: mode });
|
|
}
|
|
}
|
|
|
|
clearConversation(): void {
|
|
// Save the current session before clearing
|
|
this.saveCurrentSession();
|
|
this.messages = [];
|
|
this.conversationStateManager.clear(getSystemPromptForMode(this.currentAgentMode));
|
|
// Create a new session for the fresh conversation
|
|
this.chatHistoryManager?.createSession(this.currentAgentMode);
|
|
this.render();
|
|
}
|
|
|
|
private restoreActiveSession(): void {
|
|
if (!this.chatHistoryManager) return;
|
|
const activeId = this.chatHistoryManager.getActiveSessionId();
|
|
if (activeId) {
|
|
const session = this.chatHistoryManager.getSession(activeId);
|
|
if (session) {
|
|
this.loadSession(session);
|
|
return;
|
|
}
|
|
}
|
|
// No active session: create one
|
|
this.chatHistoryManager.createSession(this.currentAgentMode);
|
|
}
|
|
|
|
private loadSession(session: ChatSession): void {
|
|
this.messages = [...session.messages];
|
|
this.currentAgentMode = session.agentMode;
|
|
this.conversationStateManager.clear(getSystemPromptForMode(this.currentAgentMode));
|
|
// Rebuild conversation state from messages
|
|
for (const msg of this.messages) {
|
|
if (msg.role === 'user' || msg.role === 'assistant') {
|
|
this.conversationStateManager.updateShortTermContext({
|
|
role: msg.role,
|
|
content: msg.content,
|
|
});
|
|
}
|
|
}
|
|
if (this.modeSelectorEl) {
|
|
this.modeSelectorEl.value = this.currentAgentMode;
|
|
}
|
|
}
|
|
|
|
private saveCurrentSession(): void {
|
|
if (!this.chatHistoryManager) return;
|
|
const activeId = this.chatHistoryManager.getActiveSessionId();
|
|
if (!activeId) return;
|
|
const nonStreamingMessages = this.messages.filter((msg) => !msg.isStreaming);
|
|
this.chatHistoryManager.updateSession(activeId, {
|
|
messages: nonStreamingMessages,
|
|
agentMode: this.currentAgentMode,
|
|
title: this.deriveSessionTitle(nonStreamingMessages),
|
|
});
|
|
}
|
|
|
|
private syncMessagesToSession(): void {
|
|
if (!this.chatHistoryManager) return;
|
|
const activeId = this.chatHistoryManager.getActiveSessionId();
|
|
if (!activeId) return;
|
|
const nonStreamingMessages = this.messages.filter((msg) => !msg.isStreaming);
|
|
this.chatHistoryManager.updateSession(activeId, {
|
|
messages: nonStreamingMessages,
|
|
agentMode: this.currentAgentMode,
|
|
title: this.deriveSessionTitle(nonStreamingMessages),
|
|
});
|
|
}
|
|
|
|
private deriveSessionTitle(messages: ChatMessage[]): string {
|
|
const firstUser = messages.find((m) => m.role === 'user');
|
|
const text = firstUser?.content.trim() ?? '';
|
|
if (!text) return 'New Chat';
|
|
return text.length > 40 ? text.slice(0, 40) + '…' : text;
|
|
}
|
|
|
|
private populateHistoryDropdown(): void {
|
|
if (!this.historySelectEl) return;
|
|
const previousValue = this.historySelectEl.value;
|
|
this.historySelectEl.innerHTML = '';
|
|
|
|
// New Chat option
|
|
const newOption = this.historySelectEl.createEl('option', {
|
|
text: 'New Chat',
|
|
attr: { value: '__new__' },
|
|
});
|
|
|
|
const sessions = this.chatHistoryManager?.getSessions() ?? [];
|
|
const activeId = this.chatHistoryManager?.getActiveSessionId();
|
|
|
|
for (const session of sessions) {
|
|
const option = this.historySelectEl.createEl('option', {
|
|
text: session.title,
|
|
attr: { value: session.id },
|
|
});
|
|
if (session.id === activeId) {
|
|
option.setAttribute('selected', 'selected');
|
|
}
|
|
}
|
|
|
|
// If the previously selected value is still valid, keep it; otherwise select active or New Chat
|
|
if (previousValue && sessions.some((s) => s.id === previousValue)) {
|
|
this.historySelectEl.value = previousValue;
|
|
} else if (activeId) {
|
|
this.historySelectEl.value = activeId;
|
|
} else {
|
|
this.historySelectEl.value = '__new__';
|
|
}
|
|
}
|
|
|
|
private async populateModelDropdown(): Promise<void> {
|
|
if (!this.modelSelectorEl) return;
|
|
const previousValue = this.modelSelectorEl.value;
|
|
this.modelSelectorEl.innerHTML = '';
|
|
|
|
const models = await this.ollamaClient.listModels();
|
|
if (!this.modelSelectorEl) return; // Guard: view may have closed while fetching
|
|
const currentModel = this.ollamaClient.getModel();
|
|
|
|
if (models.length === 0) {
|
|
// Fallback: use the configured model name if API is unavailable
|
|
const option = this.modelSelectorEl.createEl('option', {
|
|
text: currentModel,
|
|
attr: { value: currentModel },
|
|
});
|
|
option.setAttribute('selected', 'selected');
|
|
return;
|
|
}
|
|
|
|
for (const model of models) {
|
|
if (!this.modelSelectorEl) return;
|
|
const displayName = model.name;
|
|
const option = this.modelSelectorEl.createEl('option', {
|
|
text: displayName,
|
|
attr: { value: model.name },
|
|
});
|
|
if (model.name === currentModel || model.name === previousValue) {
|
|
option.setAttribute('selected', 'selected');
|
|
}
|
|
}
|
|
|
|
if (!this.modelSelectorEl) return;
|
|
// If the previously selected value is still valid, keep it
|
|
if (previousValue && models.some((m) => m.name === previousValue)) {
|
|
this.modelSelectorEl.value = previousValue;
|
|
} else if (models.some((m) => m.name === currentModel)) {
|
|
this.modelSelectorEl.value = currentModel;
|
|
}
|
|
}
|
|
|
|
updateMessageById(id: string, updates: Partial<ChatMessage>): void {
|
|
const index = this.messages.findIndex((m) => m.id === id);
|
|
if (index !== -1) {
|
|
this.messages[index] = { ...this.messages[index], ...updates };
|
|
this.render();
|
|
this.syncMessagesToSession();
|
|
}
|
|
}
|
|
|
|
updateLastMessage(updates: Partial<ChatMessage>): void {
|
|
const streamingMessage = this.messages.find((msg) => msg.isStreaming);
|
|
if (streamingMessage) {
|
|
const index = this.messages.findIndex((msg) => msg.id === streamingMessage.id);
|
|
if (index !== -1) {
|
|
this.messages[index] = { ...this.messages[index], ...updates };
|
|
this.render();
|
|
this.syncMessagesToSession();
|
|
}
|
|
}
|
|
}
|
|
|
|
getTools(): OllamaTool[] {
|
|
const allTools: OllamaTool[] = [
|
|
{
|
|
type: 'function',
|
|
function: {
|
|
name: 'create_note',
|
|
description:
|
|
'Creates a new note in the vault at the specified path with the given content',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
path: {
|
|
type: 'string',
|
|
description: 'The path to the new note (e.g., "Projects/My Note.md")',
|
|
},
|
|
content: {
|
|
type: 'string',
|
|
description: 'The markdown content for the new note',
|
|
},
|
|
},
|
|
required: ['path', 'content'],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: 'function',
|
|
function: {
|
|
name: 'read_vault_file',
|
|
description: 'Reads the content of a file from the vault',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
path: {
|
|
type: 'string',
|
|
description: 'The path to the file to read',
|
|
},
|
|
},
|
|
required: ['path'],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: 'function',
|
|
function: {
|
|
name: 'search_vault_files',
|
|
description:
|
|
'Searches for files in the vault by content, tags, titles, and headings using a given query. Returns matching file metadata including paths, titles, and tags.',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
query: {
|
|
type: 'string',
|
|
description: 'The search query to use (can be a keyword, tag, or phrase)',
|
|
},
|
|
limit: {
|
|
type: 'number',
|
|
description: 'The maximum number of results to return',
|
|
},
|
|
},
|
|
required: ['query'],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: 'function',
|
|
function: {
|
|
name: 'append_to_note',
|
|
description: 'Appends content to the end of an existing note',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
path: {
|
|
type: 'string',
|
|
description: 'The path to the note',
|
|
},
|
|
content: {
|
|
type: 'string',
|
|
description: 'The content to append',
|
|
},
|
|
},
|
|
required: ['path', 'content'],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: 'function',
|
|
function: {
|
|
name: 'replace_note_section',
|
|
description: 'Replaces the body of a section under the specified heading in a note',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
path: {
|
|
type: 'string',
|
|
description: 'The path to the note',
|
|
},
|
|
heading: {
|
|
type: 'string',
|
|
description: 'The heading text of the section to replace',
|
|
},
|
|
content: {
|
|
type: 'string',
|
|
description: 'The new content for the section (heading will be preserved)',
|
|
},
|
|
},
|
|
required: ['path', 'heading', 'content'],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: 'function',
|
|
function: {
|
|
name: 'update_frontmatter',
|
|
description:
|
|
'Updates YAML frontmatter fields in a note. Adds, updates, or removes fields.',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
path: {
|
|
type: 'string',
|
|
description: 'The path to the note',
|
|
},
|
|
fields: {
|
|
type: 'object',
|
|
description:
|
|
'An object of frontmatter key-value pairs to set. Use null to remove a field.',
|
|
},
|
|
},
|
|
required: ['path', 'fields'],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: 'function',
|
|
function: {
|
|
name: 'rename_note',
|
|
description: 'Renames a note to a new path within the vault',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
oldPath: {
|
|
type: 'string',
|
|
description: 'The current path to the note',
|
|
},
|
|
newPath: {
|
|
type: 'string',
|
|
description: 'The new path for the note',
|
|
},
|
|
},
|
|
required: ['oldPath', 'newPath'],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: 'function',
|
|
function: {
|
|
name: 'move_note',
|
|
description: 'Moves a note into a different folder',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
path: {
|
|
type: 'string',
|
|
description: 'The current path to the note',
|
|
},
|
|
folder: {
|
|
type: 'string',
|
|
description: 'The target folder path (e.g., "Projects"). Use "" for vault root.',
|
|
},
|
|
},
|
|
required: ['path', 'folder'],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: 'function',
|
|
function: {
|
|
name: 'delete_note',
|
|
description: 'Deletes a note from the vault',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
path: {
|
|
type: 'string',
|
|
description: 'The path to the note to delete',
|
|
},
|
|
},
|
|
required: ['path'],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: 'function',
|
|
function: {
|
|
name: 'list_vault_tags',
|
|
description:
|
|
'Lists all unique tags used across the vault with usage counts. Use this when the user asks about tags, tag organization, or wants to see all tags. Do NOT use search_vault_files for tag listing.',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
sortBy: {
|
|
type: 'string',
|
|
description:
|
|
'Sort by "name" (alphabetical) or "count" (most used first). Default: "name"',
|
|
enum: ['name', 'count'],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: 'function',
|
|
function: {
|
|
name: 'get_vault_stats',
|
|
description:
|
|
'Returns an overview of the vault: total notes, folder structure, tag distribution, average note length, and recent files. Use this when the user asks about vault structure, folder organization, or wants a high-level overview before making organizational suggestions.',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: 'function',
|
|
function: {
|
|
name: 'insert_link',
|
|
description: 'Inserts a wikilink to another note at the end of a source note',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
sourcePath: {
|
|
type: 'string',
|
|
description: 'The path to the note that will contain the link',
|
|
},
|
|
targetPath: {
|
|
type: 'string',
|
|
description: 'The path to the note being linked to',
|
|
},
|
|
anchorText: {
|
|
type: 'string',
|
|
description: 'Optional display text for the link',
|
|
},
|
|
},
|
|
required: ['sourcePath', 'targetPath'],
|
|
},
|
|
},
|
|
},
|
|
];
|
|
return filterToolsForMode(allTools, this.currentAgentMode);
|
|
}
|
|
|
|
/**
|
|
* Build messages for the LLM, injecting structured memory context if available.
|
|
* This is the canonical message builder used for all LLM calls in this view.
|
|
*/
|
|
private buildMessagesWithMemory(baseMessages: OllamaMessage[]): OllamaMessage[] {
|
|
const memoryContext = this.structuredMemoryManager?.buildMemoryContext();
|
|
if (memoryContext) {
|
|
return [{ role: 'system', content: memoryContext }, ...baseMessages];
|
|
}
|
|
return baseMessages;
|
|
}
|
|
|
|
async processToolCalls(
|
|
toolCalls: OllamaToolCall[],
|
|
messages: OllamaMessage[],
|
|
tools: OllamaTool[],
|
|
fullResponse: string,
|
|
assistantMessageId: string,
|
|
depth = 0
|
|
): Promise<void> {
|
|
if (depth >= MAX_TOOL_CALL_DEPTH) {
|
|
this.updateMessageById(assistantMessageId, {
|
|
content: fullResponse || '(No response)',
|
|
isStreaming: false,
|
|
isThinking: false,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const readToolCalls = toolCalls.filter((tc) => !isWriteTool(tc.function?.name ?? ''));
|
|
const writeToolCalls = toolCalls.filter((tc) => isWriteTool(tc.function?.name ?? ''));
|
|
|
|
// Execute read/search tools immediately
|
|
const readResults = (
|
|
await Promise.all(
|
|
readToolCalls.slice(0, MAX_TOOL_CALLS).map(async (toolCall) => {
|
|
try {
|
|
const toolResult = await this.toolExecutor.handleToolCall(toolCall);
|
|
return { ...toolResult, id: toolCall.id };
|
|
} catch (error) {
|
|
ErrorHandler.handleError(error, 'ChatView.handleUserInput');
|
|
return null;
|
|
}
|
|
})
|
|
)
|
|
).filter((result): result is NonNullable<typeof result> => result !== null);
|
|
|
|
// Build previews for write tools
|
|
const writePreviews: ProposedAction[] = [];
|
|
for (const toolCall of writeToolCalls.slice(0, MAX_TOOL_CALLS)) {
|
|
try {
|
|
const preview = await this.actionPreviewBuilder.buildPreview(toolCall);
|
|
writePreviews.push(preview);
|
|
} catch (error) {
|
|
ErrorHandler.handleError(error, 'ChatView.handleUserInput');
|
|
}
|
|
}
|
|
|
|
if (writePreviews.length > 0 && modeRequiresPreview(this.currentAgentMode)) {
|
|
// Store pending state for apply/cancel
|
|
this.pendingActions = writePreviews;
|
|
this.pendingReadResults = readResults;
|
|
this.pendingFollowUpContext = { messages, tools, assistantMessageId, allToolCalls: toolCalls, assistantText: fullResponse };
|
|
|
|
this.updateMessageById(assistantMessageId, {
|
|
content: `${fullResponse}\n\n*Proposed actions:*\n${writePreviews.map((a) => `- ${a.description}`).join('\n')}`,
|
|
isStreaming: false,
|
|
isThinking: false,
|
|
});
|
|
|
|
this.render();
|
|
this.renderActionPreviews(assistantMessageId);
|
|
return;
|
|
}
|
|
|
|
// If mode does not require preview, execute write tools immediately
|
|
let writeResults: (ToolResult & { id?: string })[] = [];
|
|
if (writePreviews.length > 0 && !modeRequiresPreview(this.currentAgentMode)) {
|
|
writeResults = (
|
|
await Promise.all(
|
|
writePreviews.map(async (action) => {
|
|
try {
|
|
const toolResult = await this.toolExecutor.handleToolCall(action.toolCall);
|
|
return { ...toolResult, id: action.toolCall.id };
|
|
} catch (error) {
|
|
ErrorHandler.handleError(error, 'ChatView.processToolCalls');
|
|
return {
|
|
success: false,
|
|
message: error instanceof Error ? error.message : String(error),
|
|
id: action.toolCall.id,
|
|
};
|
|
}
|
|
})
|
|
)
|
|
);
|
|
}
|
|
|
|
// No write tools (or they were already executed) — proceed with follow-up
|
|
const allResults = [...readResults, ...writeResults];
|
|
const followUpMessages: OllamaMessage[] = allResults.map((result) => ({
|
|
role: 'tool',
|
|
content: JSON.stringify(result),
|
|
tool_call_id: result.id ?? '',
|
|
}));
|
|
|
|
const followUp: OllamaMessage = {
|
|
role: 'assistant',
|
|
content: '',
|
|
tool_calls: toolCalls,
|
|
};
|
|
|
|
if (followUpMessages.length > 0) {
|
|
const finalMessages = [...messages, followUp, ...followUpMessages];
|
|
const followUpStartTime = Date.now();
|
|
const response = await this.getActiveOllamaClient().chat(finalMessages, tools);
|
|
const followUpDurationMs = Date.now() - followUpStartTime;
|
|
const followUpContent = response.content ?? '';
|
|
const followUpToolCalls = response.tool_calls ?? [];
|
|
|
|
// Record follow-up LLM call telemetry
|
|
this.telemetryManager?.recordLlmCall({
|
|
model: this.getActiveModel(),
|
|
promptTokens: Math.round(finalMessages.reduce((sum, m) => sum + m.content.length, 0) / 4),
|
|
completionTokens: Math.round(followUpContent.length / 4),
|
|
totalTokens: Math.round(
|
|
(finalMessages.reduce((sum, m) => sum + m.content.length, 0) + followUpContent.length) / 4
|
|
),
|
|
durationMs: followUpDurationMs,
|
|
});
|
|
|
|
if (followUpToolCalls.length > 0) {
|
|
// LLM wants another round of tool use — recurse rather than discarding these calls
|
|
this.showActivityIndicator('Using tools…');
|
|
await this.processToolCalls(
|
|
followUpToolCalls,
|
|
finalMessages,
|
|
tools,
|
|
followUpContent,
|
|
assistantMessageId,
|
|
depth + 1
|
|
);
|
|
} else {
|
|
this.updateMessageById(assistantMessageId, {
|
|
content: followUpContent || fullResponse || '(No response)',
|
|
isStreaming: false,
|
|
isThinking: false,
|
|
});
|
|
}
|
|
} else {
|
|
// No tool results to follow up on — all tools failed or produced no output
|
|
this.updateMessageById(assistantMessageId, {
|
|
content: fullResponse || 'No tool results to report.',
|
|
isStreaming: false,
|
|
isThinking: false,
|
|
});
|
|
}
|
|
}
|
|
|
|
async applyPendingActions(): Promise<void> {
|
|
if (this.pendingActions.length === 0 || !this.pendingFollowUpContext) {
|
|
return;
|
|
}
|
|
|
|
const { messages, tools, assistantMessageId, allToolCalls, assistantText } = this.pendingFollowUpContext;
|
|
|
|
// Execute write tools
|
|
const writeResults = (
|
|
await Promise.all(
|
|
this.pendingActions.map(async (action) => {
|
|
try {
|
|
const toolResult = await this.toolExecutor.handleToolCall(action.toolCall);
|
|
return { ...toolResult, id: action.toolCall.id };
|
|
} catch (error) {
|
|
ErrorHandler.handleError(error, 'ChatView.applyPendingActions');
|
|
return {
|
|
success: false,
|
|
message: error instanceof Error ? error.message : String(error),
|
|
id: action.toolCall.id,
|
|
};
|
|
}
|
|
})
|
|
)
|
|
);
|
|
|
|
const allResults = [...this.pendingReadResults, ...writeResults];
|
|
const failedWrites = writeResults.filter((result) => !result.success);
|
|
|
|
const followUpMessages: OllamaMessage[] = allResults.map((result) => ({
|
|
role: 'tool',
|
|
content: JSON.stringify(result),
|
|
tool_call_id: result.id ?? '',
|
|
}));
|
|
|
|
const followUp: OllamaMessage = {
|
|
role: 'assistant',
|
|
content: assistantText,
|
|
tool_calls: allToolCalls,
|
|
};
|
|
|
|
if (followUpMessages.length > 0) {
|
|
const finalMessages = [...messages, followUp, ...followUpMessages];
|
|
const followUpStartTime = Date.now();
|
|
const response = await this.getActiveOllamaClient().chat(finalMessages, tools);
|
|
const followUpDurationMs = Date.now() - followUpStartTime;
|
|
const followUpContent = response.content ?? '';
|
|
const followUpToolCalls = response.tool_calls ?? [];
|
|
|
|
// Record follow-up LLM call telemetry
|
|
this.telemetryManager?.recordLlmCall({
|
|
model: this.getActiveModel(),
|
|
promptTokens: Math.round(finalMessages.reduce((sum, m) => sum + m.content.length, 0) / 4),
|
|
completionTokens: Math.round(followUpContent.length / 4),
|
|
totalTokens: Math.round(
|
|
(finalMessages.reduce((sum, m) => sum + m.content.length, 0) + followUpContent.length) / 4
|
|
),
|
|
durationMs: followUpDurationMs,
|
|
});
|
|
|
|
this.clearPendingActions();
|
|
|
|
if (followUpToolCalls.length > 0) {
|
|
// LLM wants more tool calls after applying — hand off to processToolCalls
|
|
this.showActivityIndicator('Using tools…');
|
|
await this.processToolCalls(
|
|
followUpToolCalls,
|
|
finalMessages,
|
|
tools,
|
|
followUpContent,
|
|
assistantMessageId,
|
|
1
|
|
);
|
|
} else {
|
|
this.updateMessageById(assistantMessageId, {
|
|
content:
|
|
followUpContent ||
|
|
(failedWrites.length > 0
|
|
? `Some actions failed:\n${failedWrites.map((r) => `- ${r.message}`).join('\n')}`
|
|
: 'Actions applied successfully.'),
|
|
isStreaming: false,
|
|
isThinking: false,
|
|
});
|
|
}
|
|
} else {
|
|
this.updateMessageById(assistantMessageId, {
|
|
content:
|
|
failedWrites.length > 0
|
|
? `Actions failed:\n${failedWrites.map((r) => `- ${r.message}`).join('\n')}`
|
|
: 'Actions applied successfully.',
|
|
isStreaming: false,
|
|
isThinking: false,
|
|
});
|
|
this.clearPendingActions();
|
|
}
|
|
|
|
this.render();
|
|
}
|
|
|
|
cancelPendingActions(): void {
|
|
if (this.pendingActions.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const context = this.pendingFollowUpContext;
|
|
if (context) {
|
|
this.updateMessageById(context.assistantMessageId, {
|
|
content: 'Actions cancelled. No changes were made.',
|
|
isStreaming: false,
|
|
isThinking: false,
|
|
});
|
|
}
|
|
|
|
this.clearPendingActions();
|
|
this.render();
|
|
}
|
|
|
|
private renderActionPreviews(assistantMessageId: string): void {
|
|
const messageEl = this.chatContainer?.querySelector(
|
|
`.ollama-message[data-msg-id="${assistantMessageId}"]`
|
|
);
|
|
if (!messageEl) {
|
|
return;
|
|
}
|
|
|
|
// Remove existing preview
|
|
const existing = messageEl.querySelector('.ollama-proposed-actions');
|
|
existing?.remove();
|
|
|
|
const container = messageEl.createEl('div', { cls: 'ollama-proposed-actions' });
|
|
container.createEl('div', {
|
|
cls: 'ollama-proposed-actions-header',
|
|
text: 'Proposed Actions',
|
|
});
|
|
|
|
for (const action of this.pendingActions) {
|
|
const card = container.createEl('div', { cls: 'ollama-proposed-action' });
|
|
card.createEl('div', { cls: 'ollama-action-description', text: action.description });
|
|
|
|
if (action.preview) {
|
|
const diffEl = card.createEl('div', { cls: 'ollama-action-diff' });
|
|
if (action.preview.before !== undefined) {
|
|
diffEl.createEl('pre', {
|
|
cls: 'ollama-diff-before',
|
|
text: `Before:\n${action.preview.before.slice(0, 500)}`,
|
|
});
|
|
}
|
|
if (action.preview.after !== undefined) {
|
|
diffEl.createEl('pre', {
|
|
cls: 'ollama-diff-after',
|
|
text: `After:\n${action.preview.after.slice(0, 500)}`,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
const buttonContainer = container.createEl('div', { cls: 'ollama-action-buttons' });
|
|
const applyBtn = buttonContainer.createEl('button', {
|
|
cls: 'ollama-apply-button',
|
|
text: 'Apply All',
|
|
});
|
|
const cancelBtn = buttonContainer.createEl('button', {
|
|
cls: 'ollama-cancel-button',
|
|
text: 'Cancel',
|
|
});
|
|
|
|
applyBtn.addEventListener('click', () => {
|
|
void this.applyPendingActions();
|
|
});
|
|
|
|
cancelBtn.addEventListener('click', () => {
|
|
this.cancelPendingActions();
|
|
});
|
|
}
|
|
|
|
private clearPendingActions(): void {
|
|
this.pendingActions = [];
|
|
this.pendingReadResults = [];
|
|
this.pendingFollowUpContext = null;
|
|
this.chatContainer?.querySelectorAll('.ollama-proposed-actions').forEach((el) => el.remove());
|
|
}
|
|
|
|
private formatWorkflowResult(result: {
|
|
workflowName: string;
|
|
success: boolean;
|
|
stepResults: { stepName: string; success: boolean; data: unknown; error?: string }[];
|
|
finalOutput: unknown;
|
|
error?: string;
|
|
}): string {
|
|
const lines: string[] = [];
|
|
lines.push(`## ${result.workflowName}`);
|
|
lines.push('');
|
|
|
|
if (result.stepResults.length > 0) {
|
|
lines.push('**Steps:**');
|
|
for (const step of result.stepResults) {
|
|
const status = step.success ? '✅' : '❌';
|
|
lines.push(`${status} **${step.stepName}**`);
|
|
if (!step.success && step.error) {
|
|
lines.push(` Error: ${step.error}`);
|
|
}
|
|
}
|
|
lines.push('');
|
|
}
|
|
|
|
if (result.error) {
|
|
lines.push(`**Workflow Error:** ${result.error}`);
|
|
lines.push('');
|
|
}
|
|
|
|
if (result.finalOutput) {
|
|
lines.push('**Result:**');
|
|
if (typeof result.finalOutput === 'string') {
|
|
lines.push(result.finalOutput);
|
|
} else {
|
|
lines.push(JSON.stringify(result.finalOutput, null, 2));
|
|
}
|
|
}
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
async handleWorkflowRequest(query: string, assistantMessageId: string): Promise<void> {
|
|
try {
|
|
this.updateMessageById(assistantMessageId, {
|
|
content: '🔄 Generating workflow plan...',
|
|
isStreaming: false,
|
|
isThinking: false,
|
|
});
|
|
|
|
const result = await this.workflowEngine.executeWorkflowFromQuery(query, this.getTools());
|
|
|
|
const formatted = this.formatWorkflowResult({
|
|
workflowName: result.workflowName,
|
|
success: result.success,
|
|
stepResults: result.stepResults.map((sr) => ({
|
|
stepName: sr.stepName,
|
|
success: sr.success,
|
|
data: sr.data,
|
|
error: sr.error,
|
|
})),
|
|
finalOutput: result.finalOutput,
|
|
error: result.error,
|
|
});
|
|
|
|
this.updateMessageById(assistantMessageId, {
|
|
content: formatted,
|
|
isStreaming: false,
|
|
isThinking: false,
|
|
});
|
|
|
|
this.conversationStateManager.updateShortTermContext({
|
|
role: 'assistant',
|
|
content: formatted,
|
|
});
|
|
} catch (error) {
|
|
ErrorHandler.handleError(error, 'ChatView.handleWorkflowRequest');
|
|
this.updateMessageById(assistantMessageId, {
|
|
content: 'An error occurred while executing the workflow.',
|
|
isStreaming: false,
|
|
});
|
|
}
|
|
}
|
|
|
|
async handleUserInput(inputValue?: string): Promise<void> {
|
|
const userMessage = (inputValue ?? this.inputEl?.value ?? '').trim();
|
|
if (!userMessage) {
|
|
return;
|
|
}
|
|
|
|
const isWorkflowCommand = userMessage.toLowerCase().startsWith('/workflow');
|
|
const actualMessage = isWorkflowCommand
|
|
? userMessage.slice('/workflow'.length).trim()
|
|
: userMessage;
|
|
|
|
const maxContextLength = this.settings.maxContextLength;
|
|
const tools = this.getTools();
|
|
const messageId = crypto.randomUUID();
|
|
const userMessageId = `${messageId}-user`;
|
|
const assistantMessageId = `${messageId}-assistant`;
|
|
|
|
const userChatMessage: ChatMessage = {
|
|
id: userMessageId,
|
|
role: 'user',
|
|
content: actualMessage,
|
|
timestamp: Date.now(),
|
|
};
|
|
|
|
const assistantMessage: ChatMessage = {
|
|
id: assistantMessageId,
|
|
role: 'assistant',
|
|
content: '',
|
|
timestamp: Date.now(),
|
|
isStreaming: true,
|
|
isThinking: true,
|
|
};
|
|
|
|
const previousStreamingEl = this.lastMessageEl;
|
|
this.messages = [...this.messages, userChatMessage, assistantMessage];
|
|
this.render();
|
|
if (this.inputEl) {
|
|
this.inputEl.value = '';
|
|
}
|
|
|
|
// Add the assistant message to the DOM to enable streaming
|
|
this.lastMessageEl =
|
|
this.chatContainer?.querySelector(`.ollama-message[data-msg-id="${assistantMessageId}"]`) ??
|
|
null;
|
|
if (!this.lastMessageEl && previousStreamingEl) {
|
|
previousStreamingEl.classList.add('ollama-message');
|
|
previousStreamingEl.setAttribute('data-msg-id', assistantMessageId);
|
|
this.contentEl.appendChild(previousStreamingEl);
|
|
this.lastMessageEl = previousStreamingEl;
|
|
}
|
|
|
|
if (isWorkflowCommand) {
|
|
this.showActivityIndicator('Running workflow…');
|
|
await this.handleWorkflowRequest(actualMessage, assistantMessageId);
|
|
this.hideActivityIndicator();
|
|
this.cleanupStreamingResources();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
this.showActivityIndicator('Thinking…');
|
|
const noteContext = await this.noteContextBuilder.buildContext(
|
|
actualMessage,
|
|
this.settings.vaultSearchLimit,
|
|
{
|
|
includeOpenNote: true,
|
|
includeSelectedText: true,
|
|
includeBacklinks: true,
|
|
includeOutlinks: true,
|
|
includeRelated: true,
|
|
maxRelatedNotes: 10,
|
|
}
|
|
);
|
|
const context = this.noteContextBuilder.formatContext(noteContext, maxContextLength);
|
|
const userMessageWithContext = context
|
|
? `Relevant vault context:\n${context}\n\nUser question:\n${actualMessage}`
|
|
: actualMessage;
|
|
|
|
// Get the complete messages array for the LLM with all context layers
|
|
const completeMessages =
|
|
this.conversationStateManager.getCompleteMessages(userMessageWithContext);
|
|
|
|
// Prepend structured memory as a system message if available
|
|
const messagesWithMemory = this.buildMessagesWithMemory(completeMessages);
|
|
|
|
const activeClient = this.getActiveOllamaClient();
|
|
const activeModel = this.getActiveModel();
|
|
const stream = activeClient.streamChat(messagesWithMemory, tools);
|
|
|
|
let fullResponse = '';
|
|
let toolCalls: OllamaToolCall[] = [];
|
|
let promptTokens = 0;
|
|
let completionTokens = 0;
|
|
const llmStartTime = Date.now();
|
|
|
|
for await (const chunk of stream) {
|
|
if (chunk.content) {
|
|
fullResponse += chunk.content;
|
|
this.updateLastMessage({
|
|
content: fullResponse,
|
|
isStreaming: true,
|
|
isThinking: false,
|
|
});
|
|
}
|
|
|
|
if (chunk.tool_calls) {
|
|
toolCalls = [...toolCalls, ...chunk.tool_calls];
|
|
}
|
|
|
|
// Capture token counts from final chunk when Ollama provides them
|
|
if (typeof chunk.prompt_eval_count === 'number') {
|
|
promptTokens = chunk.prompt_eval_count;
|
|
}
|
|
if (typeof chunk.eval_count === 'number') {
|
|
completionTokens = chunk.eval_count;
|
|
}
|
|
}
|
|
|
|
const llmDurationMs = Date.now() - llmStartTime;
|
|
// Fallback: estimate tokens from characters if Ollama didn't provide counts
|
|
const estimatedPromptTokens =
|
|
promptTokens > 0
|
|
? promptTokens
|
|
: completeMessages.reduce((sum, m) => sum + m.content.length, 0) / 4;
|
|
const estimatedCompletionTokens =
|
|
completionTokens > 0 ? completionTokens : fullResponse.length / 4;
|
|
|
|
this.telemetryManager?.recordLlmCall({
|
|
model: activeModel,
|
|
promptTokens: Math.round(estimatedPromptTokens),
|
|
completionTokens: Math.round(estimatedCompletionTokens),
|
|
totalTokens: Math.round(estimatedPromptTokens + estimatedCompletionTokens),
|
|
durationMs: llmDurationMs,
|
|
});
|
|
|
|
// Process tool calls if any
|
|
if (toolCalls.length > 0) {
|
|
this.showActivityIndicator('Using tools…');
|
|
await this.processToolCalls(
|
|
toolCalls,
|
|
messagesWithMemory,
|
|
tools,
|
|
fullResponse,
|
|
assistantMessageId
|
|
);
|
|
}
|
|
|
|
// Auto-nudge for tool-capable modes if assistant didn't emit tools but seems to intend to
|
|
let shouldFallbackToReadTools = false;
|
|
const toolCapableModes: AgentMode[] = ['edit', 'organize', 'research'];
|
|
const isToolCapable = toolCapableModes.includes(this.currentAgentMode);
|
|
|
|
// Determine if automatic tools should run based on model response OR user intent
|
|
const userWantsVaultOps = this.userMessageImpliesToolUse(actualMessage);
|
|
const modelMentionedActions = isToolCapable && this.shouldAutoRunReadTools(fullResponse);
|
|
|
|
if (isToolCapable && toolCalls.length === 0) {
|
|
if (modelMentionedActions || userWantsVaultOps) {
|
|
shouldFallbackToReadTools = true;
|
|
|
|
// Suppress the model's "Let me..." text — clear it from the DOM immediately
|
|
const priorResponse = fullResponse;
|
|
fullResponse = '';
|
|
this.updateMessageById(assistantMessageId, {
|
|
content: '',
|
|
isStreaming: false,
|
|
isThinking: false,
|
|
});
|
|
|
|
// Single nudge — only when the model actually expressed intent to act.
|
|
// Lying to models that never mentioned action ("you said you would...") confuses
|
|
// them and wastes a full LLM round-trip without benefit.
|
|
if (modelMentionedActions) {
|
|
this.showActivityIndicator('Thinking…');
|
|
const nudgeMessages: OllamaMessage[] = [
|
|
...messagesWithMemory,
|
|
...(priorResponse.trim()
|
|
? [{ role: 'assistant' as const, content: priorResponse }]
|
|
: []),
|
|
{
|
|
role: 'user',
|
|
content:
|
|
'You indicated you would take action but did not emit any tool_calls. Emit the required tool_calls now. Do not output explanatory text.',
|
|
},
|
|
];
|
|
|
|
try {
|
|
const nudgeStream = activeClient.streamChat(nudgeMessages, tools);
|
|
let nudgeResponse = '';
|
|
for await (const chunk of nudgeStream) {
|
|
if (chunk.content) nudgeResponse += chunk.content;
|
|
if (chunk.tool_calls) toolCalls = [...toolCalls, ...chunk.tool_calls];
|
|
}
|
|
|
|
if (toolCalls.length > 0) {
|
|
this.showActivityIndicator('Using tools…');
|
|
await this.processToolCalls(
|
|
toolCalls,
|
|
nudgeMessages,
|
|
tools,
|
|
nudgeResponse,
|
|
assistantMessageId
|
|
);
|
|
}
|
|
} catch {
|
|
// Stream failed — fall through to auto tool calls
|
|
}
|
|
}
|
|
|
|
// Auto tool calls: fire immediately when the user's request implies vault operations
|
|
// and the model (with or without nudging) still hasn't called any tools.
|
|
if (toolCalls.length === 0) {
|
|
const autoToolCalls = this.buildAutomaticReadToolCalls(actualMessage, tools);
|
|
if (autoToolCalls.length > 0) {
|
|
this.showActivityIndicator('Using tools…');
|
|
toolCalls = autoToolCalls;
|
|
await this.processToolCalls(
|
|
autoToolCalls,
|
|
messagesWithMemory,
|
|
tools,
|
|
'',
|
|
assistantMessageId
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update assistant message — only if no tool calls were processed
|
|
if (toolCalls.length === 0) {
|
|
this.updateMessageById(assistantMessageId, {
|
|
content: fullResponse || '(No response)',
|
|
isStreaming: false,
|
|
isThinking: false,
|
|
});
|
|
}
|
|
|
|
// Update conversation state with the assistant's response
|
|
this.conversationStateManager.updateShortTermContext({ role: 'user', content: userMessage });
|
|
this.conversationStateManager.updateShortTermContext({
|
|
role: 'assistant',
|
|
content: fullResponse,
|
|
});
|
|
|
|
// Update structured memory with preferences, facts, and a conversation summary
|
|
if (this.structuredMemoryManager) {
|
|
const prefs = this.structuredMemoryManager.extractPreferencesFromMessage(userMessage);
|
|
for (const pref of prefs) {
|
|
this.structuredMemoryManager.addUserPreference(pref);
|
|
}
|
|
|
|
const facts = this.structuredMemoryManager.extractFactsFromMessage(userMessage);
|
|
for (const fact of facts) {
|
|
this.structuredMemoryManager.addLearnedFact(fact);
|
|
}
|
|
|
|
// Also extract from assistant response
|
|
const assistantFacts = this.structuredMemoryManager.extractFactsFromMessage(fullResponse);
|
|
for (const fact of assistantFacts) {
|
|
this.structuredMemoryManager.addLearnedFact(fact);
|
|
}
|
|
|
|
const { topic, keyPoints } = this.structuredMemoryManager.summarizeConversation(
|
|
this.conversationStateManager.getShortTermContext()
|
|
);
|
|
if (keyPoints.length > 0) {
|
|
this.structuredMemoryManager.addConversationSummary({
|
|
id: crypto.randomUUID?.() ?? `summary-${Date.now()}-${Math.random()}`,
|
|
timestamp: Date.now(),
|
|
topic,
|
|
summary: keyPoints.join('; ').slice(0, 300),
|
|
keyPoints,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Limit conversation history to prevent memory issues
|
|
if (this.messages.length > this.settings.maxMessageHistory) {
|
|
this.messages = this.messages.slice(-this.settings.maxMessageHistory);
|
|
}
|
|
|
|
this.render();
|
|
this.syncMessagesToSession();
|
|
} catch (error) {
|
|
if (this.isCancelled) {
|
|
// User-initiated stop — show clean message, not an error
|
|
this.updateMessageById(assistantMessageId, {
|
|
content: 'Stopped.',
|
|
isStreaming: false,
|
|
isThinking: false,
|
|
});
|
|
this.syncMessagesToSession();
|
|
} else {
|
|
ErrorHandler.handleError(error, 'ChatView.handleUserInput');
|
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
this.updateMessageById(assistantMessageId, {
|
|
content: `An error occurred: ${errorMsg}`,
|
|
isStreaming: false,
|
|
isThinking: false,
|
|
});
|
|
this.syncMessagesToSession();
|
|
}
|
|
} finally {
|
|
// Clean up streaming resources regardless of outcome
|
|
this.isCancelled = false;
|
|
this.hideActivityIndicator();
|
|
this.cleanupStreamingResources();
|
|
}
|
|
}
|
|
|
|
private shouldAutoRunReadTools(response: string): boolean {
|
|
const toolCapableModes: AgentMode[] = ['edit', 'organize', 'research'];
|
|
if (!toolCapableModes.includes(this.currentAgentMode)) {
|
|
return false;
|
|
}
|
|
|
|
const lowerResponse = response.toLowerCase();
|
|
const intentPhrases = [
|
|
'let me',
|
|
'i will',
|
|
'i need to',
|
|
'i should',
|
|
'search for',
|
|
'look for',
|
|
'read',
|
|
'explore',
|
|
'check',
|
|
'find',
|
|
'analyze',
|
|
'examine',
|
|
'review',
|
|
'inspect',
|
|
'investigate',
|
|
'scan',
|
|
];
|
|
return intentPhrases.some((phrase) => lowerResponse.includes(phrase));
|
|
}
|
|
|
|
private userMessageImpliesToolUse(message: string): boolean {
|
|
const toolCapableModes: AgentMode[] = ['edit', 'organize', 'research'];
|
|
if (!toolCapableModes.includes(this.currentAgentMode)) {
|
|
return false;
|
|
}
|
|
|
|
const lower = message.toLowerCase();
|
|
const operationPhrases = [
|
|
'organize',
|
|
'structure',
|
|
'folder',
|
|
'tag',
|
|
'move',
|
|
'rename',
|
|
'create',
|
|
'delete',
|
|
'search',
|
|
'find',
|
|
'look for',
|
|
'read',
|
|
'show me',
|
|
'list',
|
|
'what is in',
|
|
'what are',
|
|
'tell me about',
|
|
'vault',
|
|
'notes',
|
|
'files',
|
|
'continue',
|
|
'go ahead',
|
|
'proceed',
|
|
'do it',
|
|
'execute',
|
|
'implement',
|
|
'apply',
|
|
'merge',
|
|
'clean',
|
|
'fix',
|
|
'update',
|
|
];
|
|
return operationPhrases.some((phrase) => lower.includes(phrase));
|
|
}
|
|
|
|
private buildAutomaticReadToolCalls(message: string, tools: OllamaTool[]): OllamaToolCall[] {
|
|
const availableTools = new Set(tools.map((tool) => tool.function.name));
|
|
const lowerMessage = message.toLowerCase();
|
|
const calls: OllamaToolCall[] = [];
|
|
|
|
const addCall = (name: string, args: Record<string, unknown>) => {
|
|
if (!availableTools.has(name)) {
|
|
return;
|
|
}
|
|
calls.push({
|
|
id: crypto.randomUUID(),
|
|
type: 'function',
|
|
function: {
|
|
name,
|
|
arguments: JSON.stringify(args),
|
|
},
|
|
});
|
|
};
|
|
|
|
const wantsStructure =
|
|
lowerMessage.includes('folder') ||
|
|
lowerMessage.includes('structure') ||
|
|
lowerMessage.includes('organize') ||
|
|
lowerMessage.includes('vault') ||
|
|
lowerMessage.includes('move');
|
|
const wantsTags = lowerMessage.includes('tag');
|
|
|
|
if (wantsStructure) {
|
|
addCall('get_vault_stats', {});
|
|
}
|
|
if (wantsTags || wantsStructure) {
|
|
addCall('list_vault_tags', { sortBy: wantsTags ? 'count' : 'name' });
|
|
}
|
|
|
|
const searchQuery = this.buildAutomaticSearchQuery(message);
|
|
if (searchQuery) {
|
|
addCall('search_vault_files', {
|
|
query: searchQuery,
|
|
limit: this.settings.vaultSearchLimit,
|
|
});
|
|
}
|
|
|
|
return calls.slice(0, MAX_TOOL_CALLS);
|
|
}
|
|
|
|
private buildAutomaticSearchQuery(message: string): string {
|
|
const lowerMessage = message.toLowerCase().trim();
|
|
if (
|
|
lowerMessage === 'continue' ||
|
|
lowerMessage === 'please continue' ||
|
|
lowerMessage === 'go ahead'
|
|
) {
|
|
return 'folder structure tags organization vault index';
|
|
}
|
|
|
|
return message
|
|
.replace(/\bplease\b/gi, '')
|
|
.replace(/\bcontinue\b/gi, '')
|
|
.replace(/\bgo ahead\b/gi, '')
|
|
.replace(/\bimplement\b/gi, '')
|
|
.replace(/\bmove\b/gi, '')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
}
|
|
|
|
// State
|
|
private messages: ChatMessage[] = [];
|
|
private lastMessageEl: HTMLElement | null = null;
|
|
private newChatButton: HTMLElement | null = null;
|
|
private sendButton: HTMLElement | null = null;
|
|
private stopButton: HTMLElement | null = null;
|
|
private inputEl: HTMLTextAreaElement | null = null;
|
|
private chatContainer: HTMLElement | null = null;
|
|
private sendButtonClickHandler: (() => void) | null = null;
|
|
private inputKeyDownHandler: ((event: KeyboardEvent) => void) | null = null;
|
|
private newChatButtonClickHandler: (() => void) | null = null;
|
|
private sendButtonClickWrapper: (() => void) | null = null;
|
|
private inputKeyDownWrapper: ((event: KeyboardEvent) => void) | null = null;
|
|
private newChatButtonClickWrapper: (() => void) | null = null;
|
|
private listenersAttached: boolean = false;
|
|
private isCancelled: boolean = false;
|
|
private settings: PluginSettings;
|
|
private ollamaClient: OllamaClient;
|
|
private agentOllamaClient: OllamaClient;
|
|
private vaultIndexer: VaultIndexer;
|
|
private toolExecutor: ToolExecutor;
|
|
private actionPreviewBuilder: ActionPreviewBuilder;
|
|
private noteContextBuilder: NoteContextBuilder;
|
|
private workflowEngine: WorkflowEngine;
|
|
private conversationStateManager: ConversationStateManager;
|
|
private structuredMemoryManager?: StructuredMemoryManager;
|
|
private telemetryManager?: TelemetryManager;
|
|
private chatHistoryManager?: ChatHistoryManager;
|
|
private vectorStore?: VaultVectorStore;
|
|
private onModelChange?: (model: string) => void;
|
|
|
|
private modeSelectorEl: HTMLSelectElement | null = null;
|
|
private modelSelectorEl: HTMLSelectElement | null = null;
|
|
private historySelectEl: HTMLSelectElement | null = null;
|
|
private historyDeleteButton: HTMLElement | null = null;
|
|
private currentAgentMode: AgentMode;
|
|
private pendingActions: ProposedAction[] = [];
|
|
private pendingReadResults: (ToolResult & { id?: string })[] = [];
|
|
private pendingFollowUpContext: {
|
|
messages: OllamaMessage[];
|
|
tools: OllamaTool[];
|
|
assistantMessageId: string;
|
|
allToolCalls: OllamaToolCall[];
|
|
assistantText: string;
|
|
} | null = null;
|
|
|
|
// Auto-scroll & logs UI
|
|
private shouldAutoScroll: boolean = true;
|
|
private logsVisible: boolean = false;
|
|
private showLogsButton: HTMLElement | null = null;
|
|
private logsContainer: HTMLElement | null = null;
|
|
private removeLogListener: (() => void) | null = null;
|
|
private showLogsClickHandler: (() => void) | null = null;
|
|
private chatScrollHandler: (() => void) | null = null;
|
|
|
|
// Activity indicator
|
|
private activityIndicatorEl: HTMLElement | null = null;
|
|
|
|
private showActivityIndicator(text: string): void {
|
|
if (!this.activityIndicatorEl) return;
|
|
const textEl = this.activityIndicatorEl.querySelector('.ollama-activity-text');
|
|
if (textEl) {
|
|
textEl.textContent = text;
|
|
}
|
|
this.activityIndicatorEl.style.display = 'flex';
|
|
if (this.sendButton) {
|
|
this.sendButton.style.display = 'none';
|
|
}
|
|
if (this.stopButton) {
|
|
this.stopButton.style.display = 'inline-flex';
|
|
}
|
|
if (this.inputEl) {
|
|
this.inputEl.disabled = true;
|
|
}
|
|
}
|
|
|
|
private hideActivityIndicator(): void {
|
|
if (!this.activityIndicatorEl) return;
|
|
this.activityIndicatorEl.style.display = 'none';
|
|
if (this.sendButton) {
|
|
this.sendButton.style.display = 'inline-flex';
|
|
}
|
|
if (this.stopButton) {
|
|
this.stopButton.style.display = 'none';
|
|
}
|
|
if (this.inputEl) {
|
|
this.inputEl.disabled = false;
|
|
this.inputEl.focus();
|
|
}
|
|
}
|
|
|
|
private cancelCurrentOperation(): void {
|
|
this.isCancelled = true;
|
|
this.ollamaClient.cancelStream();
|
|
this.agentOllamaClient.cancelStream();
|
|
new Notice('Stopping…');
|
|
}
|
|
|
|
private createOllamaClient(model: string, settings: PluginSettings): OllamaClient {
|
|
return new OllamaClient(settings.ollamaUrl, model, undefined, settings.cacheConfig);
|
|
}
|
|
|
|
private async initializeClientCaches(): Promise<void> {
|
|
await this.ollamaClient.initializeCache();
|
|
if (this.agentOllamaClient !== this.ollamaClient) {
|
|
await this.agentOllamaClient.initializeCache();
|
|
}
|
|
}
|
|
|
|
private getActiveModel(): string {
|
|
return this.isAgenticMode(this.currentAgentMode)
|
|
? (this.settings.agentModel ?? this.settings.model)
|
|
: (this.settings.chatModel ?? this.settings.model);
|
|
}
|
|
|
|
private getActiveOllamaClient(): OllamaClient {
|
|
return this.isAgenticMode(this.currentAgentMode) ? this.agentOllamaClient : this.ollamaClient;
|
|
}
|
|
|
|
private isAgenticMode(mode: AgentMode): boolean {
|
|
return mode === 'edit' || mode === 'organize' || mode === 'research' || mode === 'workflow';
|
|
}
|
|
|
|
private renderLogEntry(entry: LogEntry, container: HTMLElement): void {
|
|
const row = container.createEl('div', { cls: 'ollama-log-row' });
|
|
row.addClass(`ollama-log-row-${entry.levelLabel.toLowerCase()}`);
|
|
|
|
const time = new Date(entry.timestamp).toLocaleTimeString();
|
|
const timeSpan = row.createEl('span', { cls: 'ollama-log-time', text: time });
|
|
const levelSpan = row.createEl('span', {
|
|
cls: `ollama-log-level ollama-log-level-${entry.levelLabel.toLowerCase()}`,
|
|
text: entry.levelLabel,
|
|
});
|
|
const catSpan = row.createEl('span', { cls: 'ollama-log-category', text: entry.category });
|
|
const msgSpan = row.createEl('span', { cls: 'ollama-log-message', text: entry.message });
|
|
|
|
// Keep DOM lean
|
|
if (container.children.length > 250) {
|
|
container.removeChild(container.firstChild!);
|
|
}
|
|
}
|
|
|
|
private appendLogEntry(entry: LogEntry): void {
|
|
if (!this.logsContainer) return;
|
|
this.renderLogEntry(entry, this.logsContainer);
|
|
this.scrollLogsToBottom();
|
|
}
|
|
|
|
private scrollLogsToBottom(): void {
|
|
if (this.logsContainer) {
|
|
this.logsContainer.scrollTop = this.logsContainer.scrollHeight;
|
|
}
|
|
}
|
|
}
|
|
|
|
const MAX_TOOL_CALLS = 5;
|
|
const MAX_TOOL_CALL_DEPTH = 5;
|