summaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-03-24 13:44:52 +0900
committerAdam Malczewski <[email protected]>2026-03-24 13:44:52 +0900
commit5a44a97111d304945bbfc3da02d29a83191d816c (patch)
treea1e31b76db2a0b0e84c5745127a0d05ddc574ec7 /src
parentbb543c3f7840f2a3fa1b7a1fb32245fa87a30f7b (diff)
downloadai-pulse-obsidian-plugin-5a44a97111d304945bbfc3da02d29a83191d816c.tar.gz
ai-pulse-obsidian-plugin-5a44a97111d304945bbfc3da02d29a83191d816c.zip
Add initial ai tool system, and 2 tools to explore
Diffstat (limited to 'src')
-rw-r--r--src/chat-view.ts80
-rw-r--r--src/main.ts7
-rw-r--r--src/ollama-client.ts139
-rw-r--r--src/settings.ts4
-rw-r--r--src/tool-modal.ts43
-rw-r--r--src/tools.ts186
6 files changed, 436 insertions, 23 deletions
diff --git a/src/chat-view.ts b/src/chat-view.ts
index 91bff2c..16b9676 100644
--- a/src/chat-view.ts
+++ b/src/chat-view.ts
@@ -1,8 +1,11 @@
import { ItemView, Notice, WorkspaceLeaf, setIcon } from "obsidian";
import type AIOrganizer from "./main";
-import type { ChatMessage } from "./ollama-client";
+import type { ChatMessage, ToolCallEvent } from "./ollama-client";
import { sendChatMessage } from "./ollama-client";
import { SettingsModal } from "./settings-modal";
+import { ToolModal } from "./tool-modal";
+import { TOOL_REGISTRY } from "./tools";
+import type { OllamaToolDefinition } from "./tools";
export const VIEW_TYPE_CHAT = "ai-organizer-chat";
@@ -12,6 +15,7 @@ export class ChatView extends ItemView {
private messageContainer: HTMLDivElement | null = null;
private textarea: HTMLTextAreaElement | null = null;
private sendButton: HTMLButtonElement | null = null;
+ private toolsButton: HTMLButtonElement | null = null;
constructor(leaf: WorkspaceLeaf, plugin: AIOrganizer) {
super(leaf);
@@ -56,6 +60,21 @@ export class ChatView extends ItemView {
new SettingsModal(this.plugin).open();
});
+ // Tools button
+ this.toolsButton = buttonGroup.createEl("button", {
+ cls: "ai-organizer-tools-btn",
+ attr: { "aria-label": "Tools" },
+ });
+ setIcon(this.toolsButton, "wrench");
+ this.updateToolsButtonState();
+ this.toolsButton.addEventListener("click", () => {
+ const modal = new ToolModal(this.plugin);
+ modal.onClose = () => {
+ this.updateToolsButtonState();
+ };
+ modal.open();
+ });
+
// Send button
this.sendButton = buttonGroup.createEl("button", { text: "Send" });
@@ -80,6 +99,28 @@ export class ChatView extends ItemView {
this.messageContainer = null;
this.textarea = null;
this.sendButton = null;
+ this.toolsButton = null;
+ }
+
+ private getEnabledTools(): OllamaToolDefinition[] {
+ const tools: OllamaToolDefinition[] = [];
+ for (const tool of TOOL_REGISTRY) {
+ if (this.plugin.settings.enabledTools[tool.id] === true) {
+ tools.push(tool.definition);
+ }
+ }
+ return tools;
+ }
+
+ private hasAnyToolEnabled(): boolean {
+ return TOOL_REGISTRY.some(
+ (tool) => this.plugin.settings.enabledTools[tool.id] === true,
+ );
+ }
+
+ private updateToolsButtonState(): void {
+ if (this.toolsButton === null) return;
+ this.toolsButton.toggleClass("ai-organizer-tools-active", this.hasAnyToolEnabled());
}
private async handleSend(): Promise<void> {
@@ -109,10 +150,21 @@ export class ChatView extends ItemView {
this.setInputEnabled(false);
try {
+ const enabledTools = this.getEnabledTools();
+ const hasTools = enabledTools.length > 0;
+
+ const onToolCall = (event: ToolCallEvent): void => {
+ this.appendToolCall(event);
+ this.scrollToBottom();
+ };
+
const response = await sendChatMessage(
this.plugin.settings.ollamaUrl,
this.plugin.settings.model,
this.messages,
+ hasTools ? enabledTools : undefined,
+ hasTools ? this.plugin.app : undefined,
+ hasTools ? onToolCall : undefined,
);
this.messages.push({ role: "assistant", content: response });
@@ -143,6 +195,32 @@ export class ChatView extends ItemView {
this.messageContainer.createDiv({ cls, text: content });
}
+ private appendToolCall(event: ToolCallEvent): void {
+ if (this.messageContainer === null) {
+ return;
+ }
+
+ const container = this.messageContainer.createDiv({ cls: "ai-organizer-tool-call" });
+
+ const header = container.createDiv({ cls: "ai-organizer-tool-call-header" });
+ setIcon(header.createSpan({ cls: "ai-organizer-tool-call-icon" }), "wrench");
+ header.createSpan({ text: event.friendlyName, cls: "ai-organizer-tool-call-name" });
+
+ container.createDiv({ text: event.summary, cls: "ai-organizer-tool-call-summary" });
+ container.createDiv({ text: event.resultSummary, cls: "ai-organizer-tool-call-result-summary" });
+
+ const details = container.createEl("details", { cls: "ai-organizer-tool-call-details" });
+ details.createEl("summary", { text: "Details" });
+
+ const argsStr = JSON.stringify(event.args, null, 2);
+ details.createEl("pre", { text: argsStr, cls: "ai-organizer-tool-call-args" });
+
+ const resultPreview = event.result.length > 500
+ ? event.result.substring(0, 500) + "..."
+ : event.result;
+ details.createEl("pre", { text: resultPreview, cls: "ai-organizer-tool-call-result" });
+ }
+
private scrollToBottom(): void {
if (this.messageContainer !== null) {
this.messageContainer.scrollTop = this.messageContainer.scrollHeight;
diff --git a/src/main.ts b/src/main.ts
index d523bf8..d120bdf 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -2,6 +2,7 @@ import { Plugin, WorkspaceLeaf } from "obsidian";
import { AIOrganizerSettings, DEFAULT_SETTINGS } from "./settings";
import { ChatView, VIEW_TYPE_CHAT } from "./chat-view";
import { testConnection, listModels } from "./ollama-client";
+import { getDefaultToolStates } from "./tools";
export default class AIOrganizer extends Plugin {
settings: AIOrganizerSettings = DEFAULT_SETTINGS;
@@ -58,6 +59,12 @@ export default class AIOrganizer extends Plugin {
DEFAULT_SETTINGS,
await this.loadData() as Partial<AIOrganizerSettings> | null,
);
+ // Ensure enabledTools has entries for all registered tools
+ this.settings.enabledTools = Object.assign(
+ {},
+ getDefaultToolStates(),
+ this.settings.enabledTools,
+ );
}
async saveSettings(): Promise<void> {
diff --git a/src/ollama-client.ts b/src/ollama-client.ts
index 377d640..91bd40c 100644
--- a/src/ollama-client.ts
+++ b/src/ollama-client.ts
@@ -1,8 +1,31 @@
import { requestUrl } from "obsidian";
+import type { App } from "obsidian";
+import type { OllamaToolDefinition } from "./tools";
+import { findToolByName } from "./tools";
export interface ChatMessage {
- role: "system" | "user" | "assistant";
+ role: "system" | "user" | "assistant" | "tool";
content: string;
+ tool_calls?: ToolCallResponse[];
+ tool_name?: string;
+}
+
+export interface ToolCallResponse {
+ type?: string;
+ function: {
+ index?: number;
+ name: string;
+ arguments: Record<string, unknown>;
+ };
+}
+
+export interface ToolCallEvent {
+ toolName: string;
+ friendlyName: string;
+ summary: string;
+ resultSummary: string;
+ args: Record<string, unknown>;
+ result: string;
}
export async function testConnection(ollamaUrl: string): Promise<string> {
@@ -64,34 +87,106 @@ export async function listModels(ollamaUrl: string): Promise<string[]> {
}
}
+/**
+ * Send a chat message with optional tool-calling agent loop.
+ * When tools are provided, the function handles the multi-turn tool
+ * execution loop automatically and calls onToolCall for each invocation.
+ */
export async function sendChatMessage(
ollamaUrl: string,
model: string,
messages: ChatMessage[],
+ tools?: OllamaToolDefinition[],
+ app?: App,
+ onToolCall?: (event: ToolCallEvent) => void,
): Promise<string> {
- try {
- const response = await requestUrl({
- url: `${ollamaUrl}/api/chat`,
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ model, messages, stream: false }),
- });
+ const maxIterations = 10;
+ let iterations = 0;
- const message = (response.json as Record<string, unknown>).message;
- if (
- typeof message === "object" &&
- message !== null &&
- "content" in message &&
- typeof (message as Record<string, unknown>).content === "string"
- ) {
- return (message as Record<string, unknown>).content as string;
- }
+ const workingMessages = messages.map((m) => ({ ...m }));
- throw new Error("Unexpected response format: missing message content.");
- } catch (err: unknown) {
- if (err instanceof Error) {
- throw new Error(`Chat request failed: ${err.message}`);
+ while (iterations < maxIterations) {
+ iterations++;
+
+ try {
+ const body: Record<string, unknown> = {
+ model,
+ messages: workingMessages,
+ stream: false,
+ };
+
+ if (tools !== undefined && tools.length > 0) {
+ body.tools = tools;
+ }
+
+ const response = await requestUrl({
+ url: `${ollamaUrl}/api/chat`,
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+
+ const messageObj = (response.json as Record<string, unknown>).message;
+ if (typeof messageObj !== "object" || messageObj === null) {
+ throw new Error("Unexpected response format: missing message.");
+ }
+
+ const msg = messageObj as Record<string, unknown>;
+ const content = typeof msg.content === "string" ? msg.content : "";
+ const toolCalls = Array.isArray(msg.tool_calls) ? msg.tool_calls as ToolCallResponse[] : [];
+
+ // If no tool calls, return the final content
+ if (toolCalls.length === 0) {
+ return content;
+ }
+
+ // Append assistant message with tool_calls to working history
+ const assistantMsg: ChatMessage = {
+ role: "assistant",
+ content,
+ tool_calls: toolCalls,
+ };
+ workingMessages.push(assistantMsg);
+
+ // Execute each tool call and append results
+ if (app === undefined) {
+ throw new Error("App reference required for tool execution.");
+ }
+
+ for (const tc of toolCalls) {
+ const fnName = tc.function.name;
+ const fnArgs = tc.function.arguments;
+ const toolEntry = findToolByName(fnName);
+
+ let result: string;
+ if (toolEntry === undefined) {
+ result = `Error: Unknown tool "${fnName}".`;
+ } else {
+ result = await toolEntry.execute(app, fnArgs);
+ }
+
+ if (onToolCall !== undefined) {
+ const friendlyName = toolEntry !== undefined ? toolEntry.friendlyName : fnName;
+ const summary = toolEntry !== undefined ? toolEntry.summarize(fnArgs) : `Called ${fnName}`;
+ const resultSummary = toolEntry !== undefined ? toolEntry.summarizeResult(result) : "";
+ onToolCall({ toolName: fnName, friendlyName, summary, resultSummary, args: fnArgs, result });
+ }
+
+ workingMessages.push({
+ role: "tool",
+ tool_name: fnName,
+ content: result,
+ });
+ }
+
+ // Loop continues — model sees tool results
+ } catch (err: unknown) {
+ if (err instanceof Error) {
+ throw new Error(`Chat request failed: ${err.message}`);
+ }
+ throw new Error("Chat request failed: unknown error.");
}
- throw new Error("Chat request failed: unknown error.");
}
+
+ throw new Error("Tool calling loop exceeded maximum iterations.");
}
diff --git a/src/settings.ts b/src/settings.ts
index a9ed8fb..209ff98 100644
--- a/src/settings.ts
+++ b/src/settings.ts
@@ -1,9 +1,13 @@
+import { getDefaultToolStates } from "./tools";
+
export interface AIOrganizerSettings {
ollamaUrl: string;
model: string;
+ enabledTools: Record<string, boolean>;
}
export const DEFAULT_SETTINGS: AIOrganizerSettings = {
ollamaUrl: "http://localhost:11434",
model: "",
+ enabledTools: getDefaultToolStates(),
};
diff --git a/src/tool-modal.ts b/src/tool-modal.ts
new file mode 100644
index 0000000..c1b1522
--- /dev/null
+++ b/src/tool-modal.ts
@@ -0,0 +1,43 @@
+import { Modal, Setting } from "obsidian";
+import type AIOrganizer from "./main";
+import { TOOL_REGISTRY } from "./tools";
+
+export class ToolModal extends Modal {
+ private plugin: AIOrganizer;
+
+ constructor(plugin: AIOrganizer) {
+ super(plugin.app);
+ this.plugin = plugin;
+ }
+
+ onOpen(): void {
+ const { contentEl } = this;
+ contentEl.empty();
+ contentEl.addClass("ai-organizer-tool-modal");
+
+ this.setTitle("AI Tools");
+
+ contentEl.createEl("p", {
+ text: "Enable tools to give the AI access to your vault. Changes take effect on the next message.",
+ cls: "ai-organizer-tool-modal-desc",
+ });
+
+ for (const tool of TOOL_REGISTRY) {
+ new Setting(contentEl)
+ .setName(tool.label)
+ .setDesc(tool.description)
+ .addToggle((toggle) => {
+ const current = this.plugin.settings.enabledTools[tool.id] ?? false;
+ toggle.setValue(current);
+ toggle.onChange(async (value) => {
+ this.plugin.settings.enabledTools[tool.id] = value;
+ await this.plugin.saveSettings();
+ });
+ });
+ }
+ }
+
+ onClose(): void {
+ this.contentEl.empty();
+ }
+}
diff --git a/src/tools.ts b/src/tools.ts
new file mode 100644
index 0000000..a702b18
--- /dev/null
+++ b/src/tools.ts
@@ -0,0 +1,186 @@
+import type { App } from "obsidian";
+import { TFile } from "obsidian";
+
+/**
+ * Schema for an Ollama tool definition (function calling).
+ */
+export interface OllamaToolDefinition {
+ type: "function";
+ function: {
+ name: string;
+ description: string;
+ parameters: {
+ type: "object";
+ required: string[];
+ properties: Record<string, { type: string; description: string }>;
+ };
+ };
+}
+
+/**
+ * Metadata for a tool the user can enable/disable.
+ */
+export interface ToolEntry {
+ id: string;
+ label: string;
+ description: string;
+ friendlyName: string;
+ summarize: (args: Record<string, unknown>) => string;
+ summarizeResult: (result: string) => string;
+ definition: OllamaToolDefinition;
+ execute: (app: App, args: Record<string, unknown>) => Promise<string>;
+}
+
+/**
+ * Execute the "search_files" tool.
+ * Returns a newline-separated list of vault file paths matching the query.
+ */
+async function executeSearchFiles(app: App, args: Record<string, unknown>): Promise<string> {
+ const query = typeof args.query === "string" ? args.query.toLowerCase() : "";
+ if (query === "") {
+ return "Error: query parameter is required.";
+ }
+
+ const files = app.vault.getFiles();
+ const matches: string[] = [];
+
+ for (const file of files) {
+ if (file.path.toLowerCase().includes(query)) {
+ matches.push(file.path);
+ }
+ }
+
+ if (matches.length === 0) {
+ return "No files found matching the query.";
+ }
+
+ // Cap results to avoid overwhelming the context
+ const maxResults = 50;
+ const limited = matches.slice(0, maxResults);
+ const suffix = matches.length > maxResults
+ ? `\n... and ${matches.length - maxResults} more results.`
+ : "";
+
+ return limited.join("\n") + suffix;
+}
+
+/**
+ * Execute the "read_file" tool.
+ * Returns the full text content of a file by its vault path.
+ */
+async function executeReadFile(app: App, args: Record<string, unknown>): Promise<string> {
+ const filePath = typeof args.file_path === "string" ? args.file_path : "";
+ if (filePath === "") {
+ return "Error: file_path parameter is required.";
+ }
+
+ const file = app.vault.getAbstractFileByPath(filePath);
+ if (file === null || !(file instanceof TFile)) {
+ return `Error: File not found at path "${filePath}".`;
+ }
+
+ try {
+ const content = await app.vault.cachedRead(file);
+ return content;
+ } catch (err: unknown) {
+ const msg = err instanceof Error ? err.message : "Unknown error";
+ return `Error reading file: ${msg}`;
+ }
+}
+
+/**
+ * All available tools for the plugin.
+ */
+export const TOOL_REGISTRY: ToolEntry[] = [
+ {
+ id: "search_files",
+ label: "Search File Names",
+ description: "Search for files in the vault by name or path.",
+ friendlyName: "Search Files",
+ summarize: (args) => {
+ const query = typeof args.query === "string" ? args.query : "";
+ return `"${query}"`;
+ },
+ summarizeResult: (result) => {
+ if (result === "No files found matching the query.") {
+ return "No results found";
+ }
+ const lines = result.split("\n").filter((l) => l.length > 0);
+ const moreMatch = result.match(/\.\.\.\s*and\s+(\d+)\s+more/);
+ const extraCount = moreMatch !== null ? parseInt(moreMatch[1], 10) : 0;
+ const count = lines.length - (moreMatch !== null ? 1 : 0) + extraCount;
+ return `${count} result${count === 1 ? "" : "s"} found`;
+ },
+ definition: {
+ type: "function",
+ function: {
+ name: "search_files",
+ description: "Search for files in the Obsidian vault by name or path. Returns a list of matching file paths.",
+ parameters: {
+ type: "object",
+ required: ["query"],
+ properties: {
+ query: {
+ type: "string",
+ description: "The search query to match against file names and paths.",
+ },
+ },
+ },
+ },
+ },
+ execute: executeSearchFiles,
+ },
+ {
+ id: "read_file",
+ label: "Read File Contents",
+ description: "Read the full text content of a file in the vault.",
+ friendlyName: "Read File",
+ summarize: (args) => {
+ const filePath = typeof args.file_path === "string" ? args.file_path : "";
+ return `"/${filePath}"`;
+ },
+ summarizeResult: (result) => {
+ if (result.startsWith("Error")) {
+ return result;
+ }
+ const lines = result.split("\n").length;
+ return `${lines} line${lines === 1 ? "" : "s"} read`;
+ },
+ definition: {
+ type: "function",
+ function: {
+ name: "read_file",
+ description: "Read the full text content of a file in the Obsidian vault given its path.",
+ parameters: {
+ type: "object",
+ required: ["file_path"],
+ properties: {
+ file_path: {
+ type: "string",
+ description: "The vault-relative path to the file (e.g. 'folder/note.md').",
+ },
+ },
+ },
+ },
+ },
+ execute: executeReadFile,
+ },
+];
+
+/**
+ * Get the default enabled state for all tools (all disabled).
+ */
+export function getDefaultToolStates(): Record<string, boolean> {
+ const states: Record<string, boolean> = {};
+ for (const tool of TOOL_REGISTRY) {
+ states[tool.id] = false;
+ }
+ return states;
+}
+
+/**
+ * Look up a tool entry by function name.
+ */
+export function findToolByName(name: string): ToolEntry | undefined {
+ return TOOL_REGISTRY.find((t) => t.definition.function.name === name);
+}