feat: add auto-compact threshold configuration UI (#4178)
This commit is contained in:
@@ -28,6 +28,7 @@ import { DroppedFile, useFileDrop } from '../hooks/useFileDrop';
|
|||||||
import { Recipe } from '../recipe';
|
import { Recipe } from '../recipe';
|
||||||
import MessageQueue from './MessageQueue';
|
import MessageQueue from './MessageQueue';
|
||||||
import { detectInterruption } from '../utils/interruptionDetector';
|
import { detectInterruption } from '../utils/interruptionDetector';
|
||||||
|
import { getApiUrl } from '../config';
|
||||||
|
|
||||||
interface QueuedMessage {
|
interface QueuedMessage {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -141,6 +142,7 @@ export default function ChatInput({
|
|||||||
const { getCurrentModelAndProvider, currentModel, currentProvider } = useModelAndProvider();
|
const { getCurrentModelAndProvider, currentModel, currentProvider } = useModelAndProvider();
|
||||||
const [tokenLimit, setTokenLimit] = useState<number>(TOKEN_LIMIT_DEFAULT);
|
const [tokenLimit, setTokenLimit] = useState<number>(TOKEN_LIMIT_DEFAULT);
|
||||||
const [isTokenLimitLoaded, setIsTokenLimitLoaded] = useState(false);
|
const [isTokenLimitLoaded, setIsTokenLimitLoaded] = useState(false);
|
||||||
|
const [autoCompactThreshold, setAutoCompactThreshold] = useState<number>(0.8); // Default to 80%
|
||||||
|
|
||||||
// Draft functionality - get chat context and global draft context
|
// 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)
|
// 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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [currentModel, currentProvider]);
|
}, [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
|
// Handle tool count alerts and token usage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
clearAlerts();
|
clearAlerts();
|
||||||
@@ -522,6 +573,7 @@ export default function ChatInput({
|
|||||||
handleManualCompaction(messages, setMessages, append, setAncestorMessages);
|
handleManualCompaction(messages, setMessages, append, setAncestorMessages);
|
||||||
},
|
},
|
||||||
compactIcon: <ScrollText size={12} />,
|
compactIcon: <ScrollText size={12} />,
|
||||||
|
autoCompactThreshold: autoCompactThreshold,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -539,7 +591,16 @@ export default function ChatInput({
|
|||||||
}
|
}
|
||||||
// We intentionally omit setView as it shouldn't trigger a re-render of alerts
|
// We intentionally omit setView as it shouldn't trigger a re-render of alerts
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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
|
// Cleanup effect for component unmount - prevent memory leaks
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { IoIosCloseCircle, IoIosWarning, IoIosInformationCircle } from 'react-icons/io';
|
import { IoIosCloseCircle, IoIosWarning, IoIosInformationCircle } from 'react-icons/io';
|
||||||
|
import { FaPencilAlt, FaSave } from 'react-icons/fa';
|
||||||
import { cn } from '../../utils';
|
import { cn } from '../../utils';
|
||||||
import { Alert, AlertType } from './types';
|
import { Alert, AlertType } from './types';
|
||||||
|
import { getApiUrl } from '../../config';
|
||||||
|
|
||||||
const alertIcons: Record<AlertType, React.ReactNode> = {
|
const alertIcons: Record<AlertType, React.ReactNode> = {
|
||||||
[AlertType.Error]: <IoIosCloseCircle className="h-5 w-5" />,
|
[AlertType.Error]: <IoIosCloseCircle className="h-5 w-5" />,
|
||||||
@@ -22,27 +24,218 @@ const alertStyles: Record<AlertType, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const AlertBox = ({ alert, className }: AlertBoxProps) => {
|
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 (
|
return (
|
||||||
<div className={cn('flex flex-col gap-2 px-3 py-3', alertStyles[alert.type], className)}>
|
<div
|
||||||
|
className={cn('flex flex-col gap-2 px-3 py-3', alertStyles[alert.type], className)}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
// Prevent popover from closing when clicking inside the alert box
|
||||||
|
if (isEditingThreshold) {
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
{alert.progress ? (
|
{alert.progress ? (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="text-[11px]">{alert.message}</span>
|
<span className="text-[11px]">{alert.message}</span>
|
||||||
<div className="flex justify-between w-full">
|
|
||||||
{[...Array(30)].map((_, i) => (
|
{/* Auto-compact threshold indicator with edit */}
|
||||||
<div
|
{alert.autoCompactThreshold !== undefined && (
|
||||||
key={i}
|
<div className="flex items-center justify-center gap-1 min-h-[20px]">
|
||||||
className={cn(
|
{isEditingThreshold ? (
|
||||||
'h-[2px] w-[2px] rounded-full',
|
<>
|
||||||
alert.type === AlertType.Info
|
<span className="text-[10px] opacity-70">Auto summarize at</span>
|
||||||
? i < Math.round((alert.progress!.current / alert.progress!.total) * 30)
|
<input
|
||||||
? 'dark:bg-black bg-white'
|
type="number"
|
||||||
: 'dark:bg-black/20 bg-white/20'
|
min="0"
|
||||||
: i < Math.round((alert.progress!.current / alert.progress!.total) * 30)
|
max="100"
|
||||||
? 'bg-white'
|
step="1"
|
||||||
: 'bg-white/20'
|
value={thresholdValue}
|
||||||
)}
|
onChange={(e) => {
|
||||||
/>
|
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
|
||||||
|
/>
|
||||||
|
<span className="text-[10px] opacity-70">%</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
handleSaveThreshold();
|
||||||
|
}}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="p-1 hover:opacity-60 transition-opacity cursor-pointer relative z-50"
|
||||||
|
style={{ minWidth: '20px', minHeight: '20px', pointerEvents: 'auto' }}
|
||||||
|
>
|
||||||
|
<FaSave className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="text-[10px] opacity-70">
|
||||||
|
{alert.autoCompactThreshold === 0 || alert.autoCompactThreshold === 1
|
||||||
|
? 'Auto summarize disabled'
|
||||||
|
: `Auto summarize at ${Math.round(alert.autoCompactThreshold * 100)}%`}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsEditingThreshold(true);
|
||||||
|
}}
|
||||||
|
className="p-1 hover:opacity-60 transition-opacity cursor-pointer relative z-10"
|
||||||
|
style={{ minWidth: '20px', minHeight: '20px' }}
|
||||||
|
>
|
||||||
|
<FaPencilAlt className="w-3 h-3 opacity-70" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-between w-full relative">
|
||||||
|
{[...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 (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={cn(
|
||||||
|
'rounded-full transition-all relative',
|
||||||
|
isThresholdDot
|
||||||
|
? 'h-[6px] w-[6px] -mt-[2px]' // Make threshold dot twice as large
|
||||||
|
: 'h-[2px] w-[2px]',
|
||||||
|
isActive ? progressColor : inactiveColor
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-baseline text-[11px]">
|
<div className="flex justify-between items-baseline text-[11px]">
|
||||||
<div className="flex gap-1 items-baseline">
|
<div className="flex gap-1 items-baseline">
|
||||||
|
|||||||
@@ -20,4 +20,5 @@ export interface Alert {
|
|||||||
compactButtonDisabled?: boolean;
|
compactButtonDisabled?: boolean;
|
||||||
onCompact?: () => void;
|
onCompact?: () => void;
|
||||||
compactIcon?: React.ReactNode;
|
compactIcon?: React.ReactNode;
|
||||||
|
autoCompactThreshold?: number; // Add this for showing the auto-compact threshold
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,21 +132,34 @@ export default function BottomMenuAlertPopover({ alerts }: AlertPopoverProps) {
|
|||||||
}
|
}
|
||||||
}, [isHovered, isOpen, startHideTimer, wasAutoShown]);
|
}, [isHovered, isOpen, startHideTimer, wasAutoShown]);
|
||||||
|
|
||||||
// Handle click outside
|
// Handle click outside - but not when editing threshold
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
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);
|
setIsOpen(false);
|
||||||
setWasAutoShown(false);
|
setWasAutoShown(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
// Use mouseup instead of mousedown to allow button clicks to complete
|
||||||
|
document.addEventListener('mouseup', handleClickOutside);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('mousedown', handleClickOutside);
|
document.removeEventListener('mouseup', handleClickOutside);
|
||||||
};
|
};
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user