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
+2 -2
View File
@@ -4,8 +4,7 @@ use goose::agents::ExtensionConfig;
use goose::config::permission::PermissionLevel;
use goose::config::ExtensionEntry;
use goose::permission::permission_confirmation::PrincipalType;
use goose::providers::base::ConfigKey;
use goose::providers::base::ProviderMetadata;
use goose::providers::base::{ConfigKey, ModelInfo, ProviderMetadata};
use mcp_core::tool::{Tool, ToolAnnotations};
use utoipa::OpenApi;
@@ -47,6 +46,7 @@ use utoipa::OpenApi;
ToolInfo,
PermissionLevel,
PrincipalType,
ModelInfo,
))
)]
pub struct ApiDoc;
+1 -4
View File
@@ -124,10 +124,7 @@ impl Provider for AnthropicProvider {
"Anthropic",
"Claude and other models from Anthropic",
ANTHROPIC_DEFAULT_MODEL,
ANTHROPIC_KNOWN_MODELS
.iter()
.map(|&s| s.to_string())
.collect(),
ANTHROPIC_KNOWN_MODELS.to_vec(),
ANTHROPIC_DOC_URL,
vec![
ConfigKey::new("ANTHROPIC_API_KEY", true, true, None),
+1 -4
View File
@@ -101,10 +101,7 @@ impl Provider for AzureProvider {
"Azure OpenAI",
"Models through Azure OpenAI Service",
"gpt-4o",
AZURE_OPENAI_KNOWN_MODELS
.iter()
.map(|s| s.to_string())
.collect(),
AZURE_OPENAI_KNOWN_MODELS.to_vec(),
AZURE_DOC_URL,
vec![
ConfigKey::new("AZURE_OPENAI_API_KEY", true, true, None),
+77 -4
View File
@@ -25,6 +25,15 @@ pub fn get_current_model() -> Option<String> {
CURRENT_MODEL.lock().ok().and_then(|model| model.clone())
}
/// Information about a model's capabilities
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq)]
pub struct ModelInfo {
/// The name of the model
pub name: String,
/// The maximum context length this model supports
pub context_limit: usize,
}
/// Metadata about a provider's configuration requirements and capabilities
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ProviderMetadata {
@@ -36,9 +45,9 @@ pub struct ProviderMetadata {
pub description: String,
/// The default/recommended model for this provider
pub default_model: String,
/// A list of currently known models
/// A list of currently known models with their capabilities
/// TODO: eventually query the apis directly
pub known_models: Vec<String>,
pub known_models: Vec<ModelInfo>,
/// Link to the docs where models can be found
pub model_doc_link: String,
/// Required configuration keys
@@ -51,7 +60,7 @@ impl ProviderMetadata {
display_name: &str,
description: &str,
default_model: &str,
known_models: Vec<String>,
model_names: Vec<&str>,
model_doc_link: &str,
config_keys: Vec<ConfigKey>,
) -> Self {
@@ -60,7 +69,13 @@ impl ProviderMetadata {
display_name: display_name.to_string(),
description: description.to_string(),
default_model: default_model.to_string(),
known_models,
known_models: model_names
.iter()
.map(|&name| ModelInfo {
name: name.to_string(),
context_limit: ModelConfig::new(name.to_string()).context_limit(),
})
.collect(),
model_doc_link: model_doc_link.to_string(),
config_keys,
}
@@ -168,6 +183,7 @@ pub trait Provider: Send + Sync {
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use serde_json::json;
@@ -214,4 +230,61 @@ mod tests {
let model = get_current_model();
assert_eq!(model, Some("claude-3.5-sonnet".to_string()));
}
#[test]
fn test_provider_metadata_context_limits() {
// Test that ProviderMetadata::new correctly sets context limits
let test_models = vec!["gpt-4o", "claude-3-5-sonnet-latest", "unknown-model"];
let metadata = ProviderMetadata::new(
"test",
"Test Provider",
"Test Description",
"gpt-4o",
test_models,
"https://example.com",
vec![],
);
let model_info: HashMap<String, usize> = metadata
.known_models
.into_iter()
.map(|m| (m.name, m.context_limit))
.collect();
// gpt-4o should have 128k limit
assert_eq!(*model_info.get("gpt-4o").unwrap(), 128_000);
// claude-3-5-sonnet-latest should have 200k limit
assert_eq!(
*model_info.get("claude-3-5-sonnet-latest").unwrap(),
200_000
);
// unknown model should have default limit (128k)
assert_eq!(*model_info.get("unknown-model").unwrap(), 128_000);
}
#[test]
fn test_model_info_creation() {
// Test direct ModelInfo creation
let info = ModelInfo {
name: "test-model".to_string(),
context_limit: 1000,
};
assert_eq!(info.context_limit, 1000);
// Test equality
let info2 = ModelInfo {
name: "test-model".to_string(),
context_limit: 1000,
};
assert_eq!(info, info2);
// Test inequality
let info3 = ModelInfo {
name: "test-model".to_string(),
context_limit: 2000,
};
assert_ne!(info, info3);
}
}
+1 -1
View File
@@ -85,7 +85,7 @@ impl Provider for BedrockProvider {
"Amazon Bedrock",
"Run models through Amazon Bedrock. You may have to set 'AWS_' environment variables to configure authentication.",
BEDROCK_DEFAULT_MODEL,
BEDROCK_KNOWN_MODELS.iter().map(|s| s.to_string()).collect(),
BEDROCK_KNOWN_MODELS.to_vec(),
BEDROCK_DOC_LINK,
vec![ConfigKey::new("AWS_PROFILE", true, false, Some("default"))],
)
+1 -4
View File
@@ -248,10 +248,7 @@ impl Provider for DatabricksProvider {
"Databricks",
"Models on Databricks AI Gateway",
DATABRICKS_DEFAULT_MODEL,
DATABRICKS_KNOWN_MODELS
.iter()
.map(|&s| s.to_string())
.collect(),
DATABRICKS_KNOWN_MODELS.to_vec(),
DATABRICKS_DOC_URL,
vec![
ConfigKey::new("DATABRICKS_HOST", true, false, None),
+10 -7
View File
@@ -425,7 +425,7 @@ impl Provider for GcpVertexAIProvider {
where
Self: Sized,
{
let known_models = vec![
let model_strings: Vec<String> = vec![
GcpVertexAIModel::Claude(ClaudeVersion::Sonnet35),
GcpVertexAIModel::Claude(ClaudeVersion::Sonnet35V2),
GcpVertexAIModel::Claude(ClaudeVersion::Sonnet37),
@@ -434,10 +434,12 @@ impl Provider for GcpVertexAIProvider {
GcpVertexAIModel::Gemini(GeminiVersion::Flash20),
GcpVertexAIModel::Gemini(GeminiVersion::Pro20Exp),
]
.into_iter()
.iter()
.map(|model| model.to_string())
.collect();
let known_models: Vec<&str> = model_strings.iter().map(|s| s.as_str()).collect();
ProviderMetadata::new(
"gcp_vertex_ai",
"GCP Vertex AI",
@@ -583,12 +585,13 @@ mod tests {
#[test]
fn test_provider_metadata() {
let metadata = GcpVertexAIProvider::metadata();
assert!(metadata
let model_names: Vec<String> = metadata
.known_models
.contains(&"claude-3-5-sonnet-v2@20241022".to_string()));
assert!(metadata
.known_models
.contains(&"gemini-1.5-pro-002".to_string()));
.iter()
.map(|m| m.name.clone())
.collect();
assert!(model_names.contains(&"claude-3-5-sonnet-v2@20241022".to_string()));
assert!(model_names.contains(&"gemini-1.5-pro-002".to_string()));
// Should contain the original 2 config keys plus 4 new retry-related ones
assert_eq!(metadata.config_keys.len(), 6);
}
+1 -1
View File
@@ -129,7 +129,7 @@ impl Provider for GoogleProvider {
"Google Gemini",
"Gemini models from Google AI",
GOOGLE_DEFAULT_MODEL,
GOOGLE_KNOWN_MODELS.iter().map(|&s| s.to_string()).collect(),
GOOGLE_KNOWN_MODELS.to_vec(),
GOOGLE_DOC_URL,
vec![
ConfigKey::new("GOOGLE_API_KEY", true, true, None),
+1 -1
View File
@@ -105,7 +105,7 @@ impl Provider for GroqProvider {
"Groq",
"Fast inference with Groq hardware",
GROQ_DEFAULT_MODEL,
GROQ_KNOWN_MODELS.iter().map(|&s| s.to_string()).collect(),
GROQ_KNOWN_MODELS.to_vec(),
GROQ_DOC_URL,
vec![
ConfigKey::new("GROQ_API_KEY", true, true, None),
+1 -1
View File
@@ -102,7 +102,7 @@ impl Provider for OllamaProvider {
"Ollama",
"Local open source models",
OLLAMA_DEFAULT_MODEL,
OLLAMA_KNOWN_MODELS.iter().map(|&s| s.to_string()).collect(),
OLLAMA_KNOWN_MODELS.to_vec(),
OLLAMA_DOC_URL,
vec![ConfigKey::new(
"OLLAMA_HOST",
+1 -4
View File
@@ -122,10 +122,7 @@ impl Provider for OpenAiProvider {
"OpenAI",
"GPT-4 and other OpenAI models, including OpenAI compatible ones",
OPEN_AI_DEFAULT_MODEL,
OPEN_AI_KNOWN_MODELS
.iter()
.map(|&s| s.to_string())
.collect(),
OPEN_AI_KNOWN_MODELS.to_vec(),
OPEN_AI_DOC_URL,
vec![
ConfigKey::new("OPENAI_API_KEY", true, true, None),
+1 -4
View File
@@ -229,10 +229,7 @@ impl Provider for OpenRouterProvider {
"OpenRouter",
"Router for many model providers",
OPENROUTER_DEFAULT_MODEL,
OPENROUTER_KNOWN_MODELS
.iter()
.map(|&s| s.to_string())
.collect(),
OPENROUTER_KNOWN_MODELS.to_vec(),
OPENROUTER_DOC_URL,
vec![
ConfigKey::new("OPENROUTER_API_KEY", true, true, None),
+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'
);
}