diff --git a/app/components/chat/StarterTemplates.tsx b/app/components/chat/StarterTemplates.tsx index b48c92ca..7e0ee5b1 100644 --- a/app/components/chat/StarterTemplates.tsx +++ b/app/components/chat/StarterTemplates.tsx @@ -14,7 +14,7 @@ const FrameworkLink: React.FC = ({ template }) => ( className="items-center justify-center " >
); diff --git a/app/components/settings/data/DataTab.tsx b/app/components/settings/data/DataTab.tsx index 431805ee..47e34ad4 100644 --- a/app/components/settings/data/DataTab.tsx +++ b/app/components/settings/data/DataTab.tsx @@ -2,7 +2,7 @@ import { useState, useRef } from 'react'; import { motion } from 'framer-motion'; import { toast } from 'react-toastify'; import { DialogRoot, DialogClose, Dialog, DialogTitle } from '~/components/ui/Dialog'; -import { db, getAll } from '~/lib/persistence'; +import { db, getAll, deleteById } from '~/lib/persistence'; export default function DataTab() { const [isDownloadingTemplate, setIsDownloadingTemplate] = useState(false); @@ -180,11 +180,21 @@ export default function DataTab() { setIsResetting(true); try { - // Clear all stored settings + // Clear all stored settings from localStorage localStorage.removeItem('bolt_user_profile'); localStorage.removeItem('bolt_settings'); localStorage.removeItem('bolt_chat_history'); + // Clear all data from IndexedDB + if (!db) { + throw new Error('Database not initialized'); + } + + // Get all chats and delete them + const chats = await getAll(db as IDBDatabase); + const deletePromises = chats.map((chat) => deleteById(db as IDBDatabase, chat.id)); + await Promise.all(deletePromises); + // Close the dialog first setShowResetInlineConfirm(false); @@ -204,9 +214,19 @@ export default function DataTab() { setIsDeleting(true); try { - // Clear chat history + // Clear chat history from localStorage localStorage.removeItem('bolt_chat_history'); + // Clear chats from IndexedDB + if (!db) { + throw new Error('Database not initialized'); + } + + // Get all chats and delete them one by one + const chats = await getAll(db as IDBDatabase); + const deletePromises = chats.map((chat) => deleteById(db as IDBDatabase, chat.id)); + await Promise.all(deletePromises); + // Close the dialog first setShowDeleteInlineConfirm(false); diff --git a/app/components/settings/developer/DeveloperWindow.tsx b/app/components/settings/developer/DeveloperWindow.tsx index f861023b..c2a59481 100644 --- a/app/components/settings/developer/DeveloperWindow.tsx +++ b/app/components/settings/developer/DeveloperWindow.tsx @@ -48,15 +48,16 @@ const TAB_DESCRIPTIONS: Record = { profile: 'Manage your profile and account settings', settings: 'Configure application preferences', notifications: 'View and manage your notifications', - features: 'Explore new and upcoming features', + features: 'Manage application features', data: 'Manage your data and storage', - 'cloud-providers': 'Configure cloud AI providers and models', - 'local-providers': 'Configure local AI providers and models', - connection: 'Check connection status and settings', - debug: 'Debug tools and system information', - 'event-logs': 'View system events and logs', - update: 'Check for updates and release notes', - 'task-manager': 'Monitor system resources and processes', + 'cloud-providers': 'Configure cloud AI providers', + 'local-providers': 'Configure local AI providers', + connection: 'View and manage connections', + debug: 'Debug application issues', + 'event-logs': 'View application event logs', + update: 'Check for updates', + 'task-manager': 'Manage running tasks', + 'service-status': 'View service health and status', }; const DraggableTabTile = ({ diff --git a/app/components/settings/developer/TabManagement.tsx b/app/components/settings/developer/TabManagement.tsx index 34b6e56a..ea90feed 100644 --- a/app/components/settings/developer/TabManagement.tsx +++ b/app/components/settings/developer/TabManagement.tsx @@ -19,7 +19,8 @@ const TAB_ICONS: Record = { debug: 'i-ph:bug-fill', 'event-logs': 'i-ph:list-bullets-fill', update: 'i-ph:arrow-clockwise-fill', - 'task-manager': 'i-ph:gauge-fill', + 'task-manager': 'i-ph:activity-fill', + 'service-status': 'i-ph:heartbeat-fill', }; interface TabGroupProps { diff --git a/app/components/settings/providers/ServiceStatusTab.tsx b/app/components/settings/providers/ServiceStatusTab.tsx new file mode 100644 index 00000000..b61ed043 --- /dev/null +++ b/app/components/settings/providers/ServiceStatusTab.tsx @@ -0,0 +1,886 @@ +import React, { useEffect, useState, useCallback } from 'react'; +import { motion } from 'framer-motion'; +import { classNames } from '~/utils/classNames'; +import { TbActivityHeartbeat } from 'react-icons/tb'; +import { BsCheckCircleFill, BsXCircleFill, BsExclamationCircleFill } from 'react-icons/bs'; +import { SiAmazon, SiGoogle, SiHuggingface, SiPerplexity, SiOpenai } from 'react-icons/si'; +import { BsRobot, BsCloud } from 'react-icons/bs'; +import { TbBrain } from 'react-icons/tb'; +import { BiChip, BiCodeBlock } from 'react-icons/bi'; +import { FaCloud, FaBrain } from 'react-icons/fa'; +import type { IconType } from 'react-icons'; +import { useSettings } from '~/lib/hooks/useSettings'; +import { useToast } from '~/components/ui/use-toast'; + +// Types +type ProviderName = + | 'AmazonBedrock' + | 'Anthropic' + | 'Cohere' + | 'Deepseek' + | 'Google' + | 'Groq' + | 'HuggingFace' + | 'Mistral' + | 'OpenAI' + | 'OpenRouter' + | 'Perplexity' + | 'Together' + | 'XAI'; + +type ServiceStatus = { + provider: ProviderName; + status: 'operational' | 'degraded' | 'down'; + lastChecked: string; + statusUrl?: string; + icon?: IconType; + message?: string; + responseTime?: number; + incidents?: string[]; +}; + +type ProviderConfig = { + statusUrl: string; + apiUrl: string; + headers: Record; + testModel: string; +}; + +// Types for API responses +type ApiResponse = { + error?: { + message: string; + }; + message?: string; + model?: string; + models?: Array<{ + id?: string; + name?: string; + }>; + data?: Array<{ + id?: string; + name?: string; + }>; +}; + +// Constants +const PROVIDER_STATUS_URLS: Record = { + OpenAI: { + statusUrl: 'https://status.openai.com/', + apiUrl: 'https://api.openai.com/v1/models', + headers: { + Authorization: 'Bearer $OPENAI_API_KEY', + }, + testModel: 'gpt-3.5-turbo', + }, + Anthropic: { + statusUrl: 'https://status.anthropic.com/', + apiUrl: 'https://api.anthropic.com/v1/messages', + headers: { + 'x-api-key': '$ANTHROPIC_API_KEY', + 'anthropic-version': '2024-02-29', + }, + testModel: 'claude-3-sonnet-20240229', + }, + Cohere: { + statusUrl: 'https://status.cohere.com/', + apiUrl: 'https://api.cohere.ai/v1/models', + headers: { + Authorization: 'Bearer $COHERE_API_KEY', + }, + testModel: 'command', + }, + Google: { + statusUrl: 'https://status.cloud.google.com/', + apiUrl: 'https://generativelanguage.googleapis.com/v1/models', + headers: { + 'x-goog-api-key': '$GOOGLE_API_KEY', + }, + testModel: 'gemini-pro', + }, + HuggingFace: { + statusUrl: 'https://status.huggingface.co/', + apiUrl: 'https://api-inference.huggingface.co/models', + headers: { + Authorization: 'Bearer $HUGGINGFACE_API_KEY', + }, + testModel: 'mistralai/Mixtral-8x7B-Instruct-v0.1', + }, + Mistral: { + statusUrl: 'https://status.mistral.ai/', + apiUrl: 'https://api.mistral.ai/v1/models', + headers: { + Authorization: 'Bearer $MISTRAL_API_KEY', + }, + testModel: 'mistral-tiny', + }, + Perplexity: { + statusUrl: 'https://status.perplexity.com/', + apiUrl: 'https://api.perplexity.ai/v1/models', + headers: { + Authorization: 'Bearer $PERPLEXITY_API_KEY', + }, + testModel: 'pplx-7b-chat', + }, + Together: { + statusUrl: 'https://status.together.ai/', + apiUrl: 'https://api.together.xyz/v1/models', + headers: { + Authorization: 'Bearer $TOGETHER_API_KEY', + }, + testModel: 'mistralai/Mixtral-8x7B-Instruct-v0.1', + }, + AmazonBedrock: { + statusUrl: 'https://health.aws.amazon.com/health/status', + apiUrl: 'https://bedrock.us-east-1.amazonaws.com/models', + headers: { + Authorization: 'Bearer $AWS_BEDROCK_CONFIG', + }, + testModel: 'anthropic.claude-3-sonnet-20240229-v1:0', + }, + Groq: { + statusUrl: 'https://groqstatus.com/', + apiUrl: 'https://api.groq.com/v1/models', + headers: { + Authorization: 'Bearer $GROQ_API_KEY', + }, + testModel: 'mixtral-8x7b-32768', + }, + OpenRouter: { + statusUrl: 'https://status.openrouter.ai/', + apiUrl: 'https://openrouter.ai/api/v1/models', + headers: { + Authorization: 'Bearer $OPEN_ROUTER_API_KEY', + }, + testModel: 'anthropic/claude-3-sonnet', + }, + XAI: { + statusUrl: 'https://status.x.ai/', + apiUrl: 'https://api.x.ai/v1/models', + headers: { + Authorization: 'Bearer $XAI_API_KEY', + }, + testModel: 'grok-1', + }, + Deepseek: { + statusUrl: 'https://status.deepseek.com/', + apiUrl: 'https://api.deepseek.com/v1/models', + headers: { + Authorization: 'Bearer $DEEPSEEK_API_KEY', + }, + testModel: 'deepseek-chat', + }, +}; + +const PROVIDER_ICONS: Record = { + AmazonBedrock: SiAmazon, + Anthropic: FaBrain, + Cohere: BiChip, + Google: SiGoogle, + Groq: BsCloud, + HuggingFace: SiHuggingface, + Mistral: TbBrain, + OpenAI: SiOpenai, + OpenRouter: FaCloud, + Perplexity: SiPerplexity, + Together: BsCloud, + XAI: BsRobot, + Deepseek: BiCodeBlock, +}; + +const ServiceStatusTab = () => { + const [serviceStatuses, setServiceStatuses] = useState([]); + const [loading, setLoading] = useState(true); + const [lastRefresh, setLastRefresh] = useState(new Date()); + const [testApiKey, setTestApiKey] = useState(''); + const [testProvider, setTestProvider] = useState(''); + const [testingStatus, setTestingStatus] = useState<'idle' | 'testing' | 'success' | 'error'>('idle'); + const settings = useSettings(); + const { success, error } = useToast(); + + // Function to get the API key for a provider from environment variables + const getApiKey = useCallback( + (provider: ProviderName): string | null => { + if (!settings.providers) { + return null; + } + + // Map provider names to environment variable names + const envKeyMap: Record = { + OpenAI: 'OPENAI_API_KEY', + Anthropic: 'ANTHROPIC_API_KEY', + Cohere: 'COHERE_API_KEY', + Google: 'GOOGLE_GENERATIVE_AI_API_KEY', + HuggingFace: 'HuggingFace_API_KEY', + Mistral: 'MISTRAL_API_KEY', + Perplexity: 'PERPLEXITY_API_KEY', + Together: 'TOGETHER_API_KEY', + AmazonBedrock: 'AWS_BEDROCK_CONFIG', + Groq: 'GROQ_API_KEY', + OpenRouter: 'OPEN_ROUTER_API_KEY', + XAI: 'XAI_API_KEY', + Deepseek: 'DEEPSEEK_API_KEY', + }; + + const envKey = envKeyMap[provider]; + + if (!envKey) { + return null; + } + + // Get the API key from environment variables + const apiKey = (import.meta.env[envKey] as string) || null; + + // Special handling for providers with base URLs + if (provider === 'Together' && apiKey) { + const baseUrl = import.meta.env.TOGETHER_API_BASE_URL; + + if (!baseUrl) { + return null; + } + } + + return apiKey; + }, + [settings.providers], + ); + + // Update provider configurations based on available API keys + const getProviderConfig = useCallback((provider: ProviderName): ProviderConfig | null => { + const config = PROVIDER_STATUS_URLS[provider]; + + if (!config) { + return null; + } + + // Handle special cases for providers with base URLs + let updatedConfig = { ...config }; + const togetherBaseUrl = import.meta.env.TOGETHER_API_BASE_URL; + + if (provider === 'Together' && togetherBaseUrl) { + updatedConfig = { + ...config, + apiUrl: `${togetherBaseUrl}/models`, + }; + } + + return updatedConfig; + }, []); + + // Function to check if an API endpoint is accessible with model verification + const checkApiEndpoint = useCallback( + async ( + url: string, + headers?: Record, + testModel?: string, + ): Promise<{ ok: boolean; status: number | string; message?: string; responseTime: number }> => { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + + const startTime = performance.now(); + + // Add common headers + const processedHeaders = { + 'Content-Type': 'application/json', + ...headers, + }; + + // First check if the API is accessible + const response = await fetch(url, { + method: 'GET', + headers: processedHeaders, + signal: controller.signal, + }); + + const endTime = performance.now(); + const responseTime = endTime - startTime; + + clearTimeout(timeoutId); + + // Get response data + const data = (await response.json()) as ApiResponse; + + // Special handling for different provider responses + if (!response.ok) { + let errorMessage = `API returned status: ${response.status}`; + + // Handle provider-specific error messages + if (data.error?.message) { + errorMessage = data.error.message; + } else if (data.message) { + errorMessage = data.message; + } + + return { + ok: false, + status: response.status, + message: errorMessage, + responseTime, + }; + } + + // Different providers have different model list formats + let models: string[] = []; + + if (Array.isArray(data)) { + models = data.map((model: { id?: string; name?: string }) => model.id || model.name || ''); + } else if (data.data && Array.isArray(data.data)) { + models = data.data.map((model) => model.id || model.name || ''); + } else if (data.models && Array.isArray(data.models)) { + models = data.models.map((model) => model.id || model.name || ''); + } else if (data.model) { + // Some providers return single model info + models = [data.model]; + } + + // For some providers, just having a successful response is enough + if (!testModel || models.length > 0) { + return { + ok: true, + status: response.status, + responseTime, + message: 'API key is valid', + }; + } + + // If a specific model was requested, verify it exists + if (testModel && !models.includes(testModel)) { + return { + ok: true, // Still mark as ok since API works + status: 'model_not_found', + message: `API key is valid (test model ${testModel} not found in ${models.length} available models)`, + responseTime, + }; + } + + return { + ok: true, + status: response.status, + message: 'API key is valid', + responseTime, + }; + } catch (error) { + console.error(`Error checking API endpoint ${url}:`, error); + return { + ok: false, + status: error instanceof Error ? error.message : 'Unknown error', + message: error instanceof Error ? `Connection failed: ${error.message}` : 'Connection failed', + responseTime: 0, + }; + } + }, + [getApiKey], + ); + + // Function to fetch real status from provider status pages + const fetchPublicStatus = useCallback( + async ( + provider: ProviderName, + ): Promise<{ + status: ServiceStatus['status']; + message?: string; + incidents?: string[]; + }> => { + try { + // Due to CORS restrictions, we can only check if the endpoints are reachable + const checkEndpoint = async (url: string) => { + try { + const response = await fetch(url, { + mode: 'no-cors', + headers: { + Accept: 'text/html', + }, + }); + + // With no-cors, we can only know if the request succeeded + return response.type === 'opaque' ? 'reachable' : 'unreachable'; + } catch (error) { + console.error(`Error checking ${url}:`, error); + return 'unreachable'; + } + }; + + switch (provider) { + case 'HuggingFace': { + const endpointStatus = await checkEndpoint('https://status.huggingface.co/'); + + // Check API endpoint as fallback + const apiEndpoint = 'https://api-inference.huggingface.co/models'; + const apiStatus = await checkEndpoint(apiEndpoint); + + return { + status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded', + message: `Status page: ${endpointStatus}, API: ${apiStatus}`, + incidents: ['Note: Limited status information due to CORS restrictions'], + }; + } + + case 'OpenAI': { + const endpointStatus = await checkEndpoint('https://status.openai.com/'); + const apiEndpoint = 'https://api.openai.com/v1/models'; + const apiStatus = await checkEndpoint(apiEndpoint); + + return { + status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded', + message: `Status page: ${endpointStatus}, API: ${apiStatus}`, + incidents: ['Note: Limited status information due to CORS restrictions'], + }; + } + + case 'Google': { + const endpointStatus = await checkEndpoint('https://status.cloud.google.com/'); + const apiEndpoint = 'https://generativelanguage.googleapis.com/v1/models'; + const apiStatus = await checkEndpoint(apiEndpoint); + + return { + status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded', + message: `Status page: ${endpointStatus}, API: ${apiStatus}`, + incidents: ['Note: Limited status information due to CORS restrictions'], + }; + } + + // Similar pattern for other providers... + default: + return { + status: 'operational', + message: 'Basic reachability check only', + incidents: ['Note: Limited status information due to CORS restrictions'], + }; + } + } catch (error) { + console.error(`Error fetching status for ${provider}:`, error); + return { + status: 'degraded', + message: 'Unable to fetch status due to CORS restrictions', + incidents: ['Error: Unable to check service status'], + }; + } + }, + [], + ); + + // Function to fetch status for a provider with retries + const fetchProviderStatus = useCallback( + async (provider: ProviderName, config: ProviderConfig): Promise => { + const MAX_RETRIES = 2; + const RETRY_DELAY = 2000; // 2 seconds + + const attemptCheck = async (attempt: number): Promise => { + try { + // First check the public status page if available + const hasPublicStatus = [ + 'Anthropic', + 'OpenAI', + 'Google', + 'HuggingFace', + 'Mistral', + 'Groq', + 'Perplexity', + 'Together', + ].includes(provider); + + if (hasPublicStatus) { + const publicStatus = await fetchPublicStatus(provider); + + return { + provider, + status: publicStatus.status, + lastChecked: new Date().toISOString(), + statusUrl: config.statusUrl, + icon: PROVIDER_ICONS[provider], + message: publicStatus.message, + incidents: publicStatus.incidents, + }; + } + + // For other providers, we'll show status but mark API check as separate + const apiKey = getApiKey(provider); + const providerConfig = getProviderConfig(provider); + + if (!apiKey || !providerConfig) { + return { + provider, + status: 'operational', + lastChecked: new Date().toISOString(), + statusUrl: config.statusUrl, + icon: PROVIDER_ICONS[provider], + message: !apiKey + ? 'Status operational (API key needed for usage)' + : 'Status operational (configuration needed for usage)', + incidents: [], + }; + } + + // If we have API access, let's verify that too + const { ok, status, message, responseTime } = await checkApiEndpoint( + providerConfig.apiUrl, + providerConfig.headers, + providerConfig.testModel, + ); + + if (!ok && attempt < MAX_RETRIES) { + await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY)); + return attemptCheck(attempt + 1); + } + + return { + provider, + status: ok ? 'operational' : 'degraded', + lastChecked: new Date().toISOString(), + statusUrl: providerConfig.statusUrl, + icon: PROVIDER_ICONS[provider], + message: ok ? 'Service and API operational' : `Service operational (API: ${message || status})`, + responseTime, + incidents: [], + }; + } catch (error) { + console.error(`Error fetching status for ${provider} (attempt ${attempt}):`, error); + + if (attempt < MAX_RETRIES) { + await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY)); + return attemptCheck(attempt + 1); + } + + return { + provider, + status: 'degraded', + lastChecked: new Date().toISOString(), + statusUrl: config.statusUrl, + icon: PROVIDER_ICONS[provider], + message: 'Service operational (Status check error)', + responseTime: 0, + incidents: [], + }; + } + }; + + return attemptCheck(1); + }, + [checkApiEndpoint, getApiKey, getProviderConfig, fetchPublicStatus], + ); + + // Memoize the fetchAllStatuses function + const fetchAllStatuses = useCallback(async () => { + try { + setLoading(true); + + const statuses = await Promise.all( + Object.entries(PROVIDER_STATUS_URLS).map(([provider, config]) => + fetchProviderStatus(provider as ProviderName, config), + ), + ); + + setServiceStatuses(statuses.sort((a, b) => a.provider.localeCompare(b.provider))); + setLastRefresh(new Date()); + success('Service statuses updated successfully'); + } catch (err) { + console.error('Error fetching all statuses:', err); + error('Failed to update service statuses'); + } finally { + setLoading(false); + } + }, [fetchProviderStatus, success, error]); + + useEffect(() => { + fetchAllStatuses(); + + // Refresh status every 2 minutes + const interval = setInterval(fetchAllStatuses, 2 * 60 * 1000); + + return () => clearInterval(interval); + }, [fetchAllStatuses]); + + // Function to test an API key + const testApiKeyForProvider = useCallback( + async (provider: ProviderName, apiKey: string) => { + try { + setTestingStatus('testing'); + + const config = PROVIDER_STATUS_URLS[provider]; + + if (!config) { + throw new Error('Provider configuration not found'); + } + + const headers = { ...config.headers }; + + // Replace the placeholder API key with the test key + Object.keys(headers).forEach((key) => { + if (headers[key].startsWith('$')) { + headers[key] = headers[key].replace(/\$.*/, apiKey); + } + }); + + // Special handling for certain providers + switch (provider) { + case 'Anthropic': + headers['anthropic-version'] = '2024-02-29'; + break; + case 'OpenAI': + if (!headers.Authorization?.startsWith('Bearer ')) { + headers.Authorization = `Bearer ${apiKey}`; + } + + break; + case 'Google': { + // Google uses the API key directly in the URL + const googleUrl = `${config.apiUrl}?key=${apiKey}`; + const result = await checkApiEndpoint(googleUrl, {}, config.testModel); + + if (result.ok) { + setTestingStatus('success'); + success('API key is valid!'); + } else { + setTestingStatus('error'); + error(`API key test failed: ${result.message}`); + } + + return; + } + } + + const { ok, message } = await checkApiEndpoint(config.apiUrl, headers, config.testModel); + + if (ok) { + setTestingStatus('success'); + success('API key is valid!'); + } else { + setTestingStatus('error'); + error(`API key test failed: ${message}`); + } + } catch (err: unknown) { + setTestingStatus('error'); + error('Failed to test API key: ' + (err instanceof Error ? err.message : 'Unknown error')); + } finally { + // Reset testing status after a delay + setTimeout(() => setTestingStatus('idle'), 3000); + } + }, + [checkApiEndpoint, success, error], + ); + + const getStatusColor = (status: ServiceStatus['status']) => { + switch (status) { + case 'operational': + return 'text-green-500'; + case 'degraded': + return 'text-yellow-500'; + case 'down': + return 'text-red-500'; + default: + return 'text-gray-500'; + } + }; + + const getStatusIcon = (status: ServiceStatus['status']) => { + switch (status) { + case 'operational': + return ; + case 'degraded': + return ; + case 'down': + return ; + default: + return ; + } + }; + + return ( +
+ +
+
+
+ +
+
+

Service Status

+

+ Monitor and test the operational status of cloud LLM providers +

+
+
+
+ + Last updated: {lastRefresh.toLocaleTimeString()} + + +
+
+ + {/* API Key Test Section */} +
+
Test API Key
+
+ + setTestApiKey(e.target.value)} + placeholder="Enter API key to test" + className={classNames( + 'flex-1 px-3 py-1.5 rounded-lg text-sm', + 'bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor', + 'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary', + 'focus:outline-none focus:ring-2 focus:ring-purple-500/30', + )} + /> + +
+
+ + {/* Status Grid */} + {loading && serviceStatuses.length === 0 ? ( +
Loading service statuses...
+ ) : ( +
+ {serviceStatuses.map((service, index) => ( + +
service.statusUrl && window.open(service.statusUrl, '_blank')} + > +
+
+ {service.icon && ( +
+ {React.createElement(service.icon, { + className: 'w-5 h-5', + })} +
+ )} +
+

{service.provider}

+
+

+ Last checked: {new Date(service.lastChecked).toLocaleTimeString()} +

+ {service.responseTime && ( +

+ Response time: {Math.round(service.responseTime)}ms +

+ )} + {service.message && ( +

{service.message}

+ )} +
+
+
+
+ {service.status} + {getStatusIcon(service.status)} +
+
+ {service.incidents && service.incidents.length > 0 && ( +
+

Recent Incidents:

+
    + {service.incidents.map((incident, i) => ( +
  • {incident}
  • + ))} +
+
+ )} +
+
+ ))} +
+ )} +
+
+ ); +}; + +// Add tab metadata +ServiceStatusTab.tabMetadata = { + icon: 'i-ph:activity-bold', + description: 'Monitor and test LLM provider service status', + category: 'services', +}; + +export default ServiceStatusTab; diff --git a/app/components/settings/providers/service-status/base-provider.ts b/app/components/settings/providers/service-status/base-provider.ts new file mode 100644 index 00000000..dde4bd31 --- /dev/null +++ b/app/components/settings/providers/service-status/base-provider.ts @@ -0,0 +1,121 @@ +import type { ProviderConfig, StatusCheckResult, ApiResponse } from './types'; + +export abstract class BaseProviderChecker { + protected config: ProviderConfig; + + constructor(config: ProviderConfig) { + this.config = config; + } + + protected async checkApiEndpoint( + url: string, + headers?: Record, + testModel?: string, + ): Promise<{ ok: boolean; status: number | string; message?: string; responseTime: number }> { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + + const startTime = performance.now(); + + // Add common headers + const processedHeaders = { + 'Content-Type': 'application/json', + ...headers, + }; + + const response = await fetch(url, { + method: 'GET', + headers: processedHeaders, + signal: controller.signal, + }); + + const endTime = performance.now(); + const responseTime = endTime - startTime; + + clearTimeout(timeoutId); + + const data = (await response.json()) as ApiResponse; + + if (!response.ok) { + let errorMessage = `API returned status: ${response.status}`; + + if (data.error?.message) { + errorMessage = data.error.message; + } else if (data.message) { + errorMessage = data.message; + } + + return { + ok: false, + status: response.status, + message: errorMessage, + responseTime, + }; + } + + // Different providers have different model list formats + let models: string[] = []; + + if (Array.isArray(data)) { + models = data.map((model: { id?: string; name?: string }) => model.id || model.name || ''); + } else if (data.data && Array.isArray(data.data)) { + models = data.data.map((model) => model.id || model.name || ''); + } else if (data.models && Array.isArray(data.models)) { + models = data.models.map((model) => model.id || model.name || ''); + } else if (data.model) { + models = [data.model]; + } + + if (!testModel || models.length > 0) { + return { + ok: true, + status: response.status, + responseTime, + message: 'API key is valid', + }; + } + + if (testModel && !models.includes(testModel)) { + return { + ok: true, + status: 'model_not_found', + message: `API key is valid (test model ${testModel} not found in ${models.length} available models)`, + responseTime, + }; + } + + return { + ok: true, + status: response.status, + message: 'API key is valid', + responseTime, + }; + } catch (error) { + console.error(`Error checking API endpoint ${url}:`, error); + return { + ok: false, + status: error instanceof Error ? error.message : 'Unknown error', + message: error instanceof Error ? `Connection failed: ${error.message}` : 'Connection failed', + responseTime: 0, + }; + } + } + + protected async checkEndpoint(url: string): Promise<'reachable' | 'unreachable'> { + try { + const response = await fetch(url, { + mode: 'no-cors', + headers: { + Accept: 'text/html', + }, + }); + return response.type === 'opaque' ? 'reachable' : 'unreachable'; + } catch (error) { + console.error(`Error checking ${url}:`, error); + return 'unreachable'; + } + } + + abstract checkStatus(): Promise; +} diff --git a/app/components/settings/providers/service-status/provider-factory.ts b/app/components/settings/providers/service-status/provider-factory.ts new file mode 100644 index 00000000..fd2ca19d --- /dev/null +++ b/app/components/settings/providers/service-status/provider-factory.ts @@ -0,0 +1,160 @@ +import type { ProviderName, ProviderConfig, StatusCheckResult } from './types'; +import { OpenAIStatusChecker } from './providers/openai'; +import { BaseProviderChecker } from './base-provider'; + +// Import other provider implementations as they are created + +export class ProviderStatusCheckerFactory { + private static _providerConfigs: Record = { + OpenAI: { + statusUrl: 'https://status.openai.com/', + apiUrl: 'https://api.openai.com/v1/models', + headers: { + Authorization: 'Bearer $OPENAI_API_KEY', + }, + testModel: 'gpt-3.5-turbo', + }, + Anthropic: { + statusUrl: 'https://status.anthropic.com/', + apiUrl: 'https://api.anthropic.com/v1/messages', + headers: { + 'x-api-key': '$ANTHROPIC_API_KEY', + 'anthropic-version': '2024-02-29', + }, + testModel: 'claude-3-sonnet-20240229', + }, + AmazonBedrock: { + statusUrl: 'https://health.aws.amazon.com/health/status', + apiUrl: 'https://bedrock.us-east-1.amazonaws.com/models', + headers: { + Authorization: 'Bearer $AWS_BEDROCK_CONFIG', + }, + testModel: 'anthropic.claude-3-sonnet-20240229-v1:0', + }, + Cohere: { + statusUrl: 'https://status.cohere.com/', + apiUrl: 'https://api.cohere.ai/v1/models', + headers: { + Authorization: 'Bearer $COHERE_API_KEY', + }, + testModel: 'command', + }, + Deepseek: { + statusUrl: 'https://status.deepseek.com/', + apiUrl: 'https://api.deepseek.com/v1/models', + headers: { + Authorization: 'Bearer $DEEPSEEK_API_KEY', + }, + testModel: 'deepseek-chat', + }, + Google: { + statusUrl: 'https://status.cloud.google.com/', + apiUrl: 'https://generativelanguage.googleapis.com/v1/models', + headers: { + 'x-goog-api-key': '$GOOGLE_API_KEY', + }, + testModel: 'gemini-pro', + }, + Groq: { + statusUrl: 'https://groqstatus.com/', + apiUrl: 'https://api.groq.com/v1/models', + headers: { + Authorization: 'Bearer $GROQ_API_KEY', + }, + testModel: 'mixtral-8x7b-32768', + }, + HuggingFace: { + statusUrl: 'https://status.huggingface.co/', + apiUrl: 'https://api-inference.huggingface.co/models', + headers: { + Authorization: 'Bearer $HUGGINGFACE_API_KEY', + }, + testModel: 'mistralai/Mixtral-8x7B-Instruct-v0.1', + }, + Hyperbolic: { + statusUrl: 'https://status.hyperbolic.ai/', + apiUrl: 'https://api.hyperbolic.ai/v1/models', + headers: { + Authorization: 'Bearer $HYPERBOLIC_API_KEY', + }, + testModel: 'hyperbolic-1', + }, + Mistral: { + statusUrl: 'https://status.mistral.ai/', + apiUrl: 'https://api.mistral.ai/v1/models', + headers: { + Authorization: 'Bearer $MISTRAL_API_KEY', + }, + testModel: 'mistral-tiny', + }, + OpenRouter: { + statusUrl: 'https://status.openrouter.ai/', + apiUrl: 'https://openrouter.ai/api/v1/models', + headers: { + Authorization: 'Bearer $OPEN_ROUTER_API_KEY', + }, + testModel: 'anthropic/claude-3-sonnet', + }, + Perplexity: { + statusUrl: 'https://status.perplexity.com/', + apiUrl: 'https://api.perplexity.ai/v1/models', + headers: { + Authorization: 'Bearer $PERPLEXITY_API_KEY', + }, + testModel: 'pplx-7b-chat', + }, + Together: { + statusUrl: 'https://status.together.ai/', + apiUrl: 'https://api.together.xyz/v1/models', + headers: { + Authorization: 'Bearer $TOGETHER_API_KEY', + }, + testModel: 'mistralai/Mixtral-8x7B-Instruct-v0.1', + }, + XAI: { + statusUrl: 'https://status.x.ai/', + apiUrl: 'https://api.x.ai/v1/models', + headers: { + Authorization: 'Bearer $XAI_API_KEY', + }, + testModel: 'grok-1', + }, + }; + + static getChecker(provider: ProviderName): BaseProviderChecker { + const config = this._providerConfigs[provider]; + + if (!config) { + throw new Error(`No configuration found for provider: ${provider}`); + } + + // Return specific provider implementation or fallback to base implementation + switch (provider) { + case 'OpenAI': + return new OpenAIStatusChecker(config); + + // Add other provider implementations as they are created + default: + return new (class extends BaseProviderChecker { + async checkStatus(): Promise { + const endpointStatus = await this.checkEndpoint(this.config.statusUrl); + const apiStatus = await this.checkEndpoint(this.config.apiUrl); + + return { + status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded', + message: `Status page: ${endpointStatus}, API: ${apiStatus}`, + incidents: ['Note: Limited status information due to CORS restrictions'], + }; + } + })(config); + } + } + + static getProviderNames(): ProviderName[] { + return Object.keys(this._providerConfigs) as ProviderName[]; + } + + static getProviderConfig(provider: ProviderName): ProviderConfig | undefined { + return this._providerConfigs[provider]; + } +} diff --git a/app/components/settings/providers/service-status/providers/openai.ts b/app/components/settings/providers/service-status/providers/openai.ts new file mode 100644 index 00000000..3ef3a7e3 --- /dev/null +++ b/app/components/settings/providers/service-status/providers/openai.ts @@ -0,0 +1,99 @@ +import { BaseProviderChecker } from '~/components/settings/providers/service-status/base-provider'; +import type { StatusCheckResult } from '~/components/settings/providers/service-status/types'; + +export class OpenAIStatusChecker extends BaseProviderChecker { + async checkStatus(): Promise { + try { + // Check status page + const statusPageResponse = await fetch('https://status.openai.com/'); + const text = await statusPageResponse.text(); + + // Check individual services + const services = { + api: { + operational: text.includes('API ? Operational'), + degraded: text.includes('API ? Degraded Performance'), + outage: text.includes('API ? Major Outage') || text.includes('API ? Partial Outage'), + }, + chat: { + operational: text.includes('ChatGPT ? Operational'), + degraded: text.includes('ChatGPT ? Degraded Performance'), + outage: text.includes('ChatGPT ? Major Outage') || text.includes('ChatGPT ? Partial Outage'), + }, + }; + + // Extract recent incidents + const incidents: string[] = []; + const incidentMatches = text.match(/Past Incidents(.*?)(?=\w+ \d+, \d{4})/s); + + if (incidentMatches) { + const recentIncidents = incidentMatches[1] + .split('\n') + .map((line) => line.trim()) + .filter((line) => line && line.includes('202')); // Get only dated incidents + + incidents.push(...recentIncidents.slice(0, 5)); + } + + // Determine overall status + let status: StatusCheckResult['status'] = 'operational'; + const messages: string[] = []; + + if (services.api.outage || services.chat.outage) { + status = 'down'; + + if (services.api.outage) { + messages.push('API: Major Outage'); + } + + if (services.chat.outage) { + messages.push('ChatGPT: Major Outage'); + } + } else if (services.api.degraded || services.chat.degraded) { + status = 'degraded'; + + if (services.api.degraded) { + messages.push('API: Degraded Performance'); + } + + if (services.chat.degraded) { + messages.push('ChatGPT: Degraded Performance'); + } + } else if (services.api.operational) { + messages.push('API: Operational'); + } + + // If status page check fails, fallback to endpoint check + if (!statusPageResponse.ok) { + const endpointStatus = await this.checkEndpoint('https://status.openai.com/'); + const apiEndpoint = 'https://api.openai.com/v1/models'; + const apiStatus = await this.checkEndpoint(apiEndpoint); + + return { + status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded', + message: `Status page: ${endpointStatus}, API: ${apiStatus}`, + incidents: ['Note: Limited status information due to CORS restrictions'], + }; + } + + return { + status, + message: messages.join(', ') || 'Status unknown', + incidents, + }; + } catch (error) { + console.error('Error checking OpenAI status:', error); + + // Fallback to basic endpoint check + const endpointStatus = await this.checkEndpoint('https://status.openai.com/'); + const apiEndpoint = 'https://api.openai.com/v1/models'; + const apiStatus = await this.checkEndpoint(apiEndpoint); + + return { + status: endpointStatus === 'reachable' && apiStatus === 'reachable' ? 'operational' : 'degraded', + message: `Status page: ${endpointStatus}, API: ${apiStatus}`, + incidents: ['Note: Limited status information due to CORS restrictions'], + }; + } + } +} diff --git a/app/components/settings/providers/service-status/types.ts b/app/components/settings/providers/service-status/types.ts new file mode 100644 index 00000000..5a267ef8 --- /dev/null +++ b/app/components/settings/providers/service-status/types.ts @@ -0,0 +1,58 @@ +import type { IconType } from 'react-icons'; + +export type ProviderName = + | 'AmazonBedrock' + | 'Anthropic' + | 'Cohere' + | 'Deepseek' + | 'Google' + | 'Groq' + | 'HuggingFace' + | 'Hyperbolic' + | 'Mistral' + | 'OpenAI' + | 'OpenRouter' + | 'Perplexity' + | 'Together' + | 'XAI'; + +export type ServiceStatus = { + provider: ProviderName; + status: 'operational' | 'degraded' | 'down'; + lastChecked: string; + statusUrl?: string; + icon?: IconType; + message?: string; + responseTime?: number; + incidents?: string[]; +}; + +export type ProviderConfig = { + statusUrl: string; + apiUrl: string; + headers: Record; + testModel: string; +}; + +export type ApiResponse = { + error?: { + message: string; + }; + message?: string; + model?: string; + models?: Array<{ + id?: string; + name?: string; + }>; + data?: Array<{ + id?: string; + name?: string; + }>; +}; + +export type StatusCheckResult = { + status: ServiceStatus['status']; + message?: string; + incidents?: string[]; + responseTime?: number; +}; diff --git a/app/components/settings/settings.types.ts b/app/components/settings/settings.types.ts index b72c163a..29a31a54 100644 --- a/app/components/settings/settings.types.ts +++ b/app/components/settings/settings.types.ts @@ -14,7 +14,8 @@ export type TabType = | 'debug' | 'event-logs' | 'update' - | 'task-manager'; + | 'task-manager' + | 'service-status'; export type WindowType = 'user' | 'developer'; @@ -68,6 +69,7 @@ export const TAB_LABELS: Record = { 'event-logs': 'Event Logs', update: 'Update', 'task-manager': 'Task Manager', + 'service-status': 'Service Status', }; export const DEFAULT_TAB_CONFIG: TabVisibilityConfig[] = [ @@ -75,17 +77,18 @@ export const DEFAULT_TAB_CONFIG: TabVisibilityConfig[] = [ { id: 'features', visible: true, window: 'user', order: 0 }, { id: 'data', visible: true, window: 'user', order: 1 }, { id: 'cloud-providers', visible: true, window: 'user', order: 2 }, - { id: 'local-providers', visible: true, window: 'user', order: 3 }, - { id: 'connection', visible: true, window: 'user', order: 4 }, - { id: 'debug', visible: true, window: 'user', order: 5 }, + { id: 'service-status', visible: true, window: 'user', order: 3 }, + { id: 'local-providers', visible: true, window: 'user', order: 4 }, + { id: 'connection', visible: true, window: 'user', order: 5 }, + { id: 'debug', visible: true, window: 'user', order: 6 }, // User Window Tabs (Hidden by default) - { id: 'profile', visible: false, window: 'user', order: 6 }, - { id: 'settings', visible: false, window: 'user', order: 7 }, - { id: 'notifications', visible: false, window: 'user', order: 8 }, - { id: 'event-logs', visible: false, window: 'user', order: 9 }, - { id: 'update', visible: false, window: 'user', order: 10 }, - { id: 'task-manager', visible: false, window: 'user', order: 11 }, + { id: 'profile', visible: false, window: 'user', order: 7 }, + { id: 'settings', visible: false, window: 'user', order: 8 }, + { id: 'notifications', visible: false, window: 'user', order: 9 }, + { id: 'event-logs', visible: false, window: 'user', order: 10 }, + { id: 'update', visible: false, window: 'user', order: 11 }, + { id: 'task-manager', visible: false, window: 'user', order: 12 }, // Developer Window Tabs (All visible by default) { id: 'profile', visible: true, window: 'developer', order: 0 }, @@ -94,12 +97,13 @@ export const DEFAULT_TAB_CONFIG: TabVisibilityConfig[] = [ { id: 'features', visible: true, window: 'developer', order: 3 }, { id: 'data', visible: true, window: 'developer', order: 4 }, { id: 'cloud-providers', visible: true, window: 'developer', order: 5 }, - { id: 'local-providers', visible: true, window: 'developer', order: 6 }, - { id: 'connection', visible: true, window: 'developer', order: 7 }, - { id: 'debug', visible: true, window: 'developer', order: 8 }, - { id: 'event-logs', visible: true, window: 'developer', order: 9 }, - { id: 'update', visible: true, window: 'developer', order: 10 }, - { id: 'task-manager', visible: true, window: 'developer', order: 11 }, + { id: 'service-status', visible: true, window: 'developer', order: 6 }, + { id: 'local-providers', visible: true, window: 'developer', order: 7 }, + { id: 'connection', visible: true, window: 'developer', order: 8 }, + { id: 'debug', visible: true, window: 'developer', order: 9 }, + { id: 'event-logs', visible: true, window: 'developer', order: 10 }, + { id: 'update', visible: true, window: 'developer', order: 11 }, + { id: 'task-manager', visible: true, window: 'developer', order: 12 }, ]; export const categoryLabels: Record = { diff --git a/app/components/settings/settings/SettingsTab.tsx b/app/components/settings/settings/SettingsTab.tsx index 5a7c604f..90fae722 100644 --- a/app/components/settings/settings/SettingsTab.tsx +++ b/app/components/settings/settings/SettingsTab.tsx @@ -35,11 +35,11 @@ export default function SettingsTab() { const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; document.querySelector('html')?.setAttribute('data-theme', prefersDark ? 'dark' : 'light'); + themeStore.set(prefersDark ? 'dark' : 'light'); } else { - // Set specific theme + themeStore.set(settings.theme); localStorage.setItem(kTheme, settings.theme); document.querySelector('html')?.setAttribute('data-theme', settings.theme); - themeStore.set(settings.theme); } }, [settings.theme]); @@ -89,7 +89,13 @@ export default function SettingsTab() { {(['light', 'dark', 'system'] as const).map((theme) => (