feat(llm): provide better user feedback when working

This commit is contained in:
perf3ct 2025-07-04 23:44:11 +00:00
parent 6fbc5b2b14
commit e0383c49cb
No known key found for this signature in database
GPG Key ID: 569C4EEC436F5232
5 changed files with 2414 additions and 0 deletions

View File

@ -48,6 +48,9 @@ export async function checkSessionExists(noteId: string): Promise<boolean> {
* @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<any>,
onErrorRecovery: (errorData: any) => Promise<any>,
onComplete: () => void,
onError: (error: Error) => void
): Promise<void> {
@ -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<void> {
try {
await server.post<any>(`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<void> {
try {
await server.post<any>(`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<void> {
try {
await server.post<any>(`llm/chat/${sessionId}/cancel`, {});
console.log(`Chat operations cancelled for session: ${sessionId}`);
} catch (error) {
console.error('Error cancelling chat operations:', error);
throw error;
}
}

View File

@ -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;
}

View File

@ -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<string, unknown>;
}>;
autoRetryIn?: number; // seconds
context?: {
originalParams?: Record<string, unknown>;
previousAttempts?: string[];
suggestions?: string[];
};
}
interface ErrorRecoveryResponse {
errorId: string;
action: string;
parameters?: Record<string, unknown>;
timestamp: number;
}
/**
* Error Recovery Manager for LLM Chat
* Handles sophisticated error recovery with multiple strategies and user guidance
*/
export class ErrorRecoveryManager {
private activeErrors: Map<string, ErrorRecoveryOptions> = new Map();
private responseCallbacks: Map<string, (response: ErrorRecoveryResponse) => 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<ErrorRecoveryResponse> {
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 = `
<div class="error-header">
<div class="error-icon">
<i class="fas fa-exclamation-triangle"></i>
</div>
<div class="error-title">
<div class="error-tool-name">${options.toolName} Failed</div>
<div class="error-attempt-info">Attempt ${options.attempt}/${options.maxAttempts}</div>
</div>
<div class="error-type-badge ${this.getErrorTypeBadgeClass(options.errorType)}">
${options.errorType}
</div>
</div>
<div class="error-body">
<div class="error-message">
<div class="error-message-label">Error Details:</div>
<div class="error-message-content">${options.message}</div>
</div>
${this.createContextSection(options.context)}
${this.createAutoRetrySection(options.autoRetryIn)}
<div class="recovery-actions">
<div class="recovery-actions-label">Recovery Options:</div>
<div class="recovery-actions-grid">
${this.createRecoveryActions(options)}
</div>
</div>
</div>
`;
this.attachErrorEvents(element, options);
return element;
}
/**
* Create context section
*/
private createContextSection(context?: ErrorRecoveryOptions['context']): string {
if (!context) return '';
return `
<div class="error-context">
${context.originalParams ? `
<div class="context-section">
<div class="context-label">Original Parameters:</div>
<div class="context-content">
${this.formatParameters(context.originalParams)}
</div>
</div>
` : ''}
${context.previousAttempts && context.previousAttempts.length > 0 ? `
<div class="context-section">
<div class="context-label">Previous Attempts:</div>
<div class="context-content">
<ul class="previous-attempts-list">
${context.previousAttempts.map(attempt => `<li>${attempt}</li>`).join('')}
</ul>
</div>
</div>
` : ''}
${context.suggestions && context.suggestions.length > 0 ? `
<div class="context-section">
<div class="context-label">Suggestions:</div>
<div class="context-content">
<ul class="suggestions-list">
${context.suggestions.map(suggestion => `<li>${suggestion}</li>`).join('')}
</ul>
</div>
</div>
` : ''}
</div>
`;
}
/**
* Create auto-retry section
*/
private createAutoRetrySection(autoRetryIn?: number): string {
if (!autoRetryIn || autoRetryIn <= 0) return '';
return `
<div class="auto-retry-section">
<div class="auto-retry-info">
<i class="fas fa-clock"></i>
<span>Auto-retry in <span class="retry-countdown">${autoRetryIn}</span> seconds</span>
</div>
<div class="auto-retry-progress">
<div class="retry-progress-bar">
<div class="retry-progress-fill"></div>
</div>
</div>
<button class="btn btn-sm btn-secondary cancel-auto-retry">Cancel Auto-retry</button>
</div>
`;
}
/**
* 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 `
<div class="recovery-action ${actionClass}" data-action-id="${action.id}">
<div class="action-icon">
<i class="${icon}"></i>
</div>
<div class="action-content">
<div class="action-label">${action.label}</div>
${action.description ? `<div class="action-description">${action.description}</div>` : ''}
</div>
<div class="action-arrow">
<i class="fas fa-chevron-right"></i>
</div>
</div>
`;
}).join('');
}
/**
* Format parameters for display
*/
private formatParameters(params: Record<string, unknown>): 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 `<div class="param-item">
<span class="param-key">${key}:</span>
<span class="param-value">${displayValue}</span>
</div>`;
}).join('');
}
/**
* Get error type badge class
*/
private getErrorTypeBadgeClass(errorType: string): string {
const typeMap: Record<string, string> = {
'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<string, string> = {
'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<string, string> = {
'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<ErrorRecoveryOptions['context']>): 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 };

View File

@ -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<string, unknown>;
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<string, UserInteractionRequest> = new Map();
private responseCallbacks: Map<string, (response: UserInteractionResponse) => 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<UserInteractionResponse> {
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 = `
<div class="modal-header ${riskClass}">
<div class="modal-title">
<i class="fas fa-tools"></i>
Tool Execution Confirmation
</div>
<div class="risk-indicator ${riskClass}">
<span class="risk-label">${(tool.riskLevel || 'medium').toUpperCase()} RISK</span>
</div>
</div>
<div class="modal-body">
<div class="tool-info">
<div class="tool-name">${tool.name}</div>
<div class="tool-description">${tool.description}</div>
</div>
<div class="tool-arguments">
<div class="arguments-label">Parameters:</div>
<div class="arguments-content">
${this.formatToolArguments(tool.arguments)}
</div>
</div>
<div class="confirmation-message">${request.message}</div>
${this.createTimeoutIndicator(request.timeout)}
</div>
<div class="modal-footer">
${this.createActionButtons(request)}
</div>
`;
this.attachButtonEvents(modal, request);
return modal;
}
/**
* Create confirmation modal
*/
private createConfirmationModal(modal: HTMLElement, request: UserInteractionRequest): HTMLElement {
modal.innerHTML = `
<div class="modal-header">
<div class="modal-title">
<i class="fas fa-question-circle"></i>
${request.title}
</div>
</div>
<div class="modal-body">
<div class="confirmation-message">${request.message}</div>
${this.createTimeoutIndicator(request.timeout)}
</div>
<div class="modal-footer">
${this.createActionButtons(request)}
</div>
`;
this.attachButtonEvents(modal, request);
return modal;
}
/**
* Create choice modal
*/
private createChoiceModal(modal: HTMLElement, request: UserInteractionRequest): HTMLElement {
modal.innerHTML = `
<div class="modal-header">
<div class="modal-title">
<i class="fas fa-list"></i>
${request.title}
</div>
</div>
<div class="modal-body">
<div class="choice-message">${request.message}</div>
<div class="choice-options">
${(request.options || []).map(option => `
<div class="choice-option" data-option-id="${option.id}">
<div class="option-label">${option.label}</div>
${option.description ? `<div class="option-description">${option.description}</div>` : ''}
</div>
`).join('')}
</div>
${this.createTimeoutIndicator(request.timeout)}
</div>
<div class="modal-footer">
<button class="btn btn-secondary cancel-btn">Cancel</button>
</div>
`;
this.attachChoiceEvents(modal, request);
return modal;
}
/**
* Create input modal
*/
private createInputModal(modal: HTMLElement, request: UserInteractionRequest): HTMLElement {
modal.innerHTML = `
<div class="modal-header">
<div class="modal-title">
<i class="fas fa-edit"></i>
${request.title}
</div>
</div>
<div class="modal-body">
<div class="input-message">${request.message}</div>
<div class="input-field">
<input type="text" class="form-control" placeholder="Enter your response..."
value="${request.defaultValue || ''}" autofocus>
</div>
${this.createTimeoutIndicator(request.timeout)}
</div>
<div class="modal-footer">
<button class="btn btn-secondary cancel-btn">Cancel</button>
<button class="btn btn-primary submit-btn">Submit</button>
</div>
`;
this.attachInputEvents(modal, request);
return modal;
}
/**
* Create generic modal
*/
private createGenericModal(modal: HTMLElement, request: UserInteractionRequest): HTMLElement {
modal.innerHTML = `
<div class="modal-header">
<div class="modal-title">${request.title}</div>
</div>
<div class="modal-body">
<div class="generic-message">${request.message}</div>
${this.createTimeoutIndicator(request.timeout)}
</div>
<div class="modal-footer">
${this.createActionButtons(request)}
</div>
`;
this.attachButtonEvents(modal, request);
return modal;
}
/**
* Format tool arguments for display
*/
private formatToolArguments(args: Record<string, unknown>): 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 `<div class="argument-item">
<span class="argument-key">${key}:</span>
<span class="argument-value">${displayValue}</span>
</div>`;
}).join('');
return formatted || '<div class="no-arguments">No parameters</div>';
}
/**
* Create action buttons based on request options
*/
private createActionButtons(request: UserInteractionRequest): string {
if (request.options && request.options.length > 0) {
return request.options.map(option => `
<button class="btn btn-${option.style || 'secondary'} action-btn"
data-action="${option.id}" data-response="${option.action || option.id}">
${option.label}
</button>
`).join('');
} else {
// Default confirmation buttons
return `
<button class="btn btn-secondary cancel-btn" data-response="cancel">Cancel</button>
<button class="btn btn-primary confirm-btn" data-response="confirm">Confirm</button>
`;
}
}
/**
* Create timeout indicator
*/
private createTimeoutIndicator(timeout?: number): string {
if (!timeout || timeout <= 0) return '';
return `
<div class="timeout-indicator">
<div class="timeout-label">Auto-cancel in:</div>
<div class="timeout-countdown" data-timeout="${timeout}">
<div class="countdown-bar">
<div class="countdown-fill"></div>
</div>
<div class="countdown-text">${Math.ceil(timeout / 1000)}s</div>
</div>
</div>
`;
}
/**
* 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 };

View File

@ -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<string, ProgressStage> = 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 = `
<div class="llm-progress-header">
<div class="llm-progress-title">Processing...</div>
<div class="llm-progress-overall">
<div class="llm-progress-bar-container">
<div class="llm-progress-bar-fill" style="width: 0%"></div>
</div>
<div class="llm-progress-percentage">0%</div>
</div>
</div>
<div class="llm-progress-stages"></div>
<div class="llm-progress-footer">
<div class="llm-progress-time-info">
<span class="elapsed-time">Elapsed: 0s</span>
<span class="estimated-remaining">Est. remaining: --</span>
</div>
<button class="llm-progress-cancel-btn" title="Cancel operation">
<i class="fas fa-times"></i> Cancel
</button>
</div>
`;
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 = `
<div class="stage-header">
<div class="stage-status-icon">
<i class="fas fa-circle"></i>
</div>
<div class="stage-label">${stage.label}</div>
<div class="stage-timing"></div>
</div>
<div class="stage-progress">
<div class="stage-progress-bar">
<div class="stage-progress-fill"></div>
</div>
<div class="stage-progress-text">0%</div>
</div>
<div class="stage-message"></div>
`;
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 };