mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
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:
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"))],
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
};
|
||||
+89
-8
@@ -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>
|
||||
);
|
||||
}
|
||||
+5
-5
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user