diff options
| author | Adam Malczewski <[email protected]> | 2026-03-29 13:34:17 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-03-29 13:34:17 +0900 |
| commit | 67d7d50ee2b05f66de3ab6aea38ff5d7d56ce839 (patch) | |
| tree | 28ae1b054bbab57317a238550d3384a2d2027a24 | |
| parent | 90346a91a81c317b90f4ca9a64cbaaf0ade7868b (diff) | |
| download | ai-pulse-obsidian-plugin-67d7d50ee2b05f66de3ab6aea38ff5d7d56ce839.tar.gz ai-pulse-obsidian-plugin-67d7d50ee2b05f66de3ab6aea38ff5d7d56ce839.zip | |
fix chat sync
| -rw-r--r-- | .rules/changelog/2026-03/29/01.md | 30 | ||||
| -rw-r--r-- | src/chat-history.ts | 116 | ||||
| -rw-r--r-- | src/chat-view.ts | 20 | ||||
| -rw-r--r-- | src/main.ts | 15 | ||||
| -rw-r--r-- | src/settings.ts | 12 |
5 files changed, 52 insertions, 141 deletions
diff --git a/.rules/changelog/2026-03/29/01.md b/.rules/changelog/2026-03/29/01.md index 117efc2..6d037c6 100644 --- a/.rules/changelog/2026-03/29/01.md +++ b/.rules/changelog/2026-03/29/01.md @@ -1,24 +1,30 @@ # Chat History Persistence & Cross-Device Sync ## New File: `src/chat-history.ts` -- Added `PersistedMessage` and `ChatHistoryData` interfaces for typed storage -- `loadChatHistory()` — reads from `chat-history.json` in the plugin folder via `vault.adapter` -- `saveChatHistory()` — writes user/assistant messages to disk (strips system/tool messages) -- `clearChatHistory()` — writes an empty history file (not deletion) for reliable Obsidian Sync propagation -- `toPersistableMessages()` / `toRuntimeMessages()` — conversion between runtime `ChatMessage[]` and storage format -- `isValidChatHistory()` — strict type guard for safe JSON parsing with version check +- Added `toPersistableMessages()` — filters ChatMessage[] to only user/assistant messages +- Added `toRuntimeMessages()` — converts persisted messages back to ChatMessage[] for LLM context +- Re-exports `PersistedMessage` type from settings + +## Modified: `src/settings.ts` +- Added `PersistedMessage` interface (role: user | assistant, content: string) +- Added `chatHistory: PersistedMessage[]` field to `AIPulseSettings` +- Chat history is stored in `data.json` via Obsidian's `loadData()`/`saveData()`, ensuring: + - Correct file path regardless of plugin folder name vs manifest ID + - Native Obsidian Sync support + - `onExternalSettingsChange()` fires automatically on sync ## Modified: `src/chat-view.ts` -- On **open**: restores persisted chat history, renders user messages as plain text and assistant messages as markdown (with wiki-link navigation) +- On **open**: restores persisted chat history from `plugin.settings.chatHistory`, renders user messages as plain text and assistant messages as rendered markdown with wiki-link navigation - On **message send**: debounced save (500ms) after user message and after assistant response completes - On **close**: flushes pending save and cleans up debounce timer -- On **clear chat**: writes empty history file and updates sync snapshot -- Added `reloadChatHistory()` public method for external sync triggers; skips reload if streaming is active to avoid UI disruption -- Added `saveChatHistoryDebounced()` with snapshot update to prevent false sync reloads from local writes +- On **clear chat**: sets `chatHistory` to empty array and saves settings (syncs clear to all devices) +- Added `reloadChatHistory()` public method for external sync triggers; skips reload if streaming is active +- Added `saveChatHistoryDebounced()` with snapshot update to prevent false sync reloads +- Added `renderPersistedMessages()` to re-render history with markdown and wiki-link click handlers ## Modified: `src/main.ts` - Added `onExternalSettingsChange()` — reloads settings and checks for chat history changes when Obsidian Sync updates `data.json` -- Added `visibilitychange` DOM event listener — checks for synced changes when the app regains focus (covers device switching) -- Added `checkChatHistorySync()` — snapshot-based change detection that reloads the chat view only when the persisted file differs from the known state +- Added `visibilitychange` DOM event listener — reloads settings from disk when the app regains focus (covers device switching) +- Added `checkChatHistorySync()` — snapshot-based change detection that reloads the chat view only when the persisted data differs from the known state - Added `updateChatSnapshot()` — called after local saves and restores to prevent false sync triggers - Added `buildChatSnapshot()` helper — lightweight string comparison using message count and last message content diff --git a/src/chat-history.ts b/src/chat-history.ts index ac29ed9..ac7c6a8 100644 --- a/src/chat-history.ts +++ b/src/chat-history.ts @@ -1,34 +1,7 @@ -import type { App } from "obsidian"; import type { ChatMessage } from "./ollama-client"; +import type { PersistedMessage } from "./settings"; -/** - * Stored chat history format. - * Only user and assistant messages are persisted — system and tool messages - * are transient (injected per-request by the agent loop). - */ -export interface ChatHistoryData { - version: 1; - messages: PersistedMessage[]; -} - -/** - * A message stored in the chat history file. - * This is a subset of ChatMessage — we strip tool_calls, tool_name, - * and system/tool role messages since they are not meaningful across sessions. - */ -export interface PersistedMessage { - role: "user" | "assistant"; - content: string; -} - -const CHAT_HISTORY_FILENAME = "chat-history.json"; - -/** - * Resolve the full path to the chat history file inside the plugin folder. - */ -function getHistoryPath(app: App, pluginId: string): string { - return `${app.vault.configDir}/plugins/${pluginId}/${CHAT_HISTORY_FILENAME}`; -} +export type { PersistedMessage } from "./settings"; /** * Filter ChatMessage[] down to only persistable user/assistant messages. @@ -49,88 +22,3 @@ export function toPersistableMessages(messages: readonly ChatMessage[]): Persist export function toRuntimeMessages(messages: readonly PersistedMessage[]): ChatMessage[] { return messages.map((m) => ({ role: m.role, content: m.content })); } - -/** - * Load chat history from the plugin folder. - * Returns an empty array if the file doesn't exist or is corrupted. - */ -export async function loadChatHistory(app: App, pluginId: string): Promise<PersistedMessage[]> { - const path = getHistoryPath(app, pluginId); - - try { - const exists = await app.vault.adapter.exists(path); - if (!exists) { - return []; - } - - const raw = await app.vault.adapter.read(path); - const parsed = JSON.parse(raw) as unknown; - - if (!isValidChatHistory(parsed)) { - return []; - } - - return parsed.messages; - } catch { - return []; - } -} - -/** - * Save chat history to the plugin folder. - */ -export async function saveChatHistory( - app: App, - pluginId: string, - messages: readonly ChatMessage[], -): Promise<void> { - const path = getHistoryPath(app, pluginId); - const persistable = toPersistableMessages(messages); - - const data: ChatHistoryData = { - version: 1, - messages: persistable, - }; - - await app.vault.adapter.write(path, JSON.stringify(data, null, 2)); -} - -/** - * Clear the chat history by writing an empty messages array. - * Writing an empty file rather than deleting ensures Obsidian Sync - * propagates the "cleared" state to all devices. - */ -export async function clearChatHistory(app: App, pluginId: string): Promise<void> { - const path = getHistoryPath(app, pluginId); - - const data: ChatHistoryData = { - version: 1, - messages: [], - }; - - try { - await app.vault.adapter.write(path, JSON.stringify(data, null, 2)); - } catch { - // Silently ignore — clear is best-effort - } -} - -/** - * Type guard for validating the parsed chat history JSON. - */ -function isValidChatHistory(value: unknown): value is ChatHistoryData { - if (typeof value !== "object" || value === null) return false; - - const obj = value as Record<string, unknown>; - if (obj["version"] !== 1) return false; - if (!Array.isArray(obj["messages"])) return false; - - for (const msg of obj["messages"]) { - if (typeof msg !== "object" || msg === null) return false; - const m = msg as Record<string, unknown>; - if (m["role"] !== "user" && m["role"] !== "assistant") return false; - if (typeof m["content"] !== "string") return false; - } - - return true; -} diff --git a/src/chat-view.ts b/src/chat-view.ts index 72e7a32..dd27c63 100644 --- a/src/chat-view.ts +++ b/src/chat-view.ts @@ -7,7 +7,7 @@ import { ToolModal } from "./tool-modal"; import { TOOL_REGISTRY } from "./tools"; import type { OllamaToolDefinition } from "./tools"; import { collectVaultContext, formatVaultContext } from "./vault-context"; -import { loadChatHistory, saveChatHistory, clearChatHistory, toRuntimeMessages, toPersistableMessages } from "./chat-history"; +import { toRuntimeMessages, toPersistableMessages } from "./chat-history"; import type { PersistedMessage } from "./chat-history"; export const VIEW_TYPE_CHAT = "ai-pulse-chat"; @@ -116,8 +116,9 @@ export class ChatView extends ItemView { if (this.messageContainer !== null) { this.messageContainer.empty(); } - void clearChatHistory(this.plugin.app, this.plugin.manifest.id); + this.plugin.settings.chatHistory = []; this.plugin.updateChatSnapshot([]); + void this.plugin.saveSettings(); (document.activeElement as HTMLElement)?.blur(); }); @@ -161,7 +162,8 @@ export class ChatView extends ItemView { this.saveDebounceTimer = null; } // Save any pending history before closing - void saveChatHistory(this.plugin.app, this.plugin.manifest.id, this.messages); + this.plugin.settings.chatHistory = toPersistableMessages(this.messages); + void this.plugin.saveSettings(); this.contentEl.empty(); this.messages = []; this.bubbleContent.clear(); @@ -762,12 +764,12 @@ export class ChatView extends ItemView { } this.saveDebounceTimer = setTimeout(() => { this.saveDebounceTimer = null; - void saveChatHistory(this.plugin.app, this.plugin.manifest.id, this.messages); + const persistable = toPersistableMessages(this.messages); + this.plugin.settings.chatHistory = persistable; // Update the plugin's snapshot so the sync checker doesn't treat // our own save as an external change. - this.plugin.updateChatSnapshot( - toPersistableMessages(this.messages), - ); + this.plugin.updateChatSnapshot(persistable); + void this.plugin.saveSettings(); }, 500); } @@ -775,7 +777,7 @@ export class ChatView extends ItemView { * Restore chat history from the persisted file and render messages. */ private async restoreChatHistory(): Promise<void> { - const persisted = await loadChatHistory(this.plugin.app, this.plugin.manifest.id); + const persisted = this.plugin.settings.chatHistory; if (persisted.length === 0) return; this.messages = toRuntimeMessages(persisted); @@ -828,7 +830,7 @@ export class ChatView extends ItemView { * Replaces the current messages and re-renders the UI. */ async reloadChatHistory(): Promise<void> { - const persisted = await loadChatHistory(this.plugin.app, this.plugin.manifest.id); + const persisted = this.plugin.settings.chatHistory; // Skip reload if we're currently streaming — avoid disrupting the UI if (this.abortController !== null) return; diff --git a/src/main.ts b/src/main.ts index 96eb207..e09cf56 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,7 +4,6 @@ import { DEFAULT_SETTINGS } from "./settings"; import { ChatView, VIEW_TYPE_CHAT } from "./chat-view"; import { testConnection, listModels } from "./ollama-client"; import { getDefaultToolStates } from "./tools"; -import { loadChatHistory } from "./chat-history"; import type { PersistedMessage } from "./chat-history"; export default class AIPulse extends Plugin { @@ -39,7 +38,11 @@ export default class AIPulse extends Plugin { // We check when the app regains visibility (user switches back from another app/device). this.registerDomEvent(document, "visibilitychange", () => { if (document.visibilityState === "visible") { - void this.checkChatHistorySync(); + // Reload settings from disk in case Obsidian Sync updated data.json + // while the app was in the background. + void this.loadSettings().then(() => { + this.checkChatHistorySync(); + }); } }); } @@ -87,20 +90,20 @@ export default class AIPulse extends Plugin { /** * Called by Obsidian when data.json is modified externally (e.g., via Sync). - * This is a strong signal that other plugin files may also have been synced. + * Reloads settings (which now include chat history) and syncs the chat view. */ async onExternalSettingsChange(): Promise<void> { await this.loadSettings(); - void this.checkChatHistorySync(); + this.checkChatHistorySync(); } /** * Check if the persisted chat history has changed (e.g., from another device) * and reload the chat view if needed. */ - async checkChatHistorySync(): Promise<void> { + checkChatHistorySync(): void { try { - const persisted = await loadChatHistory(this.app, this.manifest.id); + const persisted = this.settings.chatHistory; const snapshot = buildChatSnapshot(persisted); if (snapshot === this.lastChatSnapshot) return; diff --git a/src/settings.ts b/src/settings.ts index c61af9d..ab20416 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,5 +1,15 @@ import { getDefaultToolStates } from "./tools"; +/** + * A message stored in the persisted chat history. + * Only user and assistant messages are persisted — system and tool messages + * are transient (injected per-request by the agent loop). + */ +export interface PersistedMessage { + role: "user" | "assistant"; + content: string; +} + export interface AIPulseSettings { ollamaUrl: string; model: string; @@ -11,6 +21,7 @@ export interface AIPulseSettings { systemPromptFile: string; injectVaultContext: boolean; vaultContextRecentFiles: number; + chatHistory: PersistedMessage[]; } export const DEFAULT_SETTINGS: AIPulseSettings = { @@ -24,4 +35,5 @@ export const DEFAULT_SETTINGS: AIPulseSettings = { systemPromptFile: "agent.md", injectVaultContext: false, vaultContextRecentFiles: 20, + chatHistory: [], }; |
