diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index bc72b21f8b33..514aab69375c 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -28,6 +28,7 @@ import { DroppedFile, useFileDrop } from '../hooks/useFileDrop'; import { Recipe } from '../recipe'; import MessageQueue from './MessageQueue'; import { detectInterruption } from '../utils/interruptionDetector'; +import { getApiUrl } from '../config'; interface QueuedMessage { id: string; @@ -141,6 +142,7 @@ export default function ChatInput({ const { getCurrentModelAndProvider, currentModel, currentProvider } = useModelAndProvider(); const [tokenLimit, setTokenLimit] = useState(TOKEN_LIMIT_DEFAULT); const [isTokenLimitLoaded, setIsTokenLimitLoaded] = useState(false); + const [autoCompactThreshold, setAutoCompactThreshold] = useState(0.8); // Default to 80% // Draft functionality - get chat context and global draft context // We need to handle the case where ChatInput is used without ChatProvider (e.g., in Hub) @@ -497,6 +499,55 @@ export default function ChatInput({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentModel, currentProvider]); + // Load auto-compact threshold + const loadAutoCompactThreshold = useCallback(async () => { + try { + const secretKey = await window.electron.getSecretKey(); + const response = await fetch(getApiUrl('/config/read'), { + method: 'POST', + headers: { + 'X-Secret-Key': secretKey, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + key: 'GOOSE_AUTO_COMPACT_THRESHOLD', + is_secret: false, + }), + }); + if (response.ok) { + const data = await response.json(); + console.log('Loaded auto-compact threshold from config:', data); + if (data !== undefined && data !== null) { + setAutoCompactThreshold(data); + console.log('Set auto-compact threshold to:', data); + } + } else { + console.error('Failed to fetch auto-compact threshold, status:', response.status); + } + } catch (err) { + console.error('Error fetching auto-compact threshold:', err); + } + }, []); + + useEffect(() => { + loadAutoCompactThreshold(); + }, [loadAutoCompactThreshold]); + + // Listen for threshold change events from AlertBox + useEffect(() => { + const handleThresholdChange = (event: CustomEvent<{ threshold: number }>) => { + setAutoCompactThreshold(event.detail.threshold); + }; + + // Type assertion to handle the mismatch between CustomEvent and EventListener + const eventListener = handleThresholdChange as (event: globalThis.Event) => void; + window.addEventListener('autoCompactThresholdChanged', eventListener); + + return () => { + window.removeEventListener('autoCompactThresholdChanged', eventListener); + }; + }, []); + // Handle tool count alerts and token usage useEffect(() => { clearAlerts(); @@ -522,6 +573,7 @@ export default function ChatInput({ handleManualCompaction(messages, setMessages, append, setAncestorMessages); }, compactIcon: , + autoCompactThreshold: autoCompactThreshold, }); } @@ -539,7 +591,16 @@ export default function ChatInput({ } // We intentionally omit setView as it shouldn't trigger a re-render of alerts // eslint-disable-next-line react-hooks/exhaustive-deps - }, [numTokens, toolCount, tokenLimit, isTokenLimitLoaded, addAlert, isCompacting, clearAlerts]); + }, [ + numTokens, + toolCount, + tokenLimit, + isTokenLimitLoaded, + addAlert, + isCompacting, + clearAlerts, + autoCompactThreshold, + ]); // Cleanup effect for component unmount - prevent memory leaks useEffect(() => { diff --git a/ui/desktop/src/components/alerts/AlertBox.tsx b/ui/desktop/src/components/alerts/AlertBox.tsx index d25b8f190f7b..148920f36958 100644 --- a/ui/desktop/src/components/alerts/AlertBox.tsx +++ b/ui/desktop/src/components/alerts/AlertBox.tsx @@ -1,7 +1,9 @@ -import React from 'react'; +import React, { useState } from 'react'; import { IoIosCloseCircle, IoIosWarning, IoIosInformationCircle } from 'react-icons/io'; +import { FaPencilAlt, FaSave } from 'react-icons/fa'; import { cn } from '../../utils'; import { Alert, AlertType } from './types'; +import { getApiUrl } from '../../config'; const alertIcons: Record = { [AlertType.Error]: , @@ -22,27 +24,218 @@ const alertStyles: Record = { }; export const AlertBox = ({ alert, className }: AlertBoxProps) => { + const [isEditingThreshold, setIsEditingThreshold] = useState(false); + const [thresholdValue, setThresholdValue] = useState( + alert.autoCompactThreshold ? Math.round(alert.autoCompactThreshold * 100) : 80 + ); + const [isSaving, setIsSaving] = useState(false); + + const handleSaveThreshold = async () => { + if (isSaving) return; // Prevent double-clicks + + // Validate threshold value - allow 0 and 100 as special values to disable + const validThreshold = Math.max(0, Math.min(100, thresholdValue)); + if (validThreshold !== thresholdValue) { + setThresholdValue(validThreshold); + } + + setIsSaving(true); + try { + const newThreshold = validThreshold / 100; // Convert percentage to decimal + console.log('Saving auto-compact threshold:', newThreshold); + + // Update the configuration via the upsert API + const response = await fetch(getApiUrl('/config/upsert'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Secret-Key': await window.electron.getSecretKey(), + }, + body: JSON.stringify({ + key: 'GOOSE_AUTO_COMPACT_THRESHOLD', + value: newThreshold, + is_secret: false, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Failed to update threshold: ${errorText}`); + } + + const responseText = await response.text(); + console.log('Threshold save response:', responseText); + + setIsEditingThreshold(false); + + // Dispatch a custom event to notify other components that the threshold has changed + // This allows ChatInput to reload the threshold without a page reload + window.dispatchEvent( + new CustomEvent('autoCompactThresholdChanged', { + detail: { threshold: newThreshold }, + }) + ); + + console.log('Dispatched autoCompactThresholdChanged event with threshold:', newThreshold); + } catch (error) { + console.error('Error saving threshold:', error); + window.alert( + `Failed to save threshold: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } finally { + setIsSaving(false); + } + }; + return ( -
+
{ + // Prevent popover from closing when clicking inside the alert box + if (isEditingThreshold) { + e.stopPropagation(); + } + }} + > {alert.progress ? (
{alert.message} -
- {[...Array(30)].map((_, i) => ( -
- ))} + + {/* Auto-compact threshold indicator with edit */} + {alert.autoCompactThreshold !== undefined && ( +
+ {isEditingThreshold ? ( + <> + Auto summarize at + { + const val = parseInt(e.target.value, 10); + // Allow empty input for easier editing + if (e.target.value === '') { + setThresholdValue(0); + } else if (!isNaN(val)) { + // Clamp value between 0 and 100 + setThresholdValue(Math.max(0, Math.min(100, val))); + } + }} + onBlur={(e) => { + // On blur, ensure we have a valid value + const val = parseInt(e.target.value, 10); + if (isNaN(val) || val < 0) { + setThresholdValue(0); + } else if (val > 100) { + setThresholdValue(100); + } + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleSaveThreshold(); + } else if (e.key === 'Escape') { + setIsEditingThreshold(false); + setThresholdValue( + alert.autoCompactThreshold + ? Math.round(alert.autoCompactThreshold * 100) + : 80 + ); + } + }} + onFocus={(e) => { + // Select all text on focus for easier editing + e.target.select(); + }} + onClick={(e) => { + // Prevent issues with text selection + e.stopPropagation(); + }} + className="w-12 px-1 text-[10px] bg-white/10 border border-current/30 rounded outline-none text-center focus:bg-white/20 focus:border-current/50 transition-colors" + disabled={isSaving} + autoFocus + /> + % + + + ) : ( + <> + + {alert.autoCompactThreshold === 0 || alert.autoCompactThreshold === 1 + ? 'Auto summarize disabled' + : `Auto summarize at ${Math.round(alert.autoCompactThreshold * 100)}%`} + + + + )} +
+ )} + +
+ {[...Array(30)].map((_, i) => { + const progress = alert.progress!.current / alert.progress!.total; + const progressPercentage = Math.round(progress * 100); + const dotPosition = i / 29; // 0 to 1 range for 30 dots + const isActive = dotPosition <= progress; + const isThresholdDot = + alert.autoCompactThreshold !== undefined && + alert.autoCompactThreshold > 0 && + alert.autoCompactThreshold < 1 && + Math.abs(dotPosition - alert.autoCompactThreshold) < 0.017; // ~1/30 tolerance + + // Determine the color based on progress percentage + const getProgressColor = () => { + if (progressPercentage <= 50) { + return 'bg-green-500'; // Green for 0-50% + } else if (progressPercentage <= 75) { + return 'bg-yellow-500'; // Yellow for 51-75% + } else if (progressPercentage <= 90) { + return 'bg-orange-500'; // Orange for 76-90% + } else { + return 'bg-red-500'; // Red for 91-100% + } + }; + + const progressColor = getProgressColor(); + const inactiveColor = 'bg-gray-300 dark:bg-gray-600'; + + return ( +
+ ); + })}
diff --git a/ui/desktop/src/components/alerts/types.ts b/ui/desktop/src/components/alerts/types.ts index ee5f187c400f..af8867261836 100644 --- a/ui/desktop/src/components/alerts/types.ts +++ b/ui/desktop/src/components/alerts/types.ts @@ -20,4 +20,5 @@ export interface Alert { compactButtonDisabled?: boolean; onCompact?: () => void; compactIcon?: React.ReactNode; + autoCompactThreshold?: number; // Add this for showing the auto-compact threshold } diff --git a/ui/desktop/src/components/bottom_menu/BottomMenuAlertPopover.tsx b/ui/desktop/src/components/bottom_menu/BottomMenuAlertPopover.tsx index 3415be9892bc..86a59ec013e8 100644 --- a/ui/desktop/src/components/bottom_menu/BottomMenuAlertPopover.tsx +++ b/ui/desktop/src/components/bottom_menu/BottomMenuAlertPopover.tsx @@ -132,21 +132,34 @@ export default function BottomMenuAlertPopover({ alerts }: AlertPopoverProps) { } }, [isHovered, isOpen, startHideTimer, wasAutoShown]); - // Handle click outside + // Handle click outside - but not when editing threshold useEffect(() => { const handleClickOutside = (event: MouseEvent) => { - if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) { + // Check if we're clicking on an input or button inside the popover + const target = event.target as HTMLElement; + const isInteractiveElement = + target.tagName === 'INPUT' || + target.tagName === 'BUTTON' || + target.closest('button') !== null || + target.closest('input') !== null; + + if ( + popoverRef.current && + !popoverRef.current.contains(event.target as Node) && + !isInteractiveElement + ) { setIsOpen(false); setWasAutoShown(false); } }; if (isOpen) { - document.addEventListener('mousedown', handleClickOutside); + // Use mouseup instead of mousedown to allow button clicks to complete + document.addEventListener('mouseup', handleClickOutside); } return () => { - document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('mouseup', handleClickOutside); }; }, [isOpen]);