diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index 54ad4a15..33607ade 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -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; diff --git a/crates/goose/src/providers/anthropic.rs b/crates/goose/src/providers/anthropic.rs index ef4aff43..3272f43e 100644 --- a/crates/goose/src/providers/anthropic.rs +++ b/crates/goose/src/providers/anthropic.rs @@ -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), diff --git a/crates/goose/src/providers/azure.rs b/crates/goose/src/providers/azure.rs index 81e9df10..0ef9203e 100644 --- a/crates/goose/src/providers/azure.rs +++ b/crates/goose/src/providers/azure.rs @@ -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), diff --git a/crates/goose/src/providers/base.rs b/crates/goose/src/providers/base.rs index f0771455..6ab1bbe5 100644 --- a/crates/goose/src/providers/base.rs +++ b/crates/goose/src/providers/base.rs @@ -25,6 +25,15 @@ pub fn get_current_model() -> Option { 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, + pub known_models: Vec, /// 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, + model_names: Vec<&str>, model_doc_link: &str, config_keys: Vec, ) -> 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 = 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); + } } diff --git a/crates/goose/src/providers/bedrock.rs b/crates/goose/src/providers/bedrock.rs index 1e047d2c..31e6cf8b 100644 --- a/crates/goose/src/providers/bedrock.rs +++ b/crates/goose/src/providers/bedrock.rs @@ -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"))], ) diff --git a/crates/goose/src/providers/databricks.rs b/crates/goose/src/providers/databricks.rs index 43b21ece..e08bed35 100644 --- a/crates/goose/src/providers/databricks.rs +++ b/crates/goose/src/providers/databricks.rs @@ -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), diff --git a/crates/goose/src/providers/gcpvertexai.rs b/crates/goose/src/providers/gcpvertexai.rs index cd89376e..f0b6d6ef 100644 --- a/crates/goose/src/providers/gcpvertexai.rs +++ b/crates/goose/src/providers/gcpvertexai.rs @@ -425,7 +425,7 @@ impl Provider for GcpVertexAIProvider { where Self: Sized, { - let known_models = vec![ + let model_strings: Vec = 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 = 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); } diff --git a/crates/goose/src/providers/google.rs b/crates/goose/src/providers/google.rs index 91c50b4d..de2628fd 100644 --- a/crates/goose/src/providers/google.rs +++ b/crates/goose/src/providers/google.rs @@ -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), diff --git a/crates/goose/src/providers/groq.rs b/crates/goose/src/providers/groq.rs index 793d7801..149499f5 100644 --- a/crates/goose/src/providers/groq.rs +++ b/crates/goose/src/providers/groq.rs @@ -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), diff --git a/crates/goose/src/providers/ollama.rs b/crates/goose/src/providers/ollama.rs index 169c7c64..7dc7f1a5 100644 --- a/crates/goose/src/providers/ollama.rs +++ b/crates/goose/src/providers/ollama.rs @@ -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", diff --git a/crates/goose/src/providers/openai.rs b/crates/goose/src/providers/openai.rs index 4bebda74..2ecb45ba 100644 --- a/crates/goose/src/providers/openai.rs +++ b/crates/goose/src/providers/openai.rs @@ -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), diff --git a/crates/goose/src/providers/openrouter.rs b/crates/goose/src/providers/openrouter.rs index 79059fc9..c1684b00 100644 --- a/crates/goose/src/providers/openrouter.rs +++ b/crates/goose/src/providers/openrouter.rs @@ -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), diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index c398e17b..00207ddf 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -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", diff --git a/ui/desktop/package.json b/ui/desktop/package.json index 7e2fd515..258920b7 100644 --- a/ui/desktop/package.json +++ b/ui/desktop/package.json @@ -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", diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 94289b42..7e9a62f3 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -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); diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 640bfa70..f8d97be5 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -100,6 +100,20 @@ export type ExtensionResponse = { extensions: Array; }; +/** + * 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; + known_models: Array; /** * Link to the docs where models can be found */ diff --git a/ui/desktop/src/components/ChatView.tsx b/ui/desktop/src/components/ChatView.tsx index 073feaff..2c139ba8 100644 --- a/ui/desktop/src/components/ChatView.tsx +++ b/ui/desktop/src/components/ChatView.tsx @@ -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(Date.now()); const [showGame, setShowGame] = useState(false); const [isGeneratingRecipe, setIsGeneratingRecipe] = useState(false); + const [sessionTokenCount, setSessionTokenCount] = useState(0); const scrollRef = useRef(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 (
{/* Loader when generating recipe */} @@ -449,7 +466,7 @@ export default function ChatView({ commandHistory={commandHistory} initialValue={_input} /> - +
diff --git a/ui/desktop/src/components/alerts/AlertBox.tsx b/ui/desktop/src/components/alerts/AlertBox.tsx new file mode 100644 index 00000000..cc098711 --- /dev/null +++ b/ui/desktop/src/components/alerts/AlertBox.tsx @@ -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.Error]: , + [AlertType.Warning]: , +}; + +interface AlertBoxProps { + alert: Alert; + className?: string; +} + +const alertStyles: Record = { + [AlertType.Error]: 'bg-[#d7040e] text-white', + [AlertType.Warning]: 'bg-[#cc4b03] text-white', +}; + +export const AlertBox = ({ alert, className }: AlertBoxProps) => { + return ( +
+
{alertIcons[alert.type]}
+
+ {alert.message} + {alert.action && ( + + )} +
+
+ ); +}; diff --git a/ui/desktop/src/components/alerts/index.ts b/ui/desktop/src/components/alerts/index.ts new file mode 100644 index 00000000..470a5c7f --- /dev/null +++ b/ui/desktop/src/components/alerts/index.ts @@ -0,0 +1,3 @@ +export * from './AlertBox'; +export * from './types'; +export * from './useAlerts'; diff --git a/ui/desktop/src/components/alerts/types.ts b/ui/desktop/src/components/alerts/types.ts new file mode 100644 index 00000000..7fdfca54 --- /dev/null +++ b/ui/desktop/src/components/alerts/types.ts @@ -0,0 +1,13 @@ +export enum AlertType { + Error = 'error', + Warning = 'warning', +} + +export interface Alert { + type: AlertType; + message: string; + action?: { + text: string; + onClick: () => void; + }; +} diff --git a/ui/desktop/src/components/alerts/useAlerts.ts b/ui/desktop/src/components/alerts/useAlerts.ts new file mode 100644 index 00000000..77076036 --- /dev/null +++ b/ui/desktop/src/components/alerts/useAlerts.ts @@ -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([]); + + 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, + }; +}; diff --git a/ui/desktop/src/components/alerts/useToolCount.ts b/ui/desktop/src/components/alerts/useToolCount.ts new file mode 100644 index 00000000..94e00f60 --- /dev/null +++ b/ui/desktop/src/components/alerts/useToolCount.ts @@ -0,0 +1,36 @@ +import { useState, useEffect } from 'react'; +import { getTools } from '../../api'; + +const { clearTimeout } = window; + +export const useToolCount = () => { + const [toolCount, setToolCount] = useState(null); + + useEffect(() => { + let timeoutId: ReturnType; + + 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; +}; diff --git a/ui/desktop/src/components/BottomMenu.tsx b/ui/desktop/src/components/bottom_menu/BottomMenu.tsx similarity index 60% rename from ui/desktop/src/components/BottomMenu.tsx rename to ui/desktop/src/components/bottom_menu/BottomMenu.tsx index b2982cf4..128c8390 100644 --- a/ui/desktop/src/components/BottomMenu.tsx +++ b/ui/desktop/src/components/bottom_menu/BottomMenu.tsx @@ -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(null); + const toolCount = useToolCount(); + const { getProviders, read } = useConfig(); + const [tokenLimit, setTokenLimit] = useState(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 (
{/* Directory Chooser - Always visible */} @@ -78,6 +157,8 @@ export default function BottomMenu({ {/* Right-side section with ToolCount and Model Selector together */}
+ {/* Tool and Token count */} + {} {/* Model Selector Dropdown */} {settingsV2Enabled ? ( diff --git a/ui/desktop/src/components/bottom_menu/BottomMenuAlertPopover.tsx b/ui/desktop/src/components/bottom_menu/BottomMenuAlertPopover.tsx new file mode 100644 index 00000000..e40464dd --- /dev/null +++ b/ui/desktop/src/components/bottom_menu/BottomMenuAlertPopover.tsx @@ -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([]); + const hideTimerRef = useRef>(); + const popoverRef = useRef(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 ( +
+ +
+ +
{ + 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); + }} + > + +
+
+ + {/* Small connector area between trigger and content */} + {isOpen && ( +
{ + setIsHovered(true); + if (hideTimerRef.current) { + clearTimeout(hideTimerRef.current); + } + }} + onMouseLeave={() => { + setIsHovered(false); + }} + /> + )} + + { + setIsHovered(true); + if (hideTimerRef.current) { + clearTimeout(hideTimerRef.current); + } + }} + onMouseLeave={() => { + setIsHovered(false); + }} + > +
+ {alerts.map((alert, index) => ( +
0 && 'border-t border-white/20')}> + +
+ ))} +
+
+
+ +
+ ); +} diff --git a/ui/desktop/src/components/BottomMenuModeSelection.tsx b/ui/desktop/src/components/bottom_menu/BottomMenuModeSelection.tsx similarity index 93% rename from ui/desktop/src/components/BottomMenuModeSelection.tsx rename to ui/desktop/src/components/bottom_menu/BottomMenuModeSelection.tsx index 42a3a487..d6f4e12c 100644 --- a/ui/desktop/src/components/BottomMenuModeSelection.tsx +++ b/ui/desktop/src/components/bottom_menu/BottomMenuModeSelection.tsx @@ -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 { diff --git a/ui/desktop/src/components/settings_v2/models/subcomponents/AddModelModal.tsx b/ui/desktop/src/components/settings_v2/models/subcomponents/AddModelModal.tsx index 9f68dc0e..e2610fed 100644 --- a/ui/desktop/src/components/settings_v2/models/subcomponents/AddModelModal.tsx +++ b/ui/desktop/src/components/settings_v2/models/subcomponents/AddModelModal.tsx @@ -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 }) => (
@@ -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(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, diff --git a/ui/desktop/src/types/message.ts b/ui/desktop/src/types/message.ts index 078defd4..b7a10c43 100644 --- a/ui/desktop/src/types/message.ts +++ b/ui/desktop/src/types/message.ts @@ -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' ); }