diff options
| author | Adam Malczewski <[email protected]> | 2026-03-29 13:25:18 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-03-29 13:25:18 +0900 |
| commit | 90346a91a81c317b90f4ca9a64cbaaf0ade7868b (patch) | |
| tree | 0be851269904a3ef76f16cff9d384914face8927 /src/chat-view.ts | |
| parent | bffeac84cb7b3094f7a4b879b6bab6ceaec561ac (diff) | |
| download | ai-pulse-obsidian-plugin-90346a91a81c317b90f4ca9a64cbaaf0ade7868b.tar.gz ai-pulse-obsidian-plugin-90346a91a81c317b90f4ca9a64cbaaf0ade7868b.zip | |
add persistent chat history with cross-device sync
Diffstat (limited to 'src/chat-view.ts')
| -rw-r--r-- | src/chat-view.ts | 108 |
1 files changed, 108 insertions, 0 deletions
diff --git a/src/chat-view.ts b/src/chat-view.ts index 55b730d..72e7a32 100644 --- a/src/chat-view.ts +++ b/src/chat-view.ts @@ -7,6 +7,8 @@ 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 type { PersistedMessage } from "./chat-history"; export const VIEW_TYPE_CHAT = "ai-pulse-chat"; @@ -19,6 +21,7 @@ export class ChatView extends ItemView { private toolsButton: HTMLButtonElement | null = null; private abortController: AbortController | null = null; private scrollDebounceTimer: ReturnType<typeof setTimeout> | null = null; + private saveDebounceTimer: ReturnType<typeof setTimeout> | null = null; private bubbleContent: Map<HTMLDivElement, string> = new Map(); private modelBadge: HTMLDivElement | null = null; @@ -113,6 +116,8 @@ export class ChatView extends ItemView { if (this.messageContainer !== null) { this.messageContainer.empty(); } + void clearChatHistory(this.plugin.app, this.plugin.manifest.id); + this.plugin.updateChatSnapshot([]); (document.activeElement as HTMLElement)?.blur(); }); @@ -142,12 +147,21 @@ export class ChatView extends ItemView { // Auto-connect on open void this.plugin.connect(); + + // Restore persisted chat history + void this.restoreChatHistory(); } async onClose(): Promise<void> { if (this.abortController !== null) { this.abortController.abort(); } + if (this.saveDebounceTimer !== null) { + clearTimeout(this.saveDebounceTimer); + this.saveDebounceTimer = null; + } + // Save any pending history before closing + void saveChatHistory(this.plugin.app, this.plugin.manifest.id, this.messages); this.contentEl.empty(); this.messages = []; this.bubbleContent.clear(); @@ -219,6 +233,7 @@ export class ChatView extends ItemView { // Track in message history this.messages.push({ role: "user", content: text }); + this.saveChatHistoryDebounced(); // Switch to streaming state this.abortController = new AbortController(); @@ -320,6 +335,7 @@ export class ChatView extends ItemView { await this.finalizeBubble(currentBubble); } this.messages.push({ role: "assistant", content: response }); + this.saveChatHistoryDebounced(); this.scrollToBottom(); } catch (err: unknown) { const isAbort = err instanceof DOMException && err.name === "AbortError"; @@ -737,6 +753,98 @@ export class ChatView extends ItemView { } } + /** + * Save chat history with debouncing to avoid excessive writes. + */ + private saveChatHistoryDebounced(): void { + if (this.saveDebounceTimer !== null) { + clearTimeout(this.saveDebounceTimer); + } + this.saveDebounceTimer = setTimeout(() => { + this.saveDebounceTimer = null; + void saveChatHistory(this.plugin.app, this.plugin.manifest.id, this.messages); + // 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), + ); + }, 500); + } + + /** + * 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); + if (persisted.length === 0) return; + + this.messages = toRuntimeMessages(persisted); + this.plugin.updateChatSnapshot(persisted); + await this.renderPersistedMessages(persisted); + this.scrollToBottom(); + } + + /** + * Render persisted messages into the chat container. + * User messages are shown as plain text; assistant messages are rendered as markdown. + */ + private async renderPersistedMessages(messages: PersistedMessage[]): Promise<void> { + if (this.messageContainer === null) return; + + for (const msg of messages) { + if (msg.role === "user") { + this.messageContainer.createDiv({ + cls: "ai-pulse-message user", + text: msg.content, + }); + } else if (msg.role === "assistant") { + const bubble = this.messageContainer.createDiv({ + cls: "ai-pulse-message assistant ai-pulse-markdown", + }); + await MarkdownRenderer.render( + this.plugin.app, + msg.content, + bubble, + "", + this, + ); + + // Wire up internal [[wiki-links]] so they navigate on click + bubble.querySelectorAll("a.internal-link").forEach((link) => { + link.addEventListener("click", (evt) => { + evt.preventDefault(); + const href = link.getAttribute("href"); + if (href !== null) { + void this.plugin.app.workspace.openLinkText(href, "", false); + } + }); + }); + } + } + } + + /** + * Reload chat history from disk (e.g., after an external sync). + * Replaces the current messages and re-renders the UI. + */ + async reloadChatHistory(): Promise<void> { + const persisted = await loadChatHistory(this.plugin.app, this.plugin.manifest.id); + + // Skip reload if we're currently streaming — avoid disrupting the UI + if (this.abortController !== null) return; + + this.messages = toRuntimeMessages(persisted); + this.bubbleContent.clear(); + if (this.messageContainer !== null) { + this.messageContainer.empty(); + } + + if (persisted.length > 0) { + await this.renderPersistedMessages(persisted); + this.scrollToBottom(); + } + } + private scrollToBottom(): void { if (this.messageContainer === null) return; const lastChild = this.messageContainer.lastElementChild; |
