summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-03-24 13:18:50 +0900
committerAdam Malczewski <[email protected]>2026-03-24 13:18:50 +0900
commitbb543c3f7840f2a3fa1b7a1fb32245fa87a30f7b (patch)
treed2a9db2741dfd9822c5f76dca278562220e9b064
parente5583b836d4fe2f7f9806ed85a190254a6ea3990 (diff)
downloadai-pulse-obsidian-plugin-bb543c3f7840f2a3fa1b7a1fb32245fa87a30f7b.tar.gz
ai-pulse-obsidian-plugin-bb543c3f7840f2a3fa1b7a1fb32245fa87a30f7b.zip
initial prototype
-rw-r--r--.rules/changelog/2026-03/24/05.md35
-rw-r--r--.rules/plan/plan.md255
-rw-r--r--README.md115
-rw-r--r--manifest.json11
-rw-r--r--package-lock.json4
-rw-r--r--package.json4
-rw-r--r--src/chat-view.ts161
-rw-r--r--src/main.ts144
-rw-r--r--src/ollama-client.ts97
-rw-r--r--src/settings-modal.ts113
-rw-r--r--src/settings.ts41
-rw-r--r--styles.css94
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`.
diff --git a/README.md b/README.md
index 8ffa20e..93c50da 100644
--- a/README.md
+++ b/README.md
@@ -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: "",
+};
diff --git a/styles.css b/styles.css
index 71cc60f..824b3be 100644
--- a/styles.css
+++ b/styles.css
@@ -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);
+}