diff --git a/apps/client/src/widgets/llm_chat/communication.ts b/apps/client/src/widgets/llm_chat/communication.ts index ae231ca20..6281791af 100644 --- a/apps/client/src/widgets/llm_chat/communication.ts +++ b/apps/client/src/widgets/llm_chat/communication.ts @@ -48,6 +48,9 @@ export async function checkSessionExists(noteId: string): Promise { * @param onContentUpdate - Callback for content updates * @param onThinkingUpdate - Callback for thinking updates * @param onToolExecution - Callback for tool execution + * @param onProgressUpdate - Callback for progress updates + * @param onUserInteraction - Callback for user interaction requests + * @param onErrorRecovery - Callback for error recovery options * @param onComplete - Callback for completion * @param onError - Callback for errors */ @@ -57,6 +60,9 @@ export async function setupStreamingResponse( onContentUpdate: (content: string, isDone?: boolean) => void, onThinkingUpdate: (thinking: string) => void, onToolExecution: (toolData: any) => void, + onProgressUpdate: (progressData: any) => void, + onUserInteraction: (interactionData: any) => Promise, + onErrorRecovery: (errorData: any) => Promise, onComplete: () => void, onError: (error: Error) => void ): Promise { @@ -177,6 +183,28 @@ export async function setupStreamingResponse( onToolExecution(message.toolExecution); } + // Handle progress updates + if (message.progressUpdate) { + console.log(`[${responseId}] Progress update:`, message.progressUpdate); + onProgressUpdate(message.progressUpdate); + } + + // Handle user interaction requests + if (message.userInteraction) { + console.log(`[${responseId}] User interaction request:`, message.userInteraction); + onUserInteraction(message.userInteraction).catch(error => { + console.error(`[${responseId}] Error handling user interaction:`, error); + }); + } + + // Handle error recovery options + if (message.errorRecovery) { + console.log(`[${responseId}] Error recovery options:`, message.errorRecovery); + onErrorRecovery(message.errorRecovery).catch(error => { + console.error(`[${responseId}] Error handling error recovery:`, error); + }); + } + // Handle content updates if (message.content) { // Simply append the new content - no complex deduplication @@ -258,3 +286,54 @@ export async function getDirectResponse(noteId: string, messageParams: any): Pro } } +/** + * Send user interaction response + * @param interactionId - The interaction ID + * @param response - The user's response + */ +export async function sendUserInteractionResponse(interactionId: string, response: string): Promise { + try { + await server.post(`llm/interactions/${interactionId}/respond`, { + response: response + }); + console.log(`User interaction response sent: ${interactionId} -> ${response}`); + } catch (error) { + console.error('Error sending user interaction response:', error); + throw error; + } +} + +/** + * Send error recovery choice + * @param sessionId - The chat session ID + * @param errorId - The error ID + * @param action - The recovery action chosen + * @param parameters - Optional parameters for the action + */ +export async function sendErrorRecoveryChoice(sessionId: string, errorId: string, action: string, parameters?: any): Promise { + try { + await server.post(`llm/chat/${sessionId}/error/${errorId}/recover`, { + action: action, + parameters: parameters + }); + console.log(`Error recovery choice sent: ${errorId} -> ${action}`); + } catch (error) { + console.error('Error sending error recovery choice:', error); + throw error; + } +} + +/** + * Cancel ongoing operations + * @param sessionId - The chat session ID + */ +export async function cancelChatOperations(sessionId: string): Promise { + try { + await server.post(`llm/chat/${sessionId}/cancel`, {}); + console.log(`Chat operations cancelled for session: ${sessionId}`); + } catch (error) { + console.error('Error cancelling chat operations:', error); + throw error; + } +} + diff --git a/apps/client/src/widgets/llm_chat/enhanced_components.css b/apps/client/src/widgets/llm_chat/enhanced_components.css new file mode 100644 index 000000000..210590f37 --- /dev/null +++ b/apps/client/src/widgets/llm_chat/enhanced_components.css @@ -0,0 +1,968 @@ +/* Enhanced LLM Chat Components CSS */ + +/* ======================= + PROGRESS INDICATOR STYLES + ======================= */ + +.llm-progress-container { + background: var(--main-background-color); + border: 1px solid var(--main-border-color); + border-radius: 8px; + margin: 10px 0; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + transition: all 0.3s ease; +} + +.llm-progress-container.fade-in { + opacity: 1; + transform: translateY(0); +} + +.llm-progress-container.fade-out { + opacity: 0; + transform: translateY(-10px); +} + +.llm-progress-header { + padding: 15px 20px 10px; + border-bottom: 1px solid var(--main-border-color); +} + +.llm-progress-title { + font-size: 16px; + font-weight: 600; + color: var(--main-text-color); + margin-bottom: 10px; +} + +.llm-progress-overall { + display: flex; + align-items: center; + gap: 10px; +} + +.llm-progress-bar-container { + flex: 1; + height: 8px; + background: var(--accented-background-color); + border-radius: 4px; + overflow: hidden; +} + +.llm-progress-bar-fill { + height: 100%; + background: linear-gradient(90deg, var(--accent-color), var(--accent-color-darker)); + border-radius: 4px; + transition: width 0.3s ease; +} + +.llm-progress-percentage { + font-size: 14px; + font-weight: 500; + color: var(--muted-text-color); + min-width: 40px; + text-align: right; +} + +.llm-progress-stages { + padding: 15px 20px; + max-height: 300px; + overflow-y: auto; +} + +.llm-progress-stage { + margin-bottom: 15px; + transition: all 0.3s ease; +} + +.llm-progress-stage:last-child { + margin-bottom: 0; +} + +.stage-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 8px; +} + +.stage-status-icon { + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; +} + +.stage-label { + flex: 1; + font-size: 14px; + font-weight: 500; + color: var(--main-text-color); +} + +.stage-timing { + font-size: 12px; + color: var(--muted-text-color); + min-width: 40px; + text-align: right; +} + +.stage-progress { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 5px; +} + +.stage-progress-bar { + flex: 1; + height: 6px; + background: var(--accented-background-color); + border-radius: 3px; + overflow: hidden; +} + +.stage-progress-fill { + height: 100%; + background: var(--accent-color); + border-radius: 3px; + transition: width 0.3s ease; +} + +.stage-progress-text { + font-size: 12px; + color: var(--muted-text-color); + min-width: 35px; + text-align: right; +} + +.stage-message { + font-size: 12px; + color: var(--muted-text-color); + margin-left: 30px; + font-style: italic; +} + +/* Stage status styles */ +.stage-pending .stage-progress-fill { + background: var(--muted-text-color); +} + +.stage-running .stage-progress-fill { + background: var(--accent-color); +} + +.stage-completed .stage-progress-fill { + background: #28a745; +} + +.stage-failed .stage-progress-fill { + background: #dc3545; +} + +.llm-progress-footer { + padding: 10px 20px 15px; + border-top: 1px solid var(--main-border-color); + display: flex; + justify-content: space-between; + align-items: center; +} + +.llm-progress-time-info { + display: flex; + gap: 20px; + font-size: 12px; + color: var(--muted-text-color); +} + +.llm-progress-cancel-btn { + background: #dc3545; + color: white; + border: none; + padding: 6px 12px; + border-radius: 4px; + font-size: 12px; + cursor: pointer; + transition: background 0.2s ease; +} + +.llm-progress-cancel-btn:hover { + background: #c82333; +} + +.llm-progress-cancel-btn:disabled { + background: var(--muted-text-color); + cursor: not-allowed; +} + +/* ======================= + USER INTERACTION STYLES + ======================= */ + +.llm-interaction-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + opacity: 0; + transition: opacity 0.3s ease; +} + +.llm-interaction-overlay.show { + opacity: 1; +} + +.llm-interaction-modal-container { + max-width: 90vw; + max-height: 90vh; + overflow: auto; +} + +.llm-interaction-modal { + background: var(--main-background-color); + border-radius: 8px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); + transform: translateY(-20px); + opacity: 0; + transition: all 0.3s ease; + min-width: 400px; + max-width: 600px; +} + +.llm-interaction-modal.show { + transform: translateY(0); + opacity: 1; +} + +.modal-header { + padding: 20px 20px 15px; + border-bottom: 1px solid var(--main-border-color); + display: flex; + justify-content: space-between; + align-items: center; +} + +.modal-header.risk-high { + background: linear-gradient(135deg, #dc3545, #c82333); + color: white; +} + +.modal-header.risk-medium { + background: linear-gradient(135deg, #ffc107, #e0a800); + color: #212529; +} + +.modal-header.risk-low { + background: linear-gradient(135deg, #28a745, #1e7e34); + color: white; +} + +.modal-title { + display: flex; + align-items: center; + gap: 10px; + font-size: 18px; + font-weight: 600; + flex: 1; +} + +.risk-indicator { + display: flex; + align-items: center; + gap: 5px; +} + +.risk-label { + background: rgba(255, 255, 255, 0.2); + padding: 2px 8px; + border-radius: 12px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.5px; +} + +.modal-body { + padding: 20px; +} + +.tool-info { + margin-bottom: 15px; +} + +.tool-name { + font-size: 16px; + font-weight: 600; + color: var(--accent-color); + margin-bottom: 5px; +} + +.tool-description { + font-size: 14px; + color: var(--muted-text-color); + margin-bottom: 10px; +} + +.tool-arguments { + background: var(--accented-background-color); + border-radius: 6px; + padding: 12px; + margin-bottom: 15px; +} + +.arguments-label { + font-size: 12px; + font-weight: 600; + color: var(--muted-text-color); + margin-bottom: 8px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.arguments-content { + font-family: 'Courier New', monospace; + font-size: 12px; +} + +.argument-item { + margin-bottom: 5px; + display: flex; + gap: 8px; +} + +.argument-key { + color: var(--accent-color); + font-weight: 600; + min-width: 80px; +} + +.argument-value { + color: var(--main-text-color); + word-break: break-all; +} + +.no-arguments { + color: var(--muted-text-color); + font-style: italic; +} + +.confirmation-message, +.choice-message, +.input-message { + font-size: 14px; + color: var(--main-text-color); + line-height: 1.5; + margin-bottom: 15px; +} + +.choice-options { + margin: 15px 0; +} + +.choice-option { + background: var(--accented-background-color); + border: 2px solid transparent; + border-radius: 6px; + padding: 12px; + margin-bottom: 8px; + cursor: pointer; + transition: all 0.2s ease; +} + +.choice-option:hover { + border-color: var(--accent-color); + background: var(--hover-item-background-color); +} + +.option-label { + font-weight: 600; + color: var(--main-text-color); + margin-bottom: 4px; +} + +.option-description { + font-size: 12px; + color: var(--muted-text-color); +} + +.input-field { + margin: 15px 0; +} + +.input-field input { + width: 100%; + padding: 10px; + border: 1px solid var(--main-border-color); + border-radius: 4px; + font-size: 14px; + background: var(--main-background-color); + color: var(--main-text-color); +} + +.input-field input:focus { + outline: none; + border-color: var(--accent-color); + box-shadow: 0 0 0 2px rgba(var(--accent-color-rgb), 0.2); +} + +.timeout-indicator { + background: var(--accented-background-color); + border-radius: 6px; + padding: 10px; + margin-top: 15px; + display: flex; + align-items: center; + gap: 10px; +} + +.timeout-label { + font-size: 12px; + color: var(--muted-text-color); + font-weight: 500; +} + +.timeout-countdown { + flex: 1; + display: flex; + align-items: center; + gap: 8px; +} + +.countdown-bar { + flex: 1; + height: 4px; + background: var(--main-border-color); + border-radius: 2px; + overflow: hidden; +} + +.countdown-fill { + height: 100%; + background: #ffc107; + border-radius: 2px; + transition: width 0.1s linear; +} + +.countdown-text { + font-size: 12px; + font-weight: 600; + color: var(--accent-color); + min-width: 30px; + text-align: right; +} + +.modal-footer { + padding: 15px 20px 20px; + border-top: 1px solid var(--main-border-color); + display: flex; + gap: 10px; + justify-content: flex-end; +} + +.btn { + padding: 8px 16px; + border: none; + border-radius: 4px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} + +.btn-primary { + background: var(--accent-color); + color: white; +} + +.btn-primary:hover { + background: var(--accent-color-darker); +} + +.btn-secondary { + background: var(--muted-text-color); + color: white; +} + +.btn-secondary:hover { + background: var(--main-text-color); +} + +.btn-warning { + background: #ffc107; + color: #212529; +} + +.btn-warning:hover { + background: #e0a800; +} + +.btn-danger { + background: #dc3545; + color: white; +} + +.btn-danger:hover { + background: #c82333; +} + +/* ======================= + ERROR RECOVERY STYLES + ======================= */ + +.llm-error-recovery-container { + margin: 15px 0; +} + +.llm-error-recovery-item { + background: var(--main-background-color); + border: 2px solid #dc3545; + border-radius: 8px; + margin-bottom: 15px; + box-shadow: 0 2px 8px rgba(220, 53, 69, 0.1); + transition: all 0.3s ease; +} + +.llm-error-recovery-item.fade-out { + opacity: 0; + transform: translateX(-20px); +} + +.error-header { + background: linear-gradient(135deg, #dc3545, #c82333); + color: white; + padding: 15px 20px; + display: flex; + align-items: center; + gap: 15px; + border-radius: 6px 6px 0 0; +} + +.error-icon { + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.2); + border-radius: 50%; +} + +.error-title { + flex: 1; +} + +.error-tool-name { + font-size: 16px; + font-weight: 600; + margin-bottom: 2px; +} + +.error-attempt-info { + font-size: 12px; + opacity: 0.9; +} + +.error-type-badge { + padding: 4px 8px; + border-radius: 12px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.5px; + text-transform: uppercase; +} + +.badge-warning { + background: #ffc107; + color: #212529; +} + +.badge-danger { + background: rgba(255, 255, 255, 0.3); + color: white; +} + +.badge-info { + background: #17a2b8; + color: white; +} + +.badge-secondary { + background: rgba(255, 255, 255, 0.2); + color: white; +} + +.error-body { + padding: 20px; +} + +.error-message { + margin-bottom: 15px; +} + +.error-message-label { + font-size: 12px; + font-weight: 600; + color: var(--muted-text-color); + margin-bottom: 5px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.error-message-content { + background: var(--accented-background-color); + border-left: 4px solid #dc3545; + padding: 10px 12px; + border-radius: 0 4px 4px 0; + font-size: 14px; + color: var(--main-text-color); + line-height: 1.4; +} + +.error-context { + margin-bottom: 15px; +} + +.context-section { + margin-bottom: 12px; +} + +.context-label { + font-size: 12px; + font-weight: 600; + color: var(--muted-text-color); + margin-bottom: 5px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.context-content { + background: var(--accented-background-color); + padding: 8px 12px; + border-radius: 4px; + font-size: 12px; +} + +.param-item { + display: flex; + gap: 8px; + margin-bottom: 4px; +} + +.param-key { + color: var(--accent-color); + font-weight: 600; + min-width: 100px; +} + +.param-value { + color: var(--main-text-color); + word-break: break-all; +} + +.previous-attempts-list, +.suggestions-list { + margin: 0; + padding-left: 16px; +} + +.previous-attempts-list li, +.suggestions-list li { + margin-bottom: 4px; + color: var(--main-text-color); +} + +.auto-retry-section { + background: linear-gradient(135deg, #ffc107, #e0a800); + color: #212529; + padding: 12px; + border-radius: 6px; + margin-bottom: 15px; +} + +.auto-retry-info { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; + font-size: 14px; + font-weight: 500; +} + +.retry-countdown { + font-weight: 700; + color: #dc3545; +} + +.auto-retry-progress { + margin-bottom: 10px; +} + +.retry-progress-bar { + height: 6px; + background: rgba(33, 37, 41, 0.2); + border-radius: 3px; + overflow: hidden; +} + +.retry-progress-fill { + height: 100%; + background: #dc3545; + border-radius: 3px; + transition: width 1s linear; +} + +.cancel-auto-retry { + background: rgba(33, 37, 41, 0.8); + color: white; + border: none; + padding: 4px 8px; + border-radius: 3px; + font-size: 11px; + cursor: pointer; +} + +.cancel-auto-retry:hover { + background: #212529; +} + +.recovery-actions { + margin-top: 15px; +} + +.recovery-actions-label { + font-size: 14px; + font-weight: 600; + color: var(--main-text-color); + margin-bottom: 10px; +} + +.recovery-actions-grid { + display: grid; + gap: 8px; +} + +.recovery-action { + background: var(--accented-background-color); + border: 2px solid transparent; + border-radius: 6px; + padding: 12px; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: center; + gap: 12px; +} + +.recovery-action:hover { + border-color: var(--accent-color); + background: var(--hover-item-background-color); +} + +.action-retry:hover { + border-color: #28a745; +} + +.action-skip:hover { + border-color: #6c757d; +} + +.action-modify:hover { + border-color: #ffc107; +} + +.action-abort:hover { + border-color: #dc3545; +} + +.action-alternative:hover { + border-color: #17a2b8; +} + +.action-icon { + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; + background: var(--accent-color); + color: white; + border-radius: 50%; + font-size: 14px; +} + +.action-retry .action-icon { + background: #28a745; +} + +.action-skip .action-icon { + background: #6c757d; +} + +.action-modify .action-icon { + background: #ffc107; + color: #212529; +} + +.action-abort .action-icon { + background: #dc3545; +} + +.action-alternative .action-icon { + background: #17a2b8; +} + +.action-content { + flex: 1; +} + +.action-label { + font-size: 14px; + font-weight: 600; + color: var(--main-text-color); + margin-bottom: 2px; +} + +.action-description { + font-size: 12px; + color: var(--muted-text-color); + line-height: 1.3; +} + +.action-arrow { + color: var(--muted-text-color); + opacity: 0; + transition: all 0.2s ease; +} + +.recovery-action:hover .action-arrow { + opacity: 1; + transform: translateX(5px); +} + +/* ======================= + RESPONSIVE DESIGN + ======================= */ + +@media (max-width: 768px) { + .llm-interaction-modal { + min-width: auto; + width: 90vw; + margin: 20px; + } + + .modal-header { + padding: 15px; + flex-direction: column; + gap: 10px; + align-items: flex-start; + } + + .modal-body { + padding: 15px; + } + + .modal-footer { + padding: 15px; + flex-direction: column; + gap: 8px; + } + + .btn { + width: 100%; + justify-content: center; + } + + .llm-progress-header, + .llm-progress-stages, + .llm-progress-footer { + padding-left: 15px; + padding-right: 15px; + } + + .llm-progress-footer { + flex-direction: column; + gap: 10px; + align-items: stretch; + } + + .recovery-actions-grid { + grid-template-columns: 1fr; + } +} + +/* ======================= + DARK MODE ADJUSTMENTS + ======================= */ + +@media (prefers-color-scheme: dark) { + .llm-interaction-overlay { + background: rgba(0, 0, 0, 0.7); + } + + .countdown-fill { + background: #f39c12; + } + + .auto-retry-section { + background: linear-gradient(135deg, #f39c12, #d68910); + color: #212529; + } +} + +/* ======================= + ANIMATIONS + ======================= */ + +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.7; + } +} + +.stage-running .stage-status-icon i { + animation: pulse 1.5s ease-in-out infinite; +} + +@keyframes slideInUp { + from { + transform: translateY(20px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +.llm-error-recovery-item { + animation: slideInUp 0.3s ease-out; +} + +@keyframes shimmer { + 0% { + background-position: -200px 0; + } + 100% { + background-position: calc(200px + 100%) 0; + } +} + +.stage-running .stage-progress-fill { + background: linear-gradient( + 90deg, + var(--accent-color) 0%, + var(--accent-color-lighter) 50%, + var(--accent-color) 100% + ); + background-size: 200px 100%; + animation: shimmer 2s infinite; +} \ No newline at end of file diff --git a/apps/client/src/widgets/llm_chat/error_recovery_manager.ts b/apps/client/src/widgets/llm_chat/error_recovery_manager.ts new file mode 100644 index 000000000..e31a2b0b4 --- /dev/null +++ b/apps/client/src/widgets/llm_chat/error_recovery_manager.ts @@ -0,0 +1,451 @@ +interface ErrorRecoveryOptions { + errorId: string; + toolName: string; + message: string; + errorType: string; + attempt: number; + maxAttempts: number; + recoveryActions: Array<{ + id: string; + label: string; + description?: string; + action: 'retry' | 'skip' | 'modify' | 'abort' | 'alternative'; + parameters?: Record; + }>; + autoRetryIn?: number; // seconds + context?: { + originalParams?: Record; + previousAttempts?: string[]; + suggestions?: string[]; + }; +} + +interface ErrorRecoveryResponse { + errorId: string; + action: string; + parameters?: Record; + timestamp: number; +} + +/** + * Error Recovery Manager for LLM Chat + * Handles sophisticated error recovery with multiple strategies and user guidance + */ +export class ErrorRecoveryManager { + private activeErrors: Map = new Map(); + private responseCallbacks: Map void> = new Map(); + private container: HTMLElement; + + constructor(parentElement: HTMLElement) { + this.container = this.createErrorContainer(); + parentElement.appendChild(this.container); + } + + /** + * Create error recovery container + */ + private createErrorContainer(): HTMLElement { + const container = document.createElement('div'); + container.className = 'llm-error-recovery-container'; + container.style.display = 'none'; + return container; + } + + /** + * Show error recovery options + */ + public async showErrorRecovery(options: ErrorRecoveryOptions): Promise { + this.activeErrors.set(options.errorId, options); + + return new Promise((resolve) => { + this.responseCallbacks.set(options.errorId, resolve); + + const errorElement = this.createErrorElement(options); + this.container.appendChild(errorElement); + this.container.style.display = 'block'; + + // Start auto-retry countdown if enabled + if (options.autoRetryIn && options.autoRetryIn > 0) { + this.startAutoRetryCountdown(options); + } + }); + } + + /** + * Create error recovery element + */ + private createErrorElement(options: ErrorRecoveryOptions): HTMLElement { + const element = document.createElement('div'); + element.className = 'llm-error-recovery-item'; + element.setAttribute('data-error-id', options.errorId); + + element.innerHTML = ` +
+
+ +
+
+
${options.toolName} Failed
+
Attempt ${options.attempt}/${options.maxAttempts}
+
+
+ ${options.errorType} +
+
+ +
+
+
Error Details:
+
${options.message}
+
+ + ${this.createContextSection(options.context)} + ${this.createAutoRetrySection(options.autoRetryIn)} + +
+
Recovery Options:
+
+ ${this.createRecoveryActions(options)} +
+
+
+ `; + + this.attachErrorEvents(element, options); + return element; + } + + /** + * Create context section + */ + private createContextSection(context?: ErrorRecoveryOptions['context']): string { + if (!context) return ''; + + return ` +
+ ${context.originalParams ? ` +
+
Original Parameters:
+
+ ${this.formatParameters(context.originalParams)} +
+
+ ` : ''} + + ${context.previousAttempts && context.previousAttempts.length > 0 ? ` +
+
Previous Attempts:
+
+
    + ${context.previousAttempts.map(attempt => `
  • ${attempt}
  • `).join('')} +
+
+
+ ` : ''} + + ${context.suggestions && context.suggestions.length > 0 ? ` +
+
Suggestions:
+
+
    + ${context.suggestions.map(suggestion => `
  • ${suggestion}
  • `).join('')} +
+
+
+ ` : ''} +
+ `; + } + + /** + * Create auto-retry section + */ + private createAutoRetrySection(autoRetryIn?: number): string { + if (!autoRetryIn || autoRetryIn <= 0) return ''; + + return ` +
+
+ + Auto-retry in ${autoRetryIn} seconds +
+
+
+
+
+
+ +
+ `; + } + + /** + * Create recovery actions + */ + private createRecoveryActions(options: ErrorRecoveryOptions): string { + return options.recoveryActions.map(action => { + const actionClass = this.getActionClass(action.action); + const icon = this.getActionIcon(action.action); + + return ` +
+
+ +
+
+
${action.label}
+ ${action.description ? `
${action.description}
` : ''} +
+
+ +
+
+ `; + }).join(''); + } + + /** + * Format parameters for display + */ + private formatParameters(params: Record): string { + return Object.entries(params).map(([key, value]) => { + let displayValue: string; + if (typeof value === 'string') { + displayValue = value.length > 50 ? value.substring(0, 50) + '...' : value; + displayValue = `"${displayValue}"`; + } else if (typeof value === 'object') { + displayValue = JSON.stringify(value, null, 2); + } else { + displayValue = String(value); + } + + return `
+ ${key}: + ${displayValue} +
`; + }).join(''); + } + + /** + * Get error type badge class + */ + private getErrorTypeBadgeClass(errorType: string): string { + const typeMap: Record = { + 'NetworkError': 'badge-warning', + 'TimeoutError': 'badge-warning', + 'ValidationError': 'badge-danger', + 'NotFoundError': 'badge-info', + 'PermissionError': 'badge-danger', + 'RateLimitError': 'badge-warning', + 'UnknownError': 'badge-secondary' + }; + + return typeMap[errorType] || 'badge-secondary'; + } + + /** + * Get action class + */ + private getActionClass(action: string): string { + const actionMap: Record = { + 'retry': 'action-retry', + 'skip': 'action-skip', + 'modify': 'action-modify', + 'abort': 'action-abort', + 'alternative': 'action-alternative' + }; + + return actionMap[action] || 'action-default'; + } + + /** + * Get action icon + */ + private getActionIcon(action: string): string { + const iconMap: Record = { + 'retry': 'fas fa-redo', + 'skip': 'fas fa-forward', + 'modify': 'fas fa-edit', + 'abort': 'fas fa-times', + 'alternative': 'fas fa-route' + }; + + return iconMap[action] || 'fas fa-cog'; + } + + /** + * Attach error events + */ + private attachErrorEvents(element: HTMLElement, options: ErrorRecoveryOptions): void { + // Recovery action clicks + const actions = element.querySelectorAll('.recovery-action'); + actions.forEach(action => { + action.addEventListener('click', (e) => { + const target = e.currentTarget as HTMLElement; + const actionId = target.getAttribute('data-action-id'); + if (actionId) { + const recoveryAction = options.recoveryActions.find(a => a.id === actionId); + if (recoveryAction) { + this.executeRecoveryAction(options.errorId, recoveryAction); + } + } + }); + }); + + // Cancel auto-retry + const cancelAutoRetry = element.querySelector('.cancel-auto-retry'); + if (cancelAutoRetry) { + cancelAutoRetry.addEventListener('click', () => { + this.cancelAutoRetry(options.errorId); + }); + } + } + + /** + * Start auto-retry countdown + */ + private startAutoRetryCountdown(options: ErrorRecoveryOptions): void { + if (!options.autoRetryIn) return; + + const element = this.container.querySelector(`[data-error-id="${options.errorId}"]`) as HTMLElement; + if (!element) return; + + const countdownElement = element.querySelector('.retry-countdown') as HTMLElement; + const progressFill = element.querySelector('.retry-progress-fill') as HTMLElement; + + let remainingTime = options.autoRetryIn; + const totalTime = options.autoRetryIn; + + const interval = setInterval(() => { + remainingTime--; + + if (countdownElement) { + countdownElement.textContent = remainingTime.toString(); + } + + if (progressFill) { + const progress = ((totalTime - remainingTime) / totalTime) * 100; + progressFill.style.width = `${progress}%`; + } + + if (remainingTime <= 0) { + clearInterval(interval); + // Auto-execute retry + const retryAction = options.recoveryActions.find(a => a.action === 'retry'); + if (retryAction) { + this.executeRecoveryAction(options.errorId, retryAction); + } + } + }, 1000); + + // Store interval for potential cancellation + element.setAttribute('data-retry-interval', interval.toString()); + } + + /** + * Cancel auto-retry + */ + private cancelAutoRetry(errorId: string): void { + const element = this.container.querySelector(`[data-error-id="${errorId}"]`) as HTMLElement; + if (!element) return; + + const intervalId = element.getAttribute('data-retry-interval'); + if (intervalId) { + clearInterval(parseInt(intervalId)); + element.removeAttribute('data-retry-interval'); + } + + // Hide auto-retry section + const autoRetrySection = element.querySelector('.auto-retry-section') as HTMLElement; + if (autoRetrySection) { + autoRetrySection.style.display = 'none'; + } + } + + /** + * Execute recovery action + */ + private executeRecoveryAction(errorId: string, action: ErrorRecoveryOptions['recoveryActions'][0]): void { + const callback = this.responseCallbacks.get(errorId); + if (!callback) return; + + const response: ErrorRecoveryResponse = { + errorId, + action: action.action, + parameters: action.parameters, + timestamp: Date.now() + }; + + // Clean up + this.activeErrors.delete(errorId); + this.responseCallbacks.delete(errorId); + this.removeErrorElement(errorId); + + // Call callback + callback(response); + } + + /** + * Remove error element + */ + private removeErrorElement(errorId: string): void { + const element = this.container.querySelector(`[data-error-id="${errorId}"]`) as HTMLElement; + if (element) { + element.classList.add('fade-out'); + setTimeout(() => { + element.remove(); + + // Hide container if no more errors + if (this.container.children.length === 0) { + this.container.style.display = 'none'; + } + }, 300); + } + } + + /** + * Clear all errors + */ + public clearAllErrors(): void { + this.activeErrors.clear(); + this.responseCallbacks.clear(); + this.container.innerHTML = ''; + this.container.style.display = 'none'; + } + + /** + * Get active error count + */ + public getActiveErrorCount(): number { + return this.activeErrors.size; + } + + /** + * Check if error recovery is active + */ + public hasActiveErrors(): boolean { + return this.activeErrors.size > 0; + } + + /** + * Update error context (for adding new information) + */ + public updateErrorContext(errorId: string, newContext: Partial): void { + const options = this.activeErrors.get(errorId); + if (!options) return; + + options.context = { ...options.context, ...newContext }; + + // Re-render the context section + const element = this.container.querySelector(`[data-error-id="${errorId}"]`) as HTMLElement; + if (element) { + const contextContainer = element.querySelector('.error-context') as HTMLElement; + if (contextContainer) { + contextContainer.outerHTML = this.createContextSection(options.context); + } + } + } +} + +// Export types for use in other modules +export type { ErrorRecoveryOptions, ErrorRecoveryResponse }; \ No newline at end of file diff --git a/apps/client/src/widgets/llm_chat/interaction_manager.ts b/apps/client/src/widgets/llm_chat/interaction_manager.ts new file mode 100644 index 000000000..534cf8da4 --- /dev/null +++ b/apps/client/src/widgets/llm_chat/interaction_manager.ts @@ -0,0 +1,529 @@ +interface UserInteractionRequest { + id: string; + type: 'confirmation' | 'choice' | 'input' | 'tool_confirmation'; + title: string; + message: string; + options?: Array<{ + id: string; + label: string; + description?: string; + style?: 'primary' | 'secondary' | 'warning' | 'danger'; + action?: string; + }>; + defaultValue?: string; + timeout?: number; // milliseconds + tool?: { + name: string; + description: string; + arguments: Record; + riskLevel?: 'low' | 'medium' | 'high'; + }; +} + +interface UserInteractionResponse { + id: string; + response: string; + value?: any; + timestamp: number; +} + +/** + * User Interaction Manager for LLM Chat + * Handles confirmations, choices, and input prompts during LLM operations + */ +export class InteractionManager { + private activeInteractions: Map = new Map(); + private responseCallbacks: Map void> = new Map(); + private modalContainer: HTMLElement; + private overlay: HTMLElement; + + constructor(parentElement: HTMLElement) { + this.createModalContainer(parentElement); + } + + /** + * Create modal container and overlay + */ + private createModalContainer(parentElement: HTMLElement): void { + // Create overlay + this.overlay = document.createElement('div'); + this.overlay.className = 'llm-interaction-overlay'; + this.overlay.style.display = 'none'; + + // Create modal container + this.modalContainer = document.createElement('div'); + this.modalContainer.className = 'llm-interaction-modal-container'; + + this.overlay.appendChild(this.modalContainer); + parentElement.appendChild(this.overlay); + + // Close on overlay click + this.overlay.addEventListener('click', (e) => { + if (e.target === this.overlay) { + this.cancelAllInteractions(); + } + }); + + // Handle escape key + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && this.hasActiveInteractions()) { + this.cancelAllInteractions(); + } + }); + } + + /** + * Request user interaction + */ + public async requestUserInteraction(request: UserInteractionRequest): Promise { + this.activeInteractions.set(request.id, request); + + return new Promise((resolve, reject) => { + // Set up response callback + this.responseCallbacks.set(request.id, resolve); + + // Create and show modal + const modal = this.createInteractionModal(request); + this.showModal(modal); + + // Set up timeout if specified + if (request.timeout && request.timeout > 0) { + setTimeout(() => { + if (this.activeInteractions.has(request.id)) { + this.handleTimeout(request.id); + } + }, request.timeout); + } + }); + } + + /** + * Create interaction modal based on request type + */ + private createInteractionModal(request: UserInteractionRequest): HTMLElement { + const modal = document.createElement('div'); + modal.className = `llm-interaction-modal llm-interaction-${request.type}`; + modal.setAttribute('data-interaction-id', request.id); + + switch (request.type) { + case 'tool_confirmation': + return this.createToolConfirmationModal(modal, request); + case 'confirmation': + return this.createConfirmationModal(modal, request); + case 'choice': + return this.createChoiceModal(modal, request); + case 'input': + return this.createInputModal(modal, request); + default: + return this.createGenericModal(modal, request); + } + } + + /** + * Create tool confirmation modal + */ + private createToolConfirmationModal(modal: HTMLElement, request: UserInteractionRequest): HTMLElement { + const tool = request.tool!; + const riskClass = tool.riskLevel ? `risk-${tool.riskLevel}` : ''; + + modal.innerHTML = ` + + + + `; + + this.attachButtonEvents(modal, request); + return modal; + } + + /** + * Create confirmation modal + */ + private createConfirmationModal(modal: HTMLElement, request: UserInteractionRequest): HTMLElement { + modal.innerHTML = ` + + + + `; + + this.attachButtonEvents(modal, request); + return modal; + } + + /** + * Create choice modal + */ + private createChoiceModal(modal: HTMLElement, request: UserInteractionRequest): HTMLElement { + modal.innerHTML = ` + + + + `; + + this.attachChoiceEvents(modal, request); + return modal; + } + + /** + * Create input modal + */ + private createInputModal(modal: HTMLElement, request: UserInteractionRequest): HTMLElement { + modal.innerHTML = ` + + + + `; + + this.attachInputEvents(modal, request); + return modal; + } + + /** + * Create generic modal + */ + private createGenericModal(modal: HTMLElement, request: UserInteractionRequest): HTMLElement { + modal.innerHTML = ` + + + + `; + + this.attachButtonEvents(modal, request); + return modal; + } + + /** + * Format tool arguments for display + */ + private formatToolArguments(args: Record): string { + const formatted = Object.entries(args).map(([key, value]) => { + let displayValue: string; + if (typeof value === 'string') { + displayValue = value.length > 100 ? value.substring(0, 100) + '...' : value; + displayValue = `"${displayValue}"`; + } else if (typeof value === 'object') { + displayValue = JSON.stringify(value, null, 2); + } else { + displayValue = String(value); + } + + return `
+ ${key}: + ${displayValue} +
`; + }).join(''); + + return formatted || '
No parameters
'; + } + + /** + * Create action buttons based on request options + */ + private createActionButtons(request: UserInteractionRequest): string { + if (request.options && request.options.length > 0) { + return request.options.map(option => ` + + `).join(''); + } else { + // Default confirmation buttons + return ` + + + `; + } + } + + /** + * Create timeout indicator + */ + private createTimeoutIndicator(timeout?: number): string { + if (!timeout || timeout <= 0) return ''; + + return ` +
+
Auto-cancel in:
+
+
+
+
+
${Math.ceil(timeout / 1000)}s
+
+
+ `; + } + + /** + * Show modal + */ + private showModal(modal: HTMLElement): void { + this.modalContainer.innerHTML = ''; + this.modalContainer.appendChild(modal); + this.overlay.style.display = 'flex'; + + // Trigger animation + setTimeout(() => { + this.overlay.classList.add('show'); + modal.classList.add('show'); + }, 10); + + // Start timeout countdown if present + this.startTimeoutCountdown(modal); + + // Focus first input if present + const firstInput = modal.querySelector('input, button') as HTMLElement; + if (firstInput) { + firstInput.focus(); + } + } + + /** + * Hide modal + */ + private hideModal(): void { + this.overlay.classList.remove('show'); + const modal = this.modalContainer.querySelector('.llm-interaction-modal') as HTMLElement; + if (modal) { + modal.classList.remove('show'); + } + + setTimeout(() => { + this.overlay.style.display = 'none'; + this.modalContainer.innerHTML = ''; + }, 300); + } + + /** + * Attach button events + */ + private attachButtonEvents(modal: HTMLElement, request: UserInteractionRequest): void { + const buttons = modal.querySelectorAll('.action-btn, .confirm-btn, .cancel-btn'); + buttons.forEach(button => { + button.addEventListener('click', (e) => { + const target = e.target as HTMLElement; + const response = target.getAttribute('data-response') || 'cancel'; + this.respondToInteraction(request.id, response); + }); + }); + } + + /** + * Attach choice events + */ + private attachChoiceEvents(modal: HTMLElement, request: UserInteractionRequest): void { + const options = modal.querySelectorAll('.choice-option'); + options.forEach(option => { + option.addEventListener('click', (e) => { + const target = e.currentTarget as HTMLElement; + const optionId = target.getAttribute('data-option-id'); + if (optionId) { + this.respondToInteraction(request.id, optionId); + } + }); + }); + + // Cancel button + const cancelBtn = modal.querySelector('.cancel-btn'); + if (cancelBtn) { + cancelBtn.addEventListener('click', () => { + this.respondToInteraction(request.id, 'cancel'); + }); + } + } + + /** + * Attach input events + */ + private attachInputEvents(modal: HTMLElement, request: UserInteractionRequest): void { + const input = modal.querySelector('input') as HTMLInputElement; + const submitBtn = modal.querySelector('.submit-btn') as HTMLElement; + const cancelBtn = modal.querySelector('.cancel-btn') as HTMLElement; + + const submitValue = () => { + const value = input.value.trim(); + this.respondToInteraction(request.id, 'submit', value); + }; + + submitBtn.addEventListener('click', submitValue); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + submitValue(); + } + }); + + cancelBtn.addEventListener('click', () => { + this.respondToInteraction(request.id, 'cancel'); + }); + } + + /** + * Start timeout countdown + */ + private startTimeoutCountdown(modal: HTMLElement): void { + const countdown = modal.querySelector('.timeout-countdown') as HTMLElement; + if (!countdown) return; + + const timeout = parseInt(countdown.getAttribute('data-timeout') || '0'); + if (timeout <= 0) return; + + const startTime = Date.now(); + const interval = setInterval(() => { + const elapsed = Date.now() - startTime; + const remaining = Math.max(0, timeout - elapsed); + const progress = (elapsed / timeout) * 100; + + // Update countdown bar + const fill = countdown.querySelector('.countdown-fill') as HTMLElement; + if (fill) { + fill.style.width = `${Math.min(100, progress)}%`; + } + + // Update countdown text + const text = countdown.querySelector('.countdown-text') as HTMLElement; + if (text) { + text.textContent = `${Math.ceil(remaining / 1000)}s`; + } + + // Stop when timeout reached + if (remaining <= 0) { + clearInterval(interval); + } + }, 100); + + // Store interval for cleanup + countdown.setAttribute('data-interval', interval.toString()); + } + + /** + * Respond to interaction + */ + private respondToInteraction(id: string, response: string, value?: any): void { + const callback = this.responseCallbacks.get(id); + if (!callback) return; + + const interactionResponse: UserInteractionResponse = { + id, + response, + value, + timestamp: Date.now() + }; + + // Clean up + this.activeInteractions.delete(id); + this.responseCallbacks.delete(id); + this.hideModal(); + + // Call callback + callback(interactionResponse); + } + + /** + * Handle interaction timeout + */ + private handleTimeout(id: string): void { + this.respondToInteraction(id, 'timeout'); + } + + /** + * Cancel all active interactions + */ + public cancelAllInteractions(): void { + const activeIds = Array.from(this.activeInteractions.keys()); + activeIds.forEach(id => { + this.respondToInteraction(id, 'cancel'); + }); + } + + /** + * Check if there are active interactions + */ + public hasActiveInteractions(): boolean { + return this.activeInteractions.size > 0; + } + + /** + * Get active interaction count + */ + public getActiveInteractionCount(): number { + return this.activeInteractions.size; + } +} + +// Export types for use in other modules +export type { UserInteractionRequest, UserInteractionResponse }; \ No newline at end of file diff --git a/apps/client/src/widgets/llm_chat/progress_indicator.ts b/apps/client/src/widgets/llm_chat/progress_indicator.ts new file mode 100644 index 000000000..ce224a8b8 --- /dev/null +++ b/apps/client/src/widgets/llm_chat/progress_indicator.ts @@ -0,0 +1,387 @@ +interface ProgressStage { + id: string; + label: string; + status: 'pending' | 'running' | 'completed' | 'failed'; + progress: number; // 0-100 + startTime?: number; + endTime?: number; + message?: string; + estimatedDuration?: number; +} + +interface ProgressUpdate { + stageId: string; + progress: number; + status: 'pending' | 'running' | 'completed' | 'failed'; + message?: string; + estimatedTimeRemaining?: number; +} + +/** + * Enhanced Progress Indicator for LLM Chat Operations + * Displays multi-stage progress with progress bars, timing, and status updates + */ +export class ProgressIndicator { + private container: HTMLElement; + private stages: Map = new Map(); + private overallProgress: number = 0; + private isVisible: boolean = false; + + constructor(parentElement: HTMLElement) { + this.container = this.createProgressContainer(); + parentElement.appendChild(this.container); + this.hide(); + } + + /** + * Create the main progress container + */ + private createProgressContainer(): HTMLElement { + const container = document.createElement('div'); + container.className = 'llm-progress-container'; + container.innerHTML = ` +
+
Processing...
+
+
+
+
+
0%
+
+
+
+ + `; + return container; + } + + /** + * Show the progress indicator + */ + public show(): void { + if (!this.isVisible) { + this.container.style.display = 'block'; + this.container.classList.add('fade-in'); + this.isVisible = true; + this.startElapsedTimer(); + } + } + + /** + * Hide the progress indicator + */ + public hide(): void { + if (this.isVisible) { + this.container.classList.add('fade-out'); + setTimeout(() => { + this.container.style.display = 'none'; + this.container.classList.remove('fade-in', 'fade-out'); + this.isVisible = false; + this.stopElapsedTimer(); + }, 300); + } + } + + /** + * Add a new progress stage + */ + public addStage(stageId: string, label: string, estimatedDuration?: number): void { + const stage: ProgressStage = { + id: stageId, + label, + status: 'pending', + progress: 0, + estimatedDuration + }; + + this.stages.set(stageId, stage); + this.renderStage(stage); + this.updateOverallProgress(); + } + + /** + * Update progress for a specific stage + */ + public updateStageProgress(update: ProgressUpdate): void { + const stage = this.stages.get(update.stageId); + if (!stage) return; + + // Update stage data + stage.progress = Math.max(0, Math.min(100, update.progress)); + stage.status = update.status; + stage.message = update.message; + + // Set timing + if (update.status === 'running' && !stage.startTime) { + stage.startTime = Date.now(); + } else if ((update.status === 'completed' || update.status === 'failed') && stage.startTime && !stage.endTime) { + stage.endTime = Date.now(); + } + + this.renderStage(stage); + this.updateOverallProgress(); + + if (update.estimatedTimeRemaining !== undefined) { + this.updateEstimatedTime(update.estimatedTimeRemaining); + } + } + + /** + * Mark a stage as completed + */ + public completeStage(stageId: string): void { + this.updateStageProgress({ + stageId, + progress: 100, + status: 'completed', + message: 'Completed' + }); + } + + /** + * Mark a stage as failed + */ + public failStage(stageId: string, message?: string): void { + this.updateStageProgress({ + stageId, + progress: 0, + status: 'failed', + message: message || 'Failed' + }); + } + + /** + * Render a specific stage + */ + private renderStage(stage: ProgressStage): void { + const stagesContainer = this.container.querySelector('.llm-progress-stages') as HTMLElement; + let stageElement = stagesContainer.querySelector(`[data-stage-id="${stage.id}"]`) as HTMLElement; + + if (!stageElement) { + stageElement = this.createStageElement(stage); + stagesContainer.appendChild(stageElement); + } + + this.updateStageElement(stageElement, stage); + } + + /** + * Create a new stage element + */ + private createStageElement(stage: ProgressStage): HTMLElement { + const element = document.createElement('div'); + element.className = 'llm-progress-stage'; + element.setAttribute('data-stage-id', stage.id); + + element.innerHTML = ` +
+
+ +
+
${stage.label}
+
+
+
+
+
+
+
0%
+
+
+ `; + + return element; + } + + /** + * Update stage element with current data + */ + private updateStageElement(element: HTMLElement, stage: ProgressStage): void { + // Update status icon + const icon = element.querySelector('.stage-status-icon i') as HTMLElement; + icon.className = this.getStatusIcon(stage.status); + + // Update progress bar + const progressFill = element.querySelector('.stage-progress-fill') as HTMLElement; + progressFill.style.width = `${stage.progress}%`; + + // Update progress text + const progressText = element.querySelector('.stage-progress-text') as HTMLElement; + progressText.textContent = `${Math.round(stage.progress)}%`; + + // Update message + const messageElement = element.querySelector('.stage-message') as HTMLElement; + messageElement.textContent = stage.message || ''; + messageElement.style.display = stage.message ? 'block' : 'none'; + + // Update timing + const timingElement = element.querySelector('.stage-timing') as HTMLElement; + timingElement.textContent = this.getStageTimingText(stage); + + // Update stage status class + element.className = `llm-progress-stage stage-${stage.status}`; + } + + /** + * Get status icon for stage + */ + private getStatusIcon(status: string): string { + switch (status) { + case 'pending': return 'fas fa-circle text-muted'; + case 'running': return 'fas fa-spinner fa-spin text-primary'; + case 'completed': return 'fas fa-check-circle text-success'; + case 'failed': return 'fas fa-exclamation-circle text-danger'; + default: return 'fas fa-circle'; + } + } + + /** + * Get timing text for stage + */ + private getStageTimingText(stage: ProgressStage): string { + if (stage.endTime && stage.startTime) { + const duration = Math.round((stage.endTime - stage.startTime) / 1000); + return `${duration}s`; + } else if (stage.startTime) { + const elapsed = Math.round((Date.now() - stage.startTime) / 1000); + return `${elapsed}s`; + } else if (stage.estimatedDuration) { + return `~${stage.estimatedDuration / 1000}s`; + } + return ''; + } + + /** + * Update overall progress + */ + private updateOverallProgress(): void { + if (this.stages.size === 0) { + this.overallProgress = 0; + } else { + const totalProgress = Array.from(this.stages.values()) + .reduce((sum, stage) => sum + stage.progress, 0); + this.overallProgress = totalProgress / this.stages.size; + } + + // Update overall progress bar + const overallFill = this.container.querySelector('.llm-progress-bar-fill') as HTMLElement; + overallFill.style.width = `${this.overallProgress}%`; + + // Update percentage text + const percentageText = this.container.querySelector('.llm-progress-percentage') as HTMLElement; + percentageText.textContent = `${Math.round(this.overallProgress)}%`; + + // Update title based on progress + const titleElement = this.container.querySelector('.llm-progress-title') as HTMLElement; + if (this.overallProgress >= 100) { + titleElement.textContent = 'Completed'; + } else if (this.overallProgress > 0) { + titleElement.textContent = 'Processing...'; + } else { + titleElement.textContent = 'Starting...'; + } + } + + /** + * Update estimated remaining time + */ + private updateEstimatedTime(seconds: number): void { + const estimatedElement = this.container.querySelector('.estimated-remaining') as HTMLElement; + if (seconds > 0) { + estimatedElement.textContent = `Est. remaining: ${this.formatTime(seconds)}`; + } else { + estimatedElement.textContent = 'Est. remaining: --'; + } + } + + /** + * Format time in seconds to readable format + */ + private formatTime(seconds: number): string { + if (seconds < 60) { + return `${Math.round(seconds)}s`; + } else if (seconds < 3600) { + const minutes = Math.floor(seconds / 60); + const remainingSeconds = Math.round(seconds % 60); + return `${minutes}m ${remainingSeconds}s`; + } else { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + return `${hours}h ${minutes}m`; + } + } + + /** + * Start elapsed time timer + */ + private elapsedTimer?: number; + private startTime: number = Date.now(); + + private startElapsedTimer(): void { + this.startTime = Date.now(); + this.elapsedTimer = window.setInterval(() => { + const elapsed = Math.round((Date.now() - this.startTime) / 1000); + const elapsedElement = this.container.querySelector('.elapsed-time') as HTMLElement; + elapsedElement.textContent = `Elapsed: ${this.formatTime(elapsed)}`; + }, 1000); + } + + /** + * Stop elapsed time timer + */ + private stopElapsedTimer(): void { + if (this.elapsedTimer) { + clearInterval(this.elapsedTimer); + this.elapsedTimer = undefined; + } + } + + /** + * Clear all stages and reset + */ + public reset(): void { + this.stages.clear(); + const stagesContainer = this.container.querySelector('.llm-progress-stages') as HTMLElement; + stagesContainer.innerHTML = ''; + this.overallProgress = 0; + this.updateOverallProgress(); + this.stopElapsedTimer(); + } + + /** + * Set cancel callback + */ + public onCancel(callback: () => void): void { + const cancelBtn = this.container.querySelector('.llm-progress-cancel-btn') as HTMLElement; + cancelBtn.onclick = callback; + } + + /** + * Disable cancel button + */ + public disableCancel(): void { + const cancelBtn = this.container.querySelector('.llm-progress-cancel-btn') as HTMLButtonElement; + cancelBtn.disabled = true; + cancelBtn.style.opacity = '0.5'; + } + + /** + * Enable cancel button + */ + public enableCancel(): void { + const cancelBtn = this.container.querySelector('.llm-progress-cancel-btn') as HTMLButtonElement; + cancelBtn.disabled = false; + cancelBtn.style.opacity = '1'; + } +} + +// Export types for use in other modules +export type { ProgressStage, ProgressUpdate }; \ No newline at end of file