Chat bottom menu bar token and tools alerts (#2146)

Co-authored-by: Lily Delalande <119957291+lily-de@users.noreply.github.com>
This commit is contained in:
Zane
2025-04-18 10:43:50 -07:00
committed by GitHub
parent f850db1847
commit e859ad1115
27 changed files with 560 additions and 65 deletions
+21 -2
View File
@@ -669,6 +669,25 @@
}
}
},
"ModelInfo": {
"type": "object",
"description": "Information about a model's capabilities",
"required": [
"name",
"context_limit"
],
"properties": {
"context_limit": {
"type": "integer",
"description": "The maximum context length this model supports",
"minimum": 0
},
"name": {
"type": "string",
"description": "The name of the model"
}
}
},
"PermissionConfirmationRequest": {
"type": "object",
"required": [
@@ -759,9 +778,9 @@
"known_models": {
"type": "array",
"items": {
"type": "string"
"$ref": "#/components/schemas/ModelInfo"
},
"description": "A list of currently known models\nTODO: eventually query the apis directly"
"description": "A list of currently known models with their capabilities\nTODO: eventually query the apis directly"
},
"model_doc_link": {
"type": "string",
+2 -1
View File
@@ -29,7 +29,8 @@
"format": "prettier --write \"src/**/*.{ts,tsx,css,json}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,css,json}\"",
"prepare": "cd ../.. && husky install",
"start-alpha-gui": "ALPHA=true npm run start-gui"
"start-alpha-gui": "ALPHA=true npm run start-gui",
"start-alpha-server": "cd ../.. && just run-ui-alpha"
},
"devDependencies": {
"@electron-forge/cli": "^7.5.0",
+2 -1
View File
@@ -357,7 +357,8 @@ export default function App() {
console.error('Unhandled error in initialization:', error);
setFatalError(`${error instanceof Error ? error.message : 'Unknown error'}`);
});
}, [read, getExtensions, addExtension, enableRecipeConfigExtensionsV2]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Empty dependency array since we only want this to run once
const [isGoosehintsModalOpen, setIsGoosehintsModalOpen] = useState(false);
const [isLoadingSession, setIsLoadingSession] = useState(false);
+16 -2
View File
@@ -100,6 +100,20 @@ export type ExtensionResponse = {
extensions: Array<ExtensionEntry>;
};
/**
* Information about a model's capabilities
*/
export type ModelInfo = {
/**
* The maximum context length this model supports
*/
context_limit: number;
/**
* The name of the model
*/
name: string;
};
export type PermissionConfirmationRequest = {
action: string;
id: string;
@@ -146,10 +160,10 @@ export type ProviderMetadata = {
*/
display_name: string;
/**
* A list of currently known models
* A list of currently known models with their capabilities
* TODO: eventually query the apis directly
*/
known_models: Array<string>;
known_models: Array<ModelInfo>;
/**
* Link to the docs where models can be found
*/
+19 -2
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState, useMemo } from 'react';
import { getApiUrl } from '../config';
import BottomMenu from './BottomMenu';
import BottomMenu from './bottom_menu/BottomMenu';
import FlappyGoose from './FlappyGoose';
import GooseMessage from './GooseMessage';
import Input from './Input';
@@ -15,6 +15,7 @@ import { SearchView } from './conversation/SearchView';
import { createRecipe } from '../recipe';
import { AgentHeader } from './AgentHeader';
import LayingEggLoader from './LayingEggLoader';
import { fetchSessionDetails } from '../sessions';
// import { configureRecipeExtensions } from '../utils/recipeExtensions';
import 'react-toastify/dist/ReactToastify.css';
import { useMessageStream } from '../hooks/useMessageStream';
@@ -70,6 +71,7 @@ export default function ChatView({
const [lastInteractionTime, setLastInteractionTime] = useState<number>(Date.now());
const [showGame, setShowGame] = useState(false);
const [isGeneratingRecipe, setIsGeneratingRecipe] = useState(false);
const [sessionTokenCount, setSessionTokenCount] = useState<number>(0);
const scrollRef = useRef<ScrollAreaHandle>(null);
// Get recipeConfig directly from appConfig
@@ -358,6 +360,21 @@ export default function ChatView({
.reverse();
}, [filteredMessages]);
// Fetch session metadata to get token count
useEffect(() => {
const fetchSessionTokens = async () => {
try {
const sessionDetails = await fetchSessionDetails(chat.id);
setSessionTokenCount(sessionDetails.metadata.total_tokens);
} catch (err) {
console.error('Error fetching session token count:', err);
}
};
if (chat.id) {
fetchSessionTokens();
}
}, [chat.id, messages]);
return (
<div className="flex flex-col w-full h-screen items-center justify-center">
{/* Loader when generating recipe */}
@@ -449,7 +466,7 @@ export default function ChatView({
commandHistory={commandHistory}
initialValue={_input}
/>
<BottomMenu hasMessages={hasMessages} setView={setView} />
<BottomMenu hasMessages={hasMessages} setView={setView} numTokens={sessionTokenCount} />
</div>
</Card>
@@ -0,0 +1,41 @@
import React from 'react';
import { IoIosCloseCircle, IoIosWarning } from 'react-icons/io';
import { cn } from '../../utils';
import { Alert, AlertType } from './types';
const alertIcons: Record<AlertType, React.ReactNode> = {
[AlertType.Error]: <IoIosCloseCircle className="h-5 w-5" />,
[AlertType.Warning]: <IoIosWarning className="h-5 w-5" />,
};
interface AlertBoxProps {
alert: Alert;
className?: string;
}
const alertStyles: Record<AlertType, string> = {
[AlertType.Error]: 'bg-[#d7040e] text-white',
[AlertType.Warning]: 'bg-[#cc4b03] text-white',
};
export const AlertBox = ({ alert, className }: AlertBoxProps) => {
return (
<div className={cn('flex items-center gap-2 px-3 py-2', alertStyles[alert.type], className)}>
<div className="flex-shrink-0">{alertIcons[alert.type]}</div>
<div className="flex flex-col gap-2 flex-1">
<span className="text-[11px] break-words whitespace-pre-line">{alert.message}</span>
{alert.action && (
<button
onClick={(e) => {
e.stopPropagation();
alert.action?.onClick();
}}
className="text-[11px] text-left underline hover:opacity-80 cursor-pointer outline-none"
>
{alert.action.text}
</button>
)}
</div>
</div>
);
};
@@ -0,0 +1,3 @@
export * from './AlertBox';
export * from './types';
export * from './useAlerts';
+13
View File
@@ -0,0 +1,13 @@
export enum AlertType {
Error = 'error',
Warning = 'warning',
}
export interface Alert {
type: AlertType;
message: string;
action?: {
text: string;
onClick: () => void;
};
}
@@ -0,0 +1,39 @@
import { useState, useCallback } from 'react';
import { Alert, AlertType } from './types';
interface UseAlerts {
alerts: Alert[];
addAlert: (
type: AlertType,
message: string,
action?: { text: string; onClick: () => void }
) => void;
removeAlert: (index: number) => void;
clearAlerts: () => void;
}
export const useAlerts = (): UseAlerts => {
const [alerts, setAlerts] = useState<Alert[]>([]);
const addAlert = useCallback(
(type: AlertType, message: string, action?: { text: string; onClick: () => void }) => {
setAlerts((prev) => [...prev, { type, message, action }]);
},
[]
);
const removeAlert = useCallback((index: number) => {
setAlerts((prev) => prev.filter((_, i) => i !== index));
}, []);
const clearAlerts = useCallback(() => {
setAlerts([]);
}, []);
return {
alerts,
addAlert,
removeAlert,
clearAlerts,
};
};
@@ -0,0 +1,36 @@
import { useState, useEffect } from 'react';
import { getTools } from '../../api';
const { clearTimeout } = window;
export const useToolCount = () => {
const [toolCount, setToolCount] = useState<number | null>(null);
useEffect(() => {
let timeoutId: ReturnType<typeof setTimeout>;
const fetchTools = async () => {
try {
const response = await getTools();
if (!response.error && response.data) {
setToolCount(response.data.length);
} else {
setToolCount(0);
}
} catch (err) {
console.error('Error fetching tools:', err);
setToolCount(0);
}
};
// Add initial 1s delay before first fetch
timeoutId = setTimeout(fetchTools, 1000);
// Cleanup timeouts on unmount
return () => {
clearTimeout(timeoutId);
};
}, []);
return toolCount;
};
@@ -1,23 +1,104 @@
import React, { useState, useEffect, useRef } from 'react';
import { useModel } from './settings/models/ModelContext';
import { useModel } from '../settings/models/ModelContext';
import { Sliders } from 'lucide-react';
import { ModelRadioList } from './settings/models/ModelRadioList';
import { Document, ChevronUp, ChevronDown } from './icons';
import type { View, ViewOptions } from '../App';
import { settingsV2Enabled } from '../flags';
import { AlertType, useAlerts } from '../alerts';
import { useToolCount } from '../alerts/useToolCount';
import BottomMenuAlertPopover from './BottomMenuAlertPopover';
import { ModelRadioList } from '../settings/models/ModelRadioList';
import { Document, ChevronUp, ChevronDown } from '../icons';
import type { View, ViewOptions } from '../../App';
import { settingsV2Enabled } from '../../flags';
import { BottomMenuModeSelection } from './BottomMenuModeSelection';
import ModelsBottomBar from './settings_v2/models/bottom_bar/ModelsBottomBar';
import ModelsBottomBar from '../settings_v2/models/bottom_bar/ModelsBottomBar';
import { useConfig } from '../ConfigContext';
import { getCurrentModelAndProvider } from '../settings_v2/models/index';
const TOKEN_LIMIT_DEFAULT = 128000; // fallback for custom models that the backend doesn't know about
const TOKEN_WARNING_THRESHOLD = 0.8; // warning shows at 80% of the token limit
const TOOLS_MAX_SUGGESTED = 25; // max number of tools before we show a warning
export default function BottomMenu({
hasMessages,
setView,
numTokens = 0,
}: {
hasMessages: boolean;
setView: (view: View, viewOptions?: ViewOptions) => void;
numTokens?: number;
}) {
const [isModelMenuOpen, setIsModelMenuOpen] = useState(false);
const { currentModel } = useModel();
const { alerts, addAlert, clearAlerts } = useAlerts();
const dropdownRef = useRef<HTMLDivElement>(null);
const toolCount = useToolCount();
const { getProviders, read } = useConfig();
const [tokenLimit, setTokenLimit] = useState<number>(TOKEN_LIMIT_DEFAULT);
// Load providers and get current model's token limit
const loadProviderDetails = async () => {
try {
// Get current model and provider first to avoid unnecessary provider fetches
const { model, provider } = await getCurrentModelAndProvider({ readFromConfig: read });
if (!model || !provider) {
console.log('No model or provider found');
return;
}
const providers = await getProviders(true);
// Find the provider details for the current provider
const currentProvider = providers.find((p) => p.name === provider);
if (currentProvider?.metadata?.known_models) {
// Find the model's token limit
const modelConfig = currentProvider.metadata.known_models.find((m) => m.name === model);
if (modelConfig?.context_limit) {
setTokenLimit(modelConfig.context_limit);
}
}
} catch (err) {
console.error('Error loading providers or token limit:', err);
}
};
// Initial load and refresh when model changes
useEffect(() => {
loadProviderDetails();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentModel]);
// Handle tool count alerts
useEffect(() => {
clearAlerts();
// Add token alerts if we have a token limit
if (tokenLimit && numTokens > 0) {
if (numTokens >= tokenLimit) {
addAlert(
AlertType.Error,
`Token limit reached (${numTokens.toLocaleString()}/${tokenLimit.toLocaleString()})`
);
} else if (numTokens >= tokenLimit * TOKEN_WARNING_THRESHOLD) {
addAlert(
AlertType.Warning,
`Approaching token limit (${numTokens.toLocaleString()}/${tokenLimit.toLocaleString()})`
);
}
}
// Add tool count alert if we have the data
if (toolCount !== null && toolCount > TOOLS_MAX_SUGGESTED) {
addAlert(
AlertType.Warning,
`Too many tools can degrade performance.\nTool count: ${toolCount} (recommend: ${TOOLS_MAX_SUGGESTED})`,
{
text: 'View extensions',
onClick: () => setView('settings'),
}
);
}
// 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, addAlert, clearAlerts]);
// Add effect to handle clicks outside
useEffect(() => {
@@ -53,8 +134,6 @@ export default function BottomMenu({
};
}, [isModelMenuOpen]);
// Removed the envModelProvider code that was checking for environment variables
return (
<div className="flex justify-between items-center text-textSubtle relative bg-bgSubtle border-t border-borderSubtle text-xs pl-4 h-[40px] pb-1 align-middle">
{/* Directory Chooser - Always visible */}
@@ -78,6 +157,8 @@ export default function BottomMenu({
{/* Right-side section with ToolCount and Model Selector together */}
<div className="flex items-center mr-4 space-x-1">
{/* Tool and Token count */}
{<BottomMenuAlertPopover alerts={alerts} />}
{/* Model Selector Dropdown */}
{settingsV2Enabled ? (
<ModelsBottomBar dropdownRef={dropdownRef} setView={setView} />
@@ -0,0 +1,164 @@
import React, { useRef, useEffect, useCallback } from 'react';
import { IoIosCloseCircle, IoIosWarning } from 'react-icons/io';
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
import { cn } from '../../utils';
import { Alert, AlertType } from '../alerts';
import { AlertBox } from '../alerts';
const { clearTimeout } = window;
interface AlertPopoverProps {
alerts: Alert[];
}
export default function BottomMenuAlertPopover({ alerts }: AlertPopoverProps) {
const [isOpen, setIsOpen] = React.useState(false);
const [hasShownInitial, setHasShownInitial] = React.useState(false);
const [isHovered, setIsHovered] = React.useState(false);
const [wasAutoShown, setWasAutoShown] = React.useState(false);
const previousAlertsRef = useRef<Alert[]>([]);
const hideTimerRef = useRef<ReturnType<typeof setTimeout>>();
const popoverRef = useRef<HTMLDivElement>(null);
// Function to start the hide timer
const startHideTimer = useCallback((duration = 3000) => {
// Clear any existing timer
if (hideTimerRef.current) {
clearTimeout(hideTimerRef.current);
}
// Start new timer
hideTimerRef.current = setTimeout(() => {
setIsOpen(false);
setWasAutoShown(false);
}, duration);
}, []);
// Handle initial show and new alerts
useEffect(() => {
if (alerts.length === 0) return;
// Compare current and previous alerts for any changes
const hasChanges = alerts.some((alert, index) => {
const prevAlert = previousAlertsRef.current[index];
return !prevAlert || prevAlert.type !== alert.type || prevAlert.message !== alert.message;
});
previousAlertsRef.current = alerts;
// Auto show the popover if there are new alerts
if (!hasShownInitial || hasChanges) {
setIsOpen(true);
setHasShownInitial(true);
setWasAutoShown(true);
// Start 3 second timer for auto-show
startHideTimer(3000);
}
}, [alerts, hasShownInitial, startHideTimer]);
// Handle auto-hide based on hover state changes
useEffect(() => {
if (!isHovered && isOpen && !wasAutoShown) {
// Only start 1 second timer for manual interactions
startHideTimer(1000);
}
}, [isHovered, isOpen, startHideTimer, wasAutoShown]);
// Handle click outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
setIsOpen(false);
setWasAutoShown(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
if (alerts.length === 0) return null;
// Determine the icon to show based on the highest priority alert
const hasError = alerts.some((alert) => alert.type === AlertType.Error);
const TriggerIcon = hasError ? IoIosCloseCircle : IoIosWarning;
const triggerColor = hasError ? 'text-[#d7040e]' : 'text-[#cc4b03]';
return (
<div ref={popoverRef}>
<Popover open={isOpen}>
<div className="relative">
<PopoverTrigger asChild>
<div
className="cursor-pointer flex items-center"
onClick={() => {
if (hideTimerRef.current) {
clearTimeout(hideTimerRef.current);
}
setWasAutoShown(false);
setIsOpen(!isOpen);
}}
onMouseEnter={() => {
setIsOpen(true);
setIsHovered(true);
setWasAutoShown(false);
if (hideTimerRef.current) {
clearTimeout(hideTimerRef.current);
}
}}
onMouseLeave={() => {
setIsHovered(false);
}}
>
<TriggerIcon className={cn('h-5 w-5', triggerColor)} />
</div>
</PopoverTrigger>
{/* Small connector area between trigger and content */}
{isOpen && (
<div
className="absolute -right-2 h-6 w-8 top-full"
onMouseEnter={() => {
setIsHovered(true);
if (hideTimerRef.current) {
clearTimeout(hideTimerRef.current);
}
}}
onMouseLeave={() => {
setIsHovered(false);
}}
/>
)}
<PopoverContent
className="w-[275px] p-0 rounded-lg overflow-hidden"
align="end"
alignOffset={-100}
sideOffset={5}
onMouseEnter={() => {
setIsHovered(true);
if (hideTimerRef.current) {
clearTimeout(hideTimerRef.current);
}
}}
onMouseLeave={() => {
setIsHovered(false);
}}
>
<div className="flex flex-col">
{alerts.map((alert, index) => (
<div key={index} className={cn(index > 0 && 'border-t border-white/20')}>
<AlertBox alert={alert} />
</div>
))}
</div>
</PopoverContent>
</div>
</Popover>
</div>
);
}
@@ -1,9 +1,9 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { getApiUrl, getSecretKey } from '../config';
import { ChevronDown, ChevronUp } from './icons';
import { all_goose_modes, ModeSelectionItem } from './settings_v2/mode/ModeSelectionItem';
import { useConfig } from './ConfigContext';
import { settingsV2Enabled } from '../flags';
import { getApiUrl, getSecretKey } from '../../config';
import { ChevronDown, ChevronUp } from '../icons';
import { all_goose_modes, ModeSelectionItem } from '../settings_v2/mode/ModeSelectionItem';
import { useConfig } from '../ConfigContext';
import { settingsV2Enabled } from '../../flags';
import { View, ViewOptions } from '../App';
interface BottomMenuModeSelectionProps {
@@ -10,6 +10,7 @@ import { useConfig } from '../../../ConfigContext';
import { changeModel } from '../index';
import type { View } from '../../../../App';
import Model, { getProviderMetadata } from '../modelInterface';
import { useModel } from '../../../settings/models/ModelContext';
const ModalButtons = ({ onSubmit, onCancel, _isValid, _validationErrors }) => (
<div>
@@ -38,6 +39,7 @@ type AddModelModalProps = {
};
export const AddModelModal = ({ onClose, setView }: AddModelModalProps) => {
const { getProviders, upsert, getExtensions, addExtension } = useConfig();
const { switchModel } = useModel();
const [providerOptions, setProviderOptions] = useState([]);
const [modelOptions, setModelOptions] = useState([]);
const [provider, setProvider] = useState<string | null>(null);
@@ -81,12 +83,18 @@ export const AddModelModal = ({ onClose, setView }: AddModelModalProps) => {
const providerMetaData = await getProviderMetadata(provider, getProviders);
const providerDisplayName = providerMetaData.display_name;
const modelObj = { name: model, provider: provider, subtext: providerDisplayName } as Model;
await changeModel({
model: { name: model, provider: provider, subtext: providerDisplayName } as Model, // pass in a Model object
model: modelObj,
writeToConfig: upsert,
getExtensions,
addExtension,
});
// Update the model context
switchModel(modelObj);
onClose();
}
};
@@ -120,7 +128,7 @@ export const AddModelModal = ({ onClose, setView }: AddModelModalProps) => {
activeProviders.forEach(({ metadata, name }) => {
if (metadata.known_models && metadata.known_models.length > 0) {
formattedModelOptions.push({
options: metadata.known_models.map((modelName) => ({
options: metadata.known_models.map(({ name: modelName }) => ({
value: modelName,
label: modelName,
provider: name,
+2 -5
View File
@@ -220,9 +220,7 @@ export function getToolResponses(message: Message): ToolResponseMessageContent[]
);
}
export function getExtensionRequests(
message: Message
): ExtensionRequestMessageContent[] {
export function getExtensionRequests(message: Message): ExtensionRequestMessageContent[] {
return message.content.filter(
(content): content is ExtensionRequestMessageContent => content.type === 'extensionRequest'
);
@@ -239,8 +237,7 @@ export function getToolConfirmationContent(
export function getExtensionContent(message: Message): ExtensionRequestMessageContent {
return message.content.find(
(content): content is ExtensionRequestMessageContent =>
content.type === 'extensionRequest'
(content): content is ExtensionRequestMessageContent => content.type === 'extensionRequest'
);
}