0xneobyte_VaultAI/main.ts
Tharushka Dinujaya 09e19f0538 feat: ChatGPT-style UI overhaul and copy buttons
- Bot messages: no bubble, flat layout like ChatGPT
- Two icon buttons (copy, file-plus) always visible below each response
- Lucide icons throughout: send, mode, actions menu, dropdowns
- Dropdown items: label + description stacked vertically
- Actions menu icon: sliders-horizontal; Custom Prompts: layout-list
- Text selection enabled on bot messages (user-select: text)
- Remove all tooltips from action buttons to avoid overflow clipping

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 21:42:32 +05:30

3384 lines
98 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { App, Notice, Plugin, PluginSettingTab, Setting, MarkdownView, Editor, setIcon } from "obsidian";
import { GeminiService, WebSearchResponse } from "./src/services/GeminiService";
import { RAGService } from "./src/services/RAGService";
import { LanguageSelectionModal } from "./src/modals/LanguageSelectionModal";
import { MarkdownRenderer } from "obsidian";
import { FileSelectionModal } from "./src/modals/FileSelectionModal";
import { CustomPromptModal } from "./src/modals/CustomPromptModal";
// Core interfaces for chat functionality
interface ChatMessage {
role: "user" | "bot";
content: string;
timestamp: number;
}
interface ChatSession {
id: string;
title: string;
timestamp: number;
messages: ChatMessage[];
}
interface CustomPrompt {
id: string;
name: string;
description: string;
prompt: string;
}
interface GeminiChatbotSettings {
apiKey: string;
floatingPosition: { x: number; y: number };
isDocked: boolean;
chatSessions: ChatSession[];
customPrompts: CustomPrompt[];
// RAG settings
ragEnabled: boolean;
ragFolderPath: string;
ragStoreName: string | null;
ragSyncedFiles: Record<string, any>;
// Model configuration
modelName: string;
temperature: number;
topK: number;
topP: number;
maxOutputTokens: number;
}
// Default plugin settings
const DEFAULT_SETTINGS: GeminiChatbotSettings = {
apiKey: "",
floatingPosition: { x: 20, y: 20 },
isDocked: false,
chatSessions: [],
customPrompts: [],
// RAG defaults
ragEnabled: false,
ragFolderPath: "/",
ragStoreName: null,
ragSyncedFiles: {},
// Model configuration defaults
modelName: "gemini-2.5-flash",
temperature: 1.0,
topK: 40,
topP: 0.95,
maxOutputTokens: 8192,
};
export default class GeminiChatbotPlugin extends Plugin {
// Core plugin properties
settings: GeminiChatbotSettings;
chatIcon: HTMLElement;
chatContainer: HTMLElement;
private geminiService: GeminiService | null = null;
public ragService: RAGService | null = null;
private messagesContainer: HTMLElement | null = null;
private inputField: HTMLTextAreaElement | null = null;
private currentFileContent: string | null = null;
private chatHistory: ChatMessage[] = [];
private isFullPage = false;
private currentSession: ChatSession | null = null;
private referencedFiles: Map<string, string> | null = null;
// Editor integration properties
private activeEditor: Editor | null = null;
private cursorPosition: { line: number; ch: number } | null = null;
private editorContext: { fileName: string; lineContent: string; surroundingLines: string[] } | null = null;
private insertMode = false;
// RAG mode toggle
private ragMode = false;
// Web search mode toggle
private webSearchMode = false;
// Rate limiting and context management
private lastApiCall = 0;
private readonly API_COOLDOWN = 1000; // Prevent rapid-fire API calls
private readonly MAX_CONTEXT_LENGTH = 30000; // Prevent token limit issues
async onload() {
await this.loadSettings();
if (this.settings.apiKey) {
this.initializeGeminiService();
}
// Add command palette commands
this.addCommand({
id: 'open-vaultai-chat',
name: 'Open VaultAI Chat',
callback: () => {
this.openChatFromCommand();
}
});
this.addCommand({
id: 'toggle-vaultai-chat',
name: 'Toggle VaultAI Chat',
hotkeys: [{ modifiers: ["Mod", "Shift"], key: "v" }],
callback: () => {
this.toggleChatContainer();
}
});
// Register custom prompt commands
this.registerCustomPromptCommands();
// Add editor integration commands
this.addCommand({
id: 'vaultai-generate-at-cursor',
name: 'VaultAI: Generate content at cursor',
editorCallback: (editor: Editor) => {
this.generateAtCursor(editor);
}
});
this.addCommand({
id: 'vaultai-complete-line',
name: 'VaultAI: Complete current line',
editorCallback: (editor: Editor) => {
this.completeLine(editor);
}
});
this.addCommand({
id: 'vaultai-explain-selection',
name: 'VaultAI: Explain selected text',
editorCallback: (editor: Editor) => {
this.explainSelection(editor);
}
});
this.addCommand({
id: 'vaultai-improve-selection',
name: 'VaultAI: Improve selected text',
editorCallback: (editor: Editor) => {
this.improveSelection(editor);
}
});
// Add settings tab
this.addSettingTab(new GeminiChatbotSettingTab(this.app, this));
// Add floating chat icon
this.addFloatingIcon();
// Add chat container
this.addChatContainer();
// Add workspace event listener for active file changes
this.registerEvent(
this.app.workspace.on("active-leaf-change", async () => {
if (
this.chatContainer &&
!this.chatContainer.hasClass("initially-hidden") &&
!this.chatContainer.hasClass("gemini-hidden")
) {
const activeFile = this.app.workspace.getActiveFile();
if (activeFile) {
this.currentFileContent = await this.app.vault.read(
activeFile
);
this.updateChatHeader();
} else {
this.currentFileContent = null;
this.updateChatHeader();
}
}
// Update editor context when active file changes
this.updateEditorContext();
})
);
// Add cursor position tracking
this.registerEvent(
this.app.workspace.on("editor-change", () => {
this.updateEditorContext();
})
);
// Initial editor context update
this.updateEditorContext();
}
public initializeGeminiService() {
try {
if (this.settings.apiKey) {
const decryptedKey = this.decryptApiKey(this.settings.apiKey);
const modelConfig = {
modelName: this.settings.modelName,
temperature: this.settings.temperature,
topK: this.settings.topK,
topP: this.settings.topP,
maxOutputTokens: this.settings.maxOutputTokens
};
this.geminiService = new GeminiService(decryptedKey, modelConfig);
// Initialize RAG service if enabled
if (this.settings.ragEnabled) {
this.initializeRAGService();
}
}
} catch (error) {
console.error("Failed to initialize Gemini service:", error);
}
}
public initializeRAGService() {
try {
if (!this.geminiService) {
return;
}
const apiKey = this.geminiService.getApiKey();
this.ragService = new RAGService(apiKey, this.app.vault);
// Load existing store if available
if (this.settings.ragStoreName) {
this.ragService.setFileSearchStoreName(this.settings.ragStoreName);
}
// Load synced files metadata
if (this.settings.ragSyncedFiles) {
this.ragService.loadSyncedFilesMetadata(this.settings.ragSyncedFiles);
}
console.log("RAG service initialized");
} catch (error) {
console.error("Failed to initialize RAG service:", error);
}
}
private openChatFromCommand() {
// Open chat if it's closed
if (this.chatContainer?.classList.contains("initially-hidden")) {
this.toggleChatContainer();
}
// Focus the input field
if (this.inputField) {
this.inputField.focus();
}
}
public registerCustomPromptCommands() {
// First, remove any existing custom prompt commands
// Note: Obsidian doesn't have a direct way to remove commands,
// so we rely on plugin reload for cleanup
// Register commands for each custom prompt
this.settings.customPrompts.forEach((prompt) => {
this.addCommand({
id: `custom-prompt-${prompt.id}`,
name: `Custom Prompt: ${prompt.name}`,
callback: () => {
this.executeCustomPrompt(prompt);
}
});
});
}
private async executeCustomPrompt(prompt: CustomPrompt) {
// Open chat if it's closed
if (this.chatContainer?.classList.contains("initially-hidden")) {
this.toggleChatContainer();
}
let processedPrompt = prompt.prompt;
// Replace placeholders with actual content
const activeFile = this.app.workspace.getActiveFile();
if (activeFile) {
// Get selected text
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
const selection = activeView?.editor?.getSelection() || "";
// Get full content
const fileContent = await this.app.vault.read(activeFile);
// Replace placeholders
processedPrompt = processedPrompt
.replace(/\{\{selection\}\}/g, selection)
.replace(/\{\{content\}\}/g, fileContent);
}
// Insert the processed prompt into the input field
if (this.inputField) {
this.inputField.value = processedPrompt;
this.inputField.focus();
// Optionally auto-send the prompt
// Uncomment the next line if you want prompts to be sent automatically
// this.handleMessage(processedPrompt);
}
}
private showCustomPromptsDropdown() {
// Remove any existing dropdown
const existingDropdown = document.querySelector('.custom-prompts-dropdown');
if (existingDropdown) {
existingDropdown.remove();
return; // Toggle behavior - close if already open
}
if (this.settings.customPrompts.length === 0) {
new Notice("No custom prompts available. Add some in Settings → VaultAI.");
return;
}
// Create dropdown
const dropdown = document.createElement('div');
dropdown.classList.add('custom-prompts-dropdown');
// Position it near the prompts button
const promptsButton = this.chatContainer.querySelector('.prompts-button') as HTMLElement;
if (!promptsButton) return;
const buttonRect = promptsButton.getBoundingClientRect();
dropdown.style.position = 'fixed';
dropdown.style.bottom = `${window.innerHeight - buttonRect.top + 10}px`;
dropdown.style.right = `${window.innerWidth - buttonRect.right}px`;
dropdown.style.maxWidth = '300px';
dropdown.style.maxHeight = '200px';
dropdown.style.overflowY = 'auto';
dropdown.style.backgroundColor = 'var(--background-primary)';
dropdown.style.border = '1px solid var(--background-modifier-border)';
dropdown.style.borderRadius = '8px';
dropdown.style.boxShadow = '0 4px 16px rgba(0, 0, 0, 0.15)';
dropdown.style.zIndex = '1000';
dropdown.style.padding = '8px';
// Add prompts to dropdown
this.settings.customPrompts.forEach((prompt) => {
const promptItem = document.createElement('div');
promptItem.classList.add('custom-prompt-item');
promptItem.style.padding = '8px 12px';
promptItem.style.cursor = 'pointer';
promptItem.style.borderRadius = '6px';
promptItem.style.marginBottom = '4px';
const promptName = document.createElement('div');
promptName.textContent = prompt.name;
promptName.style.fontWeight = '500';
promptName.style.fontSize = '14px';
const promptDesc = document.createElement('div');
promptDesc.textContent = prompt.description || 'No description';
promptDesc.style.fontSize = '12px';
promptDesc.style.color = 'var(--text-muted)';
promptDesc.style.marginTop = '2px';
promptItem.appendChild(promptName);
if (prompt.description) {
promptItem.appendChild(promptDesc);
}
// Hover effects
promptItem.addEventListener('mouseenter', () => {
promptItem.style.backgroundColor = 'var(--background-modifier-hover)';
});
promptItem.addEventListener('mouseleave', () => {
promptItem.style.backgroundColor = 'transparent';
});
// Click handler
promptItem.addEventListener('click', () => {
this.executeCustomPrompt(prompt);
dropdown.remove();
});
dropdown.appendChild(promptItem);
});
// Add to body
document.body.appendChild(dropdown);
// Close dropdown when clicking outside
const closeDropdown = (e: MouseEvent) => {
if (!dropdown.contains(e.target as Node) && !promptsButton.contains(e.target as Node)) {
dropdown.remove();
document.removeEventListener('click', closeDropdown);
}
};
// Use setTimeout to avoid immediate closure
setTimeout(() => {
document.addEventListener('click', closeDropdown);
}, 100);
}
private async handleMessage(message: string) {
if (!this.geminiService || !message.trim()) return;
// Check API cooldown
const now = Date.now();
if (now - this.lastApiCall < this.API_COOLDOWN) {
this.addErrorMessage(
"Please wait a moment before sending another message"
);
return;
}
this.toggleSuggestedActions(false);
// Build context more efficiently
let contextMessage = message;
let context = "";
// Skip context building if RAG mode is enabled
if (!this.ragMode) {
// Add referenced file content if any
const fileReferences = message.match(/@([^\s]+)/g);
if (fileReferences) {
contextMessage = message.replace(/@([^\s]+)/g, "").trim();
for (const ref of fileReferences) {
const fileName = ref.slice(1);
const fileContent = this.referencedFiles?.get(fileName);
if (fileContent) {
// Add only the first part of long files
const truncatedContent = this.truncateContent(fileContent);
context += `\nRelevant content from ${fileName}:\n${truncatedContent}\n`;
}
}
}
// Add current file content if available and no specific file was referenced
const activeFile = this.app.workspace.getActiveFile();
if (activeFile && !fileReferences) {
const content = await this.app.vault.read(activeFile);
// Add only relevant parts of the current file
const truncatedContent = this.truncateContent(content);
context += `\nRelevant content from current note:\n${truncatedContent}\n`;
}
// Add editor context (cursor position and surrounding lines)
const editorContext = this.getEditorContextString();
if (editorContext) {
context += editorContext;
}
}
// Prepare the final message
const finalMessage = context
? `${context}\n\nUser question: ${contextMessage}`
: contextMessage;
const userMessage: ChatMessage = {
role: "user",
content: finalMessage,
timestamp: Date.now(),
};
await this.addMessageToChat({
...userMessage,
content: contextMessage,
});
// Add typing indicator
const typingIndicator = document.createElement("div");
typingIndicator.addClass("typing-indicator");
// Create spans using DOM API
for (let i = 0; i < 3; i++) {
const span = document.createElement("span");
typingIndicator.appendChild(span);
}
this.messagesContainer?.appendChild(typingIndicator);
try {
this.lastApiCall = Date.now();
// Use RAG, Web Search, or Normal mode
let response: string;
if (this.ragMode && this.ragService && this.ragService.getFileSearchStoreName()) {
const ragResult = await this.ragService.queryWithRAG(
contextMessage || finalMessage,
this.settings.modelName
);
response = ragResult.text;
// Add citation info if available
if (ragResult.citations) {
response += this.formatCitations(ragResult.citations);
}
} else if (this.ragMode && (!this.ragService || !this.ragService.getFileSearchStoreName())) {
// RAG mode is on but not initialized
typingIndicator.remove();
this.addErrorMessage(
"RAG mode is enabled but your vault hasn't been synced yet. Please sync your vault in Settings → VaultAI → RAG Settings."
);
return;
} else if (this.webSearchMode) {
// Use web search mode
const webSearchResult: WebSearchResponse = await this.geminiService.sendMessageWithWebSearch(finalMessage);
response = webSearchResult.text;
// Add web search sources if available
if (webSearchResult.groundingMetadata) {
response += this.formatWebSearchSources(webSearchResult.groundingMetadata);
}
} else {
response = await this.geminiService.sendMessage(finalMessage);
}
typingIndicator.remove();
const botMessage: ChatMessage = {
role: "bot",
content: response,
timestamp: Date.now(),
};
await this.addMessageToChat(botMessage);
// If insert mode is on, automatically insert the response at cursor
if (this.insertMode && this.activeEditor) {
await this.insertAtCursor(response);
new Notice("AI response inserted at cursor");
}
// Update chat session
if (this.currentSession) {
if (this.currentSession.messages.length === 2) {
this.currentSession.title = this.generateSessionTitle(
userMessage.content
);
}
this.settings.chatSessions = [
this.currentSession,
...this.settings.chatSessions.filter(
(s) => s.id !== this.currentSession?.id
),
];
await this.saveSettings();
}
} catch (error) {
typingIndicator.remove();
let errorMessage = "Failed to get response from Gemini";
if (error instanceof Error) {
// Handle safety-related errors
if (error.message.includes("SAFETY")) {
errorMessage =
"I cannot provide a response to that as it may violate content safety guidelines.";
}
// Handle blocked content
else if (
error.message.includes("blocked") ||
error.message.includes("OTHER")
) {
errorMessage =
"I cannot process that request as it was blocked by content filters.";
}
// Handle rate limits
else if (
error.message.includes("429") ||
error.message.includes("quota")
) {
errorMessage =
"API rate limit reached. Please wait a moment before trying again.";
}
// Handle invalid requests
else if (error.message.includes("400")) {
errorMessage =
"Invalid request. Please try rephrasing your message.";
}
// Handle authentication errors
else if (
error.message.includes("401") ||
error.message.includes("403")
) {
errorMessage =
"API authentication failed. Please check your API key in settings.";
}
// Handle server errors
else if (error.message.includes("500")) {
errorMessage =
"Gemini service is currently experiencing issues. Please try again later.";
}
// Log the actual error for debugging
console.error("Gemini API Error:", error);
}
this.addErrorMessage(errorMessage);
}
}
private async addMessageToChat(message: ChatMessage) {
if (!this.messagesContainer) return;
// Hide bot info and suggested actions after first message
if (this.currentSession?.messages.length === 0) {
const botInfo = this.chatContainer?.querySelector(".bot-info");
const suggestedActions = this.chatContainer?.querySelector(
".vaultai-suggested-actions"
);
botInfo?.addClass("hidden");
suggestedActions?.addClass("hidden");
// Remove elements after animation
setTimeout(() => {
botInfo?.remove();
suggestedActions?.remove();
}, 300);
}
const messageEl = document.createElement("div");
messageEl.addClass(`gemini-message-${message.role}`);
if (message.role === "bot") {
// Render markdown content
const contentEl = messageEl.createEl("div", { cls: "message-content" });
await MarkdownRenderer.render(this.app, message.content, contentEl, "", this);
// Action bar below content — always visible
const actionBar = messageEl.createEl("div", { cls: "message-action-bar" });
const clipboardButton = actionBar.createEl("button", { cls: "message-action-btn" });
setIcon(clipboardButton, "copy");
clipboardButton.addEventListener("click", async () => {
await navigator.clipboard.writeText(message.content);
setIcon(clipboardButton, "check");
setTimeout(() => setIcon(clipboardButton, "copy"), 2000);
});
const noteButton = actionBar.createEl("button", { cls: "message-action-btn" });
setIcon(noteButton, "file-plus");
noteButton.addEventListener("click", async () => {
const title = this.generateNoteTitle(message.content);
const file = await this.app.vault.create(`${title}.md`, message.content);
const leaf = this.app.workspace.getLeaf(false);
await leaf.openFile(file);
new Notice("Response copied to new note!");
});
} else {
// For user messages, just show the visible part
const visibleContent = this.stripContextFromMessage(
message.content
);
messageEl.textContent = visibleContent;
}
this.messagesContainer.appendChild(messageEl);
this.messagesContainer.scrollTop = this.messagesContainer.scrollHeight;
if (this.currentSession) {
this.currentSession.messages.push(message);
}
}
// Add new method for typing animation
private async typeMessage(text: string, container: HTMLElement) {
// First render the markdown but keep it hidden
await MarkdownRenderer.render(this.app, text, container, "", this);
const elements = Array.from(container.children);
container.empty();
for (const element of elements) {
if (element instanceof HTMLElement) {
if (element.tagName === "P") {
// For paragraphs, type each character
const text = element.textContent || "";
const p = container.createEl("p");
for (const char of text) {
p.textContent += char;
await new Promise((resolve) => setTimeout(resolve, 10)); // Adjust speed here
if (this.messagesContainer) {
this.messagesContainer.scrollTop =
this.messagesContainer.scrollHeight;
}
}
} else {
// For other elements (code blocks, lists, etc.), add them instantly
container.appendChild(element);
}
}
}
}
// Add method to strip context from messages
private stripContextFromMessage(message: string): string {
// Remove the context part from the message
const userQuestionMatch = message.match(/User question: (.*?)$/m);
if (userQuestionMatch) {
return userQuestionMatch[1].trim();
}
return message;
}
private addErrorMessage(message: string) {
const errorDiv = createEl("div", { cls: "gemini-message-error" });
const iconDiv = createEl("div", {
cls: "error-icon",
text: "⚠️",
});
const contentDiv = createEl("div", {
cls: "error-content",
text: message,
});
errorDiv.appendChild(iconDiv);
errorDiv.appendChild(contentDiv);
this.messagesContainer?.appendChild(errorDiv);
if (this.messagesContainer) {
this.messagesContainer.scrollTop =
this.messagesContainer.scrollHeight;
}
}
private addFloatingIcon() {
this.chatIcon = createEl("div", { cls: "gemini-chat-icon" });
const svg = createSvg("svg", {
attr: {
width: "24",
height: "24",
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
"stroke-width": "2",
},
});
const path = createSvg("path", {
attr: {
d: "M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z",
},
});
svg.appendChild(path);
this.chatIcon.appendChild(svg);
this.chatIcon.addEventListener("click", () => {
this.toggleChatContainer();
});
document.body.appendChild(this.chatIcon);
}
private addChatContainer() {
this.chatContainer = document.createElement("div");
this.chatContainer.addClass("gemini-chat-container");
this.chatContainer.addClass("initially-hidden");
this.chatContainer.addClass("default-size");
// Create header
const header = this.createChatHeader();
this.chatContainer.appendChild(header);
// Create bot info
const botInfo = this.createBotInfo();
this.chatContainer.appendChild(botInfo);
// Create messages container
const messagesContainer = document.createElement("div");
messagesContainer.addClass("gemini-chat-messages");
this.chatContainer.appendChild(messagesContainer);
// Create suggested actions
const suggestedActions = this.createSuggestedActions();
this.chatContainer.appendChild(suggestedActions);
// Create input container
const inputContainer = this.createInputContainer();
this.chatContainer.appendChild(inputContainer);
document.body.appendChild(this.chatContainer);
// Add event listeners for the buttons
this.addChatEventListeners();
// Add resize handle
const resizeHandle = document.createElement("div");
resizeHandle.addClass("resize-handle");
this.chatContainer.appendChild(resizeHandle);
// Add resize functionality
this.addResizeFunctionality(resizeHandle);
}
private createChatHeader(): HTMLElement {
const header = document.createElement("div");
header.addClass("gemini-chat-header");
// Current file indicator
const currentFile = document.createElement("div");
currentFile.addClass("current-file");
header.appendChild(currentFile);
// Header controls
const controls = document.createElement("div");
controls.addClass("chat-header-controls");
// History button
const historyButton = document.createElement("button");
historyButton.addClass("history-button");
const historyIcon = this.createHistoryIcon();
historyButton.appendChild(historyIcon);
controls.appendChild(historyButton);
// More button
const moreButton = document.createElement("button");
moreButton.addClass("more-button");
const moreIcon = this.createMoreIcon();
moreButton.appendChild(moreIcon);
controls.appendChild(moreButton);
// Close button
const closeButton = document.createElement("button");
closeButton.addClass("close-button");
const closeIcon = this.createCloseIcon();
closeButton.appendChild(closeIcon);
controls.appendChild(closeButton);
header.appendChild(controls);
return header;
}
private createHistoryIcon(): SVGElement {
const svg = createSvg("svg", {
attr: {
xmlns: "http://www.w3.org/2000/svg",
width: "20",
height: "20",
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
"stroke-width": "2",
"stroke-linecap": "round",
"stroke-linejoin": "round",
},
});
const circle = createSvg("circle", {
attr: {
cx: "12",
cy: "12",
r: "10",
},
});
const polyline = createSvg("polyline", {
attr: {
points: "12 6 12 12 16 14",
},
});
svg.appendChild(circle);
svg.appendChild(polyline);
return svg;
}
private createMoreIcon(): SVGElement {
const svg = createSvg("svg", {
attr: {
xmlns: "http://www.w3.org/2000/svg",
width: "20",
height: "20",
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
"stroke-width": "2",
"stroke-linecap": "round",
"stroke-linejoin": "round",
},
});
const rect = createSvg("rect", {
attr: {
x: "3",
y: "3",
width: "18",
height: "18",
rx: "2",
ry: "2",
},
});
const line1 = createSvg("line", {
attr: {
x1: "3",
y1: "12",
x2: "21",
y2: "12",
},
});
const line2 = createSvg("line", {
attr: {
x1: "12",
y1: "3",
x2: "12",
y2: "21",
},
});
svg.appendChild(rect);
svg.appendChild(line1);
svg.appendChild(line2);
return svg;
}
private createCloseIcon(): SVGElement {
const svg = createSvg("svg", {
attr: {
xmlns: "http://www.w3.org/2000/svg",
width: "20",
height: "20",
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
"stroke-width": "2",
"stroke-linecap": "round",
"stroke-linejoin": "round",
},
});
const line1 = createSvg("line", {
attr: {
x1: "18",
y1: "6",
x2: "6",
y2: "18",
},
});
const line2 = createSvg("line", {
attr: {
x1: "6",
y1: "6",
x2: "18",
y2: "18",
},
});
svg.appendChild(line1);
svg.appendChild(line2);
return svg;
}
private createBotInfo(): HTMLElement {
const botInfo = document.createElement("div");
botInfo.addClass("bot-info");
// Bot avatar container
const avatarContainer = document.createElement("div");
avatarContainer.addClass("bot-avatar");
// Create the avatar SVG (simplified for security)
const avatarSvg = this.createBotAvatarSvg();
avatarContainer.appendChild(avatarSvg);
// Bot greeting
const greeting = document.createElement("div");
greeting.addClass("bot-greeting");
greeting.textContent = "Hello, How can I help you today?";
botInfo.appendChild(avatarContainer);
botInfo.appendChild(greeting);
return botInfo;
}
private createBotAvatarSvg(): SVGElement {
// Create simplified avatar SVG for security
const svg = createSvg("svg", {
attr: {
viewBox: "0 0 100 100",
fill: "none",
xmlns: "http://www.w3.org/2000/svg",
},
});
// Simple circle avatar
const circle = createSvg("circle", {
attr: {
cx: "50",
cy: "50",
r: "40",
fill: "#e1e7ff",
stroke: "#6366f1",
"stroke-width": "3",
},
});
// Simple face elements
const leftEye = createSvg("circle", {
attr: {
cx: "38",
cy: "40",
r: "3",
fill: "#374151",
},
});
const rightEye = createSvg("circle", {
attr: {
cx: "62",
cy: "40",
r: "3",
fill: "#374151",
},
});
const mouth = createSvg("path", {
attr: {
d: "M 38 60 Q 50 70 62 60",
stroke: "#374151",
"stroke-width": "2",
fill: "none",
"stroke-linecap": "round",
},
});
svg.appendChild(circle);
svg.appendChild(leftEye);
svg.appendChild(rightEye);
svg.appendChild(mouth);
return svg;
}
private createSuggestedActions(): HTMLElement {
const suggestedActions = document.createElement("div");
suggestedActions.addClass("vaultai-suggested-actions");
// Title
const title = document.createElement("h3");
title.textContent = "Suggested";
suggestedActions.appendChild(title);
// Action buttons
const actions = [
{ icon: "📝", text: "Summarize this page" },
{ icon: "🔍", text: "Ask about this page" },
{ icon: "📚", text: "Make a quiz" },
{ icon: "🌐", text: "Translate to" },
];
actions.forEach((action) => {
const button = document.createElement("div");
button.addClass("vaultai-action-button");
const icon = document.createElement("span");
icon.addClass("vaultai-action-icon");
icon.textContent = action.icon;
button.appendChild(icon);
button.appendChild(document.createTextNode(action.text));
suggestedActions.appendChild(button);
});
return suggestedActions;
}
private createInputContainer(): HTMLElement {
const inputContainer = document.createElement("div");
inputContainer.addClass("chat-input-container");
const inputWrapper = document.createElement("div");
inputWrapper.addClass("chat-input-wrapper");
// Text area
const textarea = document.createElement("textarea");
textarea.addClass("chat-input");
textarea.placeholder = "Ask anything or select...";
// Actions container
const actionsContainer = document.createElement("div");
actionsContainer.addClass("input-actions");
// Send button
const sendButton = document.createElement("button");
sendButton.addClass("send-button");
setIcon(sendButton, "arrow-up");
// Actions dropdown (prompts, mention, insert)
const actionsMenuContainer = document.createElement("div");
actionsMenuContainer.addClass("actions-menu-container");
const actionsMenuButton = document.createElement("button");
actionsMenuButton.addClass("actions-menu-button");
setIcon(actionsMenuButton, "sliders-horizontal");
actionsMenuButton.title = "Actions";
const actionsMenuDropdown = document.createElement("div");
actionsMenuDropdown.addClass("actions-menu-dropdown");
actionsMenuDropdown.style.display = "none";
const actions = [
{ id: "prompts", label: "Custom Prompts", icon: "layout-list", description: "Use saved prompts" },
{ id: "mention", label: "Mention File", icon: "at-sign", description: "Reference a file" },
{ id: "insert", label: "Insert Mode", icon: "pin", description: "Insert at cursor" }
];
actions.forEach(action => {
const option = document.createElement("div");
option.addClass("actions-menu-option");
option.setAttribute("data-action", action.id);
const iconSpan = document.createElement("span");
iconSpan.addClass("action-icon");
setIcon(iconSpan, action.icon);
const textDiv = document.createElement("div");
textDiv.addClass("action-text");
const labelSpan = document.createElement("span");
labelSpan.addClass("action-label");
labelSpan.textContent = action.label;
const descSpan = document.createElement("span");
descSpan.addClass("action-description");
descSpan.textContent = action.description;
textDiv.appendChild(labelSpan);
textDiv.appendChild(descSpan);
option.appendChild(iconSpan);
option.appendChild(textDiv);
actionsMenuDropdown.appendChild(option);
});
actionsMenuContainer.appendChild(actionsMenuButton);
actionsMenuContainer.appendChild(actionsMenuDropdown);
// Mode selector dropdown
const modeContainer = document.createElement("div");
modeContainer.addClass("mode-selector-container");
const modeButton = document.createElement("button");
modeButton.addClass("mode-selector-button");
setIcon(modeButton, "message-circle");
modeButton.title = "Select chat mode";
const modeDropdown = document.createElement("div");
modeDropdown.addClass("mode-dropdown");
modeDropdown.style.display = "none";
const modes = [
{ id: "normal", label: "Normal", icon: "message-circle", description: "Standard chat" },
{ id: "rag", label: "Vault Search", icon: "database", description: "Search your vault" },
{ id: "web", label: "Web Search", icon: "globe", description: "Search the web" }
];
modes.forEach(mode => {
const option = document.createElement("div");
option.addClass("mode-option");
option.setAttribute("data-mode", mode.id);
const iconSpan = document.createElement("span");
iconSpan.addClass("mode-icon");
setIcon(iconSpan, mode.icon);
const textDiv = document.createElement("div");
textDiv.addClass("mode-text");
const labelSpan = document.createElement("span");
labelSpan.addClass("mode-label");
labelSpan.textContent = mode.label;
const descSpan = document.createElement("span");
descSpan.addClass("mode-description");
descSpan.textContent = mode.description;
textDiv.appendChild(labelSpan);
textDiv.appendChild(descSpan);
option.appendChild(iconSpan);
option.appendChild(textDiv);
modeDropdown.appendChild(option);
});
modeContainer.appendChild(modeButton);
modeContainer.appendChild(modeDropdown);
actionsContainer.appendChild(actionsMenuContainer);
actionsContainer.appendChild(modeContainer);
actionsContainer.appendChild(sendButton);
inputWrapper.appendChild(textarea);
inputWrapper.appendChild(actionsContainer);
inputContainer.appendChild(inputWrapper);
return inputContainer;
}
private addChatEventListeners() {
const closeButton = this.chatContainer.querySelector(".close-button");
closeButton?.addEventListener("click", () => {
this.toggleChatContainer();
});
const sendButton = this.chatContainer.querySelector(".send-button");
const actionsMenuButton = this.chatContainer.querySelector(".actions-menu-button");
const actionsMenuDropdown = this.chatContainer.querySelector(".actions-menu-dropdown");
const actionsMenuOptions = this.chatContainer.querySelectorAll(".actions-menu-option");
const modeButton = this.chatContainer.querySelector(".mode-selector-button");
const modeDropdown = this.chatContainer.querySelector(".mode-dropdown");
const modeOptions = this.chatContainer.querySelectorAll(".mode-option");
const inputField = this.chatContainer.querySelector(
".chat-input"
) as HTMLTextAreaElement;
this.inputField = inputField;
this.messagesContainer = this.chatContainer.querySelector(
".gemini-chat-messages"
);
// Actions menu dropdown toggle
actionsMenuButton?.addEventListener("click", (e) => {
e.stopPropagation();
const isVisible = (actionsMenuDropdown as HTMLElement)?.style.display === "block";
(actionsMenuDropdown as HTMLElement).style.display = isVisible ? "none" : "block";
// Close mode dropdown if open
if (modeDropdown) {
(modeDropdown as HTMLElement).style.display = "none";
}
});
// Actions menu option selection
actionsMenuOptions.forEach(option => {
option.addEventListener("click", () => {
const action = option.getAttribute("data-action");
(actionsMenuDropdown as HTMLElement).style.display = "none";
switch (action) {
case "prompts":
this.showCustomPromptsDropdown();
break;
case "mention":
this.showFileSelectionModal();
break;
case "insert":
this.toggleInsertMode();
break;
}
});
});
// Mode selector dropdown toggle
modeButton?.addEventListener("click", (e) => {
e.stopPropagation();
const isVisible = (modeDropdown as HTMLElement)?.style.display === "block";
(modeDropdown as HTMLElement).style.display = isVisible ? "none" : "block";
// Close actions menu if open
if (actionsMenuDropdown) {
(actionsMenuDropdown as HTMLElement).style.display = "none";
}
});
// Mode option selection
modeOptions.forEach(option => {
option.addEventListener("click", () => {
const mode = option.getAttribute("data-mode");
this.setMode(mode as "normal" | "rag" | "web");
(modeDropdown as HTMLElement).style.display = "none";
});
});
// Close dropdowns when clicking outside
document.addEventListener("click", (e) => {
if (modeDropdown && !(e.target as Element).closest(".mode-selector-container")) {
(modeDropdown as HTMLElement).style.display = "none";
}
if (actionsMenuDropdown && !(e.target as Element).closest(".actions-menu-container")) {
(actionsMenuDropdown as HTMLElement).style.display = "none";
}
});
sendButton?.addEventListener("click", () => {
if (this.inputField) {
const message = this.inputField.value.trim();
if (message) {
this.handleMessage(message);
this.inputField.value = "";
}
}
});
inputField?.addEventListener("keydown", (e: KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey && this.inputField) {
e.preventDefault();
const message = this.inputField.value.trim();
if (message) {
this.handleMessage(message);
this.inputField.value = "";
}
}
});
// Simplify action buttons handlers
const actionButtons = this.chatContainer.querySelectorAll(
".vaultai-action-button"
);
actionButtons.forEach((button) => {
button.addEventListener("click", () => {
if (!this.inputField) return;
// Simply set the input value based on the action
if (button.textContent?.includes("Summarize")) {
this.inputField.value =
"Can you summarize this note for me?";
} else if (button.textContent?.includes("Ask about")) {
this.inputField.value = "What is this note about?";
} else if (button.textContent?.includes("Make a quiz")) {
this.inputField.value =
"Can you create a quiz using this note?";
} else if (button.textContent?.includes("Translate")) {
new LanguageSelectionModal(this.app, (language: string) => {
if (this.inputField) {
this.inputField.value = `Can you translate this note to ${language}?`;
}
}).open();
}
// Focus the input
this.inputField.focus();
});
});
// Update history button handler
const historyButton =
this.chatContainer.querySelector(".history-button");
historyButton?.addEventListener("click", () => {
// Show chat history view instead of just showing messages
this.showChatHistoryView();
});
// Add more options menu
const moreButton = this.chatContainer.querySelector(".more-button");
moreButton?.addEventListener("click", async (event) => {
this.showMoreOptionsMenu(event as MouseEvent);
});
}
private showLanguageSelectionModal(content: string) {
new LanguageSelectionModal(this.app, (language: string) => {
if (this.geminiService) {
this.geminiService
.translateContent(content, language)
.then((translation) => {
this.addMessageToChat({
role: "bot",
content: translation,
timestamp: Date.now(),
});
})
.catch((error) => {
this.addErrorMessage("Translation failed");
console.error("Translation error:", error);
});
}
}).open();
}
private async toggleChatContainer() {
const isVisible =
!this.chatContainer.classList.contains("initially-hidden");
if (isVisible) {
// Add closing animation
this.chatContainer.classList.add("closing");
// Wait for animation to complete before hiding
await new Promise((resolve) => setTimeout(resolve, 300));
this.chatContainer.addClass("initially-hidden");
this.chatContainer.removeClass("visible");
this.chatContainer.classList.remove("closing");
} else {
// Reset the chat container content
if (this.messagesContainer) {
this.messagesContainer.empty();
}
// Recreate bot-info and suggested-actions if they don't exist
const existingBotInfo =
this.chatContainer.querySelector(".bot-info");
const existingSuggestedActions = this.chatContainer.querySelector(
".vaultai-suggested-actions"
);
if (!existingBotInfo) {
const botInfo = this.createBotInfo();
// Insert bot-info after the header
const header = this.chatContainer.querySelector(
".gemini-chat-header"
);
header?.after(botInfo);
}
if (!existingSuggestedActions) {
const suggestedActions = this.createSuggestedActions();
// Insert vaultai-suggested-actions before the chat input container
const inputContainer = this.chatContainer.querySelector(
".chat-input-container"
);
inputContainer?.before(suggestedActions);
// Reattach event listeners for action buttons
this.addActionButtonListeners();
}
this.chatContainer.removeClass("initially-hidden");
this.chatContainer.addClass("visible");
this.currentSession = this.createNewSession();
this.showMainChatView();
this.toggleSuggestedActions(true);
const activeFile = this.app.workspace.getActiveFile();
if (activeFile) {
this.currentFileContent = await this.app.vault.read(activeFile);
this.updateChatHeader();
} else {
this.currentFileContent = null;
this.updateChatHeader();
}
}
}
// Add this new method to handle action button listeners
private addActionButtonListeners() {
const actionButtons = this.chatContainer.querySelectorAll(
".vaultai-action-button"
);
actionButtons.forEach((button) => {
button.addEventListener("click", () => {
if (!this.inputField) return;
if (button.textContent?.includes("Summarize")) {
this.inputField.value =
"Can you summarize this note for me?";
} else if (button.textContent?.includes("Ask about")) {
this.inputField.value = "What is this note about?";
} else if (button.textContent?.includes("Make a quiz")) {
this.inputField.value =
"Can you create a quiz using this note?";
} else if (button.textContent?.includes("Translate")) {
new LanguageSelectionModal(this.app, (language: string) => {
if (this.inputField) {
this.inputField.value = `Can you translate this note to ${language}?`;
}
}).open();
}
this.inputField.focus();
});
});
}
async loadSettings() {
this.settings = Object.assign(
{},
DEFAULT_SETTINGS,
await this.loadData()
);
// Migrate deprecated model name for existing users
if (this.settings.modelName === "gemini-2.0-flash-exp") {
this.settings.modelName = "gemini-2.5-flash";
await this.saveSettings();
}
}
async saveSettings() {
await this.saveData(this.settings);
}
// Helper methods for DOM manipulation (to handle obsidian-specific methods)
private addClass(element: HTMLElement, className: string) {
if (element && 'addClass' in element && typeof element.addClass === 'function') {
(element as HTMLElement & { addClass: (className: string) => void }).addClass(className);
} else {
element?.classList.add(className);
}
}
private removeClass(element: HTMLElement, className: string) {
if (element && 'removeClass' in element && typeof element.removeClass === 'function') {
(element as HTMLElement & { removeClass: (className: string) => void }).removeClass(className);
} else {
element?.classList.remove(className);
}
}
private empty(element: HTMLElement) {
if (element && 'empty' in element && typeof element.empty === 'function') {
(element as HTMLElement & { empty: () => void }).empty();
} else {
if (element) {
element.innerHTML = '';
}
}
}
// Editor integration methods
private updateEditorContext(): void {
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
if (!activeView || !activeView.editor) {
this.activeEditor = null;
this.cursorPosition = null;
this.editorContext = null;
return;
}
this.activeEditor = activeView.editor;
this.cursorPosition = this.activeEditor.getCursor();
const fileName = activeView.file?.name || "Untitled";
const currentLine = this.cursorPosition.line;
const lineContent = this.activeEditor.getLine(currentLine);
// Get surrounding lines for context (3 before, 3 after)
const surroundingLines: string[] = [];
const start = Math.max(0, currentLine - 3);
const end = Math.min(this.activeEditor.lineCount() - 1, currentLine + 3);
for (let i = start; i <= end; i++) {
const line = this.activeEditor.getLine(i);
const lineNumber = i + 1; // 1-indexed for display
const marker = i === currentLine ? " → " : " ";
surroundingLines.push(`${lineNumber.toString().padStart(3)}${marker}${line}`);
}
this.editorContext = {
fileName,
lineContent,
surroundingLines
};
}
private getEditorContextString(): string {
if (!this.editorContext || !this.cursorPosition) {
return "";
}
const { fileName, lineContent, surroundingLines } = this.editorContext;
const currentLineNum = this.cursorPosition.line + 1;
return `\n\n--- Editor Context ---
File: ${fileName}
Current line: ${currentLineNum}
Current line content: "${lineContent}"
Surrounding context:
\`\`\`
${surroundingLines.join('\n')}
\`\`\`
--- End Context ---\n`;
}
private async insertAtCursor(content: string): Promise<void> {
if (!this.activeEditor || !this.cursorPosition) {
new Notice("No active editor found");
return;
}
// Insert content at current cursor position
this.activeEditor.replaceRange(content, this.cursorPosition);
// Update cursor position to end of inserted content
const lines = content.split('\n');
const newLine = this.cursorPosition.line + lines.length - 1;
const newCh = lines.length === 1
? this.cursorPosition.ch + content.length
: lines[lines.length - 1].length;
this.activeEditor.setCursor({ line: newLine, ch: newCh });
}
private async replaceSelection(content: string): Promise<void> {
if (!this.activeEditor) {
new Notice("No active editor found");
return;
}
const selection = this.activeEditor.getSelection();
if (selection) {
this.activeEditor.replaceSelection(content);
} else {
// If no selection, insert at cursor
await this.insertAtCursor(content);
}
}
// Editor command methods
private async generateAtCursor(editor: Editor): Promise<void> {
this.activeEditor = editor;
this.updateEditorContext();
// Open chat and focus on input with a helpful prompt
this.openChatFromCommand();
if (this.inputField) {
this.inputField.value = "Generate content here: ";
this.inputField.focus();
// Position cursor at the end
setTimeout(() => {
if (this.inputField) {
this.inputField.setSelectionRange(this.inputField.value.length, this.inputField.value.length);
}
}, 100);
}
}
private async completeLine(editor: Editor): Promise<void> {
this.activeEditor = editor;
const cursor = editor.getCursor();
const currentLine = editor.getLine(cursor.line);
if (!currentLine.trim()) {
new Notice("Current line is empty");
return;
}
// Prepare a completion prompt
const prompt = `Complete this line: "${currentLine}"`;
this.updateEditorContext();
this.openChatFromCommand();
if (this.inputField) {
this.inputField.value = prompt;
// Auto-send the completion request
setTimeout(() => {
const sendButton = this.chatContainer?.querySelector('.send-button') as HTMLButtonElement;
if (sendButton) {
sendButton.click();
}
}, 200);
}
}
private async explainSelection(editor: Editor): Promise<void> {
this.activeEditor = editor;
const selection = editor.getSelection();
if (!selection.trim()) {
new Notice("Please select some text first");
return;
}
const prompt = `Explain this text: "${selection}"`;
this.updateEditorContext();
this.openChatFromCommand();
if (this.inputField) {
this.inputField.value = prompt;
// Auto-send the explanation request
setTimeout(() => {
const sendButton = this.chatContainer?.querySelector('.send-button') as HTMLButtonElement;
if (sendButton) {
sendButton.click();
}
}, 200);
}
}
private async improveSelection(editor: Editor): Promise<void> {
this.activeEditor = editor;
const selection = editor.getSelection();
if (!selection.trim()) {
new Notice("Please select some text first");
return;
}
const prompt = `Improve this text: "${selection}"`;
this.updateEditorContext();
this.openChatFromCommand();
if (this.inputField) {
this.inputField.value = prompt;
this.inputField.focus();
// Position cursor at the end
setTimeout(() => {
if (this.inputField) {
this.inputField.setSelectionRange(this.inputField.value.length, this.inputField.value.length);
}
}, 100);
}
}
private toggleInsertMode(): void {
this.insertMode = !this.insertMode;
const insertButton = this.chatContainer?.querySelector(".insert-button") as HTMLElement;
if (insertButton) {
if (this.insertMode) {
setIcon(insertButton, "pin");
insertButton.style.backgroundColor = "var(--interactive-accent)";
insertButton.style.color = "var(--text-on-accent)";
insertButton.title = "Insert mode ON - AI responses will be inserted at cursor";
} else {
setIcon(insertButton, "pin");
insertButton.style.backgroundColor = "";
insertButton.style.color = "";
insertButton.title = "Insert AI response at cursor position";
}
}
this.updateEditorContext();
new Notice(this.insertMode ? "Insert mode ON" : "Insert mode OFF");
}
private setMode(mode: "normal" | "rag" | "web"): void {
// Check if RAG is enabled and configured when trying to enable it
if (mode === "rag") {
if (!this.settings.ragEnabled) {
new Notice("RAG is not enabled. Enable it in Settings → VaultAI → RAG Settings");
return;
}
if (!this.ragService || !this.ragService.getFileSearchStoreName()) {
new Notice("Please sync your vault first in Settings → VaultAI → RAG Settings");
return;
}
}
// Update mode states
this.ragMode = mode === "rag";
this.webSearchMode = mode === "web";
// Update mode button UI
const modeButton = this.chatContainer?.querySelector(".mode-selector-button") as HTMLElement;
const modeOptions = this.chatContainer?.querySelectorAll(".mode-option");
if (modeButton) {
switch (mode) {
case "normal":
setIcon(modeButton, "message-circle");
modeButton.style.backgroundColor = "";
modeButton.style.color = "";
modeButton.title = "Mode: Normal";
new Notice("Normal mode");
break;
case "rag":
setIcon(modeButton, "database");
modeButton.style.backgroundColor = "var(--interactive-accent)";
modeButton.style.color = "var(--text-on-accent)";
modeButton.title = "Mode: RAG (Searching vault)";
new Notice("RAG mode ON - Searching entire vault");
break;
case "web":
setIcon(modeButton, "globe");
modeButton.style.backgroundColor = "var(--interactive-accent)";
modeButton.style.color = "var(--text-on-accent)";
modeButton.title = "Mode: Web Search";
new Notice("Web Search mode ON - Searching the web");
break;
}
}
// Update active state in dropdown options
modeOptions?.forEach(option => {
const optionMode = option.getAttribute("data-mode");
if (optionMode === mode) {
option.addClass("active");
} else {
option.removeClass("active");
}
});
}
private formatCitations(groundingMetadata: any): string {
if (!groundingMetadata || !groundingMetadata.groundingChunks) {
return "";
}
const chunks = groundingMetadata.groundingChunks;
if (!chunks || chunks.length === 0) {
return "";
}
// Extract information from chunks
const fileNames = new Set<string>();
const obsidianLinks = new Set<string>();
const hashtags = new Set<string>();
const textPreviews = new Set<string>();
chunks.forEach((chunk: any) => {
if (chunk.web) {
return; // Skip web sources
}
const context = chunk.retrievedContext;
// Debug: Log all available keys in retrievedContext
console.log("Retrieved Context Keys:", Object.keys(context || {}));
// Try multiple possible locations for file name/metadata
const fileName = context?.displayName ||
context?.title ||
context?.name ||
context?.uri ||
context?.metadata?.displayName ||
context?.metadata?.path ||
context?.customMetadata?.find?.((m: any) => m.key === "path")?.stringValue;
if (fileName) {
console.log("Found file name:", fileName);
fileNames.add(fileName);
}
const text = context?.text || "";
// Extract Obsidian-style [[links]]
const linkMatches = text.matchAll(/\[\[([^\]]+)\]\]/g);
for (const match of linkMatches) {
obsidianLinks.add(match[1]);
}
// Extract hashtags
const hashtagMatches = text.matchAll(/#[\w-]+/g);
for (const match of hashtagMatches) {
hashtags.add(match[0]);
}
// If we have no links or file names, add a preview of the text
if (obsidianLinks.size === 0 && fileNames.size === 0) {
const preview = text.trim().substring(0, 80).replace(/\n/g, ' ');
if (preview) {
textPreviews.add(`"${preview}..."`);
}
}
});
// Build sources list prioritizing file names, then links, then hashtags, then text previews
const allSources: string[] = [];
if (fileNames.size > 0) {
console.log("Using file names for citations:", Array.from(fileNames));
allSources.push(...Array.from(fileNames).map(name => `📄 ${name}`));
} else if (obsidianLinks.size > 0) {
console.log("Using Obsidian links for citations:", Array.from(obsidianLinks));
allSources.push(...Array.from(obsidianLinks).map(link => `📄 [[${link}]]`));
}
if (hashtags.size > 0 && (obsidianLinks.size > 0 || fileNames.size > 0)) {
// Show hashtags alongside links/files as additional context
allSources.push(...Array.from(hashtags).map(tag => `🏷️ ${tag}`));
}
if (allSources.length === 0 && textPreviews.size > 0) {
// Fallback to text previews if no structured info found
allSources.push(...Array.from(textPreviews).slice(0, 3)); // Limit to 3 previews
}
if (allSources.length === 0) {
// Last resort: just say how many chunks
return `\n\n---\n*Response grounded in ${chunks.length} source${chunks.length !== 1 ? 's' : ''} from your vault*`;
}
// Create collapsible citation section
const sourcesList = allSources.map(source => ` ${source}`).join('\n');
const count = fileNames.size || obsidianLinks.size || textPreviews.size;
return `\n\n---\n<details>\n<summary>📚 Sources from your vault (${count} reference${count !== 1 ? 's' : ''})</summary>\n\n${sourcesList}\n</details>`;
}
private formatWebSearchSources(groundingMetadata: any): string {
if (!groundingMetadata) {
return "";
}
const sources: string[] = [];
// Add search queries used
if (groundingMetadata.webSearchQueries && groundingMetadata.webSearchQueries.length > 0) {
const queries = groundingMetadata.webSearchQueries.join(", ");
sources.push(`🔍 **Search queries**: ${queries}`);
}
// Add web sources with clickable links
if (groundingMetadata.groundingChunks && groundingMetadata.groundingChunks.length > 0) {
const webSources = groundingMetadata.groundingChunks
.filter((chunk: any) => chunk.web)
.map((chunk: any, index: number) => {
const uri = chunk.web.uri;
const title = chunk.web.title || uri;
return ` ${index + 1}. [${title}](${uri})`;
});
if (webSources.length > 0) {
sources.push(`\n🌐 **Web sources**:\n${webSources.join('\n')}`);
}
}
if (sources.length === 0) {
return "\n\n---\n*Response generated with web search*";
}
return `\n\n---\n<details>\n<summary>🌐 Web Search Sources (${groundingMetadata.groundingChunks?.filter((c: any) => c.web).length || 0} result${(groundingMetadata.groundingChunks?.filter((c: any) => c.web).length || 0) !== 1 ? 's' : ''})</summary>\n\n${sources.join('\n\n')}\n</details>`;
}
onunload() {
this.chatIcon?.remove();
this.chatContainer?.remove();
}
public encryptApiKey(key: string): string {
return btoa(key.split("").reverse().join(""));
}
public decryptApiKey(encryptedKey: string): string {
return atob(encryptedKey).split("").reverse().join("");
}
// Add this method to handle showing/hiding suggested actions
private toggleSuggestedActions(show: boolean) {
const suggestedActions = this.chatContainer.querySelector(
".suggested-actions"
) as HTMLElement;
if (suggestedActions) {
if (show) {
suggestedActions.removeClass("gemini-hidden");
suggestedActions.addClass("gemini-visible");
} else {
suggestedActions.removeClass("gemini-visible");
suggestedActions.addClass("gemini-hidden");
}
}
}
private updateChatHeader() {
const activeFile = this.app.workspace.getActiveFile();
const headerEl = this.chatContainer.querySelector(
".current-file"
) as HTMLElement;
if (headerEl && activeFile) {
headerEl.textContent = activeFile.basename;
headerEl.removeClass("gemini-hidden");
headerEl.addClass("gemini-visible");
} else if (headerEl) {
headerEl.removeClass("gemini-visible");
headerEl.addClass("gemini-hidden");
}
}
// Add method to handle more options menu
private showMoreOptionsMenu(event: MouseEvent) {
// Remove existing menu if present
const existingMenu = document.querySelector('.more-options-menu');
if (existingMenu) {
existingMenu.remove();
return;
}
// Create menu container
const menu = document.createElement('div');
menu.className = 'more-options-menu';
// Position menu relative to button
const buttonRect = (event.target as HTMLElement).getBoundingClientRect();
menu.style.position = 'fixed';
menu.style.top = `${buttonRect.bottom + 5}px`;
menu.style.right = `${window.innerWidth - buttonRect.right}px`;
// Create menu items
const menuItems = [
{
icon: '↙️',
text: 'Reset Size',
action: () => this.resetChatSize()
},
{
icon: '⛶',
text: 'Toggle Full Screen',
action: () => this.toggleFullPageChat()
},
{
icon: '📐',
text: 'Compact View',
action: () => this.setCompactSize()
},
{
icon: '🔄',
text: 'New Chat',
action: () => this.startNewChat()
}
];
menuItems.forEach(item => {
const menuItem = document.createElement('div');
menuItem.className = 'vaultai-menu-item';
const icon = document.createElement('span');
icon.textContent = item.icon;
const text = document.createElement('span');
text.textContent = item.text;
menuItem.appendChild(icon);
menuItem.appendChild(text);
menuItem.addEventListener('click', () => {
item.action();
menu.remove();
});
menu.appendChild(menuItem);
});
document.body.appendChild(menu);
// Close menu when clicking outside
const closeMenu = (e: MouseEvent) => {
if (!menu.contains(e.target as Node)) {
menu.remove();
document.removeEventListener('click', closeMenu);
}
};
// Delay adding the event listener to prevent immediate closure
setTimeout(() => {
document.addEventListener('click', closeMenu);
}, 100);
}
// Add method to reset chat size
private resetChatSize() {
// Add resetting class for animation
this.chatContainer.classList.add("resetting");
this.chatContainer.classList.remove("dynamic-size");
this.chatContainer.classList.add("default-size");
// Clear custom size properties
this.chatContainer.style.removeProperty("--dynamic-width");
this.chatContainer.style.removeProperty("--dynamic-height");
// Remove resetting class after animation
setTimeout(() => {
this.chatContainer.classList.remove("resetting");
}, 400);
}
// Add method to set compact size
private setCompactSize() {
this.chatContainer.classList.remove("default-size");
this.chatContainer.classList.add("dynamic-size");
this.chatContainer.style.setProperty("--dynamic-width", "320px");
this.chatContainer.style.setProperty("--dynamic-height", "480px");
}
// Add method to start new chat
private startNewChat() {
if (this.messagesContainer) {
this.messagesContainer.empty();
}
this.currentSession = this.createNewSession();
this.showMainChatView();
}
// Add this method to handle full page toggle
private toggleFullPageChat() {
this.isFullPage = !this.isFullPage;
if (this.isFullPage) {
this.chatContainer.addClass("full-page");
} else {
this.chatContainer.removeClass("full-page");
}
}
// Add method to create new chat session
private createNewSession(): ChatSession {
return {
id: Date.now().toString(),
title: "New chat",
timestamp: Date.now(),
messages: [],
};
}
// Add method to generate session title
private generateSessionTitle(firstMessage: string): string {
// Remove any markdown formatting
const cleanMessage = firstMessage.replace(/[#*`]/g, "").trim();
// Check for specific patterns in the message
if (cleanMessage.toLowerCase().includes("summarize")) {
return "📝 Summary: " + this.extractDocumentName(cleanMessage);
}
if (cleanMessage.toLowerCase().includes("translate")) {
return "🌐 Translation: " + this.extractDocumentName(cleanMessage);
}
if (
cleanMessage.toLowerCase().includes("action items") ||
cleanMessage.toLowerCase().includes("tasks")
) {
return "✅ Tasks from: " + this.extractDocumentName(cleanMessage);
}
// For questions
if (cleanMessage.endsWith("?")) {
return (
"❓ " +
(cleanMessage.length > 40
? cleanMessage.substring(0, 40) + "..."
: cleanMessage)
);
}
// For general chat, try to extract key topic
const keywords = this.extractKeywords(cleanMessage);
if (keywords) {
return "💭 Chat about " + keywords;
}
// Fallback to default with timestamp
return (
"💬 Chat from " +
new Date().toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})
);
}
private extractDocumentName(message: string): string {
// Try to find the document name in the message
const lines = message.split("\n");
if (lines.length > 1) {
// Take the first non-empty line after the first line
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (line) {
return line.length > 30
? line.substring(0, 30) + "..."
: line;
}
}
}
return "Document";
}
private extractKeywords(message: string): string {
// Remove common words and get key topics
const commonWords = new Set([
"the",
"be",
"to",
"of",
"and",
"a",
"in",
"that",
"have",
"i",
"it",
"for",
"not",
"on",
"with",
"he",
"as",
"you",
"do",
"at",
"this",
"but",
"his",
"by",
"from",
"they",
"we",
"say",
"her",
"she",
"or",
"an",
"will",
"my",
"one",
"all",
"would",
"there",
"their",
"what",
"so",
"up",
"out",
"if",
"about",
"who",
"get",
"which",
"go",
"me",
"please",
"could",
"can",
"just",
]);
// Split message into words and filter
const words = message
.toLowerCase()
.replace(/[^\w\s]/g, "")
.split(/\s+/)
.filter((word) => !commonWords.has(word) && word.length > 2)
.slice(0, 3);
if (words.length > 0) {
// Capitalize first letters
const formattedWords = words.map(
(word) => word.charAt(0).toUpperCase() + word.slice(1)
);
return formattedWords.join(", ");
}
return "";
}
// Update showChatHistoryView method
private showChatHistoryView() {
if (!this.chatContainer) return;
// Hide existing elements
const elementsToHide = [
".bot-info",
".vaultai-suggested-actions",
".chat-input-container",
".gemini-chat-messages",
];
elementsToHide.forEach((selector) => {
const el = this.chatContainer.querySelector(selector);
if (el) {
(el as HTMLElement).removeClass("gemini-visible");
(el as HTMLElement).removeClass("gemini-visible-flex");
(el as HTMLElement).addClass("gemini-hidden");
}
});
// Remove existing history view if any
const existingHistoryView =
this.chatContainer.querySelector(".chat-history-view");
if (existingHistoryView) {
existingHistoryView.classList.add("closing");
setTimeout(() => existingHistoryView.remove(), 300);
return;
}
// Create history view using DOM API
const historyView = createDiv({ cls: "chat-history-view" });
// Create header
const header = createDiv({ cls: "chat-history-header" });
// Back button
const backButton = createDiv({ cls: "back-button" });
const backIcon = createSvg("svg", {
attr: {
width: "16",
height: "16",
viewBox: "0 0 24 24",
},
});
const backPath = createSvg("path", {
attr: {
fill: "currentColor",
d: "M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z",
},
});
backIcon.appendChild(backPath);
backButton.appendChild(backIcon);
// Title
const title = createEl("h2", { text: "All chats" });
// New chat button
const newChatButton = createDiv({
cls: "new-chat-button",
text: "New chat",
});
header.appendChild(backButton);
header.appendChild(title);
header.appendChild(newChatButton);
// Search input
const searchContainer = createDiv({ cls: "chat-history-search" });
const searchInput = createEl("input", {
type: "text",
placeholder: "Search or start new chat",
});
searchContainer.appendChild(searchInput);
// History sections
const sectionsContainer = createDiv({ cls: "chat-history-sections" });
this.renderChatHistorySections(sectionsContainer);
// Append all elements
historyView.appendChild(header);
historyView.appendChild(searchContainer);
historyView.appendChild(sectionsContainer);
this.chatContainer.appendChild(historyView);
// Add event listeners
this.attachHistoryItemListeners(historyView);
}
// Update renderHistorySection to use DOM API
private renderHistorySection(
container: HTMLElement,
title: string,
sessions: ChatSession[]
) {
if (sessions.length === 0) return;
const section = createDiv({ cls: "history-section" });
const heading = createEl("h3", { text: title });
section.appendChild(heading);
sessions.forEach((session) => {
const item = createDiv({ cls: "history-item" });
item.setAttribute("data-session-id", session.id);
const icon = createDiv({
cls: "history-item-icon",
text: "💬",
});
const content = createDiv({ cls: "history-item-content" });
const itemTitle = createDiv({
cls: "history-item-title",
text: session.title,
});
const itemTime = createDiv({
cls: "history-item-time",
text: this.formatTime(session.timestamp),
});
content.appendChild(itemTitle);
content.appendChild(itemTime);
const deleteBtn = createDiv({ cls: "delete-chat" });
deleteBtn.setAttribute("data-session-id", session.id);
const deleteIcon = createSvg("svg", {
attr: {
width: "14",
height: "14",
viewBox: "0 0 24 24",
},
});
const deletePath = createSvg("path", {
attr: {
fill: "currentColor",
d: "M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zm2.46-7.12l1.41-1.41L12 12.59l2.12-2.12 1.41 1.41L13.41 14l2.12 2.12-1.41 1.41L12 15.41l-2.12 2.12-1.41-1.41L10.59 14l-2.13-2.12zM15.5 4l-1-1h-5l-1 1H5v2h14V4z",
},
});
deleteIcon.appendChild(deletePath);
deleteBtn.appendChild(deleteIcon);
item.appendChild(icon);
item.appendChild(content);
item.appendChild(deleteBtn);
section.appendChild(item);
});
container.appendChild(section);
}
// Add method to handle chat deletion
private deleteChat(sessionId: string) {
const historyItem = this.chatContainer.querySelector(
`.history-item[data-session-id="${sessionId}"]`
);
if (historyItem) {
historyItem.classList.add("deleting");
// Wait for the animation to complete before removing the item
setTimeout(() => {
// Remove the session from settings
this.settings.chatSessions = this.settings.chatSessions.filter(
(s) => s.id !== sessionId
);
this.saveSettings();
// Remove the item from the DOM
historyItem.remove();
// Re-render the chat history sections
const sectionsContainer = this.chatContainer.querySelector(
".chat-history-sections"
);
if (sectionsContainer) {
sectionsContainer.empty();
this.renderChatHistorySections(
sectionsContainer as HTMLElement
);
this.attachHistoryItemListeners(
sectionsContainer as HTMLElement
);
}
}, 300); // Match the duration of the fadeOut animation
}
}
// Add method to attach event listeners to history items
private attachHistoryItemListeners(historyView: HTMLElement) {
// Back button
const backButton = historyView.querySelector(".back-button");
backButton?.addEventListener("click", () => {
this.goBackFromHistory();
});
// New chat button
const newChatButton = historyView.querySelector(".new-chat-button");
newChatButton?.addEventListener("click", () => {
this.startNewChat();
this.goBackFromHistory();
});
// Search functionality
const searchInput = historyView.querySelector("input") as HTMLInputElement;
searchInput?.addEventListener("input", (e) => {
const query = (e.target as HTMLInputElement).value;
this.filterChatHistory(query);
});
// Delete buttons
const deleteButtons = historyView.querySelectorAll(".delete-chat");
deleteButtons.forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation(); // Prevent triggering the history item click
const sessionId = btn.getAttribute("data-session-id");
if (sessionId) {
this.deleteChat(sessionId);
}
});
});
// History items
const historyItems = historyView.querySelectorAll(".history-item");
historyItems.forEach((item) => {
item.addEventListener("click", () => {
const sessionId = item.getAttribute("data-session-id");
const session = this.settings.chatSessions.find(
(s) => s.id === sessionId
);
if (session) {
this.currentSession = { ...session };
this.goBackFromHistory();
}
});
});
}
// Add method to go back from history view
private goBackFromHistory() {
const historyView = this.chatContainer.querySelector(".chat-history-view");
if (historyView) {
historyView.classList.add("closing");
setTimeout(() => {
historyView.remove();
this.showMainChatView();
}, 300);
} else {
this.showMainChatView();
}
}
// Add method to render chat history sections
private renderChatHistorySections(container: HTMLElement): void {
const now = Date.now();
const dayInMs = 24 * 60 * 60 * 1000;
const thirtyDaysAgo = now - 30 * dayInMs;
const today: ChatSession[] = [];
const past30Days: ChatSession[] = [];
const older: ChatSession[] = [];
this.settings.chatSessions.forEach((session) => {
if (session.timestamp > now - dayInMs) {
today.push(session);
} else if (session.timestamp > thirtyDaysAgo) {
past30Days.push(session);
} else {
older.push(session);
}
});
// Render sections using DOM API
this.renderHistorySection(container, "Today", today);
this.renderHistorySection(container, "Past 30 days", past30Days);
this.renderHistorySection(container, "Older", older);
}
// Add method to format timestamp
private formatTime(timestamp: number): string {
const date = new Date(timestamp);
const now = new Date();
if (date.toDateString() === now.toDateString()) {
return date.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
}
return date.toLocaleDateString();
}
// Update filterChatHistory method to fix search
private filterChatHistory(query: string) {
const historyView =
this.chatContainer.querySelector(".chat-history-view");
if (!historyView) return;
const items = historyView.querySelectorAll(".history-item");
items.forEach((item) => {
const title =
item
.querySelector(".history-item-title")
?.textContent?.toLowerCase() || "";
if (title.includes(query.toLowerCase())) {
item.classList.remove("hidden");
item.classList.add("visible");
} else {
item.classList.add("hidden");
item.classList.remove("visible");
}
});
}
// Update showMainChatView method
private async showMainChatView() {
// Remove history view if present
const historyView = this.chatContainer.querySelector(".chat-history-view");
if (historyView) {
historyView.remove();
}
// Show all main chat elements with proper display classes
const elementsToShow = [
".bot-info",
".chat-input-container",
".gemini-chat-messages",
];
elementsToShow.forEach((selector) => {
const el = this.chatContainer.querySelector(selector);
if (el) {
el.classList.remove("gemini-hidden");
if (selector === ".gemini-chat-messages") {
el.classList.add("gemini-visible-flex");
} else {
el.classList.add("gemini-visible");
}
}
});
if (this.messagesContainer) {
// Clear existing messages before adding new ones
this.messagesContainer.innerHTML = "";
// Only show messages if we have a current session
if (this.currentSession && this.currentSession.messages.length > 0) {
// Hide bot info and suggested actions if there are messages
const botInfo = this.chatContainer.querySelector(".bot-info");
const suggestedActions = this.chatContainer.querySelector(
".vaultai-suggested-actions"
);
if (botInfo) {
botInfo.classList.add("hidden");
setTimeout(() => botInfo.remove(), 300);
}
if (suggestedActions) {
suggestedActions.classList.add("hidden");
setTimeout(() => suggestedActions.remove(), 300);
}
// Create a new array with sorted messages
const sortedMessages = [...this.currentSession.messages].sort(
(a, b) => a.timestamp - b.timestamp
);
// Display messages in chronological order
for (const message of sortedMessages) {
const messageEl = document.createElement("div");
messageEl.className = `gemini-message-${message.role}`;
if (message.role === "bot") {
const contentEl = document.createElement("div");
contentEl.className = "message-content";
messageEl.appendChild(contentEl);
await MarkdownRenderer.render(this.app, message.content, contentEl, "", this);
const actionBar = document.createElement("div");
actionBar.className = "message-action-bar";
const clipboardButton = document.createElement("button");
clipboardButton.className = "message-action-btn";
setIcon(clipboardButton, "copy");
clipboardButton.addEventListener("click", async () => {
await navigator.clipboard.writeText(message.content);
setIcon(clipboardButton, "check");
setTimeout(() => setIcon(clipboardButton, "copy"), 2000);
});
actionBar.appendChild(clipboardButton);
const noteButton = document.createElement("button");
noteButton.className = "message-action-btn";
setIcon(noteButton, "file-plus");
noteButton.addEventListener("click", async () => {
try {
const title = this.generateNoteTitle(message.content);
const file = await this.app.vault.create(`${title}.md`, message.content);
const leaf = this.app.workspace.getLeaf(false);
await leaf.openFile(file);
new Notice("Response copied to new note!");
} catch (error) {
new Notice("Failed to create note");
}
});
actionBar.appendChild(noteButton);
messageEl.appendChild(actionBar);
} else {
// For user messages, show the visible content
const visibleContent = this.stripContextFromMessage(
message.content
);
messageEl.textContent = visibleContent;
}
this.messagesContainer.appendChild(messageEl);
}
this.messagesContainer.scrollTop = this.messagesContainer.scrollHeight;
this.toggleSuggestedActions(false);
} else {
// Show suggested actions for new chat
this.recreateBotInfoAndSuggestions();
this.toggleSuggestedActions(true);
}
}
}
// Add method to recreate bot info and suggestions when needed
private recreateBotInfoAndSuggestions() {
// Recreate bot-info and suggested-actions if they don't exist
const existingBotInfo = this.chatContainer.querySelector(".bot-info");
const existingSuggestedActions = this.chatContainer.querySelector(
".vaultai-suggested-actions"
);
if (!existingBotInfo) {
const botInfo = this.createBotInfo();
// Insert bot-info after the header
const header = this.chatContainer.querySelector(
".gemini-chat-header"
);
header?.after(botInfo);
}
if (!existingSuggestedActions) {
const suggestedActions = this.createSuggestedActions();
// Insert vaultai-suggested-actions before the chat input container
const inputContainer = this.chatContainer.querySelector(
".chat-input-container"
);
inputContainer?.before(suggestedActions);
// Reattach event listeners for action buttons
this.addActionButtonListeners();
}
}
// Add this method to handle file selection
private async showFileSelectionModal() {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const files = this.app.vault.getMarkdownFiles();
const modal = new FileSelectionModal(this.app, async (file) => {
if (file && this.inputField) {
const content = await this.app.vault.read(file);
// Add file reference to input at cursor position
const cursorPos = this.inputField.selectionStart;
const currentValue = this.inputField.value;
const newValue =
currentValue.slice(0, cursorPos) +
`@${file.basename} ` +
currentValue.slice(cursorPos);
this.inputField.value = newValue;
// Store the file content to be used in the prompt
this.referencedFiles = this.referencedFiles || new Map();
this.referencedFiles.set(file.basename, content);
this.inputField.focus();
}
});
modal.open();
}
// Add method to truncate content intelligently
private truncateContent(content: string): string {
if (content.length <= this.MAX_CONTEXT_LENGTH) {
return content;
}
// Try to find a good breaking point
const relevantPart = content.slice(0, this.MAX_CONTEXT_LENGTH);
const lastParagraph = relevantPart.lastIndexOf("\n\n");
if (lastParagraph !== -1) {
return (
relevantPart.slice(0, lastParagraph) +
"\n\n[Content truncated for length...]"
);
}
return relevantPart + "[Content truncated for length...]";
}
// Add this method to format the bot's response
private async formatBotResponse(container: HTMLElement, content: string) {
// Create header with copy button
const headerDiv = container.createDiv("response-header");
const copyButton = headerDiv.createEl("button", {
text: "Copy to new note",
cls: "copy-response-button",
});
copyButton.addEventListener("click", async () => {
// Create new note with response content
const fileName = `AI Response ${new Date()
.toLocaleString()
.replace(/[/:\\]/g, "-")}`;
const file = await this.app.vault.create(`${fileName}.md`, content);
// Open the new file
const leaf = this.app.workspace.getLeaf(false);
await leaf.openFile(file);
// Show notification
new Notice("Response copied to new note!");
});
// Create content container with proper formatting
const contentDiv = container.createDiv("response-content");
await MarkdownRenderer.render(this.app, content, contentDiv, "", this);
}
// Add method to generate creative titles
private generateNoteTitle(content: string): string {
// Try to identify the type of content
const isQuiz =
content.toLowerCase().includes("quiz") ||
content.toLowerCase().includes("question");
const isSummary =
content.toLowerCase().includes("summary") ||
content.toLowerCase().includes("summarize");
const isTranslation =
content.toLowerCase().includes("translation") ||
content.toLowerCase().includes("translated");
// Get current file name if available
const activeFile = this.app.workspace.getActiveFile();
const fileName = activeFile ? activeFile.basename : "";
// Generate base title based on content type
let title = "";
if (isQuiz) {
title = `Quiz - ${fileName}`;
} else if (isSummary) {
title = `Summary - ${fileName}`;
} else if (isTranslation) {
const langMatch = content.match(/translated? to (\w+)/i);
const language = langMatch ? langMatch[1] : "Other Language";
title = `${language} Translation - ${fileName}`;
} else {
// For other types, try to extract meaningful content
const headingMatch = content.match(/^#\s+(.+)$/m);
if (headingMatch) {
title = `${headingMatch[1].trim()} - ${fileName}`;
} else {
const firstLine = content.split("\n")[0].trim();
if (firstLine && firstLine.length < 50) {
title = `${firstLine} - ${fileName}`;
} else {
// Fallback: Use timestamp
const now = new Date();
title = `AI Response ${now.toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
})}`;
}
}
}
// Sanitize the title by removing invalid characters
return title.replace(/[\\/:*?"<>|]/g, "-");
}
private addResizeFunctionality(handle: HTMLElement) {
let isResizing = false;
let startWidth: number;
let startHeight: number;
let startX: number;
let startY: number;
let startBottom: number;
let startRight: number;
handle.addEventListener("mousedown", (e: MouseEvent) => {
isResizing = true;
startWidth = this.chatContainer.offsetWidth;
startHeight = this.chatContainer.offsetHeight;
startX = e.clientX;
startY = e.clientY;
// Store the original bottom and right positions
const rect = this.chatContainer.getBoundingClientRect();
startBottom = window.innerHeight - rect.bottom;
startRight = window.innerWidth - rect.right;
// Add resizing class for visual feedback
this.chatContainer.addClass("is-resizing");
// Add event listeners
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", stopResize);
// Prevent text selection while resizing
e.preventDefault();
document.body.style.userSelect = "none";
});
const handleMouseMove = (e: MouseEvent) => {
if (!isResizing) return;
// Calculate new dimensions (expanding leftward and upward)
const deltaX = startX - e.clientX;
const deltaY = startY - e.clientY;
const newWidth = Math.min(
Math.max(startWidth + deltaX, 320), // Minimum width: 320px
Math.min(window.innerWidth - 40, 900) // Maximum width: 900px or window width - 40px
);
const newHeight = Math.min(
Math.max(startHeight + deltaY, 400), // Minimum height: 400px
Math.min(window.innerHeight - 40, 900) // Maximum height: 900px or window height - 40px
);
// Update container dimensions using CSS custom properties
this.chatContainer.removeClass("default-size");
this.chatContainer.addClass("dynamic-size");
this.chatContainer.style.setProperty(
"--dynamic-width",
`${newWidth}px`
);
this.chatContainer.style.setProperty(
"--dynamic-height",
`${newHeight}px`
);
// Maintain position relative to bottom-right corner
this.chatContainer.style.bottom = `${startBottom}px`;
this.chatContainer.style.right = `${startRight}px`;
};
const stopResize = () => {
isResizing = false;
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", stopResize);
// Remove resizing class and restore user selection
this.chatContainer.removeClass("is-resizing");
document.body.style.userSelect = "";
};
}
}
class GeminiChatbotSettingTab extends PluginSettingTab {
plugin: GeminiChatbotPlugin;
constructor(app: App, plugin: GeminiChatbotPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
new Setting(containerEl)
.setName("Gemini API key")
.setDesc("Enter your Gemini API key (stored securely)")
.addText((text) => {
text.inputEl.type = "password";
text.setPlaceholder("Enter your API key")
.setValue(this.plugin.settings.apiKey ? "••••••••" : "")
.onChange(async (value) => {
if (value !== "••••••••") {
this.plugin.settings.apiKey =
this.plugin.encryptApiKey(value);
await this.plugin.saveSettings();
this.plugin.initializeGeminiService();
}
});
// Add show/hide password toggle
const toggleButton = text.inputEl.createEl("button", {
text: "",
cls: "password-toggle",
});
toggleButton.addEventListener("click", (e) => {
e.preventDefault();
text.inputEl.type =
text.inputEl.type === "password" ? "text" : "password";
});
});
// Model Configuration Section
containerEl.createEl("h3", { text: "Model Configuration" });
const modelConfigDesc = containerEl.createEl("p", {
text: "Configure the Gemini model and generation parameters. Changes will apply to new conversations."
});
modelConfigDesc.style.marginBottom = "20px";
modelConfigDesc.style.color = "var(--text-muted)";
// Model name setting
new Setting(containerEl)
.setName("Model Name")
.setDesc("The Gemini model to use (e.g., gemini-2.5-flash, gemini-2.0-flash, gemini-1.5-pro)")
.addText((text) => {
text
.setPlaceholder("gemini-2.5-flash")
.setValue(this.plugin.settings.modelName)
.onChange(async (value) => {
this.plugin.settings.modelName = value || "gemini-2.5-flash";
await this.plugin.saveSettings();
this.plugin.initializeGeminiService();
});
});
// Temperature setting
new Setting(containerEl)
.setName("Temperature")
.setDesc("Controls randomness in responses (0.0-2.0). Higher values make output more creative, lower values more focused.")
.addSlider((slider) => {
slider
.setLimits(0, 2, 0.1)
.setValue(this.plugin.settings.temperature)
.setDynamicTooltip()
.onChange(async (value) => {
this.plugin.settings.temperature = value;
await this.plugin.saveSettings();
this.plugin.initializeGeminiService();
});
})
.addExtraButton((button) => {
button
.setIcon("reset")
.setTooltip("Reset to default (1.0)")
.onClick(async () => {
this.plugin.settings.temperature = 1.0;
await this.plugin.saveSettings();
this.plugin.initializeGeminiService();
this.display();
});
});
// Top K setting
new Setting(containerEl)
.setName("Top K")
.setDesc("Limits token selection to top K most likely tokens (1-100). Lower values make output more deterministic.")
.addText((text) => {
text
.setPlaceholder("40")
.setValue(String(this.plugin.settings.topK))
.onChange(async (value) => {
const numValue = parseInt(value);
if (!isNaN(numValue) && numValue >= 1 && numValue <= 100) {
this.plugin.settings.topK = numValue;
await this.plugin.saveSettings();
this.plugin.initializeGeminiService();
}
});
})
.addExtraButton((button) => {
button
.setIcon("reset")
.setTooltip("Reset to default (40)")
.onClick(async () => {
this.plugin.settings.topK = 40;
await this.plugin.saveSettings();
this.plugin.initializeGeminiService();
this.display();
});
});
// Top P setting
new Setting(containerEl)
.setName("Top P")
.setDesc("Nucleus sampling threshold (0.0-1.0). Tokens with cumulative probability up to this value are considered.")
.addSlider((slider) => {
slider
.setLimits(0, 1, 0.05)
.setValue(this.plugin.settings.topP)
.setDynamicTooltip()
.onChange(async (value) => {
this.plugin.settings.topP = value;
await this.plugin.saveSettings();
this.plugin.initializeGeminiService();
});
})
.addExtraButton((button) => {
button
.setIcon("reset")
.setTooltip("Reset to default (0.95)")
.onClick(async () => {
this.plugin.settings.topP = 0.95;
await this.plugin.saveSettings();
this.plugin.initializeGeminiService();
this.display();
});
});
// Max Output Tokens setting
new Setting(containerEl)
.setName("Max Output Tokens")
.setDesc("Maximum number of tokens in the response (100-32768)")
.addText((text) => {
text
.setPlaceholder("8192")
.setValue(String(this.plugin.settings.maxOutputTokens))
.onChange(async (value) => {
const numValue = parseInt(value);
if (!isNaN(numValue) && numValue >= 100 && numValue <= 32768) {
this.plugin.settings.maxOutputTokens = numValue;
await this.plugin.saveSettings();
this.plugin.initializeGeminiService();
}
});
})
.addExtraButton((button) => {
button
.setIcon("reset")
.setTooltip("Reset to default (8192)")
.onClick(async () => {
this.plugin.settings.maxOutputTokens = 8192;
await this.plugin.saveSettings();
this.plugin.initializeGeminiService();
this.display();
});
});
// Custom Prompts Section
containerEl.createEl("h3", { text: "Custom Prompts" });
const customPromptsDesc = containerEl.createEl("p", {
text: "Create custom prompts that can be quickly accessed from the chat interface or command palette."
});
customPromptsDesc.style.marginBottom = "20px";
customPromptsDesc.style.color = "var(--text-muted)";
// Display existing custom prompts
this.displayCustomPrompts(containerEl);
// Add new prompt button
new Setting(containerEl)
.setName("Add Custom Prompt")
.setDesc("Create a new custom prompt")
.addButton((button) => {
button.setButtonText("Add Prompt")
.setCta()
.onClick(() => {
this.showAddPromptModal();
});
});
// RAG Settings Section
containerEl.createEl("h3", { text: "RAG Settings (Vault-Wide AI Search)" });
const ragDesc = containerEl.createEl("p", {
text: "Enable RAG (Retrieval Augmented Generation) to search and query your entire vault using AI. Your notes will be indexed and embedded for semantic search."
});
ragDesc.style.marginBottom = "20px";
ragDesc.style.color = "var(--text-muted)";
// Enable RAG toggle
new Setting(containerEl)
.setName("Enable RAG")
.setDesc("Enable vault-wide AI search using Google's File Search")
.addToggle((toggle) => {
toggle
.setValue(this.plugin.settings.ragEnabled)
.onChange(async (value) => {
this.plugin.settings.ragEnabled = value;
await this.plugin.saveSettings();
if (value) {
this.plugin.initializeRAGService();
new Notice("RAG enabled. Please sync your vault below.");
} else {
new Notice("RAG disabled");
}
this.display(); // Refresh settings
});
});
// Only show RAG settings if enabled
if (this.plugin.settings.ragEnabled) {
// Folder path setting
new Setting(containerEl)
.setName("Vault Folder Path")
.setDesc("Choose which folder to index (use '/' for entire vault)")
.addText((text) => {
text
.setPlaceholder("/")
.setValue(this.plugin.settings.ragFolderPath)
.onChange(async (value) => {
this.plugin.settings.ragFolderPath = value || "/";
await this.plugin.saveSettings();
});
});
// Sync status
const syncStats = this.plugin.ragService?.getSyncStats() || { total: 0, synced: 0, pending: 0 };
const syncStatusContainer = containerEl.createDiv("rag-sync-status");
syncStatusContainer.style.padding = "10px";
syncStatusContainer.style.backgroundColor = "var(--background-secondary)";
syncStatusContainer.style.borderRadius = "8px";
syncStatusContainer.style.marginBottom = "15px";
const statusText = syncStatusContainer.createEl("p", {
text: `📊 Sync Status: ${syncStats.synced} files synced out of ${syncStats.total} tracked files`
});
statusText.style.margin = "0";
// Sync button
new Setting(containerEl)
.setName("Sync Vault")
.setDesc("Upload new and modified files to the File Search store")
.addButton((button) => {
button
.setButtonText("Sync Now")
.setCta()
.onClick(async () => {
await this.syncVaultWithProgress();
});
})
.addButton((button) => {
button
.setButtonText("Delete Store")
.setWarning()
.onClick(async () => {
if (confirm("Are you sure you want to delete the RAG store? This will remove all indexed files.")) {
await this.deleteRAGStore();
}
});
});
// Store info
if (this.plugin.ragService?.getFileSearchStoreName()) {
const storeInfo = containerEl.createDiv("rag-store-info");
storeInfo.style.padding = "10px";
storeInfo.style.backgroundColor = "var(--background-primary-alt)";
storeInfo.style.borderRadius = "8px";
storeInfo.style.fontSize = "12px";
storeInfo.style.marginTop = "10px";
storeInfo.createEl("p", {
text: `📦 Store Name: ${this.plugin.ragService.getFileSearchStoreName()}`
});
}
}
}
private displayCustomPrompts(containerEl: HTMLElement): void {
const promptsContainer = containerEl.createDiv("custom-prompts-container");
if (this.plugin.settings.customPrompts.length === 0) {
const emptyState = promptsContainer.createEl("p", {
text: "No custom prompts yet. Create your first one below!"
});
emptyState.style.color = "var(--text-muted)";
emptyState.style.fontStyle = "italic";
emptyState.style.textAlign = "center";
emptyState.style.padding = "20px";
return;
}
this.plugin.settings.customPrompts.forEach((prompt, index) => {
const promptItem = promptsContainer.createDiv("custom-prompt-item");
promptItem.style.border = "1px solid var(--background-modifier-border)";
promptItem.style.borderRadius = "8px";
promptItem.style.padding = "15px";
promptItem.style.marginBottom = "10px";
promptItem.style.backgroundColor = "var(--background-secondary)";
const promptHeader = promptItem.createDiv("prompt-header");
promptHeader.style.display = "flex";
promptHeader.style.justifyContent = "space-between";
promptHeader.style.alignItems = "center";
promptHeader.style.marginBottom = "8px";
const promptTitle = promptHeader.createEl("h4", { text: prompt.name });
promptTitle.style.margin = "0";
promptTitle.style.color = "var(--text-normal)";
const promptActions = promptHeader.createDiv("prompt-actions");
promptActions.style.display = "flex";
promptActions.style.gap = "8px";
// Edit button
const editButton = promptActions.createEl("button", { text: "Edit" });
editButton.style.padding = "4px 8px";
editButton.style.fontSize = "12px";
editButton.style.cursor = "pointer";
editButton.addEventListener("click", () => {
this.showEditPromptModal(prompt, index);
});
// Delete button
const deleteButton = promptActions.createEl("button", { text: "Delete" });
deleteButton.style.padding = "4px 8px";
deleteButton.style.fontSize = "12px";
deleteButton.style.cursor = "pointer";
deleteButton.style.color = "var(--text-error)";
deleteButton.addEventListener("click", () => {
this.deletePrompt(index);
});
// Description
if (prompt.description) {
const promptDesc = promptItem.createEl("p", { text: prompt.description });
promptDesc.style.margin = "0 0 8px 0";
promptDesc.style.color = "var(--text-muted)";
promptDesc.style.fontSize = "14px";
}
// Prompt preview (truncated)
const promptPreview = promptItem.createEl("code", {
text: prompt.prompt.length > 100 ? prompt.prompt.substring(0, 100) + "..." : prompt.prompt
});
promptPreview.style.display = "block";
promptPreview.style.padding = "8px";
promptPreview.style.backgroundColor = "var(--background-primary)";
promptPreview.style.borderRadius = "4px";
promptPreview.style.fontSize = "12px";
promptPreview.style.whiteSpace = "pre-wrap";
promptPreview.style.overflow = "hidden";
});
}
private async showAddPromptModal(): Promise<void> {
const modal = new CustomPromptModal(this.app, "Add Custom Prompt", "", "", "", (name, description, prompt) => {
const newPrompt: CustomPrompt = {
id: Date.now().toString(),
name,
description,
prompt
};
this.plugin.settings.customPrompts.push(newPrompt);
this.plugin.saveSettings();
this.plugin.registerCustomPromptCommands();
this.display(); // Refresh the settings display
});
modal.open();
}
private async showEditPromptModal(prompt: CustomPrompt, index: number): Promise<void> {
const modal = new CustomPromptModal(this.app, "Edit Custom Prompt", prompt.name, prompt.description, prompt.prompt, (name, description, promptText) => {
this.plugin.settings.customPrompts[index] = {
...prompt,
name,
description,
prompt: promptText
};
this.plugin.saveSettings();
this.plugin.registerCustomPromptCommands();
this.display(); // Refresh the settings display
});
modal.open();
}
private async deletePrompt(index: number): Promise<void> {
this.plugin.settings.customPrompts.splice(index, 1);
await this.plugin.saveSettings();
this.plugin.registerCustomPromptCommands();
this.display(); // Refresh the settings display
}
// RAG helper methods
private async syncVaultWithProgress(): Promise<void> {
if (!this.plugin.ragService) {
new Notice("RAG service not initialized");
return;
}
// Create progress notice
const notice = new Notice("Starting vault sync...", 0);
try {
// Initialize store if needed
if (!this.plugin.ragService.getFileSearchStoreName()) {
notice.setMessage("Creating File Search store...");
await this.plugin.ragService.initializeStore();
this.plugin.settings.ragStoreName = this.plugin.ragService.getFileSearchStoreName();
await this.plugin.saveSettings();
}
// Sync vault
const result = await this.plugin.ragService.syncVault(
this.plugin.settings.ragFolderPath === "/" ? undefined : this.plugin.settings.ragFolderPath,
(progress) => {
notice.setMessage(
`Syncing: ${progress.processed}/${progress.total} - ${progress.current}`
);
}
);
// Save synced files metadata
this.plugin.settings.ragSyncedFiles = this.plugin.ragService.getSyncedFilesMetadata();
await this.plugin.saveSettings();
notice.hide();
new Notice(
`Sync complete! ✅ ${result.success} uploaded, ⏭️ ${result.skipped} skipped, ❌ ${result.failed} failed`
);
// Refresh settings display
this.display();
} catch (error) {
notice.hide();
console.error("Sync error:", error);
new Notice(`Sync failed: ${error.message}`);
}
}
private async deleteRAGStore(): Promise<void> {
if (!this.plugin.ragService || !this.plugin.ragService.getFileSearchStoreName()) {
new Notice("No RAG store to delete");
return;
}
try {
const storeName = this.plugin.ragService.getFileSearchStoreName()!;
await this.plugin.ragService.deleteStore(storeName);
// Clear settings
this.plugin.settings.ragStoreName = null;
this.plugin.settings.ragSyncedFiles = {};
await this.plugin.saveSettings();
new Notice("RAG store deleted successfully");
this.display();
} catch (error) {
console.error("Delete store error:", error);
new Notice(`Failed to delete store: ${error.message}`);
}
}
}