import { ItemView, MarkdownRenderer, Notice, TFile, WorkspaceLeaf, setIcon } from "obsidian"; import type AIPulse from "./main"; import type { ChatMessage, ToolCallEvent, ApprovalRequestEvent } from "./ollama-client"; import { sendChatMessageStreaming } from "./ollama-client"; import { SettingsModal } from "./settings-modal"; import { ToolModal } from "./tool-modal"; import { TOOL_REGISTRY } from "./tools"; import type { OllamaToolDefinition } from "./tools"; import { collectVaultContext, formatVaultContext } from "./vault-context"; export const VIEW_TYPE_CHAT = "ai-pulse-chat"; export class ChatView extends ItemView { private plugin: AIPulse; private messages: ChatMessage[] = []; private messageContainer: HTMLDivElement | null = null; private textarea: HTMLTextAreaElement | null = null; private sendButton: HTMLButtonElement | null = null; private toolsButton: HTMLButtonElement | null = null; private abortController: AbortController | null = null; private scrollDebounceTimer: ReturnType | null = null; private bubbleContent: Map = new Map(); private modelBadge: HTMLDivElement | null = null; constructor(leaf: WorkspaceLeaf, plugin: AIPulse) { super(leaf); this.plugin = plugin; } getViewType(): string { return VIEW_TYPE_CHAT; } getDisplayText(): string { return "AI Chat"; } getIcon(): string { return "message-square"; } async onOpen(): Promise { const { contentEl } = this; contentEl.empty(); contentEl.addClass("ai-pulse-chat-container"); // --- Top region: Chat area --- const messagesArea = contentEl.createDiv({ cls: "ai-pulse-messages-area" }); this.messageContainer = messagesArea.createDiv({ cls: "ai-pulse-messages" }); // --- Model Badge (top left) --- this.modelBadge = messagesArea.createDiv({ cls: "ai-pulse-model-badge" }); this.updateModelBadge(); // --- FAB Speed Dial --- const fab = messagesArea.createDiv({ cls: "ai-pulse-fab" }); // Main FAB trigger button (first child) const fabTrigger = fab.createEl("button", { cls: "ai-pulse-fab-trigger", attr: { "aria-label": "Actions", tabindex: "0" }, }); setIcon(fabTrigger, "settings"); // Speed dial actions (revealed on focus-within) const settingsAction = fab.createDiv({ cls: "ai-pulse-fab-action" }); const settingsLabel = settingsAction.createSpan({ cls: "ai-pulse-fab-label", text: "AI Settings" }); void settingsLabel; const settingsBtn = settingsAction.createEl("button", { cls: "ai-pulse-fab-btn", attr: { "aria-label": "Settings" }, }); setIcon(settingsBtn, "sliders-horizontal"); settingsBtn.addEventListener("click", () => { const modal = new SettingsModal(this.plugin); modal.onClose = () => { this.updateModelBadge(); }; modal.open(); // Blur to close the FAB (document.activeElement as HTMLElement)?.blur(); }); const toolsAction = fab.createDiv({ cls: "ai-pulse-fab-action" }); const toolsLabel = toolsAction.createSpan({ cls: "ai-pulse-fab-label", text: "Tools" }); void toolsLabel; this.toolsButton = toolsAction.createEl("button", { cls: "ai-pulse-fab-btn", attr: { "aria-label": "Tools" }, }); setIcon(this.toolsButton, "wrench"); this.updateToolsButtonState(); this.toolsButton.addEventListener("click", () => { const modal = new ToolModal(this.plugin); modal.onClose = () => { this.updateToolsButtonState(); }; modal.open(); (document.activeElement as HTMLElement)?.blur(); }); const clearAction = fab.createDiv({ cls: "ai-pulse-fab-action" }); const clearLabel = clearAction.createSpan({ cls: "ai-pulse-fab-label", text: "Clear Chat" }); void clearLabel; const clearBtn = clearAction.createEl("button", { cls: "ai-pulse-fab-btn", attr: { "aria-label": "Clear Chat" }, }); setIcon(clearBtn, "trash-2"); clearBtn.addEventListener("click", () => { this.messages = []; this.bubbleContent.clear(); if (this.messageContainer !== null) { this.messageContainer.empty(); } (document.activeElement as HTMLElement)?.blur(); }); const inputRow = messagesArea.createDiv({ cls: "ai-pulse-input-row" }); this.textarea = inputRow.createEl("textarea", { attr: { placeholder: "Type a message...", rows: "2" }, }); // Send button this.sendButton = inputRow.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", () => { if (this.abortController !== null) { // Currently streaming — abort this.abortController.abort(); return; } void this.handleSend(); }); // Auto-connect on open void this.plugin.connect(); } async onClose(): Promise { if (this.abortController !== null) { this.abortController.abort(); } this.contentEl.empty(); this.messages = []; this.bubbleContent.clear(); this.messageContainer = null; this.textarea = null; this.sendButton = null; this.toolsButton = null; this.modelBadge = null; this.abortController = null; } private getEnabledTools(): OllamaToolDefinition[] { const tools: OllamaToolDefinition[] = []; for (const tool of TOOL_REGISTRY) { if (this.plugin.settings.enabledTools[tool.id] === true) { tools.push(tool.definition); } } return tools; } private hasAnyToolEnabled(): boolean { return TOOL_REGISTRY.some( (tool) => this.plugin.settings.enabledTools[tool.id] === true, ); } private updateToolsButtonState(): void { if (this.toolsButton === null) return; this.toolsButton.toggleClass("ai-pulse-tools-active", this.hasAnyToolEnabled()); } private updateModelBadge(): void { if (this.modelBadge === null) return; const model = this.plugin.settings.model; if (model === "") { this.modelBadge.setText("No model selected"); this.modelBadge.addClass("ai-pulse-model-badge-empty"); } else { this.modelBadge.setText(model); this.modelBadge.removeClass("ai-pulse-model-badge-empty"); } } private async handleSend(): Promise { 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 }); // Switch to streaming state this.abortController = new AbortController(); this.setStreamingState(true); let currentBubble: HTMLDivElement | null = null; // Read custom system prompt from vault file if enabled let userSystemPrompt: string | undefined; if (this.plugin.settings.useSystemPromptFile) { const promptPath = this.plugin.settings.systemPromptFile; if (promptPath !== "") { const promptFile = this.plugin.app.vault.getAbstractFileByPath(promptPath); if (promptFile !== null && promptFile instanceof TFile) { try { userSystemPrompt = await this.plugin.app.vault.cachedRead(promptFile); } catch { // Silently skip if file can't be read } } } } // Build vault context if enabled let vaultContext: string | undefined; if (this.plugin.settings.injectVaultContext) { const ctx = collectVaultContext( this.plugin.app, this.plugin.settings.vaultContextRecentFiles, ); vaultContext = formatVaultContext(ctx); } try { const enabledTools = this.getEnabledTools(); const hasTools = enabledTools.length > 0; const onToolCall = (event: ToolCallEvent): void => { this.appendToolCall(event); this.scrollToBottom(); }; const onApprovalRequest = (event: ApprovalRequestEvent): Promise => { // Remove the empty streaming bubble since the approval // prompt is now the active UI element if (currentBubble !== null && currentBubble.textContent?.trim() === "") { this.bubbleContent.delete(currentBubble); currentBubble.remove(); currentBubble = null; } return this.showApprovalRequest(event); }; const onCreateBubble = (): void => { // Finalize any previous bubble before creating a new one if (currentBubble !== null) { void this.finalizeBubble(currentBubble); } currentBubble = this.createStreamingBubble(); }; const onChunk = (chunk: string): void => { if (currentBubble !== null) { // Remove the loading indicator on first chunk const loadingIcon = currentBubble.querySelector(".ai-pulse-loading-icon"); if (loadingIcon !== null) { loadingIcon.remove(); } // Accumulate raw text for later markdown rendering const prev = this.bubbleContent.get(currentBubble) ?? ""; this.bubbleContent.set(currentBubble, prev + chunk); currentBubble.appendText(chunk); this.debouncedScrollToBottom(); } }; const response = await sendChatMessageStreaming({ ollamaUrl: this.plugin.settings.ollamaUrl, model: this.plugin.settings.model, messages: this.messages, tools: hasTools ? enabledTools : undefined, app: hasTools ? this.plugin.app : undefined, options: { temperature: this.plugin.settings.temperature, num_ctx: this.plugin.settings.numCtx, num_predict: this.plugin.settings.numPredict, }, userSystemPrompt, vaultContext, onChunk, onToolCall: hasTools ? onToolCall : undefined, onApprovalRequest: hasTools ? onApprovalRequest : undefined, onCreateBubble, abortSignal: this.abortController.signal, }); // Finalize the last streaming bubble if (currentBubble !== null) { await this.finalizeBubble(currentBubble as HTMLDivElement); } this.messages.push({ role: "assistant", content: response }); this.scrollToBottom(); } catch (err: unknown) { // Finalize bubble even on error if (currentBubble !== null) { (currentBubble as HTMLDivElement).removeClass("ai-pulse-streaming"); const errorIcon = (currentBubble as HTMLDivElement).querySelector(".ai-pulse-loading-icon"); if (errorIcon !== null) { errorIcon.remove(); } // Remove empty bubble on error if ((currentBubble as HTMLDivElement).textContent?.trim() === "") { (currentBubble as HTMLDivElement).remove(); } this.bubbleContent.delete(currentBubble as HTMLDivElement); } const errMsg = err instanceof Error ? err.message : "Unknown error."; new Notice(errMsg); this.appendMessage("error", `Error: ${errMsg}`); this.scrollToBottom(); } // Restore normal state this.abortController = null; this.setStreamingState(false); this.textarea.focus(); } private createStreamingBubble(): HTMLDivElement { if (this.messageContainer === null) { // Should not happen, but satisfy TS throw new Error("Message container not initialized."); } const bubble = this.messageContainer.createDiv({ cls: "ai-pulse-message assistant ai-pulse-streaming", }); // Add a loading indicator icon const iconSpan = bubble.createSpan({ cls: "ai-pulse-loading-icon" }); setIcon(iconSpan, "more-horizontal"); return bubble; } /** * Finalize a streaming bubble: remove streaming state, render markdown, * and clean up the accumulated content tracker. */ private async finalizeBubble(bubble: HTMLDivElement): Promise { bubble.removeClass("ai-pulse-streaming"); // Remove loading icon if still present const loadingIcon = bubble.querySelector(".ai-pulse-loading-icon"); if (loadingIcon !== null) { loadingIcon.remove(); } const rawText = this.bubbleContent.get(bubble) ?? ""; this.bubbleContent.delete(bubble); // Remove empty bubbles (e.g., tool-only rounds with no content) if (rawText.trim() === "") { bubble.remove(); return; } // Replace plain text with rendered markdown bubble.empty(); bubble.removeClass("ai-pulse-streaming-text"); bubble.addClass("ai-pulse-markdown"); await MarkdownRenderer.render( this.plugin.app, rawText, 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); } }); }); this.scrollToBottom(); } private appendMessage(role: "user" | "assistant" | "error", content: string): void { if (this.messageContainer === null) { return; } const cls = role === "error" ? "ai-pulse-message assistant error" : `ai-pulse-message ${role}`; this.messageContainer.createDiv({ cls, text: content }); } private appendToolCall(event: ToolCallEvent): void { if (this.messageContainer === null) { return; } const container = this.messageContainer.createDiv({ cls: "ai-pulse-tool-call" }); const header = container.createDiv({ cls: "ai-pulse-tool-call-header" }); setIcon(header.createSpan({ cls: "ai-pulse-tool-call-icon" }), "wrench"); header.createSpan({ text: event.friendlyName, cls: "ai-pulse-tool-call-name" }); container.createDiv({ text: event.summary, cls: "ai-pulse-tool-call-summary" }); container.createDiv({ text: event.resultSummary, cls: "ai-pulse-tool-call-result-summary" }); // DaisyUI-style collapse with checkbox const collapse = container.createDiv({ cls: "ai-pulse-collapse ai-pulse-collapse-arrow" }); const collapseId = `tool-collapse-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; const checkbox = collapse.createEl("input", { type: "checkbox", attr: { id: collapseId }, }); checkbox.addClass("ai-pulse-collapse-toggle"); const titleEl = collapse.createEl("label", { cls: "ai-pulse-collapse-title", attr: { for: collapseId }, text: "Details", }); void titleEl; // suppress unused warning const collapseContent = collapse.createDiv({ cls: "ai-pulse-collapse-content" }); const contentInner = collapseContent.createDiv({ cls: "ai-pulse-collapse-content-inner" }); if (event.toolName === "edit_file") { // For edit_file, show old_text / new_text in dedicated labeled blocks const filePath = typeof event.args.file_path === "string" ? event.args.file_path : ""; const oldText = typeof event.args.old_text === "string" ? event.args.old_text : ""; const newText = typeof event.args.new_text === "string" ? event.args.new_text : ""; if (filePath !== "") { contentInner.createEl("div", { text: `File: ${filePath}`, cls: "ai-pulse-tool-call-label" }); } contentInner.createEl("div", { text: "Old text:", cls: "ai-pulse-tool-call-label" }); contentInner.createEl("pre", { text: oldText === "" ? "(empty — new file)" : oldText, cls: "ai-pulse-tool-call-args", }); contentInner.createEl("div", { text: "New text:", cls: "ai-pulse-tool-call-label" }); contentInner.createEl("pre", { text: newText, cls: "ai-pulse-tool-call-result", }); } else { const argsStr = JSON.stringify(event.args, null, 2); contentInner.createEl("pre", { text: argsStr, cls: "ai-pulse-tool-call-args" }); const resultPreview = event.result.length > 500 ? event.result.substring(0, 500) + "..." : event.result; contentInner.createEl("pre", { text: resultPreview, cls: "ai-pulse-tool-call-result" }); } } private showApprovalRequest(event: ApprovalRequestEvent): Promise { return new Promise((resolve) => { if (this.messageContainer === null) { resolve(false); return; } const container = this.messageContainer.createDiv({ cls: "ai-pulse-approval" }); const header = container.createDiv({ cls: "ai-pulse-approval-header" }); setIcon(header.createSpan({ cls: "ai-pulse-approval-icon" }), "shield-alert"); header.createSpan({ text: event.friendlyName, cls: "ai-pulse-approval-name" }); container.createDiv({ text: event.message, cls: "ai-pulse-approval-message" }); // Show details for edit_file so the user can review the change if (event.toolName === "edit_file" || event.toolName === "create_file" || event.toolName === "set_frontmatter") { const collapse = container.createDiv({ cls: "ai-pulse-collapse ai-pulse-collapse-arrow" }); const collapseId = `approval-collapse-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; const checkbox = collapse.createEl("input", { type: "checkbox", attr: { id: collapseId, checked: "" }, }); checkbox.addClass("ai-pulse-collapse-toggle"); checkbox.checked = true; const titleEl = collapse.createEl("label", { cls: "ai-pulse-collapse-title", attr: { for: collapseId }, text: event.toolName === "create_file" ? "Review content" : event.toolName === "set_frontmatter" ? "Review properties" : "Review changes", }); void titleEl; const collapseContent = collapse.createDiv({ cls: "ai-pulse-collapse-content" }); const contentInner = collapseContent.createDiv({ cls: "ai-pulse-collapse-content-inner" }); if (event.toolName === "edit_file") { const oldText = typeof event.args.old_text === "string" ? event.args.old_text : ""; const newText = typeof event.args.new_text === "string" ? event.args.new_text : ""; contentInner.createEl("div", { text: "Old text:", cls: "ai-pulse-tool-call-label" }); contentInner.createEl("pre", { text: oldText === "" ? "(empty \u2014 new file)" : oldText, cls: "ai-pulse-tool-call-args", }); contentInner.createEl("div", { text: "New text:", cls: "ai-pulse-tool-call-label" }); contentInner.createEl("pre", { text: newText, cls: "ai-pulse-tool-call-result", }); } else if (event.toolName === "set_frontmatter") { const props = event.args.properties; const propsStr = typeof props === "object" && props !== null ? JSON.stringify(props, null, 2) : typeof props === "string" ? props : "{}"; contentInner.createEl("div", { text: "Properties to set:", cls: "ai-pulse-tool-call-label" }); contentInner.createEl("pre", { text: propsStr, cls: "ai-pulse-tool-call-result", }); } else { // create_file const content = typeof event.args.content === "string" ? event.args.content : ""; contentInner.createEl("div", { text: "Content:", cls: "ai-pulse-tool-call-label" }); contentInner.createEl("pre", { text: content === "" ? "(empty file)" : content, cls: "ai-pulse-tool-call-result", }); } } const buttonRow = container.createDiv({ cls: "ai-pulse-approval-buttons" }); const approveBtn = buttonRow.createEl("button", { text: "Approve", cls: "ai-pulse-approval-approve", }); const declineBtn = buttonRow.createEl("button", { text: "Decline", cls: "ai-pulse-approval-decline", }); const finalize = (approved: boolean): void => { approveBtn.disabled = true; declineBtn.disabled = true; container.addClass(approved ? "ai-pulse-approval-approved" : "ai-pulse-approval-declined"); const statusEl = container.createDiv({ cls: "ai-pulse-approval-status" }); statusEl.setText(approved ? "Approved" : "Declined"); this.scrollToBottom(); resolve(approved); }; approveBtn.addEventListener("click", () => finalize(true)); declineBtn.addEventListener("click", () => finalize(false)); this.scrollToBottom(); }); } private scrollToBottom(): void { if (this.messageContainer !== null) { this.messageContainer.scrollTop = this.messageContainer.scrollHeight; } } private debouncedScrollToBottom(): void { if (this.scrollDebounceTimer !== null) return; this.scrollDebounceTimer = setTimeout(() => { this.scrollDebounceTimer = null; this.scrollToBottom(); }, 50); } private setStreamingState(streaming: boolean): void { if (this.textarea !== null) { this.textarea.disabled = streaming; } if (this.sendButton !== null) { this.sendButton.textContent = streaming ? "Stop" : "Send"; this.sendButton.toggleClass("ai-pulse-stop-btn", streaming); } } }