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 /src | |
| parent | e5583b836d4fe2f7f9806ed85a190254a6ea3990 (diff) | |
| download | ai-pulse-obsidian-plugin-bb543c3f7840f2a3fa1b7a1fb32245fa87a30f7b.tar.gz ai-pulse-obsidian-plugin-bb543c3f7840f2a3fa1b7a1fb32245fa87a30f7b.zip | |
initial prototype
Diffstat (limited to 'src')
| -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 |
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: "", +}; |
