29 KiB
Architecture Documentation
Technical overview of Sage Utils system design, patterns, and implementation details.
Table of Contents
- System Overview
- Module Structure
- Cross-Tab Messaging
- Event System
- Performance Optimizations
- Accessibility Architecture
- State Management
- Error Handling
- Testing Strategy
System Overview
High-Level Architecture
┌─────────────────────────────────────────────────────┐
│ ComfyUI Frontend │
├──────────────┬──────────────┬──────────────────────┤
│ Workflow │ Sidebar │ Gallery │
│ Canvas │ Tabs │ │
│ │ │ │
│ ├──────────────┤ │
│ │ LLM Tab │◄─────────────────────┤
│ │ │ Image Transfer │
│ ├──────────────┤ │
│ │Prompt Builder│ │
│ │ │ │
└──────────────┴──────┬───────┴──────────────────────┘
│
Cross-Tab Messaging (Event Bus)
│
┌─────────────────────┴──────────────────────────────┐
│ Backend API (Python/aiohttp) │
├────────────────────────────────────────────────────┤
│ LLM Routes │ Workflow Routes │ Other Routes │
└──────┬───────┴───────────────────┴─────────────────┘
│
┌──────┴─────────────────────────────────────────────┐
│ External LLM Providers │
├────────────────────────────────────────────────────┤
│ Ollama │ LM Studio │
└────────────────────────────────────────────────────┘
Technology Stack
Frontend:
- Pure JavaScript (ES6+)
- No frameworks (for lightweight integration)
- Native Web APIs (fetch, EventSource, FileReader)
- CSS3 for styling
Backend:
- Python 3.9+
- aiohttp (async web framework)
- Server-Sent Events (SSE) for streaming
Communication:
- REST API (JSON)
- SSE for real-time streaming
- Custom event bus for cross-tab messaging
Module Structure
Directory Layout
comfyui_sageutils/
├── js/ # Frontend JavaScript
│ ├── sidebar/ # Sidebar tab components
│ │ ├── llmTab.js # LLM chat interface (4317 lines)
│ │ ├── cacheSidebar.js # Main sidebar container
│ │ └── ...
│ ├── promptBuilder/ # Prompt builder components
│ │ ├── promptGeneration.js # Tag-based prompt UI
│ │ └── ...
│ ├── shared/ # Shared utilities
│ │ ├── crossTabMessaging.js # Event bus system
│ │ └── performanceUtils.js # Performance helpers
│ ├── gallery/ # Gallery integration
│ │ └── galleryEvents.js # Image transfer logic
│ └── llm/ # LLM client
│ └── llmApi.js # API wrapper
├── routes/ # Backend routes
│ ├── llm_routes.py # LLM endpoints (842 lines)
│ ├── base.py # Route helpers
│ └── ...
├── utils/ # Backend utilities
│ ├── llm_wrapper.py # LLM provider abstraction
│ └── settings.py # Configuration
├── nodes/ # ComfyUI custom nodes
├── assets/ # Static assets
│ ├── llm_prompts.json # Prompt templates
│ └── default_tag_library.json # Prompt builder tags
└── docs/ # Documentation
Module Dependencies
llmTab.js
├─ imports llmApi.js (API calls)
├─ imports crossTabMessaging.js (events)
└─ exports createLLMTab()
promptGeneration.js
├─ imports crossTabMessaging.js
└─ exports createPromptGenerationUI()
crossTabMessaging.js
├─ imports performanceUtils.js
└─ exports {
subscribe(), publish(),
sendTextToPromptBuilder(),
sendImageToLLM(),
showNotification()
}
performanceUtils.js
└─ exports {
debounce(), throttle(),
RateLimiter, BatchProcessor,
rafThrottle(), memoize(), lazy()
}
Key Design Principles
- Modularity: Each feature in its own module
- Loose Coupling: Event bus decouples components
- Single Responsibility: Each module has one clear purpose
- Progressive Enhancement: Features work independently
- Performance First: Optimizations built-in from start
Cross-Tab Messaging
Event Bus Architecture
The crossTabMessaging.js module implements a publish-subscribe event bus for cross-component communication.
// Architecture
┌──────────────┐ ┌──────────────────┐ ┌──────────────┐
│ Publisher │────────►│ Event Bus │────────►│ Subscriber │
│ (Gallery) │ publish │ (crossTabMsg.js) │subscribe│ (LLM Tab) │
└──────────────┘ └──────────────────┘ └──────────────┘
Message Types
// Image transfer (Gallery → LLM)
{
type: 'image-transfer', // MessageTypes.IMAGE_TRANSFER
data: {
images: [{ base64: '...', filename: '...', metadata: {...} }],
source: 'gallery',
autoSwitch: true // Optional: auto-switch to LLM tab
}
}
// Text transfer (LLM → Prompt Builder)
{
type: 'text-to-prompt-builder', // MessageTypes.TEXT_TO_PROMPT_BUILDER
data: {
text: 'enhanced prompt...',
source: 'llm',
autoSwitch: true, // Optional: auto-switch to Prompts tab
append: false // Optional: append vs replace
}
}
// Text transfer (Prompt Builder → LLM)
{
type: 'text-to-llm', // MessageTypes.TEXT_TO_LLM
data: {
text: 'prompt to enhance...',
source: 'prompts',
autoSwitch: true
}
}
// Tab switching request
{
type: 'tab-switch-request', // MessageTypes.TAB_SWITCH_REQUEST
data: {
tabId: 'llm' | 'prompts' | 'gallery' | 'models' | 'files' | 'search',
source: 'user-action',
metadata: {...} // Optional context
}
}
// Tab switched confirmation
{
type: 'tab-switched', // MessageTypes.TAB_SWITCHED
data: {
tabId: 'llm',
source: 'sidebar'
}
}
// Notifications
{
type: 'notification', // MessageTypes.NOTIFICATION
data: {
message: 'Image uploaded successfully',
type: 'success', // 'info' | 'success' | 'warning' | 'error'
duration: 3000 // Optional: milliseconds
}
}
// Image queue update
{
type: 'image-queue-update', // MessageTypes.IMAGE_QUEUE_UPDATE
data: {
count: 5,
source: 'llm'
}
}
// LLM state request/response
{
type: 'llm-state-request', // MessageTypes.LLM_STATE_REQUEST
data: { requestId: 'abc123' }
}
{
type: 'llm-state-response', // MessageTypes.LLM_STATE_RESPONSE
data: {
isGenerating: false,
model: 'gpt-4o',
provider: 'openai'
}
}
// LLM preset applied
{
type: 'llm-preset-applied', // MessageTypes.LLM_PRESET_APPLIED
data: {
presetId: 'creative-writer',
presetName: 'Creative Writer',
settings: { temperature: 0.9, ... }
}
}
Implementation
Getting Event Bus:
import { getEventBus, MessageTypes } from '../shared/crossTabMessaging.js';
const bus = getEventBus();
Publishing:
import { getEventBus, MessageTypes } from '../shared/crossTabMessaging.js';
const bus = getEventBus();
// Publish with rate limiting
bus.publish(MessageTypes.IMAGE_TRANSFER, {
images: [{ base64: imageData, filename: 'img.jpg' }],
source: 'gallery',
autoSwitch: true
});
// Ignore rate limiting (use sparingly - critical messages only)
bus.publish(MessageTypes.NOTIFICATION, {
message: 'Critical error occurred',
type: 'error'
}, { ignoreRateLimit: true });
Helper Functions (recommended):
import {
sendImageToLLM,
sendTextToPromptBuilder,
sendTextToLLM,
requestTabSwitch,
showNotification
} from '../shared/crossTabMessaging.js';
// Send image to LLM
await sendImageToLLM(imageBase64, 'photo.jpg', { autoSwitch: true });
// Send text to Prompt Builder
sendTextToPromptBuilder(promptText, {
source: 'llm',
append: false,
autoSwitch: true
});
// Send text to LLM
sendTextToLLM(promptText, { source: 'prompts', autoSwitch: true });
// Request tab switch
requestTabSwitch('llm', { source: 'button-click' });
// Show notification
showNotification('Operation complete', 'success', 3000);
Subscribing:
import { getEventBus, MessageTypes } from '../shared/crossTabMessaging.js';
const bus = getEventBus();
// Subscribe to message type
const unsubscribe = bus.subscribe(MessageTypes.IMAGE_TRANSFER, (message) => {
console.log('Received:', message.data);
console.log('Timestamp:', message.timestamp);
console.log('Type:', message.type);
// Process the images
const { images, source, autoSwitch } = message.data;
handleImages(images);
});
// Store unsubscribe for cleanup
state.unsubscribers = state.unsubscribers || [];
state.unsubscribers.push(unsubscribe);
// Later: cleanup
state.unsubscribers.forEach(unsub => unsub());
Rate Limiting
Rate limits prevent message flooding:
Rate Limits (per second):
- IMAGE_TRANSFER: 10 messages
- STATE_SYNC: 20 messages
- NOTIFICATION: 5 messages
- Default (others): No rate limit by default
Implementation uses sliding window algorithm in RateLimiter class:
class RateLimiter {
constructor(maxCalls, timeWindow) {
this.maxCalls = maxCalls; // Max calls allowed
this.timeWindow = timeWindow; // Time window in ms
this.calls = []; // Timestamp array
}
allowCall() {
const now = Date.now();
// Remove calls outside time window
this.calls = this.calls.filter(t => now - t < this.timeWindow);
if (this.calls.length < this.maxCalls) {
this.calls.push(now);
return true;
}
return false; // Rate limited
}
getWaitTime() {
if (this.calls.length === 0) return 0;
const oldest = this.calls[0];
return Math.max(0, this.timeWindow - (Date.now() - oldest));
}
}
Override Rate Limiting (use sparingly):
bus.publish(messageType, data, { ignoreRateLimit: true });
Available Sidebar Tabs
The sidebar contains multiple tabs that communicate via the event bus:
- Models Tab (
'models'): Browse and manage models/LoRAs with Civitai integration - Files Tab (
'files'): File browser and management system - Civitai Search Tab (
'search'): Search and download content from Civitai - Image Gallery Tab (
'gallery'): View and manage generated images - Prompt Builder Tab (
'prompts'): Wildcard-based prompt construction with tag library - LLM Tab (
'llm'): AI chat interface with vision support and conversation history
Tab IDs for requestTabSwitch():
'models','files','search','gallery','prompts','llm'
Event System
Event Flow
User Action
│
▼
UI Event Handler
│
▼
Business Logic
│
▼
API Call (if needed)
│
▼
Update Local State
│
▼
Publish Event (if cross-tab)
│
▼
Subscribers React
│
▼
Update UI
Event Handler Pattern
Standard Pattern:
async function handleUserAction(state, ui, data) {
// 1. Validate input
if (!validateInput(data)) {
showError('Invalid input');
return;
}
// 2. Update UI (loading state)
ui.button.disabled = true;
ui.spinner.style.display = 'block';
try {
// 3. Business logic / API call
const result = await performAction(data);
// 4. Update state
state.lastResult = result;
// 5. Update UI (success)
ui.output.textContent = result;
// 6. Publish event (if needed)
publish('ACTION_COMPLETE', { result });
// 7. Show feedback
showNotification('Action successful', 'success');
} catch (error) {
// 8. Error handling
console.error('Action failed:', error);
showNotification(error.message, 'error');
} finally {
// 9. Cleanup UI
ui.button.disabled = false;
ui.spinner.style.display = 'none';
}
}
Cleanup Pattern
Memory Leak Prevention:
function createComponent(container) {
const state = {
unsubscribers: [] // Track subscriptions
};
// Subscribe to events
const unsub1 = subscribe('EVENT_1', handler1);
const unsub2 = subscribe('EVENT_2', handler2);
state.unsubscribers.push(unsub1, unsub2);
// Return cleanup function
return {
destroy() {
// Unsubscribe from all events
state.unsubscribers.forEach(unsub => unsub());
// Clear references
state.unsubscribers = [];
// Remove DOM listeners
container.innerHTML = '';
}
};
}
Performance Optimizations
Debouncing
Delays execution until input stops:
import { debounce } from '../shared/performanceUtils.js';
// Create debounced function (300ms delay)
const debouncedUpdate = debounce((text) => {
updatePrompt(text);
}, 300);
// Use in input handler
textarea.addEventListener('input', (e) => {
debouncedUpdate(e.target.value);
// Only calls updatePrompt 300ms after user stops typing
});
Use Cases:
- Text input handlers
- Search fields
- Auto-save functionality
Throttling
Limits execution rate:
import { throttle } from '../shared/performanceUtils.js';
// Create throttled function (max once per 1000ms)
const throttledScroll = throttle(() => {
updateVisibleItems();
}, 1000);
// Use in scroll handler
window.addEventListener('scroll', throttledScroll);
// Only calls updateVisibleItems max once per second
Use Cases:
- Scroll handlers
- Resize handlers
- Mouse move tracking
Rate Limiting
Controls message frequency:
import { RateLimiter } from '../shared/performanceUtils.js';
// Create rate limiter (10 per second)
const limiter = new RateLimiter(10, 1000);
function sendMessage(data) {
if (!limiter.checkLimit()) {
const waitTime = limiter.getWaitTime();
console.warn(`Rate limited. Wait ${waitTime}ms`);
return false;
}
// Send message
publish('MESSAGE', data);
return true;
}
Use Cases:
- API calls
- Event publishing
- Resource-intensive operations
Batch Processing
Collects items and processes in batches:
import { BatchProcessor } from '../shared/performanceUtils.js';
const processor = new BatchProcessor(
async (items) => {
// Process batch of items
await api.processBatch(items);
},
{ maxSize: 50, maxDelay: 1000 }
);
// Add items individually
processor.add(item1);
processor.add(item2);
// ... automatically processes when:
// - 50 items collected, OR
// - 1000ms elapsed since first item
Use Cases:
- Bulk API requests
- Database inserts
- Log aggregation
Request Animation Frame Throttling
Syncs with browser rendering:
import { rafThrottle } from '../shared/performanceUtils.js';
const throttledAnimate = rafThrottle(() => {
updateAnimation();
});
// Call in game loop
function gameLoop() {
throttledAnimate();
requestAnimationFrame(gameLoop);
}
Use Cases:
- Animations
- Visual updates
- Canvas rendering
Memoization
Caches function results:
import { memoize } from '../shared/performanceUtils.js';
const expensiveComputation = memoize((input) => {
// Complex calculation
return result;
}, 100); // Cache up to 100 results
// First call: computes
const result1 = expensiveComputation(42);
// Second call with same input: returns cached
const result2 = expensiveComputation(42); // Instant!
Use Cases:
- Expensive calculations
- Repeated API calls
- Data transformations
Accessibility Architecture
WCAG 2.1 Level AA Compliance
The system implements comprehensive accessibility:
Principles:
├─ Perceivable
│ ├─ ARIA labels on all interactive elements
│ ├─ Live regions for dynamic content
│ └─ Semantic HTML structure
├─ Operable
│ ├─ Full keyboard navigation
│ ├─ Keyboard shortcuts (Ctrl+Enter, Escape)
│ └─ Focus management
├─ Understandable
│ ├─ Clear labels and instructions
│ ├─ Error messages and validation
│ └─ Consistent behavior
└─ Robust
├─ Valid HTML/ARIA
├─ Screen reader compatible
└─ Cross-browser support
ARIA Implementation
Interactive Elements:
// Buttons
button.setAttribute('aria-label', 'Send prompt to LLM');
// Inputs
input.setAttribute('aria-label', 'Prompt text');
input.setAttribute('aria-describedby', 'prompt-help');
// Containers
section.setAttribute('role', 'region');
section.setAttribute('aria-label', 'LLM Response');
Live Regions:
// Dynamic content updates
responseDiv.setAttribute('role', 'log');
responseDiv.setAttribute('aria-live', 'polite');
responseDiv.setAttribute('aria-atomic', 'true');
// Notifications
notificationDiv.setAttribute('role', 'status');
notificationDiv.setAttribute('aria-live', 'assertive');
Keyboard Navigation:
// Tab order
element.setAttribute('tabindex', '0'); // Focusable
// Keyboard shortcuts
textarea.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 'Enter') {
e.preventDefault();
handleSubmit();
}
if (e.key === 'Escape') {
e.target.blur();
}
});
Screen Reader Support
Announcements:
function announceToScreenReader(message, priority = 'polite') {
const liveRegion = document.getElementById('sr-announce');
liveRegion.setAttribute('aria-live', priority);
liveRegion.textContent = message;
// Clear after announcement
setTimeout(() => {
liveRegion.textContent = '';
}, 1000);
}
// Usage
announceToScreenReader('Image uploaded successfully');
State Management
State Pattern
Each component maintains its own state:
function createComponent() {
// Component state
const state = {
// Data
model: null,
provider: null,
generating: false,
images: [],
// Settings
settings: {
temperature: 0.7,
maxTokens: 2048
},
// Lifecycle
unsubscribers: [],
initialized: false
};
// State modifiers
function setState(updates) {
Object.assign(state, updates);
render(state);
}
// Component logic...
return { state, setState, destroy };
}
State Updates
Immutable Pattern (for complex objects):
// DON'T mutate directly
state.settings.temperature = 0.8; // ❌
// DO create new object
setState({
settings: {
...state.settings,
temperature: 0.8
}
}); // ✅
Simple Updates:
// For simple values, direct assignment is fine
state.generating = true;
state.model = 'llama3.2';
State Persistence
Session Storage:
// Save state
function saveState(key, state) {
try {
sessionStorage.setItem(key, JSON.stringify(state));
} catch (error) {
console.error('Failed to save state:', error);
}
}
// Load state
function loadState(key, defaultState) {
try {
const saved = sessionStorage.getItem(key);
return saved ? JSON.parse(saved) : defaultState;
} catch (error) {
console.error('Failed to load state:', error);
return defaultState;
}
}
Error Handling
Error Hierarchy
Error Types:
├─ Validation Errors (4xx)
│ ├─ Invalid input
│ ├─ Missing required fields
│ └─ Format errors
├─ Service Errors (5xx)
│ ├─ API failures
│ ├─ Provider unavailable
│ └─ Generation errors
└─ Client Errors
├─ Network failures
├─ Parse errors
└─ Unexpected errors
Error Handling Pattern
async function apiCall(endpoint, data) {
try {
// Make request
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
// Parse response
const json = await response.json();
// Check success
if (!json.success) {
// API returned error
throw new APIError(json.error, response.status);
}
return json;
} catch (error) {
// Network error or API error
if (error instanceof APIError) {
// Known API error
handleAPIError(error);
} else if (error instanceof TypeError) {
// Network error
handleNetworkError(error);
} else {
// Unexpected error
handleUnexpectedError(error);
}
throw error; // Re-throw for caller
}
}
class APIError extends Error {
constructor(message, status) {
super(message);
this.name = 'APIError';
this.status = status;
}
}
User-Friendly Errors
function getUserFriendlyError(error) {
// Map technical errors to user-friendly messages
const errorMap = {
'ECONNREFUSED': 'Cannot connect to service. Is it running?',
'Model not found': 'Selected model is not available. Please choose another.',
'Invalid API key': 'API key is incorrect. Please check your settings.',
'429': 'Rate limit exceeded. Please wait a moment and try again.'
};
// Check error patterns
for (const [pattern, message] of Object.entries(errorMap)) {
if (error.message.includes(pattern) || error.status?.toString() === pattern) {
return message;
}
}
// Default message
return 'An error occurred. Please try again.';
}
Testing Strategy
Testing Pyramid
┌───────────────┐
│ E2E Tests │ ← Manual/automated UI tests
├───────────────┤
│ Integration │ ← API endpoint tests
├───────────────┤
│ Unit Tests │ ← Function/module tests
└───────────────┘
Unit Testing
Example (using Jest):
import { debounce } from '../performanceUtils.js';
describe('debounce', () => {
jest.useFakeTimers();
it('should delay execution', () => {
const fn = jest.fn();
const debounced = debounce(fn, 300);
debounced();
expect(fn).not.toHaveBeenCalled();
jest.advanceTimersByTime(300);
expect(fn).toHaveBeenCalledTimes(1);
});
it('should cancel previous call', () => {
const fn = jest.fn();
const debounced = debounce(fn, 300);
debounced();
debounced();
debounced();
jest.advanceTimersByTime(300);
expect(fn).toHaveBeenCalledTimes(1);
});
});
Integration Testing
Example (API endpoint):
import pytest
from aiohttp import web
from routes.llm_routes import register_routes
@pytest.fixture
async def client(aiohttp_client):
app = web.Application()
routes = web.RouteTableDef()
register_routes(routes)
app.add_routes(routes)
return await aiohttp_client(app)
async def test_get_models(client):
resp = await client.get('/sage_llm/models')
assert resp.status == 200
data = await resp.json()
assert data['success'] is True
assert 'models' in data
Manual Testing Checklist
LLM Tab:
- Provider selection works
- Model loading works
- Text generation succeeds
- Vision generation with images works
- Streaming displays properly
- Error handling shows messages
- Keyboard shortcuts work
- Screen reader announces updates
Prompt Builder:
- Tag selection updates prompt
- Send to LLM transfers text
- Receive from LLM updates field
- Keyboard shortcuts work
- Validation shows errors
Cross-Tab:
- Gallery to LLM image transfer
- LLM to Prompt Builder text transfer
- Notifications appear
- Rate limiting prevents flooding
Security Considerations
Input Validation
Frontend:
function validateImageFile(file) {
// Check type
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
if (!allowedTypes.includes(file.type.toLowerCase())) {
return { error: 'Unsupported format' };
}
// Check size
const maxSize = 10 * 1024 * 1024; // 10MB
if (file.size > maxSize) {
return { error: 'File too large' };
}
return null; // Valid
}
Backend:
def validate_request_data(data):
"""Validate incoming request data"""
# Check required fields
required = ['provider', 'model', 'prompt']
for field in required:
if field not in data:
raise ValueError(f'Missing required field: {field}')
# Validate types
if not isinstance(data['prompt'], str):
raise ValueError('Prompt must be a string')
# Validate ranges
if 'temperature' in data:
temp = data['temperature']
if not (0.0 <= temp <= 2.0):
raise ValueError('Temperature must be 0.0-2.0')
return True
API Key Security
// ✅ DO: Store in backend, send via secure headers
const response = await fetch('/sage_llm/generate', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
// API key handled server-side
},
body: JSON.stringify({ provider, model, prompt })
});
// ❌ DON'T: Include API keys in frontend code
const apiKey = 'sk-...'; // Never do this!
Content Security
// ✅ DO: Sanitize user input before display
function sanitizeHTML(html) {
const div = document.createElement('div');
div.textContent = html; // Sets as text, not HTML
return div.innerHTML;
}
responseDiv.textContent = sanitizedResponse; // Safe
// ❌ DON'T: Insert unsanitized content
responseDiv.innerHTML = userInput; // XSS vulnerability!
Performance Metrics
Key Metrics
Frontend:
- Time to Interactive (TTI): < 2s
- First Contentful Paint (FCP): < 1s
- Input Latency: < 100ms
- Memory Usage: < 50MB per tab
Backend:
- API Response Time: < 100ms (non-LLM)
- LLM First Token: < 500ms
- Throughput: 100 req/s (non-streaming)
- Memory: < 200MB idle
Monitoring
// Performance monitoring
const perfObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log('Performance:', entry.name, entry.duration);
}
});
perfObserver.observe({ entryTypes: ['measure'] });
// Mark key events
performance.mark('generation-start');
await generateText(prompt);
performance.mark('generation-end');
performance.measure('generation', 'generation-start', 'generation-end');
Future Enhancements
Planned Features
- Offline Support: Service workers for caching
- WebSocket: Replace SSE for bidirectional communication
- Web Workers: Offload heavy computation
- IndexedDB: Client-side data persistence
- Progressive Web App: Installable experience
Scalability Considerations
- Horizontal Scaling: Multiple ComfyUI instances
- Load Balancing: Distribute LLM requests
- Caching: Redis for shared state
- Queue System: Background job processing
Related Documentation
- API Documentation - Complete API reference
- LLM Tab Guide - User guide
- Prompt Builder Guide - Feature guide
Architecture Version: 1.0
Last Updated: Phase 10 Documentation