- Font size slider now calls saveSettings() instead of saveData() so changes propagate to open views immediately - Chat messages persist across plugin reload via chatMessages array in plugin settings — ChatPersistence interface bridges ChatView to Plugin data layer - Messages auto-save on append and on close
526 lines
No EOL
18 KiB
TypeScript
526 lines
No EOL
18 KiB
TypeScript
import { ItemView, WorkspaceLeaf, MarkdownView, MarkdownRenderer } from "obsidian";
|
|
import type { Message, ChatState, ChatCompletionResponse, SlashCommand } from "./types";
|
|
import { ContextBridge } from "./context-bridge";
|
|
|
|
// ApiClient interface — the concrete implementation (api-client.ts) is
|
|
// instantiated with GatewayConfig and passed to ChatView via setApiClient().
|
|
export interface ApiClient {
|
|
chat(
|
|
messages: { role: string; content: string }[],
|
|
stream?: boolean
|
|
): Promise<ChatCompletionResponse>;
|
|
|
|
streamChat(
|
|
messages: { role: string; content: string }[],
|
|
onDelta: (text: string) => void
|
|
): Promise<ChatCompletionResponse>;
|
|
}
|
|
|
|
/** Persistence interface — saves/loads chat messages via plugin data. */
|
|
export interface ChatPersistence {
|
|
loadMessages(): Message[];
|
|
saveMessages(messages: Message[]): Promise<void>;
|
|
}
|
|
|
|
export const VIEW_TYPE = "hermes-chat-view";
|
|
|
|
export class ChatView extends ItemView {
|
|
private apiClient: ApiClient | null = null;
|
|
private contextBridge: ContextBridge | null = null;
|
|
private persistence: ChatPersistence | null = null;
|
|
private chatState: ChatState;
|
|
private messagesContainer: HTMLElement | null = null;
|
|
private statusBar: HTMLElement | null = null;
|
|
private loadingEl: HTMLElement | null = null;
|
|
private textarea: HTMLTextAreaElement | null = null;
|
|
private sendButton: HTMLButtonElement | null = null;
|
|
|
|
/** Context captured on chat focus — avoids the timing bug where
|
|
* activeEditor is null by the time handleSend() runs. */
|
|
private capturedNote: {
|
|
vault: string;
|
|
path: string;
|
|
title: string;
|
|
selection: string | null;
|
|
} | null = null;
|
|
|
|
// --- Slash command state ---
|
|
private slashCommands: SlashCommand[] = [];
|
|
private suggestEl: HTMLElement | null = null;
|
|
private suggestIndex: number = -1;
|
|
private filteredCommands: SlashCommand[] = [];
|
|
|
|
constructor(leaf: WorkspaceLeaf, apiClient?: ApiClient, contextBridge?: ContextBridge, persistence?: ChatPersistence) {
|
|
super(leaf);
|
|
this.apiClient = apiClient ?? null;
|
|
this.contextBridge = contextBridge ?? null;
|
|
this.persistence = persistence ?? null;
|
|
this.chatState = {
|
|
sessionId: null,
|
|
messages: [],
|
|
isLoading: false,
|
|
model: "hermes-agent",
|
|
contextActive: false,
|
|
};
|
|
}
|
|
|
|
getViewType(): string {
|
|
return VIEW_TYPE;
|
|
}
|
|
|
|
getDisplayText(): string {
|
|
return "Hermes";
|
|
}
|
|
|
|
async onOpen(): Promise<void> {
|
|
const container = this.containerEl.children[1];
|
|
container.empty();
|
|
container.addClass("hermes-chat-container");
|
|
|
|
// --- Message list area ---
|
|
this.messagesContainer = container.createDiv("chat-messages");
|
|
this.messagesContainer.id = "chat-messages";
|
|
|
|
// Capture note context on focus — this runs when the user clicks
|
|
// into chat from a markdown note, before activeEditor becomes null.
|
|
this.containerEl.addEventListener("focusin", () => {
|
|
if (this.contextBridge) {
|
|
this.capturedNote = this.contextBridge.snapshot();
|
|
}
|
|
});
|
|
|
|
// Also capture when user switches to a different note while chat is
|
|
// already open (no focus change on chat itself, but we want the
|
|
// new note's context).
|
|
this.registerEvent(
|
|
this.app.workspace.on("active-leaf-change", (leaf) => {
|
|
if (leaf?.view instanceof MarkdownView && this.contextBridge) {
|
|
this.capturedNote = this.contextBridge.snapshot();
|
|
}
|
|
})
|
|
);
|
|
|
|
// Loading indicator (hidden by default)
|
|
this.loadingEl = this.messagesContainer.createDiv("chat-loading");
|
|
this.loadingEl.setText("Thinking\u2026");
|
|
this.loadingEl.style.display = "none";
|
|
|
|
// --- Input area ---
|
|
const inputArea = container.createDiv("chat-input-area");
|
|
|
|
this.textarea = inputArea.createEl("textarea");
|
|
this.textarea.placeholder = "Type your message\u2026";
|
|
this.textarea.setAttribute("rows", "1");
|
|
|
|
// Keydown: handle Enter (send or autocomplete select), arrow keys,
|
|
// Escape (close suggest)
|
|
this.textarea.addEventListener("keydown", (event: KeyboardEvent) => {
|
|
if (this.suggestEl && this.suggestEl.style.display !== "none") {
|
|
// Autocomplete is open — intercept navigation keys
|
|
if (event.key === "ArrowDown") {
|
|
event.preventDefault();
|
|
this.suggestMove(1);
|
|
return;
|
|
}
|
|
if (event.key === "ArrowUp") {
|
|
event.preventDefault();
|
|
this.suggestMove(-1);
|
|
return;
|
|
}
|
|
if (event.key === "Enter" && !event.shiftKey) {
|
|
event.preventDefault();
|
|
if (this.suggestIndex >= 0 && this.suggestIndex < this.filteredCommands.length) {
|
|
this.suggestSelect(this.filteredCommands[this.suggestIndex]);
|
|
} else {
|
|
this.suggestClose();
|
|
}
|
|
return;
|
|
}
|
|
if (event.key === "Escape") {
|
|
event.preventDefault();
|
|
this.suggestClose();
|
|
return;
|
|
}
|
|
if (event.key === "Tab") {
|
|
event.preventDefault();
|
|
if (this.suggestIndex >= 0 && this.suggestIndex < this.filteredCommands.length) {
|
|
this.suggestSelect(this.filteredCommands[this.suggestIndex]);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Normal Enter = send
|
|
if (event.key === "Enter" && !event.shiftKey) {
|
|
event.preventDefault();
|
|
this.handleSend();
|
|
}
|
|
});
|
|
|
|
// Input event: detect '/' for slash command autocomplete
|
|
this.textarea.addEventListener("input", () => {
|
|
this.checkSlashTrigger();
|
|
});
|
|
|
|
this.sendButton = inputArea.createEl("button");
|
|
this.sendButton.setText("Send");
|
|
this.sendButton.addEventListener("click", () => this.handleSend());
|
|
|
|
// --- Slash command suggestion dropdown ---
|
|
this.suggestEl = container.createDiv("hermes-slash-suggest");
|
|
this.suggestEl.style.display = "none";
|
|
this.suggestEl.style.position = "absolute";
|
|
this.suggestEl.style.zIndex = "1000";
|
|
|
|
// --- Status bar ---
|
|
this.statusBar = container.createDiv("chat-status-bar");
|
|
this.updateStatus(this.chatState.model, null);
|
|
|
|
// Restore saved messages from previous session
|
|
if (this.persistence) {
|
|
const saved = this.persistence.loadMessages();
|
|
if (saved.length > 0) {
|
|
for (const msg of saved) {
|
|
await this.appendMessage(msg.role, msg.content);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async onClose(): Promise<void> {
|
|
await this.persistence?.saveMessages(this.chatState.messages);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public API
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Set the API client for real streaming calls (called by main.ts). */
|
|
setApiClient(client: ApiClient): void {
|
|
this.apiClient = client;
|
|
}
|
|
|
|
/** Toggle whether context (e.g. note content) should be sent with requests. */
|
|
setContextActive(active: boolean): void {
|
|
this.chatState.contextActive = active;
|
|
}
|
|
|
|
/** Set available slash commands (built-in + custom from settings). */
|
|
setSlashCommands(commands: SlashCommand[]): void {
|
|
this.slashCommands = commands;
|
|
}
|
|
|
|
/** Set chat font size via CSS custom property on the container. */
|
|
setChatFontSize(size: number): void {
|
|
this.containerEl.style.setProperty("--hermes-chat-font-size", `${size}px`);
|
|
}
|
|
|
|
/** Append a message bubble to the chat display and scroll to bottom. */
|
|
async appendMessage(role: "user" | "assistant" | "system", content: string): Promise<void> {
|
|
if (!this.messagesContainer) return;
|
|
|
|
// Track in state for persistence
|
|
const msg: Message = {
|
|
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
role,
|
|
content,
|
|
timestamp: Date.now(),
|
|
};
|
|
this.chatState.messages.push(msg);
|
|
// Fire-and-forget save — don't block rendering
|
|
this.persistence?.saveMessages(this.chatState.messages);
|
|
|
|
const wrapper = this.messagesContainer.createDiv("chat-message");
|
|
wrapper.addClass(`chat-message-${role}`);
|
|
|
|
const roleLabel = wrapper.createDiv("chat-message-role");
|
|
roleLabel.setText(
|
|
role === "user" ? "You" : role === "assistant" ? "Hermes" : "System"
|
|
);
|
|
|
|
const contentEl = wrapper.createDiv("chat-message-content");
|
|
await MarkdownRenderer.render(this.app, content, contentEl, "", this);
|
|
|
|
this.scrollToBottom();
|
|
}
|
|
|
|
/** Show or hide the "Thinking\u2026" loading indicator and lock input. */
|
|
setLoading(isLoading: boolean): void {
|
|
this.chatState.isLoading = isLoading;
|
|
if (this.loadingEl) {
|
|
this.loadingEl.style.display = isLoading ? "block" : "none";
|
|
}
|
|
if (this.textarea) {
|
|
this.textarea.disabled = isLoading;
|
|
}
|
|
if (this.sendButton) {
|
|
this.sendButton.disabled = isLoading;
|
|
}
|
|
}
|
|
|
|
/** Update the bottom status bar with current model and session info. */
|
|
updateStatus(model: string, sessionId: string | null): void {
|
|
this.chatState.model = model;
|
|
this.chatState.sessionId = sessionId;
|
|
if (this.statusBar) {
|
|
const sessionPart = sessionId ? `Session: ${sessionId}` : "Session: \u2014";
|
|
this.statusBar.setText(`Model: ${model} | ${sessionPart}`);
|
|
}
|
|
}
|
|
|
|
/** Clear all messages from the display. */
|
|
clearChat(): void {
|
|
if (!this.messagesContainer) return;
|
|
const children = Array.from(this.messagesContainer.children);
|
|
for (const child of children) {
|
|
if (child !== this.loadingEl) {
|
|
child.remove();
|
|
}
|
|
}
|
|
this.chatState.messages = [];
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Internal helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
private scrollToBottom(): void {
|
|
if (this.messagesContainer) {
|
|
this.messagesContainer.scrollTop = this.messagesContainer.scrollHeight;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Slash command autocomplete
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Check if the textarea currently has a '/' trigger worth suggesting for. */
|
|
private checkSlashTrigger(): void {
|
|
if (!this.textarea) return;
|
|
const value = this.textarea.value;
|
|
const cursorPos = this.textarea.selectionStart;
|
|
|
|
// Find the start of the current "word" — the text from the last
|
|
// whitespace/newline/start to the cursor.
|
|
let wordStart = cursorPos - 1;
|
|
while (wordStart >= 0 && value[wordStart] !== " " && value[wordStart] !== "\n") {
|
|
wordStart--;
|
|
}
|
|
wordStart++; // first char of the current word
|
|
|
|
const currentWord = value.slice(wordStart, cursorPos);
|
|
|
|
// Only trigger if current word starts with '/' and we're at the beginning
|
|
// of a word (after whitespace/newline/start of input).
|
|
if (currentWord.startsWith("/") && (wordStart === 0 || value[wordStart - 1] === " " || value[wordStart - 1] === "\n")) {
|
|
const query = currentWord.slice(1).toLowerCase();
|
|
this.filteredCommands = this.slashCommands.filter(
|
|
(cmd) =>
|
|
cmd.trigger.toLowerCase().includes(query) ||
|
|
cmd.description.toLowerCase().includes(query)
|
|
);
|
|
if (this.filteredCommands.length > 0) {
|
|
this.suggestShow();
|
|
return;
|
|
}
|
|
}
|
|
|
|
this.suggestClose();
|
|
}
|
|
|
|
private suggestShow(): void {
|
|
if (!this.suggestEl || !this.textarea) return;
|
|
this.suggestIndex = -1;
|
|
this.suggestEl.empty();
|
|
|
|
for (let i = 0; i < this.filteredCommands.length; i++) {
|
|
const cmd = this.filteredCommands[i];
|
|
const item = this.suggestEl.createDiv("hermes-slash-item");
|
|
const triggerEl = item.createSpan("hermes-slash-trigger");
|
|
triggerEl.setText(cmd.trigger);
|
|
const descEl = item.createSpan("hermes-slash-desc");
|
|
descEl.setText(cmd.description);
|
|
|
|
item.addEventListener("click", () => this.suggestSelect(cmd));
|
|
item.addEventListener("mouseenter", () => {
|
|
this.suggestIndex = i;
|
|
this.suggestHighlight();
|
|
});
|
|
}
|
|
|
|
// Position below textarea
|
|
const rect = this.textarea.getBoundingClientRect();
|
|
const containerRect = this.containerEl.getBoundingClientRect();
|
|
this.suggestEl.style.left = `${rect.left - containerRect.left}px`;
|
|
this.suggestEl.style.top = `${rect.bottom - containerRect.top}px`;
|
|
this.suggestEl.style.width = `${rect.width}px`;
|
|
this.suggestEl.style.display = "block";
|
|
}
|
|
|
|
private suggestClose(): void {
|
|
if (this.suggestEl) {
|
|
this.suggestEl.style.display = "none";
|
|
this.suggestEl.empty();
|
|
}
|
|
this.suggestIndex = -1;
|
|
this.filteredCommands = [];
|
|
}
|
|
|
|
private suggestMove(delta: number): void {
|
|
if (this.filteredCommands.length === 0) return;
|
|
this.suggestIndex += delta;
|
|
if (this.suggestIndex < 0) this.suggestIndex = this.filteredCommands.length - 1;
|
|
if (this.suggestIndex >= this.filteredCommands.length) this.suggestIndex = 0;
|
|
this.suggestHighlight();
|
|
}
|
|
|
|
private suggestHighlight(): void {
|
|
if (!this.suggestEl) return;
|
|
const items = this.suggestEl.querySelectorAll(".hermes-slash-item");
|
|
items.forEach((item, i) => {
|
|
if (i === this.suggestIndex) {
|
|
item.addClass("hermes-slash-item-active");
|
|
} else {
|
|
item.removeClass("hermes-slash-item-active");
|
|
}
|
|
});
|
|
}
|
|
|
|
/** Select a slash command: fill the textarea with the full prompt. */
|
|
private suggestSelect(cmd: SlashCommand): void {
|
|
if (!this.textarea) return;
|
|
const value = this.textarea.value;
|
|
const cursorPos = this.textarea.selectionStart;
|
|
|
|
// Find the word boundaries of the '/' trigger
|
|
let wordStart = cursorPos - 1;
|
|
while (wordStart >= 0 && value[wordStart] !== " " && value[wordStart] !== "\n") {
|
|
wordStart--;
|
|
}
|
|
wordStart++;
|
|
|
|
// Replace the /trigger with the full prompt
|
|
const before = value.slice(0, wordStart);
|
|
const after = value.slice(cursorPos);
|
|
this.textarea.value = before + cmd.prompt + after;
|
|
this.suggestClose();
|
|
this.textarea.focus();
|
|
}
|
|
|
|
/** Resolve slash commands in user input. If input starts with a known
|
|
* trigger, returns the full prompt. Otherwise returns input unchanged. */
|
|
private resolveSlashCommand(input: string): string {
|
|
const trimmed = input.trim();
|
|
for (const cmd of this.slashCommands) {
|
|
if (trimmed.startsWith(cmd.trigger)) {
|
|
return cmd.prompt + trimmed.slice(cmd.trigger.length);
|
|
}
|
|
}
|
|
return input;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Send
|
|
// ---------------------------------------------------------------------------
|
|
|
|
private async handleSend(): Promise<void> {
|
|
if (!this.textarea) return;
|
|
const rawInput = this.textarea.value.trim();
|
|
if (!rawInput) return;
|
|
this.textarea.value = "";
|
|
|
|
// Resolve slash commands to full prompts before sending
|
|
const userInput = this.resolveSlashCommand(rawInput);
|
|
|
|
// Display user message (show the resolved prompt, not the /trigger)
|
|
await this.appendMessage("user", userInput);
|
|
|
|
// Check API client availability
|
|
if (!this.apiClient) {
|
|
await this.appendMessage(
|
|
"assistant",
|
|
"[API client not configured. Check plugin settings.]"
|
|
);
|
|
return;
|
|
}
|
|
|
|
this.setLoading(true);
|
|
|
|
// Create a placeholder div for the streaming assistant response
|
|
const assistantMsgDiv = this.messagesContainer?.createDiv(
|
|
"chat-message chat-message-assistant"
|
|
);
|
|
const roleLabel = assistantMsgDiv?.createDiv("chat-message-role");
|
|
if (roleLabel) roleLabel.setText("Hermes");
|
|
const contentDiv = assistantMsgDiv?.createDiv("chat-message-content");
|
|
if (contentDiv) contentDiv.setText("");
|
|
|
|
let fullContent = "";
|
|
|
|
try {
|
|
// Build messages using the captured note context (grabbed on focus,
|
|
// before activeEditor became null). Falls back to no context if
|
|
// nothing was captured.
|
|
let messages: { role: string; content: string }[];
|
|
if (this.chatState.contextActive && this.capturedNote) {
|
|
const cn = this.capturedNote;
|
|
if (cn.selection) {
|
|
// Selection embedded directly — small, intentional, works anywhere
|
|
messages = [{
|
|
role: "system",
|
|
content: `[Selection from ${cn.vault}/${cn.path}]\n\n${cn.selection}`,
|
|
}, { role: "user", content: userInput }];
|
|
} else {
|
|
// Path reference — Hermes reads the file via MCP on Helm
|
|
messages = [{
|
|
role: "system",
|
|
content: [
|
|
`[Current note: ${cn.vault}/${cn.path}]`,
|
|
`Use mcp_obsidian_get_vault_document(vault_name="${cn.vault}", doc_path="${cn.path}") to read the full contents.`,
|
|
].join("\n"),
|
|
}, { role: "user", content: userInput }];
|
|
}
|
|
} else {
|
|
// No context captured (or injection disabled) — plain user message
|
|
messages = this.contextBridge
|
|
? this.contextBridge.buildMessages(userInput, this.chatState.contextActive)
|
|
: [{ role: "user", content: userInput }];
|
|
}
|
|
const response = await this.apiClient!.streamChat(
|
|
messages,
|
|
(delta: string) => {
|
|
fullContent += delta;
|
|
if (contentDiv) contentDiv.setText(fullContent);
|
|
this.scrollToBottom();
|
|
}
|
|
);
|
|
|
|
// Replace streaming plain text with rendered markdown
|
|
if (contentDiv) {
|
|
contentDiv.empty();
|
|
await MarkdownRenderer.render(this.app, fullContent, contentDiv, "", this);
|
|
}
|
|
|
|
// Track assistant response in state for persistence
|
|
const assistantMsg: Message = {
|
|
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
role: "assistant",
|
|
content: fullContent,
|
|
timestamp: Date.now(),
|
|
};
|
|
this.chatState.messages.push(assistantMsg);
|
|
this.persistence?.saveMessages(this.chatState.messages);
|
|
|
|
if (response.sessionId) {
|
|
this.chatState.sessionId = response.sessionId;
|
|
this.updateStatus(response.model, response.sessionId);
|
|
}
|
|
} catch (error: any) {
|
|
if (contentDiv) {
|
|
contentDiv.setText(`Error: ${error.message || "Unknown error"}`);
|
|
}
|
|
} finally {
|
|
this.setLoading(false);
|
|
}
|
|
}
|
|
} |