Add automatic folder creation for file operations and improve chat session handling
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.
This commit is contained in:
@@ -5,11 +5,13 @@ export class Vault {
|
|||||||
getMarkdownFiles: () => any[];
|
getMarkdownFiles: () => any[];
|
||||||
read: (file: any) => Promise<string>;
|
read: (file: any) => Promise<string>;
|
||||||
create: (path: string, content: string) => Promise<any>;
|
create: (path: string, content: string) => Promise<any>;
|
||||||
|
createFolder: (path: string) => Promise<any>;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.getMarkdownFiles = () => [];
|
this.getMarkdownFiles = () => [];
|
||||||
this.read = async () => '';
|
this.read = async () => '';
|
||||||
this.create = async () => null;
|
this.create = async () => null;
|
||||||
|
this.createFolder = async () => null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,6 +115,14 @@ export interface TFile {
|
|||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class TFolder {
|
||||||
|
path: string;
|
||||||
|
|
||||||
|
constructor(path: string = '') {
|
||||||
|
this.path = path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Plugin class (used by main.ts)
|
// Plugin class (used by main.ts)
|
||||||
export class Plugin {
|
export class Plugin {
|
||||||
app: App;
|
app: App;
|
||||||
|
|||||||
@@ -8968,6 +8968,30 @@ var ToolExecutor = class {
|
|||||||
const file = this.getFile(path);
|
const file = this.getFile(path);
|
||||||
await this.vault.modify(file, content);
|
await this.vault.modify(file, content);
|
||||||
}
|
}
|
||||||
|
getParentFolderPath(path) {
|
||||||
|
const parts = path.split("/").filter((part) => part.length > 0);
|
||||||
|
parts.pop();
|
||||||
|
return parts.join("/");
|
||||||
|
}
|
||||||
|
async ensureFolderExists(folderPath) {
|
||||||
|
const normalizedFolder = folderPath.replace(/\/$/, "").trim();
|
||||||
|
if (!normalizedFolder) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parts = normalizedFolder.split("/").filter((part) => part.length > 0);
|
||||||
|
let currentPath = "";
|
||||||
|
for (const part of parts) {
|
||||||
|
currentPath = currentPath ? `${currentPath}/${part}` : part;
|
||||||
|
const existing = this.vault.getAbstractFileByPath(currentPath);
|
||||||
|
if (existing) {
|
||||||
|
if (!(existing instanceof import_obsidian.TFolder)) {
|
||||||
|
throw new Error(`Cannot create folder ${currentPath}: a file already exists at that path`);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await this.vault.createFolder(currentPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
async handleToolCall(toolCall) {
|
async handleToolCall(toolCall) {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const toolName = toolCall.function?.name ?? "unknown";
|
const toolName = toolCall.function?.name ?? "unknown";
|
||||||
@@ -9062,6 +9086,7 @@ var ToolExecutor = class {
|
|||||||
throw new Error("Invalid file path detected");
|
throw new Error("Invalid file path detected");
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
await this.ensureFolderExists(this.getParentFolderPath(path));
|
||||||
await this.vault.create(path, content);
|
await this.vault.create(path, content);
|
||||||
return { success: true, message: "Note created successfully" };
|
return { success: true, message: "Note created successfully" };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -9285,6 +9310,7 @@ ${lines.join("\n")}
|
|||||||
throw new Error("Invalid file path detected");
|
throw new Error("Invalid file path detected");
|
||||||
}
|
}
|
||||||
const file = this.getFile(oldPath);
|
const file = this.getFile(oldPath);
|
||||||
|
await this.ensureFolderExists(this.getParentFolderPath(newPath));
|
||||||
await this.vault.rename(file, newPath);
|
await this.vault.rename(file, newPath);
|
||||||
return { success: true, message: `Note renamed from ${oldPath} to ${newPath}` };
|
return { success: true, message: `Note renamed from ${oldPath} to ${newPath}` };
|
||||||
}
|
}
|
||||||
@@ -9307,6 +9333,7 @@ ${lines.join("\n")}
|
|||||||
const file = this.getFile(path);
|
const file = this.getFile(path);
|
||||||
const fileName = file.name;
|
const fileName = file.name;
|
||||||
const newPath = normalizedFolder ? `${normalizedFolder}/${fileName}` : fileName;
|
const newPath = normalizedFolder ? `${normalizedFolder}/${fileName}` : fileName;
|
||||||
|
await this.ensureFolderExists(normalizedFolder);
|
||||||
await this.vault.rename(file, newPath);
|
await this.vault.rename(file, newPath);
|
||||||
return { success: true, message: `Note moved to ${newPath}` };
|
return { success: true, message: `Note moved to ${newPath}` };
|
||||||
}
|
}
|
||||||
@@ -11022,6 +11049,7 @@ var ChatView = class extends import_obsidian5.ItemView {
|
|||||||
newSettings.agentModel ?? newSettings.model,
|
newSettings.agentModel ?? newSettings.model,
|
||||||
{ cacheConfig: newSettings.cacheConfig }
|
{ cacheConfig: newSettings.cacheConfig }
|
||||||
);
|
);
|
||||||
|
this.conversationStateManager.setSystemPrompt(getSystemPromptForMode(this.currentAgentMode));
|
||||||
void this.initializeClientCaches().catch(() => {
|
void this.initializeClientCaches().catch(() => {
|
||||||
new import_obsidian5.Notice(
|
new import_obsidian5.Notice(
|
||||||
"Semantic cache unavailable: could not connect to ChromaDB. Check the ChromaDB URL in settings."
|
"Semantic cache unavailable: could not connect to ChromaDB. Check the ChromaDB URL in settings."
|
||||||
@@ -11171,7 +11199,7 @@ var ChatView = class extends import_obsidian5.ItemView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.modeSelectorEl.addEventListener("change", () => {
|
this.modeSelectorEl.addEventListener("change", () => {
|
||||||
this.currentAgentMode = this.modeSelectorEl.value;
|
this.setAgentMode(this.modeSelectorEl.value);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
newChatContainer.appendChild(this.modeSelectorEl);
|
newChatContainer.appendChild(this.modeSelectorEl);
|
||||||
@@ -11368,6 +11396,10 @@ var ChatView = class extends import_obsidian5.ItemView {
|
|||||||
this.modeSelectorEl.value = mode;
|
this.modeSelectorEl.value = mode;
|
||||||
}
|
}
|
||||||
this.conversationStateManager.setSystemPrompt(getSystemPromptForMode(mode));
|
this.conversationStateManager.setSystemPrompt(getSystemPromptForMode(mode));
|
||||||
|
const activeId = this.chatHistoryManager?.getActiveSessionId();
|
||||||
|
if (activeId) {
|
||||||
|
this.chatHistoryManager?.updateSession(activeId, { agentMode: mode });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
clearConversation() {
|
clearConversation() {
|
||||||
this.saveCurrentSession();
|
this.saveCurrentSession();
|
||||||
@@ -11409,14 +11441,28 @@ var ChatView = class extends import_obsidian5.ItemView {
|
|||||||
const activeId = this.chatHistoryManager.getActiveSessionId();
|
const activeId = this.chatHistoryManager.getActiveSessionId();
|
||||||
if (!activeId) return;
|
if (!activeId) return;
|
||||||
const nonStreamingMessages = this.messages.filter((msg) => !msg.isStreaming);
|
const nonStreamingMessages = this.messages.filter((msg) => !msg.isStreaming);
|
||||||
this.chatHistoryManager.updateSessionMessages(activeId, nonStreamingMessages);
|
this.chatHistoryManager.updateSession(activeId, {
|
||||||
|
messages: nonStreamingMessages,
|
||||||
|
agentMode: this.currentAgentMode,
|
||||||
|
title: this.deriveSessionTitle(nonStreamingMessages)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
syncMessagesToSession() {
|
syncMessagesToSession() {
|
||||||
if (!this.chatHistoryManager) return;
|
if (!this.chatHistoryManager) return;
|
||||||
const activeId = this.chatHistoryManager.getActiveSessionId();
|
const activeId = this.chatHistoryManager.getActiveSessionId();
|
||||||
if (!activeId) return;
|
if (!activeId) return;
|
||||||
const nonStreamingMessages = this.messages.filter((msg) => !msg.isStreaming);
|
const nonStreamingMessages = this.messages.filter((msg) => !msg.isStreaming);
|
||||||
this.chatHistoryManager.updateSessionMessages(activeId, nonStreamingMessages);
|
this.chatHistoryManager.updateSession(activeId, {
|
||||||
|
messages: nonStreamingMessages,
|
||||||
|
agentMode: this.currentAgentMode,
|
||||||
|
title: this.deriveSessionTitle(nonStreamingMessages)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
deriveSessionTitle(messages) {
|
||||||
|
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) + "\u2026" : text;
|
||||||
}
|
}
|
||||||
populateHistoryDropdown() {
|
populateHistoryDropdown() {
|
||||||
if (!this.historySelectEl) return;
|
if (!this.historySelectEl) return;
|
||||||
@@ -11785,7 +11831,7 @@ var ChatView = class extends import_obsidian5.ItemView {
|
|||||||
if (writePreviews.length > 0 && modeRequiresPreview(this.currentAgentMode)) {
|
if (writePreviews.length > 0 && modeRequiresPreview(this.currentAgentMode)) {
|
||||||
this.pendingActions = writePreviews;
|
this.pendingActions = writePreviews;
|
||||||
this.pendingReadResults = readResults;
|
this.pendingReadResults = readResults;
|
||||||
this.pendingFollowUpContext = { messages, tools, assistantMessageId };
|
this.pendingFollowUpContext = { messages, tools, assistantMessageId, allToolCalls: toolCalls, assistantText: fullResponse };
|
||||||
this.updateMessageById(assistantMessageId, {
|
this.updateMessageById(assistantMessageId, {
|
||||||
content: `${fullResponse}
|
content: `${fullResponse}
|
||||||
|
|
||||||
@@ -11800,17 +11846,21 @@ ${writePreviews.map((a) => `- ${a.description}`).join("\n")}`,
|
|||||||
}
|
}
|
||||||
let writeResults = [];
|
let writeResults = [];
|
||||||
if (writePreviews.length > 0 && !modeRequiresPreview(this.currentAgentMode)) {
|
if (writePreviews.length > 0 && !modeRequiresPreview(this.currentAgentMode)) {
|
||||||
writeResults = (await Promise.all(
|
writeResults = await Promise.all(
|
||||||
writePreviews.map(async (action) => {
|
writePreviews.map(async (action) => {
|
||||||
try {
|
try {
|
||||||
const toolResult = await this.toolExecutor.handleToolCall(action.toolCall);
|
const toolResult = await this.toolExecutor.handleToolCall(action.toolCall);
|
||||||
return { ...toolResult, id: action.toolCall.id };
|
return { ...toolResult, id: action.toolCall.id };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ErrorHandler.handleError(error, "ChatView.processToolCalls");
|
ErrorHandler.handleError(error, "ChatView.processToolCalls");
|
||||||
return null;
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
id: action.toolCall.id
|
||||||
|
};
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
)).filter((result) => result !== null);
|
);
|
||||||
}
|
}
|
||||||
const allResults = [...readResults, ...writeResults];
|
const allResults = [...readResults, ...writeResults];
|
||||||
const followUpMessages = allResults.map((result) => ({
|
const followUpMessages = allResults.map((result) => ({
|
||||||
@@ -11868,19 +11918,24 @@ ${writePreviews.map((a) => `- ${a.description}`).join("\n")}`,
|
|||||||
if (this.pendingActions.length === 0 || !this.pendingFollowUpContext) {
|
if (this.pendingActions.length === 0 || !this.pendingFollowUpContext) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { messages, tools, assistantMessageId } = this.pendingFollowUpContext;
|
const { messages, tools, assistantMessageId, allToolCalls, assistantText } = this.pendingFollowUpContext;
|
||||||
const writeResults = (await Promise.all(
|
const writeResults = await Promise.all(
|
||||||
this.pendingActions.map(async (action) => {
|
this.pendingActions.map(async (action) => {
|
||||||
try {
|
try {
|
||||||
const toolResult = await this.toolExecutor.handleToolCall(action.toolCall);
|
const toolResult = await this.toolExecutor.handleToolCall(action.toolCall);
|
||||||
return { ...toolResult, id: action.toolCall.id };
|
return { ...toolResult, id: action.toolCall.id };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ErrorHandler.handleError(error, "ChatView.applyPendingActions");
|
ErrorHandler.handleError(error, "ChatView.applyPendingActions");
|
||||||
return null;
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
id: action.toolCall.id
|
||||||
|
};
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
)).filter((result) => result !== null);
|
);
|
||||||
const allResults = [...this.pendingReadResults, ...writeResults];
|
const allResults = [...this.pendingReadResults, ...writeResults];
|
||||||
|
const failedWrites = writeResults.filter((result) => !result.success);
|
||||||
const followUpMessages = allResults.map((result) => ({
|
const followUpMessages = allResults.map((result) => ({
|
||||||
role: "tool",
|
role: "tool",
|
||||||
content: JSON.stringify(result),
|
content: JSON.stringify(result),
|
||||||
@@ -11888,8 +11943,8 @@ ${writePreviews.map((a) => `- ${a.description}`).join("\n")}`,
|
|||||||
}));
|
}));
|
||||||
const followUp = {
|
const followUp = {
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: "I have processed your request using the following tools. Here are the results:",
|
content: assistantText,
|
||||||
tool_calls: this.pendingActions.map((a) => a.toolCall)
|
tool_calls: allToolCalls
|
||||||
};
|
};
|
||||||
if (followUpMessages.length > 0) {
|
if (followUpMessages.length > 0) {
|
||||||
const finalMessages = [...messages, followUp, ...followUpMessages];
|
const finalMessages = [...messages, followUp, ...followUpMessages];
|
||||||
@@ -11920,14 +11975,16 @@ ${writePreviews.map((a) => `- ${a.description}`).join("\n")}`,
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
this.updateMessageById(assistantMessageId, {
|
this.updateMessageById(assistantMessageId, {
|
||||||
content: followUpContent || "Actions applied successfully.",
|
content: followUpContent || (failedWrites.length > 0 ? `Some actions failed:
|
||||||
|
${failedWrites.map((r) => `- ${r.message}`).join("\n")}` : "Actions applied successfully."),
|
||||||
isStreaming: false,
|
isStreaming: false,
|
||||||
isThinking: false
|
isThinking: false
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.updateMessageById(assistantMessageId, {
|
this.updateMessageById(assistantMessageId, {
|
||||||
content: "Actions applied successfully.",
|
content: failedWrites.length > 0 ? `Actions failed:
|
||||||
|
${failedWrites.map((r) => `- ${r.message}`).join("\n")}` : "Actions applied successfully.",
|
||||||
isStreaming: false,
|
isStreaming: false,
|
||||||
isThinking: false
|
isThinking: false
|
||||||
});
|
});
|
||||||
@@ -12477,7 +12534,7 @@ ${actualMessage}` : actualMessage;
|
|||||||
return this.isAgenticMode(this.currentAgentMode) ? this.agentOllamaClient : this.ollamaClient;
|
return this.isAgenticMode(this.currentAgentMode) ? this.agentOllamaClient : this.ollamaClient;
|
||||||
}
|
}
|
||||||
isAgenticMode(mode) {
|
isAgenticMode(mode) {
|
||||||
return mode === "edit" || mode === "organize" || mode === "workflow";
|
return mode === "edit" || mode === "organize" || mode === "research" || mode === "workflow";
|
||||||
}
|
}
|
||||||
renderLogEntry(entry, container) {
|
renderLogEntry(entry, container) {
|
||||||
const row = container.createEl("div", { cls: "ollama-log-row" });
|
const row = container.createEl("div", { cls: "ollama-log-row" });
|
||||||
|
|||||||
+45
-9
@@ -140,6 +140,7 @@ export class ChatView extends ItemView {
|
|||||||
newSettings.agentModel ?? newSettings.model,
|
newSettings.agentModel ?? newSettings.model,
|
||||||
{ cacheConfig: newSettings.cacheConfig }
|
{ cacheConfig: newSettings.cacheConfig }
|
||||||
);
|
);
|
||||||
|
this.conversationStateManager.setSystemPrompt(getSystemPromptForMode(this.currentAgentMode));
|
||||||
void this.initializeClientCaches().catch(() => {
|
void this.initializeClientCaches().catch(() => {
|
||||||
new Notice(
|
new Notice(
|
||||||
'Semantic cache unavailable: could not connect to ChromaDB. Check the ChromaDB URL in settings.'
|
'Semantic cache unavailable: could not connect to ChromaDB. Check the ChromaDB URL in settings.'
|
||||||
@@ -318,7 +319,7 @@ export class ChatView extends ItemView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.modeSelectorEl.addEventListener('change', () => {
|
this.modeSelectorEl.addEventListener('change', () => {
|
||||||
this.currentAgentMode = this.modeSelectorEl!.value as AgentMode;
|
this.setAgentMode(this.modeSelectorEl!.value as AgentMode);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
newChatContainer.appendChild(this.modeSelectorEl);
|
newChatContainer.appendChild(this.modeSelectorEl);
|
||||||
@@ -561,6 +562,10 @@ export class ChatView extends ItemView {
|
|||||||
this.modeSelectorEl.value = mode;
|
this.modeSelectorEl.value = mode;
|
||||||
}
|
}
|
||||||
this.conversationStateManager.setSystemPrompt(getSystemPromptForMode(mode));
|
this.conversationStateManager.setSystemPrompt(getSystemPromptForMode(mode));
|
||||||
|
const activeId = this.chatHistoryManager?.getActiveSessionId();
|
||||||
|
if (activeId) {
|
||||||
|
this.chatHistoryManager?.updateSession(activeId, { agentMode: mode });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
clearConversation(): void {
|
clearConversation(): void {
|
||||||
@@ -610,7 +615,11 @@ export class ChatView extends ItemView {
|
|||||||
const activeId = this.chatHistoryManager.getActiveSessionId();
|
const activeId = this.chatHistoryManager.getActiveSessionId();
|
||||||
if (!activeId) return;
|
if (!activeId) return;
|
||||||
const nonStreamingMessages = this.messages.filter((msg) => !msg.isStreaming);
|
const nonStreamingMessages = this.messages.filter((msg) => !msg.isStreaming);
|
||||||
this.chatHistoryManager.updateSessionMessages(activeId, nonStreamingMessages);
|
this.chatHistoryManager.updateSession(activeId, {
|
||||||
|
messages: nonStreamingMessages,
|
||||||
|
agentMode: this.currentAgentMode,
|
||||||
|
title: this.deriveSessionTitle(nonStreamingMessages),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private syncMessagesToSession(): void {
|
private syncMessagesToSession(): void {
|
||||||
@@ -618,7 +627,18 @@ export class ChatView extends ItemView {
|
|||||||
const activeId = this.chatHistoryManager.getActiveSessionId();
|
const activeId = this.chatHistoryManager.getActiveSessionId();
|
||||||
if (!activeId) return;
|
if (!activeId) return;
|
||||||
const nonStreamingMessages = this.messages.filter((msg) => !msg.isStreaming);
|
const nonStreamingMessages = this.messages.filter((msg) => !msg.isStreaming);
|
||||||
this.chatHistoryManager.updateSessionMessages(activeId, nonStreamingMessages);
|
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 {
|
private populateHistoryDropdown(): void {
|
||||||
@@ -1053,11 +1073,15 @@ export class ChatView extends ItemView {
|
|||||||
return { ...toolResult, id: action.toolCall.id };
|
return { ...toolResult, id: action.toolCall.id };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ErrorHandler.handleError(error, 'ChatView.processToolCalls');
|
ErrorHandler.handleError(error, 'ChatView.processToolCalls');
|
||||||
return null;
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
id: action.toolCall.id,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
).filter((result): result is NonNullable<typeof result> => result !== null);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// No write tools (or they were already executed) — proceed with follow-up
|
// No write tools (or they were already executed) — proceed with follow-up
|
||||||
@@ -1137,13 +1161,18 @@ export class ChatView extends ItemView {
|
|||||||
return { ...toolResult, id: action.toolCall.id };
|
return { ...toolResult, id: action.toolCall.id };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ErrorHandler.handleError(error, 'ChatView.applyPendingActions');
|
ErrorHandler.handleError(error, 'ChatView.applyPendingActions');
|
||||||
return null;
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
id: action.toolCall.id,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
).filter((result): result is NonNullable<typeof result> => result !== null);
|
);
|
||||||
|
|
||||||
const allResults = [...this.pendingReadResults, ...writeResults];
|
const allResults = [...this.pendingReadResults, ...writeResults];
|
||||||
|
const failedWrites = writeResults.filter((result) => !result.success);
|
||||||
|
|
||||||
const followUpMessages: OllamaMessage[] = allResults.map((result) => ({
|
const followUpMessages: OllamaMessage[] = allResults.map((result) => ({
|
||||||
role: 'tool',
|
role: 'tool',
|
||||||
@@ -1191,14 +1220,21 @@ export class ChatView extends ItemView {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
this.updateMessageById(assistantMessageId, {
|
this.updateMessageById(assistantMessageId, {
|
||||||
content: followUpContent || 'Actions applied successfully.',
|
content:
|
||||||
|
followUpContent ||
|
||||||
|
(failedWrites.length > 0
|
||||||
|
? `Some actions failed:\n${failedWrites.map((r) => `- ${r.message}`).join('\n')}`
|
||||||
|
: 'Actions applied successfully.'),
|
||||||
isStreaming: false,
|
isStreaming: false,
|
||||||
isThinking: false,
|
isThinking: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.updateMessageById(assistantMessageId, {
|
this.updateMessageById(assistantMessageId, {
|
||||||
content: 'Actions applied successfully.',
|
content:
|
||||||
|
failedWrites.length > 0
|
||||||
|
? `Actions failed:\n${failedWrites.map((r) => `- ${r.message}`).join('\n')}`
|
||||||
|
: 'Actions applied successfully.',
|
||||||
isStreaming: false,
|
isStreaming: false,
|
||||||
isThinking: false,
|
isThinking: false,
|
||||||
});
|
});
|
||||||
|
|||||||
+31
-1
@@ -1,6 +1,6 @@
|
|||||||
// src/tool-executor.ts
|
// src/tool-executor.ts
|
||||||
|
|
||||||
import { Vault, App, TFile } from 'obsidian';
|
import { Vault, App, TFile, TFolder } from 'obsidian';
|
||||||
import type { ToolCall, ToolResult, VaultIndexEntry } 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';
|
||||||
@@ -97,6 +97,33 @@ export class ToolExecutor {
|
|||||||
await this.vault.modify(file, content);
|
await this.vault.modify(file, content);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getParentFolderPath(path: string): string {
|
||||||
|
const parts = path.split('/').filter((part) => part.length > 0);
|
||||||
|
parts.pop();
|
||||||
|
return parts.join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureFolderExists(folderPath: string): Promise<void> {
|
||||||
|
const normalizedFolder = folderPath.replace(/\/$/, '').trim();
|
||||||
|
if (!normalizedFolder) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = normalizedFolder.split('/').filter((part) => part.length > 0);
|
||||||
|
let currentPath = '';
|
||||||
|
for (const part of parts) {
|
||||||
|
currentPath = currentPath ? `${currentPath}/${part}` : part;
|
||||||
|
const existing = this.vault.getAbstractFileByPath(currentPath);
|
||||||
|
if (existing) {
|
||||||
|
if (!(existing instanceof TFolder)) {
|
||||||
|
throw new Error(`Cannot create folder ${currentPath}: a file already exists at that path`);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await this.vault.createFolder(currentPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async handleToolCall(toolCall: ToolCall): Promise<ToolResult> {
|
async handleToolCall(toolCall: ToolCall): Promise<ToolResult> {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const toolName = toolCall.function?.name ?? 'unknown';
|
const toolName = toolCall.function?.name ?? 'unknown';
|
||||||
@@ -203,6 +230,7 @@ export class ToolExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await this.ensureFolderExists(this.getParentFolderPath(path));
|
||||||
await this.vault.create(path, content);
|
await this.vault.create(path, content);
|
||||||
return { success: true, message: 'Note created successfully' };
|
return { success: true, message: 'Note created successfully' };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -494,6 +522,7 @@ export class ToolExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const file = this.getFile(oldPath);
|
const file = this.getFile(oldPath);
|
||||||
|
await this.ensureFolderExists(this.getParentFolderPath(newPath));
|
||||||
await this.vault.rename(file, newPath);
|
await this.vault.rename(file, newPath);
|
||||||
return { success: true, message: `Note renamed from ${oldPath} to ${newPath}` };
|
return { success: true, message: `Note renamed from ${oldPath} to ${newPath}` };
|
||||||
}
|
}
|
||||||
@@ -522,6 +551,7 @@ export class ToolExecutor {
|
|||||||
const fileName = file.name;
|
const fileName = file.name;
|
||||||
const newPath = normalizedFolder ? `${normalizedFolder}/${fileName}` : fileName;
|
const newPath = normalizedFolder ? `${normalizedFolder}/${fileName}` : fileName;
|
||||||
|
|
||||||
|
await this.ensureFolderExists(normalizedFolder);
|
||||||
await this.vault.rename(file, newPath);
|
await this.vault.rename(file, newPath);
|
||||||
return { success: true, message: `Note moved to ${newPath}` };
|
return { success: true, message: `Note moved to ${newPath}` };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -234,6 +234,18 @@ describe('ChatView', () => {
|
|||||||
const messages = view.contentEl.querySelectorAll('.ollama-message');
|
const messages = view.contentEl.querySelectorAll('.ollama-message');
|
||||||
expect(messages.length).toBe(1);
|
expect(messages.length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should update the system prompt when mode selector changes', async () => {
|
||||||
|
await view.render();
|
||||||
|
const selector = view.contentEl.querySelector('.ollama-mode-selector') as HTMLSelectElement;
|
||||||
|
selector.appendChild(new Option('Edit', 'edit'));
|
||||||
|
selector.value = 'edit';
|
||||||
|
selector.dispatchEvent(new Event('change'));
|
||||||
|
|
||||||
|
const systemPrompt = view['conversationStateManager'].getLongTermContext()[0].content;
|
||||||
|
expect(view.getAgentMode()).toBe('edit');
|
||||||
|
expect(systemPrompt).toContain('helps edit and manage notes');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('handleUserInput', () => {
|
describe('handleUserInput', () => {
|
||||||
@@ -600,6 +612,8 @@ describe('ChatView', () => {
|
|||||||
messages: [],
|
messages: [],
|
||||||
tools: [],
|
tools: [],
|
||||||
assistantMessageId,
|
assistantMessageId,
|
||||||
|
allToolCalls: [],
|
||||||
|
assistantText: 'test',
|
||||||
};
|
};
|
||||||
|
|
||||||
const followUpSpy = jest
|
const followUpSpy = jest
|
||||||
@@ -653,6 +667,8 @@ describe('ChatView', () => {
|
|||||||
messages: [],
|
messages: [],
|
||||||
tools: [],
|
tools: [],
|
||||||
assistantMessageId,
|
assistantMessageId,
|
||||||
|
allToolCalls: [],
|
||||||
|
assistantText: 'Proposed actions...',
|
||||||
};
|
};
|
||||||
|
|
||||||
view.cancelPendingActions();
|
view.cancelPendingActions();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ToolExecutor } from '../src/tool-executor';
|
import { ToolExecutor } from '../src/tool-executor';
|
||||||
import { TFile } from 'obsidian';
|
import { TFile, TFolder } from 'obsidian';
|
||||||
import { ToolCall, ToolResult } from '../src/types';
|
import { ToolCall, ToolResult } from '../src/types';
|
||||||
import { ErrorHandler } from '../src/error-handler';
|
import { ErrorHandler } from '../src/error-handler';
|
||||||
import { TelemetryManager } from '../src/tool-telemetry';
|
import { TelemetryManager } from '../src/tool-telemetry';
|
||||||
@@ -7,6 +7,7 @@ import { TelemetryManager } from '../src/tool-telemetry';
|
|||||||
// Mock Obsidian types
|
// Mock Obsidian types
|
||||||
interface MockVault {
|
interface MockVault {
|
||||||
create: (path: string, content: string) => Promise<any>;
|
create: (path: string, content: string) => Promise<any>;
|
||||||
|
createFolder: (path: string) => Promise<any>;
|
||||||
getAbstractFileByPath: (path: string) => any;
|
getAbstractFileByPath: (path: string) => any;
|
||||||
cachedRead: (file: any) => Promise<string>;
|
cachedRead: (file: any) => Promise<string>;
|
||||||
getMarkdownFiles: () => any[];
|
getMarkdownFiles: () => any[];
|
||||||
@@ -32,6 +33,7 @@ jest.mock('obsidian', () => {
|
|||||||
App: jest.fn(),
|
App: jest.fn(),
|
||||||
Notice: jest.fn(),
|
Notice: jest.fn(),
|
||||||
TFile,
|
TFile,
|
||||||
|
TFolder: class TFolder {},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -50,6 +52,7 @@ describe('ToolExecutor', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockVault = {
|
mockVault = {
|
||||||
create: jest.fn().mockResolvedValue(null),
|
create: jest.fn().mockResolvedValue(null),
|
||||||
|
createFolder: jest.fn().mockResolvedValue(null),
|
||||||
getAbstractFileByPath: jest.fn(),
|
getAbstractFileByPath: jest.fn(),
|
||||||
cachedRead: jest.fn().mockResolvedValue(''),
|
cachedRead: jest.fn().mockResolvedValue(''),
|
||||||
getMarkdownFiles: jest.fn().mockReturnValue([]),
|
getMarkdownFiles: jest.fn().mockReturnValue([]),
|
||||||
@@ -103,6 +106,7 @@ describe('ToolExecutor', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should successfully create a file in a subdirectory', async () => {
|
it('should successfully create a file in a subdirectory', async () => {
|
||||||
|
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(null);
|
||||||
const call: ToolCall = {
|
const call: ToolCall = {
|
||||||
id: 'call_3',
|
id: 'call_3',
|
||||||
type: 'function',
|
type: 'function',
|
||||||
@@ -116,12 +120,32 @@ describe('ToolExecutor', () => {
|
|||||||
};
|
};
|
||||||
const result = await executor.handleToolCall(call);
|
const result = await executor.handleToolCall(call);
|
||||||
expect(result).toEqual({ success: true, message: 'Note created successfully' });
|
expect(result).toEqual({ success: true, message: 'Note created successfully' });
|
||||||
|
expect(mockVault.createFolder).toHaveBeenCalledWith('subdirectory');
|
||||||
expect(mockVault.create).toHaveBeenCalledWith(
|
expect(mockVault.create).toHaveBeenCalledWith(
|
||||||
'subdirectory/test-file.md',
|
'subdirectory/test-file.md',
|
||||||
'Subdir content'
|
'Subdir content'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not recreate existing parent folders', async () => {
|
||||||
|
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(new TFolder());
|
||||||
|
const call: ToolCall = {
|
||||||
|
id: 'call_existing_folder',
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'create_file',
|
||||||
|
arguments: JSON.stringify({
|
||||||
|
path: 'existing/test-file.md',
|
||||||
|
content: 'Subdir content',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const result = await executor.handleToolCall(call);
|
||||||
|
expect(result).toEqual({ success: true, message: 'Note created successfully' });
|
||||||
|
expect(mockVault.createFolder).not.toHaveBeenCalled();
|
||||||
|
expect(mockVault.create).toHaveBeenCalledWith('existing/test-file.md', 'Subdir content');
|
||||||
|
});
|
||||||
|
|
||||||
it('should handle multiple slashes gracefully by normalizing path', async () => {
|
it('should handle multiple slashes gracefully by normalizing path', async () => {
|
||||||
const call: ToolCall = {
|
const call: ToolCall = {
|
||||||
id: 'call_4',
|
id: 'call_4',
|
||||||
@@ -1170,7 +1194,9 @@ describe('ToolExecutor', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const file = new MockTFile('Projects/old.md');
|
const file = new MockTFile('Projects/old.md');
|
||||||
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(file);
|
mockVault.getAbstractFileByPath = jest
|
||||||
|
.fn()
|
||||||
|
.mockImplementation((path: string) => (path === 'Projects/old.md' ? file : null));
|
||||||
|
|
||||||
const call: ToolCall = {
|
const call: ToolCall = {
|
||||||
id: 'call_mn1',
|
id: 'call_mn1',
|
||||||
@@ -1185,6 +1211,7 @@ describe('ToolExecutor', () => {
|
|||||||
};
|
};
|
||||||
const result = await executor.handleToolCall(call);
|
const result = await executor.handleToolCall(call);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
|
expect(mockVault.createFolder).toHaveBeenCalledWith('Archive');
|
||||||
expect(mockVault.rename).toHaveBeenCalledWith(file, 'Archive/old.md');
|
expect(mockVault.rename).toHaveBeenCalledWith(file, 'Archive/old.md');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user