diff options
| -rw-r--r-- | .rules/changelog/2026-03/24/06.md | 21 | ||||
| -rw-r--r-- | src/chat-view.ts | 80 | ||||
| -rw-r--r-- | src/main.ts | 7 | ||||
| -rw-r--r-- | src/ollama-client.ts | 139 | ||||
| -rw-r--r-- | src/settings.ts | 4 | ||||
| -rw-r--r-- | src/tool-modal.ts | 43 | ||||
| -rw-r--r-- | src/tools.ts | 186 | ||||
| -rw-r--r-- | styles.css | 106 |
8 files changed, 563 insertions, 23 deletions
diff --git a/.rules/changelog/2026-03/24/06.md b/.rules/changelog/2026-03/24/06.md new file mode 100644 index 0000000..ffe233c --- /dev/null +++ b/.rules/changelog/2026-03/24/06.md @@ -0,0 +1,21 @@ +# Changelog — 2026-03-24 (06) + +## Added: AI Tool System with Tool Modal and Vault Tools + +### New Files +- **`src/tools.ts`** — Tool registry with `OllamaToolDefinition` schema, `ToolEntry` interface, and two tool implementations: + - `search_files` — case-insensitive search of vault file paths (capped at 50 results) + - `read_file` — reads full text content of a file by vault path using `cachedRead()` + - Each tool has `friendlyName`, `summarize()`, and `summarizeResult()` for UI display +- **`src/tool-modal.ts`** — `ToolModal` with toggles for each registered tool, persisted via plugin settings + +### Modified Files +- **`src/settings.ts`** — Added `enabledTools: Record<string, boolean>` to settings +- **`src/main.ts`** — Merges tool states on load to handle new tools added to registry +- **`src/ollama-client.ts`** — Extended `ChatMessage` with `tool` role, `tool_calls`, `tool_name`. `sendChatMessage` now supports optional tools with full agent loop (up to 10 iterations). `ToolCallEvent` includes `friendlyName`, `summary`, and `resultSummary` +- **`src/chat-view.ts`** — Added wrench button (highlights when tools active), opens tool modal, passes enabled tools to chat, displays tool calls inline with: + - Friendly name header with wrench icon + - One-line summary of what the tool did + - Result summary line (e.g. "3 results found") + - Collapsible `<details>` for JSON args and result preview +- **`styles.css`** — Styles for tools button (active indicator), tool call blocks (accent border, summary, collapsible details), tool modal description 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); +} @@ -90,3 +90,109 @@ color: var(--text-normal); background-color: var(--background-modifier-hover); } + + +.ai-organizer-tools-btn { + display: flex; + align-items: center; + justify-content: center; + padding: 4px; + background: transparent; + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + color: var(--text-muted); + cursor: pointer; +} + +.ai-organizer-tools-btn:hover { + color: var(--text-normal); + background-color: var(--background-modifier-hover); +} + +.ai-organizer-tools-btn.ai-organizer-tools-active { + color: var(--interactive-accent); + border-color: var(--interactive-accent); +} + +.ai-organizer-tool-call { + align-self: flex-start; + max-width: 85%; + padding: 6px 10px; + border-radius: 6px; + background-color: var(--background-secondary-alt); + border-left: 3px solid var(--interactive-accent); + font-size: 0.85em; + margin: 2px 0; +} + +.ai-organizer-tool-call-header { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 4px; + color: var(--text-muted); +} + +.ai-organizer-tool-call-icon { + display: flex; + align-items: center; +} + +.ai-organizer-tool-call-icon svg { + width: 14px; + height: 14px; +} + +.ai-organizer-tool-call-name { + font-weight: 600; +} + +.ai-organizer-tool-call-summary { + margin: 4px 0 2px 0; + color: var(--text-normal); + font-style: italic; +} + +.ai-organizer-tool-call-result-summary { + margin: 0 0 4px 0; + color: var(--text-muted); + font-size: 0.9em; +} + +.ai-organizer-tool-call-details { + margin-top: 4px; +} + +.ai-organizer-tool-call-details > summary { + cursor: pointer; + color: var(--text-muted); + font-size: 0.9em; + user-select: none; +} + +.ai-organizer-tool-call-details > summary:hover { + color: var(--text-normal); +} + +.ai-organizer-tool-call-args, +.ai-organizer-tool-call-result { + margin: 2px 0; + padding: 4px 6px; + border-radius: 4px; + background-color: var(--background-primary); + white-space: pre-wrap; + word-wrap: break-word; + font-size: 0.9em; + max-height: 150px; + overflow-y: auto; +} + +.ai-organizer-tool-call-result { + color: var(--text-muted); +} + +.ai-organizer-tool-modal-desc { + color: var(--text-muted); + font-size: 0.9em; + margin-bottom: 8px; +} |
