mirror of
https://github.com/zadam/trilium.git
synced 2025-12-05 15:04:24 +01:00
feat(llm): provide better user feedback when working
This commit is contained in:
parent
6fbc5b2b14
commit
e0383c49cb
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
968
apps/client/src/widgets/llm_chat/enhanced_components.css
Normal file
968
apps/client/src/widgets/llm_chat/enhanced_components.css
Normal 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;
|
||||
}
|
||||
451
apps/client/src/widgets/llm_chat/error_recovery_manager.ts
Normal file
451
apps/client/src/widgets/llm_chat/error_recovery_manager.ts
Normal 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 };
|
||||
529
apps/client/src/widgets/llm_chat/interaction_manager.ts
Normal file
529
apps/client/src/widgets/llm_chat/interaction_manager.ts
Normal 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 };
|
||||
387
apps/client/src/widgets/llm_chat/progress_indicator.ts
Normal file
387
apps/client/src/widgets/llm_chat/progress_indicator.ts
Normal 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 };
|
||||
Loading…
x
Reference in New Issue
Block a user