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:
@@ -3124,10 +3124,7 @@ var ALL_AGENT_MODES = ["ask", "edit", "organize", "research", "workflow"];
|
||||
function filterToolsByName(tools, allowed) {
|
||||
return tools.filter((t) => allowed.has(t.function.name));
|
||||
}
|
||||
var READ_TOOLS = /* @__PURE__ */ new Set([
|
||||
"read_vault_file",
|
||||
"search_vault_files"
|
||||
]);
|
||||
var READ_TOOLS = /* @__PURE__ */ new Set(["read_vault_file", "search_vault_files"]);
|
||||
var ORGANIZE_TOOLS = /* @__PURE__ */ new Set([
|
||||
"read_vault_file",
|
||||
"search_vault_files",
|
||||
@@ -3148,16 +3145,15 @@ var EDIT_TOOLS = /* @__PURE__ */ new Set([
|
||||
"delete_note",
|
||||
"insert_link"
|
||||
]);
|
||||
var RESEARCH_TOOLS = /* @__PURE__ */ new Set([
|
||||
"read_vault_file",
|
||||
"search_vault_files"
|
||||
]);
|
||||
var RESEARCH_TOOLS = /* @__PURE__ */ new Set(["read_vault_file", "search_vault_files"]);
|
||||
var AGENT_MODE_CONFIGS = {
|
||||
ask: {
|
||||
label: "Ask",
|
||||
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.
|
||||
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.
|
||||
If you cannot find relevant information, say so clearly.
|
||||
Do not make up facts.`,
|
||||
@@ -3170,6 +3166,8 @@ Do not make up facts.`,
|
||||
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.
|
||||
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:
|
||||
- Prefer modifying existing content over creating duplicates.
|
||||
- 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.",
|
||||
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.
|
||||
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:
|
||||
- Suggest consistent tag vocabularies.
|
||||
- Group related notes by linking them.
|
||||
@@ -3199,6 +3199,8 @@ When organizing:
|
||||
description: "Deep vault search and synthesis across multiple notes.",
|
||||
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.
|
||||
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.
|
||||
Cite specific notes and quotes where possible.
|
||||
If information is incomplete or contradictory, note it explicitly.`,
|
||||
@@ -3251,28 +3253,69 @@ var _Logger = class _Logger {
|
||||
_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") {
|
||||
if (0 /* DEBUG */ >= _Logger.minLevel) {
|
||||
console.debug(`[${category}] DEBUG: ${message}`);
|
||||
}
|
||||
_Logger.record(0 /* DEBUG */, "DEBUG", message, category);
|
||||
}
|
||||
static info(message, category = "general") {
|
||||
if (1 /* INFO */ >= _Logger.minLevel) {
|
||||
console.info(`[${category}] INFO: ${message}`);
|
||||
}
|
||||
_Logger.record(1 /* INFO */, "INFO", message, category);
|
||||
}
|
||||
static warn(message, category = "general") {
|
||||
if (2 /* WARN */ >= _Logger.minLevel) {
|
||||
console.warn(`[${category}] WARN: ${message}`);
|
||||
}
|
||||
_Logger.record(2 /* WARN */, "WARN", message, category);
|
||||
}
|
||||
static error(message, category = "general") {
|
||||
if (3 /* ERROR */ >= _Logger.minLevel) {
|
||||
console.error(`[${category}] ERROR: ${message}`);
|
||||
}
|
||||
_Logger.record(3 /* ERROR */, "ERROR", message, category);
|
||||
}
|
||||
};
|
||||
_Logger.minLevel = 0 /* DEBUG */;
|
||||
_Logger.listeners = [];
|
||||
_Logger.history = [];
|
||||
_Logger.maxHistory = 500;
|
||||
var Logger = _Logger;
|
||||
var MAX_JSON_SIZE = 1e6;
|
||||
var MAX_JSON_NESTING = 24;
|
||||
@@ -8772,10 +8815,11 @@ var INVALID_PATH_CHARS = /[<>:"|?*~]/;
|
||||
var MAX_PATH_LENGTH = 200;
|
||||
var FORBIDDEN_DIRS = [".obsidian", ".git"];
|
||||
var ToolExecutor = class {
|
||||
constructor(vault, app, telemetryManager) {
|
||||
constructor(vault, app, telemetryManager, vaultIndexer) {
|
||||
this.vault = vault;
|
||||
this.app = app;
|
||||
this.telemetryManager = telemetryManager;
|
||||
this.vaultIndexer = vaultIndexer;
|
||||
}
|
||||
isSafePath(path) {
|
||||
if (!path || path.trim().length === 0) {
|
||||
@@ -8856,7 +8900,7 @@ var ToolExecutor = class {
|
||||
result = await this.handleReadVaultFile(parsedArgs);
|
||||
break;
|
||||
case "search_vault_files":
|
||||
result = this.handleSearchVaultFiles(parsedArgs);
|
||||
result = await this.handleSearchVaultFiles(parsedArgs);
|
||||
break;
|
||||
case "append_to_note":
|
||||
result = await this.handleAppendToNote(parsedArgs);
|
||||
@@ -8945,13 +8989,28 @@ var ToolExecutor = class {
|
||||
data: { path, content }
|
||||
};
|
||||
}
|
||||
handleSearchVaultFiles(args) {
|
||||
async handleSearchVaultFiles(args) {
|
||||
const query = args.query;
|
||||
const limitArg = args.limit;
|
||||
if (typeof query !== "string") {
|
||||
throw new Error("Query must be a string");
|
||||
}
|
||||
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 files = this.vault.getMarkdownFiles().filter((file) => file.path.toLowerCase().includes(normalizedQuery)).slice(0, limit).map((file) => ({ path: file.path, basename: file.basename }));
|
||||
return {
|
||||
@@ -9505,8 +9564,11 @@ ${lines.join("\n")}
|
||||
};
|
||||
|
||||
// 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 {
|
||||
constructor() {
|
||||
constructor(initialSystemPrompt) {
|
||||
this.shortTermContext = [];
|
||||
this.mediumTermContext = [];
|
||||
this.longTermContext = [];
|
||||
@@ -9515,10 +9577,7 @@ var ConversationStateManager = class {
|
||||
this.longTermContext = [
|
||||
{
|
||||
role: "system",
|
||||
content: `You are an assistant that can help answer questions using the contents of a vault.
|
||||
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.`
|
||||
content: initialSystemPrompt ?? DEFAULT_SYSTEM_PROMPT
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -9546,12 +9605,15 @@ var ConversationStateManager = class {
|
||||
* Sets the user's persona or core knowledge as long-term context
|
||||
* @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) {
|
||||
this.longTermContext = this.longTermContext.filter(
|
||||
(msg) => msg.role !== "system" || !msg.content.includes(
|
||||
"You are an assistant that can help answer questions using the contents of a vault"
|
||||
)
|
||||
);
|
||||
this.longTermContext = this.longTermContext.filter((msg) => msg.role !== "system");
|
||||
this.longTermContext.push({
|
||||
role: "system",
|
||||
content: personaContent
|
||||
@@ -9589,16 +9651,13 @@ var ConversationStateManager = class {
|
||||
/**
|
||||
* Clears all conversation context
|
||||
*/
|
||||
clear() {
|
||||
clear(systemPrompt) {
|
||||
this.shortTermContext = [];
|
||||
this.mediumTermContext = [];
|
||||
this.longTermContext = [
|
||||
{
|
||||
role: "system",
|
||||
content: `You are an assistant that can help answer questions using the contents of a vault.
|
||||
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.`
|
||||
content: systemPrompt ?? DEFAULT_SYSTEM_PROMPT
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -9641,7 +9700,7 @@ var VARIABLE_PATTERN = /\{\{([\w.]+)\}\}/g;
|
||||
var WorkflowEngine = class _WorkflowEngine {
|
||||
constructor(vault, app, ollamaUrl, model, options) {
|
||||
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.conversationStateManager = new ConversationStateManager();
|
||||
this.maxSteps = options?.maxSteps ?? 20;
|
||||
@@ -10660,10 +10719,17 @@ var ChatView = class extends import_obsidian5.ItemView {
|
||||
this.newChatButtonClickWrapper = null;
|
||||
this.listenersAttached = false;
|
||||
this.modeSelectorEl = null;
|
||||
// Pending action state
|
||||
this.pendingActions = [];
|
||||
this.pendingReadResults = [];
|
||||
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.lastMessageEl = 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.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.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.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.telemetryManager = telemetryManager;
|
||||
this.workflowEngine = new WorkflowEngine(
|
||||
@@ -10695,6 +10768,11 @@ var ChatView = class extends import_obsidian5.ItemView {
|
||||
settings.agentModel ?? settings.model,
|
||||
{ cacheConfig: settings.cacheConfig }
|
||||
);
|
||||
this.removeLogListener = Logger.addListener((entry) => {
|
||||
if (this.logsVisible && this.logsContainer) {
|
||||
this.appendLogEntry(entry);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Getters for testing
|
||||
getSendButtonClickHandler() {
|
||||
@@ -10768,10 +10846,19 @@ var ChatView = class extends import_obsidian5.ItemView {
|
||||
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.inputEl = null;
|
||||
this.chatContainer = null;
|
||||
this.showLogsButton = null;
|
||||
this.logsContainer = null;
|
||||
return Promise.resolve();
|
||||
}
|
||||
cleanupStreamingResources() {
|
||||
@@ -10857,6 +10944,27 @@ var ChatView = class extends import_obsidian5.ItemView {
|
||||
} else {
|
||||
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) {
|
||||
this.inputEl = inputContainer.createEl("textarea", {
|
||||
cls: "ollama-input",
|
||||
@@ -10876,6 +10984,9 @@ var ChatView = class extends import_obsidian5.ItemView {
|
||||
this.contentEl.appendChild(newChatContainer);
|
||||
this.contentEl.appendChild(inputContainer);
|
||||
this.contentEl.appendChild(container);
|
||||
if (this.shouldAutoScroll && container) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
this.inputEl.focus();
|
||||
}
|
||||
setupEventListeners() {
|
||||
@@ -10894,6 +11005,10 @@ var ChatView = class extends import_obsidian5.ItemView {
|
||||
this.newChatButtonClickHandler = () => {
|
||||
this.clearConversation();
|
||||
};
|
||||
this.showLogsClickHandler = () => {
|
||||
this.logsVisible = !this.logsVisible;
|
||||
this.render();
|
||||
};
|
||||
if (this.sendButton && this.sendButtonClickHandler) {
|
||||
this.sendButton.addEventListener("click", this.sendButtonClickHandler);
|
||||
}
|
||||
@@ -10903,6 +11018,18 @@ var ChatView = class extends import_obsidian5.ItemView {
|
||||
if (this.newChatButton && 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;
|
||||
}
|
||||
removeEventListeners() {
|
||||
@@ -10918,6 +11045,12 @@ var ChatView = class extends import_obsidian5.ItemView {
|
||||
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() {
|
||||
@@ -10928,10 +11061,11 @@ var ChatView = class extends import_obsidian5.ItemView {
|
||||
if (this.modeSelectorEl) {
|
||||
this.modeSelectorEl.value = mode;
|
||||
}
|
||||
this.conversationStateManager.setSystemPrompt(getSystemPromptForMode(mode));
|
||||
}
|
||||
clearConversation() {
|
||||
this.messages = [];
|
||||
this.conversationStateManager.clear();
|
||||
this.conversationStateManager.clear(getSystemPromptForMode(this.currentAgentMode));
|
||||
this.render();
|
||||
}
|
||||
updateMessageById(id, updates) {
|
||||
@@ -11177,35 +11311,6 @@ var ChatView = class extends import_obsidian5.ItemView {
|
||||
}
|
||||
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) {
|
||||
const readToolCalls = toolCalls.filter((tc) => !isWriteTool(tc.function?.name ?? ""));
|
||||
const writeToolCalls = toolCalls.filter((tc) => isWriteTool(tc.function?.name ?? ""));
|
||||
@@ -11675,6 +11780,31 @@ ${actualMessage}` : actualMessage;
|
||||
isAgenticMode(mode) {
|
||||
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;
|
||||
|
||||
|
||||
+122
-3
@@ -27,6 +27,7 @@ import { ConversationStateManager } from './conversation-state';
|
||||
import { ErrorHandler } from './error-handler';
|
||||
import { StructuredMemoryManager } from './structured-memory';
|
||||
import { TelemetryManager } from './tool-telemetry';
|
||||
import { Logger, LogEntry } from './utils';
|
||||
|
||||
export const VIEW_TYPE_OLLAMA_CHAT = 'ollama-chat-view';
|
||||
|
||||
@@ -73,7 +74,12 @@ export class ChatView extends ItemView {
|
||||
? this.ollamaClient
|
||||
: this.createOllamaClient(settings.agentModel ?? settings.model, settings);
|
||||
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.noteContextBuilder = new NoteContextBuilder(this.app.vault, this.app, this.vaultIndexer);
|
||||
this.conversationStateManager = new ConversationStateManager(
|
||||
@@ -88,6 +94,13 @@ export class ChatView extends ItemView {
|
||||
settings.agentModel ?? settings.model,
|
||||
{ cacheConfig: settings.cacheConfig }
|
||||
);
|
||||
|
||||
// Subscribe to plugin logs
|
||||
this.removeLogListener = Logger.addListener((entry) => {
|
||||
if (this.logsVisible && this.logsContainer) {
|
||||
this.appendLogEntry(entry);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateSettings(newSettings: PluginSettings) {
|
||||
@@ -163,10 +176,19 @@ export class ChatView extends ItemView {
|
||||
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.inputEl = null;
|
||||
this.chatContainer = null;
|
||||
this.showLogsButton = null;
|
||||
this.logsContainer = null;
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
@@ -274,6 +296,32 @@ export class ChatView extends ItemView {
|
||||
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
|
||||
if (!this.inputEl) {
|
||||
this.inputEl = inputContainer.createEl('textarea', {
|
||||
@@ -299,6 +347,11 @@ export class ChatView extends ItemView {
|
||||
this.contentEl.appendChild(inputContainer);
|
||||
this.contentEl.appendChild(container);
|
||||
|
||||
// Auto-scroll chat to bottom if enabled
|
||||
if (this.shouldAutoScroll && container) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
|
||||
// Focus input on open
|
||||
this.inputEl.focus();
|
||||
}
|
||||
@@ -323,6 +376,11 @@ export class ChatView extends ItemView {
|
||||
this.clearConversation();
|
||||
};
|
||||
|
||||
this.showLogsClickHandler = () => {
|
||||
this.logsVisible = !this.logsVisible;
|
||||
this.render();
|
||||
};
|
||||
|
||||
if (this.sendButton && this.sendButtonClickHandler) {
|
||||
this.sendButton.addEventListener('click', this.sendButtonClickHandler);
|
||||
}
|
||||
@@ -335,6 +393,21 @@ export class ChatView extends ItemView {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -355,6 +428,14 @@ export class ChatView extends ItemView {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1217,8 +1298,6 @@ export class ChatView extends ItemView {
|
||||
|
||||
private modeSelectorEl: HTMLSelectElement | null = null;
|
||||
private currentAgentMode: AgentMode;
|
||||
|
||||
// Pending action state
|
||||
private pendingActions: ProposedAction[] = [];
|
||||
private pendingReadResults: (ToolResult & { id?: string })[] = [];
|
||||
private pendingFollowUpContext: {
|
||||
@@ -1227,6 +1306,15 @@ export class ChatView extends ItemView {
|
||||
assistantMessageId: 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;
|
||||
|
||||
private createOllamaClient(model: string, settings: PluginSettings): OllamaClient {
|
||||
return new OllamaClient(settings.ollamaUrl, model, undefined, settings.cacheConfig);
|
||||
}
|
||||
@@ -1251,6 +1339,37 @@ export class ChatView extends ItemView {
|
||||
private isAgenticMode(mode: AgentMode): boolean {
|
||||
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;
|
||||
|
||||
+31
-4
@@ -1,9 +1,10 @@
|
||||
// src/tool-executor.ts
|
||||
|
||||
import { Vault, App, TFile } from 'obsidian';
|
||||
import type { ToolCall, ToolResult } from './types';
|
||||
import type { ToolCall, ToolResult, VaultIndexEntry } from './types';
|
||||
import { safeParseJson } from './utils';
|
||||
import { TelemetryManager } from './tool-telemetry';
|
||||
import { VaultIndexer } from './vault-indexer';
|
||||
|
||||
// Disallow characters that are invalid in file paths
|
||||
const INVALID_PATH_CHARS = /[<>:"|?*~]/;
|
||||
@@ -14,11 +15,18 @@ export class ToolExecutor {
|
||||
private vault: Vault;
|
||||
private app: App;
|
||||
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.app = app;
|
||||
this.telemetryManager = telemetryManager;
|
||||
this.vaultIndexer = vaultIndexer;
|
||||
}
|
||||
|
||||
private isSafePath(path: string): boolean {
|
||||
@@ -126,7 +134,7 @@ export class ToolExecutor {
|
||||
result = await this.handleReadVaultFile(parsedArgs);
|
||||
break;
|
||||
case 'search_vault_files':
|
||||
result = this.handleSearchVaultFiles(parsedArgs);
|
||||
result = await this.handleSearchVaultFiles(parsedArgs);
|
||||
break;
|
||||
case 'append_to_note':
|
||||
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 limitArg = args.limit;
|
||||
|
||||
@@ -236,6 +244,25 @@ export class ToolExecutor {
|
||||
}
|
||||
|
||||
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 files = this.vault
|
||||
.getMarkdownFiles()
|
||||
|
||||
@@ -12,8 +12,19 @@ const SEVERITY_ORDER: Record<string, number> = {
|
||||
error: LogLevel.ERROR,
|
||||
};
|
||||
|
||||
export interface LogEntry {
|
||||
timestamp: number;
|
||||
level: LogLevel;
|
||||
levelLabel: string;
|
||||
category: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export class Logger {
|
||||
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 {
|
||||
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 {
|
||||
if (LogLevel.DEBUG >= Logger.minLevel) {
|
||||
console.debug(`[${category}] DEBUG: ${message}`);
|
||||
}
|
||||
Logger.record(LogLevel.DEBUG, 'DEBUG', message, category);
|
||||
}
|
||||
|
||||
static info(message: string, category: string = 'general'): void {
|
||||
if (LogLevel.INFO >= Logger.minLevel) {
|
||||
console.info(`[${category}] INFO: ${message}`);
|
||||
}
|
||||
Logger.record(LogLevel.INFO, 'INFO', message, category);
|
||||
}
|
||||
|
||||
static warn(message: string, category: string = 'general'): void {
|
||||
if (LogLevel.WARN >= Logger.minLevel) {
|
||||
console.warn(`[${category}] WARN: ${message}`);
|
||||
}
|
||||
Logger.record(LogLevel.WARN, 'WARN', message, category);
|
||||
}
|
||||
|
||||
static error(message: string, category: string = 'general'): void {
|
||||
if (LogLevel.ERROR >= Logger.minLevel) {
|
||||
console.error(`[${category}] ERROR: ${message}`);
|
||||
}
|
||||
Logger.record(LogLevel.ERROR, 'ERROR', message, category);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ export class WorkflowEngine {
|
||||
}
|
||||
) {
|
||||
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.conversationStateManager = new ConversationStateManager();
|
||||
this.maxSteps = options?.maxSteps ?? 20;
|
||||
|
||||
+84
@@ -4,6 +4,11 @@
|
||||
--ollama-border: var(--background-modifier-border);
|
||||
--ollama-radius: var(--radius-m);
|
||||
--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 {
|
||||
@@ -297,3 +302,82 @@
|
||||
outline: none;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -725,6 +725,57 @@ describe('ToolExecutor', () => {
|
||||
expect(result.data).toHaveLength(0);
|
||||
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', () => {
|
||||
|
||||
Reference in New Issue
Block a user