summaryrefslogtreecommitdiffhomepage
path: root/src/main.ts
blob: e09cf56fb57ddc4b739428400015b5f0bd31f606 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
import { Plugin, WorkspaceLeaf } from "obsidian";
import type { AIPulseSettings } from "./settings";
import { DEFAULT_SETTINGS } from "./settings";
import { ChatView, VIEW_TYPE_CHAT } from "./chat-view";
import { testConnection, listModels } from "./ollama-client";
import { getDefaultToolStates } from "./tools";
import type { PersistedMessage } from "./chat-history";

export default class AIPulse extends Plugin {
	settings: AIPulseSettings = DEFAULT_SETTINGS;

	// Runtime connection state (not persisted)
	connectionStatus: "disconnected" | "connecting" | "connected" | "error" = "disconnected";
	connectionMessage = "";
	availableModels: string[] = [];

	// Snapshot of persisted chat history for sync change detection
	private lastChatSnapshot = "";

	async onload(): Promise<void> {
		await this.loadSettings();

		this.registerView(VIEW_TYPE_CHAT, (leaf) => new ChatView(leaf, this));

		this.addRibbonIcon("message-square", "Open AI Chat", () => {
			void this.activateView();
		});

		this.addCommand({
			id: "open-chat",
			name: "Open AI Chat",
			callback: () => {
				void this.activateView();
			},
		});

		// Detect chat history changes from Obsidian Sync or other devices.
		// We check when the app regains visibility (user switches back from another app/device).
		this.registerDomEvent(document, "visibilitychange", () => {
			if (document.visibilityState === "visible") {
				// Reload settings from disk in case Obsidian Sync updated data.json
				// while the app was in the background.
				void this.loadSettings().then(() => {
					this.checkChatHistorySync();
				});
			}
		});
	}

	onunload(): void {
		this.app.workspace.detachLeavesOfType(VIEW_TYPE_CHAT);
	}

	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;
		}

		await leaf.setViewState({ type: VIEW_TYPE_CHAT, active: true });
		this.app.workspace.revealLeaf(leaf);
	}

	async loadSettings(): Promise<void> {
		this.settings = Object.assign(
			{},
			DEFAULT_SETTINGS,
			await this.loadData() as Partial<AIPulseSettings> | null,
		);
		// Ensure enabledTools has entries for all registered tools
		this.settings.enabledTools = Object.assign(
			{},
			getDefaultToolStates(),
			this.settings.enabledTools,
		);
	}

	async saveSettings(): Promise<void> {
		await this.saveData(this.settings);
	}

	/**
	 * Called by Obsidian when data.json is modified externally (e.g., via Sync).
	 * Reloads settings (which now include chat history) and syncs the chat view.
	 */
	async onExternalSettingsChange(): Promise<void> {
		await this.loadSettings();
		this.checkChatHistorySync();
	}

	/**
	 * Check if the persisted chat history has changed (e.g., from another device)
	 * and reload the chat view if needed.
	 */
	checkChatHistorySync(): void {
		try {
			const persisted = this.settings.chatHistory;
			const snapshot = buildChatSnapshot(persisted);

			if (snapshot === this.lastChatSnapshot) return;
			this.lastChatSnapshot = snapshot;

			// Find the active chat view and reload it
			const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_CHAT);
			for (const leaf of leaves) {
				const view = leaf.view;
				if (view instanceof ChatView) {
					void view.reloadChatHistory();
				}
			}
		} catch {
			// Silently ignore — sync check is best-effort
		}
	}

	/**
	 * Update the snapshot after a local save so we don't trigger a false reload.
	 */
	updateChatSnapshot(messages: PersistedMessage[]): void {
		this.lastChatSnapshot = buildChatSnapshot(messages);
	}

	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})`;
			}

			this.connectionStatus = "connected";
		} catch (err: unknown) {
			const errMsg = err instanceof Error ? err.message : "Connection failed.";
			this.connectionMessage = errMsg;
			this.connectionStatus = "error";
		}
	}
}

/**
 * Build a lightweight snapshot string of chat messages for change detection.
 * Uses message count + last message content hash to detect changes
 * without deep comparison.
 */
function buildChatSnapshot(messages: PersistedMessage[]): string {
	if (messages.length === 0) return "empty";
	const last = messages[messages.length - 1];
	if (last === undefined) return "empty";
	return `${messages.length}:${last.role}:${last.content.length}:${last.content.slice(0, 100)}`;
}