Add in-app debug log panel and rich vault search

- Add listener/history API to Logger for live log streaming
- Add toggle button and styled log panel to chat view
- Enhance ToolExecutor to use VaultIndexer for rich search results
- Instruct all agent modes to emit tool calls immediately instead of describing intent
This commit is contained in:
2026-05-21 09:37:46 +02:00
parent 68ac64cc02
commit 5350df92dd
7 changed files with 537 additions and 68 deletions
+190 -60
View File
@@ -3124,10 +3124,7 @@ var ALL_AGENT_MODES = ["ask", "edit", "organize", "research", "workflow"];
function filterToolsByName(tools, allowed) { function filterToolsByName(tools, allowed) {
return tools.filter((t) => allowed.has(t.function.name)); return tools.filter((t) => allowed.has(t.function.name));
} }
var READ_TOOLS = /* @__PURE__ */ new Set([ var READ_TOOLS = /* @__PURE__ */ new Set(["read_vault_file", "search_vault_files"]);
"read_vault_file",
"search_vault_files"
]);
var ORGANIZE_TOOLS = /* @__PURE__ */ new Set([ var ORGANIZE_TOOLS = /* @__PURE__ */ new Set([
"read_vault_file", "read_vault_file",
"search_vault_files", "search_vault_files",
@@ -3148,16 +3145,15 @@ var EDIT_TOOLS = /* @__PURE__ */ new Set([
"delete_note", "delete_note",
"insert_link" "insert_link"
]); ]);
var RESEARCH_TOOLS = /* @__PURE__ */ new Set([ var RESEARCH_TOOLS = /* @__PURE__ */ new Set(["read_vault_file", "search_vault_files"]);
"read_vault_file",
"search_vault_files"
]);
var AGENT_MODE_CONFIGS = { var AGENT_MODE_CONFIGS = {
ask: { ask: {
label: "Ask", label: "Ask",
description: "Answer questions using vault context. Read-only mode.", description: "Answer questions using vault context. Read-only mode.",
systemPrompt: `You are a helpful assistant that answers questions using the contents of the user's Obsidian vault. systemPrompt: `You are a helpful assistant that answers questions using the contents of the user's Obsidian vault.
You have access to search and read tools to find relevant information. You have access to search and read tools to find relevant information.
IMPORTANT: When you need vault information, do NOT say you will search or read files. You MUST immediately emit a tool_call to search_vault_files or read_vault_file.
Only respond to the user after you have received and analyzed the tool results.
Always base your answers on vault content when possible. Always base your answers on vault content when possible.
If you cannot find relevant information, say so clearly. If you cannot find relevant information, say so clearly.
Do not make up facts.`, Do not make up facts.`,
@@ -3170,6 +3166,8 @@ Do not make up facts.`,
description: "Create, modify, and organize notes with full editing tools.", description: "Create, modify, and organize notes with full editing tools.",
systemPrompt: `You are an assistant that helps edit and manage notes in the user's Obsidian vault. systemPrompt: `You are an assistant that helps edit and manage notes in the user's Obsidian vault.
You have full access to reading, searching, creating, appending, renaming, moving, and deleting notes. You have full access to reading, searching, creating, appending, renaming, moving, and deleting notes.
IMPORTANT: When you need vault information, do NOT say you will search or read files. You MUST immediately emit a tool_call to the appropriate tool.
Only respond to the user after you have received and analyzed the tool results.
When editing notes: When editing notes:
- Prefer modifying existing content over creating duplicates. - Prefer modifying existing content over creating duplicates.
- Use the replace_note_section tool to update specific sections. - Use the replace_note_section tool to update specific sections.
@@ -3185,6 +3183,8 @@ When editing notes:
description: "Tag, rename, move, and link notes to keep the vault tidy.", description: "Tag, rename, move, and link notes to keep the vault tidy.",
systemPrompt: `You are an assistant that helps organize the user's Obsidian vault. systemPrompt: `You are an assistant that helps organize the user's Obsidian vault.
You can search notes, read them, update frontmatter tags, rename files, move files to folders, and insert wiki-links. You can search notes, read them, update frontmatter tags, rename files, move files to folders, and insert wiki-links.
IMPORTANT: When you need vault information, do NOT say you will search or read files. You MUST immediately emit a tool_call to the appropriate tool.
Only respond to the user after you have received and analyzed the tool results.
When organizing: When organizing:
- Suggest consistent tag vocabularies. - Suggest consistent tag vocabularies.
- Group related notes by linking them. - Group related notes by linking them.
@@ -3199,6 +3199,8 @@ When organizing:
description: "Deep vault search and synthesis across multiple notes.", description: "Deep vault search and synthesis across multiple notes.",
systemPrompt: `You are a research assistant that dives deep into the user's Obsidian vault. systemPrompt: `You are a research assistant that dives deep into the user's Obsidian vault.
Your job is to synthesize information across multiple notes, find connections, and produce comprehensive summaries. Your job is to synthesize information across multiple notes, find connections, and produce comprehensive summaries.
IMPORTANT: When you need vault information, do NOT say you will search or read files. You MUST immediately emit a tool_call to search_vault_files or read_vault_file.
Only respond to the user after you have received and analyzed the tool results.
Search broadly, read key sources, and cross-reference information. Search broadly, read key sources, and cross-reference information.
Cite specific notes and quotes where possible. Cite specific notes and quotes where possible.
If information is incomplete or contradictory, note it explicitly.`, If information is incomplete or contradictory, note it explicitly.`,
@@ -3251,28 +3253,69 @@ var _Logger = class _Logger {
_Logger.minLevel = level; _Logger.minLevel = level;
} }
} }
static addListener(callback) {
_Logger.listeners.push(callback);
return () => {
const idx = _Logger.listeners.indexOf(callback);
if (idx !== -1) {
_Logger.listeners.splice(idx, 1);
}
};
}
static getHistory() {
return [..._Logger.history];
}
static record(level, levelLabel, message, category) {
if (level < _Logger.minLevel) {
return;
}
const entry = {
timestamp: Date.now(),
level,
levelLabel,
category,
message
};
_Logger.history.push(entry);
if (_Logger.history.length > _Logger.maxHistory) {
_Logger.history = _Logger.history.slice(-_Logger.maxHistory);
}
for (const listener of _Logger.listeners) {
try {
listener(entry);
} catch {
}
}
}
static debug(message, category = "general") { static debug(message, category = "general") {
if (0 /* DEBUG */ >= _Logger.minLevel) { if (0 /* DEBUG */ >= _Logger.minLevel) {
console.debug(`[${category}] DEBUG: ${message}`); console.debug(`[${category}] DEBUG: ${message}`);
} }
_Logger.record(0 /* DEBUG */, "DEBUG", message, category);
} }
static info(message, category = "general") { static info(message, category = "general") {
if (1 /* INFO */ >= _Logger.minLevel) { if (1 /* INFO */ >= _Logger.minLevel) {
console.info(`[${category}] INFO: ${message}`); console.info(`[${category}] INFO: ${message}`);
} }
_Logger.record(1 /* INFO */, "INFO", message, category);
} }
static warn(message, category = "general") { static warn(message, category = "general") {
if (2 /* WARN */ >= _Logger.minLevel) { if (2 /* WARN */ >= _Logger.minLevel) {
console.warn(`[${category}] WARN: ${message}`); console.warn(`[${category}] WARN: ${message}`);
} }
_Logger.record(2 /* WARN */, "WARN", message, category);
} }
static error(message, category = "general") { static error(message, category = "general") {
if (3 /* ERROR */ >= _Logger.minLevel) { if (3 /* ERROR */ >= _Logger.minLevel) {
console.error(`[${category}] ERROR: ${message}`); console.error(`[${category}] ERROR: ${message}`);
} }
_Logger.record(3 /* ERROR */, "ERROR", message, category);
} }
}; };
_Logger.minLevel = 0 /* DEBUG */; _Logger.minLevel = 0 /* DEBUG */;
_Logger.listeners = [];
_Logger.history = [];
_Logger.maxHistory = 500;
var Logger = _Logger; var Logger = _Logger;
var MAX_JSON_SIZE = 1e6; var MAX_JSON_SIZE = 1e6;
var MAX_JSON_NESTING = 24; var MAX_JSON_NESTING = 24;
@@ -8772,10 +8815,11 @@ var INVALID_PATH_CHARS = /[<>:"|?*~]/;
var MAX_PATH_LENGTH = 200; var MAX_PATH_LENGTH = 200;
var FORBIDDEN_DIRS = [".obsidian", ".git"]; var FORBIDDEN_DIRS = [".obsidian", ".git"];
var ToolExecutor = class { var ToolExecutor = class {
constructor(vault, app, telemetryManager) { constructor(vault, app, telemetryManager, vaultIndexer) {
this.vault = vault; this.vault = vault;
this.app = app; this.app = app;
this.telemetryManager = telemetryManager; this.telemetryManager = telemetryManager;
this.vaultIndexer = vaultIndexer;
} }
isSafePath(path) { isSafePath(path) {
if (!path || path.trim().length === 0) { if (!path || path.trim().length === 0) {
@@ -8856,7 +8900,7 @@ var ToolExecutor = class {
result = await this.handleReadVaultFile(parsedArgs); result = await this.handleReadVaultFile(parsedArgs);
break; break;
case "search_vault_files": case "search_vault_files":
result = this.handleSearchVaultFiles(parsedArgs); result = await this.handleSearchVaultFiles(parsedArgs);
break; break;
case "append_to_note": case "append_to_note":
result = await this.handleAppendToNote(parsedArgs); result = await this.handleAppendToNote(parsedArgs);
@@ -8945,13 +8989,28 @@ var ToolExecutor = class {
data: { path, content } data: { path, content }
}; };
} }
handleSearchVaultFiles(args) { async handleSearchVaultFiles(args) {
const query = args.query; const query = args.query;
const limitArg = args.limit; const limitArg = args.limit;
if (typeof query !== "string") { if (typeof query !== "string") {
throw new Error("Query must be a string"); throw new Error("Query must be a string");
} }
const limit = typeof limitArg === "number" && Number.isFinite(limitArg) ? limitArg : 10; const limit = typeof limitArg === "number" && Number.isFinite(limitArg) ? limitArg : 10;
if (this.vaultIndexer) {
const results = await this.vaultIndexer.searchVault(query, limit);
const files2 = results.map((entry) => ({
path: entry.path,
basename: entry.path.split("/").pop() ?? entry.path,
title: entry.title,
score: entry.score,
tags: entry.tags
}));
return {
success: true,
message: `Found ${files2.length} matching files`,
data: files2
};
}
const normalizedQuery = query.toLowerCase(); const normalizedQuery = query.toLowerCase();
const files = this.vault.getMarkdownFiles().filter((file) => file.path.toLowerCase().includes(normalizedQuery)).slice(0, limit).map((file) => ({ path: file.path, basename: file.basename })); const files = this.vault.getMarkdownFiles().filter((file) => file.path.toLowerCase().includes(normalizedQuery)).slice(0, limit).map((file) => ({ path: file.path, basename: file.basename }));
return { return {
@@ -9505,8 +9564,11 @@ ${lines.join("\n")}
}; };
// src/conversation-state.ts // src/conversation-state.ts
var DEFAULT_SYSTEM_PROMPT = `You are an assistant that can help answer questions using the contents of a vault.
When a user asks for information about their vault, you MUST call the search_vault_files or read_vault_file tool to find the answer.
Do not say you will search or read files \u2014 immediately emit the tool_call.`;
var ConversationStateManager = class { var ConversationStateManager = class {
constructor() { constructor(initialSystemPrompt) {
this.shortTermContext = []; this.shortTermContext = [];
this.mediumTermContext = []; this.mediumTermContext = [];
this.longTermContext = []; this.longTermContext = [];
@@ -9515,10 +9577,7 @@ var ConversationStateManager = class {
this.longTermContext = [ this.longTermContext = [
{ {
role: "system", role: "system",
content: `You are an assistant that can help answer questions using the contents of a vault. content: initialSystemPrompt ?? DEFAULT_SYSTEM_PROMPT
The user can ask questions about their vault contents, and you should provide helpful responses based on the files.
When a user asks for information, try to find relevant files using the search_vault_files tool and read their contents with the read_vault_file tool.
Only use the tools if you need to access vault content that is not already in the context.`
} }
]; ];
} }
@@ -9546,12 +9605,15 @@ var ConversationStateManager = class {
* Sets the user's persona or core knowledge as long-term context * Sets the user's persona or core knowledge as long-term context
* @param personaContent The persona or core knowledge content * @param personaContent The persona or core knowledge content
*/ */
setSystemPrompt(systemPrompt) {
this.longTermContext = this.longTermContext.filter((msg) => msg.role !== "system");
this.longTermContext.unshift({
role: "system",
content: systemPrompt
});
}
setPersona(personaContent) { setPersona(personaContent) {
this.longTermContext = this.longTermContext.filter( this.longTermContext = this.longTermContext.filter((msg) => msg.role !== "system");
(msg) => msg.role !== "system" || !msg.content.includes(
"You are an assistant that can help answer questions using the contents of a vault"
)
);
this.longTermContext.push({ this.longTermContext.push({
role: "system", role: "system",
content: personaContent content: personaContent
@@ -9589,16 +9651,13 @@ var ConversationStateManager = class {
/** /**
* Clears all conversation context * Clears all conversation context
*/ */
clear() { clear(systemPrompt) {
this.shortTermContext = []; this.shortTermContext = [];
this.mediumTermContext = []; this.mediumTermContext = [];
this.longTermContext = [ this.longTermContext = [
{ {
role: "system", role: "system",
content: `You are an assistant that can help answer questions using the contents of a vault. content: systemPrompt ?? DEFAULT_SYSTEM_PROMPT
The user can ask questions about their vault contents, and you should provide helpful responses based on the files.
When a user asks for information, try to find relevant files using the search_vault_files tool and read their contents with the read_vault_file tool.
Only use the tools if you need to access vault content that is not already in the context.`
} }
]; ];
} }
@@ -9641,7 +9700,7 @@ var VARIABLE_PATTERN = /\{\{([\w.]+)\}\}/g;
var WorkflowEngine = class _WorkflowEngine { var WorkflowEngine = class _WorkflowEngine {
constructor(vault, app, ollamaUrl, model, options) { constructor(vault, app, ollamaUrl, model, options) {
this.vaultIndexer = new VaultIndexer(vault); this.vaultIndexer = new VaultIndexer(vault);
this.toolExecutor = new ToolExecutor(vault, app); this.toolExecutor = new ToolExecutor(vault, app, void 0, this.vaultIndexer);
this.ollamaClient = new OllamaClient(ollamaUrl, model, void 0, options?.cacheConfig); this.ollamaClient = new OllamaClient(ollamaUrl, model, void 0, options?.cacheConfig);
this.conversationStateManager = new ConversationStateManager(); this.conversationStateManager = new ConversationStateManager();
this.maxSteps = options?.maxSteps ?? 20; this.maxSteps = options?.maxSteps ?? 20;
@@ -10660,10 +10719,17 @@ var ChatView = class extends import_obsidian5.ItemView {
this.newChatButtonClickWrapper = null; this.newChatButtonClickWrapper = null;
this.listenersAttached = false; this.listenersAttached = false;
this.modeSelectorEl = null; this.modeSelectorEl = null;
// Pending action state
this.pendingActions = []; this.pendingActions = [];
this.pendingReadResults = []; this.pendingReadResults = [];
this.pendingFollowUpContext = null; this.pendingFollowUpContext = null;
// Auto-scroll & logs UI
this.shouldAutoScroll = true;
this.logsVisible = false;
this.showLogsButton = null;
this.logsContainer = null;
this.removeLogListener = null;
this.showLogsClickHandler = null;
this.chatScrollHandler = null;
this.messages = []; this.messages = [];
this.lastMessageEl = null; this.lastMessageEl = null;
this.newChatButton = null; this.newChatButton = null;
@@ -10682,10 +10748,17 @@ var ChatView = class extends import_obsidian5.ItemView {
this.ollamaClient = this.createOllamaClient(settings.chatModel ?? settings.model, settings); 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.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, void 0, vectorStore); this.vaultIndexer = new VaultIndexer(this.app.vault, void 0, vectorStore);
this.toolExecutor = new ToolExecutor(this.app.vault, this.app, telemetryManager); this.toolExecutor = new ToolExecutor(
this.app.vault,
this.app,
telemetryManager,
this.vaultIndexer
);
this.actionPreviewBuilder = new ActionPreviewBuilder(this.app.vault, this.app); this.actionPreviewBuilder = new ActionPreviewBuilder(this.app.vault, this.app);
this.noteContextBuilder = new NoteContextBuilder(this.app.vault, this.app, this.vaultIndexer); this.noteContextBuilder = new NoteContextBuilder(this.app.vault, this.app, this.vaultIndexer);
this.conversationStateManager = new ConversationStateManager(); this.conversationStateManager = new ConversationStateManager(
getSystemPromptForMode(this.currentAgentMode)
);
this.structuredMemoryManager = structuredMemoryManager; this.structuredMemoryManager = structuredMemoryManager;
this.telemetryManager = telemetryManager; this.telemetryManager = telemetryManager;
this.workflowEngine = new WorkflowEngine( this.workflowEngine = new WorkflowEngine(
@@ -10695,6 +10768,11 @@ var ChatView = class extends import_obsidian5.ItemView {
settings.agentModel ?? settings.model, settings.agentModel ?? settings.model,
{ cacheConfig: settings.cacheConfig } { cacheConfig: settings.cacheConfig }
); );
this.removeLogListener = Logger.addListener((entry) => {
if (this.logsVisible && this.logsContainer) {
this.appendLogEntry(entry);
}
});
} }
// Getters for testing // Getters for testing
getSendButtonClickHandler() { getSendButtonClickHandler() {
@@ -10768,10 +10846,19 @@ var ChatView = class extends import_obsidian5.ItemView {
this.ollamaClient.cancelStream(); this.ollamaClient.cancelStream();
this.removeEventListeners(); this.removeEventListeners();
this.cleanupStreamingResources(); 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.lastMessageEl = null;
this.sendButton = null; this.sendButton = null;
this.inputEl = null; this.inputEl = null;
this.chatContainer = null; this.chatContainer = null;
this.showLogsButton = null;
this.logsContainer = null;
return Promise.resolve(); return Promise.resolve();
} }
cleanupStreamingResources() { cleanupStreamingResources() {
@@ -10857,6 +10944,27 @@ var ChatView = class extends import_obsidian5.ItemView {
} else { } else {
newChatContainer.appendChild(this.newChatButton); newChatContainer.appendChild(this.newChatButton);
} }
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);
}
if (this.logsVisible) {
if (!this.logsContainer) {
this.logsContainer = this.contentEl.createEl("div", { cls: "ollama-logs-container" });
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);
}
if (!this.inputEl) { if (!this.inputEl) {
this.inputEl = inputContainer.createEl("textarea", { this.inputEl = inputContainer.createEl("textarea", {
cls: "ollama-input", cls: "ollama-input",
@@ -10876,6 +10984,9 @@ var ChatView = class extends import_obsidian5.ItemView {
this.contentEl.appendChild(newChatContainer); this.contentEl.appendChild(newChatContainer);
this.contentEl.appendChild(inputContainer); this.contentEl.appendChild(inputContainer);
this.contentEl.appendChild(container); this.contentEl.appendChild(container);
if (this.shouldAutoScroll && container) {
container.scrollTop = container.scrollHeight;
}
this.inputEl.focus(); this.inputEl.focus();
} }
setupEventListeners() { setupEventListeners() {
@@ -10894,6 +11005,10 @@ var ChatView = class extends import_obsidian5.ItemView {
this.newChatButtonClickHandler = () => { this.newChatButtonClickHandler = () => {
this.clearConversation(); this.clearConversation();
}; };
this.showLogsClickHandler = () => {
this.logsVisible = !this.logsVisible;
this.render();
};
if (this.sendButton && this.sendButtonClickHandler) { if (this.sendButton && this.sendButtonClickHandler) {
this.sendButton.addEventListener("click", this.sendButtonClickHandler); this.sendButton.addEventListener("click", this.sendButtonClickHandler);
} }
@@ -10903,6 +11018,18 @@ var ChatView = class extends import_obsidian5.ItemView {
if (this.newChatButton && this.newChatButtonClickHandler) { if (this.newChatButton && this.newChatButtonClickHandler) {
this.newChatButton.addEventListener("click", this.newChatButtonClickHandler); this.newChatButton.addEventListener("click", this.newChatButtonClickHandler);
} }
if (this.showLogsButton && this.showLogsClickHandler) {
this.showLogsButton.addEventListener("click", this.showLogsClickHandler);
}
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; this.listenersAttached = true;
} }
removeEventListeners() { removeEventListeners() {
@@ -10918,6 +11045,12 @@ var ChatView = class extends import_obsidian5.ItemView {
if (this.newChatButton && this.newChatButtonClickHandler) { if (this.newChatButton && this.newChatButtonClickHandler) {
this.newChatButton.removeEventListener("click", 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; this.listenersAttached = false;
} }
getAgentMode() { getAgentMode() {
@@ -10928,10 +11061,11 @@ var ChatView = class extends import_obsidian5.ItemView {
if (this.modeSelectorEl) { if (this.modeSelectorEl) {
this.modeSelectorEl.value = mode; this.modeSelectorEl.value = mode;
} }
this.conversationStateManager.setSystemPrompt(getSystemPromptForMode(mode));
} }
clearConversation() { clearConversation() {
this.messages = []; this.messages = [];
this.conversationStateManager.clear(); this.conversationStateManager.clear(getSystemPromptForMode(this.currentAgentMode));
this.render(); this.render();
} }
updateMessageById(id, updates) { updateMessageById(id, updates) {
@@ -11177,35 +11311,6 @@ var ChatView = class extends import_obsidian5.ItemView {
} }
return baseMessages; return baseMessages;
} }
buildMessages(userMessageContent, tools) {
const systemContent = getSystemPromptForMode(this.currentAgentMode);
const messages = [];
if (this.structuredMemoryManager) {
const memoryContext = this.structuredMemoryManager.buildMemoryContext();
if (memoryContext) {
messages.push({
role: "system",
content: memoryContext
});
}
}
messages.push({
role: "system",
content: systemContent
});
const userMessage = {
role: "user",
content: userMessageContent
};
messages.push(userMessage);
if (tools && tools.length > 0) {
messages.push({
role: "assistant",
content: "I have access to the following tools to help answer your questions:"
});
}
return messages;
}
async processToolCalls(toolCalls, messages, tools, fullResponse, assistantMessageId) { async processToolCalls(toolCalls, messages, tools, fullResponse, assistantMessageId) {
const readToolCalls = toolCalls.filter((tc) => !isWriteTool(tc.function?.name ?? "")); const readToolCalls = toolCalls.filter((tc) => !isWriteTool(tc.function?.name ?? ""));
const writeToolCalls = toolCalls.filter((tc) => isWriteTool(tc.function?.name ?? "")); const writeToolCalls = toolCalls.filter((tc) => isWriteTool(tc.function?.name ?? ""));
@@ -11675,6 +11780,31 @@ ${actualMessage}` : actualMessage;
isAgenticMode(mode) { isAgenticMode(mode) {
return mode === "edit" || mode === "organize" || mode === "workflow"; return mode === "edit" || mode === "organize" || mode === "workflow";
} }
renderLogEntry(entry, container) {
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 });
if (container.children.length > 250) {
container.removeChild(container.firstChild);
}
}
appendLogEntry(entry) {
if (!this.logsContainer) return;
this.renderLogEntry(entry, this.logsContainer);
this.scrollLogsToBottom();
}
scrollLogsToBottom() {
if (this.logsContainer) {
this.logsContainer.scrollTop = this.logsContainer.scrollHeight;
}
}
}; };
var MAX_TOOL_CALLS = 5; var MAX_TOOL_CALLS = 5;
+122 -3
View File
@@ -27,6 +27,7 @@ import { ConversationStateManager } from './conversation-state';
import { ErrorHandler } from './error-handler'; import { ErrorHandler } from './error-handler';
import { StructuredMemoryManager } from './structured-memory'; import { StructuredMemoryManager } from './structured-memory';
import { TelemetryManager } from './tool-telemetry'; import { TelemetryManager } from './tool-telemetry';
import { Logger, LogEntry } from './utils';
export const VIEW_TYPE_OLLAMA_CHAT = 'ollama-chat-view'; export const VIEW_TYPE_OLLAMA_CHAT = 'ollama-chat-view';
@@ -73,7 +74,12 @@ export class ChatView extends ItemView {
? this.ollamaClient ? this.ollamaClient
: this.createOllamaClient(settings.agentModel ?? settings.model, settings); : this.createOllamaClient(settings.agentModel ?? settings.model, settings);
this.vaultIndexer = new VaultIndexer(this.app.vault, undefined, vectorStore); this.vaultIndexer = new VaultIndexer(this.app.vault, undefined, vectorStore);
this.toolExecutor = new ToolExecutor(this.app.vault, this.app, telemetryManager); this.toolExecutor = new ToolExecutor(
this.app.vault,
this.app,
telemetryManager,
this.vaultIndexer
);
this.actionPreviewBuilder = new ActionPreviewBuilder(this.app.vault, this.app); this.actionPreviewBuilder = new ActionPreviewBuilder(this.app.vault, this.app);
this.noteContextBuilder = new NoteContextBuilder(this.app.vault, this.app, this.vaultIndexer); this.noteContextBuilder = new NoteContextBuilder(this.app.vault, this.app, this.vaultIndexer);
this.conversationStateManager = new ConversationStateManager( this.conversationStateManager = new ConversationStateManager(
@@ -88,6 +94,13 @@ export class ChatView extends ItemView {
settings.agentModel ?? settings.model, settings.agentModel ?? settings.model,
{ cacheConfig: settings.cacheConfig } { cacheConfig: settings.cacheConfig }
); );
// Subscribe to plugin logs
this.removeLogListener = Logger.addListener((entry) => {
if (this.logsVisible && this.logsContainer) {
this.appendLogEntry(entry);
}
});
} }
updateSettings(newSettings: PluginSettings) { updateSettings(newSettings: PluginSettings) {
@@ -163,10 +176,19 @@ export class ChatView extends ItemView {
this.ollamaClient.cancelStream(); this.ollamaClient.cancelStream();
this.removeEventListeners(); this.removeEventListeners();
this.cleanupStreamingResources(); 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.lastMessageEl = null;
this.sendButton = null; this.sendButton = null;
this.inputEl = null; this.inputEl = null;
this.chatContainer = null; this.chatContainer = null;
this.showLogsButton = null;
this.logsContainer = null;
return Promise.resolve(); return Promise.resolve();
} }
@@ -274,6 +296,32 @@ export class ChatView extends ItemView {
newChatContainer.appendChild(this.newChatButton); newChatContainer.appendChild(this.newChatButton);
} }
// 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 // Setup input area
if (!this.inputEl) { if (!this.inputEl) {
this.inputEl = inputContainer.createEl('textarea', { this.inputEl = inputContainer.createEl('textarea', {
@@ -299,6 +347,11 @@ export class ChatView extends ItemView {
this.contentEl.appendChild(inputContainer); this.contentEl.appendChild(inputContainer);
this.contentEl.appendChild(container); this.contentEl.appendChild(container);
// Auto-scroll chat to bottom if enabled
if (this.shouldAutoScroll && container) {
container.scrollTop = container.scrollHeight;
}
// Focus input on open // Focus input on open
this.inputEl.focus(); this.inputEl.focus();
} }
@@ -323,6 +376,11 @@ export class ChatView extends ItemView {
this.clearConversation(); this.clearConversation();
}; };
this.showLogsClickHandler = () => {
this.logsVisible = !this.logsVisible;
this.render();
};
if (this.sendButton && this.sendButtonClickHandler) { if (this.sendButton && this.sendButtonClickHandler) {
this.sendButton.addEventListener('click', this.sendButtonClickHandler); this.sendButton.addEventListener('click', this.sendButtonClickHandler);
} }
@@ -335,6 +393,21 @@ export class ChatView extends ItemView {
this.newChatButton.addEventListener('click', 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; this.listenersAttached = true;
} }
@@ -355,6 +428,14 @@ export class ChatView extends ItemView {
this.newChatButton.removeEventListener('click', 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; this.listenersAttached = false;
} }
@@ -1217,8 +1298,6 @@ export class ChatView extends ItemView {
private modeSelectorEl: HTMLSelectElement | null = null; private modeSelectorEl: HTMLSelectElement | null = null;
private currentAgentMode: AgentMode; private currentAgentMode: AgentMode;
// Pending action state
private pendingActions: ProposedAction[] = []; private pendingActions: ProposedAction[] = [];
private pendingReadResults: (ToolResult & { id?: string })[] = []; private pendingReadResults: (ToolResult & { id?: string })[] = [];
private pendingFollowUpContext: { private pendingFollowUpContext: {
@@ -1227,6 +1306,15 @@ export class ChatView extends ItemView {
assistantMessageId: string; assistantMessageId: string;
} | null = null; } | 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;
private createOllamaClient(model: string, settings: PluginSettings): OllamaClient { private createOllamaClient(model: string, settings: PluginSettings): OllamaClient {
return new OllamaClient(settings.ollamaUrl, model, undefined, settings.cacheConfig); return new OllamaClient(settings.ollamaUrl, model, undefined, settings.cacheConfig);
} }
@@ -1251,6 +1339,37 @@ export class ChatView extends ItemView {
private isAgenticMode(mode: AgentMode): boolean { private isAgenticMode(mode: AgentMode): boolean {
return mode === 'edit' || mode === 'organize' || mode === 'workflow'; return mode === 'edit' || mode === 'organize' || 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_CALLS = 5;
+31 -4
View File
@@ -1,9 +1,10 @@
// src/tool-executor.ts // src/tool-executor.ts
import { Vault, App, TFile } from 'obsidian'; import { Vault, App, TFile } from 'obsidian';
import type { ToolCall, ToolResult } from './types'; import type { ToolCall, ToolResult, VaultIndexEntry } from './types';
import { safeParseJson } from './utils'; import { safeParseJson } from './utils';
import { TelemetryManager } from './tool-telemetry'; import { TelemetryManager } from './tool-telemetry';
import { VaultIndexer } from './vault-indexer';
// Disallow characters that are invalid in file paths // Disallow characters that are invalid in file paths
const INVALID_PATH_CHARS = /[<>:"|?*~]/; const INVALID_PATH_CHARS = /[<>:"|?*~]/;
@@ -14,11 +15,18 @@ export class ToolExecutor {
private vault: Vault; private vault: Vault;
private app: App; private app: App;
private telemetryManager?: TelemetryManager; private telemetryManager?: TelemetryManager;
private vaultIndexer?: VaultIndexer;
constructor(vault: Vault, app: App, telemetryManager?: TelemetryManager) { constructor(
vault: Vault,
app: App,
telemetryManager?: TelemetryManager,
vaultIndexer?: VaultIndexer
) {
this.vault = vault; this.vault = vault;
this.app = app; this.app = app;
this.telemetryManager = telemetryManager; this.telemetryManager = telemetryManager;
this.vaultIndexer = vaultIndexer;
} }
private isSafePath(path: string): boolean { private isSafePath(path: string): boolean {
@@ -126,7 +134,7 @@ export class ToolExecutor {
result = await this.handleReadVaultFile(parsedArgs); result = await this.handleReadVaultFile(parsedArgs);
break; break;
case 'search_vault_files': case 'search_vault_files':
result = this.handleSearchVaultFiles(parsedArgs); result = await this.handleSearchVaultFiles(parsedArgs);
break; break;
case 'append_to_note': case 'append_to_note':
result = await this.handleAppendToNote(parsedArgs); result = await this.handleAppendToNote(parsedArgs);
@@ -227,7 +235,7 @@ export class ToolExecutor {
}; };
} }
private handleSearchVaultFiles(args: Record<string, unknown>): ToolResult { private async handleSearchVaultFiles(args: Record<string, unknown>): Promise<ToolResult> {
const query = args.query; const query = args.query;
const limitArg = args.limit; const limitArg = args.limit;
@@ -236,6 +244,25 @@ export class ToolExecutor {
} }
const limit = typeof limitArg === 'number' && Number.isFinite(limitArg) ? limitArg : 10; const limit = typeof limitArg === 'number' && Number.isFinite(limitArg) ? limitArg : 10;
// Use VaultIndexer for rich content/tag/search if available
if (this.vaultIndexer) {
const results = await this.vaultIndexer.searchVault(query, limit);
const files = results.map((entry: VaultIndexEntry) => ({
path: entry.path,
basename: entry.path.split('/').pop() ?? entry.path,
title: entry.title,
score: entry.score,
tags: entry.tags,
}));
return {
success: true,
message: `Found ${files.length} matching files`,
data: files,
};
}
// Fallback to simple path-based search
const normalizedQuery = query.toLowerCase(); const normalizedQuery = query.toLowerCase();
const files = this.vault const files = this.vault
.getMarkdownFiles() .getMarkdownFiles()
+58
View File
@@ -12,8 +12,19 @@ const SEVERITY_ORDER: Record<string, number> = {
error: LogLevel.ERROR, error: LogLevel.ERROR,
}; };
export interface LogEntry {
timestamp: number;
level: LogLevel;
levelLabel: string;
category: string;
message: string;
}
export class Logger { export class Logger {
private static minLevel: LogLevel = LogLevel.DEBUG; private static minLevel: LogLevel = LogLevel.DEBUG;
private static listeners: Array<(entry: LogEntry) => void> = [];
private static history: LogEntry[] = [];
private static maxHistory: number = 500;
static setLevel(level: string | LogLevel): void { static setLevel(level: string | LogLevel): void {
if (typeof level === 'string') { if (typeof level === 'string') {
@@ -24,28 +35,75 @@ export class Logger {
} }
} }
static addListener(callback: (entry: LogEntry) => void): () => void {
Logger.listeners.push(callback);
return () => {
const idx = Logger.listeners.indexOf(callback);
if (idx !== -1) {
Logger.listeners.splice(idx, 1);
}
};
}
static getHistory(): LogEntry[] {
return [...Logger.history];
}
private static record(
level: LogLevel,
levelLabel: string,
message: string,
category: string
): void {
if (level < Logger.minLevel) {
return;
}
const entry: LogEntry = {
timestamp: Date.now(),
level,
levelLabel,
category,
message,
};
Logger.history.push(entry);
if (Logger.history.length > Logger.maxHistory) {
Logger.history = Logger.history.slice(-Logger.maxHistory);
}
for (const listener of Logger.listeners) {
try {
listener(entry);
} catch {
// ignore listener errors
}
}
}
static debug(message: string, category: string = 'general'): void { static debug(message: string, category: string = 'general'): void {
if (LogLevel.DEBUG >= Logger.minLevel) { if (LogLevel.DEBUG >= Logger.minLevel) {
console.debug(`[${category}] DEBUG: ${message}`); console.debug(`[${category}] DEBUG: ${message}`);
} }
Logger.record(LogLevel.DEBUG, 'DEBUG', message, category);
} }
static info(message: string, category: string = 'general'): void { static info(message: string, category: string = 'general'): void {
if (LogLevel.INFO >= Logger.minLevel) { if (LogLevel.INFO >= Logger.minLevel) {
console.info(`[${category}] INFO: ${message}`); console.info(`[${category}] INFO: ${message}`);
} }
Logger.record(LogLevel.INFO, 'INFO', message, category);
} }
static warn(message: string, category: string = 'general'): void { static warn(message: string, category: string = 'general'): void {
if (LogLevel.WARN >= Logger.minLevel) { if (LogLevel.WARN >= Logger.minLevel) {
console.warn(`[${category}] WARN: ${message}`); console.warn(`[${category}] WARN: ${message}`);
} }
Logger.record(LogLevel.WARN, 'WARN', message, category);
} }
static error(message: string, category: string = 'general'): void { static error(message: string, category: string = 'general'): void {
if (LogLevel.ERROR >= Logger.minLevel) { if (LogLevel.ERROR >= Logger.minLevel) {
console.error(`[${category}] ERROR: ${message}`); console.error(`[${category}] ERROR: ${message}`);
} }
Logger.record(LogLevel.ERROR, 'ERROR', message, category);
} }
} }
+1 -1
View File
@@ -58,7 +58,7 @@ export class WorkflowEngine {
} }
) { ) {
this.vaultIndexer = new VaultIndexer(vault); this.vaultIndexer = new VaultIndexer(vault);
this.toolExecutor = new ToolExecutor(vault, app); this.toolExecutor = new ToolExecutor(vault, app, undefined, this.vaultIndexer);
this.ollamaClient = new OllamaClient(ollamaUrl, model, undefined, options?.cacheConfig); this.ollamaClient = new OllamaClient(ollamaUrl, model, undefined, options?.cacheConfig);
this.conversationStateManager = new ConversationStateManager(); this.conversationStateManager = new ConversationStateManager();
this.maxSteps = options?.maxSteps ?? 20; this.maxSteps = options?.maxSteps ?? 20;
+84
View File
@@ -4,6 +4,11 @@
--ollama-border: var(--background-modifier-border); --ollama-border: var(--background-modifier-border);
--ollama-radius: var(--radius-m); --ollama-radius: var(--radius-m);
--ollama-gap: var(--size-4-2); --ollama-gap: var(--size-4-2);
--ollama-log-debug: var(--text-muted);
--ollama-log-info: var(--text-accent);
--ollama-log-warn: var(--text-warning);
--ollama-log-error: var(--text-error);
--ollama-log-bg: var(--background-primary-alt);
} }
.ollama-chat-container { .ollama-chat-container {
@@ -297,3 +302,82 @@
outline: none; outline: none;
border-color: var(--interactive-accent); border-color: var(--interactive-accent);
} }
/* Show Logs Button */
.ollama-show-logs-button {
display: inline-flex;
align-items: center;
gap: var(--size-4-1);
padding: var(--size-4-1) var(--size-4-2);
font-size: var(--font-smallest);
border-radius: var(--ollama-radius);
border: 1px solid var(--ollama-border);
background-color: var(--background-modifier-form-field);
color: var(--text-normal);
cursor: pointer;
transition: background-color 0.15s ease;
}
.ollama-show-logs-button:hover {
background-color: var(--background-modifier-hover);
}
/* Logs Panel */
.ollama-logs-container {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--size-4-1) var(--size-4-2);
max-height: 12rem;
overflow-y: auto;
background-color: var(--ollama-log-bg);
border-top: 1px solid var(--ollama-border);
border-bottom: 1px solid var(--ollama-border);
font-family: var(--font-monospace);
font-size: var(--font-smallest);
}
.ollama-log-row {
display: flex;
gap: var(--size-4-1);
align-items: baseline;
line-height: var(--line-height-tight);
}
.ollama-log-time {
color: var(--text-faint);
flex-shrink: 0;
}
.ollama-log-level {
flex-shrink: 0;
font-weight: var(--font-semibold);
text-transform: uppercase;
min-width: 3em;
}
.ollama-log-level-debug {
color: var(--ollama-log-debug);
}
.ollama-log-level-info {
color: var(--ollama-log-info);
}
.ollama-log-level-warn {
color: var(--ollama-log-warn);
}
.ollama-log-level-error {
color: var(--ollama-log-error);
}
.ollama-log-category {
color: var(--text-muted);
flex-shrink: 0;
}
.ollama-log-message {
color: var(--text-normal);
word-break: break-word;
}
+51
View File
@@ -725,6 +725,57 @@ describe('ToolExecutor', () => {
expect(result.data).toHaveLength(0); expect(result.data).toHaveLength(0);
expect(result.message).toContain('Found 0 matching files'); expect(result.message).toContain('Found 0 matching files');
}); });
it('should use VaultIndexer for rich search when available', async () => {
const mockIndexer = {
searchVault: jest.fn().mockResolvedValue([
{
path: 'Projects/AI/ml-basics.md',
title: 'Machine Learning Basics',
content: 'Intro to ML...',
score: 0.95,
tags: 'ai, ml, tutorial',
},
{
path: 'Projects/AI/deep-learning.md',
title: 'Deep Learning',
content: 'Neural networks...',
score: 0.88,
tags: 'ai, neural-networks',
},
]),
};
const indexedExecutor = new ToolExecutor(
mockVault as unknown as any,
mockApp as unknown as any,
undefined,
mockIndexer as unknown as any
);
const call: ToolCall = {
id: 'call_39',
type: 'function',
function: {
name: 'search_vault_files',
arguments: JSON.stringify({
query: 'machine learning',
limit: 5,
}),
},
};
const result = await indexedExecutor.handleToolCall(call);
expect(result.success).toBe(true);
expect(result.data).toHaveLength(2);
expect(mockIndexer.searchVault).toHaveBeenCalledWith('machine learning', 5);
const firstResult = (result.data as any[])[0];
expect(firstResult).toMatchObject({
path: 'Projects/AI/ml-basics.md',
basename: 'ml-basics.md',
title: 'Machine Learning Basics',
score: 0.95,
tags: 'ai, ml, tutorial',
});
});
}); });
describe('executeTool method', () => { describe('executeTool method', () => {