diff options
| author | Adam Malczewski <[email protected]> | 2026-03-24 13:18:50 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-03-24 13:18:50 +0900 |
| commit | bb543c3f7840f2a3fa1b7a1fb32245fa87a30f7b (patch) | |
| tree | d2a9db2741dfd9822c5f76dca278562220e9b064 | |
| parent | e5583b836d4fe2f7f9806ed85a190254a6ea3990 (diff) | |
| download | ai-pulse-obsidian-plugin-bb543c3f7840f2a3fa1b7a1fb32245fa87a30f7b.tar.gz ai-pulse-obsidian-plugin-bb543c3f7840f2a3fa1b7a1fb32245fa87a30f7b.zip | |
initial prototype
| -rw-r--r-- | .rules/changelog/2026-03/24/05.md | 35 | ||||
| -rw-r--r-- | .rules/plan/plan.md | 255 | ||||
| -rw-r--r-- | README.md | 115 | ||||
| -rw-r--r-- | manifest.json | 11 | ||||
| -rw-r--r-- | package-lock.json | 4 | ||||
| -rw-r--r-- | package.json | 4 | ||||
| -rw-r--r-- | src/chat-view.ts | 161 | ||||
| -rw-r--r-- | src/main.ts | 144 | ||||
| -rw-r--r-- | src/ollama-client.ts | 97 | ||||
| -rw-r--r-- | src/settings-modal.ts | 113 | ||||
| -rw-r--r-- | src/settings.ts | 41 | ||||
| -rw-r--r-- | styles.css | 94 |
12 files changed, 623 insertions, 451 deletions
diff --git a/.rules/changelog/2026-03/24/05.md b/.rules/changelog/2026-03/24/05.md new file mode 100644 index 0000000..7970687 --- /dev/null +++ b/.rules/changelog/2026-03/24/05.md @@ -0,0 +1,35 @@ +# Changelog — 2026-03-24 #05 + +## Settings moved from sidebar panel to modal + +### Changes + +- **New file: `src/settings-modal.ts`** + - Created `SettingsModal` class extending Obsidian `Modal` + - Contains Ollama URL, Connect button, and Model dropdown settings + - Reads shared connection state from the plugin instance + - Helper method `populateModelDropdown()` for reusable dropdown population + +- **`src/chat-view.ts`** + - Removed inline settings panel from bottom of chat sidebar + - Added a gear (⚙) settings button next to the Send button in the input row + - Clicking the gear button opens the `SettingsModal` + - Auto-connects to Ollama when the chat view opens via `plugin.connect()` + - Removed unused imports (`Setting`, `testConnection`, `listModels`) + - Added imports for `setIcon` and `SettingsModal` + +- **`src/main.ts`** + - Added runtime connection state fields: `connectionStatus`, `connectionMessage`, `availableModels` + - Added `connect()` method that tests connection, fetches available models, and stores results + - Added `testConnection` and `listModels` imports + +- **`styles.css`** + - Removed `.ai-organizer-settings-panel` styles + - Added `.ai-organizer-input-buttons` vertical button group layout + - Added `.ai-organizer-settings-btn` with transparent background and hover state + +### Summary + +- "Test Connection" renamed to "Connect" +- Settings are now in a modal opened via a gear button in the sidebar input row +- Auto-connect runs when the chat view opens, pre-populating models for the settings modal diff --git a/.rules/plan/plan.md b/.rules/plan/plan.md deleted file mode 100644 index f4106ac..0000000 --- a/.rules/plan/plan.md +++ /dev/null @@ -1,255 +0,0 @@ -# AI Organizer — Stage 1: Chat Sidebar with Ollama Connection - -## Goal - -Replace the sample plugin scaffolding with a functional Ollama chat sidebar. The sidebar view contains a chat area (top half) and a settings panel (bottom half). The user configures the Ollama URL, tests the connection, selects a model, and chats with the AI. - ---- - -## Existing State - -- Project is the Obsidian sample plugin template (TypeScript, esbuild). -- `manifest.json` has `id: "sample-plugin"`, `isDesktopOnly: false`. -- `src/main.ts` contains `MyPlugin` with boilerplate commands, ribbon icon, status bar, and a modal. -- `src/settings.ts` contains `MyPluginSettings` with a single `mySetting: string` field and `SampleSettingTab`. -- `styles.css` is empty (comments only). -- Build: `npm run dev` (esbuild watch), `npm run build` (tsc + esbuild production). - ---- - -## File Plan - -| File | Action | Purpose | -|------|--------|---------| -| `manifest.json` | Modify | Update `id`, `name`, `description`, `author`, `authorUrl`. Remove `fundingUrl`. | -| `package.json` | Modify | Update `name` and `description`. | -| `src/main.ts` | Rewrite | New plugin class `AIOrganizer`. Register view, register command, load/save settings. Remove all sample code. | -| `src/settings.ts` | Rewrite | New `AIOrganizerSettings` interface with `ollamaUrl` and `model`. New `DEFAULT_SETTINGS`. Remove `SampleSettingTab` (settings live in the sidebar view, not a settings tab). | -| `src/chat-view.ts` | Create | `ItemView` subclass for the sidebar. Contains chat UI (top) and settings panel (bottom). | -| `src/ollama-client.ts` | Create | Functions: `testConnection`, `listModels`, `sendChatMessage`. All use `requestUrl`. | -| `styles.css` | Rewrite | Styles for the chat view layout, messages, input area, settings panel. | - ---- - -## Step-by-Step Tasks - -### Step 1 — Update Metadata - -**`manifest.json`**: -- Set `id` to `"ai-organizer"`. -- Set `name` to `"AI Organizer"`. -- Set `description` to `"Organize notes via AI powered by Ollama."`. -- Set `author` to the repo owner's name. -- Set `authorUrl` to the repo URL. -- Remove `fundingUrl`. -- Keep `isDesktopOnly` as `false`. -- Keep `minAppVersion` as `"0.15.0"`. - -**`package.json`**: -- Set `name` to `"ai-organizer"`. -- Set `description` to match `manifest.json`. - -### Step 2 — Settings Interface - -**`src/settings.ts`** — delete all existing content and replace: - -- Define `AIOrganizerSettings` interface: - - `ollamaUrl: string` — the Ollama server base URL. - - `model: string` — the selected model name (empty string means none selected). -- Define `DEFAULT_SETTINGS: AIOrganizerSettings`: - - `ollamaUrl`: `"http://localhost:11434"` - - `model`: `""` -- Export both. -- Do NOT create a `PluginSettingTab`. Settings are embedded in the sidebar view. - -### Step 3 — Ollama Client - -**`src/ollama-client.ts`** — create: - -#### `testConnection(ollamaUrl: string): Promise<string>` -- `GET {ollamaUrl}/api/version` using `requestUrl` with `throw: false`. -- On success (status 200): return the version string from `response.json.version`. -- On failure: throw an `Error` with a descriptive message. If `status` is 0 or the error message contains `"net"` or `"fetch"`, the message must say Ollama is unreachable. Otherwise include the status code. - -#### `listModels(ollamaUrl: string): Promise<string[]>` -- `GET {ollamaUrl}/api/tags` using `requestUrl`. -- Return `response.json.models.map((m: {name: string}) => m.name)`. -- On failure: throw an `Error` with a descriptive message. - -#### `sendChatMessage(ollamaUrl: string, model: string, messages: ChatMessage[]): Promise<string>` -- Define `ChatMessage` interface: `{ role: "system" | "user" | "assistant"; content: string }`. Export it. -- `POST {ollamaUrl}/api/chat` using `requestUrl`. -- Body: `{ model, messages, stream: false }`. -- Return `response.json.message.content`. -- On failure: throw an `Error` with a descriptive message. - -All three functions are standalone exports (no class). All use `import { requestUrl } from "obsidian"`. - -### Step 4 — Chat View - -**`src/chat-view.ts`** — create: - -- Export `VIEW_TYPE_CHAT = "ai-organizer-chat"`. -- Export class `ChatView extends ItemView`. - -#### Constructor -- Accept `leaf: WorkspaceLeaf` and a reference to the plugin instance (`AIOrganizer`). Store the plugin reference as a private property. - -#### `getViewType()` → return `VIEW_TYPE_CHAT`. - -#### `getDisplayText()` → return `"AI Chat"`. - -#### `getIcon()` → return `"message-square"`. - -#### `onOpen()` - -Build the entire UI inside `this.contentEl`. The layout is a vertical flexbox split into two regions: - -**Top region — Chat area** (flexbox column, `flex: 1`, overflow-y scroll): -- A message container `div` that holds chat message elements. -- Each message is a `div` with a CSS class indicating the role (`"user"` or `"assistant"`). -- Below the message container: an input row (flexbox row) with: - - A `textarea` for user input (flex: 1, placeholder: `"Type a message..."`). Pressing Enter (without Shift) sends the message. Shift+Enter inserts a newline. - - A send `button` (text: `"Send"`). -- The send button and Enter key trigger the send flow (defined below). -- While waiting for a response, disable the textarea and send button and change the button text to `"..."`. - -**Bottom region — Settings panel** (fixed height, border-top separator, padding, overflow-y auto): -- A heading element: `"Settings"`. -- **Ollama URL**: Use an Obsidian `Setting` component. - - Name: `"Ollama URL"`. - - Description: `"Base URL of the Ollama server."`. - - `addText` input pre-filled with `plugin.settings.ollamaUrl`. - - `onChange`: update `plugin.settings.ollamaUrl` and call `plugin.saveSettings()`. -- **Test Connection**: Use an Obsidian `Setting` component. - - Name: `"Test Connection"`. - - Description: initially empty. This description element will be used to display the result. - - `addButton` with text `"Test"`. - - `onClick`: call `testConnection(plugin.settings.ollamaUrl)`. - - On success: set the description to `"Connected — Ollama v{version}"`. Then automatically call `listModels` and populate the model dropdown (see below). - - On failure: set the description to the error message. -- **Model Selection**: Use an Obsidian `Setting` component. - - Name: `"Model"`. - - Description: `"Select the model to use."`. - - `addDropdown`. - - Initially the dropdown has one option: `{ value: "", display: "Test connection first" }` and is disabled. - - After a successful `testConnection` + `listModels`: - - Clear the dropdown options (use `selectEl.empty()` on the underlying `<select>` element). - - Add a placeholder option `{ value: "", display: "Select a model..." }`. - - Add one option per model name returned by `listModels` (value and display are both the model name). - - If `plugin.settings.model` matches one of the returned models, set the dropdown value to it. - - Enable the dropdown. - - `onChange`: update `plugin.settings.model` and call `plugin.saveSettings()`. - -#### Send flow - -1. Read the textarea value. If empty (after trim), do nothing. -2. If `plugin.settings.model` is empty, show a `Notice`: `"Select a model first."` and return. -3. Append a user message `div` to the message container with the textarea content. -4. Clear the textarea. -5. Scroll the message container to the bottom. -6. Maintain a local `ChatMessage[]` array as instance state on the view. Push `{ role: "user", content: text }`. -7. Disable input (textarea + button). -8. Call `sendChatMessage(plugin.settings.ollamaUrl, plugin.settings.model, messages)`. -9. On success: - - Push `{ role: "assistant", content: response }` to the messages array. - - Append an assistant message `div` to the message container. - - Scroll to bottom. -10. On failure: - - Show a `Notice` with the error message. - - Append an assistant message `div` with class `"error"` and the text `"Error: {message}"`. -11. Re-enable input. - -#### `onClose()` -- Empty `this.contentEl`. - -#### Instance state -- `messages: ChatMessage[]` — starts as empty array. Resets when the view is re-opened. - -### Step 5 — Main Plugin Class - -**`src/main.ts`** — delete all existing content and replace: - -- Import `Plugin`, `WorkspaceLeaf`, `Notice` from `"obsidian"`. -- Import `AIOrganizerSettings`, `DEFAULT_SETTINGS` from `"./settings"`. -- Import `ChatView`, `VIEW_TYPE_CHAT` from `"./chat-view"`. - -- Export default class `AIOrganizer extends Plugin`: - - Property: `settings: AIOrganizerSettings`. - - - `async onload()`: - 1. Call `await this.loadSettings()`. - 2. Register the chat view: `this.registerView(VIEW_TYPE_CHAT, (leaf) => new ChatView(leaf, this))`. - 3. Add a ribbon icon: - - Icon: `"message-square"`. - - Tooltip: `"Open AI Chat"`. - - Callback: call `this.activateView()`. - 4. Add a command: - - `id`: `"open-chat"`. - - `name`: `"Open AI Chat"`. - - `callback`: call `this.activateView()`. - - - `onunload()`: - 1. `this.app.workspace.detachLeavesOfType(VIEW_TYPE_CHAT)`. - - - `async activateView()`: - 1. Get existing leaves: `this.app.workspace.getLeavesOfType(VIEW_TYPE_CHAT)`. - 2. If a leaf exists, call `this.app.workspace.revealLeaf(leaf)` on the first one. - 3. Otherwise: - - Get a right sidebar leaf: `this.app.workspace.getRightLeaf(false)`. - - Set its view state: `await leaf.setViewState({ type: VIEW_TYPE_CHAT, active: true })`. - - Reveal it: `this.app.workspace.revealLeaf(leaf)`. - - - `async loadSettings()`: - - `this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData())`. - - - `async saveSettings()`: - - `await this.saveData(this.settings)`. - -### Step 6 — Styles - -**`styles.css`** — delete all existing content and replace with styles for: - -- `.ai-organizer-chat-container`: vertical flexbox, full height (`height: 100%`). -- `.ai-organizer-messages-area`: top region. `flex: 1`, `overflow-y: auto`, `display: flex`, `flex-direction: column`. -- `.ai-organizer-messages`: the scrollable message list inside the messages area. `flex: 1`, `overflow-y: auto`, padding. -- `.ai-organizer-message`: individual message. Padding, margin-bottom, border-radius. -- `.ai-organizer-message.user`: right-aligned background. Use `--interactive-accent` for background, `--text-on-accent` for text color. -- `.ai-organizer-message.assistant`: left-aligned background. Use `--background-secondary` for background. -- `.ai-organizer-message.error`: use `--text-error` for text color. -- `.ai-organizer-input-row`: flexbox row, gap, padding. -- `.ai-organizer-input-row textarea`: `flex: 1`, resize vertical, use Obsidian CSS variables for background/border/text. -- `.ai-organizer-settings-panel`: bottom region. Fixed `min-height` (do NOT use a fixed pixel height — let it size to content). `border-top: 1px solid var(--background-modifier-border)`, padding, `overflow-y: auto`. - -All class names are prefixed with `ai-organizer-` to avoid collisions. Use Obsidian CSS variables everywhere (no hardcoded colors). - ---- - -## Verification Checklist - -After completing all steps, verify: - -1. `npm run build` succeeds with zero errors. -2. The plugin loads in Obsidian without console errors. -3. The ribbon icon and command `"Open AI Chat"` both open the chat sidebar. -4. The sidebar opens in the right panel on desktop. -5. The sidebar opens in the right drawer on mobile. -6. Entering an Ollama URL and clicking "Test" with Ollama running shows the version and populates the model dropdown. -7. Clicking "Test" with Ollama stopped shows an error in the description. -8. Selecting a model persists across plugin reload. -9. Typing a message and pressing Enter sends it and displays the AI response. -10. Pressing Shift+Enter in the textarea inserts a newline instead of sending. -11. The send button and textarea are disabled while waiting for a response. -12. A network error during chat shows a `Notice` and an error message in the chat. -13. The Ollama URL persists across plugin reload. - ---- - -## Constraints - -- Do NOT use `PluginSettingTab`. All settings are in the sidebar view. -- Do NOT use streaming for chat in this stage. Set `stream: false` on all Ollama requests. -- Do NOT store chat history in `data.json`. Chat history lives only in view instance memory and resets on close/reopen. -- Do NOT hardcode colors. Use Obsidian CSS variables. -- All CSS class names must be prefixed with `ai-organizer-`. -- `manifest.json` must have `isDesktopOnly: false`. @@ -1,90 +1,61 @@ -# Obsidian Sample Plugin +# AI Organizer -This is a sample plugin for Obsidian (https://obsidian.md). +An Obsidian plugin that organizes notes via AI powered by [Ollama](https://ollama.com). -This project uses TypeScript to provide type checking and documentation. -The repo depends on the latest plugin API (obsidian.d.ts) in TypeScript Definition format, which contains TSDoc comments describing what it does. +## Prerequisites -This sample plugin demonstrates some of the basic functionality the plugin API can do. -- Adds a ribbon icon, which shows a Notice when clicked. -- Adds a command "Open modal (simple)" which opens a Modal. -- Adds a plugin setting tab to the settings page. -- Registers a global click event and output 'click' to the console. -- Registers a global interval which logs 'setInterval' to the console. +- [Obsidian](https://obsidian.md) v0.15.0 or later +- [Ollama](https://ollama.com) installed and running locally (default: `http://localhost:11434`) +- [Node.js](https://nodejs.org) v16 or later (for building from source) -## First time developing plugins? +## Building from Source -Quick starting guide for new plugin devs: - -- Check if [someone already developed a plugin for what you want](https://obsidian.md/plugins)! There might be an existing plugin similar enough that you can partner up with. -- Make a copy of this repo as a template with the "Use this template" button (login to GitHub if you don't see it). -- Clone your repo to a local development folder. For convenience, you can place this folder in your `.obsidian/plugins/your-plugin-name` folder. -- Install NodeJS, then run `npm i` in the command line under your repo folder. -- Run `npm run dev` to compile your plugin from `main.ts` to `main.js`. -- Make changes to `main.ts` (or create new `.ts` files). Those changes should be automatically compiled into `main.js`. -- Reload Obsidian to load the new version of your plugin. -- Enable plugin in settings window. -- For updates to the Obsidian API run `npm update` in the command line under your repo folder. - -## Releasing new releases - -- Update your `manifest.json` with your new version number, such as `1.0.1`, and the minimum Obsidian version required for your latest release. -- Update your `versions.json` file with `"new-plugin-version": "minimum-obsidian-version"` so older versions of Obsidian can download an older version of your plugin that's compatible. -- Create new GitHub release using your new version number as the "Tag version". Use the exact version number, don't include a prefix `v`. See here for an example: https://github.com/obsidianmd/obsidian-sample-plugin/releases -- Upload the files `manifest.json`, `main.js`, `styles.css` as binary attachments. Note: The manifest.json file must be in two places, first the root path of your repository and also in the release. -- Publish the release. - -> You can simplify the version bump process by running `npm version patch`, `npm version minor` or `npm version major` after updating `minAppVersion` manually in `manifest.json`. -> The command will bump version in `manifest.json` and `package.json`, and add the entry for the new version to `versions.json` - -## Adding your plugin to the community plugin list - -- Check the [plugin guidelines](https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines). -- Publish an initial version. -- Make sure you have a `README.md` file in the root of your repo. -- Make a pull request at https://github.com/obsidianmd/obsidian-releases to add your plugin. - -## How to use +```bash +git clone https://github.com/your-repo/aiorganizer_obsidian.git +cd aiorganizer_obsidian +npm install +npm run build +``` -- Clone this repo. -- Make sure your NodeJS is at least v16 (`node --version`). -- `npm i` or `yarn` to install dependencies. -- `npm run dev` to start compilation in watch mode. +For development with auto-rebuild on file changes: -## Manually installing the plugin +```bash +npm run dev +``` -- Copy over `main.js`, `styles.css`, `manifest.json` to your vault `VaultFolder/.obsidian/plugins/your-plugin-id/`. +## Installing the Plugin -## Improve code quality with eslint -- [ESLint](https://eslint.org/) is a tool that analyzes your code to quickly find problems. You can run ESLint against your plugin to find common bugs and ways to improve your code. -- This project already has eslint preconfigured, you can invoke a check by running`npm run lint` -- Together with a custom eslint [plugin](https://github.com/obsidianmd/eslint-plugin) for Obsidan specific code guidelines. -- A GitHub action is preconfigured to automatically lint every commit on all branches. +### Manual Installation -## Funding URL +1. Build the plugin (see above). +2. Copy `main.js`, `styles.css`, and `manifest.json` into your vault at: + ``` + <VaultFolder>/.obsidian/plugins/ai-organizer/ + ``` +3. Open Obsidian, go to **Settings > Community Plugins**, and enable **AI Organizer**. -You can include funding URLs where people who use your plugin can financially support it. +### Development Installation -The simple way is to set the `fundingUrl` field to your link in your `manifest.json` file: +Clone or symlink this repo directly into your vault's plugin folder for live development: -```json -{ - "fundingUrl": "https://buymeacoffee.com" -} +```bash +cd /path/to/your/vault/.obsidian/plugins +ln -s /path/to/aiorganizer_obsidian ai-organizer ``` -If you have multiple URLs, you can also do: +Then run `npm run dev` and reload Obsidian to pick up changes. -```json -{ - "fundingUrl": { - "Buy Me a Coffee": "https://buymeacoffee.com", - "GitHub Sponsor": "https://github.com/sponsors", - "Patreon": "https://www.patreon.com/" - } -} -``` +## Usage + +1. Click the **message icon** in the left ribbon or run the **"Open AI Chat"** command from the command palette. +2. The chat sidebar opens in the right panel. +3. In the **Settings** section at the bottom of the sidebar: + - Set the **Ollama URL** (defaults to `http://localhost:11434`). + - Click **Test** to verify the connection. + - Select a **Model** from the dropdown. +4. Type a message and press **Enter** to chat with the AI. + - **Shift+Enter** inserts a newline. -## API Documentation +## License -See https://docs.obsidian.md +[0-BSD](LICENSE) diff --git a/manifest.json b/manifest.json index dfa940e..9ba100f 100644 --- a/manifest.json +++ b/manifest.json @@ -1,11 +1,10 @@ { - "id": "sample-plugin", - "name": "Sample Plugin", + "id": "ai-organizer", + "name": "AI Organizer", "version": "1.0.0", "minAppVersion": "0.15.0", - "description": "Demonstrates some of the capabilities of the Obsidian API.", - "author": "Obsidian", - "authorUrl": "https://obsidian.md", - "fundingUrl": "https://obsidian.md/pricing", + "description": "Organize notes via AI powered by Ollama.", + "author": "Adam Malczewski", + "authorUrl": "https://malcz.com", "isDesktopOnly": false } diff --git a/package-lock.json b/package-lock.json index d0dac39..32e7b1f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "obsidian-sample-plugin", + "name": "ai-organizer", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "obsidian-sample-plugin", + "name": "ai-organizer", "version": "1.0.0", "license": "0-BSD", "dependencies": { diff --git a/package.json b/package.json index 17268d7..5f99ba4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "obsidian-sample-plugin", + "name": "ai-organizer", "version": "1.0.0", - "description": "This is a sample plugin for Obsidian (https://obsidian.md)", + "description": "Organize notes via AI powered by Ollama.", "main": "main.js", "type": "module", "scripts": { diff --git a/src/chat-view.ts b/src/chat-view.ts new file mode 100644 index 0000000..91bff2c --- /dev/null +++ b/src/chat-view.ts @@ -0,0 +1,161 @@ +import { ItemView, Notice, WorkspaceLeaf, setIcon } from "obsidian"; +import type AIOrganizer from "./main"; +import type { ChatMessage } from "./ollama-client"; +import { sendChatMessage } from "./ollama-client"; +import { SettingsModal } from "./settings-modal"; + +export const VIEW_TYPE_CHAT = "ai-organizer-chat"; + +export class ChatView extends ItemView { + private plugin: AIOrganizer; + private messages: ChatMessage[] = []; + private messageContainer: HTMLDivElement | null = null; + private textarea: HTMLTextAreaElement | null = null; + private sendButton: HTMLButtonElement | null = null; + + constructor(leaf: WorkspaceLeaf, plugin: AIOrganizer) { + super(leaf); + this.plugin = plugin; + } + + getViewType(): string { + return VIEW_TYPE_CHAT; + } + + getDisplayText(): string { + return "AI Chat"; + } + + getIcon(): string { + return "message-square"; + } + + async onOpen(): Promise<void> { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass("ai-organizer-chat-container"); + + // --- Top region: Chat area --- + const messagesArea = contentEl.createDiv({ cls: "ai-organizer-messages-area" }); + this.messageContainer = messagesArea.createDiv({ cls: "ai-organizer-messages" }); + + const inputRow = messagesArea.createDiv({ cls: "ai-organizer-input-row" }); + this.textarea = inputRow.createEl("textarea", { + attr: { placeholder: "Type a message...", rows: "2" }, + }); + + const buttonGroup = inputRow.createDiv({ cls: "ai-organizer-input-buttons" }); + + // Settings button + const settingsBtn = buttonGroup.createEl("button", { + cls: "ai-organizer-settings-btn", + attr: { "aria-label": "Settings" }, + }); + setIcon(settingsBtn, "settings"); + settingsBtn.addEventListener("click", () => { + new SettingsModal(this.plugin).open(); + }); + + // Send button + this.sendButton = buttonGroup.createEl("button", { text: "Send" }); + + this.textarea.addEventListener("keydown", (e: KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + void this.handleSend(); + } + }); + + this.sendButton.addEventListener("click", () => { + void this.handleSend(); + }); + + // Auto-connect on open + void this.plugin.connect(); + } + + async onClose(): Promise<void> { + this.contentEl.empty(); + this.messages = []; + this.messageContainer = null; + this.textarea = null; + this.sendButton = null; + } + + private async handleSend(): Promise<void> { + if (this.textarea === null || this.sendButton === null || this.messageContainer === null) { + return; + } + + const text = this.textarea.value.trim(); + if (text === "") { + return; + } + + if (this.plugin.settings.model === "") { + new Notice("Select a model first."); + return; + } + + // Append user message + this.appendMessage("user", text); + this.textarea.value = ""; + this.scrollToBottom(); + + // Track in message history + this.messages.push({ role: "user", content: text }); + + // Disable input + this.setInputEnabled(false); + + try { + const response = await sendChatMessage( + this.plugin.settings.ollamaUrl, + this.plugin.settings.model, + this.messages, + ); + + this.messages.push({ role: "assistant", content: response }); + this.appendMessage("assistant", response); + this.scrollToBottom(); + } catch (err: unknown) { + const errMsg = err instanceof Error ? err.message : "Unknown error."; + new Notice(errMsg); + this.appendMessage("error", `Error: ${errMsg}`); + this.scrollToBottom(); + } + + // Re-enable input + this.setInputEnabled(true); + this.textarea.focus(); + } + + private appendMessage(role: "user" | "assistant" | "error", content: string): void { + if (this.messageContainer === null) { + return; + } + + const cls = + role === "error" + ? "ai-organizer-message assistant error" + : `ai-organizer-message ${role}`; + + this.messageContainer.createDiv({ cls, text: content }); + } + + private scrollToBottom(): void { + if (this.messageContainer !== null) { + this.messageContainer.scrollTop = this.messageContainer.scrollHeight; + } + } + + private setInputEnabled(enabled: boolean): void { + if (this.textarea !== null) { + this.textarea.disabled = !enabled; + } + if (this.sendButton !== null) { + this.sendButton.disabled = !enabled; + this.sendButton.textContent = enabled ? "Send" : "..."; + } + } +} diff --git a/src/main.ts b/src/main.ts index 6fe0c83..d523bf8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,99 +1,93 @@ -import {App, Editor, MarkdownView, Modal, Notice, Plugin} from 'obsidian'; -import {DEFAULT_SETTINGS, MyPluginSettings, SampleSettingTab} from "./settings"; +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"; -// Remember to rename these classes and interfaces! +export default class AIOrganizer extends Plugin { + settings: AIOrganizerSettings = DEFAULT_SETTINGS; -export default class MyPlugin extends Plugin { - settings: MyPluginSettings; + // Runtime connection state (not persisted) + connectionStatus: "disconnected" | "connecting" | "connected" | "error" = "disconnected"; + connectionMessage = ""; + availableModels: string[] = []; - async onload() { + async onload(): Promise<void> { await this.loadSettings(); - // This creates an icon in the left ribbon. - this.addRibbonIcon('dice', 'Sample', (evt: MouseEvent) => { - // Called when the user clicks the icon. - new Notice('This is a notice!'); - }); + this.registerView(VIEW_TYPE_CHAT, (leaf) => new ChatView(leaf, this)); - // This adds a status bar item to the bottom of the app. Does not work on mobile apps. - const statusBarItemEl = this.addStatusBarItem(); - statusBarItemEl.setText('Status bar text'); + this.addRibbonIcon("message-square", "Open AI Chat", () => { + void this.activateView(); + }); - // This adds a simple command that can be triggered anywhere this.addCommand({ - id: 'open-modal-simple', - name: 'Open modal (simple)', + id: "open-chat", + name: "Open AI Chat", callback: () => { - new SampleModal(this.app).open(); - } - }); - // This adds an editor command that can perform some operation on the current editor instance - this.addCommand({ - id: 'replace-selected', - name: 'Replace selected content', - editorCallback: (editor: Editor, view: MarkdownView) => { - editor.replaceSelection('Sample editor command'); - } + void this.activateView(); + }, }); - // This adds a complex command that can check whether the current state of the app allows execution of the command - this.addCommand({ - id: 'open-modal-complex', - name: 'Open modal (complex)', - checkCallback: (checking: boolean) => { - // Conditions to check - const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView); - if (markdownView) { - // If checking is true, we're simply "checking" if the command can be run. - // If checking is false, then we want to actually perform the operation. - if (!checking) { - new SampleModal(this.app).open(); - } - - // This command will only show up in Command Palette when the check function returns true - return true; - } - return false; - } - }); - - // This adds a settings tab so the user can configure various aspects of the plugin - this.addSettingTab(new SampleSettingTab(this.app, this)); + } - // If the plugin hooks up any global DOM events (on parts of the app that doesn't belong to this plugin) - // Using this function will automatically remove the event listener when this plugin is disabled. - this.registerDomEvent(document, 'click', (evt: MouseEvent) => { - new Notice("Click"); - }); + onunload(): void { + this.app.workspace.detachLeavesOfType(VIEW_TYPE_CHAT); + } - // When registering intervals, this function will automatically clear the interval when the plugin is disabled. - this.registerInterval(window.setInterval(() => console.log('setInterval'), 5 * 60 * 1000)); + async activateView(): Promise<void> { + const existing = this.app.workspace.getLeavesOfType(VIEW_TYPE_CHAT); + if (existing.length > 0) { + const first = existing[0]; + if (first !== undefined) { + this.app.workspace.revealLeaf(first); + } + return; + } - } + const leaf: WorkspaceLeaf | null = this.app.workspace.getRightLeaf(false); + if (leaf === null) { + return; + } - onunload() { + await leaf.setViewState({ type: VIEW_TYPE_CHAT, active: true }); + this.app.workspace.revealLeaf(leaf); } - async loadSettings() { - this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData() as Partial<MyPluginSettings>); + async loadSettings(): Promise<void> { + this.settings = Object.assign( + {}, + DEFAULT_SETTINGS, + await this.loadData() as Partial<AIOrganizerSettings> | null, + ); } - async saveSettings() { + async saveSettings(): Promise<void> { await this.saveData(this.settings); } -} - -class SampleModal extends Modal { - constructor(app: App) { - super(app); - } - onOpen() { - let {contentEl} = this; - contentEl.setText('Woah!'); - } + async connect(): Promise<void> { + this.connectionStatus = "connecting"; + this.connectionMessage = "Connecting..."; + this.availableModels = []; + + try { + const version = await testConnection(this.settings.ollamaUrl); + this.connectionMessage = `Connected — Ollama v${version}`; + + try { + this.availableModels = await listModels(this.settings.ollamaUrl); + } catch (modelErr: unknown) { + const modelMsg = + modelErr instanceof Error + ? modelErr.message + : "Failed to list models."; + this.connectionMessage = `Connected — Ollama v${version} (${modelMsg})`; + } - onClose() { - const {contentEl} = this; - contentEl.empty(); + this.connectionStatus = "connected"; + } catch (err: unknown) { + const errMsg = err instanceof Error ? err.message : "Connection failed."; + this.connectionMessage = errMsg; + this.connectionStatus = "error"; + } } } diff --git a/src/ollama-client.ts b/src/ollama-client.ts new file mode 100644 index 0000000..377d640 --- /dev/null +++ b/src/ollama-client.ts @@ -0,0 +1,97 @@ +import { requestUrl } from "obsidian"; + +export interface ChatMessage { + role: "system" | "user" | "assistant"; + content: string; +} + +export async function testConnection(ollamaUrl: string): Promise<string> { + try { + const response = await requestUrl({ + url: `${ollamaUrl}/api/version`, + method: "GET", + throw: false, + }); + + if (response.status === 200) { + const version = (response.json as Record<string, unknown>).version; + if (typeof version === "string") { + return version; + } + throw new Error("Unexpected response format: missing version field."); + } + + throw new Error(`Ollama returned status ${response.status}.`); + } catch (err: unknown) { + if (err instanceof Error) { + const msg = err.message.toLowerCase(); + if (msg.includes("net") || msg.includes("fetch") || msg.includes("failed to fetch")) { + throw new Error("Ollama is unreachable. Is the server running?"); + } + throw err; + } + throw new Error("Ollama is unreachable. Is the server running?"); + } +} + +export async function listModels(ollamaUrl: string): Promise<string[]> { + try { + const response = await requestUrl({ + url: `${ollamaUrl}/api/tags`, + method: "GET", + }); + + const models = (response.json as Record<string, unknown>).models; + if (!Array.isArray(models)) { + throw new Error("Unexpected response format: missing models array."); + } + + return models.map((m: unknown) => { + if (typeof m === "object" && m !== null && "name" in m) { + const name = (m as Record<string, unknown>).name; + if (typeof name === "string") { + return name; + } + return String(name); + } + return String(m); + }); + } catch (err: unknown) { + if (err instanceof Error) { + throw new Error(`Failed to list models: ${err.message}`); + } + throw new Error("Failed to list models: unknown error."); + } +} + +export async function sendChatMessage( + ollamaUrl: string, + model: string, + messages: ChatMessage[], +): 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 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; + } + + throw new Error("Unexpected response format: missing message content."); + } catch (err: unknown) { + if (err instanceof Error) { + throw new Error(`Chat request failed: ${err.message}`); + } + throw new Error("Chat request failed: unknown error."); + } +} diff --git a/src/settings-modal.ts b/src/settings-modal.ts new file mode 100644 index 0000000..2daca89 --- /dev/null +++ b/src/settings-modal.ts @@ -0,0 +1,113 @@ +import { Modal, Setting } from "obsidian"; +import type AIOrganizer from "./main"; + +export class SettingsModal 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-settings-modal"); + + this.setTitle("AI Organizer Settings"); + + // Ollama URL setting + new Setting(contentEl) + .setName("Ollama URL") + .setDesc("Base URL of the Ollama server.") + .addText((text) => + text + .setValue(this.plugin.settings.ollamaUrl) + .onChange(async (value) => { + this.plugin.settings.ollamaUrl = value; + await this.plugin.saveSettings(); + }), + ); + + // Model dropdown + let modelDropdownSelectEl: HTMLSelectElement | null = null; + + const modelSetting = new Setting(contentEl) + .setName("Model") + .setDesc("Select the model to use.") + .addDropdown((dropdown) => { + this.populateModelDropdown(dropdown.selectEl); + dropdown.onChange(async (value) => { + this.plugin.settings.model = value; + await this.plugin.saveSettings(); + }); + modelDropdownSelectEl = dropdown.selectEl; + }); + + // Connect button + const connectSetting = new Setting(contentEl) + .setName("Connect") + .setDesc(this.plugin.connectionMessage); + + connectSetting.addButton((button) => + button.setButtonText("Connect").onClick(async () => { + const descEl = connectSetting.descEl; + descEl.setText("Connecting..."); + + await this.plugin.connect(); + + descEl.setText(this.plugin.connectionMessage); + + if (modelDropdownSelectEl !== null) { + this.populateModelDropdown(modelDropdownSelectEl); + } + }), + ); + + // Move connect above model in the DOM + contentEl.insertBefore(connectSetting.settingEl, modelSetting.settingEl); + } + + onClose(): void { + this.contentEl.empty(); + } + + private populateModelDropdown(selectEl: HTMLSelectElement): void { + const models = this.plugin.availableModels; + + selectEl.empty(); + + if (models.length === 0) { + const placeholderOpt = selectEl.createEl("option", { + text: "Connect first", + attr: { value: "" }, + }); + placeholderOpt.value = ""; + selectEl.disabled = true; + return; + } + + const placeholderOpt = selectEl.createEl("option", { + text: "Select a model...", + attr: { value: "" }, + }); + placeholderOpt.value = ""; + + for (const modelName of models) { + const opt = selectEl.createEl("option", { + text: modelName, + attr: { value: modelName }, + }); + opt.value = modelName; + } + + if ( + this.plugin.settings.model !== "" && + models.includes(this.plugin.settings.model) + ) { + selectEl.value = this.plugin.settings.model; + } + + selectEl.disabled = false; + } +} diff --git a/src/settings.ts b/src/settings.ts index 352121e..a9ed8fb 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,36 +1,9 @@ -import {App, PluginSettingTab, Setting} from "obsidian"; -import MyPlugin from "./main"; - -export interface MyPluginSettings { - mySetting: string; -} - -export const DEFAULT_SETTINGS: MyPluginSettings = { - mySetting: 'default' +export interface AIOrganizerSettings { + ollamaUrl: string; + model: string; } -export class SampleSettingTab extends PluginSettingTab { - plugin: MyPlugin; - - constructor(app: App, plugin: MyPlugin) { - super(app, plugin); - this.plugin = plugin; - } - - display(): void { - const {containerEl} = this; - - containerEl.empty(); - - new Setting(containerEl) - .setName('Settings #1') - .setDesc('It\'s a secret') - .addText(text => text - .setPlaceholder('Enter your secret') - .setValue(this.plugin.settings.mySetting) - .onChange(async (value) => { - this.plugin.settings.mySetting = value; - await this.plugin.saveSettings(); - })); - } -} +export const DEFAULT_SETTINGS: AIOrganizerSettings = { + ollamaUrl: "http://localhost:11434", + model: "", +}; @@ -1,8 +1,92 @@ -/* +.ai-organizer-chat-container { + display: flex; + flex-direction: column; + height: 100%; +} -This CSS file will be included with your plugin, and -available in the app when your plugin is enabled. +.ai-organizer-messages-area { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + min-height: 0; +} -If your plugin does not need CSS, delete this file. +.ai-organizer-messages { + flex: 1; + overflow-y: auto; + padding: 8px; + display: flex; + flex-direction: column; + gap: 6px; +} -*/ +.ai-organizer-message { + padding: 8px 12px; + border-radius: 8px; + max-width: 85%; + word-wrap: break-word; + white-space: pre-wrap; +} + +.ai-organizer-message.user { + align-self: flex-end; + background-color: var(--interactive-accent); + color: var(--text-on-accent); +} + +.ai-organizer-message.assistant { + align-self: flex-start; + background-color: var(--background-secondary); +} + +.ai-organizer-message.error { + color: var(--text-error); +} + +.ai-organizer-input-row { + display: flex; + flex-direction: row; + gap: 6px; + padding: 8px; +} + +.ai-organizer-input-row textarea { + flex: 1; + resize: vertical; + background-color: var(--background-primary); + border: 1px solid var(--background-modifier-border); + color: var(--text-normal); + border-radius: 4px; + padding: 6px 8px; + font-family: inherit; + font-size: inherit; +} + +.ai-organizer-input-row textarea:focus { + border-color: var(--interactive-accent); + outline: none; +} + +.ai-organizer-input-buttons { + display: flex; + flex-direction: column; + gap: 4px; +} + +.ai-organizer-settings-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-settings-btn:hover { + color: var(--text-normal); + background-color: var(--background-modifier-hover); +} |
