summaryrefslogtreecommitdiffhomepage
path: root/src/chat-view.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/chat-view.ts')
-rw-r--r--src/chat-view.ts108
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;