summaryrefslogtreecommitdiffhomepage
path: root/src
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 /src
parente5583b836d4fe2f7f9806ed85a190254a6ea3990 (diff)
downloadai-pulse-obsidian-plugin-bb543c3f7840f2a3fa1b7a1fb32245fa87a30f7b.tar.gz
ai-pulse-obsidian-plugin-bb543c3f7840f2a3fa1b7a1fb32245fa87a30f7b.zip
initial prototype
Diffstat (limited to 'src')
-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
5 files changed, 447 insertions, 109 deletions
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: "",
+};