feat: Phase 10 - UI Notifications (request-only)
Implement visual feedback for MCP tool calls with configurable notifications. Features: - Real-time notifications when tools are called (request only, no completion) - Tool-specific emoji icons for visual clarity - Rate limiting (max 10 notifications/second) - Notification history tracking (last 100 entries) - Configurable settings: enable/disable, show parameters, duration, console logging - History modal with filtering and export to clipboard Implementation: - Created NotificationManager with queue-based rate limiting - Created NotificationHistoryModal for viewing past tool calls - Integrated into tool call interceptor in ToolRegistry - Added notification settings UI section - Added 'View MCP Notification History' command Benefits: - Visual feedback for debugging and monitoring - Transparency into AI agent actions - Simple on/off toggle, no complex verbosity settings - Zero performance impact when disabled - History tracks success/failure/duration for all calls All 10 phases of the roadmap are now complete\!
This commit is contained in:
215
src/ui/notification-history.ts
Normal file
215
src/ui/notification-history.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { App, Modal } from 'obsidian';
|
||||
import { NotificationHistoryEntry } from './notifications';
|
||||
|
||||
/**
|
||||
* Modal for viewing notification history
|
||||
*/
|
||||
export class NotificationHistoryModal extends Modal {
|
||||
private history: NotificationHistoryEntry[];
|
||||
private filteredHistory: NotificationHistoryEntry[];
|
||||
private filterTool: string = '';
|
||||
private filterType: 'all' | 'success' | 'error' = 'all';
|
||||
|
||||
constructor(app: App, history: NotificationHistoryEntry[]) {
|
||||
super(app);
|
||||
this.history = history;
|
||||
this.filteredHistory = [...history];
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
contentEl.addClass('mcp-notification-history-modal');
|
||||
|
||||
// Title
|
||||
contentEl.createEl('h2', { text: 'MCP Notification History' });
|
||||
|
||||
// Filters
|
||||
this.createFilters(contentEl);
|
||||
|
||||
// History list
|
||||
this.createHistoryList(contentEl);
|
||||
|
||||
// Actions
|
||||
this.createActions(contentEl);
|
||||
}
|
||||
|
||||
onClose() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create filter controls
|
||||
*/
|
||||
private createFilters(containerEl: HTMLElement): void {
|
||||
const filterContainer = containerEl.createDiv({ cls: 'mcp-history-filters' });
|
||||
filterContainer.style.marginBottom = '16px';
|
||||
filterContainer.style.display = 'flex';
|
||||
filterContainer.style.gap = '12px';
|
||||
filterContainer.style.flexWrap = 'wrap';
|
||||
|
||||
// Tool name filter
|
||||
const toolFilterContainer = filterContainer.createDiv();
|
||||
toolFilterContainer.createEl('label', { text: 'Tool: ' });
|
||||
const toolInput = toolFilterContainer.createEl('input', {
|
||||
type: 'text',
|
||||
placeholder: 'Filter by tool name...'
|
||||
});
|
||||
toolInput.style.marginLeft = '4px';
|
||||
toolInput.style.padding = '4px 8px';
|
||||
toolInput.addEventListener('input', (e) => {
|
||||
this.filterTool = (e.target as HTMLInputElement).value.toLowerCase();
|
||||
this.applyFilters();
|
||||
});
|
||||
|
||||
// Type filter
|
||||
const typeFilterContainer = filterContainer.createDiv();
|
||||
typeFilterContainer.createEl('label', { text: 'Type: ' });
|
||||
const typeSelect = typeFilterContainer.createEl('select');
|
||||
typeSelect.style.marginLeft = '4px';
|
||||
typeSelect.style.padding = '4px 8px';
|
||||
|
||||
const allOption = typeSelect.createEl('option', { text: 'All', value: 'all' });
|
||||
const successOption = typeSelect.createEl('option', { text: 'Success', value: 'success' });
|
||||
const errorOption = typeSelect.createEl('option', { text: 'Error', value: 'error' });
|
||||
|
||||
typeSelect.addEventListener('change', (e) => {
|
||||
this.filterType = (e.target as HTMLSelectElement).value as 'all' | 'success' | 'error';
|
||||
this.applyFilters();
|
||||
});
|
||||
|
||||
// Results count
|
||||
const countEl = filterContainer.createDiv({ cls: 'mcp-history-count' });
|
||||
countEl.style.marginLeft = 'auto';
|
||||
countEl.style.alignSelf = 'center';
|
||||
countEl.textContent = `${this.filteredHistory.length} entries`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create history list
|
||||
*/
|
||||
private createHistoryList(containerEl: HTMLElement): void {
|
||||
const listContainer = containerEl.createDiv({ cls: 'mcp-history-list' });
|
||||
listContainer.style.maxHeight = '400px';
|
||||
listContainer.style.overflowY = 'auto';
|
||||
listContainer.style.marginBottom = '16px';
|
||||
listContainer.style.border = '1px solid var(--background-modifier-border)';
|
||||
listContainer.style.borderRadius = '4px';
|
||||
|
||||
if (this.filteredHistory.length === 0) {
|
||||
const emptyEl = listContainer.createDiv({ cls: 'mcp-history-empty' });
|
||||
emptyEl.style.padding = '24px';
|
||||
emptyEl.style.textAlign = 'center';
|
||||
emptyEl.style.color = 'var(--text-muted)';
|
||||
emptyEl.textContent = 'No entries found';
|
||||
return;
|
||||
}
|
||||
|
||||
this.filteredHistory.forEach((entry, index) => {
|
||||
const entryEl = listContainer.createDiv({ cls: 'mcp-history-entry' });
|
||||
entryEl.style.padding = '12px';
|
||||
entryEl.style.borderBottom = index < this.filteredHistory.length - 1
|
||||
? '1px solid var(--background-modifier-border)'
|
||||
: 'none';
|
||||
|
||||
// Header row
|
||||
const headerEl = entryEl.createDiv({ cls: 'mcp-history-entry-header' });
|
||||
headerEl.style.display = 'flex';
|
||||
headerEl.style.justifyContent = 'space-between';
|
||||
headerEl.style.marginBottom = '8px';
|
||||
|
||||
// Tool name and status
|
||||
const titleEl = headerEl.createDiv();
|
||||
const statusIcon = entry.success ? '✅' : '❌';
|
||||
const toolName = titleEl.createEl('strong', { text: `${statusIcon} ${entry.toolName}` });
|
||||
toolName.style.color = entry.success ? 'var(--text-success)' : 'var(--text-error)';
|
||||
|
||||
// Timestamp and duration
|
||||
const metaEl = headerEl.createDiv();
|
||||
metaEl.style.fontSize = '0.85em';
|
||||
metaEl.style.color = 'var(--text-muted)';
|
||||
const timestamp = new Date(entry.timestamp).toLocaleTimeString();
|
||||
const durationStr = entry.duration ? ` • ${entry.duration}ms` : '';
|
||||
metaEl.textContent = `${timestamp}${durationStr}`;
|
||||
|
||||
// Arguments
|
||||
if (entry.args && Object.keys(entry.args).length > 0) {
|
||||
const argsEl = entryEl.createDiv({ cls: 'mcp-history-entry-args' });
|
||||
argsEl.style.fontSize = '0.85em';
|
||||
argsEl.style.fontFamily = 'monospace';
|
||||
argsEl.style.backgroundColor = 'var(--background-secondary)';
|
||||
argsEl.style.padding = '8px';
|
||||
argsEl.style.borderRadius = '4px';
|
||||
argsEl.style.marginBottom = '8px';
|
||||
argsEl.style.overflowX = 'auto';
|
||||
argsEl.textContent = JSON.stringify(entry.args, null, 2);
|
||||
}
|
||||
|
||||
// Error message
|
||||
if (!entry.success && entry.error) {
|
||||
const errorEl = entryEl.createDiv({ cls: 'mcp-history-entry-error' });
|
||||
errorEl.style.fontSize = '0.85em';
|
||||
errorEl.style.color = 'var(--text-error)';
|
||||
errorEl.style.backgroundColor = 'var(--background-secondary)';
|
||||
errorEl.style.padding = '8px';
|
||||
errorEl.style.borderRadius = '4px';
|
||||
errorEl.style.fontFamily = 'monospace';
|
||||
errorEl.textContent = entry.error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create action buttons
|
||||
*/
|
||||
private createActions(containerEl: HTMLElement): void {
|
||||
const actionsContainer = containerEl.createDiv({ cls: 'mcp-history-actions' });
|
||||
actionsContainer.style.display = 'flex';
|
||||
actionsContainer.style.gap = '8px';
|
||||
actionsContainer.style.justifyContent = 'flex-end';
|
||||
|
||||
// Export button
|
||||
const exportButton = actionsContainer.createEl('button', { text: 'Export to Clipboard' });
|
||||
exportButton.addEventListener('click', async () => {
|
||||
const exportData = JSON.stringify(this.filteredHistory, null, 2);
|
||||
await navigator.clipboard.writeText(exportData);
|
||||
// Show temporary success message
|
||||
exportButton.textContent = '✅ Copied!';
|
||||
setTimeout(() => {
|
||||
exportButton.textContent = 'Export to Clipboard';
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
// Close button
|
||||
const closeButton = actionsContainer.createEl('button', { text: 'Close' });
|
||||
closeButton.addEventListener('click', () => {
|
||||
this.close();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply filters to history
|
||||
*/
|
||||
private applyFilters(): void {
|
||||
this.filteredHistory = this.history.filter(entry => {
|
||||
// Tool name filter
|
||||
if (this.filterTool && !entry.toolName.toLowerCase().includes(this.filterTool)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Type filter
|
||||
if (this.filterType === 'success' && !entry.success) {
|
||||
return false;
|
||||
}
|
||||
if (this.filterType === 'error' && entry.success) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Re-render
|
||||
this.onOpen();
|
||||
}
|
||||
}
|
||||
232
src/ui/notifications.ts
Normal file
232
src/ui/notifications.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { App, Notice } from 'obsidian';
|
||||
import { MCPPluginSettings } from '../types/settings-types';
|
||||
|
||||
/**
|
||||
* Notification history entry
|
||||
*/
|
||||
export interface NotificationHistoryEntry {
|
||||
timestamp: number;
|
||||
toolName: string;
|
||||
args: any;
|
||||
success: boolean;
|
||||
duration?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool icon mapping
|
||||
*/
|
||||
const TOOL_ICONS: Record<string, string> = {
|
||||
read_note: '📖',
|
||||
read_excalidraw: '📖',
|
||||
create_note: '✏️',
|
||||
update_note: '✏️',
|
||||
update_frontmatter: '✏️',
|
||||
update_sections: '✏️',
|
||||
delete_note: '🗑️',
|
||||
rename_file: '📝',
|
||||
search: '🔍',
|
||||
search_waypoints: '🔍',
|
||||
list: '📋',
|
||||
list_notes: '📋',
|
||||
stat: '📊',
|
||||
exists: '📊',
|
||||
get_vault_info: 'ℹ️',
|
||||
get_folder_waypoint: '🗺️',
|
||||
is_folder_note: '📁',
|
||||
validate_wikilinks: '🔗',
|
||||
resolve_wikilink: '🔗',
|
||||
backlinks: '🔗'
|
||||
};
|
||||
|
||||
/**
|
||||
* Notification manager for MCP tool calls
|
||||
* Displays notifications in the Obsidian UI with rate limiting
|
||||
*/
|
||||
export class NotificationManager {
|
||||
private app: App;
|
||||
private settings: MCPPluginSettings;
|
||||
private history: NotificationHistoryEntry[] = [];
|
||||
private maxHistorySize = 100;
|
||||
|
||||
// Rate limiting
|
||||
private notificationQueue: Array<() => void> = [];
|
||||
private isProcessingQueue = false;
|
||||
private maxNotificationsPerSecond = 10;
|
||||
private notificationInterval = 1000 / this.maxNotificationsPerSecond; // 100ms between notifications
|
||||
|
||||
// Batching
|
||||
private pendingToolCalls: Map<string, number> = new Map();
|
||||
private batchTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(app: App, settings: MCPPluginSettings) {
|
||||
this.app = app;
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update settings reference
|
||||
*/
|
||||
updateSettings(settings: MCPPluginSettings): void {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show notification for tool call start
|
||||
*/
|
||||
showToolCall(toolName: string, args: any, duration?: number): void {
|
||||
if (!this.shouldShowNotification()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const icon = TOOL_ICONS[toolName] || '🔧';
|
||||
const argsStr = this.formatArgs(args);
|
||||
const message = `${icon} MCP: ${toolName}${argsStr}`;
|
||||
|
||||
this.queueNotification(() => {
|
||||
new Notice(message, duration || this.settings.notificationDuration);
|
||||
});
|
||||
|
||||
// Log to console if enabled
|
||||
if (this.settings.logToConsole) {
|
||||
console.log(`[MCP] Tool call: ${toolName}`, args);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Add entry to notification history
|
||||
*/
|
||||
addToHistory(entry: NotificationHistoryEntry): void {
|
||||
this.history.unshift(entry);
|
||||
|
||||
// Limit history size
|
||||
if (this.history.length > this.maxHistorySize) {
|
||||
this.history = this.history.slice(0, this.maxHistorySize);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get notification history
|
||||
*/
|
||||
getHistory(): NotificationHistoryEntry[] {
|
||||
return [...this.history];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear notification history
|
||||
*/
|
||||
clearHistory(): void {
|
||||
this.history = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all active notifications (not possible with Obsidian API)
|
||||
*/
|
||||
clearAll(): void {
|
||||
// Obsidian doesn't provide API to clear notices
|
||||
// This is a no-op for compatibility
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if notification should be shown
|
||||
*/
|
||||
private shouldShowNotification(): boolean {
|
||||
return this.settings.notificationsEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format arguments for display
|
||||
*/
|
||||
private formatArgs(args: any): string {
|
||||
if (!this.settings.showParameters) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!args || Object.keys(args).length === 0) {
|
||||
return '()';
|
||||
}
|
||||
|
||||
try {
|
||||
// Extract key parameters for display
|
||||
const keyParams: string[] = [];
|
||||
|
||||
if (args.path) {
|
||||
keyParams.push(`path: "${this.truncateString(args.path, 30)}"`);
|
||||
}
|
||||
if (args.query) {
|
||||
keyParams.push(`query: "${this.truncateString(args.query, 30)}"`);
|
||||
}
|
||||
if (args.folder) {
|
||||
keyParams.push(`folder: "${this.truncateString(args.folder, 30)}"`);
|
||||
}
|
||||
if (args.recursive !== undefined) {
|
||||
keyParams.push(`recursive: ${args.recursive}`);
|
||||
}
|
||||
|
||||
// If no key params, show first 50 chars of JSON
|
||||
if (keyParams.length === 0) {
|
||||
const json = JSON.stringify(args);
|
||||
return `(${this.truncateString(json, 50)})`;
|
||||
}
|
||||
|
||||
return `({ ${keyParams.join(', ')} })`;
|
||||
} catch (e) {
|
||||
return '(...)';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate string to max length
|
||||
*/
|
||||
private truncateString(str: string, maxLength: number): string {
|
||||
if (str.length <= maxLength) {
|
||||
return str;
|
||||
}
|
||||
return str.substring(0, maxLength - 3) + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue notification with rate limiting
|
||||
*/
|
||||
private queueNotification(notificationFn: () => void): void {
|
||||
this.notificationQueue.push(notificationFn);
|
||||
|
||||
if (!this.isProcessingQueue) {
|
||||
this.processQueue();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process notification queue with rate limiting
|
||||
*/
|
||||
private async processQueue(): Promise<void> {
|
||||
if (this.isProcessingQueue) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isProcessingQueue = true;
|
||||
|
||||
while (this.notificationQueue.length > 0) {
|
||||
const notificationFn = this.notificationQueue.shift();
|
||||
|
||||
if (notificationFn) {
|
||||
notificationFn();
|
||||
}
|
||||
|
||||
// Wait before processing next notification
|
||||
if (this.notificationQueue.length > 0) {
|
||||
await this.sleep(this.notificationInterval);
|
||||
}
|
||||
}
|
||||
|
||||
this.isProcessingQueue = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep helper
|
||||
*/
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user